@nocobase/cli 2.1.0-beta.15 → 2.1.0-beta.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.
Files changed (132) hide show
  1. package/LICENSE.txt +107 -0
  2. package/README.md +307 -63
  3. package/README.zh-CN.md +332 -0
  4. package/bin/run.cmd +3 -0
  5. package/bin/run.js +114 -0
  6. package/dist/commands/api/resource/create.js +15 -0
  7. package/dist/commands/api/resource/destroy.js +15 -0
  8. package/dist/commands/api/resource/get.js +15 -0
  9. package/dist/commands/api/resource/index.js +20 -0
  10. package/dist/commands/api/resource/list.js +16 -0
  11. package/dist/commands/api/resource/query.js +15 -0
  12. package/dist/commands/api/resource/update.js +15 -0
  13. package/dist/commands/build.js +57 -0
  14. package/dist/commands/db/logs.js +85 -0
  15. package/dist/commands/db/ps.js +60 -0
  16. package/dist/commands/db/shared.js +81 -0
  17. package/dist/commands/db/start.js +70 -0
  18. package/dist/commands/db/stop.js +70 -0
  19. package/dist/commands/dev.js +149 -0
  20. package/dist/commands/down.js +193 -0
  21. package/dist/commands/download.js +779 -0
  22. package/dist/commands/env/add.js +327 -0
  23. package/dist/commands/env/auth.js +62 -0
  24. package/dist/commands/env/list.js +41 -0
  25. package/dist/commands/env/remove.js +65 -0
  26. package/dist/commands/env/update.js +73 -0
  27. package/dist/commands/env/use.js +36 -0
  28. package/dist/commands/init.js +813 -0
  29. package/dist/commands/install.js +1850 -0
  30. package/dist/commands/logs.js +90 -0
  31. package/dist/commands/pm/disable.js +63 -0
  32. package/dist/commands/pm/enable.js +63 -0
  33. package/dist/commands/pm/list.js +54 -0
  34. package/dist/commands/prompts-stages.js +150 -0
  35. package/dist/commands/prompts-test.js +181 -0
  36. package/dist/commands/ps.js +116 -0
  37. package/dist/commands/scaffold/migration.js +38 -0
  38. package/dist/commands/scaffold/plugin.js +37 -0
  39. package/dist/commands/start.js +211 -0
  40. package/dist/commands/stop.js +90 -0
  41. package/dist/commands/upgrade.js +583 -0
  42. package/dist/generated/command-registry.js +133 -0
  43. package/dist/help/runtime-help.js +20 -0
  44. package/dist/lib/api-client.js +243 -0
  45. package/dist/lib/app-runtime.js +142 -0
  46. package/dist/lib/auth-store.js +241 -0
  47. package/dist/lib/bootstrap.js +387 -0
  48. package/dist/lib/build-config.js +10 -0
  49. package/dist/lib/cli-home.js +30 -0
  50. package/dist/lib/cli-locale.js +115 -0
  51. package/dist/lib/command-discovery.js +39 -0
  52. package/dist/lib/env-auth.js +872 -0
  53. package/dist/lib/generated-command.js +142 -0
  54. package/dist/lib/naming.js +70 -0
  55. package/dist/lib/openapi.js +62 -0
  56. package/dist/lib/post-processors.js +23 -0
  57. package/dist/lib/prompt-catalog.js +574 -0
  58. package/dist/lib/prompt-validators.js +185 -0
  59. package/dist/lib/prompt-web-ui.js +2061 -0
  60. package/dist/lib/resource-command.js +335 -0
  61. package/dist/lib/resource-request.js +104 -0
  62. package/dist/lib/run-npm.js +123 -0
  63. package/dist/lib/runtime-generator.js +419 -0
  64. package/dist/lib/runtime-store.js +56 -0
  65. package/dist/lib/ui.js +175 -0
  66. package/dist/locale/en-US.json +319 -0
  67. package/dist/locale/zh-CN.json +319 -0
  68. package/dist/post-processors/data-modeling.js +66 -0
  69. package/dist/post-processors/data-source-manager.js +114 -0
  70. package/dist/post-processors/index.js +19 -0
  71. package/nocobase-ctl.config.json +287 -0
  72. package/package.json +53 -25
  73. package/LICENSE +0 -201
  74. package/bin/index.js +0 -39
  75. package/nocobase.conf.tpl +0 -95
  76. package/src/cli.js +0 -19
  77. package/src/commands/benchmark.js +0 -73
  78. package/src/commands/build.js +0 -49
  79. package/src/commands/clean.js +0 -30
  80. package/src/commands/client.js +0 -166
  81. package/src/commands/create-nginx-conf.js +0 -37
  82. package/src/commands/create-plugin.js +0 -33
  83. package/src/commands/dev.js +0 -200
  84. package/src/commands/doc.js +0 -76
  85. package/src/commands/e2e.js +0 -265
  86. package/src/commands/global.js +0 -43
  87. package/src/commands/index.js +0 -45
  88. package/src/commands/instance-id.js +0 -47
  89. package/src/commands/locale/cronstrue.js +0 -122
  90. package/src/commands/locale/react-js-cron/en-US.json +0 -75
  91. package/src/commands/locale/react-js-cron/index.js +0 -17
  92. package/src/commands/locale/react-js-cron/zh-CN.json +0 -33
  93. package/src/commands/locale/react-js-cron/zh-TW.json +0 -33
  94. package/src/commands/locale.js +0 -81
  95. package/src/commands/p-test.js +0 -88
  96. package/src/commands/perf.js +0 -63
  97. package/src/commands/pkg.js +0 -321
  98. package/src/commands/pm2.js +0 -37
  99. package/src/commands/postinstall.js +0 -88
  100. package/src/commands/start.js +0 -148
  101. package/src/commands/tar.js +0 -36
  102. package/src/commands/test-coverage.js +0 -55
  103. package/src/commands/test.js +0 -107
  104. package/src/commands/umi.js +0 -33
  105. package/src/commands/update-deps.js +0 -72
  106. package/src/commands/upgrade.js +0 -47
  107. package/src/commands/view-license-key.js +0 -44
  108. package/src/index.js +0 -14
  109. package/src/license.js +0 -76
  110. package/src/logger.js +0 -75
  111. package/src/plugin-generator.js +0 -80
  112. package/src/util.js +0 -517
  113. package/templates/bundle-status.html +0 -338
  114. package/templates/create-app-package.json +0 -39
  115. package/templates/plugin/.npmignore.tpl +0 -2
  116. package/templates/plugin/README.md.tpl +0 -1
  117. package/templates/plugin/client.d.ts +0 -2
  118. package/templates/plugin/client.js +0 -1
  119. package/templates/plugin/package.json.tpl +0 -11
  120. package/templates/plugin/server.d.ts +0 -2
  121. package/templates/plugin/server.js +0 -1
  122. package/templates/plugin/src/client/client.d.ts +0 -249
  123. package/templates/plugin/src/client/index.tsx.tpl +0 -1
  124. package/templates/plugin/src/client/locale.ts +0 -21
  125. package/templates/plugin/src/client/models/index.ts +0 -12
  126. package/templates/plugin/src/client/plugin.tsx.tpl +0 -10
  127. package/templates/plugin/src/index.ts +0 -2
  128. package/templates/plugin/src/locale/en-US.json +0 -1
  129. package/templates/plugin/src/locale/zh-CN.json +0 -1
  130. package/templates/plugin/src/server/collections/.gitkeep +0 -0
  131. package/templates/plugin/src/server/index.ts.tpl +0 -1
  132. package/templates/plugin/src/server/plugin.ts.tpl +0 -19
