@nocobase/cli 2.1.0-beta.35 → 2.1.0-beta.37

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 (39) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/bin/run.js +3 -2
  4. package/dist/commands/app/upgrade.js +38 -16
  5. package/dist/commands/backup/create.js +147 -0
  6. package/dist/commands/backup/index.js +20 -0
  7. package/dist/commands/backup/restore.js +105 -0
  8. package/dist/commands/config/delete.js +4 -0
  9. package/dist/commands/config/get.js +4 -0
  10. package/dist/commands/config/set.js +5 -1
  11. package/dist/commands/env/add.js +129 -15
  12. package/dist/commands/env/auth.js +145 -12
  13. package/dist/commands/env/info.js +52 -8
  14. package/dist/commands/env/list.js +2 -2
  15. package/dist/commands/env/shared.js +41 -3
  16. package/dist/commands/init.js +254 -136
  17. package/dist/commands/install.js +447 -272
  18. package/dist/commands/license/activate.js +6 -4
  19. package/dist/commands/source/publish.js +17 -0
  20. package/dist/commands/v1.js +210 -0
  21. package/dist/lib/app-managed-resources.js +20 -1
  22. package/dist/lib/app-runtime.js +13 -4
  23. package/dist/lib/auth-store.js +69 -18
  24. package/dist/lib/backup.js +171 -0
  25. package/dist/lib/bootstrap.js +23 -13
  26. package/dist/lib/cli-config.js +99 -4
  27. package/dist/lib/cli-locale.js +19 -7
  28. package/dist/lib/db-connection-check.js +61 -0
  29. package/dist/lib/env-auth.js +79 -0
  30. package/dist/lib/env-config.js +8 -1
  31. package/dist/lib/prompt-validators.js +23 -5
  32. package/dist/lib/prompt-web-ui.js +143 -19
  33. package/dist/lib/run-npm.js +166 -30
  34. package/dist/lib/skills-manager.js +74 -4
  35. package/dist/lib/source-publish.js +20 -1
  36. package/dist/lib/source-registry.js +2 -2
  37. package/dist/locale/en-US.json +36 -5
  38. package/dist/locale/zh-CN.json +36 -5
  39. package/package.json +6 -3
@@ -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
  }
@@ -8,13 +8,21 @@
8
8
  */
9
9
  import { loadExactAuthConfig, saveAuthConfig } from './auth-store.js';
10
10
  import { resolveDefaultConfigScope } from './cli-home.js';
11
+ import { CLI_LOCALE_FLAG_OPTIONS, normalizeCliLocale, resolveCliLocale } from './cli-locale.js';
11
12
  export const DEFAULT_LICENSE_PKG_URL = 'https://pkg.nocobase.com/';
12
13
  export const DEFAULT_DOCKER_NETWORK = 'nocobase';
13
14
  export const DEFAULT_DOCKER_CONTAINER_PREFIX = 'nb';
15
+ export const DEFAULT_DOCKER_BIN = 'docker';
16
+ export const DEFAULT_GIT_BIN = 'git';
17
+ export const DEFAULT_YARN_BIN = 'yarn';
14
18
  export const SUPPORTED_CLI_CONFIG_KEYS = [
19
+ 'locale',
15
20
  'license.pkg-url',
16
21
  'docker.network',
17
22
  'docker.container-prefix',
23
+ 'bin.docker',
24
+ 'bin.git',
25
+ 'bin.yarn',
18
26
  ];
