@nocobase/cli 2.1.0-beta.34 → 2.1.0-beta.36

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.
@@ -159,6 +159,9 @@ export class Env {
159
159
  get auth() {
160
160
  return this.config.auth;
161
161
  }
162
+ get authType() {
163
+ return resolveConfiguredAuthType(this.config);
164
+ }
162
165
  get runtime() {
163
166
  return this.config.runtime;
164
167
  }
@@ -210,6 +213,9 @@ export class Env {
210
213
  put('DB_DATABASE', this.config.dbDatabase);
211
214
  put('DB_USER', this.config.dbUser);
212
215
  put('DB_PASSWORD', this.config.dbPassword);
216
+ put('DB_SCHEMA', this.config.dbSchema);
217
+ put('DB_TABLE_PREFIX', this.config.dbTablePrefix);
218
+ put('DB_UNDERSCORED', this.config.dbUnderscored);
213
219
  return out;
214
220
  }
215
221
  }
@@ -253,27 +259,38 @@ async function writeEnv(envName, updater, options = {}) {
253
259
  config.envs[envName] = updater(previous);
254
260
  await saveAuthConfig(config, options);
255
261
  }
262
+ function normalizeConfiguredAuthType(value) {
263
+ return value === 'token' || value === 'oauth' ? value : undefined;
264
+ }
265
+ export function resolveConfiguredAuthType(config) {
266
+ return normalizeConfiguredAuthType(config?.authType) ?? normalizeConfiguredAuthType(config?.auth?.type);
267
+ }
256
268
  export async function upsertEnv(envName, config, options = {}) {
257
269
  await writeEnv(envName, (previous) => {
258
- const { apiBaseUrl: _apiBaseUrl, baseUrl: _baseUrl, apibaseUrl: _legacyApiBaseUrl, accessToken, ...rest } = config;
270
+ const { apiBaseUrl: _apiBaseUrl, baseUrl: _baseUrl, apibaseUrl: _legacyApiBaseUrl, accessToken, authType, ...rest } = config;
259
271
  const nextApiBaseUrl = readEnvApiBaseUrl(config);
260
272
  const previousApiBaseUrl = readEnvApiBaseUrl(previous);
261
273
  const baseUrlChanged = previousApiBaseUrl !== nextApiBaseUrl;
274
+ const previousAuthType = resolveConfiguredAuthType(previous);
275
+ const requestedAuthType = normalizeConfiguredAuthType(authType);
276
+ const nextAuthType = requestedAuthType ?? (accessToken ? 'token' : previousAuthType);
262
277
  const nextAuth = accessToken
263
278
  ? {
264
279
  type: 'token',
265
280
  accessToken,
266
281
  }
267
- : baseUrlChanged || previous?.auth?.type === 'token'
282
+ : nextAuthType === 'token' || baseUrlChanged || previous?.auth?.type === 'token'
268
283
  ? undefined
269
284
  : previous?.auth;
270
285
  const authChanged = !areAuthConfigsEquivalent(previous?.auth, nextAuth);
286
+ const authTypeChanged = previousAuthType !== nextAuthType;
271
287
  return {
272
288
  ...previous,
273
289
  apiBaseUrl: nextApiBaseUrl,
290
+ authType: nextAuthType,
274
291
  auth: nextAuth,
275
292
  ...rest,
276
- runtime: baseUrlChanged || authChanged ? undefined : previous?.runtime,
293
+ runtime: baseUrlChanged || authChanged || authTypeChanged ? undefined : previous?.runtime,
277
294
  };
278
295
  }, options);
279
296
  }
