@nocobase/cli 2.2.0-beta.5 → 2.2.0-beta.6

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.
@@ -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
  });
@@ -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
  });
@@ -15,7 +15,7 @@ import { stdin as stdinStream, stdout as stdoutStream } from 'node:process';
15
15
  import { getEnv, upsertEnv } from "../lib/auth-store.js";
16
16
  import { runPromptCatalog, } from "../lib/prompt-catalog.js";
17
17
  import { applyCliLocale, localeText, translateCli } from "../lib/cli-locale.js";
18
- import { resolveDefaultConfigScope } from '../lib/cli-home.js';
18
+ import { resolveConfiguredEnvPath, resolveDefaultConfigScope, resolveEnvRelativePath } from '../lib/cli-home.js';
19
19
  import { resolveDefaultApiHost, resolveDefaultUiHost } from '../lib/cli-config.js';
20
20
  import { areConfiguredPathsEquivalent, deriveConfiguredSourcePath, deriveConfiguredStoragePath, inferConfiguredAppPathFromLegacyConfig, } from '../lib/env-paths.js';
21
21
  import { formatMissingManagedAppEnvMessage } from '../lib/app-runtime.js';
@@ -25,6 +25,7 @@ import { installNocoBaseSkills, isNpmRegistryUnavailable } from '../lib/skills-m
25
25
  import { omitKeys, pickKeys } from "../lib/object-utils.js";
26
26
  import { ENV_CONFIG_SCHEMA_VERSION } from '../lib/env-config.js';
27
27
  import { printInfo, printStage, printVerbose, printWarning } from '../lib/ui.js';
28
+ import { persistHookScript } from '../lib/hook-script.js';
28
29
  import Download from "./download.js";
29
30
  import EnvAdd from "./env/add.js";
30
31
  import Install, { defaultDbPortForDialect } from "./install.js";
@@ -1029,6 +1030,14 @@ Prompt modes:
1029
1030
  const skipDownload = results.skipDownload === true;
1030
1031
  const appKey = resolveManagedAppKey(results.appKey ?? existingEnv?.config.appKey);
1031
1032
  const timeZone = resolveManagedTimeZone(results.timeZone ?? existingEnv?.config.timezone);
1033
+ const hookScriptInput = String(flags['hook-script'] ?? '').trim();
1034
+ const hookAppPath = appPath || `./${envName}/`;
1035
+ const hookScript = hookScriptInput
1036
+ ? await persistHookScript({
1037
+ sourcePath: hookScriptInput,
1038
+ appPath: resolveConfiguredEnvPath(hookAppPath) ?? resolveEnvRelativePath(hookAppPath),
1039
+ })
1040
+ : String(results.hookScript ?? '').trim();
1032
1041
  const builtinDb = explicitDbHostFlag(flags)
1033
1042
  ? false
1034
1043
  : results.builtinDb === undefined
@@ -1055,6 +1064,7 @@ Prompt modes:
1055
1064
  ...(dockerPlatform ? { dockerPlatform } : {}),
1056
1065
  ...(gitUrl ? { gitUrl } : {}),
1057
1066
  ...(npmRegistry ? { npmRegistry } : {}),
1067
+ ...(hookScript ? { hookScript } : {}),
1058
1068
  ...(appPath ? { appPath } : {}),
1059
1069
  ...(appRootPath && !areConfiguredPathsEquivalent(appRootPath, derivedAppRootPath) ? { appRootPath } : {}),
1060
1070
  ...(storagePath && !areConfiguredPathsEquivalent(storagePath, derivedStoragePath) ? { storagePath } : {}),
@@ -1130,6 +1140,10 @@ Prompt modes:
1130
1140
  if (flags['prepare-only']) {
1131
1141
  argv.push('--prepare-only');
1132
1142
  }
1143
+ const hookScript = String(results.hookScript ?? flags['hook-script'] ?? '').trim();
1144
+ if (hookScript) {
1145
+ argv.push('--hook-script', hookScript);
1146
+ }
1133
1147
  if (flags.verbose) {
1134
1148
  argv.push('--verbose');
1135
1149
  }
@@ -29,6 +29,7 @@ import { buildStoredEnvConfig } from '../lib/env-config.js';
29
29
  import { resolveDockerEnvFileArg } from "../lib/docker-env-file.js";
30
30
  import { startDockerLogFollower } from '../lib/docker-log-stream.js';
