@redocly/cli 1.29.0 → 1.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -1
- package/README.md +36 -16
- package/lib/__tests__/commands/push-region.test.js +3 -3
- package/lib/auth/__tests__/device-flow.test.js +62 -0
- package/lib/auth/__tests__/oauth-client.test.js +93 -0
- package/lib/auth/device-flow.d.ts +26 -0
- package/lib/auth/device-flow.js +133 -0
- package/lib/auth/oauth-client.d.ts +14 -0
- package/lib/auth/oauth-client.js +93 -0
- package/lib/commands/auth.d.ts +13 -0
- package/lib/commands/auth.js +51 -0
- package/lib/commands/push.d.ts +1 -1
- package/lib/commands/push.js +4 -4
- package/lib/index.js +103 -15
- package/lib/otel.d.ts +10 -0
- package/lib/otel.js +47 -0
- package/lib/reunite/api/__tests__/domains.test.js +32 -0
- package/lib/{cms → reunite}/api/api-client.d.ts +9 -0
- package/lib/{cms → reunite}/api/api-client.js +2 -1
- package/lib/reunite/api/domains.d.ts +4 -0
- package/lib/reunite/api/domains.js +22 -0
- package/lib/reunite/commands/__tests__/push.test.d.ts +1 -0
- package/lib/reunite/commands/__tests__/utils.test.d.ts +1 -0
- package/lib/types.d.ts +5 -4
- package/lib/utils/miscellaneous.d.ts +5 -4
- package/lib/utils/miscellaneous.js +14 -14
- package/package.json +11 -4
- package/src/__tests__/commands/push-region.test.ts +2 -2
- package/src/auth/__tests__/device-flow.test.ts +73 -0
- package/src/auth/__tests__/oauth-client.test.ts +117 -0
- package/src/auth/device-flow.ts +175 -0
- package/src/auth/oauth-client.ts +111 -0
- package/src/commands/auth.ts +66 -0
- package/src/commands/push.ts +3 -3
- package/src/index.ts +115 -16
- package/src/otel.ts +59 -0
- package/src/reunite/api/__tests__/domains.test.ts +41 -0
- package/src/{cms → reunite}/api/api-client.ts +1 -1
- package/src/reunite/api/domains.ts +23 -0
- package/src/types.ts +8 -4
- package/src/utils/miscellaneous.ts +19 -18
- package/tsconfig.json +1 -1
- package/tsconfig.tsbuildinfo +1 -1
- package/lib/cms/api/__tests__/domains.test.js +0 -13
- package/lib/cms/api/domains.d.ts +0 -1
- package/lib/cms/api/domains.js +0 -11
- package/lib/commands/login.d.ts +0 -9
- package/lib/commands/login.js +0 -23
- package/src/cms/api/__tests__/domains.test.ts +0 -15
- package/src/cms/api/domains.ts +0 -11
- package/src/commands/login.ts +0 -34
- /package/lib/{cms/api/__tests__/api-keys.test.d.ts → auth/__tests__/device-flow.test.d.ts} +0 -0
- /package/lib/{cms/api/__tests__/api.client.test.d.ts → auth/__tests__/oauth-client.test.d.ts} +0 -0
- /package/lib/{cms/api/__tests__/domains.test.d.ts → reunite/api/__tests__/api-keys.test.d.ts} +0 -0
- /package/lib/{cms → reunite}/api/__tests__/api-keys.test.js +0 -0
- /package/lib/{cms/commands/__tests__/push-status.test.d.ts → reunite/api/__tests__/api.client.test.d.ts} +0 -0
- /package/lib/{cms → reunite}/api/__tests__/api.client.test.js +0 -0
- /package/lib/{cms/commands/__tests__/push.test.d.ts → reunite/api/__tests__/domains.test.d.ts} +0 -0
- /package/lib/{cms → reunite}/api/api-keys.d.ts +0 -0
- /package/lib/{cms → reunite}/api/api-keys.js +0 -0
- /package/lib/{cms → reunite}/api/index.d.ts +0 -0
- /package/lib/{cms → reunite}/api/index.js +0 -0
- /package/lib/{cms → reunite}/api/types.d.ts +0 -0
- /package/lib/{cms → reunite}/api/types.js +0 -0
- /package/lib/{cms/commands/__tests__/utils.test.d.ts → reunite/commands/__tests__/push-status.test.d.ts} +0 -0
- /package/lib/{cms → reunite}/commands/__tests__/push-status.test.js +0 -0
- /package/lib/{cms → reunite}/commands/__tests__/push.test.js +0 -0
- /package/lib/{cms → reunite}/commands/__tests__/utils.test.js +0 -0
- /package/lib/{cms → reunite}/commands/push-status.d.ts +0 -0
- /package/lib/{cms → reunite}/commands/push-status.js +0 -0
- /package/lib/{cms → reunite}/commands/push.d.ts +0 -0
- /package/lib/{cms → reunite}/commands/push.js +0 -0
- /package/lib/{cms → reunite}/commands/utils.d.ts +0 -0
- /package/lib/{cms → reunite}/commands/utils.js +0 -0
- /package/lib/{cms → reunite}/utils.d.ts +0 -0
- /package/lib/{cms → reunite}/utils.js +0 -0
- /package/src/{cms → reunite}/api/__tests__/api-keys.test.ts +0 -0
- /package/src/{cms → reunite}/api/__tests__/api.client.test.ts +0 -0
- /package/src/{cms → reunite}/api/api-keys.ts +0 -0
- /package/src/{cms → reunite}/api/index.ts +0 -0
- /package/src/{cms → reunite}/api/types.ts +0 -0
- /package/src/{cms → reunite}/commands/__tests__/push-status.test.ts +0 -0
- /package/src/{cms → reunite}/commands/__tests__/push.test.ts +0 -0
- /package/src/{cms → reunite}/commands/__tests__/utils.test.ts +0 -0
- /package/src/{cms → reunite}/commands/push-status.ts +0 -0
- /package/src/{cms → reunite}/commands/push.ts +0 -0
- /package/src/{cms → reunite}/commands/utils.ts +0 -0
- /package/src/{cms → reunite}/utils.ts +0 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { RedoclyOAuthClient } from '../oauth-client';
|
|
2
|
+
import { RedoclyOAuthDeviceFlow } from '../device-flow';
|
|
3
|
+
import * as fs from 'node:fs';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import * as os from 'node:os';
|
|
6
|
+
|
|
7
|
+
jest.mock('node:fs');
|
|
8
|
+
jest.mock('node:os');
|
|
9
|
+
jest.mock('../device-flow');
|
|
10
|
+
|
|
11
|
+
describe('RedoclyOAuthClient', () => {
|
|
12
|
+
const mockClientName = 'test-client';
|
|
13
|
+
const mockVersion = '1.0.0';
|
|
14
|
+
const mockBaseUrl = 'https://test.redocly.com';
|
|
15
|
+
const mockHomeDir = '/mock/home/dir';
|
|
16
|
+
const mockRedoclyDir = path.join(mockHomeDir, '.redocly');
|
|
17
|
+
let client: RedoclyOAuthClient;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
jest.resetAllMocks();
|
|
21
|
+
(os.homedir as jest.Mock).mockReturnValue(mockHomeDir);
|
|
22
|
+
process.env.HOME = mockHomeDir;
|
|
23
|
+
client = new RedoclyOAuthClient(mockClientName, mockVersion);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('login', () => {
|
|
27
|
+
it('successfully logs in and saves token', async () => {
|
|
28
|
+
const mockToken = { access_token: 'test-token' };
|
|
29
|
+
const mockDeviceFlow = {
|
|
30
|
+
run: jest.fn().mockResolvedValue(mockToken),
|
|
31
|
+
};
|
|
32
|
+
(RedoclyOAuthDeviceFlow as jest.Mock).mockImplementation(() => mockDeviceFlow);
|
|
33
|
+
|
|
34
|
+
await client.login(mockBaseUrl);
|
|
35
|
+
|
|
36
|
+
expect(mockDeviceFlow.run).toHaveBeenCalled();
|
|
37
|
+
expect(fs.writeFileSync).toHaveBeenCalled();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('throws error when login fails', async () => {
|
|
41
|
+
const mockDeviceFlow = {
|
|
42
|
+
run: jest.fn().mockResolvedValue(null),
|
|
43
|
+
};
|
|
44
|
+
(RedoclyOAuthDeviceFlow as jest.Mock).mockImplementation(() => mockDeviceFlow);
|
|
45
|
+
|
|
46
|
+
await expect(client.login(mockBaseUrl)).rejects.toThrow('Failed to login');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('logout', () => {
|
|
51
|
+
it('removes token file if it exists', async () => {
|
|
52
|
+
(fs.existsSync as jest.Mock).mockReturnValue(true);
|
|
53
|
+
|
|
54
|
+
await client.logout();
|
|
55
|
+
|
|
56
|
+
expect(fs.rmSync).toHaveBeenCalledWith(path.join(mockRedoclyDir, 'auth.json'));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('silently fails if token file does not exist', async () => {
|
|
60
|
+
(fs.existsSync as jest.Mock).mockReturnValue(false);
|
|
61
|
+
|
|
62
|
+
await expect(client.logout()).resolves.not.toThrow();
|
|
63
|
+
expect(fs.rmSync).not.toHaveBeenCalled();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('isAuthorized', () => {
|
|
68
|
+
it('verifies API key if provided', async () => {
|
|
69
|
+
const mockDeviceFlow = {
|
|
70
|
+
verifyApiKey: jest.fn().mockResolvedValue(true),
|
|
71
|
+
};
|
|
72
|
+
(RedoclyOAuthDeviceFlow as jest.Mock).mockImplementation(() => mockDeviceFlow);
|
|
73
|
+
|
|
74
|
+
const result = await client.isAuthorized(mockBaseUrl, 'test-api-key');
|
|
75
|
+
|
|
76
|
+
expect(result).toBe(true);
|
|
77
|
+
expect(mockDeviceFlow.verifyApiKey).toHaveBeenCalledWith('test-api-key');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('verifies access token if no API key provided', async () => {
|
|
81
|
+
const mockToken = { access_token: 'test-token' };
|
|
82
|
+
const mockDeviceFlow = {
|
|
83
|
+
verifyToken: jest.fn().mockResolvedValue(true),
|
|
84
|
+
};
|
|
85
|
+
(RedoclyOAuthDeviceFlow as jest.Mock).mockImplementation(() => mockDeviceFlow);
|
|
86
|
+
(fs.readFileSync as jest.Mock).mockReturnValue(
|
|
87
|
+
client['cipher'].update(JSON.stringify(mockToken), 'utf8', 'hex') +
|
|
88
|
+
client['cipher'].final('hex')
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const result = await client.isAuthorized(mockBaseUrl);
|
|
92
|
+
|
|
93
|
+
expect(result).toBe(true);
|
|
94
|
+
expect(mockDeviceFlow.verifyToken).toHaveBeenCalledWith('test-token');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('returns false if token refresh fails', async () => {
|
|
98
|
+
const mockToken = {
|
|
99
|
+
access_token: 'old-token',
|
|
100
|
+
refresh_token: 'refresh-token',
|
|
101
|
+
};
|
|
102
|
+
const mockDeviceFlow = {
|
|
103
|
+
verifyToken: jest.fn().mockResolvedValue(false),
|
|
104
|
+
refreshToken: jest.fn().mockRejectedValue(new Error('Refresh failed')),
|
|
105
|
+
};
|
|
106
|
+
(RedoclyOAuthDeviceFlow as jest.Mock).mockImplementation(() => mockDeviceFlow);
|
|
107
|
+
(fs.readFileSync as jest.Mock).mockReturnValue(
|
|
108
|
+
client['cipher'].update(JSON.stringify(mockToken), 'utf8', 'hex') +
|
|
109
|
+
client['cipher'].final('hex')
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const result = await client.isAuthorized(mockBaseUrl);
|
|
113
|
+
|
|
114
|
+
expect(result).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { blue, green } from 'colorette';
|
|
2
|
+
import * as childProcess from 'child_process';
|
|
3
|
+
import { ReuniteApiClient } from '../reunite/api/api-client';
|
|
4
|
+
|
|
5
|
+
export type AuthToken = {
|
|
6
|
+
access_token: string;
|
|
7
|
+
refresh_token?: string;
|
|
8
|
+
token_type?: string;
|
|
9
|
+
expires_in?: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export class RedoclyOAuthDeviceFlow {
|
|
13
|
+
private apiClient: ReuniteApiClient;
|
|
14
|
+
|
|
15
|
+
constructor(private baseUrl: string, private clientName: string, private version: string) {
|
|
16
|
+
this.apiClient = new ReuniteApiClient(this.version, 'login');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async run() {
|
|
20
|
+
const code = await this.getDeviceCode();
|
|
21
|
+
process.stdout.write(
|
|
22
|
+
'Attempting to automatically open the SSO authorization page in your default browser.\n'
|
|
23
|
+
);
|
|
24
|
+
process.stdout.write(
|
|
25
|
+
'If the browser does not open or you wish to use a different device to authorize this request, open the following URL:\n\n'
|
|
26
|
+
);
|
|
27
|
+
process.stdout.write(blue(code.verificationUri));
|
|
28
|
+
process.stdout.write(`\n\n`);
|
|
29
|
+
process.stdout.write(`Then enter the code:\n\n`);
|
|
30
|
+
process.stdout.write(blue(code.userCode));
|
|
31
|
+
process.stdout.write(`\n\n`);
|
|
32
|
+
|
|
33
|
+
this.openBrowser(code.verificationUriComplete);
|
|
34
|
+
|
|
35
|
+
const accessToken = await this.pollingAccessToken(
|
|
36
|
+
code.deviceCode,
|
|
37
|
+
code.interval,
|
|
38
|
+
code.expiresIn
|
|
39
|
+
);
|
|
40
|
+
process.stdout.write(green('✅ Logged in\n\n'));
|
|
41
|
+
|
|
42
|
+
return accessToken;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private openBrowser(url: string) {
|
|
46
|
+
try {
|
|
47
|
+
const cmd =
|
|
48
|
+
process.platform === 'win32'
|
|
49
|
+
? `start ${url}`
|
|
50
|
+
: process.platform === 'darwin'
|
|
51
|
+
? `open ${url}`
|
|
52
|
+
: `xdg-open ${url}`;
|
|
53
|
+
|
|
54
|
+
childProcess.execSync(cmd);
|
|
55
|
+
} catch {
|
|
56
|
+
// silently fail if browser cannot be opened
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async verifyToken(accessToken: string) {
|
|
61
|
+
try {
|
|
62
|
+
const response = await this.sendRequest('/session', 'GET', undefined, {
|
|
63
|
+
Cookie: `accessToken=${accessToken};`,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return !!response.user;
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async verifyApiKey(apiKey: string) {
|
|
73
|
+
try {
|
|
74
|
+
const response = await this.sendRequest('/api-keys-verify', 'POST', {
|
|
75
|
+
apiKey,
|
|
76
|
+
});
|
|
77
|
+
return !!response.success;
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async refreshToken(refreshToken: string) {
|
|
84
|
+
const response = await this.sendRequest(`/device-rotate-token`, 'POST', {
|
|
85
|
+
grant_type: 'refresh_token',
|
|
86
|
+
client_name: this.clientName,
|
|
87
|
+
refresh_token: refreshToken,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
if (!response.access_token) {
|
|
91
|
+
throw new Error('Failed to refresh token');
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
access_token: response.access_token,
|
|
95
|
+
refresh_token: response.refresh_token,
|
|
96
|
+
expires_in: response.expires_in,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private async pollingAccessToken(
|
|
101
|
+
deviceCode: string,
|
|
102
|
+
interval: number,
|
|
103
|
+
expiresIn: number
|
|
104
|
+
): Promise<AuthToken> {
|
|
105
|
+
return new Promise((resolve, reject) => {
|
|
106
|
+
const intervalId = setInterval(async () => {
|
|
107
|
+
const response = await this.getAccessToken(deviceCode);
|
|
108
|
+
if (response.access_token) {
|
|
109
|
+
clearInterval(intervalId);
|
|
110
|
+
clearTimeout(timeoutId);
|
|
111
|
+
resolve(response);
|
|
112
|
+
}
|
|
113
|
+
if (response.error && response.error !== 'authorization_pending') {
|
|
114
|
+
clearInterval(intervalId);
|
|
115
|
+
clearTimeout(timeoutId);
|
|
116
|
+
reject(response.error_description);
|
|
117
|
+
}
|
|
118
|
+
}, interval * 1000);
|
|
119
|
+
|
|
120
|
+
const timeoutId = setTimeout(async () => {
|
|
121
|
+
clearInterval(intervalId);
|
|
122
|
+
clearTimeout(timeoutId);
|
|
123
|
+
reject('Authorization has expired. Please try again.');
|
|
124
|
+
}, expiresIn * 1000);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private async getAccessToken(deviceCode: string) {
|
|
129
|
+
return await this.sendRequest('/device-token', 'POST', {
|
|
130
|
+
client_name: this.clientName,
|
|
131
|
+
device_code: deviceCode,
|
|
132
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private async getDeviceCode() {
|
|
137
|
+
const {
|
|
138
|
+
device_code: deviceCode,
|
|
139
|
+
user_code: userCode,
|
|
140
|
+
verification_uri: verificationUri,
|
|
141
|
+
verification_uri_complete: verificationUriComplete,
|
|
142
|
+
interval = 10,
|
|
143
|
+
expires_in: expiresIn = 300,
|
|
144
|
+
} = await this.sendRequest('/device-authorize', 'POST', {
|
|
145
|
+
client_name: this.clientName,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
deviceCode,
|
|
150
|
+
userCode,
|
|
151
|
+
verificationUri,
|
|
152
|
+
verificationUriComplete,
|
|
153
|
+
interval,
|
|
154
|
+
expiresIn,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private async sendRequest(
|
|
159
|
+
url: string,
|
|
160
|
+
method: string = 'GET',
|
|
161
|
+
body: Record<string, unknown> | undefined = undefined,
|
|
162
|
+
headers: Record<string, string> = {}
|
|
163
|
+
) {
|
|
164
|
+
url = `${this.baseUrl}${url}`;
|
|
165
|
+
const response = await this.apiClient.request(url, {
|
|
166
|
+
body: body ? JSON.stringify(body) : body,
|
|
167
|
+
method,
|
|
168
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
|
169
|
+
});
|
|
170
|
+
if (response.status === 204) {
|
|
171
|
+
return { success: true };
|
|
172
|
+
}
|
|
173
|
+
return await response.json();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { mkdirSync, existsSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
|
|
4
|
+
import * as crypto from 'node:crypto';
|
|
5
|
+
import { Buffer } from 'node:buffer';
|
|
6
|
+
import { type AuthToken, RedoclyOAuthDeviceFlow } from './device-flow';
|
|
7
|
+
|
|
8
|
+
const SALT = '4618dbc9-8aed-4e27-aaf0-225f4603e5a4';
|
|
9
|
+
const CRYPTO_ALGORITHM = 'aes-256-cbc';
|
|
10
|
+
|
|
11
|
+
export class RedoclyOAuthClient {
|
|
12
|
+
private dir: string;
|
|
13
|
+
private cipher: crypto.Cipher;
|
|
14
|
+
private decipher: crypto.Decipher;
|
|
15
|
+
|
|
16
|
+
constructor(private clientName: string, private version: string) {
|
|
17
|
+
this.dir = path.join(homedir(), '.redocly');
|
|
18
|
+
if (!existsSync(this.dir)) {
|
|
19
|
+
mkdirSync(this.dir);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const homeDirPath = process.env.HOME as string;
|
|
23
|
+
const hash = crypto.createHash('sha256');
|
|
24
|
+
hash.update(`${homeDirPath}${SALT}`);
|
|
25
|
+
const hashHex = hash.digest('hex');
|
|
26
|
+
|
|
27
|
+
const key = Buffer.alloc(
|
|
28
|
+
32,
|
|
29
|
+
Buffer.from(hashHex).toString('base64')
|
|
30
|
+
).toString() as crypto.CipherKey;
|
|
31
|
+
const iv = Buffer.alloc(
|
|
32
|
+
16,
|
|
33
|
+
Buffer.from(process.env.HOME as string).toString('base64')
|
|
34
|
+
).toString() as crypto.BinaryLike;
|
|
35
|
+
this.cipher = crypto.createCipheriv(CRYPTO_ALGORITHM, key, iv);
|
|
36
|
+
this.decipher = crypto.createDecipheriv(CRYPTO_ALGORITHM, key, iv);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async login(baseUrl: string) {
|
|
40
|
+
const deviceFlow = new RedoclyOAuthDeviceFlow(baseUrl, this.clientName, this.version);
|
|
41
|
+
|
|
42
|
+
const token = await deviceFlow.run();
|
|
43
|
+
if (!token) {
|
|
44
|
+
throw new Error('Failed to login');
|
|
45
|
+
}
|
|
46
|
+
this.saveToken(token);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async logout() {
|
|
50
|
+
try {
|
|
51
|
+
this.removeToken();
|
|
52
|
+
} catch (err) {
|
|
53
|
+
// do nothing
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async isAuthorized(baseUrl: string, apiKey?: string) {
|
|
58
|
+
const deviceFlow = new RedoclyOAuthDeviceFlow(baseUrl, this.clientName, this.version);
|
|
59
|
+
|
|
60
|
+
if (apiKey) {
|
|
61
|
+
return await deviceFlow.verifyApiKey(apiKey);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const token = await this.readToken();
|
|
65
|
+
if (!token) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const isValidAccessToken = await deviceFlow.verifyToken(token.access_token);
|
|
70
|
+
|
|
71
|
+
if (isValidAccessToken) {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const newToken = await deviceFlow.refreshToken(token.refresh_token);
|
|
77
|
+
await this.saveToken(newToken);
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private async saveToken(token: AuthToken) {
|
|
86
|
+
try {
|
|
87
|
+
const encrypted =
|
|
88
|
+
this.cipher.update(JSON.stringify(token), 'utf8', 'hex') + this.cipher.final('hex');
|
|
89
|
+
writeFileSync(path.join(this.dir, 'auth.json'), encrypted);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
process.stderr.write('Error saving tokens:', error);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private async readToken() {
|
|
96
|
+
try {
|
|
97
|
+
const token = readFileSync(path.join(this.dir, 'auth.json'), 'utf8');
|
|
98
|
+
const decrypted = this.decipher.update(token, 'hex', 'utf8') + this.decipher.final('utf8');
|
|
99
|
+
return decrypted ? JSON.parse(decrypted) : null;
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private async removeToken() {
|
|
106
|
+
const tokenPath = path.join(this.dir, 'auth.json');
|
|
107
|
+
if (existsSync(tokenPath)) {
|
|
108
|
+
rmSync(tokenPath);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { blue, green, gray, yellow } from 'colorette';
|
|
2
|
+
import { RedoclyClient } from '@redocly/openapi-core';
|
|
3
|
+
import { exitWithError, promptUser } from '../utils/miscellaneous';
|
|
4
|
+
import { RedoclyOAuthClient } from '../auth/oauth-client';
|
|
5
|
+
import { getReuniteUrl } from '../reunite/api';
|
|
6
|
+
|
|
7
|
+
import type { CommandArgs } from '../wrapper';
|
|
8
|
+
import type { Region } from '@redocly/openapi-core';
|
|
9
|
+
|
|
10
|
+
export function promptClientToken(domain: string) {
|
|
11
|
+
return promptUser(
|
|
12
|
+
green(
|
|
13
|
+
`\n 🔑 Copy your API key from ${blue(`https://app.${domain}/profile`)} and paste it below`
|
|
14
|
+
) + yellow(' (if you want to log in with Reunite, please run `redocly login --next` instead)'),
|
|
15
|
+
true
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type LoginOptions = {
|
|
20
|
+
verbose?: boolean;
|
|
21
|
+
residency?: string;
|
|
22
|
+
config?: string;
|
|
23
|
+
next?: boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export async function handleLogin({ argv, config, version }: CommandArgs<LoginOptions>) {
|
|
27
|
+
if (argv.next) {
|
|
28
|
+
try {
|
|
29
|
+
const reuniteUrl = getReuniteUrl(argv.residency);
|
|
30
|
+
const oauthClient = new RedoclyOAuthClient('redocly-cli', version);
|
|
31
|
+
await oauthClient.login(reuniteUrl);
|
|
32
|
+
} catch {
|
|
33
|
+
if (argv.residency) {
|
|
34
|
+
const reuniteUrl = getReuniteUrl(argv.residency);
|
|
35
|
+
exitWithError(`❌ Connection to ${reuniteUrl} failed.`);
|
|
36
|
+
} else {
|
|
37
|
+
exitWithError(`❌ Login failed. Please check your credentials and try again.`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
} else {
|
|
41
|
+
try {
|
|
42
|
+
const region = (argv.residency as Region) || config.region;
|
|
43
|
+
const client = new RedoclyClient(region);
|
|
44
|
+
const clientToken = await promptClientToken(client.domain);
|
|
45
|
+
process.stdout.write(gray('\n Logging in...\n'));
|
|
46
|
+
await client.login(clientToken, argv.verbose);
|
|
47
|
+
process.stdout.write(green(' Authorization confirmed. ✅\n\n'));
|
|
48
|
+
} catch (err) {
|
|
49
|
+
exitWithError(' ' + err?.message);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type LogoutOptions = {
|
|
55
|
+
config?: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export async function handleLogout({ version }: CommandArgs<LogoutOptions>) {
|
|
59
|
+
const client = new RedoclyClient();
|
|
60
|
+
client.logout();
|
|
61
|
+
|
|
62
|
+
const oauthClient = new RedoclyOAuthClient('redocly-cli', version);
|
|
63
|
+
oauthClient.logout();
|
|
64
|
+
|
|
65
|
+
process.stdout.write('Logged out from the Redocly account. ✋ \n');
|
|
66
|
+
}
|
package/src/commands/push.ts
CHANGED
|
@@ -19,9 +19,9 @@ import {
|
|
|
19
19
|
getFallbackApisOrExit,
|
|
20
20
|
dumpBundle,
|
|
21
21
|
} from '../utils/miscellaneous';
|
|
22
|
-
import { promptClientToken } from './
|
|
23
|
-
import { handlePush as handleCMSPush } from '../
|
|
24
|
-
import { streamToBuffer } from '../
|
|
22
|
+
import { promptClientToken } from './auth';
|
|
23
|
+
import { handlePush as handleCMSPush } from '../reunite/commands/push';
|
|
24
|
+
import { streamToBuffer } from '../reunite/api/api-client';
|
|
25
25
|
|
|
26
26
|
import type { Readable } from 'node:stream';
|
|
27
27
|
import type { Agent } from 'node:http';
|
package/src/index.ts
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as dotenv from 'dotenv';
|
|
3
4
|
import './utils/assert-node-version';
|
|
4
5
|
import * as yargs from 'yargs';
|
|
5
6
|
import * as colors from 'colorette';
|
|
6
|
-
import { RedoclyClient } from '@redocly/openapi-core';
|
|
7
7
|
import { outputExtensions, regionChoices } from './types';
|
|
8
8
|
import { previewDocs } from './commands/preview-docs';
|
|
9
9
|
import { handleStats } from './commands/stats';
|
|
10
10
|
import { handleSplit } from './commands/split';
|
|
11
11
|
import { handleJoin } from './commands/join';
|
|
12
|
-
import { handlePushStatus } from './
|
|
12
|
+
import { handlePushStatus } from './reunite/commands/push-status';
|
|
13
13
|
import { handleLint } from './commands/lint';
|
|
14
14
|
import { handleBundle } from './commands/bundle';
|
|
15
|
-
import { handleLogin } from './commands/
|
|
15
|
+
import { handleLogin, handleLogout } from './commands/auth';
|
|
16
16
|
import { handlerBuildCommand } from './commands/build-docs';
|
|
17
17
|
import {
|
|
18
18
|
cacheLatestVersion,
|
|
@@ -28,11 +28,14 @@ import { commonPushHandler } from './commands/push';
|
|
|
28
28
|
|
|
29
29
|
import type { Arguments } from 'yargs';
|
|
30
30
|
import type { OutputFormat, RuleSeverity } from '@redocly/openapi-core';
|
|
31
|
+
import type { GenerateArazzoFileOptions, RespectOptions } from '@redocly/respect-core';
|
|
31
32
|
import type { BuildDocsArgv } from './commands/build-docs/types';
|
|
32
|
-
import type { PushStatusOptions } from './
|
|
33
|
+
import type { PushStatusOptions } from './reunite/commands/push-status';
|
|
33
34
|
import type { PushArguments } from './types';
|
|
34
35
|
import type { EjectOptions } from './commands/eject';
|
|
35
36
|
|
|
37
|
+
dotenv.config({ path: path.resolve(process.cwd(), './.env') });
|
|
38
|
+
|
|
36
39
|
if (!('replaceAll' in String.prototype)) {
|
|
37
40
|
require('core-js/actual/string/replace-all');
|
|
38
41
|
}
|
|
@@ -605,23 +608,27 @@ yargs
|
|
|
605
608
|
)
|
|
606
609
|
.command(
|
|
607
610
|
'login',
|
|
608
|
-
'
|
|
611
|
+
'Log in to Redocly.',
|
|
609
612
|
async (yargs) =>
|
|
610
613
|
yargs.options({
|
|
611
614
|
verbose: {
|
|
612
615
|
description: 'Include additional output.',
|
|
613
616
|
type: 'boolean',
|
|
614
617
|
},
|
|
615
|
-
|
|
616
|
-
description: '
|
|
617
|
-
alias: 'r',
|
|
618
|
-
|
|
618
|
+
residency: {
|
|
619
|
+
description: 'Residency of the application. Defaults to `us`.',
|
|
620
|
+
alias: ['r', 'region'],
|
|
621
|
+
type: 'string',
|
|
619
622
|
},
|
|
620
623
|
config: {
|
|
621
624
|
description: 'Path to the config file.',
|
|
622
625
|
requiresArg: true,
|
|
623
626
|
type: 'string',
|
|
624
627
|
},
|
|
628
|
+
next: {
|
|
629
|
+
description: 'Use Reunite application to login.',
|
|
630
|
+
type: 'boolean',
|
|
631
|
+
},
|
|
625
632
|
}),
|
|
626
633
|
(argv) => {
|
|
627
634
|
process.env.REDOCLY_CLI_COMMAND = 'login';
|
|
@@ -632,13 +639,9 @@ yargs
|
|
|
632
639
|
'logout',
|
|
633
640
|
'Clear your stored credentials for the Redocly API registry.',
|
|
634
641
|
(yargs) => yargs,
|
|
635
|
-
|
|
642
|
+
(argv) => {
|
|
636
643
|
process.env.REDOCLY_CLI_COMMAND = 'logout';
|
|
637
|
-
|
|
638
|
-
const client = new RedoclyClient();
|
|
639
|
-
client.logout();
|
|
640
|
-
process.stdout.write('Logged out from the Redocly account. ✋\n');
|
|
641
|
-
})(argv);
|
|
644
|
+
commandWrapper(handleLogout)(argv);
|
|
642
645
|
}
|
|
643
646
|
)
|
|
644
647
|
.command(
|
|
@@ -864,6 +867,102 @@ yargs
|
|
|
864
867
|
commandWrapper(handleEject)(argv as Arguments<EjectOptions>);
|
|
865
868
|
}
|
|
866
869
|
)
|
|
870
|
+
.command(
|
|
871
|
+
'respect [files...]',
|
|
872
|
+
'Run Arazzo tests.',
|
|
873
|
+
(yargs) => {
|
|
874
|
+
return yargs
|
|
875
|
+
.positional('files', {
|
|
876
|
+
describe: 'Test files or glob pattern.',
|
|
877
|
+
type: 'string',
|
|
878
|
+
array: true,
|
|
879
|
+
default: [],
|
|
880
|
+
})
|
|
881
|
+
.env('REDOCLY_CLI_RESPECT')
|
|
882
|
+
.options({
|
|
883
|
+
input: {
|
|
884
|
+
alias: 'i',
|
|
885
|
+
describe: 'Input parameters.',
|
|
886
|
+
type: 'string',
|
|
887
|
+
},
|
|
888
|
+
server: {
|
|
889
|
+
alias: 'S',
|
|
890
|
+
describe: 'Server parameters.',
|
|
891
|
+
type: 'string',
|
|
892
|
+
},
|
|
893
|
+
workflow: {
|
|
894
|
+
alias: 'w',
|
|
895
|
+
describe: 'Workflow name.',
|
|
896
|
+
type: 'string',
|
|
897
|
+
array: true,
|
|
898
|
+
},
|
|
899
|
+
skip: {
|
|
900
|
+
alias: 's',
|
|
901
|
+
describe: 'Workflow to skip.',
|
|
902
|
+
type: 'string',
|
|
903
|
+
array: true,
|
|
904
|
+
},
|
|
905
|
+
verbose: {
|
|
906
|
+
alias: 'v',
|
|
907
|
+
describe: 'Apply verbose mode.',
|
|
908
|
+
type: 'boolean',
|
|
909
|
+
},
|
|
910
|
+
'har-output': {
|
|
911
|
+
describe: 'Har file output name.',
|
|
912
|
+
type: 'string',
|
|
913
|
+
},
|
|
914
|
+
'json-output': {
|
|
915
|
+
describe: 'JSON file output name.',
|
|
916
|
+
type: 'string',
|
|
917
|
+
},
|
|
918
|
+
'client-cert': {
|
|
919
|
+
describe: 'Mutual TLS client certificate.',
|
|
920
|
+
type: 'string',
|
|
921
|
+
},
|
|
922
|
+
'client-key': {
|
|
923
|
+
describe: 'Mutual TLS client key.',
|
|
924
|
+
type: 'string',
|
|
925
|
+
},
|
|
926
|
+
'ca-cert': {
|
|
927
|
+
describe: 'Mutual TLS CA certificate.',
|
|
928
|
+
type: 'string',
|
|
929
|
+
},
|
|
930
|
+
severity: {
|
|
931
|
+
describe: 'Severity of the check.',
|
|
932
|
+
type: 'string',
|
|
933
|
+
},
|
|
934
|
+
});
|
|
935
|
+
},
|
|
936
|
+
async (argv) => {
|
|
937
|
+
process.env.REDOCLY_CLI_COMMAND = 'respect';
|
|
938
|
+
const { handleRun } = await import('@redocly/respect-core');
|
|
939
|
+
commandWrapper(handleRun)(argv as Arguments<RespectOptions>);
|
|
940
|
+
}
|
|
941
|
+
)
|
|
942
|
+
.command(
|
|
943
|
+
'generate-arazzo <descriptionPath>',
|
|
944
|
+
'Auto-generate arazzo description file from an API description.',
|
|
945
|
+
(yargs) => {
|
|
946
|
+
return yargs
|
|
947
|
+
.positional('descriptionPath', {
|
|
948
|
+
describe: 'Description file path.',
|
|
949
|
+
type: 'string',
|
|
950
|
+
})
|
|
951
|
+
.env('REDOCLY_CLI_RESPECT')
|
|
952
|
+
.options({
|
|
953
|
+
'output-file': {
|
|
954
|
+
alias: 'o',
|
|
955
|
+
describe: 'Output File name.',
|
|
956
|
+
type: 'string',
|
|
957
|
+
},
|
|
958
|
+
});
|
|
959
|
+
},
|
|
960
|
+
async (argv) => {
|
|
961
|
+
process.env.REDOCLY_CLI_COMMAND = 'generate-arazzo';
|
|
962
|
+
const { handleGenerate } = await import('@redocly/respect-core');
|
|
963
|
+
commandWrapper(handleGenerate)(argv as Arguments<GenerateArazzoFileOptions>);
|
|
964
|
+
}
|
|
965
|
+
)
|
|
867
966
|
.completion('completion', 'Generate autocomplete script for `redocly` command.')
|
|
868
967
|
.demandCommand(1)
|
|
869
968
|
.middleware([notifyUpdateCliVersion])
|