From f070c3d8d455e22f0473c7b9401ec55fcf86d2f3 Mon Sep 17 00:00:00 2001 From: Roman Sainchuk Date: Fri, 26 Jan 2024 14:01:48 +0200 Subject: [PATCH] feat: basic push-status command implementation and extended push command (#1321) Co-authored-by: IhorKarpiuk Co-authored-by: SmoliyY --- .changeset/new-eyes-beam.md | 5 + jest.config.js | 2 +- package-lock.json | 99 ++++-- packages/cli/package.json | 4 +- .../src/__tests__/commands/build-docs.test.ts | 4 +- .../cli/src/__tests__/commands/bundle.test.ts | 4 +- .../cli/src/__tests__/commands/join.test.ts | 4 +- .../cli/src/__tests__/commands/lint.test.ts | 6 +- .../__tests__/commands/push-region.test.ts | 2 +- .../cli/src/__tests__/commands/push.test.ts | 4 +- .../src/__tests__/fetch-with-timeout.test.ts | 20 +- packages/cli/src/__tests__/spinner.test.ts | 51 +++ packages/cli/src/__tests__/utils.test.ts | 7 +- packages/cli/src/__tests__/wrapper.test.ts | 4 +- .../src/cms/api/__tests__/api-keys.test.ts | 37 +++ .../src/cms/api/__tests__/api.client.test.ts | 275 ++++++++++++++++ .../cli/src/cms/api/__tests__/domains.test.ts | 15 + packages/cli/src/cms/api/api-client.ts | 199 ++++++++++++ packages/cli/src/cms/api/api-keys.ts | 26 ++ packages/cli/src/cms/api/domains.ts | 11 + packages/cli/src/cms/api/index.ts | 3 + packages/cli/src/cms/api/types.ts | 101 ++++++ .../commands/__tests__/push-status.test.ts | 212 +++++++++++++ .../src/cms/commands/__tests__/push.test.ts | 293 ++++++++++++++++++ packages/cli/src/cms/commands/push-status.ts | 203 ++++++++++++ packages/cli/src/cms/commands/push.ts | 215 +++++++++++++ packages/cli/src/cms/utils.ts | 1 + packages/cli/src/commands/build-docs/index.ts | 2 +- packages/cli/src/commands/build-docs/utils.ts | 2 +- packages/cli/src/commands/bundle.ts | 4 +- packages/cli/src/commands/join.ts | 4 +- packages/cli/src/commands/lint.ts | 2 +- packages/cli/src/commands/login.ts | 2 +- .../cli/src/commands/preview-docs/index.ts | 6 +- .../preview-server/preview-server.ts | 2 +- .../cli/src/commands/preview-project/types.ts | 2 +- packages/cli/src/commands/push.ts | 16 +- .../commands/split/__tests__/index.test.ts | 7 +- packages/cli/src/commands/split/index.ts | 4 +- packages/cli/src/commands/stats.ts | 4 +- packages/cli/src/index.ts | 158 ++++++++-- packages/cli/src/types.ts | 8 + .../__mocks__/miscellaneous.ts} | 2 +- .../src/{ => utils}/assert-node-version.ts | 0 .../cli/src/{ => utils}/fetch-with-timeout.ts | 7 +- packages/cli/src/{ => utils}/js-utils.ts | 0 .../src/{utils.ts => utils/miscellaneous.ts} | 4 +- packages/cli/src/utils/spinner.ts | 50 +++ .../{ => utils}/update-version-notifier.ts | 4 +- packages/cli/src/wrapper.ts | 9 +- packages/core/src/redocly/index.ts | 2 +- 51 files changed, 1983 insertions(+), 125 deletions(-) create mode 100644 .changeset/new-eyes-beam.md create mode 100644 packages/cli/src/__tests__/spinner.test.ts create mode 100644 packages/cli/src/cms/api/__tests__/api-keys.test.ts create mode 100644 packages/cli/src/cms/api/__tests__/api.client.test.ts create mode 100644 packages/cli/src/cms/api/__tests__/domains.test.ts create mode 100644 packages/cli/src/cms/api/api-client.ts create mode 100644 packages/cli/src/cms/api/api-keys.ts create mode 100644 packages/cli/src/cms/api/domains.ts create mode 100644 packages/cli/src/cms/api/index.ts create mode 100644 packages/cli/src/cms/api/types.ts create mode 100644 packages/cli/src/cms/commands/__tests__/push-status.test.ts create mode 100644 packages/cli/src/cms/commands/__tests__/push.test.ts create mode 100644 packages/cli/src/cms/commands/push-status.ts create mode 100644 packages/cli/src/cms/commands/push.ts create mode 100644 packages/cli/src/cms/utils.ts rename packages/cli/src/{__mocks__/utils.ts => utils/__mocks__/miscellaneous.ts} (93%) rename packages/cli/src/{ => utils}/assert-node-version.ts (100%) rename packages/cli/src/{ => utils}/fetch-with-timeout.ts (60%) rename packages/cli/src/{ => utils}/js-utils.ts (100%) rename packages/cli/src/{utils.ts => utils/miscellaneous.ts} (99%) create mode 100644 packages/cli/src/utils/spinner.ts rename packages/cli/src/{ => utils}/update-version-notifier.ts (96%) diff --git a/.changeset/new-eyes-beam.md b/.changeset/new-eyes-beam.md new file mode 100644 index 00000000..38c5b32c --- /dev/null +++ b/.changeset/new-eyes-beam.md @@ -0,0 +1,5 @@ +--- +"@redocly/cli": minor +--- + +Added a `push` and `push-status` command for use with future Redocly products. diff --git a/jest.config.js b/jest.config.js index 3f42c097..e55bf1de 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,7 +8,7 @@ module.exports = { '!packages/**/__tests__/**/*', '!packages/core/src/benchmark/**/*', '!packages/cli/src/index.ts', - '!packages/cli/src/assert-node-version.ts', + '!packages/cli/src/utils/assert-node-version.ts', ], coverageThreshold: { 'packages/core/': { diff --git a/package-lock.json b/package-lock.json index 1352e0e4..34887709 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3865,9 +3865,9 @@ "dev": true }, "node_modules/@types/yargs": { - "version": "17.0.29", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.29.tgz", - "integrity": "sha512-nacjqA3ee9zRF/++a3FUY1suHTFKZeHba2n8WeDw9cCVdmzmHpIxyzOJBcpHvvEmS8E9KqWlSnWHUkOrkhWcvA==", + "version": "17.0.32", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", + "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", "dev": true, "dependencies": { "@types/yargs-parser": "*" @@ -4268,6 +4268,17 @@ "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", "dev": true }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.10.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", @@ -4513,8 +4524,7 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/available-typed-arrays": { "version": "1.0.5", @@ -5123,7 +5133,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -5469,7 +5478,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "engines": { "node": ">=0.4.0" } @@ -6058,6 +6066,14 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", @@ -9150,7 +9166,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -9159,7 +9174,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "dependencies": { "mime-db": "1.52.0" }, @@ -12854,9 +12868,11 @@ "license": "MIT", "dependencies": { "@redocly/openapi-core": "1.7.0", + "abort-controller": "^3.0.0", "chokidar": "^3.5.1", "colorette": "^1.2.0", "core-js": "^3.32.1", + "form-data": "^4.0.0", "get-port-please": "^3.0.1", "glob": "^7.1.6", "handlebars": "^4.7.6", @@ -12880,7 +12896,7 @@ "@types/react": "^17.0.0 || ^18.2.21", "@types/react-dom": "^17.0.0 || ^18.2.7", "@types/semver": "^7.5.0", - "@types/yargs": "17.0.5", + "@types/yargs": "17.0.32", "typescript": "^5.2.2" }, "engines": { @@ -12888,13 +12904,17 @@ "npm": ">=7.0.0" } }, - "packages/cli/node_modules/@types/yargs": { - "version": "17.0.5", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.5.tgz", - "integrity": "sha512-4HNq144yhaVjJs+ON6A07NEoi9Hh0Rhl/jI9Nt/l/YRjt+T6St/QK3meFARWZ8IgkzoD1LC0PdTdJenlQQi2WQ==", - "dev": true, + "packages/cli/node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", "dependencies": { - "@types/yargs-parser": "*" + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" } }, "packages/core": { @@ -15524,10 +15544,12 @@ "@types/react": "^17.0.0 || ^18.2.21", "@types/react-dom": "^17.0.0 || ^18.2.7", "@types/semver": "^7.5.0", - "@types/yargs": "17.0.5", + "@types/yargs": "17.0.32", + "abort-controller": "^3.0.0", "chokidar": "^3.5.1", "colorette": "^1.2.0", "core-js": "^3.32.1", + "form-data": "^4.0.0", "get-port-please": "^3.0.1", "glob": "^7.1.6", "handlebars": "^4.7.6", @@ -15543,13 +15565,14 @@ "yargs": "17.0.1" }, "dependencies": { - "@types/yargs": { - "version": "17.0.5", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.5.tgz", - "integrity": "sha512-4HNq144yhaVjJs+ON6A07NEoi9Hh0Rhl/jI9Nt/l/YRjt+T6St/QK3meFARWZ8IgkzoD1LC0PdTdJenlQQi2WQ==", - "dev": true, + "form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", "requires": { - "@types/yargs-parser": "*" + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" } } } @@ -15936,9 +15959,9 @@ "dev": true }, "@types/yargs": { - "version": "17.0.29", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.29.tgz", - "integrity": "sha512-nacjqA3ee9zRF/++a3FUY1suHTFKZeHba2n8WeDw9cCVdmzmHpIxyzOJBcpHvvEmS8E9KqWlSnWHUkOrkhWcvA==", + "version": "17.0.32", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", + "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -16237,6 +16260,14 @@ "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", "dev": true }, + "abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "requires": { + "event-target-shim": "^5.0.0" + } + }, "acorn": { "version": "8.10.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", @@ -16416,8 +16447,7 @@ "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "available-typed-arrays": { "version": "1.0.5", @@ -16849,7 +16879,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "requires": { "delayed-stream": "~1.0.0" } @@ -17119,8 +17148,7 @@ "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" }, "detect-indent": { "version": "6.1.0", @@ -17558,6 +17586,11 @@ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==" }, + "event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==" + }, "eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", @@ -19866,14 +19899,12 @@ "mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" }, "mime-types": { "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "requires": { "mime-db": "1.52.0" } diff --git a/packages/cli/package.json b/packages/cli/package.json index 34a2f894..70dd8770 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -37,9 +37,11 @@ ], "dependencies": { "@redocly/openapi-core": "1.7.0", + "abort-controller": "^3.0.0", "chokidar": "^3.5.1", "colorette": "^1.2.0", "core-js": "^3.32.1", + "form-data": "^4.0.0", "get-port-please": "^3.0.1", "glob": "^7.1.6", "handlebars": "^4.7.6", @@ -59,7 +61,7 @@ "@types/react": "^17.0.0 || ^18.2.21", "@types/react-dom": "^17.0.0 || ^18.2.7", "@types/semver": "^7.5.0", - "@types/yargs": "17.0.5", + "@types/yargs": "17.0.32", "typescript": "^5.2.2" } } diff --git a/packages/cli/src/__tests__/commands/build-docs.test.ts b/packages/cli/src/__tests__/commands/build-docs.test.ts index 16fab291..39583b98 100644 --- a/packages/cli/src/__tests__/commands/build-docs.test.ts +++ b/packages/cli/src/__tests__/commands/build-docs.test.ts @@ -3,11 +3,11 @@ import { renderToString } from 'react-dom/server'; import { handlerBuildCommand } from '../../commands/build-docs'; import { BuildDocsArgv } from '../../commands/build-docs/types'; import { getPageHTML } from '../../commands/build-docs/utils'; -import { getFallbackApisOrExit } from '../../utils'; +import { getFallbackApisOrExit } from '../../utils/miscellaneous'; jest.mock('redoc'); jest.mock('fs'); -jest.mock('../../utils'); +jest.mock('../../utils/miscellaneous'); const config = { output: '', diff --git a/packages/cli/src/__tests__/commands/bundle.test.ts b/packages/cli/src/__tests__/commands/bundle.test.ts index 2cc40086..f22d9937 100644 --- a/packages/cli/src/__tests__/commands/bundle.test.ts +++ b/packages/cli/src/__tests__/commands/bundle.test.ts @@ -1,13 +1,13 @@ import { lint, bundle, getTotals, getMergedConfig } from '@redocly/openapi-core'; import { BundleOptions, handleBundle } from '../../commands/bundle'; -import { handleError } from '../../utils'; +import { handleError } from '../../utils/miscellaneous'; import { commandWrapper } from '../../wrapper'; import SpyInstance = jest.SpyInstance; import { Arguments } from 'yargs'; jest.mock('@redocly/openapi-core'); -jest.mock('../../utils'); +jest.mock('../../utils/miscellaneous'); (getMergedConfig as jest.Mock).mockImplementation((config) => config); diff --git a/packages/cli/src/__tests__/commands/join.test.ts b/packages/cli/src/__tests__/commands/join.test.ts index e9f995fc..a82f9edd 100644 --- a/packages/cli/src/__tests__/commands/join.test.ts +++ b/packages/cli/src/__tests__/commands/join.test.ts @@ -1,11 +1,11 @@ import { handleJoin } from '../../commands/join'; -import { exitWithError, writeToFileByExtension, writeYaml } from '../../utils'; +import { exitWithError, writeToFileByExtension, writeYaml } from '../../utils/miscellaneous'; import { yellow } from 'colorette'; import { detectSpec } from '@redocly/openapi-core'; import { loadConfig } from '../../__mocks__/@redocly/openapi-core'; import { ConfigFixture } from '../fixtures/config'; -jest.mock('../../utils'); +jest.mock('../../utils/miscellaneous'); jest.mock('colorette'); diff --git a/packages/cli/src/__tests__/commands/lint.test.ts b/packages/cli/src/__tests__/commands/lint.test.ts index 3198bc9f..4b4b15d4 100644 --- a/packages/cli/src/__tests__/commands/lint.test.ts +++ b/packages/cli/src/__tests__/commands/lint.test.ts @@ -14,7 +14,7 @@ import { exitWithError, loadConfigAndHandleErrors, checkIfRulesetExist, -} from '../../utils'; +} from '../../utils/miscellaneous'; import { ConfigFixture } from '../fixtures/config'; import { performance } from 'perf_hooks'; import { commandWrapper } from '../../wrapper'; @@ -22,10 +22,10 @@ import { Arguments } from 'yargs'; import { blue } from 'colorette'; jest.mock('@redocly/openapi-core'); -jest.mock('../../utils'); +jest.mock('../../utils/miscellaneous'); jest.mock('perf_hooks'); -jest.mock('../../update-version-notifier', () => ({ +jest.mock('../../utils/update-version-notifier', () => ({ version: '1.0.0', })); diff --git a/packages/cli/src/__tests__/commands/push-region.test.ts b/packages/cli/src/__tests__/commands/push-region.test.ts index 3217f900..8dd66631 100644 --- a/packages/cli/src/__tests__/commands/push-region.test.ts +++ b/packages/cli/src/__tests__/commands/push-region.test.ts @@ -12,7 +12,7 @@ jest.mock('node-fetch', () => ({ })); jest.mock('@redocly/openapi-core'); jest.mock('../../commands/login'); -jest.mock('../../utils'); +jest.mock('../../utils/miscellaneous'); (getMergedConfig as jest.Mock).mockImplementation((config) => config); diff --git a/packages/cli/src/__tests__/commands/push.test.ts b/packages/cli/src/__tests__/commands/push.test.ts index a10c3b46..8926a771 100644 --- a/packages/cli/src/__tests__/commands/push.test.ts +++ b/packages/cli/src/__tests__/commands/push.test.ts @@ -1,6 +1,6 @@ import * as fs from 'fs'; import { Config, getMergedConfig } from '@redocly/openapi-core'; -import { exitWithError } from '../../utils'; +import { exitWithError } from '../../utils/miscellaneous'; import { getApiRoot, getDestinationProps, handlePush, transformPush } from '../../commands/push'; import { ConfigFixture } from '../fixtures/config'; import { yellow } from 'colorette'; @@ -13,7 +13,7 @@ jest.mock('node-fetch', () => ({ })), })); jest.mock('@redocly/openapi-core'); -jest.mock('../../utils'); +jest.mock('../../utils/miscellaneous'); (getMergedConfig as jest.Mock).mockImplementation((config) => config); diff --git a/packages/cli/src/__tests__/fetch-with-timeout.test.ts b/packages/cli/src/__tests__/fetch-with-timeout.test.ts index 598fc82e..e3db223d 100644 --- a/packages/cli/src/__tests__/fetch-with-timeout.test.ts +++ b/packages/cli/src/__tests__/fetch-with-timeout.test.ts @@ -1,4 +1,5 @@ -import fetchWithTimeout from '../fetch-with-timeout'; +import AbortController from 'abort-controller'; +import fetchWithTimeout from '../utils/fetch-with-timeout'; import nodeFetch from 'node-fetch'; jest.mock('node-fetch'); @@ -8,20 +9,7 @@ describe('fetchWithTimeout', () => { jest.clearAllMocks(); }); - it('should use bare node-fetch if AbortController is not available', async () => { - // @ts-ignore - global.AbortController = undefined; - // @ts-ignore - global.setTimeout = jest.fn(); - await fetchWithTimeout('url', { method: 'GET' }); - - expect(nodeFetch).toHaveBeenCalledWith('url', { method: 'GET' }); - - expect(global.setTimeout).toHaveBeenCalledTimes(0); - }); - - it('should call node-fetch with signal if AbortController is available', async () => { - global.AbortController = jest.fn().mockImplementation(() => ({ signal: 'something' })); + it('should call node-fetch with signal', async () => { // @ts-ignore global.setTimeout = jest.fn(); @@ -29,7 +17,7 @@ describe('fetchWithTimeout', () => { await fetchWithTimeout('url'); expect(global.setTimeout).toHaveBeenCalledTimes(1); - expect(nodeFetch).toHaveBeenCalledWith('url', { signal: 'something' }); + expect(nodeFetch).toHaveBeenCalledWith('url', { signal: new AbortController().signal }); expect(global.clearTimeout).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/cli/src/__tests__/spinner.test.ts b/packages/cli/src/__tests__/spinner.test.ts new file mode 100644 index 00000000..3b8be2fa --- /dev/null +++ b/packages/cli/src/__tests__/spinner.test.ts @@ -0,0 +1,51 @@ +import { Spinner } from '../utils/spinner'; +import * as process from 'process'; + +jest.useFakeTimers(); + +describe('Spinner', () => { + const IS_TTY = process.stdout.isTTY; + + let writeMock: jest.SpyInstance; + let spinner: Spinner; + + beforeEach(() => { + process.stdout.isTTY = true; + writeMock = jest.spyOn(process.stdout, 'write').mockImplementation(jest.fn()); + spinner = new Spinner(); + }); + + afterEach(() => { + writeMock.mockRestore(); + jest.clearAllTimers(); + }); + + afterAll(() => { + process.stdout.isTTY = IS_TTY; + }); + + it('starts the spinner', () => { + spinner.start('Loading'); + jest.advanceTimersByTime(100); + expect(writeMock).toHaveBeenCalledWith('\r⠋ Loading'); + }); + + it('stops the spinner', () => { + spinner.start('Loading'); + spinner.stop(); + expect(writeMock).toHaveBeenCalledWith('\r'); + }); + + it('should write 3 frames', () => { + spinner.start('Loading'); + jest.advanceTimersByTime(300); + expect(writeMock).toHaveBeenCalledTimes(3); + }); + + it('should call write 1 times if CI set to true', () => { + process.stdout.isTTY = false; + spinner.start('Loading'); + jest.advanceTimersByTime(300); + expect(writeMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/cli/src/__tests__/utils.test.ts b/packages/cli/src/__tests__/utils.test.ts index 63d3377e..af84cc5b 100644 --- a/packages/cli/src/__tests__/utils.test.ts +++ b/packages/cli/src/__tests__/utils.test.ts @@ -13,10 +13,8 @@ import { cleanArgs, cleanRawInput, getAndValidateFileExtension, - writeYaml, - writeJson, writeToFileByExtension, -} from '../utils'; +} from '../utils/miscellaneous'; import { ResolvedApi, Totals, @@ -26,10 +24,9 @@ import { stringifyYaml, } from '@redocly/openapi-core'; import { blue, red, yellow } from 'colorette'; -import { existsSync, statSync, writeFileSync } from 'fs'; +import { existsSync, statSync } from 'fs'; import * as path from 'path'; import * as process from 'process'; -import * as utils from '../utils'; jest.mock('os'); jest.mock('colorette'); diff --git a/packages/cli/src/__tests__/wrapper.test.ts b/packages/cli/src/__tests__/wrapper.test.ts index d5ab7b78..d3554898 100644 --- a/packages/cli/src/__tests__/wrapper.test.ts +++ b/packages/cli/src/__tests__/wrapper.test.ts @@ -1,4 +1,4 @@ -import { loadConfigAndHandleErrors, sendTelemetry } from '../utils'; +import { loadConfigAndHandleErrors, sendTelemetry } from '../utils/miscellaneous'; import * as process from 'process'; import { commandWrapper } from '../wrapper'; import { handleLint } from '../commands/lint'; @@ -6,7 +6,7 @@ import { Arguments } from 'yargs'; import { handlePush, PushOptions } from '../commands/push'; jest.mock('node-fetch'); -jest.mock('../utils', () => ({ +jest.mock('../utils/miscellaneous', () => ({ sendTelemetry: jest.fn(), loadConfigAndHandleErrors: jest.fn(), })); diff --git a/packages/cli/src/cms/api/__tests__/api-keys.test.ts b/packages/cli/src/cms/api/__tests__/api-keys.test.ts new file mode 100644 index 00000000..36169e12 --- /dev/null +++ b/packages/cli/src/cms/api/__tests__/api-keys.test.ts @@ -0,0 +1,37 @@ +import { getApiKeys } from '../api-keys'; +import * as fs from 'fs'; + +describe('getApiKeys()', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should return api key from environment variable', () => { + process.env.REDOCLY_AUTHORIZATION = 'test-api-key'; + + expect(getApiKeys('test-domain')).toEqual('test-api-key'); + }); + + it('should return api key from credentials file', () => { + process.env.REDOCLY_AUTHORIZATION = ''; + + jest.spyOn(fs, 'existsSync').mockReturnValue(true); + jest.spyOn(fs, 'readFileSync').mockReturnValue( + JSON.stringify({ + ['test-domain']: 'test-api-key-from-credentials-file', + }) + ); + + expect(getApiKeys('test-domain')).toEqual('test-api-key-from-credentials-file'); + }); + + it('should throw an error if no api key provided', () => { + process.env.REDOCLY_AUTHORIZATION = ''; + + jest.spyOn(fs, 'existsSync').mockReturnValue(false); + + expect(() => getApiKeys('test-domain')).toThrowError( + 'No api key provided, please use environment variable REDOCLY_AUTHORIZATION.' + ); + }); +}); diff --git a/packages/cli/src/cms/api/__tests__/api.client.test.ts b/packages/cli/src/cms/api/__tests__/api.client.test.ts new file mode 100644 index 00000000..f654ad70 --- /dev/null +++ b/packages/cli/src/cms/api/__tests__/api.client.test.ts @@ -0,0 +1,275 @@ +import fetch, { Response } from 'node-fetch'; +import * as FormData from 'form-data'; + +import { ReuniteApiClient, PushPayload } from '../api-client'; + +jest.mock('node-fetch', () => ({ + default: jest.fn(), +})); + +function mockFetchResponse(response: any) { + (fetch as jest.MockedFunction).mockResolvedValue(response as unknown as Response); +} + +describe('ApiClient', () => { + const testToken = 'test-token'; + const testDomain = 'test-domain.com'; + const testOrg = 'test-org'; + const testProject = 'test-project'; + + describe('getDefaultBranch()', () => { + let apiClient: ReuniteApiClient; + + beforeEach(() => { + apiClient = new ReuniteApiClient(testDomain, testToken); + }); + + it('should get default project branch', async () => { + mockFetchResponse({ + ok: true, + json: jest.fn().mockResolvedValue({ + branchName: 'test-branch', + }), + }); + + const result = await apiClient.remotes.getDefaultBranch(testOrg, testProject); + + expect(fetch).toHaveBeenCalledWith( + `${testDomain}/api/orgs/${testOrg}/projects/${testProject}/source`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${testToken}`, + }, + } + ); + + expect(result).toEqual('test-branch'); + }); + + it('should throw parsed error if response is not ok', async () => { + mockFetchResponse({ + ok: false, + json: jest.fn().mockResolvedValue({ + type: 'about:blank', + title: 'Project source not found', + status: 404, + detail: 'Not Found', + object: 'problem', + }), + }); + + await expect(apiClient.remotes.getDefaultBranch(testOrg, testProject)).rejects.toThrow( + new Error('Failed to fetch default branch: Project source not found') + ); + }); + + it('should throw statusText error if response is not ok', async () => { + mockFetchResponse({ + ok: false, + statusText: 'Not found', + json: jest.fn().mockResolvedValue({ + unknownField: 'unknown-error', + }), + }); + + await expect(apiClient.remotes.getDefaultBranch(testOrg, testProject)).rejects.toThrow( + new Error('Failed to fetch default branch: Not found') + ); + }); + }); + + describe('upsert()', () => { + const remotePayload = { + mountBranchName: 'remote-mount-branch-name', + mountPath: 'remote-mount-path', + }; + let apiClient: ReuniteApiClient; + + beforeEach(() => { + apiClient = new ReuniteApiClient(testDomain, testToken); + }); + + it('should upsert remote', async () => { + const responseMock = { + id: 'remote-id', + type: 'CICD', + mountPath: 'remote-mount-path', + mountBranchName: 'remote-mount-branch-name', + organizationId: testOrg, + projectId: testProject, + }; + + mockFetchResponse({ + ok: true, + json: jest.fn().mockResolvedValue(responseMock), + }); + + const result = await apiClient.remotes.upsert(testOrg, testProject, remotePayload); + + expect(fetch).toHaveBeenCalledWith( + `${testDomain}/api/orgs/${testOrg}/projects/${testProject}/remotes`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${testToken}`, + }, + body: JSON.stringify({ + mountPath: remotePayload.mountPath, + mountBranchName: remotePayload.mountBranchName, + type: 'CICD', + autoMerge: true, + }), + } + ); + + expect(result).toEqual(responseMock); + }); + + it('should throw parsed error if response is not ok', async () => { + mockFetchResponse({ + ok: false, + json: jest.fn().mockResolvedValue({ + type: 'about:blank', + title: 'Not allowed to mount remote outside of project content path: /docs', + status: 403, + detail: 'Forbidden', + object: 'problem', + }), + }); + + await expect(apiClient.remotes.upsert(testOrg, testProject, remotePayload)).rejects.toThrow( + new Error( + 'Failed to upsert remote: Not allowed to mount remote outside of project content path: /docs' + ) + ); + }); + + it('should throw statusText error if response is not ok', async () => { + mockFetchResponse({ + ok: false, + statusText: 'Not found', + json: jest.fn().mockResolvedValue({ + unknownField: 'unknown-error', + }), + }); + + await expect(apiClient.remotes.upsert(testOrg, testProject, remotePayload)).rejects.toThrow( + new Error('Failed to upsert remote: Not found') + ); + }); + }); + + describe('push()', () => { + const testRemoteId = 'test-remote-id'; + const pushPayload = { + remoteId: testRemoteId, + commit: { + message: 'test-message', + author: { + name: 'test-name', + email: 'test-email', + }, + branchName: 'test-branch-name', + }, + } as unknown as PushPayload; + + const filesMock = [{ path: 'some-file.yaml', stream: Buffer.from('fefef') }]; + + const responseMock = { + branchName: 'rem/cicd/rem_01he7sr6ys2agb7w0g9t7978fn-main', + hasChanges: true, + files: [ + { + type: 'file', + name: 'some-file.yaml', + path: 'docs/remotes/some-file.yaml', + lastModified: 1698925132394.2993, + mimeType: 'text/yaml', + }, + ], + commitSha: 'bb23a2f8e012ac0b7b9961b57fb40d8686b21b43', + outdated: false, + }; + + let apiClient: ReuniteApiClient; + + beforeEach(() => { + apiClient = new ReuniteApiClient(testDomain, testToken); + }); + + it('should push to remote', async () => { + let passedFormData = new FormData(); + + (fetch as jest.MockedFunction).mockImplementationOnce( + async (_: any, options: any): Promise => { + passedFormData = options.body as FormData; + + return { + ok: true, + json: jest.fn().mockResolvedValue(responseMock), + } as unknown as Response; + } + ); + + const formData = new FormData(); + + formData.append('remoteId', testRemoteId); + formData.append('commit[message]', pushPayload.commit.message); + formData.append('commit[author][name]', pushPayload.commit.author.name); + formData.append('commit[author][email]', pushPayload.commit.author.email); + formData.append('commit[branchName]', pushPayload.commit.branchName); + formData.append('files[some-file.yaml]', filesMock[0].stream); + + const result = await apiClient.remotes.push(testOrg, testProject, pushPayload, filesMock); + + expect(fetch).toHaveBeenCalledWith( + `${testDomain}/api/orgs/${testOrg}/projects/${testProject}/pushes`, + expect.objectContaining({ + method: 'POST', + headers: { + Authorization: `Bearer ${testToken}`, + }, + }) + ); + + expect( + JSON.stringify(passedFormData).replace(new RegExp(passedFormData.getBoundary(), 'g'), '') + ).toEqual(JSON.stringify(formData).replace(new RegExp(formData.getBoundary(), 'g'), '')); + expect(result).toEqual(responseMock); + }); + + it('should throw parsed error if response is not ok', async () => { + mockFetchResponse({ + ok: false, + json: jest.fn().mockResolvedValue({ + type: 'about:blank', + title: 'Cannot push to remote', + status: 403, + detail: 'Forbidden', + object: 'problem', + }), + }); + + await expect( + apiClient.remotes.push(testOrg, testProject, pushPayload, filesMock) + ).rejects.toThrow(new Error('Failed to push: Cannot push to remote')); + }); + + it('should throw statusText error if response is not ok', async () => { + mockFetchResponse({ + ok: false, + statusText: 'Not found', + json: jest.fn().mockResolvedValue({ + unknownField: 'unknown-error', + }), + }); + + await expect( + apiClient.remotes.push(testOrg, testProject, pushPayload, filesMock) + ).rejects.toThrow(new Error('Failed to push: Not found')); + }); + }); +}); diff --git a/packages/cli/src/cms/api/__tests__/domains.test.ts b/packages/cli/src/cms/api/__tests__/domains.test.ts new file mode 100644 index 00000000..261b1e7f --- /dev/null +++ b/packages/cli/src/cms/api/__tests__/domains.test.ts @@ -0,0 +1,15 @@ +import { getDomain } from '../domains'; + +describe('getDomain()', () => { + it('should return the domain from environment variable', () => { + process.env.REDOCLY_DOMAIN = 'test-domain'; + + expect(getDomain()).toBe('test-domain'); + }); + + it('should return the default domain if no domain provided', () => { + process.env.REDOCLY_DOMAIN = ''; + + expect(getDomain()).toBe('https://app.cloud.redocly.com'); + }); +}); diff --git a/packages/cli/src/cms/api/api-client.ts b/packages/cli/src/cms/api/api-client.ts new file mode 100644 index 00000000..671da6c2 --- /dev/null +++ b/packages/cli/src/cms/api/api-client.ts @@ -0,0 +1,199 @@ +import fetchWithTimeout from '../../utils/fetch-with-timeout'; +import fetch from 'node-fetch'; +import * as FormData from 'form-data'; + +import type { Response } from 'node-fetch'; +import type { ReadStream } from 'fs'; +import type { + ListRemotesResponse, + ProjectSourceResponse, + PushResponse, + UpsertRemoteResponse, +} from './types'; + +class RemotesApiClient { + constructor(private readonly domain: string, private readonly apiKey: string) {} + + private async getParsedResponse(response: Response): Promise { + const responseBody = await response.json(); + + if (response.ok) { + return responseBody as T; + } + + throw new Error(responseBody.title || response.statusText); + } + + async getDefaultBranch(organizationId: string, projectId: string) { + const response = await fetch( + `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/source`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.apiKey}`, + }, + } + ); + + try { + const source = await this.getParsedResponse(response); + + return source.branchName; + } catch (err) { + throw new Error(`Failed to fetch default branch: ${err.message || 'Unknown error'}`); + } + } + + async upsert( + organizationId: string, + projectId: string, + remote: { + mountPath: string; + mountBranchName: string; + } + ): Promise { + const response = await fetch( + `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/remotes`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ + mountPath: remote.mountPath, + mountBranchName: remote.mountBranchName, + type: 'CICD', + autoMerge: true, + }), + } + ); + + try { + return await this.getParsedResponse(response); + } catch (err) { + throw new Error(`Failed to upsert remote: ${err.message || 'Unknown error'}`); + } + } + + async push( + organizationId: string, + projectId: string, + payload: PushPayload, + files: { path: string; stream: ReadStream | Buffer }[] + ): Promise { + const formData = new FormData(); + + formData.append('remoteId', payload.remoteId); + formData.append('commit[message]', payload.commit.message); + formData.append('commit[author][name]', payload.commit.author.name); + formData.append('commit[author][email]', payload.commit.author.email); + formData.append('commit[branchName]', payload.commit.branchName); + payload.commit.url && formData.append('commit[url]', payload.commit.url); + payload.commit.namespace && formData.append('commit[namespaceId]', payload.commit.namespace); + payload.commit.sha && formData.append('commit[sha]', payload.commit.sha); + payload.commit.repository && formData.append('commit[repositoryId]', payload.commit.repository); + payload.commit.createdAt && formData.append('commit[createdAt]', payload.commit.createdAt); + + for (const file of files) { + formData.append(`files[${file.path}]`, file.stream); + } + + payload.isMainBranch && formData.append('isMainBranch', 'true'); + + const response = await fetch( + `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/pushes`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${this.apiKey}`, + }, + body: formData, + } + ); + + try { + return await this.getParsedResponse(response); + } catch (err) { + throw new Error(`Failed to push: ${err.message || 'Unknown error'}`); + } + } + + async getRemotesList(organizationId: string, projectId: string, mountPath: string) { + const response = await fetch( + `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/remotes?filter=mountPath:/${mountPath}/`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.apiKey}`, + }, + } + ); + + try { + return await this.getParsedResponse(response); + } catch (err) { + throw new Error(`Failed to get remote list: ${err.message || 'Unknown error'}`); + } + } + + async getPush({ + organizationId, + projectId, + pushId, + }: { + organizationId: string; + projectId: string; + pushId: string; + }) { + const response = await fetchWithTimeout( + `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/pushes/${pushId}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.apiKey}`, + }, + } + ); + + if (!response) { + throw new Error(`Failed to get push status: Time is up`); + } + + try { + return await this.getParsedResponse(response); + } catch (err) { + throw new Error(`Failed to get push status: ${err.message || 'Unknown error'}`); + } + } +} + +export class ReuniteApiClient { + remotes: RemotesApiClient; + + constructor(public domain: string, private readonly apiKey: string) { + this.remotes = new RemotesApiClient(this.domain, this.apiKey); + } +} + +export type PushPayload = { + remoteId: string; + commit: { + message: string; + branchName: string; + sha?: string; + url?: string; + createdAt?: string; + namespace?: string; + repository?: string; + author: { + name: string; + email: string; + image?: string; + }; + }; + isMainBranch?: boolean; +}; diff --git a/packages/cli/src/cms/api/api-keys.ts b/packages/cli/src/cms/api/api-keys.ts new file mode 100644 index 00000000..79bf7762 --- /dev/null +++ b/packages/cli/src/cms/api/api-keys.ts @@ -0,0 +1,26 @@ +import { resolve } from 'path'; +import { homedir } from 'os'; +import { existsSync, readFileSync } from 'fs'; +import { isNotEmptyObject } from '@redocly/openapi-core/lib/utils'; +import { TOKEN_FILENAME } from '@redocly/openapi-core/lib/redocly'; + +function readCredentialsFile(credentialsPath: string) { + return existsSync(credentialsPath) ? JSON.parse(readFileSync(credentialsPath, 'utf-8')) : {}; +} + +export function getApiKeys(domain: string) { + const apiKey = process.env.REDOCLY_AUTHORIZATION; + + if (apiKey) { + return apiKey; + } + + const credentialsPath = resolve(homedir(), TOKEN_FILENAME); + const credentials = readCredentialsFile(credentialsPath); + + if (isNotEmptyObject(credentials) && credentials[domain]) { + return credentials[domain]; + } + + throw new Error('No api key provided, please use environment variable REDOCLY_AUTHORIZATION.'); +} diff --git a/packages/cli/src/cms/api/domains.ts b/packages/cli/src/cms/api/domains.ts new file mode 100644 index 00000000..16e0b1aa --- /dev/null +++ b/packages/cli/src/cms/api/domains.ts @@ -0,0 +1,11 @@ +const DEFAULT_DOMAIN = 'https://app.cloud.redocly.com'; + +export function getDomain(): string { + const domain = process.env.REDOCLY_DOMAIN; + + if (domain) { + return domain; + } + + return DEFAULT_DOMAIN; +} diff --git a/packages/cli/src/cms/api/index.ts b/packages/cli/src/cms/api/index.ts new file mode 100644 index 00000000..a21875a3 --- /dev/null +++ b/packages/cli/src/cms/api/index.ts @@ -0,0 +1,3 @@ +export * from './api-client'; +export * from './domains'; +export * from './api-keys'; diff --git a/packages/cli/src/cms/api/types.ts b/packages/cli/src/cms/api/types.ts new file mode 100644 index 00000000..e49c5c02 --- /dev/null +++ b/packages/cli/src/cms/api/types.ts @@ -0,0 +1,101 @@ +export type ProjectSourceResponse = { + branchName: string; + contentPath: string; + isInternal: boolean; +}; + +export type UpsertRemoteResponse = { + id: string; + type: 'CICD'; + mountPath: string; + mountBranchName: string; + organizationId: string; + projectId: string; +}; + +export type ListRemotesResponse = { + object: 'list'; + page: { + endCursor: string; + startCursor: string; + haxNextPage: boolean; + hasPrevPage: boolean; + limit: number; + total: number; + }; + items: Remote[]; +}; + +export type Remote = { + mountPath: string; + type: string; + autoSync: boolean; + autoMerge: boolean; + createdAt: string; + updatedAt: string; + providerType: string; + namespaceId: string; + repositoryId: string; + projectId: string; + mountBranchName: string; + contentPath: string; + credentialId: string; + branchName: string; + contentType: string; + id: string; +}; + +export type PushResponse = { + id: string; + remoteId: string; + commit: { + message: string; + branchName: string; + sha: string | null; + url: string | null; + createdAt: string | null; + namespace: string | null; + repository: string | null; + author: { + name: string; + email: string; + image: string | null; + }; + }; + remote: { + commits: { + branchName: string; + sha: string; + }[]; + }; + hasChanges: boolean; + isOutdated: boolean; + isMainBranch: boolean; + status: PushStatusResponse; +}; + +type DeploymentStatusResponse = { + deploy: { + url: string | null; + status: DeploymentStatus; + }; + scorecard: ScorecardItem[]; +}; + +export type PushStatusResponse = { + preview: DeploymentStatusResponse; + production: DeploymentStatusResponse; +}; + +export type ScorecardItem = { + name: string; + status: PushStatusBase; + description: string; + url: string; +}; + +export type PushStatusBase = 'pending' | 'success' | 'running' | 'failed'; + +// export type BuildStatus = PushStatusBase | 'NOT_STARTED' | 'QUEUED'; + +export type DeploymentStatus = 'skipped' | PushStatusBase; diff --git a/packages/cli/src/cms/commands/__tests__/push-status.test.ts b/packages/cli/src/cms/commands/__tests__/push-status.test.ts new file mode 100644 index 00000000..e2b8e350 --- /dev/null +++ b/packages/cli/src/cms/commands/__tests__/push-status.test.ts @@ -0,0 +1,212 @@ +import { handlePushStatus } from '../push-status'; +import { PushResponse } from '../../api/types'; +import { exitWithError } from '../../../utils/miscellaneous'; + +const remotes = { + getPush: jest.fn(), + getRemotesList: jest.fn(), +}; + +jest.mock('../../../utils/miscellaneous'); + +jest.mock('colorette', () => ({ + green: (str: string) => str, + yellow: (str: string) => str, + red: (str: string) => str, + gray: (str: string) => str, + magenta: (str: string) => str, + cyan: (str: string) => str, +})); + +jest.mock('../../api', () => ({ + ...jest.requireActual('../../api'), + ReuniteApiClient: jest.fn().mockImplementation(function (this: any, ...args) { + this.remotes = remotes; + }), +})); + +describe('handlePushStatus()', () => { + const mockConfig = { apis: {} } as any; + + const pushResponseStub = { + hasChanges: true, + status: { + preview: { + scorecard: [], + deploy: { + url: 'https://test-url', + status: 'success', + }, + }, + production: { + scorecard: [], + deploy: { + url: 'https://test-url', + status: 'success', + }, + }, + }, + } as unknown as PushResponse; + + beforeEach(() => { + jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should throw error if organization not provided', async () => { + await handlePushStatus( + { + domain: 'test-domain', + organization: '', + project: 'test-project', + pushId: 'test-push-id', + 'max-execution-time': 1000, + }, + mockConfig + ); + + expect(exitWithError).toHaveBeenCalledWith( + "No organization provided, please use --organization option or specify the 'organization' field in the config file." + ); + }); + + it('should return success push status for preview-build', async () => { + process.env.REDOCLY_AUTHORIZATION = 'test-api-key'; + remotes.getPush.mockResolvedValueOnce(pushResponseStub); + + await handlePushStatus( + { + domain: 'test-domain', + organization: 'test-org', + project: 'test-project', + pushId: 'test-push-id', + 'max-execution-time': 1000, + }, + mockConfig + ); + expect(process.stdout.write).toHaveBeenCalledTimes(1); + expect(process.stdout.write).toHaveBeenCalledWith( + '🚀 PREVIEW deployment succeeded.\nPreview URL: https://test-url\n' + ); + }); + + it('should return success push status for preview and production builds', async () => { + process.env.REDOCLY_AUTHORIZATION = 'test-api-key'; + remotes.getPush.mockResolvedValue({ ...pushResponseStub, isMainBranch: true }); + + await handlePushStatus( + { + domain: 'test-domain', + organization: 'test-org', + project: 'test-project', + pushId: 'test-push-id', + 'max-execution-time': 1000, + }, + mockConfig + ); + expect(process.stdout.write).toHaveBeenCalledTimes(2); + expect(process.stdout.write).toHaveBeenCalledWith( + '🚀 PREVIEW deployment succeeded.\nPreview URL: https://test-url\n' + ); + expect(process.stdout.write).toHaveBeenCalledWith( + '🚀 PRODUCTION deployment succeeded.\nPreview URL: https://test-url\n' + ); + }); + + it('should return failed push status for preview build', async () => { + process.env.REDOCLY_AUTHORIZATION = 'test-api-key'; + + remotes.getPush.mockResolvedValue({ + isOutdated: false, + hasChanges: true, + status: { + preview: { deploy: { status: 'failed', url: 'https://test-url' }, scorecard: [] }, + }, + }); + + await handlePushStatus( + { + domain: 'test-domain', + organization: 'test-org', + project: 'test-project', + pushId: 'test-push-id', + 'max-execution-time': 1000, + }, + mockConfig + ); + expect(exitWithError).toHaveBeenCalledWith( + '❌ PREVIEW deployment failed.\nPreview URL: https://test-url' + ); + }); + + it('should return success push status for preview build and print scorecards', async () => { + process.env.REDOCLY_AUTHORIZATION = 'test-api-key'; + + remotes.getPush.mockResolvedValue({ + isOutdated: false, + hasChanges: true, + status: { + preview: { + deploy: { status: 'success', url: 'https://test-url' }, + scorecard: [ + { + name: 'test-name', + status: 'success', + description: 'test-description', + url: 'test-url', + }, + ], + }, + }, + }); + + await handlePushStatus( + { + domain: 'test-domain', + organization: 'test-org', + project: 'test-project', + pushId: 'test-push-id', + 'max-execution-time': 1000, + }, + mockConfig + ); + expect(process.stdout.write).toHaveBeenCalledTimes(4); + expect(process.stdout.write).toHaveBeenCalledWith( + '🚀 PREVIEW deployment succeeded.\nPreview URL: https://test-url\n' + ); + expect(process.stdout.write).toHaveBeenCalledWith('\nScorecard:'); + expect(process.stdout.write).toHaveBeenCalledWith( + '\n Name: test-name\n Status: success\n URL: test-url\n Description: test-description\n' + ); + expect(process.stdout.write).toHaveBeenCalledWith('\n'); + }); + + it('should display message if there is no changes', async () => { + process.env.REDOCLY_AUTHORIZATION = 'test-api-key'; + + remotes.getPush.mockResolvedValueOnce({ + isOutdated: false, + hasChanges: false, + status: { + preview: { deploy: { status: 'skipped', url: 'https://test-url' }, scorecard: [] }, + }, + }); + + await handlePushStatus( + { + domain: 'test-domain', + organization: 'test-org', + project: 'test-project', + pushId: 'test-push-id', + wait: true, + 'max-execution-time': 1000, + }, + mockConfig + ); + expect(process.stderr.write).toHaveBeenCalledWith('Files not uploaded. Reason: no changes.\n'); + }); +}); diff --git a/packages/cli/src/cms/commands/__tests__/push.test.ts b/packages/cli/src/cms/commands/__tests__/push.test.ts new file mode 100644 index 00000000..ebdb1228 --- /dev/null +++ b/packages/cli/src/cms/commands/__tests__/push.test.ts @@ -0,0 +1,293 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { handlePush } from '../push'; +import { ReuniteApiClient } from '../../api'; + +const remotes = { + push: jest.fn(), + upsert: jest.fn(), + getDefaultBranch: jest.fn(), +}; + +jest.mock('@redocly/openapi-core', () => ({ + slash: jest.fn().mockImplementation((p) => p), +})); + +jest.mock('../../api', () => ({ + ...jest.requireActual('../../api'), + ReuniteApiClient: jest.fn().mockImplementation(function (this: any, ...args) { + this.remotes = remotes; + }), +})); + +describe('handlePush()', () => { + let pathResolveSpy: jest.SpyInstance; + let pathRelativeSpy: jest.SpyInstance; + let pathDirnameSpy: jest.SpyInstance; + let fsStatSyncSpy: jest.SpyInstance; + let fsReaddirSyncSpy: jest.SpyInstance; + + beforeEach(() => { + remotes.getDefaultBranch.mockResolvedValueOnce('test-default-branch'); + remotes.upsert.mockResolvedValueOnce({ id: 'test-remote-id' }); + remotes.push.mockResolvedValueOnce({ branchName: 'uploaded-to-branch' }); + + jest.spyOn(fs, 'createReadStream').mockReturnValue('stream' as any); + + pathResolveSpy = jest.spyOn(path, 'resolve'); + pathRelativeSpy = jest.spyOn(path, 'relative'); + pathDirnameSpy = jest.spyOn(path, 'dirname'); + fsStatSyncSpy = jest.spyOn(fs, 'statSync'); + fsReaddirSyncSpy = jest.spyOn(fs, 'readdirSync'); + }); + + afterEach(() => { + pathResolveSpy.mockRestore(); + pathRelativeSpy.mockRestore(); + pathDirnameSpy.mockRestore(); + fsStatSyncSpy.mockRestore(); + fsReaddirSyncSpy.mockRestore(); + }); + + it('should upload files', async () => { + const mockConfig = { apis: {} } as any; + process.env.REDOCLY_AUTHORIZATION = 'test-api-key'; + + fsStatSyncSpy.mockReturnValueOnce({ + isDirectory() { + return false; + }, + } as any); + + pathResolveSpy.mockImplementationOnce((p) => p); + pathRelativeSpy.mockImplementationOnce((_, p) => p); + pathDirnameSpy.mockImplementation((_: string) => '.'); + + await handlePush( + { + domain: 'test-domain', + 'mount-path': 'test-mount-path', + organization: 'test-org', + project: 'test-project', + branch: 'test-branch', + namespace: 'test-namespace', + repository: 'test-repository', + 'commit-sha': 'test-commit-sha', + 'commit-url': 'test-commit-url', + 'default-branch': 'test-branch', + 'created-at': 'test-created-at', + author: 'TestAuthor ', + message: 'Test message', + files: ['test-file'], + 'max-execution-time': 10, + }, + mockConfig + ); + + expect(remotes.getDefaultBranch).toHaveBeenCalledWith('test-org', 'test-project'); + expect(remotes.upsert).toHaveBeenCalledWith('test-org', 'test-project', { + mountBranchName: 'test-default-branch', + mountPath: 'test-mount-path', + }); + expect(remotes.push).toHaveBeenCalledWith( + 'test-org', + 'test-project', + { + isMainBranch: true, + remoteId: 'test-remote-id', + commit: { + message: 'Test message', + branchName: 'test-branch', + createdAt: 'test-created-at', + namespace: 'test-namespace', + repository: 'test-repository', + sha: 'test-commit-sha', + url: 'test-commit-url', + author: { + name: 'TestAuthor', + email: 'test-author@mail.com', + }, + }, + }, + [ + { + path: 'test-file', + stream: 'stream', + }, + ] + ); + }); + + it('should collect files from directory and preserve file structure', async () => { + const mockConfig = { apis: {} } as any; + process.env.REDOCLY_AUTHORIZATION = 'test-api-key'; + + /* + ├── app + │ ├── index.html + ├── openapi.yaml + └── some-ref.yaml + */ + + fsStatSyncSpy.mockImplementation( + (filePath) => + ({ + isDirectory() { + return filePath === 'test-folder' || filePath === 'test-folder/app'; + }, + } as any) + ); + + fsReaddirSyncSpy.mockImplementation((dirPath): any => { + if (dirPath === 'test-folder') { + return ['app', 'another-ref.yaml', 'openapi.yaml']; + } + + if (dirPath === 'test-folder/app') { + return ['index.html']; + } + + throw new Error('Not a directory'); + }); + + await handlePush( + { + domain: 'test-domain', + 'mount-path': 'test-mount-path', + organization: 'test-org', + project: 'test-project', + branch: 'test-branch', + author: 'TestAuthor ', + message: 'Test message', + 'default-branch': 'main', + files: ['test-folder'], + 'max-execution-time': 10, + }, + mockConfig + ); + + expect(remotes.push).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + [ + { + path: 'app/index.html', + stream: 'stream', + }, + + { + path: 'another-ref.yaml', + stream: 'stream', + }, + { + path: 'openapi.yaml', + stream: 'stream', + }, + ] + ); + }); + + it('should not upload files if no files passed', async () => { + const mockConfig = { apis: {} } as any; + process.env.REDOCLY_AUTHORIZATION = 'test-api-key'; + + await handlePush( + { + domain: 'test-domain', + 'mount-path': 'test-mount-path', + organization: 'test-org', + project: 'test-project', + branch: 'test-branch', + author: 'TestAuthor ', + message: 'Test message', + 'default-branch': 'main', + files: [], + 'max-execution-time': 10, + }, + mockConfig + ); + + expect(remotes.getDefaultBranch).not.toHaveBeenCalled(); + expect(remotes.upsert).not.toHaveBeenCalled(); + expect(remotes.push).not.toHaveBeenCalled(); + }); + + it('should get organization from config if not passed', async () => { + const mockConfig = { organization: 'test-org-from-config', apis: {} } as any; + process.env.REDOCLY_AUTHORIZATION = 'test-api-key'; + + fsStatSyncSpy.mockReturnValueOnce({ + isDirectory() { + return false; + }, + } as any); + + pathResolveSpy.mockImplementationOnce((p) => p); + pathRelativeSpy.mockImplementationOnce((_, p) => p); + pathDirnameSpy.mockImplementation((_: string) => '.'); + + await handlePush( + { + domain: 'test-domain', + 'mount-path': 'test-mount-path', + project: 'test-project', + branch: 'test-branch', + author: 'TestAuthor ', + message: 'Test message', + files: ['test-file'], + 'default-branch': 'main', + 'max-execution-time': 10, + }, + mockConfig + ); + + expect(remotes.getDefaultBranch).toHaveBeenCalledWith( + 'test-org-from-config', + expect.anything() + ); + expect(remotes.upsert).toHaveBeenCalledWith( + 'test-org-from-config', + expect.anything(), + expect.anything() + ); + expect(remotes.push).toHaveBeenCalledWith( + 'test-org-from-config', + expect.anything(), + expect.anything(), + expect.anything() + ); + }); + + it('should get domain from env if not passed', async () => { + const mockConfig = { organization: 'test-org-from-config', apis: {} } as any; + process.env.REDOCLY_AUTHORIZATION = 'test-api-key'; + process.env.REDOCLY_DOMAIN = 'test-domain-from-env'; + + fsStatSyncSpy.mockReturnValueOnce({ + isDirectory() { + return false; + }, + } as any); + + pathResolveSpy.mockImplementationOnce((p) => p); + pathRelativeSpy.mockImplementationOnce((_, p) => p); + pathDirnameSpy.mockImplementation((_: string) => '.'); + + await handlePush( + { + 'mount-path': 'test-mount-path', + project: 'test-project', + branch: 'test-branch', + 'default-branch': 'main', + author: 'TestAuthor ', + message: 'Test message', + files: ['test-file'], + 'max-execution-time': 10, + }, + mockConfig + ); + + expect(ReuniteApiClient).toBeCalledWith('test-domain-from-env', 'test-api-key'); + }); +}); diff --git a/packages/cli/src/cms/commands/push-status.ts b/packages/cli/src/cms/commands/push-status.ts new file mode 100644 index 00000000..f072974e --- /dev/null +++ b/packages/cli/src/cms/commands/push-status.ts @@ -0,0 +1,203 @@ +import * as colors from 'colorette'; +import { Config } from '@redocly/openapi-core'; +import { exitWithError, printExecutionTime } from '../../utils/miscellaneous'; +import { Spinner } from '../../utils/spinner'; +import { DeploymentError } from '../utils'; +import { yellow } from 'colorette'; +import { ReuniteApiClient, getApiKeys, getDomain } from '../api'; + +import type { DeploymentStatus, PushResponse, ScorecardItem } from '../api/types'; + +const INTERVAL = 5000; + +export type PushStatusOptions = { + organization: string; + project: string; + pushId: string; + domain?: string; + config?: string; + format?: 'stylish' | 'json'; + wait?: boolean; + 'max-execution-time': number; +}; + +export async function handlePushStatus(argv: PushStatusOptions, config: Config) { + const startedAt = performance.now(); + const spinner = new Spinner(); + + const { organization, project: projectId, pushId, wait } = argv; + + const orgId = organization || config.organization; + + if (!orgId) { + return exitWithError( + `No organization provided, please use --organization option or specify the 'organization' field in the config file.` + ); + } + + const domain = argv.domain || getDomain(); + + if (!domain) { + return exitWithError( + `No domain provided, please use --domain option or environment variable REDOCLY_DOMAIN.` + ); + } + + const maxExecutionTime = argv['max-execution-time'] || 600; + + try { + const apiKey = getApiKeys(domain); + const client = new ReuniteApiClient(domain, apiKey); + + if (wait) { + const push = await waitForDeployment(client, 'preview'); + + if (push.isMainBranch && push.status.preview.deploy.status === 'success') { + await waitForDeployment(client, 'production'); + } + + printPushStatusInfo(); + return; + } + + const pushPreview = await getAndPrintPushStatus(client, 'preview'); + printScorecard(pushPreview.status.preview.scorecard); + + if (pushPreview.isMainBranch) { + await getAndPrintPushStatus(client, 'production'); + printScorecard(pushPreview.status.production.scorecard); + } + + printPushStatusInfo(); + } catch (err) { + const message = + err instanceof DeploymentError + ? err.message + : `✗ Failed to get push status. Reason: ${err.message}\n`; + exitWithError(message); + } + + function printPushStatusInfo() { + process.stderr.write( + `\nProcessed push-status for ${colors.yellow(orgId!)}, ${colors.yellow( + projectId + )} and pushID ${colors.yellow(pushId)}.\n` + ); + printExecutionTime('push-status', startedAt, 'Finished'); + } + + async function waitForDeployment( + client: ReuniteApiClient, + buildType: 'preview' | 'production' + ): Promise { + return new Promise((resolve, reject) => { + if (performance.now() - startedAt > maxExecutionTime * 1000) { + spinner.stop(); + reject(new Error(`Time limit exceeded.`)); + } + + getAndPrintPushStatus(client, buildType) + .then((push) => { + if (!['pending', 'running'].includes(push.status[buildType].deploy.status)) { + printScorecard(push.status[buildType].scorecard); + resolve(push); + return; + } + + setTimeout(async () => { + try { + const pushResponse = await waitForDeployment(client, buildType); + resolve(pushResponse); + } catch (e) { + reject(e); + } + }, INTERVAL); + }) + .catch(reject); + }); + } + + async function getAndPrintPushStatus( + client: ReuniteApiClient, + buildType: 'preview' | 'production' + ) { + const push = await client.remotes.getPush({ + organizationId: orgId!, + projectId, + pushId, + }); + + if (push.isOutdated || !push.hasChanges) { + process.stderr.write( + yellow(`Files not uploaded. Reason: ${push.isOutdated ? 'outdated' : 'no changes'}.\n`) + ); + } else { + displayDeploymentAndBuildStatus({ + status: push.status[buildType].deploy.status, + previewUrl: push.status[buildType].deploy.url, + buildType, + spinner, + wait, + }); + } + + return push; + } +} + +function printScorecard(scorecard: ScorecardItem[]) { + if (!scorecard.length) { + return; + } + process.stdout.write(`\n${colors.magenta('Scorecard')}:`); + for (const scorecardItem of scorecard) { + process.stdout.write(` + ${colors.magenta('Name')}: ${scorecardItem.name} + ${colors.magenta('Status')}: ${scorecardItem.status} + ${colors.magenta('URL')}: ${colors.cyan(scorecardItem.url)} + ${colors.magenta('Description')}: ${scorecardItem.description}\n`); + } + process.stdout.write(`\n`); +} + +function displayDeploymentAndBuildStatus({ + status, + previewUrl, + spinner, + buildType, + wait, +}: { + status: DeploymentStatus; + previewUrl: string | null; + spinner: Spinner; + buildType: 'preview' | 'production'; + wait?: boolean; +}) { + switch (status) { + case 'success': + spinner.stop(); + return process.stdout.write( + `${colors.green( + `🚀 ${buildType.toLocaleUpperCase()} deployment succeeded.` + )}\n${colors.magenta('Preview URL')}: ${colors.cyan(previewUrl!)}\n` + ); + case 'failed': + spinner.stop(); + throw new DeploymentError( + `${colors.red(`❌ ${buildType.toLocaleUpperCase()} deployment failed.`)}\n${colors.magenta( + 'Preview URL' + )}: ${colors.cyan(previewUrl!)}` + ); + case 'pending': + return wait + ? spinner.start(`${colors.yellow(`Pending ${buildType}`)}`) + : process.stdout.write(`Status: ${colors.yellow(`Pending ${buildType}`)}\n`); + case 'skipped': + spinner.stop(); + return process.stdout.write(`${colors.yellow(`Skipped ${buildType}`)}\n`); + case 'running': + return wait + ? spinner.start(`${colors.yellow(`Running ${buildType}`)}`) + : process.stdout.write(`Status: ${colors.yellow(`Running ${buildType}`)}\n`); + } +} diff --git a/packages/cli/src/cms/commands/push.ts b/packages/cli/src/cms/commands/push.ts new file mode 100644 index 00000000..55dbea1a --- /dev/null +++ b/packages/cli/src/cms/commands/push.ts @@ -0,0 +1,215 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { Config, slash } from '@redocly/openapi-core'; +import { exitWithError, HandledError, printExecutionTime } from '../../utils/miscellaneous'; +import { green, yellow } from 'colorette'; +import pluralize = require('pluralize'); +import { handlePushStatus } from './push-status'; +import { ReuniteApiClient, getDomain, getApiKeys } from '../api'; + +export type PushOptions = { + apis?: string[]; + organization?: string; + project: string; + 'mount-path': string; + + branch: string; + author: string; + message: string; + 'commit-sha'?: string; + 'commit-url'?: string; + namespace?: string; + repository?: string; + 'created-at'?: string; + + files: string[]; + + 'default-branch': string; + domain?: string; + config?: string; + 'wait-for-deployment'?: boolean; + 'max-execution-time': number; + verbose?: boolean; +}; + +type FileToUpload = { name: string; path: string }; + +export async function handlePush(argv: PushOptions, config: Config) { + const startedAt = performance.now(); + const { organization, project: projectId, 'mount-path': mountPath, verbose } = argv; + + const orgId = organization || config.organization; + + if (!argv.message || !argv.author || !argv.branch) { + exitWithError('Error: message, author and branch are required for push to the CMS'); + } + + if (!orgId) { + return exitWithError( + `No organization provided, please use --organization option or specify the 'organization' field in the config file.` + ); + } + + const domain = argv.domain || getDomain(); + + if (!domain) { + return exitWithError( + `No domain provided, please use --domain option or environment variable REDOCLY_AUTHORIZATION.` + ); + } + + try { + const { + 'commit-sha': commitSha, + 'commit-url': commitUrl, + 'default-branch': defaultBranch, + 'wait-for-deployment': waitForDeployment, + 'max-execution-time': maxExecutionTime, + } = argv; + const author = parseCommitAuthor(argv.author); + const apiKey = getApiKeys(domain); + const filesToUpload = collectFilesToPush(argv.files || argv.apis); + + if (!filesToUpload.length) { + return printExecutionTime('push', startedAt, `No files to upload`); + } + + const client = new ReuniteApiClient(domain, apiKey); + const projectDefaultBranch = await client.remotes.getDefaultBranch(orgId, projectId); + const remote = await client.remotes.upsert(orgId, projectId, { + mountBranchName: projectDefaultBranch, + mountPath, + }); + + process.stderr.write( + `Uploading to ${remote.mountPath} ${filesToUpload.length} ${pluralize( + 'file', + filesToUpload.length + )}:\n` + ); + + const { id } = await client.remotes.push( + orgId, + projectId, + { + remoteId: remote.id, + commit: { + message: argv.message, + branchName: argv.branch, + sha: commitSha, + url: commitUrl, + createdAt: argv['created-at'], + namespace: argv.namespace, + repository: argv.repository, + author, + }, + isMainBranch: defaultBranch === argv.branch, + }, + filesToUpload.map((f) => ({ path: slash(f.name), stream: fs.createReadStream(f.path) })) + ); + + filesToUpload.forEach((f) => { + process.stderr.write(green(`✓ ${f.name}\n`)); + }); + process.stdout.write('\n'); + + process.stdout.write(`Push ID: ${id}\n`); + + if (waitForDeployment) { + process.stdout.write('\n'); + + await handlePushStatus( + { + organization: orgId, + project: projectId, + pushId: id, + wait: true, + domain, + 'max-execution-time': maxExecutionTime, + }, + config + ); + } + verbose && + printExecutionTime( + 'push', + startedAt, + `${pluralize( + 'file', + filesToUpload.length + )} uploaded to organization ${orgId}, project ${projectId}. Push ID: ${id}.` + ); + } catch (err) { + const message = + err instanceof HandledError ? '' : `✗ File upload failed. Reason: ${err.message}`; + exitWithError(message); + } +} + +function parseCommitAuthor(author: string): { name: string; email: string } { + // Author Name + const reg = /^.+\s<[^<>]+>$/; + + if (!reg.test(author)) { + throw new Error('Invalid author format. Use "Author Name "'); + } + + const [name, email] = author.split('<'); + + return { + name: name.trim(), + email: email.replace('>', '').trim(), + }; +} + +function collectFilesToPush(files: string[]): FileToUpload[] { + const collectedFiles: Record = {}; + + for (const file of files) { + if (fs.statSync(file).isDirectory()) { + const dir = file; + const fileList = getFilesList(dir, []); + + fileList.forEach((f) => addFile(f, dir)); + } else { + addFile(file, path.dirname(file)); + } + } + + function addFile(filePath: string, fileDir: string) { + const fileName = path.relative(fileDir, filePath); + + if (collectedFiles[fileName]) { + process.stdout.write( + yellow(`File ${collectedFiles[fileName]} is overwritten by ${filePath}\n`) + ); + } + + collectedFiles[fileName] = filePath; + } + + return Object.entries(collectedFiles).map(([name, filePath]) => getFileEntry(name, filePath)); +} + +function getFileEntry(name: string, filePath: string): FileToUpload { + return { + name, + path: path.resolve(filePath), + }; +} + +function getFilesList(dir: string, files: string[]): string[] { + const filesAndDirs = fs.readdirSync(dir); + + for (const name of filesAndDirs) { + const currentPath = path.join(dir, name); + + if (fs.statSync(currentPath).isDirectory()) { + files = getFilesList(currentPath, files); + } else { + files.push(currentPath); + } + } + + return files; +} diff --git a/packages/cli/src/cms/utils.ts b/packages/cli/src/cms/utils.ts new file mode 100644 index 00000000..aa7d0495 --- /dev/null +++ b/packages/cli/src/cms/utils.ts @@ -0,0 +1 @@ +export class DeploymentError extends Error {} diff --git a/packages/cli/src/commands/build-docs/index.ts b/packages/cli/src/commands/build-docs/index.ts index fdf3a3a7..e837d44d 100644 --- a/packages/cli/src/commands/build-docs/index.ts +++ b/packages/cli/src/commands/build-docs/index.ts @@ -6,7 +6,7 @@ import { performance } from 'perf_hooks'; import { getObjectOrJSON, getPageHTML } from './utils'; import type { BuildDocsArgv } from './types'; import { Config, getMergedConfig, isAbsoluteUrl } from '@redocly/openapi-core'; -import { exitWithError, getExecutionTime, getFallbackApisOrExit } from '../../utils'; +import { exitWithError, getExecutionTime, getFallbackApisOrExit } from '../../utils/miscellaneous'; export const handlerBuildCommand = async (argv: BuildDocsArgv, configFromFile: Config) => { const startedAt = performance.now(); diff --git a/packages/cli/src/commands/build-docs/utils.ts b/packages/cli/src/commands/build-docs/utils.ts index 02eb7aa9..568664b6 100644 --- a/packages/cli/src/commands/build-docs/utils.ts +++ b/packages/cli/src/commands/build-docs/utils.ts @@ -10,7 +10,7 @@ import { existsSync, lstatSync, readFileSync } from 'fs'; import type { BuildDocsOptions } from './types'; import { red } from 'colorette'; -import { exitWithError } from '../../utils'; +import { exitWithError } from '../../utils/miscellaneous'; export function getObjectOrJSON( openapiOptions: string | Record, diff --git a/packages/cli/src/commands/bundle.ts b/packages/cli/src/commands/bundle.ts index aade0ab8..b55d708b 100644 --- a/packages/cli/src/commands/bundle.ts +++ b/packages/cli/src/commands/bundle.ts @@ -18,12 +18,12 @@ import { printLintTotals, checkIfRulesetExist, sortTopLevelKeysForOas, -} from '../utils'; +} from '../utils/miscellaneous'; import type { OutputExtensions, Skips, Totals } from '../types'; import { performance } from 'perf_hooks'; import { blue, gray, green, yellow } from 'colorette'; import { writeFileSync } from 'fs'; -import { checkForDeprecatedOptions } from '../utils'; +import { checkForDeprecatedOptions } from '../utils/miscellaneous'; export type BundleOptions = { apis?: string[]; diff --git a/packages/cli/src/commands/join.ts b/packages/cli/src/commands/join.ts index e7b6981d..0432f83d 100644 --- a/packages/cli/src/commands/join.ts +++ b/packages/cli/src/commands/join.ts @@ -30,8 +30,8 @@ import { getAndValidateFileExtension, writeToFileByExtension, checkForDeprecatedOptions, -} from '../utils'; -import { isObject, isString, keysOf } from '../js-utils'; +} from '../utils/miscellaneous'; +import { isObject, isString, keysOf } from '../utils/js-utils'; import { Oas3Parameter, Oas3PathItem, diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index ee7313bb..bb15ffe1 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -17,7 +17,7 @@ import { printConfigLintTotals, printLintTotals, printUnusedWarnings, -} from '../utils'; +} from '../utils/miscellaneous'; import { blue, gray } from 'colorette'; import { performance } from 'perf_hooks'; diff --git a/packages/cli/src/commands/login.ts b/packages/cli/src/commands/login.ts index 21215b1d..14d1c1d7 100644 --- a/packages/cli/src/commands/login.ts +++ b/packages/cli/src/commands/login.ts @@ -1,6 +1,6 @@ import { Region, RedoclyClient, Config } from '@redocly/openapi-core'; import { blue, green, gray } from 'colorette'; -import { promptUser } from '../utils'; +import { promptUser } from '../utils/miscellaneous'; export function promptClientToken(domain: string) { return promptUser( diff --git a/packages/cli/src/commands/preview-docs/index.ts b/packages/cli/src/commands/preview-docs/index.ts index e905aced..896e12cc 100644 --- a/packages/cli/src/commands/preview-docs/index.ts +++ b/packages/cli/src/commands/preview-docs/index.ts @@ -1,7 +1,11 @@ import * as colorette from 'colorette'; import * as chockidar from 'chokidar'; import { bundle, RedoclyClient, getTotals, getMergedConfig, Config } from '@redocly/openapi-core'; -import { getFallbackApisOrExit, handleError, loadConfigAndHandleErrors } from '../../utils'; +import { + getFallbackApisOrExit, + handleError, + loadConfigAndHandleErrors, +} from '../../utils/miscellaneous'; import startPreviewServer from './preview-server/preview-server'; import type { Skips } from '../../types'; diff --git a/packages/cli/src/commands/preview-docs/preview-server/preview-server.ts b/packages/cli/src/commands/preview-docs/preview-server/preview-server.ts index 958efcf4..aafccc9b 100644 --- a/packages/cli/src/commands/preview-docs/preview-server/preview-server.ts +++ b/packages/cli/src/commands/preview-docs/preview-server/preview-server.ts @@ -6,7 +6,7 @@ import * as path from 'path'; import { startHttpServer, startWsServer, respondWithGzip, mimeTypes } from './server'; import type { IncomingMessage } from 'http'; -import { isSubdir } from '../../../utils'; +import { isSubdir } from '../../../utils/miscellaneous'; function getPageHTML( htmlTemplate: string, diff --git a/packages/cli/src/commands/preview-project/types.ts b/packages/cli/src/commands/preview-project/types.ts index 31314253..0e88bb01 100644 --- a/packages/cli/src/commands/preview-project/types.ts +++ b/packages/cli/src/commands/preview-project/types.ts @@ -8,5 +8,5 @@ export type PreviewProjectOptions = { plan: ProductPlan | string; port?: number; 'source-dir': string; - config: string | undefined; + config?: string; }; diff --git a/packages/cli/src/commands/push.ts b/packages/cli/src/commands/push.ts index b2f3a029..0b9bb5b3 100644 --- a/packages/cli/src/commands/push.ts +++ b/packages/cli/src/commands/push.ts @@ -21,8 +21,9 @@ import { getFallbackApisOrExit, pluralize, dumpBundle, -} from '../utils'; +} from '../utils/miscellaneous'; import { promptClientToken } from './login'; +import { handlePush as handleCMSPush } from '../cms/commands/push'; const DEFAULT_VERSION = 'latest'; @@ -44,6 +45,19 @@ export type PushOptions = { config?: string; }; +export function commonPushHandler({ + project, + 'mount-path': mountPath, +}: { + project?: string; + 'mount-path'?: string; +}) { + if (project && mountPath) { + return handleCMSPush; + } + return handlePush; +} + export async function handlePush(argv: PushOptions, config: Config): Promise { const client = new RedoclyClient(config.region); const isAuthorized = await client.isAuthorizedWithRedoclyByRegion(); diff --git a/packages/cli/src/commands/split/__tests__/index.test.ts b/packages/cli/src/commands/split/__tests__/index.test.ts index beb2fe37..b29e5bc2 100644 --- a/packages/cli/src/commands/split/__tests__/index.test.ts +++ b/packages/cli/src/commands/split/__tests__/index.test.ts @@ -3,12 +3,11 @@ import * as path from 'path'; import * as openapiCore from '@redocly/openapi-core'; import { ComponentsFiles } from '../types'; import { blue, green } from 'colorette'; -import { writeToFileByExtension } from '../../../utils'; -const utils = require('../../../utils'); +const utils = require('../../../utils/miscellaneous'); -jest.mock('../../../utils', () => ({ - ...jest.requireActual('../../../utils'), +jest.mock('../../../utils/miscellaneous', () => ({ + ...jest.requireActual('../../../utils/miscellaneous'), writeToFileByExtension: jest.fn(), })); diff --git a/packages/cli/src/commands/split/index.ts b/packages/cli/src/commands/split/index.ts index 2dd54e0b..a5d0aa19 100644 --- a/packages/cli/src/commands/split/index.ts +++ b/packages/cli/src/commands/split/index.ts @@ -15,8 +15,8 @@ import { langToExt, writeToFileByExtension, getAndValidateFileExtension, -} from '../../utils'; -import { isString, isObject, isEmptyObject } from '../../js-utils'; +} from '../../utils/miscellaneous'; +import { isString, isObject, isEmptyObject } from '../../utils/js-utils'; import { Definition, Oas2Definition, diff --git a/packages/cli/src/commands/stats.ts b/packages/cli/src/commands/stats.ts index 27104f3d..e37f66c7 100755 --- a/packages/cli/src/commands/stats.ts +++ b/packages/cli/src/commands/stats.ts @@ -13,8 +13,8 @@ import { Stats, bundle, } from '@redocly/openapi-core'; -import { getFallbackApisOrExit } from '../utils'; -import { printExecutionTime } from '../utils'; +import { getFallbackApisOrExit } from '../utils/miscellaneous'; +import { printExecutionTime } from '../utils/miscellaneous'; import type { StatsAccumulator, StatsName, WalkContext, OutputFormat } from '@redocly/openapi-core'; const statsAccumulator: StatsAccumulator = { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index a4d9e874..828fe0c0 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,26 +1,27 @@ #!/usr/bin/env node -import './assert-node-version'; +import './utils/assert-node-version'; import * as yargs from 'yargs'; -import { outputExtensions, regionChoices } from './types'; +import { outputExtensions, PushArguments, regionChoices } from './types'; import { RedoclyClient } from '@redocly/openapi-core'; import { previewDocs } from './commands/preview-docs'; import { handleStats } from './commands/stats'; import { handleSplit } from './commands/split'; import { handleJoin } from './commands/join'; -import { handlePush, transformPush } from './commands/push'; +import { handlePushStatus, PushStatusOptions } from './cms/commands/push-status'; import { handleLint } from './commands/lint'; import { handleBundle } from './commands/bundle'; import { handleLogin } from './commands/login'; import { handlerBuildCommand } from './commands/build-docs'; -import { cacheLatestVersion, notifyUpdateCliVersion } from './update-version-notifier'; +import { cacheLatestVersion, notifyUpdateCliVersion } from './utils/update-version-notifier'; import { commandWrapper } from './wrapper'; -import { version } from './update-version-notifier'; +import { version } from './utils/update-version-notifier'; import type { Arguments } from 'yargs'; import type { OutputFormat, RuleSeverity } from '@redocly/openapi-core'; import type { BuildDocsArgv } from './commands/build-docs/types'; import { previewProject } from './commands/preview-project'; import { PRODUCT_PLANS } from './commands/preview-project/constants'; +import { commonPushHandler } from './commands/push'; if (!('replaceAll' in String.prototype)) { require('core-js/actual/string/replace-all'); @@ -146,23 +147,75 @@ yargs commandWrapper(handleJoin)(argv); } ) - .command( - 'push [api] [maybeDestination] [maybeBranchName]', - 'Push an API description to the Redocly API registry.', + 'push-status [pushId]', + false, (yargs) => yargs - .usage('push [api]') - .positional('api', { type: 'string' }) - .positional('maybeDestination', { type: 'string' }) - .hide('maybeDestination') - .hide('maybeBranchName') + .positional('pushId', { + description: 'Push id.', + type: 'string', + required: true, + }) + .implies('max-execution-time', 'wait') .option({ organization: { description: 'Name of the organization to push to.', type: 'string', alias: 'o', }, + project: { + description: 'Name of the project to push to.', + type: 'string', + alias: 'p', + }, + domain: { description: 'Specify a domain.', alias: 'd', type: 'string' }, + wait: { + description: 'Wait for build to finish.', + type: 'boolean', + default: false, + }, + 'max-execution-time': { + description: 'Maximum execution time in seconds.', + type: 'number', + }, + }), + (argv) => { + process.env.REDOCLY_CLI_COMMAND = 'push-status'; + commandWrapper(handlePushStatus)(argv as Arguments); + } + ) + .command( + 'push [apis...]', + 'Push an API description to the Redocly API registry.', + (yargs) => + yargs + .positional('apis', { + type: 'string', + array: true, + required: true, + default: [], + }) + .hide('project') + .hide('domain') + .hide('mount-path') + .hide('author') + .hide('message') + .hide('default-branch') + .hide('verbose') + .hide('commit-sha') + .hide('commit-url') + .hide('namespace') + .hide('repository') + .hide('wait-for-deployment') + .hide('created-at') + .hide('max-execution-time') + .deprecateOption('batch-id', 'use --job-id') + .implies('job-id', 'batch-size') + .implies('batch-id', 'batch-size') + .implies('batch-size', 'job-id') + .implies('max-execution-time', 'wait-for-deployment') + .option({ destination: { description: 'API name and version in the format `name@version`.', type: 'string', @@ -217,15 +270,80 @@ yargs choices: ['warn', 'error', 'off'] as ReadonlyArray, default: 'warn' as RuleSeverity, }, - }) - .deprecateOption('batch-id', 'use --job-id') - .deprecateOption('maybeDestination') - .implies('job-id', 'batch-size') - .implies('batch-id', 'batch-size') - .implies('batch-size', 'job-id'), + organization: { + description: 'Name of the organization to push to.', + type: 'string', + alias: 'o', + }, + project: { + description: 'Name of the project to push to.', + type: 'string', + alias: 'p', + }, + 'mount-path': { + description: 'The path files should be pushed to.', + type: 'string', + alias: 'mp', + }, + author: { + description: 'Author of the commit.', + type: 'string', + alias: 'a', + }, + message: { + description: 'Commit message.', + type: 'string', + alias: 'm', + }, + 'commit-sha': { + description: 'Commit SHA.', + type: 'string', + alias: 'sha', + }, + 'commit-url': { + description: 'Commit URL.', + type: 'string', + alias: 'url', + }, + namespace: { + description: 'Repository namespace.', + type: 'string', + }, + repository: { + description: 'Repository name.', + type: 'string', + }, + 'created-at': { + description: 'Commit creation date.', + type: 'string', + }, + domain: { description: 'Specify a domain.', alias: 'd', type: 'string' }, + config: { + description: 'Path to the config file.', + requiresArg: true, + type: 'string', + }, + 'default-branch': { + type: 'string', + default: 'main', + }, + 'max-execution-time': { + description: 'Maximum execution time in seconds.', + type: 'number', + }, + 'wait-for-deployment': { + description: 'Wait for build to finish.', + type: 'boolean', + default: false, + }, + verbose: { + type: 'boolean', + default: false, + }, + }), (argv) => { process.env.REDOCLY_CLI_COMMAND = 'push'; - commandWrapper(transformPush(handlePush))(argv); + commandWrapper(commonPushHandler(argv))(argv as PushArguments); } ) .command( diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index c9313f23..7d20912f 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -1,4 +1,5 @@ import type { BundleOutputFormat, Region, Config } from '@redocly/openapi-core'; +import type { ArgumentsCamelCase } from 'yargs'; import type { LintOptions } from './commands/lint'; import type { BundleOptions } from './commands/bundle'; import type { JoinOptions } from './commands/join'; @@ -8,6 +9,9 @@ import type { StatsOptions } from './commands/stats'; import type { SplitOptions } from './commands/split'; import type { PreviewDocsOptions } from './commands/preview-docs'; import type { BuildDocsArgv } from './commands/build-docs/types'; +import type { PushOptions as PushBhOptions } from './cms/commands/push'; +import type { PushStatusOptions } from './cms/commands/push-status'; +import type { PushOptions as CMSPushOptions } from './cms/commands/push'; import type { PreviewProjectOptions } from './commands/preview-project/types'; export type Totals = { @@ -27,11 +31,13 @@ export type CommandOptions = | SplitOptions | JoinOptions | PushOptions + | PushBhOptions | LintOptions | BundleOptions | LoginOptions | PreviewDocsOptions | BuildDocsArgv + | PushStatusOptions | PreviewProjectOptions; export type Skips = { @@ -41,3 +47,5 @@ export type Skips = { }; export type ConfigApis = Pick; + +export type PushArguments = ArgumentsCamelCase; diff --git a/packages/cli/src/__mocks__/utils.ts b/packages/cli/src/utils/__mocks__/miscellaneous.ts similarity index 93% rename from packages/cli/src/__mocks__/utils.ts rename to packages/cli/src/utils/__mocks__/miscellaneous.ts index 48bf562a..1298db94 100644 --- a/packages/cli/src/__mocks__/utils.ts +++ b/packages/cli/src/utils/__mocks__/miscellaneous.ts @@ -1,4 +1,4 @@ -import { ConfigFixture } from '../__tests__/fixtures/config'; +import { ConfigFixture } from '../../__tests__/fixtures/config'; export const getFallbackApisOrExit = jest.fn((entrypoints) => entrypoints.map((path: string) => ({ path })) diff --git a/packages/cli/src/assert-node-version.ts b/packages/cli/src/utils/assert-node-version.ts similarity index 100% rename from packages/cli/src/assert-node-version.ts rename to packages/cli/src/utils/assert-node-version.ts diff --git a/packages/cli/src/fetch-with-timeout.ts b/packages/cli/src/utils/fetch-with-timeout.ts similarity index 60% rename from packages/cli/src/fetch-with-timeout.ts rename to packages/cli/src/utils/fetch-with-timeout.ts index 2a8738dd..9a373e33 100644 --- a/packages/cli/src/fetch-with-timeout.ts +++ b/packages/cli/src/utils/fetch-with-timeout.ts @@ -1,20 +1,15 @@ import nodeFetch from 'node-fetch'; +import AbortController from 'abort-controller'; const TIMEOUT = 3000; export default async (url: string, options = {}) => { try { - if (!global.AbortController) { - return nodeFetch(url, options); - } const controller = new AbortController(); const timeout = setTimeout(() => { controller.abort(); }, TIMEOUT); - // FIXME: fix this (possibly along with this issue: https://github.com/Redocly/redocly-cli/issues/1260) - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore const res = await nodeFetch(url, { signal: controller.signal, ...options }); clearTimeout(timeout); return res; diff --git a/packages/cli/src/js-utils.ts b/packages/cli/src/utils/js-utils.ts similarity index 100% rename from packages/cli/src/js-utils.ts rename to packages/cli/src/utils/js-utils.ts diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils/miscellaneous.ts similarity index 99% rename from packages/cli/src/utils.ts rename to packages/cli/src/utils/miscellaneous.ts index ef8bce1f..495ab526 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils/miscellaneous.ts @@ -30,11 +30,11 @@ import { ConfigApis, CommandOptions, OutputExtensions, -} from './types'; +} from '../types'; import { isEmptyObject } from '@redocly/openapi-core/lib/utils'; import { Arguments } from 'yargs'; import { version } from './update-version-notifier'; -import { DESTINATION_REGEX } from './commands/push'; +import { DESTINATION_REGEX } from '../commands/push'; import { ConfigValidationError } from '@redocly/openapi-core/lib/config'; import type { RawConfigProcessor } from '@redocly/openapi-core/lib/config'; diff --git a/packages/cli/src/utils/spinner.ts b/packages/cli/src/utils/spinner.ts new file mode 100644 index 00000000..d7a7fb8a --- /dev/null +++ b/packages/cli/src/utils/spinner.ts @@ -0,0 +1,50 @@ +import * as process from 'process'; + +const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + +export class Spinner { + private readonly frames: string[]; + private currentFrame: number; + private intervalId: NodeJS.Timeout | null; + private message: string; + + constructor() { + this.frames = SPINNER_FRAMES; + this.currentFrame = 0; + this.intervalId = null; + this.message = ''; + } + + private showFrame() { + process.stdout.write('\r' + this.frames[this.currentFrame] + ' ' + this.message); + this.currentFrame = (this.currentFrame + 1) % this.frames.length; + } + + start(message: string) { + if (this.message === message) { + return; + } + + this.message = message; + // If we're not in a TTY, don't display the spinner. + if (!process.stdout.isTTY) { + process.stdout.write(`${message}...\n`); + return; + } + + if (this.intervalId === null) { + this.intervalId = setInterval(() => { + this.showFrame(); + }, 100); + } + } + + stop() { + if (this.intervalId !== null) { + clearInterval(this.intervalId); + this.intervalId = null; + process.stdout.write('\r'); + } + this.message = ''; + } +} diff --git a/packages/cli/src/update-version-notifier.ts b/packages/cli/src/utils/update-version-notifier.ts similarity index 96% rename from packages/cli/src/update-version-notifier.ts rename to packages/cli/src/utils/update-version-notifier.ts index e6163681..4b92012e 100644 --- a/packages/cli/src/update-version-notifier.ts +++ b/packages/cli/src/utils/update-version-notifier.ts @@ -4,9 +4,9 @@ import { existsSync, writeFileSync, readFileSync, statSync } from 'fs'; import { compare } from 'semver'; import fetch from './fetch-with-timeout'; import { cyan, green, yellow } from 'colorette'; -import { cleanColors } from './utils'; +import { cleanColors } from './miscellaneous'; -export const { version, name } = require('../package.json'); +export const { version, name } = require('../../package.json'); const VERSION_CACHE_FILE = 'redocly-cli-version'; const SPACE_TO_BORDER = 4; diff --git a/packages/cli/src/wrapper.ts b/packages/cli/src/wrapper.ts index 2e1fa85d..4816e503 100644 --- a/packages/cli/src/wrapper.ts +++ b/packages/cli/src/wrapper.ts @@ -1,7 +1,12 @@ import { Config, Region, doesYamlFileExist } from '@redocly/openapi-core'; import type { Arguments } from 'yargs'; -import { version } from './update-version-notifier'; -import { ExitCode, exitWithError, loadConfigAndHandleErrors, sendTelemetry } from './utils'; +import { version } from './utils/update-version-notifier'; +import { + ExitCode, + exitWithError, + loadConfigAndHandleErrors, + sendTelemetry, +} from './utils/miscellaneous'; import { lintConfigCallback } from './commands/lint'; import type { CommandOptions } from './types'; diff --git a/packages/core/src/redocly/index.ts b/packages/core/src/redocly/index.ts index b0571ff5..4b1de8b6 100644 --- a/packages/core/src/redocly/index.ts +++ b/packages/core/src/redocly/index.ts @@ -10,7 +10,7 @@ import { colorize } from '../logger'; import type { AccessTokens, Region } from '../config/types'; -const TOKEN_FILENAME = '.redocly-config.json'; +export const TOKEN_FILENAME = '.redocly-config.json'; export class RedoclyClient { private accessTokens: AccessTokens = {};