31
31
  import { buildInitAppEnvVarsFromConfig } from '../lib/managed-init-env.js';
32
+ import { buildHookContext, persistHookScript, resolveHookScriptPath, runHookScriptHook, } from '../lib/hook-script.js';
32
33
  import { areConfiguredPathsEquivalent, deriveConfiguredSourcePath, deriveConfiguredStoragePath, inferConfiguredAppPathFromLegacyConfig, } from '../lib/env-paths.js';
33
34
  import Download, { defaultDockerRegistryForLang } from './download.js';
34
35
  import EnvAdd from "./env/add.js";
@@ -472,7 +473,10 @@ export default class Install extends Command {
472
473
  description: 'Skip the download step and reuse an existing local app directory or Docker image',
473
474
  default: false,
474
475
  }),
475
- ...omitKeys(Download.flags, ['yes']),
476
+ 'hook-script': Flags.string({
477
+ description: 'Hook module copied to <appPath>/.nb/hooks.mjs and run before npm/git dependency installation.',
478
+ }),
479
+ ...omitKeys(Download.flags, ['yes', 'hook-script']),
476
480
  };
477
481
  /** Environment name only: run before {@link Install.prompts} (see `run`). */
478
482
  static envPrompts = {
@@ -1095,6 +1099,7 @@ export default class Install extends Command {
1095
1099
  const appPort = Install.toOptionalPromptString(config.appPort);
1096
1100
  const storagePath = Install.toOptionalPromptString(config.storagePath);
1097
1101
  const appPublicPath = Install.toOptionalPromptString(config.appPublicPath);
1102
+ const hookScript = Install.toOptionalPromptString(config.hookScript);
1098
1103
  const apiBaseUrl = Install.toOptionalPromptString(config.apiBaseUrl);
1099
1104
  const downloadVersion = Install.toOptionalPromptString(config.downloadVersion);
1100
1105
  const dockerRegistry = Install.toOptionalPromptString(config.dockerRegistry);
@@ -1125,6 +1130,7 @@ export default class Install extends Command {
1125
1130
  ...(appPort ? { appPort } : {}),
1126
1131
  ...(storagePath ? { storagePath } : {}),
1127
1132
  ...(appPublicPath ? { appPublicPath } : {}),
1133
+ ...(hookScript ? { hookScript } : {}),
1128
1134
  };
1129
1135
  const downloadPreset = {
1130
1136
  ...(source ? { source } : {}),
@@ -1916,6 +1922,12 @@ export default class Install extends Command {
1916
1922
  if (results.buildDts) {
1917
1923
  argv.push('--build-dts');
1918
1924
  }
1925
+ Install.pushDownloadArgIfValue(argv, '--hook-script', results.hookScript);
1926
+ Install.pushDownloadArgIfValue(argv, '--hook-phase', results.hookPhase);
1927
+ Install.pushDownloadArgIfValue(argv, '--hook-command', results.hookCommand);
1928
+ Install.pushDownloadArgIfValue(argv, '--hook-env-name', results.hookEnvName);
1929
+ Install.pushDownloadArgIfValue(argv, '--hook-app-path', results.hookAppPath);
1930
+ Install.pushDownloadArgIfValue(argv, '--hook-storage-path', results.hookStoragePath);
1919
1931
  return argv;
1920
1932
  }
1921
1933
  static resolveLocalProjectRoot(params) {
@@ -1942,6 +1954,40 @@ export default class Install extends Command {
1942
1954
  buildDts: false,
1943
1955
  };
1944
1956
  }
1957
+ static resolveAbsoluteAppPath(envName, appResults) {
1958
+ const configuredAppPath = resolveConfiguredAppPathValue(appResults) ?? defaultInstallAppPath(envName);
1959
+ return resolveConfiguredEnvPath(configuredAppPath) ?? resolveEnvRelativePath(defaultInstallAppPath(envName));
1960
+ }
1961
+ static resolveAbsoluteStoragePath(envName, appResults) {
1962
+ const configuredStoragePath = resolveConfiguredStoragePathValue(appResults, envName);
1963
+ return resolveConfiguredEnvPath(configuredStoragePath) ?? resolveEnvRelativePath(defaultInstallStoragePath(envName));
1964
+ }
1965
+ async prepareHookScriptForInstall(params) {
1966
+ const appPath = Install.resolveAbsoluteAppPath(params.envName, params.appResults);
1967
+ const storagePath = Install.resolveAbsoluteStoragePath(params.envName, params.appResults);
1968
+ const inputHookScript = Install.toOptionalPromptString(params.hookScript);
1969
+ if (inputHookScript) {
1970
+ params.appResults.hookScript = await persistHookScript({
1971
+ sourcePath: inputHookScript,
1972
+ appPath,
1973
+ });
1974
+ printInfo(`Saved hook script to ${path.join(appPath, String(params.appResults.hookScript))}.`);
1975
+ }
1976
+ const savedHookScript = Install.toOptionalPromptString(params.appResults.hookScript);
1977
+ const hookScriptPath = resolveHookScriptPath({
1978
+ appPath,
1979
+ hookScript: savedHookScript,
1980
+ });
1981
+ if (!hookScriptPath) {
1982
+ return;
1983
+ }
1984
+ params.downloadResults.hookScript = hookScriptPath;
1985
+ params.downloadResults.hookPhase = 'init';
1986
+ params.downloadResults.hookCommand = 'init';
1987
+ params.downloadResults.hookEnvName = params.envName;
1988
+ params.downloadResults.hookAppPath = appPath;
1989
+ params.downloadResults.hookStoragePath = storagePath;
1990
+ }
1945
1991
  commandStdio(verbose) {
1946
1992
  return verbose ? 'inherit' : 'ignore';
1947
1993
  }
@@ -2216,6 +2262,44 @@ export default class Install extends Command {
2216
2262
  }
2217
2263
  await this.config.runCommand('env:update', [params.envName]);
2218
2264
  }
2265
+ async runInstallHookIfNeeded(params) {
2266
+ const appPath = Install.resolveAbsoluteAppPath(params.envName, params.appResults);
2267
+ const savedHookScript = Install.toOptionalPromptString(params.appResults.hookScript);
2268
+ const hookScriptPath = resolveHookScriptPath({
2269
+ appPath,
2270
+ hookScript: savedHookScript,
2271
+ });
2272
+ if (!hookScriptPath) {
2273
+ return;
2274
+ }
2275
+ const storagePath = Install.resolveAbsoluteStoragePath(params.envName, params.appResults);
2276
+ const sourcePath = params.projectRoot ??
2277
+ resolveConfiguredEnvPath(resolveConfiguredSourcePathValue(params.appResults, params.envName)) ??
2278
+ path.join(appPath, 'source');
2279
+ const context = buildHookContext({
2280
+ phase: 'init',
2281
+ command: 'init',
2282
+ envName: params.envName,
2283
+ source: params.source,
2284
+ version: downloadResultsValue(params.downloadResults, 'version'),
2285
+ appPath,
2286
+ sourcePath,
2287
+ storagePath,
2288
+ hookScript: savedHookScript,
2289
+ envConfig: Install.buildSavedEnvConfig(params, {
2290
+ defaultApiHost: params.defaultApiHost,
2291
+ }),
2292
+ });
2293
+ if (!context) {
2294
+ return;
2295
+ }
2296
+ this.log(`Running hook ${params.hookName}: ${hookScriptPath}`);
2297
+ await runHookScriptHook({
2298
+ hookScriptPath,
2299
+ hookName: params.hookName,
2300
+ context,
2301
+ });
2302
+ }
2219
2303
  static buildSavedEnvConfig(params, options = {}) {
2220
2304
  const appPath = resolveConfiguredAppPathValue(params.appResults);
2221
2305
  const appRootPath = Install.toOptionalPromptString(params.appResults.appRootPath);
@@ -2247,6 +2331,7 @@ export default class Install extends Command {
2247
2331
  devDependencies: downloadResultsValue(params.downloadResults, 'devDependencies'),
2248
2332
  build: downloadResultsValue(params.downloadResults, 'build'),
2249
2333
  buildDts: downloadResultsValue(params.downloadResults, 'buildDts'),
2334
+ hookScript: params.appResults.hookScript,
2250
2335
  ...(appPath ? { appPath } : {}),
2251
2336
  ...(appRootPath && !areConfiguredPathsEquivalent(appRootPath, derivedAppRootPath) ? { appRootPath } : {}),
2252
2337
  appPort,
@@ -2311,6 +2396,9 @@ export default class Install extends Command {
2311
2396
  yesInitialValues: { resume: parsed.resume },
2312
2397
  yes,
2313
2398
  });
2399
+ if (resumePreset?.appPreset?.hookScript !== undefined && appResults.hookScript === undefined) {
2400
+ appResults.hookScript = resumePreset.appPreset.hookScript;
2401
+ }
2314
2402
  const downloadOpts = Install.buildDownloadPromptOptionsForInstall(appResults, envName);
2315
2403
  downloadOpts.values = {
2316
2404
  ...(resumePreset?.downloadPreset ?? {}),
@@ -2440,6 +2528,12 @@ export default class Install extends Command {
2440
2528
  appResults,
2441
2529
  });
2442
2530
  appResults.setupState = 'prepared';
2531
+ await this.prepareHookScriptForInstall({
2532
+ envName,
2533
+ appResults,
2534
+ downloadResults,
2535
+ hookScript: parsed['hook-script'],
2536
+ });
2443
2537
  const source = String(downloadResultsValue(downloadResults, 'source') ?? '').trim();
2444
2538
  const usesDockerResources = Boolean(dbResults.builtinDb) || source === 'docker';
2445
2539
  const dockerNetworkName = usesDockerResources
@@ -2505,6 +2599,17 @@ export default class Install extends Command {
2505
2599
  printInfo('Application image ready.');
2506
2600
  }
2507
2601
  if (!parsed['prepare-only']) {
2602
+ await this.runInstallHookIfNeeded({
2603
+ hookName: 'beforeAppInstall',
2604
+ envName,
2605
+ source,
2606
+ appResults,
2607
+ downloadResults,
2608
+ dbResults,
2609
+ rootResults,
2610
+ envAddResults,
2611
+ defaultApiHost,
2612
+ });
2508
2613
  dockerAppPlan = await this.installDockerApp({
2509
2614
  envName,
2510
2615
  dockerNetworkName,
@@ -2523,7 +2628,7 @@ export default class Install extends Command {
2523
2628
  }
2524
2629
  else if (source === 'npm' || source === 'git') {
2525
2630
  const localSource = source === 'npm' ? 'npm' : 'git';
2526
- const projectRoot = parsed['skip-download']
2631
+ const projectRoot = parsed['skip-download'] || parsed['prepare-only']
2527
2632
  ? Install.resolveLocalProjectRoot({
2528
2633
  envName,
2529
2634
  appResults,
@@ -2535,10 +2640,22 @@ export default class Install extends Command {
2535
2640
  downloadResults,
2536
2641
  verbose: parsed.verbose,
2537
2642
  });
2538
- if (!parsed['skip-download']) {
2643
+ if (!parsed['skip-download'] && !parsed['prepare-only']) {
2539
2644
  printInfo('Application files ready.');
2540
2645
  }
2541
2646
  if (!parsed['prepare-only']) {
2647
+ await this.runInstallHookIfNeeded({
2648
+ hookName: 'beforeAppInstall',
2649
+ envName,
2650
+ source,
2651
+ appResults,
2652
+ downloadResults,
2653
+ dbResults,
2654
+ rootResults,
2655
+ envAddResults,
2656
+ projectRoot,
2657
+ defaultApiHost,
2658
+ });
2542
2659
  localAppPlan = await this.startLocalApp({
2543
2660
  envName,
2544
2661
  source: localSource,
@@ -2573,6 +2690,18 @@ export default class Install extends Command {
2573
2690
  });
2574
2691
  printInfo(`NocoBase is ready at ${formatInstallDisplayUrl(displayApiBaseUrl)}`);
2575
2692
  appResults.setupState = 'installed';
2693
+ await this.runInstallHookIfNeeded({
2694
+ hookName: 'afterAppStart',
2695
+ envName,
2696
+ source,
2697
+ appResults,
2698
+ downloadResults,
2699
+ dbResults,
2700
+ rootResults,
2701
+ envAddResults,
2702
+ projectRoot: localAppPlan?.projectRoot,
2703
+ defaultApiHost,
2704
+ });
2576
2705
  }
2577
2706
  if (dockerAppPlan || localAppPlan || builtinDbPlan) {
2578
2707
  await this.saveInstalledEnv({
@@ -13,6 +13,9 @@ import { stdin as stdinStream, stdout as stdoutStream } from 'node:process';
13
13
  import { runPromptCatalog, } from "../../lib/prompt-catalog.js";
14
14
  import { applyCliLocale, CLI_LOCALE_FLAG_DESCRIPTION, CLI_LOCALE_FLAG_OPTIONS, localeText, resolveCliLocale, translateCli, } from "../../lib/cli-locale.js";
15
15
  import { DEFAULT_DOCKER_REGISTRY, DEFAULT_DOCKER_REGISTRY_ZH_CN, resolveDockerImageRef, } from "../../lib/docker-image.js";
16
+ import { getEnv } from '../../lib/auth-store.js';
17
+ import { resolveDefaultConfigScope } from '../../lib/cli-home.js';
18
+ import { buildBeforeDependencyInstallHookContext, runBeforeDependencyInstallHook, } from '../../lib/hook-script.js';
16
19
  import { run } from "../../lib/run-npm.js";
17
20
  import { printVerbose, setVerboseMode, startTask, stopTask, updateTask } from '../../lib/ui.js';
18
21
  const DEFAULT_DOCKER_PLATFORM = 'auto';
@@ -231,6 +234,26 @@ export default class SourceDownload extends Command {
231
234
  'npm-registry': Flags.string({
232
235
  description: 'npm registry for npm/git downloads and dependency installation.',
233
236
  }),
237
+ 'hook-script': Flags.string({
238
+ description: 'Hook module to run after npm scaffold or git clone and before dependency installation.',
239
+ }),
240
+ 'hook-phase': Flags.string({
241
+ hidden: true,
242
+ options: ['init', 'upgrade', 'restore', 'source-download', 'app-start'],
243
+ }),
244
+ 'hook-command': Flags.string({
245
+ hidden: true,
246
+ options: ['init', 'source:download', 'app:start', 'app:restart', 'app:upgrade'],
247
+ }),
248
+ 'hook-env-name': Flags.string({
249
+ hidden: true,
250
+ }),
251
+ 'hook-app-path': Flags.string({
252
+ hidden: true,
253
+ }),
254
+ 'hook-storage-path': Flags.string({
255
+ hidden: true,
256
+ }),
234
257
  build: Flags.boolean({
235
258
  allowNo: true,
236
259
  description: 'Build npm/git source after dependencies are installed.',
@@ -573,6 +596,9 @@ export default class SourceDownload extends Command {
573
596
  const npmRegistryRaw = results.npmRegistry !== undefined ? String(results.npmRegistry) : flags['npm-registry'] ?? '';
574
597
  const npmRegistry = npmRegistryRaw.trim() || undefined;
575
598
  const npmDevDependencies = devDependencies ?? false;
599
+ const hookScript = flags['hook-script']?.trim()
600
+ ? path.resolve(process.cwd(), flags['hook-script'].trim())
601
+ : undefined;
576
602
  return {
577
603
  source,
578
604
  version,
@@ -586,6 +612,14 @@ export default class SourceDownload extends Command {
586
612
  ...(source === 'docker' ? { 'docker-platform': dockerPlatform } : {}),
587
613
  ...(source === 'docker' ? { 'docker-save': dockerSave } : {}),
588
614
  ...(npmRegistry ? { 'npm-registry': npmRegistry } : {}),
615
+ ...(hookScript ? { 'hook-script': hookScript } : {}),
616
+ ...(flags['hook-phase'] ? { 'hook-phase': flags['hook-phase'] } : {}),
617
+ ...(flags['hook-command'] ? { 'hook-command': flags['hook-command'] } : {}),
618
+ ...(flags['hook-env-name'] ? { 'hook-env-name': flags['hook-env-name'].trim() } : {}),
619
+ ...(flags['hook-app-path'] ? { 'hook-app-path': path.resolve(process.cwd(), flags['hook-app-path']) } : {}),
620
+ ...(flags['hook-storage-path']
621
+ ? { 'hook-storage-path': path.resolve(process.cwd(), flags['hook-storage-path']) }
622
+ : {}),
589
623
  };
590
624
  }
591
625
  async resolveDownloadFlags(flags) {
@@ -718,6 +752,36 @@ export default class SourceDownload extends Command {
718
752
  }
719
753
  return argv;
720
754
  }
755
+ async runBeforeDependencyInstallHookIfNeeded(flags, projectRoot) {
756
+ const hookScript = flags['hook-script']?.trim();
757
+ if (!hookScript || (flags.source !== 'npm' && flags.source !== 'git')) {
758
+ return;
759
+ }
760
+ const envName = flags['hook-env-name']?.trim() || '';
761
+ const env = envName ? await getEnv(envName, { scope: resolveDefaultConfigScope() }) : undefined;
762
+ const appPath = flags['hook-app-path']?.trim() || projectRoot;
763
+ const storagePath = flags['hook-storage-path']?.trim() || path.join(appPath, 'storage');
764
+ const context = buildBeforeDependencyInstallHookContext({
765
+ phase: flags['hook-phase'] ?? 'source-download',
766
+ command: flags['hook-command'] ?? 'source:download',
767
+ envName,
768
+ source: flags.source,
769
+ version: flags.version,
770
+ appPath,
771
+ sourcePath: projectRoot,
772
+ storagePath,
773
+ hookScript,
774
+ envConfig: env?.config ?? {},
775
+ });
776
+ if (!context) {
777
+ return;
778
+ }
779
+ this.log(`Running hook before dependency install: ${hookScript}`);
780
+ await runBeforeDependencyInstallHook({
781
+ hookScriptPath: hookScript,
782
+ context,
783
+ });
784
+ }
721
785
  async downloadFromDocker(flags) {
722
786
  const imageRef = resolveDockerImageRef(flags['docker-registry'], flags.version, {
723
787
  defaultRegistry: defaultDockerRegistryForLang(process.env.NB_LOCALE),
@@ -777,6 +841,7 @@ export default class SourceDownload extends Command {
777
841
  errorName: 'npx create-nocobase-app',
778
842
  loadingMessage: 'Creating the app scaffold',
779
843
  });
844
+ await this.runBeforeDependencyInstallHookIfNeeded(flags, projectRoot);
780
845
  const installArgs = ['install'];
781
846
  if (!flags['dev-dependencies']) {
782
847
  installArgs.push('--production');
@@ -820,6 +885,7 @@ export default class SourceDownload extends Command {
820
885
  });
821
886
  const projectRoot = path.resolve(process.cwd(), outputDir);
822
887
  const registryEnv = this.npmRegistryEnv(flags);
888
+ await this.runBeforeDependencyInstallHookIfNeeded(flags, projectRoot);
823
889
  this.log(`Installing dependencies in ${projectRoot}`);
824
890
  await this.runExternalCommand('yarn', ['install'], {
825
891
  ...this.runOptionsWithCwd(projectRoot, registryEnv),
@@ -13,6 +13,7 @@ import { deriveBuiltinDbConnection, resolveBuiltinDbConnection } from './builtin
13
13
  import { resolveConfiguredStoragePath } from './env-paths.js';
14
14
  import { resolveDockerEnvFileArg } from "./docker-env-file.js";
15
15
  import { DEFAULT_DOCKER_REGISTRY, DEFAULT_DOCKER_VERSION, resolveDockerImageRef, } from "./docker-image.js";
16
+ import { resolveHookScriptPath } from './hook-script.js';
16
17
  import { commandSucceeds, ensureDockerDaemonRunning, run } from './run-npm.js';
17
18
  import Install from '../commands/install.js';
18
19
  const DOCKER_APP_STORAGE_DESTINATION = '/app/nocobase/storage';
@@ -302,6 +303,13 @@ export function buildSavedLocalDownloadArgv(runtime, options) {
302
303
  if (config.buildDts === true) {
303
304
  argv.push('--build-dts');
304
305
  }
306
+ const hookScriptPath = resolveHookScriptPath({
307
+ appPath: runtime.env.appPath,
308
+ hookScript: config.hookScript,
309
+ });
310
+ if (hookScriptPath) {
311
+ argv.push('--hook-script', hookScriptPath, '--hook-phase', options?.hookPhase ?? 'restore', '--hook-command', options?.hookCommand ?? 'app:start', '--hook-env-name', runtime.envName, '--hook-app-path', runtime.env.appPath, '--hook-storage-path', runtime.env.storagePath);
312
+ }
305
313
  return argv;
306
314
  }
307
315
  export async function ensureSavedLocalSource(runtime, runCommand, options) {
@@ -313,6 +321,8 @@ export async function ensureSavedLocalSource(runtime, runCommand, options) {
313
321
  try {
314
322
  await runCommand('source:download', buildSavedLocalDownloadArgv(runtime, {
315
323
  verbose: options?.verbose,
324
+ hookPhase: options?.hookPhase,
325
+ hookCommand: options?.hookCommand,
316
326
  }));
317
327
  options?.onSucceedTask?.(`NocoBase files are ready for "${runtime.envName}".`);
318
328
  }
@@ -14,6 +14,7 @@ const STRING_ENV_CONFIG_KEYS = [
14
14
  'dockerPlatform',
15
15
  'gitUrl',
16
16
  'npmRegistry',
17
+ 'hookScript',
17
18
  'appPath',
18
19
  'appRootPath',
19
20
  'storagePath',
@@ -0,0 +1,160 @@
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 { copyFile, mkdir } from 'node:fs/promises';
10
+ import { createRequire } from 'node:module';
11
+ import path from 'node:path';
12
+ export const ENV_HOOK_SCRIPT_CONFIG_PATH = '.nb/hooks.mjs';
13
+ const require = createRequire(import.meta.url);
14
+ const { spawn } = require('node:child_process');
15
+ function trimValue(value) {
16
+ return String(value ?? '').trim();
17
+ }
18
+ function normalizeHookPhase(value) {
19
+ const text = trimValue(value);
20
+ if (text === 'init' || text === 'upgrade' || text === 'restore' || text === 'source-download' || text === 'app-start') {
21
+ return text;
22
+ }
23
+ return 'init';
24
+ }
25
+ function normalizeHookCommand(value) {
26
+ const text = trimValue(value);
27
+ if (text === 'source:download' || text === 'app:start' || text === 'app:restart' || text === 'app:upgrade') {
28
+ return text;
29
+ }
30
+ return 'init';
31
+ }
32
+ function normalizeHookSource(value) {
33
+ const text = trimValue(value);
34
+ if (text === 'npm' || text === 'git' || text === 'docker') {
35
+ return text;
36
+ }
37
+ return undefined;
38
+ }
39
+ function isDependencyHookSource(source) {
40
+ return source === 'npm' || source === 'git';
41
+ }
42
+ function isRecord(value) {
43
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
44
+ }
45
+ export function resolveHookScriptPath(params) {
46
+ const hookScript = trimValue(params.hookScript);
47
+ if (!hookScript) {
48
+ return undefined;
49
+ }
50
+ if (path.isAbsolute(hookScript)) {
51
+ return hookScript;
52
+ }
53
+ const appPath = trimValue(params.appPath);
54
+ if (!appPath) {
55
+ return hookScript;
56
+ }
57
+ const usesWindowsSeparators = appPath.includes('\\') || /^[a-zA-Z]:([\\/]|$)/.test(appPath) || appPath.startsWith('\\\\');
58
+ return usesWindowsSeparators ? path.win32.join(appPath, hookScript) : path.posix.join(appPath, hookScript);
59
+ }
60
+ export async function persistHookScript(params) {
61
+ const sourcePath = path.resolve(params.sourcePath);
62
+ const targetPath = path.join(params.appPath, ENV_HOOK_SCRIPT_CONFIG_PATH);
63
+ await mkdir(path.dirname(targetPath), { recursive: true });
64
+ if (path.resolve(sourcePath) !== path.resolve(targetPath)) {
65
+ await copyFile(sourcePath, targetPath);
66
+ }
67
+ return ENV_HOOK_SCRIPT_CONFIG_PATH;
68
+ }
69
+ const hookRunnerScript = `
70
+ import { pathToFileURL } from 'node:url';
71
+
72
+ const knownHookNames = ['beforeDependencyInstall', 'beforeAppInstall', 'afterAppStart'];
73
+ const [, hookScriptPath, hookName, contextJson] = process.argv;
74
+ const url = pathToFileURL(hookScriptPath);
75
+ url.searchParams.set('t', String(Date.now()));
76
+
77
+ const imported = await import(url.href);
78
+ const hooks = imported.default ?? imported;
79
+
80
+ if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) {
81
+ throw new Error('Hook script must export an object.');
82
+ }
83
+
84
+ for (const knownHookName of knownHookNames) {
85
+ if (Object.prototype.hasOwnProperty.call(hooks, knownHookName) && typeof hooks[knownHookName] !== 'function') {
86
+ throw new Error(\`Hook "\${knownHookName}" must be a function.\`);
87
+ }
88
+ }
89
+
90
+ const hook = hooks[hookName];
91
+ if (typeof hook === 'function') {
92
+ await hook(JSON.parse(contextJson));
93
+ }
94
+ `;
95
+ async function runHookInSubprocess(params) {
96
+ await new Promise((resolve, reject) => {
97
+ const child = spawn(process.execPath, ['--input-type=module', '--eval', hookRunnerScript, params.hookScriptPath, params.hookName, JSON.stringify(params.context)], {
98
+ stdio: ['ignore', 'pipe', 'pipe'],
99
+ });
100
+ let stdout = '';
101
+ let stderr = '';
102
+ child.stdout?.on?.('data', (chunk) => {
103
+ stdout += String(chunk);
104
+ });
105
+ child.stderr?.on?.('data', (chunk) => {
106
+ stderr += String(chunk);
107
+ });
108
+ child.once('error', reject);
109
+ child.once('close', (code) => {
110
+ if (code === 0) {
111
+ resolve();
112
+ return;
113
+ }
114
+ const output = stderr.trim() || stdout.trim();
115
+ reject(new Error(output || `Hook process exited with code ${code ?? 'unknown'}.`));
116
+ });
117
+ });
118
+ }
119
+ export function buildHookContext(params) {
120
+ const source = normalizeHookSource(params.source);
121
+ if (!source) {
122
+ return undefined;
123
+ }
124
+ const version = trimValue(params.version);
125
+ return {
126
+ phase: normalizeHookPhase(params.phase),
127
+ command: normalizeHookCommand(params.command),
128
+ envName: trimValue(params.envName),
129
+ source,
130
+ ...(version ? { version } : {}),
131
+ appPath: params.appPath,
132
+ sourcePath: params.sourcePath,
133
+ storagePath: params.storagePath,
134
+ hookScript: params.hookScript,
135
+ envConfig: { ...(params.envConfig ?? {}) },
136
+ };
137
+ }
138
+ export function buildBeforeDependencyInstallHookContext(params) {
139
+ const context = buildHookContext(params);
140
+ if (!context || !isDependencyHookSource(context.source)) {
141
+ return undefined;
142
+ }
143
+ return context;
144
+ }
145
+ export async function runHookScriptHook(params) {
146
+ try {
147
+ await runHookInSubprocess(params);
148
+ }
149
+ catch (error) {
150
+ const message = error instanceof Error ? error.message : String(error);
151
+ throw new Error([`Hook script failed: ${params.hookScriptPath}`, `Hook stage: ${params.hookName}`, `Details: ${message}`].join('\n'));
152
+ }
153
+ }
154
+ export async function runBeforeDependencyInstallHook(params) {
155
+ await runHookScriptHook({
156
+ hookScriptPath: params.hookScriptPath,
157
+ hookName: 'beforeDependencyInstall',
158
+ context: params.context,
159
+ });
160
+ }
@@ -232,7 +232,7 @@ export async function publishSourceSnapshot(params) {
232
232
  version,
233
233
  stdio,
234
234
  });
235
- await run('yarn', ['lerna', 'publish', 'from-package', '--registry', npmRegistry, '--dist-tag', 'local', '--yes', '--no-verify-access', '--git-head', gitSha], {
235
+ await run('yarn', ['lerna', 'publish', 'from-package', '--registry', npmRegistry, '--dist-tag', 'local', '--yes', '--no-verify-access', '--no-git-reset', '--git-head', gitSha], {
236
236
  cwd: projectRoot,
237
237
  errorName: 'lerna publish',
238
238
  stdio,
@@ -319,7 +319,7 @@ export function buildSuggestedInitCommand(result) {
319
319
  const normalizedRegistry = result.npmRegistry || `http://${host}:${port || DEFAULT_SOURCE_REGISTRY_PORT}`;
320
320
  const suggestedEnv = ['snapshot', sanitizeEnvSegment(result.gitSha)].filter(Boolean).join('');
321
321
  return [
322
- `nb init --ui --env ${suggestedEnv} --yes --source npm`,
322
+ `nb init --env ${suggestedEnv} --yes --source npm`,
323
323
  `--version ${result.version}`,
324
324
  `--npm-registry=${normalizedRegistry}`,
325
325
  ].join(' ');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/cli",
3
- "version": "2.2.0-beta.5",
3
+ "version": "2.2.0-beta.6",
4
4
  "description": "NocoBase Command Line Tool",
5
5
  "type": "module",
6
6
  "main": "dist/generated/command-registry.js",
@@ -143,5 +143,5 @@
143
143
  "type": "git",
144
144
  "url": "git+https://github.com/nocobase/nocobase.git"
145
145
  },
146
- "gitHead": "81eab2cfa9d3d989e99ba0c914807b38db55f023"
146
+ "gitHead": "dc9246516f8a3efd6056f22fe7b3bc947bd4575b"
147
147
  }