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:
Roman Sainchuk
2024-01-26 14:01:48 +02:00
committed by GitHub
parent 0a65c35fd4
commit f070c3d8d4
51 changed files with 1983 additions and 125 deletions

View File

@@ -0,0 +1,5 @@
---
"@redocly/cli": minor
---
Added a `push` and `push-status` command for use with future Redocly products.

View File

@@ -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
View File

@@ -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"
}

View File

@@ -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"
}
}

View File

@@ -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: '',

View File

@@ -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);

View File

@@ -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');

View File

@@ -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',
}));

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);
});
});

View 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);
});
});

View File

@@ -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');

View File

@@ -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(),
}));

View 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.'
);
});
});

View 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'));
});
});
});

View 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');
});
});

View 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;
};

View 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.');
}

View 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;
}

View File

@@ -0,0 +1,3 @@
export * from './api-client';
export * from './domains';
export * from './api-keys';

View 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;

View 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');
});
});

View 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');
});
});

View 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`);
}
}

View 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;
}

View File

@@ -0,0 +1 @@
export class DeploymentError extends Error {}

View File

@@ -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();

View File

@@ -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>,

View File

@@ -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[];

View File

@@ -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,

View File

@@ -17,7 +17,7 @@ import {
printConfigLintTotals,
printLintTotals,
printUnusedWarnings,
} from '../utils';
} from '../utils/miscellaneous';
import { blue, gray } from 'colorette';
import { performance } from 'perf_hooks';

View File

@@ -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(

View File

@@ -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';

View File

@@ -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,

View File

@@ -8,5 +8,5 @@ export type PreviewProjectOptions = {
plan: ProductPlan | string;
port?: number;
'source-dir': string;
config: string | undefined;
config?: string;
};

View File

@@ -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();

View File

@@ -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(),
}));

View File

@@ -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,

View File

@@ -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 = {

View File

@@ -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(

View File

@@ -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[] }>;

View File

@@ -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 }))

View File

@@ -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;

View File

@@ -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';

View 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 = '';
}
}

View File

@@ -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;

View File

@@ -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';

View File

@@ -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 = {};