@nocobase/cli 2.2.0-alpha.1 → 2.2.0-alpha.3

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 (52) hide show
  1. package/bin/early-locale.js +89 -0
  2. package/bin/node-version.js +35 -0
  3. package/bin/run.js +9 -0
  4. package/bin/windows-admin.js +60 -0
  5. package/dist/commands/app/destroy.js +4 -3
  6. package/dist/commands/app/restart.js +38 -0
  7. package/dist/commands/app/shared.js +49 -3
  8. package/dist/commands/app/start.js +92 -0
  9. package/dist/commands/app/upgrade.js +11 -0
  10. package/dist/commands/examples/prompts-stages.js +2 -2
  11. package/dist/commands/examples/prompts-test.js +2 -2
  12. package/dist/commands/init.js +22 -10
  13. package/dist/commands/install.js +135 -4
  14. package/dist/commands/license/activate.js +4 -1
  15. package/dist/commands/license/shared.js +24 -15
  16. package/dist/commands/self/check.js +1 -1
  17. package/dist/commands/self/update.js +4 -4
  18. package/dist/commands/skills/check.js +4 -5
  19. package/dist/commands/skills/install.js +18 -1
  20. package/dist/commands/skills/update.js +19 -4
  21. package/dist/commands/source/dev.js +9 -5
  22. package/dist/commands/source/download.js +67 -2
  23. package/dist/lib/api-command-compat.js +51 -8
  24. package/dist/lib/app-managed-resources.js +101 -1
  25. package/dist/lib/auth-store.js +34 -12
  26. package/dist/lib/cli-config.js +19 -0
  27. package/dist/lib/env-auth.js +291 -45
  28. package/dist/lib/env-config.js +6 -0
  29. package/dist/lib/hook-script.js +160 -0
  30. package/dist/lib/prompt-validators.js +1 -1
  31. package/dist/lib/prompt-web-ui.js +7 -11
  32. package/dist/lib/run-npm.js +4 -0
  33. package/dist/lib/self-manager.js +254 -46
  34. package/dist/lib/skills-manager.js +116 -23
  35. package/dist/lib/source-publish.js +2 -2
  36. package/dist/lib/startup-update.js +1 -1
  37. package/dist/locale/en-US.json +11 -5
  38. package/dist/locale/zh-CN.json +11 -5
  39. package/package.json +7 -2
  40. package/assets/env-proxy/nginx/app.conf.tpl +0 -23
  41. package/assets/env-proxy/nginx/nocobase.conf.tpl +0 -5
  42. package/assets/env-proxy/nginx/snippets/dist-location.conf +0 -5
  43. package/assets/env-proxy/nginx/snippets/gzip.conf +0 -17
  44. package/assets/env-proxy/nginx/snippets/log-format-http.conf +0 -13
  45. package/assets/env-proxy/nginx/snippets/maps-http.conf +0 -14
  46. package/assets/env-proxy/nginx/snippets/mime-types.conf +0 -98
  47. package/assets/env-proxy/nginx/snippets/proxy-location.conf +0 -17
  48. package/assets/env-proxy/nginx/snippets/spa-location.conf +0 -6
  49. package/assets/env-proxy/nginx/snippets/uploads-location.conf +0 -21
  50. package/scripts/build.mjs +0 -34
  51. package/scripts/clean.mjs +0 -9
  52. package/tsconfig.json +0 -19
