mirror of
https://github.com/LukeHagar/redocly-cli.git
synced 2025-12-06 04:21:09 +00:00
feat: basic push-status command implementation and extended push command (#1321)
Co-authored-by: IhorKarpiuk <ihor.karpiuk@redocly.com> Co-authored-by: SmoliyY <yevhen.smoliy@gmail.com>
This commit is contained in:
5
.changeset/new-eyes-beam.md
Normal file
5
.changeset/new-eyes-beam.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@redocly/cli": minor
|
||||
---
|
||||
|
||||
Added a `push` and `push-status` command for use with future Redocly products.
|
||||
@@ -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/': {
|
||||
|
||||
99
package-lock.json
generated
99
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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',
|
||||
}));
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
51
packages/cli/src/__tests__/spinner.test.ts
Normal file
51
packages/cli/src/__tests__/spinner.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
37
packages/cli/src/cms/api/__tests__/api-keys.test.ts
Normal file
37
packages/cli/src/cms/api/__tests__/api-keys.test.ts
Normal file
@@ -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.'
|
||||
);
|
||||
});
|
||||
});
|
||||
275
packages/cli/src/cms/api/__tests__/api.client.test.ts
Normal file
275
packages/cli/src/cms/api/__tests__/api.client.test.ts
Normal file
@@ -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<typeof fetch>).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<typeof fetch>).mockImplementationOnce(
|
||||
async (_: any, options: any): Promise<Response> => {
|
||||
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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
15
packages/cli/src/cms/api/__tests__/domains.test.ts
Normal file
15
packages/cli/src/cms/api/__tests__/domains.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
199
packages/cli/src/cms/api/api-client.ts
Normal file
199
packages/cli/src/cms/api/api-client.ts
Normal file
@@ -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<T>(response: Response): Promise<T> {
|
||||
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<ProjectSourceResponse>(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<UpsertRemoteResponse> {
|
||||
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<UpsertRemoteResponse>(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<PushResponse> {
|
||||
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<PushResponse>(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<ListRemotesResponse>(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<PushResponse>(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;
|
||||
};
|
||||
26
packages/cli/src/cms/api/api-keys.ts
Normal file
26
packages/cli/src/cms/api/api-keys.ts
Normal file
@@ -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.');
|
||||
}
|
||||
11
packages/cli/src/cms/api/domains.ts
Normal file
11
packages/cli/src/cms/api/domains.ts
Normal file
@@ -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;
|
||||
}
|
||||
3
packages/cli/src/cms/api/index.ts
Normal file
3
packages/cli/src/cms/api/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './api-client';
|
||||
export * from './domains';
|
||||
export * from './api-keys';
|
||||
101
packages/cli/src/cms/api/types.ts
Normal file
101
packages/cli/src/cms/api/types.ts
Normal file
@@ -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;
|
||||
212
packages/cli/src/cms/commands/__tests__/push-status.test.ts
Normal file
212
packages/cli/src/cms/commands/__tests__/push-status.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
293
packages/cli/src/cms/commands/__tests__/push.test.ts
Normal file
293
packages/cli/src/cms/commands/__tests__/push.test.ts
Normal file
@@ -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 <test-author@mail.com>',
|
||||
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 <test-author@mail.com>',
|
||||
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 <test-author@mail.com>',
|
||||
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 <test-author@mail.com>',
|
||||
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 <test-author@mail.com>',
|
||||
message: 'Test message',
|
||||
files: ['test-file'],
|
||||
'max-execution-time': 10,
|
||||
},
|
||||
mockConfig
|
||||
);
|
||||
|
||||
expect(ReuniteApiClient).toBeCalledWith('test-domain-from-env', 'test-api-key');
|
||||
});
|
||||
});
|
||||
203
packages/cli/src/cms/commands/push-status.ts
Normal file
203
packages/cli/src/cms/commands/push-status.ts
Normal file
@@ -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<PushResponse> {
|
||||
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`);
|
||||
}
|
||||
}
|
||||
215
packages/cli/src/cms/commands/push.ts
Normal file
215
packages/cli/src/cms/commands/push.ts
Normal file
@@ -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 <author@email.com>
|
||||
const reg = /^.+\s<[^<>]+>$/;
|
||||
|
||||
if (!reg.test(author)) {
|
||||
throw new Error('Invalid author format. Use "Author Name <author@email.com>"');
|
||||
}
|
||||
|
||||
const [name, email] = author.split('<');
|
||||
|
||||
return {
|
||||
name: name.trim(),
|
||||
email: email.replace('>', '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function collectFilesToPush(files: string[]): FileToUpload[] {
|
||||
const collectedFiles: Record<string, string> = {};
|
||||
|
||||
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;
|
||||
}
|
||||
1
packages/cli/src/cms/utils.ts
Normal file
1
packages/cli/src/cms/utils.ts
Normal file
@@ -0,0 +1 @@
|
||||
export class DeploymentError extends Error {}
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
printConfigLintTotals,
|
||||
printLintTotals,
|
||||
printUnusedWarnings,
|
||||
} from '../utils';
|
||||
} from '../utils/miscellaneous';
|
||||
import { blue, gray } from 'colorette';
|
||||
import { performance } from 'perf_hooks';
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -8,5 +8,5 @@ export type PreviewProjectOptions = {
|
||||
plan: ProductPlan | string;
|
||||
port?: number;
|
||||
'source-dir': string;
|
||||
config: string | undefined;
|
||||
config?: string;
|
||||
};
|
||||
|
||||
@@ -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<void> {
|
||||
const client = new RedoclyClient(config.region);
|
||||
const isAuthorized = await client.isAuthorizedWithRedoclyByRegion();
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<PushStatusOptions>);
|
||||
}
|
||||
)
|
||||
.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<RuleSeverity>,
|
||||
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(
|
||||
|
||||
@@ -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<Config, 'apis' | 'configFile'>;
|
||||
|
||||
export type PushArguments = ArgumentsCamelCase<PushOptions & CMSPushOptions & { apis: string[] }>;
|
||||
|
||||
@@ -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 }))
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
50
packages/cli/src/utils/spinner.ts
Normal file
50
packages/cli/src/utils/spinner.ts
Normal file
@@ -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 = '';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
Reference in New Issue
Block a user