@nocobase/cli 2.1.0-alpha.16 → 2.1.0-alpha.17
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/LICENSE.txt +107 -0
- package/README.md +134 -63
- package/bin/run.cmd +3 -0
- package/bin/run.js +87 -0
- package/dist/commands/api/index.js +8 -0
- package/dist/commands/env/add.js +53 -0
- package/dist/commands/env/auth.js +36 -0
- package/dist/commands/env/index.js +27 -0
- package/dist/commands/env/list.js +31 -0
- package/dist/commands/env/remove.js +54 -0
- package/dist/commands/env/update.js +58 -0
- package/dist/commands/env/use.js +26 -0
- package/dist/commands/resource/create.js +15 -0
- package/dist/commands/resource/destroy.js +15 -0
- package/dist/commands/resource/get.js +15 -0
- package/dist/commands/resource/index.js +7 -0
- package/dist/commands/resource/list.js +16 -0
- package/dist/commands/resource/query.js +15 -0
- package/dist/commands/resource/update.js +15 -0
- package/dist/generated/command-registry.js +88 -0
- package/dist/lib/api-client.js +199 -0
- package/dist/lib/auth-store.js +155 -0
- package/dist/lib/bootstrap.js +349 -0
- package/dist/lib/build-config.js +10 -0
- package/dist/lib/cli-home.js +30 -0
- package/dist/lib/env-auth.js +405 -0
- package/dist/lib/generated-command.js +142 -0
- package/dist/lib/naming.js +70 -0
- package/dist/lib/openapi.js +254 -0
- package/dist/lib/post-processors.js +23 -0
- package/dist/lib/resource-command.js +335 -0
- package/dist/lib/resource-request.js +104 -0
- package/dist/lib/runtime-generator.js +408 -0
- package/dist/lib/runtime-store.js +56 -0
- package/dist/lib/ui.js +169 -0
- package/dist/post-processors/data-modeling.js +66 -0
- package/dist/post-processors/data-source-manager.js +114 -0
- package/dist/post-processors/index.js +19 -0
- package/nocobase-ctl.config.json +327 -0
- package/package.json +50 -25
- package/LICENSE +0 -201
- package/bin/index.js +0 -39
- package/nocobase.conf.tpl +0 -184
- package/src/cli.js +0 -28
- package/src/commands/benchmark.js +0 -73
- package/src/commands/build.js +0 -81
- package/src/commands/clean.js +0 -30
- package/src/commands/client.js +0 -168
- package/src/commands/create-nginx-conf.js +0 -53
- package/src/commands/create-plugin.js +0 -33
- package/src/commands/dev.js +0 -290
- package/src/commands/doc.js +0 -76
- package/src/commands/e2e.js +0 -265
- package/src/commands/global.js +0 -43
- package/src/commands/index.js +0 -45
- package/src/commands/instance-id.js +0 -47
- package/src/commands/locale/cronstrue.js +0 -122
- package/src/commands/locale/react-js-cron/en-US.json +0 -75
- package/src/commands/locale/react-js-cron/index.js +0 -17
- package/src/commands/locale/react-js-cron/zh-CN.json +0 -33
- package/src/commands/locale/react-js-cron/zh-TW.json +0 -33
- package/src/commands/locale.js +0 -81
- package/src/commands/p-test.js +0 -88
- package/src/commands/perf.js +0 -63
- package/src/commands/pkg.js +0 -321
- package/src/commands/pm2.js +0 -37
- package/src/commands/postinstall.js +0 -88
- package/src/commands/start.js +0 -148
- package/src/commands/tar.js +0 -36
- package/src/commands/test-coverage.js +0 -55
- package/src/commands/test.js +0 -107
- package/src/commands/umi.js +0 -33
- package/src/commands/update-deps.js +0 -72
- package/src/commands/upgrade.js +0 -47
- package/src/commands/view-license-key.js +0 -44
- package/src/index.js +0 -14
- package/src/license.js +0 -76
- package/src/logger.js +0 -75
- package/src/plugin-generator.js +0 -80
- package/src/util.js +0 -607
- package/templates/bundle-status.html +0 -338
- package/templates/create-app-package.json +0 -39
- package/templates/plugin/.npmignore.tpl +0 -2
- package/templates/plugin/README.md.tpl +0 -1
- package/templates/plugin/client-v2.d.ts +0 -2
- package/templates/plugin/client-v2.js +0 -1
- package/templates/plugin/client.d.ts +0 -2
- package/templates/plugin/client.js +0 -1
- package/templates/plugin/package.json.tpl +0 -12
- package/templates/plugin/server.d.ts +0 -2
- package/templates/plugin/server.js +0 -1
- package/templates/plugin/src/client/client.d.ts +0 -249
- package/templates/plugin/src/client/index.tsx.tpl +0 -1
- package/templates/plugin/src/client/locale.ts +0 -21
- package/templates/plugin/src/client/models/index.ts +0 -12
- package/templates/plugin/src/client/plugin.tsx.tpl +0 -10
- package/templates/plugin/src/client-v2/client.d.ts +0 -103
- package/templates/plugin/src/client-v2/index.tsx.tpl +0 -1
- package/templates/plugin/src/client-v2/plugin.tsx.tpl +0 -7
- package/templates/plugin/src/index.ts +0 -2
- package/templates/plugin/src/locale/en-US.json +0 -1
- package/templates/plugin/src/locale/zh-CN.json +0 -1
- package/templates/plugin/src/server/collections/.gitkeep +0 -0
- package/templates/plugin/src/server/index.ts.tpl +0 -1
- package/templates/plugin/src/server/plugin.ts.tpl +0 -19
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { URL } from 'node:url';
|
|
5
|
+
import { getCurrentEnvName, getEnv, setEnvOauthSession, } from './auth-store.js';
|
|
6
|
+
import { printInfo, printVerbose, printWarning, updateTask } from './ui.js';
|
|
7
|
+
const ACCESS_TOKEN_REFRESH_WINDOW_MS = 60_000;
|
|
8
|
+
const LOOPBACK_HOST = '127.0.0.1';
|
|
9
|
+
const OAUTH_LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
10
|
+
const DEFAULT_OAUTH_SCOPE = 'openid api offline_access';
|
|
11
|
+
const DEFAULT_CLIENT_NAME = 'NocoBase CTL';
|
|
12
|
+
function normalizeBaseUrl(baseUrl) {
|
|
13
|
+
return baseUrl.replace(/\/+$/, '');
|
|
14
|
+
}
|
|
15
|
+
export function getOauthMetadataUrl(baseUrl) {
|
|
16
|
+
return `${normalizeBaseUrl(baseUrl)}/.well-known/oauth-authorization-server`;
|
|
17
|
+
}
|
|
18
|
+
export function getOauthResource(issuerOrBaseUrl) {
|
|
19
|
+
return `${normalizeBaseUrl(issuerOrBaseUrl)}/`;
|
|
20
|
+
}
|
|
21
|
+
export function getDefaultOauthScope() {
|
|
22
|
+
return DEFAULT_OAUTH_SCOPE;
|
|
23
|
+
}
|
|
24
|
+
export function isOauthAccessTokenExpired(auth, now = Date.now()) {
|
|
25
|
+
if (!auth.expiresAt) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
const expiresAt = Date.parse(auth.expiresAt);
|
|
29
|
+
if (Number.isNaN(expiresAt)) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
return expiresAt - ACCESS_TOKEN_REFRESH_WINDOW_MS <= now;
|
|
33
|
+
}
|
|
34
|
+
function calculateExpiresAt(expiresIn) {
|
|
35
|
+
if (typeof expiresIn !== 'number' || !Number.isFinite(expiresIn) || expiresIn <= 0) {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
return new Date(Date.now() + expiresIn * 1000).toISOString();
|
|
39
|
+
}
|
|
40
|
+
async function parseJsonResponse(response) {
|
|
41
|
+
const text = await response.text();
|
|
42
|
+
if (!text) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(text);
|
|
47
|
+
}
|
|
48
|
+
catch (_error) {
|
|
49
|
+
return text;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function formatOauthError(prefix, data, fallbackStatus) {
|
|
53
|
+
if (typeof data === 'string' && data.trim()) {
|
|
54
|
+
return `${prefix}: ${data}`;
|
|
55
|
+
}
|
|
56
|
+
if (data?.error || data?.error_description) {
|
|
57
|
+
const description = [data.error, data.error_description].filter(Boolean).join(': ');
|
|
58
|
+
return `${prefix}: ${description}`;
|
|
59
|
+
}
|
|
60
|
+
if (typeof fallbackStatus === 'number') {
|
|
61
|
+
return `${prefix}: HTTP ${fallbackStatus}`;
|
|
62
|
+
}
|
|
63
|
+
return prefix;
|
|
64
|
+
}
|
|
65
|
+
async function fetchOauthServerMetadata(baseUrl) {
|
|
66
|
+
const metadataUrl = getOauthMetadataUrl(baseUrl);
|
|
67
|
+
const response = await fetch(metadataUrl);
|
|
68
|
+
const data = await parseJsonResponse(response);
|
|
69
|
+
if (!response.ok) {
|
|
70
|
+
throw new Error(formatOauthError(`Failed to load OAuth metadata from ${metadataUrl}`, data, response.status));
|
|
71
|
+
}
|
|
72
|
+
if (!data ||
|
|
73
|
+
typeof data !== 'object' ||
|
|
74
|
+
typeof data.issuer !== 'string' ||
|
|
75
|
+
typeof data.authorization_endpoint !== 'string' ||
|
|
76
|
+
typeof data.token_endpoint !== 'string') {
|
|
77
|
+
throw new Error(`Invalid OAuth metadata from ${metadataUrl}.`);
|
|
78
|
+
}
|
|
79
|
+
return data;
|
|
80
|
+
}
|
|
81
|
+
async function registerOauthClient(metadata, redirectUri) {
|
|
82
|
+
if (!metadata.registration_endpoint) {
|
|
83
|
+
throw new Error('OAuth server does not expose a dynamic client registration endpoint.');
|
|
84
|
+
}
|
|
85
|
+
const response = await fetch(metadata.registration_endpoint, {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
headers: {
|
|
88
|
+
accept: 'application/json',
|
|
89
|
+
'content-type': 'application/json',
|
|
90
|
+
},
|
|
91
|
+
body: JSON.stringify({
|
|
92
|
+
client_name: DEFAULT_CLIENT_NAME,
|
|
93
|
+
application_type: 'native',
|
|
94
|
+
token_endpoint_auth_method: 'none',
|
|
95
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
96
|
+
response_types: ['code'],
|
|
97
|
+
scope: DEFAULT_OAUTH_SCOPE,
|
|
98
|
+
redirect_uris: [redirectUri],
|
|
99
|
+
}),
|
|
100
|
+
});
|
|
101
|
+
const data = await parseJsonResponse(response);
|
|
102
|
+
if (!response.ok) {
|
|
103
|
+
throw new Error(formatOauthError('Failed to register OAuth client', data, response.status));
|
|
104
|
+
}
|
|
105
|
+
if (!data || typeof data !== 'object' || typeof data.client_id !== 'string') {
|
|
106
|
+
throw new Error('OAuth client registration succeeded but no client_id was returned.');
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
clientId: data.client_id,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function encodeBase64Url(input) {
|
|
113
|
+
return input
|
|
114
|
+
.toString('base64')
|
|
115
|
+
.replace(/\+/g, '-')
|
|
116
|
+
.replace(/\//g, '_')
|
|
117
|
+
.replace(/=+$/g, '');
|
|
118
|
+
}
|
|
119
|
+
function buildPkcePair() {
|
|
120
|
+
const codeVerifier = encodeBase64Url(crypto.randomBytes(32));
|
|
121
|
+
const codeChallenge = encodeBase64Url(crypto.createHash('sha256').update(codeVerifier).digest());
|
|
122
|
+
return {
|
|
123
|
+
codeVerifier,
|
|
124
|
+
codeChallenge,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
function maybeOpenBrowser(url) {
|
|
128
|
+
const candidates = process.platform === 'darwin'
|
|
129
|
+
? [['open', url]]
|
|
130
|
+
: process.platform === 'win32'
|
|
131
|
+
? [['cmd', '/c', 'start', '', url]]
|
|
132
|
+
: [['xdg-open', url]];
|
|
133
|
+
for (const [command, ...args] of candidates) {
|
|
134
|
+
try {
|
|
135
|
+
const child = spawn(command, args, {
|
|
136
|
+
detached: true,
|
|
137
|
+
stdio: 'ignore',
|
|
138
|
+
});
|
|
139
|
+
child.unref();
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
catch (_error) {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
async function createLoopbackServer(state) {
|
|
149
|
+
const result = await new Promise((resolve, reject) => {
|
|
150
|
+
const server = createServer((req, res) => {
|
|
151
|
+
try {
|
|
152
|
+
const requestUrl = new URL(req.url || '/', `http://${LOOPBACK_HOST}`);
|
|
153
|
+
const receivedState = requestUrl.searchParams.get('state');
|
|
154
|
+
const code = requestUrl.searchParams.get('code');
|
|
155
|
+
const error = requestUrl.searchParams.get('error');
|
|
156
|
+
const errorDescription = requestUrl.searchParams.get('error_description');
|
|
157
|
+
res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
158
|
+
if (receivedState !== state) {
|
|
159
|
+
res.statusCode = 400;
|
|
160
|
+
res.end('<html><body><h1>Authentication failed</h1><p>Invalid state.</p></body></html>');
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (error) {
|
|
164
|
+
res.statusCode = 400;
|
|
165
|
+
res.end(`<html><body><h1>Authentication failed</h1><p>${errorDescription || error}</p></body></html>`);
|
|
166
|
+
reject(new Error(`OAuth authorization failed: ${errorDescription || error}`));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (!code) {
|
|
170
|
+
res.statusCode = 400;
|
|
171
|
+
res.end('<html><body><h1>Authentication failed</h1><p>Missing authorization code.</p></body></html>');
|
|
172
|
+
reject(new Error('OAuth authorization failed: missing authorization code.'));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
res.statusCode = 200;
|
|
176
|
+
res.end('<html><body><h1>Authentication complete</h1><p>You can return to the terminal.</p></body></html>');
|
|
177
|
+
resolveWaiter(code);
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
reject(error);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
let resolveWaiter;
|
|
184
|
+
let rejectWaiter;
|
|
185
|
+
const waitForCode = () => new Promise((resolveCode, rejectCode) => {
|
|
186
|
+
resolveWaiter = (code) => {
|
|
187
|
+
void close();
|
|
188
|
+
resolveCode(code);
|
|
189
|
+
};
|
|
190
|
+
rejectWaiter = (error) => {
|
|
191
|
+
void close();
|
|
192
|
+
rejectCode(error);
|
|
193
|
+
};
|
|
194
|
+
});
|
|
195
|
+
const close = async () => {
|
|
196
|
+
await new Promise((resolveClose) => {
|
|
197
|
+
server.close(() => resolveClose());
|
|
198
|
+
});
|
|
199
|
+
};
|
|
200
|
+
server.on('error', (error) => {
|
|
201
|
+
reject(error);
|
|
202
|
+
rejectWaiter?.(error);
|
|
203
|
+
});
|
|
204
|
+
server.listen(0, LOOPBACK_HOST, () => {
|
|
205
|
+
const address = server.address();
|
|
206
|
+
if (!address || typeof address === 'string') {
|
|
207
|
+
reject(new Error('Failed to open the OAuth callback listener.'));
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
resolve({
|
|
211
|
+
redirectUri: `http://${LOOPBACK_HOST}:${address.port}/callback`,
|
|
212
|
+
waitForCode,
|
|
213
|
+
close,
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
return result;
|
|
218
|
+
}
|
|
219
|
+
async function exchangeAuthorizationCode(options) {
|
|
220
|
+
const body = new URLSearchParams({
|
|
221
|
+
grant_type: 'authorization_code',
|
|
222
|
+
client_id: options.clientId,
|
|
223
|
+
code: options.code,
|
|
224
|
+
code_verifier: options.codeVerifier,
|
|
225
|
+
redirect_uri: options.redirectUri,
|
|
226
|
+
resource: options.resource,
|
|
227
|
+
});
|
|
228
|
+
const response = await fetch(options.metadata.token_endpoint, {
|
|
229
|
+
method: 'POST',
|
|
230
|
+
headers: {
|
|
231
|
+
accept: 'application/json',
|
|
232
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
233
|
+
},
|
|
234
|
+
body,
|
|
235
|
+
});
|
|
236
|
+
const data = await parseJsonResponse(response);
|
|
237
|
+
if (!response.ok) {
|
|
238
|
+
throw new Error(formatOauthError('Failed to exchange OAuth authorization code', data, response.status));
|
|
239
|
+
}
|
|
240
|
+
if (!data || typeof data !== 'object' || typeof data.access_token !== 'string') {
|
|
241
|
+
throw new Error('OAuth token response is missing access_token.');
|
|
242
|
+
}
|
|
243
|
+
return data;
|
|
244
|
+
}
|
|
245
|
+
async function refreshOauthAccessToken(options) {
|
|
246
|
+
if (!options.auth.refreshToken || !options.auth.clientId) {
|
|
247
|
+
throw new Error(`OAuth session for env "${options.envName}" cannot be refreshed. Run \`nb env auth -e ${options.envName}\`.`);
|
|
248
|
+
}
|
|
249
|
+
const metadata = await fetchOauthServerMetadata(options.baseUrl);
|
|
250
|
+
const resource = options.auth.resource || getOauthResource(metadata.issuer);
|
|
251
|
+
const body = new URLSearchParams({
|
|
252
|
+
grant_type: 'refresh_token',
|
|
253
|
+
client_id: options.auth.clientId,
|
|
254
|
+
refresh_token: options.auth.refreshToken,
|
|
255
|
+
resource,
|
|
256
|
+
});
|
|
257
|
+
const response = await fetch(metadata.token_endpoint, {
|
|
258
|
+
method: 'POST',
|
|
259
|
+
headers: {
|
|
260
|
+
accept: 'application/json',
|
|
261
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
262
|
+
},
|
|
263
|
+
body,
|
|
264
|
+
});
|
|
265
|
+
const data = await parseJsonResponse(response);
|
|
266
|
+
if (!response.ok) {
|
|
267
|
+
throw new Error(formatOauthError(`Failed to refresh OAuth session for env "${options.envName}". Run \`nb env auth -e ${options.envName}\` again`, data, response.status));
|
|
268
|
+
}
|
|
269
|
+
if (!data || typeof data !== 'object' || typeof data.access_token !== 'string') {
|
|
270
|
+
throw new Error(`OAuth refresh response for env "${options.envName}" is missing access_token.`);
|
|
271
|
+
}
|
|
272
|
+
const nextAuth = {
|
|
273
|
+
type: 'oauth',
|
|
274
|
+
accessToken: data.access_token,
|
|
275
|
+
refreshToken: typeof data.refresh_token === 'string' ? data.refresh_token : options.auth.refreshToken,
|
|
276
|
+
expiresAt: calculateExpiresAt(data.expires_in),
|
|
277
|
+
scope: typeof data.scope === 'string' ? data.scope : options.auth.scope,
|
|
278
|
+
issuer: metadata.issuer,
|
|
279
|
+
clientId: options.auth.clientId,
|
|
280
|
+
resource,
|
|
281
|
+
};
|
|
282
|
+
await setEnvOauthSession(options.envName, nextAuth, {
|
|
283
|
+
scope: options.scope,
|
|
284
|
+
preserveRuntime: true,
|
|
285
|
+
});
|
|
286
|
+
return nextAuth.accessToken;
|
|
287
|
+
}
|
|
288
|
+
export async function resolveAccessToken(options) {
|
|
289
|
+
if (options.token) {
|
|
290
|
+
return options.token;
|
|
291
|
+
}
|
|
292
|
+
const envName = options.envName ?? (await getCurrentEnvName({ scope: options.scope }));
|
|
293
|
+
const env = await getEnv(envName, { scope: options.scope });
|
|
294
|
+
if (!env?.auth) {
|
|
295
|
+
return undefined;
|
|
296
|
+
}
|
|
297
|
+
if (env.auth.type === 'token') {
|
|
298
|
+
return env.auth.accessToken;
|
|
299
|
+
}
|
|
300
|
+
if (!isOauthAccessTokenExpired(env.auth)) {
|
|
301
|
+
return env.auth.accessToken;
|
|
302
|
+
}
|
|
303
|
+
const baseUrl = options.baseUrl ?? env.baseUrl;
|
|
304
|
+
if (!baseUrl) {
|
|
305
|
+
throw new Error(`Env "${envName}" is missing a base URL. Run \`nb env add --name ${envName} --base-url <url>\`.`);
|
|
306
|
+
}
|
|
307
|
+
printVerbose(`Refreshing OAuth session for env "${envName}"`);
|
|
308
|
+
return refreshOauthAccessToken({
|
|
309
|
+
envName,
|
|
310
|
+
baseUrl,
|
|
311
|
+
auth: env.auth,
|
|
312
|
+
scope: options.scope,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
export async function resolveServerRequestTarget(options) {
|
|
316
|
+
const envName = options.envName ?? (await getCurrentEnvName({ scope: options.scope }));
|
|
317
|
+
const env = await getEnv(envName, { scope: options.scope });
|
|
318
|
+
const baseUrl = options.baseUrl ?? env?.baseUrl;
|
|
319
|
+
const token = await resolveAccessToken({
|
|
320
|
+
envName,
|
|
321
|
+
baseUrl,
|
|
322
|
+
token: options.token,
|
|
323
|
+
scope: options.scope,
|
|
324
|
+
});
|
|
325
|
+
if (!baseUrl) {
|
|
326
|
+
throw new Error('Missing base URL. Use --base-url or configure one with `nb env add`.');
|
|
327
|
+
}
|
|
328
|
+
return { baseUrl, token };
|
|
329
|
+
}
|
|
330
|
+
export async function authenticateEnvWithOauth(options) {
|
|
331
|
+
const envName = options.envName ?? (await getCurrentEnvName({ scope: options.scope }));
|
|
332
|
+
const env = await getEnv(envName, { scope: options.scope });
|
|
333
|
+
const baseUrl = env?.baseUrl;
|
|
334
|
+
if (!baseUrl) {
|
|
335
|
+
throw new Error([
|
|
336
|
+
`Env "${envName}" is missing a base URL.`,
|
|
337
|
+
'Run `nb env add --name <name> --base-url <url>` first.',
|
|
338
|
+
].join('\n'));
|
|
339
|
+
}
|
|
340
|
+
updateTask(`Loading OAuth metadata for env "${envName}"...`);
|
|
341
|
+
const metadata = await fetchOauthServerMetadata(baseUrl);
|
|
342
|
+
const state = encodeBase64Url(crypto.randomBytes(16));
|
|
343
|
+
const { codeVerifier, codeChallenge } = buildPkcePair();
|
|
344
|
+
const callback = await createLoopbackServer(state);
|
|
345
|
+
const resource = getOauthResource(metadata.issuer);
|
|
346
|
+
try {
|
|
347
|
+
updateTask(`Registering OAuth client for env "${envName}"...`);
|
|
348
|
+
const registration = await registerOauthClient(metadata, callback.redirectUri);
|
|
349
|
+
const authorizationUrl = new URL(metadata.authorization_endpoint);
|
|
350
|
+
authorizationUrl.searchParams.set('response_type', 'code');
|
|
351
|
+
authorizationUrl.searchParams.set('client_id', registration.clientId);
|
|
352
|
+
authorizationUrl.searchParams.set('redirect_uri', callback.redirectUri);
|
|
353
|
+
authorizationUrl.searchParams.set('scope', DEFAULT_OAUTH_SCOPE);
|
|
354
|
+
authorizationUrl.searchParams.set('state', state);
|
|
355
|
+
authorizationUrl.searchParams.set('prompt', 'consent');
|
|
356
|
+
authorizationUrl.searchParams.set('code_challenge', codeChallenge);
|
|
357
|
+
authorizationUrl.searchParams.set('code_challenge_method', 'S256');
|
|
358
|
+
authorizationUrl.searchParams.set('resource', resource);
|
|
359
|
+
updateTask(`Waiting for OAuth login for env "${envName}"...`);
|
|
360
|
+
const opened = maybeOpenBrowser(authorizationUrl.toString());
|
|
361
|
+
if (!opened) {
|
|
362
|
+
printWarning('Unable to open the browser automatically. Open this URL manually:');
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
printInfo('Complete the OAuth login in your browser.');
|
|
366
|
+
}
|
|
367
|
+
printInfo(authorizationUrl.toString());
|
|
368
|
+
const code = await new Promise((resolve, reject) => {
|
|
369
|
+
const timeout = setTimeout(() => reject(new Error('OAuth login timed out.')), OAUTH_LOGIN_TIMEOUT_MS);
|
|
370
|
+
timeout.unref?.();
|
|
371
|
+
callback.waitForCode().then((value) => {
|
|
372
|
+
clearTimeout(timeout);
|
|
373
|
+
resolve(value);
|
|
374
|
+
}, (error) => {
|
|
375
|
+
clearTimeout(timeout);
|
|
376
|
+
reject(error);
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
updateTask(`Exchanging OAuth code for env "${envName}"...`);
|
|
380
|
+
const tokenResponse = await exchangeAuthorizationCode({
|
|
381
|
+
metadata,
|
|
382
|
+
clientId: registration.clientId,
|
|
383
|
+
redirectUri: callback.redirectUri,
|
|
384
|
+
code,
|
|
385
|
+
codeVerifier,
|
|
386
|
+
resource,
|
|
387
|
+
});
|
|
388
|
+
if (!tokenResponse.refresh_token) {
|
|
389
|
+
printWarning('OAuth login succeeded but no refresh_token was returned. The server did not grant offline access for this client/session.');
|
|
390
|
+
}
|
|
391
|
+
await setEnvOauthSession(envName, {
|
|
392
|
+
type: 'oauth',
|
|
393
|
+
accessToken: tokenResponse.access_token,
|
|
394
|
+
refreshToken: tokenResponse.refresh_token,
|
|
395
|
+
expiresAt: calculateExpiresAt(tokenResponse.expires_in),
|
|
396
|
+
scope: tokenResponse.scope || DEFAULT_OAUTH_SCOPE,
|
|
397
|
+
issuer: metadata.issuer,
|
|
398
|
+
clientId: registration.clientId,
|
|
399
|
+
resource,
|
|
400
|
+
}, { scope: options.scope });
|
|
401
|
+
}
|
|
402
|
+
finally {
|
|
403
|
+
await callback.close().catch(() => undefined);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { Command, Flags } from '@oclif/core';
|
|
2
|
+
import { executeApiRequest } from './api-client.js';
|
|
3
|
+
import { applyPostProcessor } from './post-processors.js';
|
|
4
|
+
import { registerPostProcessors } from '../post-processors/index.js';
|
|
5
|
+
function buildParameterFlag(parameter, options) {
|
|
6
|
+
const hints = [parameter.in];
|
|
7
|
+
if (parameter.type === 'object' || parameter.type === 'array' || parameter.jsonEncoded) {
|
|
8
|
+
hints.push('JSON');
|
|
9
|
+
}
|
|
10
|
+
else if (parameter.isArray) {
|
|
11
|
+
hints.push('repeatable');
|
|
12
|
+
}
|
|
13
|
+
else if (parameter.type) {
|
|
14
|
+
hints.push(parameter.type);
|
|
15
|
+
}
|
|
16
|
+
const description = [
|
|
17
|
+
`${parameter.description ?? ''}${parameter.description ? ' ' : ''}[${hints.join(', ')}]`.trim(),
|
|
18
|
+
parameter.jsonShape ? `Shape: ${parameter.jsonShape}` : undefined,
|
|
19
|
+
]
|
|
20
|
+
.filter(Boolean)
|
|
21
|
+
.join('\n');
|
|
22
|
+
const required = options?.required ?? parameter.required;
|
|
23
|
+
const helpGroup = parameter.in === 'body'
|
|
24
|
+
? 'Body Field'
|
|
25
|
+
: parameter.in === 'path'
|
|
26
|
+
? 'Path Parameter'
|
|
27
|
+
: parameter.in === 'query'
|
|
28
|
+
? 'Query Parameter'
|
|
29
|
+
: parameter.in === 'header'
|
|
30
|
+
? 'Header Parameter'
|
|
31
|
+
: parameter.in === 'cookie'
|
|
32
|
+
? 'Cookie Parameter'
|
|
33
|
+
: undefined;
|
|
34
|
+
if (parameter.type === 'boolean') {
|
|
35
|
+
return Flags.boolean({
|
|
36
|
+
description,
|
|
37
|
+
...(helpGroup ? { helpGroup } : {}),
|
|
38
|
+
...(required ? { required: true } : {}),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
if (parameter.isArray && !parameter.jsonEncoded) {
|
|
42
|
+
return Flags.string({
|
|
43
|
+
description,
|
|
44
|
+
multiple: true,
|
|
45
|
+
...(helpGroup ? { helpGroup } : {}),
|
|
46
|
+
...(required ? { required: true } : {}),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return Flags.string({
|
|
50
|
+
description,
|
|
51
|
+
...(helpGroup ? { helpGroup } : {}),
|
|
52
|
+
...(required ? { required: true } : {}),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
export function createGeneratedFlags(operation) {
|
|
56
|
+
const flags = {};
|
|
57
|
+
for (const parameter of operation.parameters) {
|
|
58
|
+
flags[parameter.flagName] = buildParameterFlag(parameter, {
|
|
59
|
+
// Body flags are an alternative authoring path to --body/--body-file.
|
|
60
|
+
// Enforce required body semantics later in parseBody(), after we know
|
|
61
|
+
// which input mode the user chose.
|
|
62
|
+
required: parameter.in === 'body' ? false : parameter.required,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
if (operation.hasBody) {
|
|
66
|
+
flags.body = Flags.string({
|
|
67
|
+
description: 'Full JSON request body string. Do not combine with body field flags.',
|
|
68
|
+
helpGroup: 'Raw JSON Body',
|
|
69
|
+
exclusive: ['body-file'],
|
|
70
|
+
});
|
|
71
|
+
flags['body-file'] = Flags.string({
|
|
72
|
+
description: 'Path to a JSON file containing the full request body. Do not combine with body field flags.',
|
|
73
|
+
helpGroup: 'Raw JSON Body',
|
|
74
|
+
exclusive: ['body'],
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
flags['base-url'] = Flags.string({
|
|
78
|
+
description: 'NocoBase API base URL, for example http://localhost:13000/api',
|
|
79
|
+
helpGroup: 'Global',
|
|
80
|
+
});
|
|
81
|
+
flags.verbose = Flags.boolean({
|
|
82
|
+
description: 'Show detailed progress output',
|
|
83
|
+
default: false,
|
|
84
|
+
helpGroup: 'Global',
|
|
85
|
+
});
|
|
86
|
+
flags.env = Flags.string({
|
|
87
|
+
char: 'e',
|
|
88
|
+
description: 'Environment name',
|
|
89
|
+
helpGroup: 'Global',
|
|
90
|
+
});
|
|
91
|
+
flags.role = Flags.string({
|
|
92
|
+
description: 'Role override, sent as X-Role',
|
|
93
|
+
helpGroup: 'Global',
|
|
94
|
+
});
|
|
95
|
+
flags.token = Flags.string({
|
|
96
|
+
char: 't',
|
|
97
|
+
description: 'API key override',
|
|
98
|
+
helpGroup: 'Global',
|
|
99
|
+
});
|
|
100
|
+
flags['json-output'] = Flags.boolean({
|
|
101
|
+
char: 'j',
|
|
102
|
+
description: 'Print raw JSON response',
|
|
103
|
+
default: true,
|
|
104
|
+
allowNo: true,
|
|
105
|
+
helpGroup: 'Global',
|
|
106
|
+
});
|
|
107
|
+
return flags;
|
|
108
|
+
}
|
|
109
|
+
export class GeneratedApiCommand extends Command {
|
|
110
|
+
static operation;
|
|
111
|
+
async run() {
|
|
112
|
+
registerPostProcessors();
|
|
113
|
+
const ctor = this.constructor;
|
|
114
|
+
const { flags } = await this.parse(ctor);
|
|
115
|
+
const response = await executeApiRequest({
|
|
116
|
+
envName: flags.env,
|
|
117
|
+
baseUrl: flags['base-url'],
|
|
118
|
+
role: flags.role,
|
|
119
|
+
token: flags.token,
|
|
120
|
+
flags,
|
|
121
|
+
operation: {
|
|
122
|
+
method: ctor.operation.method,
|
|
123
|
+
pathTemplate: ctor.operation.pathTemplate,
|
|
124
|
+
parameters: ctor.operation.parameters,
|
|
125
|
+
hasBody: ctor.operation.hasBody,
|
|
126
|
+
bodyRequired: ctor.operation.bodyRequired,
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
if (!response.ok) {
|
|
130
|
+
this.error(`Request failed with status ${response.status}\n${JSON.stringify(response.data, null, 2)}`);
|
|
131
|
+
}
|
|
132
|
+
const processedData = await applyPostProcessor(response.data, {
|
|
133
|
+
flags,
|
|
134
|
+
operation: ctor.operation,
|
|
135
|
+
});
|
|
136
|
+
if (flags['json-output']) {
|
|
137
|
+
this.log(JSON.stringify(processedData, null, 2));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
this.log(`HTTP ${response.status}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
export function toKebabCase(value) {
|
|
3
|
+
return value
|
|
4
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
5
|
+
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
6
|
+
.replace(/-+/g, '-')
|
|
7
|
+
.replace(/^-|-$/g, '')
|
|
8
|
+
.toLowerCase();
|
|
9
|
+
}
|
|
10
|
+
export function splitPathAction(pathTemplate) {
|
|
11
|
+
const normalizedPath = pathTemplate.replace(/^\/+/, '');
|
|
12
|
+
const separatorIndex = normalizedPath.lastIndexOf(':');
|
|
13
|
+
if (separatorIndex === -1) {
|
|
14
|
+
return {
|
|
15
|
+
resourcePath: normalizedPath,
|
|
16
|
+
action: 'call',
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
resourcePath: normalizedPath.slice(0, separatorIndex),
|
|
21
|
+
action: normalizedPath.slice(separatorIndex + 1),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function toLogicalResourceName(pathTemplate) {
|
|
25
|
+
const { resourcePath } = splitPathAction(pathTemplate);
|
|
26
|
+
return resourcePath
|
|
27
|
+
.split('/')
|
|
28
|
+
.filter(Boolean)
|
|
29
|
+
.filter((segment) => !segment.startsWith('{'))
|
|
30
|
+
.map((segment) => toKebabCase(segment))
|
|
31
|
+
.join('.');
|
|
32
|
+
}
|
|
33
|
+
export function toLogicalActionName(pathTemplate) {
|
|
34
|
+
return toKebabCase(splitPathAction(pathTemplate).action);
|
|
35
|
+
}
|
|
36
|
+
export function toResourceSegments(pathTemplate, options) {
|
|
37
|
+
const { resourcePath, action } = splitPathAction(pathTemplate);
|
|
38
|
+
const pathSegments = resourcePath
|
|
39
|
+
.split('/')
|
|
40
|
+
.filter(Boolean)
|
|
41
|
+
.flatMap((segment) => {
|
|
42
|
+
if (!segment.startsWith('{')) {
|
|
43
|
+
return [toKebabCase(segment)];
|
|
44
|
+
}
|
|
45
|
+
if (!options?.includeParams) {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
return [`by-${toKebabCase(segment.slice(1, -1))}`];
|
|
49
|
+
});
|
|
50
|
+
return [...pathSegments, toKebabCase(action)].filter(Boolean);
|
|
51
|
+
}
|
|
52
|
+
export function toCommandSegments(moduleName, pathTemplate, options) {
|
|
53
|
+
const resourceSegments = toResourceSegments(pathTemplate, options);
|
|
54
|
+
const segments = [options?.omitModule ? '' : toKebabCase(moduleName), ...resourceSegments].filter(Boolean);
|
|
55
|
+
return segments.length ? segments : [toKebabCase(moduleName), 'call'];
|
|
56
|
+
}
|
|
57
|
+
export function toClassName(segments) {
|
|
58
|
+
return segments
|
|
59
|
+
.map((segment) => segment.replace(/(^\w|-\w)/g, (token) => token.replace('-', '').toUpperCase()))
|
|
60
|
+
.join('');
|
|
61
|
+
}
|
|
62
|
+
export function toOutputFile(outputRoot, segments) {
|
|
63
|
+
const folder = path.join(outputRoot, ...segments.slice(0, -1));
|
|
64
|
+
const filePath = path.join(folder, `${segments.at(-1)}.ts`);
|
|
65
|
+
return filePath;
|
|
66
|
+
}
|
|
67
|
+
export function toImportPath(fromFile, targetFile) {
|
|
68
|
+
const relative = path.relative(path.dirname(fromFile), targetFile).replace(/\\/g, '/');
|
|
69
|
+
return relative.startsWith('.') ? relative : `./${relative}`;
|
|
70
|
+
}
|