@@ -282,26 +299,32 @@ export async function updateEnvConnection(envName, updates, options = {}) {
282
299
  const nextApiBaseUrl = readEnvApiBaseUrl(updates) ?? readEnvApiBaseUrl(previous);
283
300
  const previousApiBaseUrl = readEnvApiBaseUrl(previous);
284
301
  const baseUrlChanged = previousApiBaseUrl !== nextApiBaseUrl;
302
+ const previousAuthType = resolveConfiguredAuthType(previous);
303
+ const requestedAuthType = normalizeConfiguredAuthType(updates.authType);
304
+ const nextAuthType = requestedAuthType ?? (updates.accessToken ? 'token' : previousAuthType);
285
305
  const nextAuth = updates.accessToken
286
306
  ? {
287
307
  type: 'token',
288
308
  accessToken: updates.accessToken,
289
309
  }
290
- : baseUrlChanged || previous?.auth?.type === 'token'
310
+ : nextAuthType === 'token' || baseUrlChanged || previous?.auth?.type === 'token'
291
311
  ? undefined
292
312
  : previous?.auth;
293
313
  const authChanged = !areAuthConfigsEquivalent(previous?.auth, nextAuth);
314
+ const authTypeChanged = previousAuthType !== nextAuthType;
294
315
  return {
295
316
  ...previous,
296
317
  ...(nextApiBaseUrl !== undefined ? { apiBaseUrl: nextApiBaseUrl } : {}),
318
+ authType: nextAuthType,
297
319
  auth: nextAuth,
298
- runtime: baseUrlChanged || authChanged ? undefined : previous?.runtime,
320
+ runtime: baseUrlChanged || authChanged || authTypeChanged ? undefined : previous?.runtime,
299
321
  };
300
322
  }, options);
301
323
  }