@@ -0,0 +1,89 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ export function normalizeEarlyCliLocale(value) {
7
+ const normalized = String(value ?? '')
8
+ .trim()
9
+ .replace(/\..*$/, '')
10
+ .replace(/_/g, '-')
11
+ .toLowerCase();
12
+
13
+ if (normalized === 'zh' || normalized.startsWith('zh-')) {
14
+ return 'zh-CN';
15
+ }
16
+
17
+ if (normalized === 'en' || normalized.startsWith('en-')) {
18
+ return 'en-US';
19
+ }
20
+
21
+ return undefined;
22
+ }
23
+
24
+ function readConfiguredEarlyCliLocale() {
25
+ try {
26
+ const cliHomeRoot = String(process.env.NB_CLI_ROOT ?? '').trim() || os.homedir();
27
+ const configPath = path.join(cliHomeRoot, '.nocobase', 'config.json');
28
+ const content = fs.readFileSync(configPath, 'utf8');
29
+ const parsed = JSON.parse(content);
30
+ return normalizeEarlyCliLocale(parsed?.settings?.locale);
31
+ } catch {
32
+ return undefined;
33
+ }
34
+ }
35
+
36
+ export function detectEarlyCliLocale() {
37
+ const candidates = [
38
+ process.env.NB_LOCALE,
39
+ readConfiguredEarlyCliLocale(),
40
+ process.env.LC_ALL,
41
+ process.env.LC_MESSAGES,
42
+ process.env.LANG,
43
+ Intl.DateTimeFormat().resolvedOptions().locale,
44
+ ];
45
+
46
+ for (const candidate of candidates) {
47
+ const locale = normalizeEarlyCliLocale(candidate);
48
+ if (locale) {
49
+ return locale;
50
+ }
51
+ }
52
+
53
+ return 'en-US';
54
+ }
55
+
56
+ function getEarlyLocalePathValue(input, key) {
57
+ let current = input;
58
+ for (const part of key.split('.')) {
59
+ if (!current || typeof current !== 'object' || !Object.prototype.hasOwnProperty.call(current, part)) {
60
+ return undefined;
61
+ }
62
+ current = current[part];
63
+ }
64
+ return typeof current === 'string' ? current : undefined;
65
+ }
66
+
67
+ function readEarlyLocaleMessages(locale) {
68
+ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
69
+ const packageRoot = path.resolve(moduleDir, '..');
70
+ const localePaths = [
71
+ path.join(packageRoot, 'src', 'locale', `${locale}.json`),
72
+ path.join(packageRoot, 'dist', 'locale', `${locale}.json`),
73
+ ];
74
+
75
+ for (const localePath of localePaths) {
76
+ try {
77
+ return JSON.parse(fs.readFileSync(localePath, 'utf8'));
78
+ } catch {
79
+ // Try the next runtime layout.
80
+ }
81
+ }
82
+
83
+ return undefined;
84
+ }
85
+
86
+ export function translateEarlyCli(key, fallback, locale = detectEarlyCliLocale()) {
87
+ const messages = readEarlyLocaleMessages(locale);
88
+ return getEarlyLocalePathValue(messages, key) ?? fallback;
89
+ }
@@ -0,0 +1,35 @@
1
+ const MINIMUM_NODE_MAJOR_VERSION = 22;
2
+
3
+ export function getNodeMajorVersion(version = process.versions.node) {
4
+ const match = String(version ?? '')
5
+ .trim()
6
+ .match(/^v?(\d+)/);
7
+
8
+ if (!match) {
9
+ return Number.NaN;
10
+ }
11
+
12
+ return Number.parseInt(match[1], 10);
13
+ }
14
+
15
+ export function isSupportedNodeVersion(
16
+ version = process.versions.node,
17
+ minimumMajorVersion = MINIMUM_NODE_MAJOR_VERSION,
18
+ ) {
19
+ const majorVersion = getNodeMajorVersion(version);
20
+ return Number.isInteger(majorVersion) && majorVersion >= minimumMajorVersion;
21
+ }
22
+
23
+ export function formatUnsupportedNodeVersionMessage(
24
+ version = process.version,
25
+ minimumMajorVersion = MINIMUM_NODE_MAJOR_VERSION,
26
+ ) {
27
+ const currentVersion = String(version ?? '').trim() || 'unknown';
28
+
29
+ return [
30
+ `[nocobase cli]: Node.js ${minimumMajorVersion} or later is required to run nb.`,
31
+ `[nocobase cli]: Current version: ${currentVersion}. Please install Node.js ${minimumMajorVersion} or later and try again.`,
32
+ ].join('\n');
33
+ }
34
+
35
+ export { MINIMUM_NODE_MAJOR_VERSION };
package/bin/run.js CHANGED
@@ -6,7 +6,9 @@ import { createRequire } from 'node:module';
6
6
  import path from 'node:path';