19
27
  function trimValue(value) {
20
28
  const text = String(value ?? '').trim();
@@ -36,11 +44,16 @@ export function assertSupportedCliConfigKey(value) {
36
44
  }
37
45
  function cloneSettings(config) {
38
46
  return {
47
+ ...(config.settings?.locale ? { locale: trimValue(config.settings.locale) } : {}),
39
48
  license: config.settings?.license ? { ...config.settings.license } : undefined,
40
49
  docker: config.settings?.docker ? { ...config.settings.docker } : undefined,
50
+ bin: config.settings?.bin ? { ...config.settings.bin } : undefined,
41
51
  };
42
52
  }
43
53
  function pruneSettings(config) {
54
+ if (config.settings && !trimValue(config.settings.locale)) {
55
+ delete config.settings.locale;
56
+ }
44
57
  const license = config.settings?.license;
45
58
  if (license && !trimValue(license.pkgUrl)) {
46
59
  delete config.settings?.license;
@@ -49,34 +62,56 @@ function pruneSettings(config) {
49
62
  if (docker && !trimValue(docker.network) && !trimValue(docker.containerPrefix)) {
50
63
  delete config.settings?.docker;
51
64
  }
52
- if (config.settings
53
- && !config.settings.license
54
- && !config.settings.docker) {
65
+ const bin = config.settings?.bin;
66
+ if (bin && !trimValue(bin.docker) && !trimValue(bin.git) && !trimValue(bin.yarn)) {
67
+ delete config.settings?.bin;
68
+ }
69
+ if (config.settings &&
70
+ !config.settings.locale &&
71
+ !config.settings.license &&
72
+ !config.settings.docker &&
73
+ !config.settings.bin) {
55
74
  delete config.settings;
56
75
  }
57
76
  }
58
77
  export function getExplicitCliConfigValue(config, key) {
59
78
  switch (key) {
79
+ case 'locale':
80
+ return trimValue(config.settings?.locale);
60
81
  case 'license.pkg-url':
61
82
  return trimValue(config.settings?.license?.pkgUrl);
62
83
  case 'docker.network':
63
84
  return trimValue(config.settings?.docker?.network);
64
85
  case 'docker.container-prefix':
65
86
  return trimValue(config.settings?.docker?.containerPrefix);
87
+ case 'bin.docker':
88
+ return trimValue(config.settings?.bin?.docker);
89
+ case 'bin.git':
90
+ return trimValue(config.settings?.bin?.git);
91
+ case 'bin.yarn':
92
+ return trimValue(config.settings?.bin?.yarn);
66
93
  }
67
94
  }
68
95
  export function getEffectiveCliConfigValue(config, key) {
69
96
  const explicit = getExplicitCliConfigValue(config, key);
70
- if (explicit) {
97
+ if (explicit && key !== 'locale') {
71
98
  return explicit;
72
99
  }
73
100
  switch (key) {
101
+ case 'locale':
102
+ return resolveCliLocale(undefined, { configuredLocale: trimValue(config.settings?.locale) });
74
103
  case 'license.pkg-url':
75
104
  return DEFAULT_LICENSE_PKG_URL;
76
105
  case 'docker.network':
77
106
  return trimValue(config.name) || DEFAULT_DOCKER_NETWORK;
78
107
  case 'docker.container-prefix':
79
108
  return trimValue(config.name) || DEFAULT_DOCKER_CONTAINER_PREFIX;
109
+ case 'bin.docker':
110
+ return DEFAULT_DOCKER_BIN;
111
+ case 'bin.git':
112
+ return DEFAULT_GIT_BIN;
113
+ case 'bin.yarn':
114
+ return DEFAULT_YARN_BIN;
80
115
  }
81
116
  }
82
117
  export function normalizeCliConfigValue(key, value) {
@@ -87,6 +122,13 @@ export function normalizeCliConfigValue(key, value) {
87
122
  if (key === 'license.pkg-url') {
88
123
  return normalized.replace(/\/+$/, '') + '/';
89
124
  }
125
+ if (key === 'locale') {
126
+ const locale = normalizeCliLocale(normalized);
127
+ if (!locale) {
128
+ throw new Error(`Config key "${key}" must be one of: ${CLI_LOCALE_FLAG_OPTIONS.join(', ')}`);
129
+ }
130
+ return locale;
131
+ }
90
132
  return normalized;
91
133
  }
92
134
  export async function loadCliConfig(options = {}) {
@@ -113,6 +155,9 @@ export async function setCliConfigValue(key, value, options = {}) {
113
155
  const normalized = normalizeCliConfigValue(key, value);
114
156
  config.settings = cloneSettings(config);
115
157
  switch (key) {
158
+ case 'locale':
159
+ config.settings.locale = normalized;
160
+ break;
116
161
  case 'license.pkg-url':
117
162
  config.settings.license = {
118
163
  ...(config.settings.license ?? {}),
@@ -131,6 +176,24 @@ export async function setCliConfigValue(key, value, options = {}) {
131
176
  containerPrefix: normalized,
132
177
  };
133
178
  break;
179
+ case 'bin.docker':
180
+ config.settings.bin = {
181
+ ...(config.settings.bin ?? {}),
182
+ docker: normalized,
183
+ };
184
+ break;
185
+ case 'bin.git':
186
+ config.settings.bin = {
187
+ ...(config.settings.bin ?? {}),
188
+ git: normalized,
189
+ };
190
+ break;
191
+ case 'bin.yarn':
192
+ config.settings.bin = {
193
+ ...(config.settings.bin ?? {}),
194
+ yarn: normalized,
195
+ };
196
+ break;
134
197
  }
135
198
  pruneSettings(config);
136
199
  await saveAuthConfig(config, scope);
@@ -145,6 +208,9 @@ export async function deleteCliConfigValue(key, options = {}) {
145
208
  }
146
209
  config.settings = cloneSettings(config);
147
210
  switch (key) {
211
+ case 'locale':
212
+ delete config.settings.locale;
213
+ break;
148
214
  case 'license.pkg-url':
149
215
  if (config.settings.license) {
150
216
  delete config.settings.license.pkgUrl;
@@ -160,6 +226,21 @@ export async function deleteCliConfigValue(key, options = {}) {
160
226
  delete config.settings.docker.containerPrefix;
161
227
  }
162
228
  break;
229
+ case 'bin.docker':
230
+ if (config.settings.bin) {
231
+ delete config.settings.bin.docker;
232
+ }
233
+ break;
234
+ case 'bin.git':
235
+ if (config.settings.bin) {
236
+ delete config.settings.bin.git;
237
+ }
238
+ break;
239
+ case 'bin.yarn':
240
+ if (config.settings.bin) {
241
+ delete config.settings.bin.yarn;
242
+ }
243
+ break;
163
244
  }
164
245
  pruneSettings(config);
165
246
  await saveAuthConfig(config, scope);
@@ -174,3 +255,17 @@ export async function resolveDockerContainerPrefix(options = {}) {
174
255
  export async function resolveLicensePkgUrlFromConfig(options = {}) {
175
256
  return await getCliConfigValue('license.pkg-url', options);
176
257
  }
258
+ const CONFIGURABLE_COMMAND_KEYS = {
259
+ docker: 'bin.docker',
260
+ git: 'bin.git',
261
+ yarn: 'bin.yarn',
262
+ };
263
+ export function isConfigurableCommandName(value) {
264
+ return Object.prototype.hasOwnProperty.call(CONFIGURABLE_COMMAND_KEYS, value);
265
+ }
266
+ export async function resolveConfiguredCommandName(commandName, options = {}) {
267
+ if (!isConfigurableCommandName(commandName)) {
268
+ return commandName;
269
+ }
270
+ return await getCliConfigValue(CONFIGURABLE_COMMAND_KEYS[commandName], options);
271
+ }
@@ -9,12 +9,13 @@
9
9
  import { readFileSync } from 'node:fs';
10
10
  import path from 'node:path';
11
11
  import { fileURLToPath } from 'node:url';
12
+ import { resolveCliHomeDir } from './cli-home.js';
12
13
  export const SUPPORTED_CLI_LOCALES = ['en-US', 'zh-CN'];
13
14
  export const CLI_LOCALE_FLAG_OPTIONS = [...SUPPORTED_CLI_LOCALES];
14
15
  export const CLI_LOCALE_FLAG_DESCRIPTION = 'Language for CLI prompts and the local setup UI.';
15
16
  const DEFAULT_CLI_LOCALE = 'en-US';
16
17
  const localeCache = {};
17
- function normalizeCliLocale(value) {
18
+ export function normalizeCliLocale(value) {
18
19
  const raw = String(value ?? '').trim();
19
20
  if (!raw) {
20
21
  return undefined;
@@ -28,6 +29,17 @@ function normalizeCliLocale(value) {
28
29
  }
29
30
  return undefined;
30
31
  }
32
+ function readConfiguredCliLocale() {
33
+ try {
34
+ const configPath = path.join(resolveCliHomeDir(), 'config.json');
35
+ const content = readFileSync(configPath, 'utf8');
36
+ const parsed = JSON.parse(content);
37
+ return normalizeCliLocale(parsed.settings?.locale === undefined ? undefined : String(parsed.settings.locale));
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ }
31
43
  function loadLocaleMessages(locale) {
32
44
  if (localeCache[locale]) {
33
45
  return localeCache[locale];
@@ -65,9 +77,11 @@ function interpolateTemplate(template, values) {
65
77
  return value === undefined || value === null ? '' : String(value);
66
78
  });
67
79
  }
68
- export function detectCliLocale() {
80
+ export function detectCliLocale(configuredLocale) {
81
+ const resolvedConfiguredLocale = configuredLocale ?? readConfiguredCliLocale();
69
82
  const candidates = [
70
83
  process.env.NB_LOCALE,
84
+ resolvedConfiguredLocale,
71
85
  process.env.LC_ALL,
72
86
  process.env.LC_MESSAGES,
73
87
  process.env.LANG,
@@ -81,8 +95,8 @@ export function detectCliLocale() {
81
95
  }
82
96
  return DEFAULT_CLI_LOCALE;
83
97
  }
84
- export function resolveCliLocale(preferred) {
85
- return normalizeCliLocale(preferred) ?? detectCliLocale();
98
+ export function resolveCliLocale(preferred, options) {
99
+ return normalizeCliLocale(preferred) ?? detectCliLocale(options?.configuredLocale);
86
100
  }
87
101
  export function applyCliLocale(preferred) {
88
102
  const locale = resolveCliLocale(preferred);
@@ -111,9 +125,7 @@ export function localeText(key, values, fallback) {
111
125
  };
112
126
  }
113
127
  export function isLocalizedTextDef(value) {
114
- return Boolean(value
115
- && typeof value === 'object'
116
- && typeof value.key === 'string');
128
+ return Boolean(value && typeof value === 'object' && typeof value.key === 'string');
117
129
  }
118
130
  export function resolveLocalizedText(text, options) {
119
131
  if (text === undefined) {
@@ -10,6 +10,7 @@ import { translateCli } from "./cli-locale.js";
10
10
  import { validateTcpPort } from "./prompt-validators.js";
11
11
  const DB_CONNECTION_TIMEOUT_MS = 5_000;
12
12
  const externalDbValidationCache = new Map();
13
+ const mysqlLowerCaseTableNamesCache = new Map();
13
14
  function trimPromptValue(value) {
14
15
  return String(value ?? '').trim();
15
16
  }
@@ -117,6 +118,36 @@ async function checkMysqlFamilyConnection(config) {
117
118
  await Promise.resolve(connection.end()).catch(() => undefined);
118
119
  }
119
120
  }
121
+ async function readMysqlFamilyLowerCaseTableNames(config) {
122
+ const { default: mysql } = await import('mysql2/promise');
123
+ const connection = await mysql.createConnection({
124
+ host: config.host,
125
+ port: config.port,
126
+ user: config.user,
127
+ password: config.password,
128
+ database: config.database,
129
+ connectTimeout: DB_CONNECTION_TIMEOUT_MS,
130
+ });
131
+ try {
132
+ const [rows] = await connection.query(`SHOW VARIABLES LIKE 'lower_case_table_names'`);
133
+ if (!Array.isArray(rows)) {
134
+ return undefined;
135
+ }
136
+ for (const row of rows) {
137
+ if (!row || typeof row !== 'object') {
138
+ continue;
139
+ }
140
+ const value = String(row.Value ?? '').trim();
141
+ if (value === '0' || value === '1' || value === '2') {
142
+ return value;
143
+ }
144
+ }
145
+ return undefined;
146
+ }
147
+ finally {
148
+ await Promise.resolve(connection.end()).catch(() => undefined);
149
+ }
150
+ }
120
151
  async function performExternalDbConnectionCheck(config) {
121
152
  try {
122
153
  switch (config.dialect) {
@@ -146,6 +177,19 @@ export async function checkExternalDbConnection(config) {
146
177
  externalDbValidationCache.set(cacheKey, pending);
147
178
  return await pending;
148
179
  }
180
+ async function readMysqlLowerCaseTableNamesMode(config) {
181
+ if (config.dialect !== 'mysql' && config.dialect !== 'mariadb') {
182
+ return undefined;
183
+ }
184
+ const cacheKey = buildValidationCacheKey(config);
185
+ const cached = mysqlLowerCaseTableNamesCache.get(cacheKey);
186
+ if (cached) {
187
+ return await cached;
188
+ }
189
+ const pending = readMysqlFamilyLowerCaseTableNames(config);
190
+ mysqlLowerCaseTableNamesCache.set(cacheKey, pending);
191
+ return await pending;
192
+ }
149
193
  export async function validateExternalDbConfig(values) {
150
194
  const config = readExternalDbConnectionConfig(values);
151
195
  if (!config) {
@@ -153,6 +197,23 @@ export async function validateExternalDbConfig(values) {
153
197
  }
154
198
  return await checkExternalDbConnection(config);
155
199
  }
200
+ export async function validateMysqlLowerCaseTableNamesCompatibility(values) {
201
+ const config = readExternalDbConnectionConfig(values);
202
+ if (!config || (config.dialect !== 'mysql' && config.dialect !== 'mariadb')) {
203
+ return undefined;
204
+ }
205
+ try {
206
+ const mode = await readMysqlLowerCaseTableNamesMode(config);
207
+ if (mode === '1' && values.dbUnderscored !== true) {
208
+ return translateCli('validators.dbConnection.lowerCaseTableNamesRequiresUnderscored');
209
+ }
210
+ return undefined;
211
+ }
212
+ catch (error) {
213
+ return formatDbConnectionError(config, error);
214
+ }
215
+ }
156
216
  export function clearExternalDbValidationCache() {
157
217
  externalDbValidationCache.clear();
218
+ mysqlLowerCaseTableNamesCache.clear();
158
219
  }