302
324
  export async function setEnvOauthSession(envName, auth, options = {}) {
303
325
  await writeEnv(envName, (previous) => ({
304
326
  ...previous,
327
+ authType: 'oauth',
305
328
  auth,
306
329
  runtime: options.preserveRuntime ? previous?.runtime : undefined,
307
330
  }), options);
@@ -0,0 +1,171 @@
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 { promises as fs } from 'node:fs';
10
+ import path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { getCurrentEnvName, getEnv, listEnvs } from './auth-store.js';
13
+ import { updateEnvRuntime } from './bootstrap.js';
14
+ import { resolveDefaultConfigScope } from './cli-home.js';
15
+ import { commandOutput } from './run-npm.js';
16
+ import { loadRuntimeSync } from './runtime-store.js';
17
+ import { failTask, startTask, succeedTask } from './ui.js';
18
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
+ const CLI_PACKAGE_ROOT = path.resolve(__dirname, '..', '..');
20
+ const CLI_CONFIG_FILE = path.join(CLI_PACKAGE_ROOT, 'nocobase-ctl.config.json');
21
+ const CLI_ENTRY_FILE = path.join(CLI_PACKAGE_ROOT, 'bin', 'run.js');
22
+ export const BACKUP_POLL_INTERVAL_MS = 2_000;
23
+ export const BACKUP_CREATE_TIMEOUT_MS = 600_000;
24
+ export const BACKUP_RUNTIME_COMMANDS = {
25
+ create: 'backup create',
26
+ status: 'backup status',
27
+ download: 'backup download',
28
+ restoreUpload: 'backup restore-upload',
29
+ };
30
+ function hasRequiredBackupCommands(runtime, commandIds) {
31
+ if (!runtime) {
32
+ return false;
33
+ }
34
+ const available = new Set(runtime.commands.map((command) => command.commandId));
35
+ return commandIds.every((commandId) => available.has(commandId));
36
+ }
37
+ function formatMissingBackupRuntimeCommands(envName, commandIds) {
38
+ const missing = commandIds.map((commandId) => `nb api ${commandId}`).join(', ');
39
+ return [
40
+ `The selected env "${envName}" does not expose the backup API commands required by \`nb backup\`.`,
41
+ `Missing commands: ${missing}`,
42
+ 'Enable or upgrade the backup/restore capability for that env, then try again.',
43
+ ].join('\n');
44
+ }
45
+ export function buildBackupEnvArgv(options) {
46
+ const argv = [];
47
+ if (options.explicitEnvSelection && options.requestedEnv) {
48
+ argv.push('--env', options.requestedEnv);
49
+ }
50
+ if (options.yes || options.explicitEnvSelection) {
51
+ argv.push('--yes');
52
+ }
53
+ return argv;
54
+ }
55
+ export async function resolveBackupTargetEnv(requestedEnv) {
56
+ const scope = resolveDefaultConfigScope();
57
+ const envName = requestedEnv?.trim() || (await getCurrentEnvName({ scope }));
58
+ const env = await getEnv(envName, { scope });
59
+ if (env) {
60
+ return { scope, envName, env };
61
+ }
62
+ const { envs } = await listEnvs({ scope });
63
+ const configuredEnvNames = Object.keys(envs);
64
+ if (!configuredEnvNames.length) {
65
+ throw new Error('No env is configured. Run `nb env add <name> --api-base-url <url>` first.');
66
+ }
67
+ if (requestedEnv?.trim()) {
68
+ throw new Error(`Env "${envName}" is not configured. Run \`nb env add ${envName} --api-base-url <url>\` first.`);
69
+ }
70
+ throw new Error([
71
+ `Current env "${envName}" is not configured.`,
72
+ 'Switch to an existing env with `nb env use <name>`, or add one with `nb env add <name> --api-base-url <url>`.',
73
+ ].join('\n'));
74
+ }
75
+ export async function ensureBackupRuntimeCommands(params) {
76
+ const scope = resolveDefaultConfigScope();
77
+ const env = params.env ?? (await getEnv(params.envName, { scope }));
78
+ const currentRuntime = loadRuntimeSync(env?.runtime?.version, { scope });
79
+ if (hasRequiredBackupCommands(currentRuntime, params.commandIds)) {
80
+ return;
81
+ }
82
+ if (!params.quiet) {
83
+ startTask(`Refreshing env runtime for "${params.envName}" to load backup commands...`);
84
+ }
85
+ try {
86
+ const runtime = await updateEnvRuntime({
87
+ envName: params.envName,
88
+ scope,
89
+ configFile: CLI_CONFIG_FILE,
90
+ quiet: params.quiet,
91
+ });
92
+ if (!hasRequiredBackupCommands(runtime, params.commandIds)) {
93
+ throw new Error(formatMissingBackupRuntimeCommands(params.envName, params.commandIds));
94
+ }
95
+ if (!params.quiet) {
96
+ succeedTask(`Env runtime is ready for backup commands in "${params.envName}".`);
97
+ }
98
+ }
99
+ catch (error) {
100
+ if (!params.quiet) {
101
+ failTask(`Failed to refresh backup commands for "${params.envName}".`);
102
+ }
103
+ throw error;
104
+ }
105
+ }
106
+ export async function runBackupCliCommand(argv, options) {
107
+ return await commandOutput(process.execPath, [CLI_ENTRY_FILE, ...argv], {
108
+ errorName: options?.errorName ?? `nb ${argv.join(' ')}`,
109
+ env: {
110
+ NB_SKIP_STARTUP_UPDATE: '1',
111
+ // When the parent CLI already runs in tsx source mode, it sets
112
+ // `_NOCO_CLI_TSX_CHILD=1`. Clear it here so `bin/run.js` can re-exec
113
+ // itself with `--import tsx` again instead of trying to import `.ts`
114
+ // sources without the loader.
115
+ _NOCO_CLI_TSX_CHILD: '',
116
+ },
117
+ });
118
+ }
119
+ export async function runBackupCliJsonCommand(argv, options) {
120
+ const output = await runBackupCliCommand([...argv, '--json-output'], options);
121
+ try {
122
+ return JSON.parse(output);
123
+ }
124
+ catch {
125
+ throw new Error(`Unexpected JSON output from ${options?.errorName ?? `nb ${argv.join(' ')}`}: ${output || '(empty output)'}`);
126
+ }
127
+ }
128
+ export async function resolveBackupCreateOutputPath(output, remoteName) {
129
+ const requestedOutput = String(output ?? '').trim();
130
+ if (!requestedOutput) {
131
+ return path.resolve(process.cwd(), remoteName);
132
+ }
133
+ const resolvedOutput = path.resolve(process.cwd(), requestedOutput);
134
+ try {
135
+ const stats = await fs.stat(resolvedOutput);
136
+ if (stats.isDirectory()) {
137
+ return path.join(resolvedOutput, remoteName);
138
+ }
139
+ }
140
+ catch {
141
+ // Treat non-existing paths as an explicit target file path.
142
+ }
143
+ return resolvedOutput;
144
+ }
145
+ export async function resolveBackupRestoreFilePath(file) {
146
+ const resolvedFile = path.resolve(process.cwd(), file);
147
+ let stats;
148
+ try {
149
+ stats = await fs.stat(resolvedFile);
150
+ }
151
+ catch {
152
+ throw new Error(`Backup file not found: ${resolvedFile}`);
153
+ }
154
+ if (!stats.isFile()) {
155
+ throw new Error(`Backup restore input must be a file: ${resolvedFile}`);
156
+ }
157
+ return resolvedFile;
158
+ }
159
+ export function resolveBackupWaitApiBaseUrl(env) {
160
+ const baseUrl = String(env.baseUrl ?? '').trim();
161
+ if (baseUrl) {
162
+ return baseUrl.replace(/\/+$/, '');
163
+ }
164
+ const appPort = env.appPort === undefined || env.appPort === null
165
+ ? ''
166
+ : String(env.appPort).trim();
167
+ return appPort ? `http://127.0.0.1:${appPort}/api` : undefined;
168
+ }
169
+ export async function sleep(ms) {
170
+ await new Promise((resolve) => setTimeout(resolve, ms));
171
+ }
@@ -137,7 +137,7 @@ function getSwaggerUrl(baseUrl) {
137
137
  function getHealthCheckUrl(baseUrl) {
138
138
  return `${baseUrl.replace(/\/+$/, '')}/__health_check`;
139
139
  }
140
- async function waitForServiceReady(baseUrl, token, role) {
140
+ async function waitForServiceReady(baseUrl, token, role, options) {
141
141
  const healthCheckUrl = getHealthCheckUrl(baseUrl);
142
142
  const startedAt = Date.now();
143
143
  let notified = false;
@@ -162,18 +162,22 @@ async function waitForServiceReady(baseUrl, token, role) {
162
162
  return;
163
163
  }
164
164
  if (!notified) {
165
- printVerbose(`Waiting for health check: ${healthCheckUrl}`);
166
- updateTask(`Waiting for application readiness (${healthCheckUrl})`);
165
+ if (!options?.quiet) {
166
+ printVerbose(`Waiting for health check: ${healthCheckUrl}`);
167
+ updateTask(`Waiting for application readiness (${healthCheckUrl})`);
168
+ }
167
169
  notified = true;
168
170
  }
169
171
  await sleep(APP_RETRY_INTERVAL);
170
172
  }
171
173
  throw new Error(`The application did not become ready in time. Expected \`${healthCheckUrl}\` to respond with \`ok\`.`);
172
174
  }
173
- async function waitForSwaggerSchema(baseUrl, token, role) {
175
+ async function waitForSwaggerSchema(baseUrl, token, role, options) {
174
176
  const swaggerUrl = getSwaggerUrl(baseUrl);
175
177
  const startedAt = Date.now();
176
- printVerbose(`Checking swagger schema: ${swaggerUrl}`);
178
+ if (!options?.quiet) {
179
+ printVerbose(`Checking swagger schema: ${swaggerUrl}`);
180
+ }
177
181
  while (Date.now() - startedAt < APP_RETRY_TIMEOUT) {
178
182
  const response = await requestJson(swaggerUrl, { token, role });
179
183
  if (response.ok) {
@@ -182,7 +186,7 @@ async function waitForSwaggerSchema(baseUrl, token, role) {
182
186
  if (!shouldRetryAppAvailability(response)) {
183
187
  return response;
184
188
  }
185
- await waitForServiceReady(baseUrl, token, role);
189
+ await waitForServiceReady(baseUrl, token, role, options);
186
190
  }
187
191
  return await requestJson(swaggerUrl, { token, role });
188
192
  }
@@ -200,9 +204,9 @@ async function confirmEnableApiDoc() {
200
204
  async function fetchSwaggerSchema(baseUrl, token, role, context = {}, options = {}) {
201
205
  let response = options.retryAppAvailability === false
202
206
  ? await requestJson(getSwaggerUrl(baseUrl), { token, role })
203
- : await waitForSwaggerSchema(baseUrl, token, role);
207
+ : await waitForSwaggerSchema(baseUrl, token, role, { quiet: options.quiet });
204
208
  if (response.status === 404) {
205
- if (options.allowEnableApiDoc === false) {
209
+ if (options.allowEnableApiDoc === false || options.quiet) {
206
210
  throw new Error('`swagger:get` returned 404. Check the base URL and enable the `API documentation plugin` if needed.');
207
211
  }
208
212
  printInfo('The API documentation plugin is not enabled.');
@@ -257,7 +261,7 @@ export function formatSwaggerSchemaError(response, context) {
257
261
  `Authentication failed while loading the command runtime from \`swagger:get\`${envLabel}.`,
258
262
  `Base URL: ${context.baseUrl}`,
259
263
  details,
260
- 'Update the API key with `nb env add <name> --api-base-url <url> --auth-type token --token <api-key>`, log in with `nb env auth <name>`, or rerun the command with `--token <api-key>`.',
264
+ 'Update the API key with `nb env add <name> --api-base-url <url> --auth-type token --access-token <api-key>`, log in with `nb env auth <name>`, or rerun the command with `--access-token <api-key>`.',
261
265
  commandHint,
262
266
  ].join('\n');
263
267
  }
@@ -368,10 +372,14 @@ export async function updateEnvRuntime(options) {
368
372
  .filter(Boolean)
369
373
  .join('\n'));
370
374
  }
371
- updateTask('Loading command runtime...');
375
+ if (!options.quiet) {
376
+ updateTask('Loading command runtime...');
377
+ }
372
378
  try {
373
- printVerbose(`Runtime source: ${baseUrl}`);
374
- const document = await fetchSwaggerSchema(baseUrl, token, options.role, { envName });
379
+ if (!options.quiet) {
380
+ printVerbose(`Runtime source: ${baseUrl}`);
381
+ }
382
+ const document = await fetchSwaggerSchema(baseUrl, token, options.role, { envName }, { quiet: options.quiet });
375
383
  const runtime = await generateRuntime(document, options.configFile, baseUrl);
376
384
  await saveRuntime(runtime, { scope: options.scope });
377
385
  if (options.baseUrl !== undefined || options.token !== undefined) {
@@ -388,6 +396,8 @@ export async function updateEnvRuntime(options) {
388
396
  return runtime;
389
397
  }
390
398
  finally {
391
- stopTask();
399
+ if (!options.quiet) {
400
+ stopTask();
401
+ }
392
402
  }
393
403
  }
@@ -26,6 +26,8 @@ const STRING_ENV_CONFIG_KEYS = [
26
26
  'dbDatabase',
27
27
  'dbUser',
28
28
  'dbPassword',
29
+ 'dbSchema',
30
+ 'dbTablePrefix',
29
31
  'rootUsername',
30
32
  'rootEmail',
31
33
  'rootPassword',
@@ -36,6 +38,7 @@ const BOOLEAN_ENV_CONFIG_KEYS = [
36
38
  'devDependencies',
37
39
  'build',
38
40
  'buildDts',
41
+ 'dbUnderscored',
39
42
  ];
40
43
  function trimConfigValue(value) {
41
44
  const text = String(value ?? '').trim();
@@ -80,6 +83,9 @@ export function buildStoredEnvConfig(input) {
80
83
  }
81
84
  }
82
85
  const authType = trimConfigValue(input.authType);
86
+ if (authType === 'token' || authType === 'oauth') {
87
+ envConfig.authType = authType;
88
+ }
83
89
  const accessToken = trimConfigValue(input.accessToken);
84
90
  if (authType === 'token' && accessToken) {
85
91
  envConfig.accessToken = accessToken;
@@ -31,6 +31,14 @@ function pathExists(candidate) {
31
31
  return false;
32
32
  }
33
33
  }
34
+ function isDirectory(candidate) {
35
+ try {
36
+ return Boolean(candidate) && fs.statSync(candidate).isDirectory();
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
34
42
  function hasLocalNocoBaseBinary(candidate) {
35
43
  return (pathExists(path.join(candidate, 'node_modules', '.bin', 'nocobase-v1'))
36
44
  || pathExists(path.join(candidate, 'node_modules', '.bin', 'nocobase-v1.cmd')));
@@ -44,21 +52,24 @@ export function resolveCwd(cwd) {
44
52
  }
45
53
  export function resolveProjectCwd(cwd) {
46
54
  const normalizedCwd = typeof cwd === 'string' && cwd.trim() === '' ? undefined : cwd;
47
- const next = normalizedCwd ?? process.cwd();
48
- const resolvedNext = resolveCwd(normalizedCwd);
49
- if (!normalizedCwd || path.isAbsolute(next)) {
50
- return resolvedNext;
55
+ const fallback = resolveCwd(normalizedCwd);
56
+ const hasExplicitInput = normalizedCwd !== undefined;
57
+ if (hasExplicitInput && !pathExists(fallback)) {
58
+ throw new Error(`The specified --cwd does not exist: ${fallback}`);
51
59
  }
52
- const baseCwd = process.cwd();
53
- let current = baseCwd;
54
- const fallback = resolvedNext;
60
+ if (hasExplicitInput && !isDirectory(fallback)) {
61
+ throw new Error(`The specified --cwd is not a directory: ${fallback}`);
62
+ }
63
+ let current = hasExplicitInput ? fallback : process.cwd();
55
64
  while (true) {
56
- const candidate = path.resolve(current, next);
57
- if (hasLocalNocoBaseBinary(candidate)) {
58
- return candidate;
65
+ if (hasLocalNocoBaseBinary(current)) {
66
+ return current;
59
67
  }
60
68
  const parent = path.dirname(current);
61
69
  if (parent === current) {
70
+ if (hasExplicitInput) {
71
+ throw new Error(`Couldn't find a NocoBase source project from --cwd: ${fallback}`);
72
+ }
62
73
  return fallback;
63
74
  }
64
75
  current = parent;
@@ -78,6 +89,20 @@ export function run(name, args, options) {
78
89
  },
79
90
  windowsHide: process.platform === 'win32',
80
91
  });
92
+ if (options?.stdio === 'pipe') {
93
+ child.stdout?.setEncoding('utf8');
94
+ child.stderr?.setEncoding('utf8');
95
+ if (options.onStdout) {
96
+ child.stdout?.on('data', (chunk) => {
97
+ options.onStdout?.(String(chunk));
98
+ });
99
+ }
100
+ if (options.onStderr) {
101
+ child.stderr?.on('data', (chunk) => {
102
+ options.onStderr?.(String(chunk));
103
+ });
104
+ }
105
+ }
81
106
  const cleanupSignalForwarding = forwardSignalsToChild(child);
82
107
  child.once('error', (error) => {
83
108
  cleanupSignalForwarding();