7
7
  import pc from 'picocolors';
8
8
  import { fileURLToPath, pathToFileURL } from 'node:url';
9
+ import { formatUnsupportedNodeVersionMessage, isSupportedNodeVersion } from './node-version.js';
9
10
  import { normalizeNodeOptions, normalizeSessionEnv } from './session-env.js';
11
+ import { ensureWindowsAdministrator } from './windows-admin.js';
10
12
 
11
13
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
14
  const requireFromCli = createRequire(import.meta.url);
@@ -18,9 +20,16 @@ if (process.env.NB_CLI_USE_DIST === '1') {
18
20
  isDev = false;
19
21
  }
20
22
 
23
+ if (!isSupportedNodeVersion()) {
24
+ console.error(pc.red(formatUnsupportedNodeVersionMessage(process.version)));
25
+ process.exit(1);
26
+ }
27
+
21
28
  normalizeSessionEnv();
22
29
  normalizeNodeOptions();
23
30
 
31
+ ensureWindowsAdministrator();
32
+
24
33
  /**
25
34
  * In the monorepo, plain `node` cannot load `.ts`. Re-exec once with `--import <tsx>`
26
35
  * (same effect as a dedicated dev entry with `#!/usr/bin/env -S node --import tsx`).
@@ -0,0 +1,60 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import pc from 'picocolors';
3
+ import { detectEarlyCliLocale, translateEarlyCli } from './early-locale.js';
4
+
5
+ const windowsAdministratorCheckScript = [
6
+ '$identity = [Security.Principal.WindowsIdentity]::GetCurrent();',
7
+ '$principal = New-Object Security.Principal.WindowsPrincipal($identity);',
8
+ 'if ($principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { exit 0 }',
9
+ 'exit 1',
10
+ ].join(' ');
11
+
12
+ export function formatWindowsAdministratorRequiredMessage() {
13
+ const locale = detectEarlyCliLocale();
14
+ const message = translateEarlyCli(
15
+ 'entry.windowsAdministratorRequired.message',
16
+ 'NocoBase CLI must be run as Administrator on Windows.',
17
+ locale,
18
+ );
19
+ const hint = translateEarlyCli(
20
+ 'entry.windowsAdministratorRequired.hint',
21
+ 'Open your terminal as Administrator, then run the command again.',
22
+ locale,
23
+ );
24
+
25
+ return [message, hint].join('\n');
26
+ }
27
+
28
+ function isWindowsAdministrator() {
29
+ for (const command of ['pwsh.exe', 'powershell.exe']) {
30
+ const result = spawnSync(
31
+ command,
32
+ ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', windowsAdministratorCheckScript],
33
+ {
34
+ stdio: 'ignore',
35
+ windowsHide: true,
36
+ },
37
+ );
38
+
39
+ if (result.error?.code === 'ENOENT') {
40
+ continue;
41
+ }
42
+
43
+ return result.status === 0;
44
+ }
45
+
46
+ return false;
47
+ }
48
+
49
+ export function ensureWindowsAdministrator() {
50
+ if (process.platform !== 'win32' || process.env.NB_CLI_WINDOWS_ADMIN_CHECKED === '1') {
51
+ return;
52
+ }
53
+
54
+ if (!isWindowsAdministrator()) {
55
+ console.error(pc.red(formatWindowsAdministratorRequiredMessage()));
56
+ process.exit(1);
57
+ }
58
+
59
+ process.env.NB_CLI_WINDOWS_ADMIN_CHECKED = '1';
60
+ }
@@ -143,6 +143,7 @@ export default class AppDestroy extends Command {
143
143
  }
144
144
  announceTargetEnv(runtime.envName);
145
145
  try {
146
+ const retryCommand = `nb env remove ${runtime.envName} --purge --force`;
146
147
  if (runtime.kind === 'docker') {
147
148
  startTask(`Removing Docker app container for "${runtime.envName}"...`);
148
149
  const state = await removeDockerContainerIfExists(runtime.containerName, {
@@ -192,7 +193,7 @@ export default class AppDestroy extends Command {
192
193
  const localAppPath = resolveManagedLocalAppPath(runtime);
193
194
  if (localAppPath && removesManagedLocalAppFiles) {
194
195
  startTask(`Removing managed local app files for "${runtime.envName}"...`);
195
- await removePathIfExists(localAppPath, `managed app files for "${runtime.envName}"`);
196
+ await removePathIfExists(localAppPath, `managed app files for "${runtime.envName}"`, { retryCommand });
196
197
  succeedTask(`Managed local app files removed for "${runtime.envName}".`);
197
198
  }
198
199
  else {
@@ -206,13 +207,13 @@ export default class AppDestroy extends Command {
206
207
  ];
207
208
  startTask(`Removing proxy entry files for "${runtime.envName}"...`);
208
209
  for (const proxyEntryDir of proxyEntryDirs) {
209
- await removePathIfExists(proxyEntryDir, `proxy entry files for "${runtime.envName}"`);
210
+ await removePathIfExists(proxyEntryDir, `proxy entry files for "${runtime.envName}"`, { retryCommand });
210
211
  }
211
212
  succeedTask(`Proxy entry files removed for "${runtime.envName}".`);
212
213
  const configuredStoragePath = resolveConfiguredStoragePath(runtime.env.config);
213
214
  if (configuredStoragePath) {
214
215
  startTask(`Removing storage data for "${runtime.envName}"...`);
215
- await removePathIfExists(configuredStoragePath, `storage data for "${runtime.envName}"`);
216
+ await removePathIfExists(configuredStoragePath, `storage data for "${runtime.envName}"`, { retryCommand });
216
217
  succeedTask(`Storage data removed for "${runtime.envName}".`);
217
218
  }
218
219
  else {
@@ -14,6 +14,7 @@ import { recreateSavedDockerApp } from '../../lib/app-managed-resources.js';
14
14
  import { resolveAppUrlFromApiBaseUrl } from '../env/shared.js';
15
15
  import { run } from '../../lib/run-npm.js';
16
16
  import { announceTargetEnv, failTask, startTask, succeedTask } from '../../lib/ui.js';
17
+ import { buildHookContext, resolveHookScriptPath, runHookScriptHook } from '../../lib/hook-script.js';
17
18
  function argvHasToken(argv, tokens) {
18
19
  return tokens.some((token) => argv.includes(token));
19
20
  }
@@ -62,6 +63,39 @@ function resolveDisplayAppUrl(apiBaseUrl, port, appPublicPath) {
62
63
  }
63
64
  return formatAppUrl(port, appPublicPath);
64
65
  }
66
+ async function runDockerAfterAppStartHookIfNeeded(runtime) {
67
+ const hookScript = runtime.env.config?.hookScript;
68
+ if (!hookScript) {
69
+ return;
70
+ }
71
+ const hookScriptPath = resolveHookScriptPath({
72
+ appPath: runtime.env.appPath,
73
+ hookScript,
74
+ });
75
+ if (!hookScriptPath) {
76
+ return;
77
+ }
78
+ const context = buildHookContext({
79
+ phase: 'app-start',
80
+ command: 'app:restart',
81
+ envName: runtime.envName,
82
+ source: runtime.source,
83
+ version: runtime.env.config?.downloadVersion,
84
+ appPath: runtime.env.appPath,
85
+ sourcePath: runtime.env.sourcePath,
86
+ storagePath: runtime.env.storagePath,
87
+ hookScript: String(hookScript).trim(),
88
+ envConfig: runtime.env.config,
89
+ });
90
+ if (!context) {
91
+ return;
92
+ }
93
+ await runHookScriptHook({
94
+ hookScriptPath,
95
+ hookName: 'afterAppStart',
96
+ context,
97
+ });
98
+ }
65
99
  export default class AppRestart extends Command {
66
100
  static hidden = false;
67
101
  static description = 'Restart NocoBase for the selected env. When applicable, the CLI synchronizes licensed commercial plugins first, then restarts the local app or recreates the saved Docker container.';
@@ -199,6 +233,7 @@ export default class AppRestart extends Command {
199
233
  logHint: `You can inspect startup logs with \`nb app logs --env ${runtime.envName}\`.`,
200
234
  ...(flags.verbose ? { verbose: true } : {}),
201
235
  });
236
+ await runDockerAfterAppStartHookIfNeeded(runtime);
202
237
  succeedTask(`NocoBase is running for "${runtime.envName}"${appUrl ? ` at ${appUrl}` : ''}.`);
203
238
  return;
204
239
  }
@@ -222,6 +257,9 @@ export default class AppRestart extends Command {
222
257
  if (daemonFlagWasProvided) {
223
258
  startArgv.push(flags.daemon === false ? '--no-daemon' : '--daemon');
224
259
  }
260
+ if (runtime.env.config?.hookScript) {
261
+ startArgv.push('--hook-command', 'app:restart');
262
+ }
225
263
  await runWithSuppressedTargetEnvLog(async () => {
226
264
  await runCommand('app:start', startArgv);
227
265
  });
@@ -25,10 +25,54 @@ function assertSafeRemovalPath(target, label) {
25
25
  throw new Error(`Refusing to remove ${label} at "${resolved}" because it is too broad.`);
26
26
  }
27
27
  }
28
- export async function removePathIfExists(target, label) {
28
+ function getErrorCode(error) {
29
+ if (!(error instanceof Error)) {
30
+ return undefined;
31
+ }
32
+ const { code } = error;
33
+ return typeof code === 'string' ? code : undefined;
34
+ }
35
+ function isPermissionDeniedError(error) {
36
+ const code = getErrorCode(error);
37
+ return code === 'EACCES' || code === 'EPERM';
38
+ }
39
+ function formatOriginalError(error) {
40
+ const message = error instanceof Error ? error.message : String(error);
41
+ const code = getErrorCode(error);
42
+ return code && !message.includes(code) ? `${code}: ${message}` : message;
43
+ }
44
+ function quoteShellValue(value) {
45
+ return `"${value.replace(/(["\\$`])/g, '\\$1')}"`;
46
+ }
47
+ function formatPermissionDeniedRemovalError(target, label, error, options) {
48
+ const retryLines = os.platform() === 'win32' ? [] : [` sudo chown -R "$(id -u):$(id -g)" ${quoteShellValue(target)}`];
49
+ if (options.retryCommand) {
50
+ retryLines.push(` ${options.retryCommand}`);
51
+ }
52
+ return [
53
+ `Failed to remove ${label} at "${target}".`,
54
+ 'The current user cannot delete one or more files under this path. Files may have been created by a Docker container running as root.',
55
+ '',
56
+ retryLines.length > 0
57
+ ? 'Fix ownership or permissions, then retry:'
58
+ : 'Fix ownership or permissions, then retry the command.',
59
+ ...retryLines,
60
+ '',
61
+ `Original error: ${formatOriginalError(error)}`,
62
+ ].join('\n');
63
+ }
64
+ export async function removePathIfExists(target, label, options = {}) {
29
65
  const resolved = path.resolve(target);
30
66
  assertSafeRemovalPath(resolved, label);
31
- await fsp.rm(resolved, { recursive: true, force: true });
67
+ try {
68
+ await fsp.rm(resolved, { recursive: true, force: true });
69
+ }
70
+ catch (error) {
71
+ if (isPermissionDeniedError(error)) {
72
+ throw new Error(formatPermissionDeniedRemovalError(resolved, label, error, options));
73
+ }
74
+ throw error;
75
+ }
32
76
  }
33
77
  function isMissingDockerContainerError(error) {
34
78
  const message = error instanceof Error ? error.message : String(error);
@@ -116,7 +160,9 @@ export function managedDockerNetworkName(runtime) {
116
160
  return runtime.dockerNetworkName?.trim() || runtime.workspaceName?.trim() || undefined;
117
161
  }
118
162
  export function resolveManagedLocalAppPath(runtime) {
119
- return resolveConfiguredAppPath(runtime.env.config) || runtime.projectRoot || resolveConfiguredPath(runtime.env.config.appRootPath);
163
+ return (resolveConfiguredAppPath(runtime.env.config) ||
164
+ runtime.projectRoot ||
165
+ resolveConfiguredPath(runtime.env.config.appRootPath));
120
166
  }
121
167
  export function shouldRemoveManagedLocalAppFiles(runtime) {
122
168
  return runtime.source === 'npm' || runtime.source === 'git' || runtime.source === 'local';
@@ -18,6 +18,7 @@ import { resolveAppUrlFromApiBaseUrl } from '../env/shared.js';
18
18
  import { readManagedRuntimeEnvValues } from '../../lib/managed-env-file.js';
19
19
  import { run } from '../../lib/run-npm.js';
20
20
  import { announceTargetEnv, failTask, printInfo, printWarning, startTask, succeedTask } from '../../lib/ui.js';
21
+ import { buildHookContext, resolveHookScriptPath, runHookScriptHook, } from '../../lib/hook-script.js';
21
22
  function shouldPrintStartSuccess() {
22
23
  return process.env.NB_SKIP_APP_START_SUCCESS_LOG !== '1';
23
24
  }
@@ -34,6 +35,57 @@ function buildLicenseSyncArgv(envName, options) {
34
35
  }
35
36
  return argv;
36
37
  }
38
+ function resolveHookCommand(value) {
39
+ const text = String(value ?? '').trim();
40
+ if (text === 'app:restart' || text === 'app:upgrade') {
41
+ return text;
42
+ }
43
+ return 'app:start';
44
+ }
45
+ function resolveAppStartHookPhase(options) {
46
+ if (options.isPreparedEnv) {
47
+ return 'init';
48
+ }
49
+ if (options.command === 'app:upgrade') {
50
+ return 'upgrade';
51
+ }
52
+ return 'app-start';
53
+ }
54
+ async function runRuntimeHookIfNeeded(params) {
55
+ const hookScript = params.runtime.env.config?.hookScript;
56
+ if (!hookScript) {
57
+ return;
58
+ }
59
+ const hookScriptPath = resolveHookScriptPath({
60
+ appPath: params.runtime.env.appPath,
61
+ hookScript,
62
+ });
63
+ if (!hookScriptPath) {
64
+ return;
65
+ }
66
+ const context = buildHookContext({
67
+ phase: params.phase,
68
+ command: params.command,
69
+ envName: params.runtime.envName,
70
+ source: params.runtime.source,
71
+ version: params.runtime.env.config?.downloadVersion,
72
+ appPath: params.runtime.env.appPath,
73
+ sourcePath: params.runtime.env.sourcePath,
74
+ storagePath: params.runtime.env.storagePath,
75
+ hookScript: String(hookScript).trim(),
76
+ envConfig: params.hookName === 'afterAppStart' && params.phase === 'init'
77
+ ? { ...params.runtime.env.config, setupState: 'installed' }
78
+ : params.runtime.env.config,
79
+ });
80
+ if (!context) {
81
+ return;
82
+ }
83
+ await runHookScriptHook({
84
+ hookScriptPath,
85
+ hookName: params.hookName,
86
+ context,
87
+ });
88
+ }
37
89
  async function runWithSuppressedTargetEnvLog(task) {
38
90
  const previousTargetEnv = process.env.NB_SKIP_TARGET_ENV_LOG;
39
91
  process.env.NB_SKIP_TARGET_ENV_LOG = '1';
@@ -171,6 +223,10 @@ export default class AppStart extends Command {
171
223
  description: 'Show raw startup output from the underlying local or Docker command',
172
224
  default: false,
173
225
  }),
226
+ 'hook-command': Flags.string({
227
+ hidden: true,
228
+ options: ['app:start', 'app:restart', 'app:upgrade'],
229
+ }),
174
230
  };
175
231
  async run() {
176
232
  const { flags } = await this.parse(AppStart);
@@ -192,6 +248,12 @@ export default class AppStart extends Command {
192
248
  const runtime = await resolveManagedAppRuntime(requestedEnv);
193
249
  const preparedInitEnvVars = buildInitAppEnvVarsFromConfig(runtime?.env.config);
194
250
  const isPreparedEnv = isPreparedSetupState(runtime?.env.config?.setupState);
251
+ const hookCommand = resolveHookCommand(flags['hook-command']);
252
+ const hookPhase = resolveAppStartHookPhase({
253
+ isPreparedEnv,
254
+ command: hookCommand,
255
+ });
256
+ const shouldRunBeforeAppInstall = isPreparedEnv || hookCommand === 'app:upgrade';
195
257
  const runCommand = this.config.runCommand.bind(this.config);
196
258
  const commandStdio = flags.verbose ? 'inherit' : 'ignore';
197
259
  if (!runtime) {
@@ -249,6 +311,14 @@ export default class AppStart extends Command {
249
311
  errorName: 'docker rm',
250
312
  stdio: commandStdio,
251
313
  }).catch(() => undefined);
314
+ if (shouldRunBeforeAppInstall) {
315
+ await runRuntimeHookIfNeeded({
316
+ hookName: 'beforeAppInstall',
317
+ runtime,
318
+ phase: hookPhase,
319
+ command: hookCommand,
320
+ });
321
+ }
252
322
  await recreateSavedDockerApp(runtime, {
253
323
  initEnvVars: isPreparedEnv ? preparedInitEnvVars : undefined,
254
324
  verbose: flags.verbose,
@@ -270,6 +340,12 @@ export default class AppStart extends Command {
270
340
  if (isPreparedEnv) {
271
341
  await finalizePreparedEnv(runtime.envName);
272
342
  }
343
+ await runRuntimeHookIfNeeded({
344
+ hookName: 'afterAppStart',
345
+ runtime,
346
+ phase: hookPhase,
347
+ command: hookCommand,
348
+ });
273
349
  if (shouldPrintStartSuccess()) {
274
350
  succeedTask(`NocoBase is running for "${runtime.envName}"${appUrl ? ` at ${appUrl}` : ''}.`);
275
351
  }
@@ -285,6 +361,8 @@ export default class AppStart extends Command {
285
361
  const downloadableRuntime = runtime;
286
362
  await ensureSavedLocalSource(downloadableRuntime, runCommand, {
287
363
  verbose: flags.verbose,
364
+ hookPhase: isPreparedEnv ? 'init' : 'restore',
365
+ hookCommand,
288
366
  onStartTask: startTask,
289
367
  onSucceedTask: succeedTask,
290
368
  onFailTask: failTask,
@@ -381,6 +459,14 @@ export default class AppStart extends Command {
381
459
  ...managedAppLifecycleEnvVars(),
382
460
  ...(isPreparedEnv ? preparedInitEnvVars : {}),
383
461
  };
462
+ if (shouldRunBeforeAppInstall) {
463
+ await runRuntimeHookIfNeeded({
464
+ hookName: 'beforeAppInstall',
465
+ runtime,
466
+ phase: hookPhase,
467
+ command: hookCommand,
468
+ });
469
+ }
384
470
  await runLocalNocoBaseCommand(runtime, npmArgs, {
385
471
  env: startEnv,
386
472
  stdio: commandStdio,
@@ -394,6 +480,12 @@ export default class AppStart extends Command {
394
480
  if (isPreparedEnv) {
395
481
  await finalizePreparedEnv(runtime.envName);
396
482
  }
483
+ await runRuntimeHookIfNeeded({
484
+ hookName: 'afterAppStart',
485
+ runtime,
486
+ phase: hookPhase,
487
+ command: hookCommand,
488
+ });
397
489
  if (shouldPrintStartSuccess()) {
398
490
  succeedTask(`NocoBase is running for "${runtime.envName}"${appUrl ? ` at ${appUrl}` : ''}.`);
399
491
  }
@@ -12,6 +12,7 @@ import { formatMissingManagedAppEnvMessage, resolveManagedAppRuntime, } from '..
12
12
  import { ensureCrossEnvConfirmed, hasExplicitEnvSelection } from '../../lib/env-guard.js';
13
13
  import { DEFAULT_DOCKER_REGISTRY } from "../../lib/docker-image.js";
14
14
  import { confirm } from "../../lib/inquirer.js";
15
+ import { resolveHookScriptPath } from '../../lib/hook-script.js';
15
16
  import { announceTargetEnv, isInteractiveTerminal, printInfo, printWarning, succeedTask } from '../../lib/ui.js';
16
17
  import { resolveAppUrlFromApiBaseUrl } from '../env/shared.js';
17
18
  function trimValue(value) {
@@ -345,6 +346,13 @@ export default class AppUpgrade extends Command {
345
346
  if (runtime.env.config.buildDts === true) {
346
347
  argv.push('--build-dts');
347
348
  }
349
+ const hookScriptPath = resolveHookScriptPath({
350
+ appPath: runtime.env.appPath,
351
+ hookScript: runtime.env.config.hookScript,
352
+ });
353
+ if (hookScriptPath) {
354
+ argv.push('--hook-script', hookScriptPath, '--hook-phase', 'upgrade', '--hook-command', 'app:upgrade', '--hook-env-name', runtime.envName, '--hook-app-path', runtime.env.appPath, '--hook-storage-path', runtime.env.storagePath);
355
+ }
348
356
  return argv;
349
357
  }
350
358
  static buildDockerDownloadArgv(runtime, downloadVersion, options) {
@@ -497,6 +505,9 @@ export default class AppUpgrade extends Command {
497
505
  await runWithSuppressedTargetEnvLog(async () => {
498
506
  const startArgv = buildManagedActionArgv(runtime.envName, parsed, { quickstart: true });
499
507
  startArgv.push('--no-sync-licensed-plugins');
508
+ if (runtime.env.config.hookScript) {
509
+ startArgv.push('--hook-command', 'app:upgrade');
510
+ }
500
511
  await runWithSuppressedStartSuccessLog(async () => {
501
512
  await runCommand('app:start', startArgv);
502
513
  });
@@ -109,8 +109,8 @@ export default class PromptsStages extends Command {
109
109
  values: presetValues,
110
110
  pageTitle: 'nb prompts-stages — Web UI',
111
111
  documentHeading: 'nb prompts-stages — `stages` demo',
112
- onServerStart: ({ host, port, url }) => {
113
- this.log(`Local Web UI (multi-stage) ready — ${url} (listening on ${host}:${port}). Submit the form in the browser to continue.`);
112
+ onServerStart: ({ listenHost, port, url }) => {
113
+ this.log(`Local Web UI (multi-stage) ready — ${url} (listening on ${listenHost}:${port}). Submit the form in the browser to continue.`);
114
114
  },
115
115
  onOpenBrowserError: (url, err) => {
116
116
  this.log(`Open this URL in a browser: ${url} (${err instanceof Error ? err.message : String(err)})`);
@@ -139,8 +139,8 @@ export default class PromptsTest extends Command {
139
139
  values: presetValues,
140
140
  pageTitle: 'nb prompts-test — UI',
141
141
  documentHeading: 'nb prompts-test',
142
- onServerStart: ({ host, port, url }) => {
143
- this.log(`Local Web UI ready — ${url} (listening on ${host}:${port}). Submit the form in the browser to continue.`);
142
+ onServerStart: ({ listenHost, port, url }) => {
143
+ this.log(`Local Web UI ready — ${url} (listening on ${listenHost}:${port}). Submit the form in the browser to continue.`);
144
144
  },
145
145
  onOpenBrowserError: (url, err) => {
146
146
  this.log(`Open this URL in a browser: ${url} (${err instanceof Error ? err.message : String(err)})`);