@@ -0,0 +1,872 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+ import crypto from 'node:crypto';
10
+ import { createServer } from 'node:http';
11
+ import { spawn } from 'node:child_process';
12
+ import { URL } from 'node:url';
13
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import { getCurrentEnvName, getEnv, setEnvOauthSession, } from './auth-store.js';
17
+ import { printInfo, printVerbose, printWarning, printWarningBlock, updateTask } from './ui.js';
18
+ const ACCESS_TOKEN_REFRESH_WINDOW_MS = 60_000;
19
+ const LOOPBACK_HOST = '127.0.0.1';
20
+ const OAUTH_LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
21
+ const OAUTH_FETCH_TIMEOUT_MS = 15_000;
22
+ const OAUTH_FETCH_RETRY_DELAYS_MS = [500, 1_000, 2_000];
23
+ const DEFAULT_OAUTH_SCOPE = 'openid api offline_access';
24
+ const DEFAULT_CLIENT_NAME = 'NocoBase CLI';
25
+ function normalizeBaseUrl(baseUrl) {
26
+ return baseUrl.replace(/\/+$/, '');
27
+ }
28
+ export function getOauthMetadataUrl(baseUrl) {
29
+ return `${normalizeBaseUrl(baseUrl)}/.well-known/oauth-authorization-server`;
30
+ }
31
+ export function getOauthResource(issuerOrBaseUrl) {
32
+ return `${normalizeBaseUrl(issuerOrBaseUrl)}/`;
33
+ }
34
+ export function getDefaultOauthScope() {
35
+ return DEFAULT_OAUTH_SCOPE;
36
+ }
37
+ export function isOauthAccessTokenExpired(auth, now = Date.now()) {
38
+ if (!auth.expiresAt) {
39
+ return false;
40
+ }
41
+ const expiresAt = Date.parse(auth.expiresAt);
42
+ if (Number.isNaN(expiresAt)) {
43
+ return false;
44
+ }
45
+ return expiresAt - ACCESS_TOKEN_REFRESH_WINDOW_MS <= now;
46
+ }
47
+ function calculateExpiresAt(expiresIn) {
48
+ if (typeof expiresIn !== 'number' || !Number.isFinite(expiresIn) || expiresIn <= 0) {
49
+ return undefined;
50
+ }
51
+ return new Date(Date.now() + expiresIn * 1000).toISOString();
52
+ }
53
+ async function parseJsonResponse(response) {
54
+ const text = await response.text();
55
+ if (!text) {
56
+ return undefined;
57
+ }
58
+ try {
59
+ return JSON.parse(text);
60
+ }
61
+ catch (_error) {
62
+ return text;
63
+ }
64
+ }
65
+ function formatOauthError(prefix, data, fallbackStatus) {
66
+ if (typeof data === 'string' && data.trim()) {
67
+ return `${prefix}: ${data}`;
68
+ }
69
+ if (data?.error || data?.error_description) {
70
+ const description = [data.error, data.error_description].filter(Boolean).join(': ');
71
+ return `${prefix}: ${description}`;
72
+ }
73
+ if (typeof fallbackStatus === 'number') {
74
+ return `${prefix}: HTTP ${fallbackStatus}`;
75
+ }
76
+ return prefix;
77
+ }
78
+ function formatOauthFetchFailure(prefix, options) {
79
+ return [
80
+ prefix,
81
+ options.envName ? `Env: ${options.envName}` : undefined,
82
+ options.baseUrl ? `Base URL: ${options.baseUrl}` : undefined,
83
+ `Request URL: ${options.url}`,
84
+ `Network error: ${options.rawMessage || 'fetch failed'}`,
85
+ 'Check that the NocoBase app is running, the base URL is correct, and the server is reachable from this machine.',
86
+ options.envName
87
+ ? `If the saved login is stale, run \`nb env auth ${options.envName}\` again after connectivity is restored.`
88
+ : 'If the saved login is stale, run `nb env auth <name>` again after connectivity is restored.',
89
+ 'Use `nb env list` to inspect the current env configuration.',
90
+ ]
91
+ .filter(Boolean)
92
+ .join('\n');
93
+ }
94
+ function isRetryableOauthStatus(status) {
95
+ return status === 408 || status === 425 || status === 429 || status >= 500;
96
+ }
97
+ function getOauthFetchRetryDelays() {
98
+ const override = process.env.NOCOBASE_CLI_OAUTH_RETRY_DELAY_MS;
99
+ if (override !== undefined) {
100
+ const delay = Number(override);
101
+ if (Number.isFinite(delay) && delay >= 0) {
102
+ return OAUTH_FETCH_RETRY_DELAYS_MS.map(() => delay);
103
+ }
104
+ }
105
+ return OAUTH_FETCH_RETRY_DELAYS_MS;
106
+ }
107
+ function formatOauthRetryMessage(options) {
108
+ return `${options.operation} failed (${options.reason}). Retrying ${options.attempt}/${options.maxAttempts}...`;
109
+ }
110
+ async function sleep(ms) {
111
+ await new Promise((resolve) => setTimeout(resolve, ms));
112
+ }
113
+ async function fetchWithOauthRetry(url, init, options) {
114
+ const retryDelaysMs = options.retryDelaysMs ?? getOauthFetchRetryDelays();
115
+ const maxAttempts = retryDelaysMs.length + 1;
116
+ let lastError;
117
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
118
+ const controller = new AbortController();
119
+ const timeout = setTimeout(() => {
120
+ controller.abort();
121
+ }, options.timeoutMs ?? OAUTH_FETCH_TIMEOUT_MS);
122
+ try {
123
+ const response = await fetch(url, {
124
+ ...init,
125
+ signal: controller.signal,
126
+ });
127
+ if (!isRetryableOauthStatus(response.status) || attempt === maxAttempts) {
128
+ return response;
129
+ }
130
+ const reason = `HTTP ${response.status}`;
131
+ const message = formatOauthRetryMessage({
132
+ operation: options.operation,
133
+ attempt: attempt + 1,
134
+ maxAttempts,
135
+ reason,
136
+ });
137
+ printVerbose(message);
138
+ options.onRetry?.(message);
139
+ await sleep(retryDelaysMs[attempt - 1] ?? 0);
140
+ }
141
+ catch (error) {
142
+ lastError = error;
143
+ const reason = error instanceof Error && error.name === 'AbortError'
144
+ ? `request timed out after ${Math.ceil((options.timeoutMs ?? OAUTH_FETCH_TIMEOUT_MS) / 1000)}s`
145
+ : error instanceof Error
146
+ ? error.message
147
+ : String(error);
148
+ if (attempt === maxAttempts) {
149
+ throw new Error(reason);
150
+ }
151
+ const message = formatOauthRetryMessage({
152
+ operation: options.operation,
153
+ attempt: attempt + 1,
154
+ maxAttempts,
155
+ reason,
156
+ });
157
+ printVerbose(message);
158
+ options.onRetry?.(message);
159
+ await sleep(retryDelaysMs[attempt - 1] ?? 0);
160
+ }
161
+ finally {
162
+ clearTimeout(timeout);
163
+ }
164
+ }
165
+ throw lastError;
166
+ }
167
+ async function fetchOauthServerMetadata(baseUrl, options = {}) {
168
+ const metadataUrl = getOauthMetadataUrl(baseUrl);
169
+ let response;
170
+ try {
171
+ response = await fetchWithOauthRetry(metadataUrl, undefined, {
172
+ operation: 'Loading OAuth metadata',
173
+ onRetry: options.onRetry,
174
+ });
175
+ }
176
+ catch (error) {
177
+ throw new Error(formatOauthFetchFailure('Failed to load OAuth metadata.', {
178
+ envName: options.envName,
179
+ baseUrl,
180
+ url: metadataUrl,
181
+ rawMessage: error?.message,
182
+ }));
183
+ }
184
+ const data = await parseJsonResponse(response);
185
+ if (!response.ok) {
186
+ throw new Error(formatOauthError(`Failed to load OAuth metadata from ${metadataUrl}`, data, response.status));
187
+ }
188
+ if (!data ||
189
+ typeof data !== 'object' ||
190
+ typeof data.issuer !== 'string' ||
191
+ typeof data.authorization_endpoint !== 'string' ||
192
+ typeof data.token_endpoint !== 'string') {
193
+ throw new Error(`Invalid OAuth metadata from ${metadataUrl}.`);
194
+ }
195
+ return data;
196
+ }
197
+ async function registerOauthClient(metadata, redirectUri, options = {}) {
198
+ if (!metadata.registration_endpoint) {
199
+ throw new Error('OAuth server does not expose a dynamic client registration endpoint.');
200
+ }
201
+ let response;
202
+ try {
203
+ response = await fetchWithOauthRetry(metadata.registration_endpoint, {
204
+ method: 'POST',
205
+ headers: {
206
+ accept: 'application/json',
207
+ 'content-type': 'application/json',
208
+ },
209
+ body: JSON.stringify({
210
+ client_name: DEFAULT_CLIENT_NAME,
211
+ application_type: 'native',
212
+ token_endpoint_auth_method: 'none',
213
+ grant_types: ['authorization_code', 'refresh_token'],
214
+ response_types: ['code'],
215
+ scope: DEFAULT_OAUTH_SCOPE,
216
+ redirect_uris: [redirectUri],
217
+ }),
218
+ }, {
219
+ operation: 'Registering OAuth client',
220
+ onRetry: (message) => updateTask(message),
221
+ });
222
+ }
223
+ catch (error) {
224
+ throw new Error(formatOauthFetchFailure('Failed to register OAuth client.', {
225
+ envName: options.envName,
226
+ baseUrl: options.baseUrl,
227
+ url: metadata.registration_endpoint,
228
+ rawMessage: error?.message,
229
+ }));
230
+ }
231
+ const data = await parseJsonResponse(response);
232
+ if (!response.ok) {
233
+ throw new Error(formatOauthError('Failed to register OAuth client', data, response.status));
234
+ }
235
+ if (!data || typeof data !== 'object' || typeof data.client_id !== 'string') {
236
+ throw new Error('OAuth client registration succeeded but no client_id was returned.');
237
+ }
238
+ return {
239
+ clientId: data.client_id,
240
+ };
241
+ }
242
+ function encodeBase64Url(input) {
243
+ return input
244
+ .toString('base64')
245
+ .replace(/\+/g, '-')
246
+ .replace(/\//g, '_')
247
+ .replace(/=+$/g, '');
248
+ }
249
+ function buildPkcePair() {
250
+ const codeVerifier = encodeBase64Url(crypto.randomBytes(32));
251
+ const codeChallenge = encodeBase64Url(crypto.createHash('sha256').update(codeVerifier).digest());
252
+ return {
253
+ codeVerifier,
254
+ codeChallenge,
255
+ };
256
+ }
257
+ function escapeHtmlAttribute(value) {
258
+ return value
259
+ .replace(/&/g, '&amp;')
260
+ .replace(/"/g, '&quot;')
261
+ .replace(/</g, '&lt;')
262
+ .replace(/>/g, '&gt;');
263
+ }
264
+ function escapeHtmlText(value) {
265
+ return escapeHtmlAttribute(value).replace(/\r?\n/g, '<br>');
266
+ }
267
+ function escapeScriptString(value) {
268
+ return JSON.stringify(value).replace(/</g, '\\u003c');
269
+ }
270
+ function buildOauthPage(options) {
271
+ const tone = options.statusTone === 'success'
272
+ ? {
273
+ color: '#52c41a',
274
+ soft: '#f6ffed',
275
+ border: '#b7eb8f',
276
+ }
277
+ : options.statusTone === 'error'
278
+ ? {
279
+ color: '#ff4d4f',
280
+ soft: '#fff2f0',
281
+ border: '#ffccc7',
282
+ }
283
+ : {
284
+ color: '#1677ff',
285
+ soft: '#e6f4ff',
286
+ border: '#91caff',
287
+ };
288
+ return `<!doctype html>
289
+ <html>
290
+ <head>
291
+ <meta charset="utf-8">
292
+ <meta name="viewport" content="width=device-width, initial-scale=1">
293
+ <title>${escapeHtmlAttribute(options.title)}</title>
294
+ <style>
295
+ :root {
296
+ --bg: #f5f5f5;
297
+ --panel: #ffffff;
298
+ --panel-border: #f0f0f0;
299
+ --text: rgba(0, 0, 0, 0.88);
300
+ --muted: rgba(0, 0, 0, 0.45);
301
+ --status: ${tone.color};
302
+ --status-soft: ${tone.soft};
303
+ --status-border: ${tone.border};
304
+ --primary: #1677ff;
305
+ --shadow: 0 12px 32px rgba(0, 0, 0, 0.08);
306
+ }
307
+ * { box-sizing: border-box; }
308
+ body {
309
+ margin: 0;
310
+ min-height: 100vh;
311
+ display: grid;
312
+ place-items: center;
313
+ padding: 24px;
314
+ background: var(--bg);
315
+ color: var(--text);
316
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
317
+ }
318
+ .shell {
319
+ width: min(100%, 560px);
320
+ border: 1px solid var(--panel-border);
321
+ border-radius: 12px;
322
+ background: var(--panel);
323
+ box-shadow: var(--shadow);
324
+ overflow: hidden;
325
+ }
326
+ .card-head {
327
+ display: flex;
328
+ align-items: center;
329
+ justify-content: space-between;
330
+ min-height: 56px;
331
+ padding: 0 24px;
332
+ border-bottom: 1px solid var(--panel-border);
333
+ background: #ffffff;
334
+ }
335
+ .card-title {
336
+ font-size: 16px;
337
+ font-weight: 600;
338
+ color: var(--text);
339
+ }
340
+ .card-extra {
341
+ font-size: 12px;
342
+ color: var(--primary);
343
+ font-weight: 500;
344
+ letter-spacing: 0.02em;
345
+ }
346
+ .card-body {
347
+ padding: 40px 32px 28px;
348
+ text-align: center;
349
+ }
350
+ .mark {
351
+ width: 64px;
352
+ height: 64px;
353
+ margin: 0 auto 24px;
354
+ display: grid;
355
+ place-items: center;
356
+ border-radius: 50%;
357
+ background: var(--status-soft);
358
+ border: 1px solid var(--status-border);
359
+ color: var(--status);
360
+ font-size: 30px;
361
+ font-weight: 700;
362
+ }
363
+ h1 {
364
+ margin: 0 0 12px;
365
+ font-size: 28px;
366
+ line-height: 1.2;
367
+ font-weight: 600;
368
+ }
369
+ p {
370
+ margin: 0;
371
+ color: var(--muted);
372
+ font-size: 14px;
373
+ line-height: 1.7;
374
+ }
375
+ .tip {
376
+ margin-top: 24px;
377
+ padding: 12px 16px;
378
+ border-radius: 8px;
379
+ background: #fafafa;
380
+ border: 1px solid #f0f0f0;
381
+ color: rgba(0, 0, 0, 0.65);
382
+ font-size: 13px;
383
+ }
384
+ .detail {
385
+ margin-top: 16px;
386
+ padding: 14px 16px;
387
+ border-radius: 8px;
388
+ background: #fff2f0;
389
+ border: 1px solid #ffccc7;
390
+ color: rgba(0, 0, 0, 0.72);
391
+ font-size: 13px;
392
+ line-height: 1.7;
393
+ text-align: left;
394
+ word-break: break-word;
395
+ }
396
+ .actions {
397
+ margin-top: 24px;
398
+ display: flex;
399
+ justify-content: center;
400
+ gap: 12px;
401
+ flex-wrap: wrap;
402
+ }
403
+ .actions a {
404
+ display: inline-flex;
405
+ align-items: center;
406
+ justify-content: center;
407
+ min-width: 148px;
408
+ height: 40px;
409
+ padding: 0 16px;
410
+ border-radius: 8px;
411
+ border: 1px solid #1677ff;
412
+ background: #1677ff;
413
+ color: #fff;
414
+ text-decoration: none;
415
+ font-size: 14px;
416
+ font-weight: 500;
417
+ }
418
+ .actions a.secondary {
419
+ background: #fff;
420
+ color: #1677ff;
421
+ }
422
+ .manual {
423
+ min-height: 22px;
424
+ margin-top: 14px;
425
+ font-size: 13px;
426
+ color: var(--muted);
427
+ }
428
+ .card-foot {
429
+ padding: 12px 24px;
430
+ border-top: 1px solid var(--panel-border);
431
+ background: #fafafa;
432
+ font-size: 12px;
433
+ color: var(--muted);
434
+ text-align: center;
435
+ }
436
+ </style>
437
+ ${options.extraHeadHtml ?? ''}
438
+ </head>
439
+ <body>
440
+ <main class="shell">
441
+ <div class="card-head">
442
+ <div class="card-title">NocoBase CLI</div>
443
+ <div class="card-extra">${escapeHtmlText(options.cardExtra ?? 'OAuth')}</div>
444
+ </div>
445
+ <div class="card-body">
446
+ <div class="mark">${escapeHtmlText(options.statusMark)}</div>
447
+ <h1>${escapeHtmlText(options.heading)}</h1>
448
+ <p>${escapeHtmlText(options.description)}</p>
449
+ ${options.tip ? `<p class="tip">${escapeHtmlText(options.tip)}</p>` : ''}
450
+ ${options.detailHtml ? `<div class="detail">${options.detailHtml}</div>` : ''}
451
+ ${options.actionsHtml ? `<div class="actions">${options.actionsHtml}</div>` : ''}
452
+ <p id="manual" class="manual"></p>
453
+ </div>
454
+ <div class="card-foot">${escapeHtmlText(options.footer ?? 'You can close this page after returning to the terminal.')}</div>
455
+ </main>
456
+ ${options.extraScriptHtml ?? ''}
457
+ </body>
458
+ </html>
459
+ `;
460
+ }
461
+ export function buildOauthRedirectHtml(url) {
462
+ const escapedUrl = escapeHtmlAttribute(url);
463
+ return buildOauthPage({
464
+ title: 'NocoBase OAuth Login',
465
+ cardExtra: 'OAuth',
466
+ statusTone: 'info',
467
+ statusMark: '→',
468
+ heading: 'Redirecting to sign-in',
469
+ description: 'Your browser is opening the NocoBase login page so you can finish authentication.',
470
+ tip: 'If the redirect does not start automatically, continue manually using the button below.',
471
+ actionsHtml: `<a href="${escapedUrl}">Continue to sign-in</a>` +
472
+ `<a class="secondary" href="${escapedUrl}">Open manually</a>`,
473
+ footer: 'After sign-in, this page will hand control back to the terminal.',
474
+ extraHeadHtml: ` <meta http-equiv="refresh" content="0; url=${escapedUrl}">`,
475
+ extraScriptHtml: ` <script>window.location.replace(${escapeScriptString(url)});</script>`,
476
+ });
477
+ }
478
+ export function buildOauthCompletionHtml() {
479
+ return buildOauthPage({
480
+ title: 'Authentication complete',
481
+ cardExtra: 'OAuth',
482
+ statusTone: 'success',
483
+ statusMark: '✓',
484
+ heading: 'Authentication complete',
485
+ description: 'Your sign-in finished successfully. You can return to the terminal and continue there.',
486
+ tip: 'This page will close automatically in 10 seconds.',
487
+ footer: 'You can close this page after returning to the terminal.',
488
+ extraScriptHtml: ` <script>
489
+ setTimeout(function () {
490
+ window.close();
491
+ setTimeout(function () {
492
+ var el = document.getElementById('manual');
493
+ if (document.visibilityState === 'visible' && el) {
494
+ el.textContent = 'If this tab stays open, you can close it manually.';
495
+ }
496
+ }, 400);
497
+ }, 10000);
498
+ </script>`,
499
+ });
500
+ }
501
+ export function buildOauthErrorHtml(message, options) {
502
+ return buildOauthPage({
503
+ title: options?.title ?? 'Authentication failed',
504
+ cardExtra: 'OAuth',
505
+ statusTone: 'error',
506
+ statusMark: '!',
507
+ heading: options?.title ?? 'Authentication failed',
508
+ description: 'The OAuth sign-in flow could not be completed in this browser tab.',
509
+ detailHtml: escapeHtmlText(message),
510
+ tip: 'Return to the terminal to review the error details and try again if needed.',
511
+ footer: 'You can close this page and restart authentication from the CLI.',
512
+ });
513
+ }
514
+ async function createWindowsBrowserRedirectFile(url) {
515
+ const directory = await mkdtemp(path.join(os.tmpdir(), 'nocobase-cli-oauth-'));
516
+ const filePath = path.join(directory, 'authorize.html');
517
+ await writeFile(filePath, buildOauthRedirectHtml(url), 'utf8');
518
+ const cleanup = setTimeout(() => {
519
+ void rm(directory, { recursive: true, force: true });
520
+ }, OAUTH_LOGIN_TIMEOUT_MS);
521
+ cleanup.unref?.();
522
+ return {
523
+ target: filePath,
524
+ cleanup: async () => {
525
+ clearTimeout(cleanup);
526
+ await rm(directory, { recursive: true, force: true });
527
+ },
528
+ };
529
+ }
530
+ async function getBrowserOpenTarget(url) {
531
+ if (process.platform !== 'win32') {
532
+ return { target: url };
533
+ }
534
+ return createWindowsBrowserRedirectFile(url);
535
+ }
536
+ async function maybeOpenBrowser(url) {
537
+ const { target, cleanup } = await getBrowserOpenTarget(url);
538
+ const candidates = process.platform === 'darwin'
539
+ ? [['open', target]]
540
+ : process.platform === 'win32'
541
+ ? [['cmd', '/c', 'start', '', target]]
542
+ : [['xdg-open', target]];
543
+ for (const [command, ...args] of candidates) {
544
+ const opened = await new Promise((resolve) => {
545
+ try {
546
+ const child = spawn(command, args, {
547
+ detached: true,
548
+ stdio: 'ignore',
549
+ });
550
+ child.once('error', () => resolve(false));
551
+ child.once('spawn', () => {
552
+ child.unref();
553
+ resolve(true);
554
+ });
555
+ }
556
+ catch (_error) {
557
+ resolve(false);
558
+ }
559
+ });
560
+ if (opened) {
561
+ return {
562
+ opened: true,
563
+ cleanup,
564
+ };
565
+ }
566
+ }
567
+ return {
568
+ opened: false,
569
+ cleanup,
570
+ };
571
+ }
572
+ async function createLoopbackServer(state) {
573
+ const result = await new Promise((resolve, reject) => {
574
+ const server = createServer((req, res) => {
575
+ try {
576
+ const requestUrl = new URL(req.url || '/', `http://${LOOPBACK_HOST}`);
577
+ const receivedState = requestUrl.searchParams.get('state');
578
+ const code = requestUrl.searchParams.get('code');
579
+ const error = requestUrl.searchParams.get('error');
580
+ const errorDescription = requestUrl.searchParams.get('error_description');
581
+ res.setHeader('content-type', 'text/html; charset=utf-8');
582
+ if (receivedState !== state) {
583
+ res.statusCode = 400;
584
+ res.end(buildOauthErrorHtml('Invalid state.'));
585
+ rejectWaiter?.(new Error('OAuth authorization failed: invalid state.'));
586
+ return;
587
+ }
588
+ if (error) {
589
+ res.statusCode = 400;
590
+ res.end(buildOauthErrorHtml(String(errorDescription || error)));
591
+ rejectWaiter?.(new Error(`OAuth authorization failed: ${errorDescription || error}`));
592
+ return;
593
+ }
594
+ if (!code) {
595
+ res.statusCode = 400;
596
+ res.end(buildOauthErrorHtml('Missing authorization code.'));
597
+ rejectWaiter?.(new Error('OAuth authorization failed: missing authorization code.'));
598
+ return;
599
+ }
600
+ res.statusCode = 200;
601
+ res.end(buildOauthCompletionHtml());
602
+ resolveWaiter(code);
603
+ }
604
+ catch (error) {
605
+ reject(error);
606
+ }
607
+ });
608
+ let resolveWaiter;
609
+ let rejectWaiter;
610
+ const waitForCode = () => new Promise((resolveCode, rejectCode) => {
611
+ resolveWaiter = (code) => {
612
+ void close();
613
+ resolveCode(code);
614
+ };
615
+ rejectWaiter = (error) => {
616
+ void close();
617
+ rejectCode(error);
618
+ };
619
+ });
620
+ const close = async () => {
621
+ await new Promise((resolveClose) => {
622
+ server.close(() => resolveClose());
623
+ });
624
+ };
625
+ server.on('error', (error) => {
626
+ reject(error);
627
+ rejectWaiter?.(error);
628
+ });
629
+ server.listen(0, LOOPBACK_HOST, () => {
630
+ const address = server.address();
631
+ if (!address || typeof address === 'string') {
632
+ reject(new Error('Failed to open the OAuth callback listener.'));
633
+ return;
634
+ }
635
+ resolve({
636
+ redirectUri: `http://${LOOPBACK_HOST}:${address.port}/callback`,
637
+ waitForCode,
638
+ close,
639
+ });
640
+ });
641
+ });
642
+ return result;
643
+ }
644
+ async function exchangeAuthorizationCode(options) {
645
+ const body = new URLSearchParams({
646
+ grant_type: 'authorization_code',
647
+ client_id: options.clientId,
648
+ code: options.code,
649
+ code_verifier: options.codeVerifier,
650
+ redirect_uri: options.redirectUri,
651
+ resource: options.resource,
652
+ });
653
+ let response;
654
+ try {
655
+ response = await fetchWithOauthRetry(options.metadata.token_endpoint, {
656
+ method: 'POST',
657
+ headers: {
658
+ accept: 'application/json',
659
+ 'content-type': 'application/x-www-form-urlencoded',
660
+ },
661
+ body,
662
+ }, {
663
+ operation: 'Exchanging OAuth authorization code',
664
+ onRetry: (message) => updateTask(message),
665
+ });
666
+ }
667
+ catch (error) {
668
+ throw new Error(formatOauthFetchFailure('Failed to exchange OAuth authorization code.', {
669
+ envName: options.envName,
670
+ baseUrl: options.baseUrl,
671
+ url: options.metadata.token_endpoint,
672
+ rawMessage: error?.message,
673
+ }));
674
+ }
675
+ const data = await parseJsonResponse(response);
676
+ if (!response.ok) {
677
+ throw new Error(formatOauthError('Failed to exchange OAuth authorization code', data, response.status));
678
+ }
679
+ if (!data || typeof data !== 'object' || typeof data.access_token !== 'string') {
680
+ throw new Error('OAuth token response is missing access_token.');
681
+ }
682
+ return data;
683
+ }
684
+ async function refreshOauthAccessToken(options) {
685
+ if (!options.auth.refreshToken || !options.auth.clientId) {
686
+ throw new Error(`OAuth session for env "${options.envName}" cannot be refreshed. Run \`nb env auth ${options.envName}\`.`);
687
+ }
688
+ const metadata = await fetchOauthServerMetadata(options.baseUrl, { envName: options.envName });
689
+ const resource = options.auth.resource || getOauthResource(metadata.issuer);
690
+ const body = new URLSearchParams({
691
+ grant_type: 'refresh_token',
692
+ client_id: options.auth.clientId,
693
+ refresh_token: options.auth.refreshToken,
694
+ resource,
695
+ });
696
+ const response = await fetchWithOauthRetry(metadata.token_endpoint, {
697
+ method: 'POST',
698
+ headers: {
699
+ accept: 'application/json',
700
+ 'content-type': 'application/x-www-form-urlencoded',
701
+ },
702
+ body,
703
+ }, {
704
+ operation: `Refreshing OAuth session for env "${options.envName}"`,
705
+ }).catch((error) => {
706
+ throw new Error(formatOauthFetchFailure(`Failed to refresh OAuth session for env "${options.envName}".`, {
707
+ envName: options.envName,
708
+ baseUrl: options.baseUrl,
709
+ url: metadata.token_endpoint,
710
+ rawMessage: error?.message,
711
+ }));
712
+ });
713
+ const data = await parseJsonResponse(response);
714
+ if (!response.ok) {
715
+ throw new Error(formatOauthError(`Failed to refresh OAuth session for env "${options.envName}". Run \`nb env auth ${options.envName}\` again`, data, response.status));
716
+ }
717
+ if (!data || typeof data !== 'object' || typeof data.access_token !== 'string') {
718
+ throw new Error(`OAuth refresh response for env "${options.envName}" is missing access_token.`);
719
+ }
720
+ const nextAuth = {
721
+ type: 'oauth',
722
+ accessToken: data.access_token,
723
+ refreshToken: typeof data.refresh_token === 'string' ? data.refresh_token : options.auth.refreshToken,
724
+ expiresAt: calculateExpiresAt(data.expires_in),
725
+ scope: typeof data.scope === 'string' ? data.scope : options.auth.scope,
726
+ issuer: metadata.issuer,
727
+ clientId: options.auth.clientId,
728
+ resource,
729
+ };
730
+ await setEnvOauthSession(options.envName, nextAuth, {
731
+ scope: options.scope,
732
+ preserveRuntime: true,
733
+ });
734
+ return nextAuth.accessToken;
735
+ }
736
+ export async function resolveAccessToken(options) {
737
+ if (options.token) {
738
+ return options.token;
739
+ }
740
+ const envName = options.envName ?? (await getCurrentEnvName({ scope: options.scope }));
741
+ const env = await getEnv(envName, { scope: options.scope });
742
+ if (!env?.auth) {
743
+ return undefined;
744
+ }
745
+ if (env.auth.type === 'token') {
746
+ return env.auth.accessToken;
747
+ }
748
+ if (!isOauthAccessTokenExpired(env.auth)) {
749
+ return env.auth.accessToken;
750
+ }
751
+ const baseUrl = options.baseUrl ?? env.baseUrl;
752
+ if (!baseUrl) {
753
+ throw new Error(`Env "${envName}" is missing a base URL. Run \`nb env add ${envName} --base-url <url>\`.`);
754
+ }
755
+ printVerbose(`Refreshing OAuth session for env "${envName}"`);
756
+ return refreshOauthAccessToken({
757
+ envName,
758
+ baseUrl,
759
+ auth: env.auth,
760
+ scope: options.scope,
761
+ });
762
+ }
763
+ export async function resolveServerRequestTarget(options) {
764
+ const envName = options.envName ?? (await getCurrentEnvName({ scope: options.scope }));
765
+ const env = await getEnv(envName, { scope: options.scope });
766
+ const baseUrl = options.baseUrl ?? env?.baseUrl;
767
+ const token = await resolveAccessToken({
768
+ envName,
769
+ baseUrl,
770
+ token: options.token,
771
+ scope: options.scope,
772
+ });
773
+ if (!baseUrl) {
774
+ throw new Error('Missing base URL. Use --base-url or configure one with `nb env add`.');
775
+ }
776
+ return { baseUrl, token };
777
+ }
778
+ export async function authenticateEnvWithOauth(options) {
779
+ const envName = options.envName ?? (await getCurrentEnvName({ scope: options.scope }));
780
+ const env = await getEnv(envName, { scope: options.scope });
781
+ const baseUrl = env?.baseUrl;
782
+ if (!baseUrl) {
783
+ throw new Error([
784
+ env
785
+ ? `Environment "${envName}" does not have an API base URL yet.`
786
+ : `Environment "${envName}" has not been set up yet.`,
787
+ env
788
+ ? `Run \`nb env add ${envName} --base-url <url>\` to finish setting it up.`
789
+ : `Run \`nb env add ${envName}\` first.`,
790
+ ]
791
+ .filter(Boolean)
792
+ .join('\n'));
793
+ }
794
+ printVerbose(`Starting OAuth sign-in for env "${envName}" using ${baseUrl}`);
795
+ updateTask(`Checking sign-in settings for "${envName}"...`);
796
+ const metadata = await fetchOauthServerMetadata(baseUrl, {
797
+ envName,
798
+ onRetry: (message) => updateTask(message),
799
+ });
800
+ const state = encodeBase64Url(crypto.randomBytes(16));
801
+ const { codeVerifier, codeChallenge } = buildPkcePair();
802
+ const callback = await createLoopbackServer(state);
803
+ const resource = getOauthResource(metadata.issuer);
804
+ let cleanupBrowserOpenTarget;
805
+ try {
806
+ printVerbose(`OAuth callback listener ready at ${callback.redirectUri}`);
807
+ updateTask(`Preparing secure browser sign-in for "${envName}"...`);
808
+ const registration = await registerOauthClient(metadata, callback.redirectUri, {
809
+ envName,
810
+ baseUrl,
811
+ });
812
+ const authorizationUrl = new URL(metadata.authorization_endpoint);
813
+ authorizationUrl.searchParams.set('response_type', 'code');
814
+ authorizationUrl.searchParams.set('client_id', registration.clientId);
815
+ authorizationUrl.searchParams.set('redirect_uri', callback.redirectUri);
816
+ authorizationUrl.searchParams.set('scope', DEFAULT_OAUTH_SCOPE);
817
+ authorizationUrl.searchParams.set('state', state);
818
+ authorizationUrl.searchParams.set('prompt', 'consent');
819
+ authorizationUrl.searchParams.set('code_challenge', codeChallenge);
820
+ authorizationUrl.searchParams.set('code_challenge_method', 'S256');
821
+ authorizationUrl.searchParams.set('resource', resource);
822
+ updateTask(`Waiting for you to finish signing in for "${envName}"...`);
823
+ const browser = await maybeOpenBrowser(authorizationUrl.toString());
824
+ cleanupBrowserOpenTarget = browser.cleanup;
825
+ if (!browser.opened) {
826
+ printWarningBlock('We could not open your browser automatically. Open this URL to continue signing in:');
827
+ }
828
+ else {
829
+ printInfo('Your browser should open shortly. Finish signing in there to continue.');
830
+ }
831
+ printInfo(authorizationUrl.toString());
832
+ const code = await new Promise((resolve, reject) => {
833
+ const timeout = setTimeout(() => reject(new Error(`OAuth sign-in timed out after 5 minutes. Run \`nb env auth ${envName}\` to try again.`)), OAUTH_LOGIN_TIMEOUT_MS);
834
+ timeout.unref?.();
835
+ callback.waitForCode().then((value) => {
836
+ clearTimeout(timeout);
837
+ resolve(value);
838
+ }, (error) => {
839
+ clearTimeout(timeout);
840
+ reject(error);
841
+ });
842
+ });
843
+ updateTask(`Finishing sign-in for "${envName}"...`);
844
+ const tokenResponse = await exchangeAuthorizationCode({
845
+ metadata,
846
+ clientId: registration.clientId,
847
+ redirectUri: callback.redirectUri,
848
+ code,
849
+ codeVerifier,
850
+ resource,
851
+ envName,
852
+ baseUrl,
853
+ });
854
+ if (!tokenResponse.refresh_token) {
855
+ printWarning('Sign-in succeeded, but no refresh token was returned. You may need to sign in again when this session expires.');
856
+ }
857
+ await setEnvOauthSession(envName, {
858
+ type: 'oauth',
859
+ accessToken: tokenResponse.access_token,
860
+ refreshToken: tokenResponse.refresh_token,
861
+ expiresAt: calculateExpiresAt(tokenResponse.expires_in),
862
+ scope: tokenResponse.scope || DEFAULT_OAUTH_SCOPE,
863
+ issuer: metadata.issuer,
864
+ clientId: registration.clientId,
865
+ resource,
866
+ }, { scope: options.scope });
867
+ }
868
+ finally {
869
+ await cleanupBrowserOpenTarget?.().catch(() => undefined);
870
+ await callback.close().catch(() => undefined);
871
+ }
872
+ }