@nocobase/cli 3.0.0-alpha.4 → 3.0.0-alpha.5

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.
@@ -12,7 +12,7 @@ import { resolveDefaultConfigScope } from '../../lib/cli-home.js';
12
12
  import { translateCli } from '../../lib/cli-locale.js';
13
13
  import { ensureCrossEnvConfirmed, hasExplicitEnvSelection } from '../../lib/env-guard.js';
14
14
  import { createPortalWorkspace } from '../../lib/portal-create.js';
15
- import { printInfo, printSuccess } from '../../lib/ui.js';
15
+ import { printInfo, printSuccess, printWarning } from '../../lib/ui.js';
16
16
  const DEFAULT_PORTAL_TEMPLATE = '@nocobase/portal-template-default';
17
17
  const portalCreateText = (key, values, fallback) => translateCli(`commands.portalCreate.${key}`, values, { fallback });
18
18
  export default class PortalCreate extends Command {
@@ -101,5 +101,8 @@ export default class PortalCreate extends Command {
101
101
  printInfo(portalCreateText('messages.app', { app: result.app }, `App: ${result.app}`));
102
102
  printInfo(portalCreateText('messages.base', { base: result.portalBase }, `Base: ${result.portalBase}`));
103
103
  printInfo(portalCreateText('messages.sourceStorage', { sourceStorage: result.sourceStorage }, `Source storage: ${result.sourceStorage}`));
104
+ if (result.installFailed) {
105
+ printWarning(portalCreateText('messages.installFailed', { portalDir: result.portalDir }, `Dependency installation did not finish successfully. Run \`pnpm install\` manually in ${result.portalDir}.`));
106
+ }
104
107
  }
105
108
  }
@@ -59,6 +59,8 @@ export default class PortalDev extends Command {
59
59
  await devPortalWorkspace({
60
60
  portal: args.portal,
61
61
  env,
62
+ envName,
63
+ cliVersion: String(this.config?.pjson?.version ?? '').trim(),
62
64
  onStart: (result) => {
63
65
  printInfo(portalDevText('messages.starting', { portal: result.portal }, `Starting portal "${result.portal}"...`));
64
66
  printInfo(portalDevText('messages.mode', { mode: result.mode }, `Mode: ${result.mode}`));
@@ -12,7 +12,7 @@ import { resolveDefaultConfigScope } from '../../lib/cli-home.js';
12
12
  import { translateCli } from '../../lib/cli-locale.js';
13
13
  import { ensureCrossEnvConfirmed, hasExplicitEnvSelection } from '../../lib/env-guard.js';
14
14
  import { pullPortalSource } from '../../lib/portal-source.js';
15
- import { printInfo, printSuccess } from '../../lib/ui.js';
15
+ import { printInfo, printSuccess, printWarning } from '../../lib/ui.js';
16
16
  const portalPullText = (key, values, fallback) => translateCli(`commands.portalPull.${key}`, values, { fallback });
17
17
  export default class PortalPull extends Command {
18
18
  static summary = 'Pull portal source into local files';
@@ -80,5 +80,8 @@ export default class PortalPull extends Command {
80
80
  return;
81
81
  }
82
82
  printSuccess(portalPullText('messages.pulled', { portal: result.portal, portalDir: result.portalDir }, `Pulled portal source "${result.portal}" into ${result.portalDir}.`));
83
+ if (result.installFailed) {
84
+ printWarning(portalPullText('messages.installFailed', { portalDir: result.portalDir }, `Dependency installation did not finish successfully. Run \`pnpm install\` manually in ${result.portalDir}.`));
85
+ }
83
86
  }
84
87
  }
@@ -0,0 +1,27 @@
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 { readFile, writeFile } from 'node:fs/promises';
10
+ import path from 'node:path';
11
+ const BUILD_HTML_SCRIPT_PATH = path.join('scripts', 'build-html.mjs');
12
+ const BUILD_HTML_ENV_FILES_PATTERN = /return\s+\[\s*["']\.env["']\s*,\s*["']\.env\.local["']\s*,\s*`\.env\.\$\{mode\}`\s*,\s*`\.env\.\$\{mode\}\.local`\s*\]\.map\(\s*\(?file\)?\s*=>\s*path\.join\(rootDir,\s*file\)\s*\);/m;
13
+ const BUILD_HTML_ENV_ONLY_REPLACEMENT = 'return [".env"].map((file) => path.join(rootDir, file));';
14
+ export async function ensurePortalBuildHtmlReadsEnvOnly(portalDir) {
15
+ const scriptPath = path.join(portalDir, BUILD_HTML_SCRIPT_PATH);
16
+ let content;
17
+ try {
18
+ content = await readFile(scriptPath, 'utf-8');
19
+ }
20
+ catch {
21
+ return;
22
+ }
23
+ const nextContent = content.replace(BUILD_HTML_ENV_FILES_PATTERN, BUILD_HTML_ENV_ONLY_REPLACEMENT);
24
+ if (nextContent !== content) {
25
+ await writeFile(scriptPath, nextContent, 'utf-8');
26
+ }
27
+ }
@@ -19,9 +19,10 @@ const UPDATE_PORTAL_OPERATION = {
19
19
  bodyRequired: true,
20
20
  parameters: [
21
21
  {
22
- name: 'filterByTk',
23
- flagName: 'filterByTk',
22
+ name: 'filter',
23
+ flagName: 'filter',
24
24
  in: 'query',
25
+ type: 'object',
25
26
  required: true,
26
27
  },
27
28
  ],
@@ -120,7 +121,9 @@ export async function syncPortalConfigToRemote(options) {
120
121
  cliVersion: options.cliVersion ?? '',
121
122
  envName: options.envName,
122
123
  flags: {
123
- filterByTk: options.portal,
124
+ filter: {
125
+ portalName: options.portal,
126
+ },
124
127
  body: JSON.stringify({
125
128
  options: mergePortalConfigIntoOptions(options.config, options.currentOptions),
126
129
  }),
@@ -9,7 +9,7 @@
9
9
  import { mkdir, stat } from 'node:fs/promises';
10
10
  import path from 'node:path';
11
11
  import { translateCli } from './cli-locale.js';
12
- import { resolvePortalAppFromApiBaseUrl, resolvePortalStoragePath, validatePortalSlug, } from './portal-create.js';
12
+ import { resolvePortalAppContext, resolvePortalStoragePath, validatePortalSlug, } from './portal-create.js';
13
13
  import { buildPortalConfig, buildPortalConfigFromOptions, readPortalConfig, syncPortalConfigToRemote, writePortalConfig, } from './portal-config.js';
14
14
  import { findPortalListItem } from './portal-info.js';
15
15
  import { listPortalWorkspaces } from './portal-list.js';
@@ -64,9 +64,9 @@ export async function configurePortalWorkspace(options) {
64
64
  throw new Error(portalConfigureText('errors.noChanges', undefined, 'No portal configuration changes were provided. Pass --source-storage or a --git-* flag.'));
65
65
  }
66
66
  const portal = validatePortalSlug(options.portal);
67
- const apiBaseUrl = trimValue(options.env.apiBaseUrl);
68
67
  const storagePath = resolvePortalStoragePath(options.env);
69
- const { app } = resolvePortalAppFromApiBaseUrl(apiBaseUrl, options.env.config.appPublicPath);
68
+ const appContext = await resolvePortalAppContext(options);
69
+ const { app } = appContext;
70
70
  const portalDir = path.join(storagePath, 'portals', app, portal);
71
71
  if (!(await pathExists(portalDir))) {
72
72
  throw new Error(portalConfigureText('errors.workspaceMissing', { portalDir, portal }, `Portal does not exist: ${portalDir}\nRun \`nb portal create ${portal}\` or \`nb portal pull ${portal}\` first.`));
@@ -76,6 +76,7 @@ export async function configurePortalWorkspace(options) {
76
76
  envName: options.envName,
77
77
  cliVersion: options.cliVersion,
78
78
  apiRequest: options.apiRequest,
79
+ appContext,
79
80
  });
80
81
  const remoteItem = findPortalListItem(list.items, portal);
81
82
  const existingConfig = (await readExistingConfig(portalDir)) ??
@@ -19,9 +19,10 @@ import { appendAppPublicPath, resolveAppPublicPath } from './app-public-path.js'
19
19
  import { executeApiRequest } from './api-client.js';
20
20
  import { resolveEnvRelativePath } from './cli-home.js';
21
21
  import { translateCli } from './cli-locale.js';
22
+ import { ensurePortalBuildHtmlReadsEnvOnly } from './portal-build-html.js';
22
23
  import { buildPortalCommandEnv } from './portal-command-env.js';
23
24
  import { buildPortalConfig, mergePortalConfigIntoOptions, writePortalConfig, } from './portal-config.js';
24
- import { run, runPnpmCommand } from './run-npm.js';
25
+ import { resolvePnpmInstallCommand, run, runPnpmInstallCommand } from './run-npm.js';
25
26
  const DEFAULT_PORTAL_TEMPLATE = '@nocobase/portal-template-default';
26
27
  const DEFAULT_PORTAL_APP_NAME = 'main';
27
28
  const TEMPLATE_COPY_EXCLUDED_NAMES = new Set(['.git', 'node_modules', '.DS_Store']);
@@ -42,6 +43,11 @@ const FIRST_OR_CREATE_PORTAL_OPERATION = {
42
43
  },
43
44
  ],
44
45
  };
46
+ const APP_INFO_OPERATION = {
47
+ method: 'GET',
48
+ pathTemplate: '/app:getInfo',
49
+ parameters: [],
50
+ };
45
51
  function trimValue(value) {
46
52
  return String(value ?? '').trim();
47
53
  }
@@ -319,6 +325,54 @@ export function resolvePortalAppFromApiBaseUrl(apiBaseUrl, appPublicPath) {
319
325
  appPublicPath: resolveAppPublicPath(configuredPublicPath || inferredPublicPath),
320
326
  };
321
327
  }
328
+ function readAppNameFromInfo(data) {
329
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
330
+ return undefined;
331
+ }
332
+ const direct = data.name;
333
+ if (typeof direct === 'string' && direct.trim()) {
334
+ return direct.trim();
335
+ }
336
+ return readAppNameFromInfo(data.data);
337
+ }
338
+ function isValidPortalAppName(value) {
339
+ return /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(value);
340
+ }
341
+ async function resolvePortalAppFromServer(options) {
342
+ const apiRequest = options.apiRequest ?? executeApiRequest;
343
+ try {
344
+ const response = await apiRequest({
345
+ cliVersion: options.cliVersion ?? '',
346
+ envName: options.envName,
347
+ flags: {},
348
+ operation: APP_INFO_OPERATION,
349
+ });
350
+ if (!response.ok) {
351
+ return undefined;
352
+ }
353
+ const appName = readAppNameFromInfo(response.data);
354
+ return appName && isValidPortalAppName(appName) ? appName : undefined;
355
+ }
356
+ catch {
357
+ return undefined;
358
+ }
359
+ }
360
+ export async function resolvePortalAppContext(options) {
361
+ const apiBaseUrl = trimValue(options.env.apiBaseUrl);
362
+ const resolvedApp = resolvePortalAppFromApiBaseUrl(apiBaseUrl, options.env.config.appPublicPath);
363
+ if (options.env.kind !== 'http') {
364
+ return {
365
+ ...resolvedApp,
366
+ portalBaseApp: resolvedApp.app,
367
+ };
368
+ }
369
+ const serverApp = await resolvePortalAppFromServer(options);
370
+ return {
371
+ app: serverApp ?? resolvedApp.app,
372
+ appPublicPath: resolvedApp.appPublicPath,
373
+ portalBaseApp: resolvedApp.app,
374
+ };
375
+ }
322
376
  export function buildPortalBasePath(params) {
323
377
  const segment = params.app === DEFAULT_PORTAL_APP_NAME
324
378
  ? `x/${params.portal}`
@@ -363,8 +417,8 @@ export async function createPortalWorkspace(options) {
363
417
  const apiBaseUrl = trimValue(options.env.apiBaseUrl);
364
418
  const envApiUrl = resolvePortalEnvApiUrl(apiBaseUrl);
365
419
  const storagePath = resolvePortalStoragePath(options.env);
366
- const { app, appPublicPath } = resolvePortalAppFromApiBaseUrl(apiBaseUrl, options.env.config.appPublicPath);
367
- const portalBase = buildPortalBasePath({ app, appPublicPath, portal });
420
+ const { app, appPublicPath, portalBaseApp } = await resolvePortalAppContext(options);
421
+ const portalBase = buildPortalBasePath({ app: portalBaseApp ?? app, appPublicPath, portal });
368
422
  const portalParentDir = path.join(storagePath, 'portals', app);
369
423
  const portalDir = path.join(portalParentDir, portal);
370
424
  assertPortalDirIsInsideParent(portalParentDir, portalDir);
@@ -381,6 +435,7 @@ export async function createPortalWorkspace(options) {
381
435
  let shouldCleanupTempDir = true;
382
436
  try {
383
437
  await copyTemplate(template.dir, tempDir);
438
+ await ensurePortalBuildHtmlReadsEnvOnly(tempDir);
384
439
  await writeFile(path.join(tempDir, '.env'), [`NOCOBASE_API_URL=${envApiUrl}`, `NOCOBASE_PORTAL_BASE=${portalBase}`].join('\n') + '\n', 'utf-8');
385
440
  await writeFile(path.join(tempDir, '.env.local'), [`NOCOBASE_API_URL=${apiBaseUrl}`, `NOCOBASE_PORTAL_BASE=${portalBase}`].join('\n') + '\n', 'utf-8');
386
441
  await writePortalConfig(tempDir, portalConfig);
@@ -390,14 +445,23 @@ export async function createPortalWorkspace(options) {
390
445
  await rename(tempDir, portalDir);
391
446
  shouldCleanupTempDir = false;
392
447
  const hasPackageJson = await pathExists(path.join(portalDir, 'package.json'));
448
+ let dependenciesInstalled = false;
449
+ let installFailed = false;
393
450
  if (hasPackageJson) {
394
451
  const runCommand = options.runCommand ?? run;
395
- await runPnpmCommand(runCommand, ['install'], {
396
- cwd: portalDir,
397
- env: buildPortalCommandEnv(),
398
- envMode: 'replace',
399
- errorName: 'pnpm install',
400
- });
452
+ const installCommand = await resolvePnpmInstallCommand(portalDir);
453
+ try {
454
+ await runPnpmInstallCommand(runCommand, installCommand.args, {
455
+ cwd: portalDir,
456
+ env: buildPortalCommandEnv(),
457
+ envMode: 'replace',
458
+ errorName: installCommand.errorName,
459
+ });
460
+ dependenciesInstalled = true;
461
+ }
462
+ catch {
463
+ installFailed = true;
464
+ }
401
465
  }
402
466
  else {
403
467
  options.onSkipInstall?.(portalCreateText('messages.skipInstall', { portalDir }, `Skipped pnpm install because package.json was not found in ${portalDir}.`));
@@ -421,6 +485,8 @@ export async function createPortalWorkspace(options) {
421
485
  portalBase,
422
486
  template,
423
487
  installSkipped: !hasPackageJson,
488
+ dependenciesInstalled,
489
+ installFailed,
424
490
  sourceStorage: portalConfig.sourceStorage,
425
491
  };
426
492
  }
@@ -12,11 +12,12 @@ import path from 'node:path';
12
12
  import * as tar from 'tar';
13
13
  import { executeApiRequest } from './api-client.js';
14
14
  import { translateCli } from './cli-locale.js';
15
- import { buildPortalBasePath, resolvePortalAppFromApiBaseUrl, resolvePortalStoragePath, titleFromPortalSlug, validatePortalSlug, } from './portal-create.js';
15
+ import { ensurePortalBuildHtmlReadsEnvOnly } from './portal-build-html.js';
16
+ import { buildPortalBasePath, resolvePortalAppContext, resolvePortalEnvApiUrl, resolvePortalStoragePath, titleFromPortalSlug, validatePortalSlug, } from './portal-create.js';
16
17
  import { buildPortalCommandEnv } from './portal-command-env.js';
17
18
  import { updatePortalEnvFiles } from './portal-env-files.js';
18
19
  import { mergePortalConfigIntoOptions, readPortalConfig } from './portal-config.js';
19
- import { run, runPnpmCommand } from './run-npm.js';
20
+ import { resolvePnpmInstallCommand, run, runPnpmCommand, runPnpmInstallCommand } from './run-npm.js';
20
21
  const portalDeployText = (key, values, fallback) => translateCli(`commands.portalDeploy.${key}`, values, { fallback });
21
22
  const DEPLOY_OPERATION = {
22
23
  method: 'POST',
@@ -192,9 +193,11 @@ async function syncMultiPortalRecord(params) {
192
193
  export async function deployPortalWorkspace(options) {
193
194
  const portal = validatePortalSlug(options.portal);
194
195
  const apiBaseUrl = trimValue(options.env.apiBaseUrl);
196
+ const envApiUrl = resolvePortalEnvApiUrl(apiBaseUrl);
195
197
  const storagePath = resolvePortalStoragePath(options.env);
196
- const { app, appPublicPath } = resolvePortalAppFromApiBaseUrl(apiBaseUrl, options.env.config.appPublicPath);
197
- const portalBase = buildPortalBasePath({ app, appPublicPath, portal });
198
+ const { app, appPublicPath, portalBaseApp } = await resolvePortalAppContext(options);
199
+ const portalBase = buildPortalBasePath({ app: portalBaseApp ?? app, appPublicPath, portal });
200
+ const deployBase = buildPortalBasePath({ app, appPublicPath, portal });
198
201
  const portalDir = path.join(storagePath, 'portals', app, portal);
199
202
  const distDir = path.join(portalDir, 'dist');
200
203
  if (!(await pathExists(portalDir))) {
@@ -207,12 +210,14 @@ export async function deployPortalWorkspace(options) {
207
210
  apiBaseUrl,
208
211
  portalBase,
209
212
  });
213
+ await ensurePortalBuildHtmlReadsEnvOnly(portalDir);
210
214
  const runCommand = options.runCommand ?? run;
211
- await runPnpmCommand(runCommand, ['install'], {
215
+ const installCommand = await resolvePnpmInstallCommand(portalDir);
216
+ await runPnpmInstallCommand(runCommand, installCommand.args, {
212
217
  cwd: portalDir,
213
218
  env: buildPortalCommandEnv(),
214
219
  envMode: 'replace',
215
- errorName: 'pnpm install',
220
+ errorName: installCommand.errorName,
216
221
  });
217
222
  await runPnpmCommand(runCommand, ['build'], {
218
223
  cwd: portalDir,
@@ -226,7 +231,7 @@ export async function deployPortalWorkspace(options) {
226
231
  await runPnpmCommand(runCommand, ['build:html'], {
227
232
  cwd: portalDir,
228
233
  env: buildPortalCommandEnv({
229
- NOCOBASE_API_URL: apiBaseUrl,
234
+ NOCOBASE_API_URL: envApiUrl,
230
235
  NOCOBASE_PORTAL_BASE: portalBase,
231
236
  }),
232
237
  envMode: 'replace',
@@ -268,7 +273,7 @@ export async function deployPortalWorkspace(options) {
268
273
  archivePath: archive.archivePath,
269
274
  app,
270
275
  portal,
271
- portalBase,
276
+ portalBase: deployBase,
272
277
  envName: options.envName,
273
278
  cliVersion: options.cliVersion,
274
279
  apiRequest: options.apiRequest,
@@ -10,23 +10,21 @@ import { rm, stat } from 'node:fs/promises';
10
10
  import path from 'node:path';
11
11
  import { executeApiRequest } from './api-client.js';
12
12
  import { translateCli } from './cli-locale.js';
13
- import { buildPortalBasePath, resolvePortalAppFromApiBaseUrl, resolvePortalStoragePath, validatePortalSlug, } from './portal-create.js';
13
+ import { buildPortalBasePath, resolvePortalAppContext, resolvePortalStoragePath, validatePortalSlug, } from './portal-create.js';
14
14
  const portalDestroyText = (key, values, fallback) => translateCli(`commands.portalDestroy.${key}`, values, { fallback });
15
15
  const DESTROY_PORTAL_OPERATION = {
16
16
  method: 'POST',
17
17
  pathTemplate: '/multiPortals:destroy',
18
18
  parameters: [
19
19
  {
20
- name: 'filterByTk',
21
- flagName: 'filterByTk',
20
+ name: 'filter',
21
+ flagName: 'filter',
22
22
  in: 'query',
23
+ type: 'object',
23
24
  required: true,
24
25
  },
25
26
  ],
26
27
  };
27
- function trimValue(value) {
28
- return String(value ?? '').trim();
29
- }
30
28
  async function pathExists(target) {
31
29
  try {
32
30
  await stat(target);
@@ -48,7 +46,9 @@ async function destroyMultiPortalRecord(params) {
48
46
  cliVersion: params.cliVersion ?? '',
49
47
  envName: params.envName,
50
48
  flags: {
51
- filterByTk: params.portal,
49
+ filter: {
50
+ portalName: params.portal,
51
+ },
52
52
  },
53
53
  operation: DESTROY_PORTAL_OPERATION,
54
54
  });
@@ -62,10 +62,9 @@ async function destroyMultiPortalRecord(params) {
62
62
  }
63
63
  export async function destroyPortalWorkspace(options) {
64
64
  const portal = validatePortalSlug(options.portal);
65
- const apiBaseUrl = trimValue(options.env.apiBaseUrl);
66
65
  const storagePath = resolvePortalStoragePath(options.env);
67
- const { app, appPublicPath } = resolvePortalAppFromApiBaseUrl(apiBaseUrl, options.env.config.appPublicPath);
68
- const portalBase = buildPortalBasePath({ app, appPublicPath, portal });
66
+ const { app, appPublicPath, portalBaseApp } = await resolvePortalAppContext(options);
67
+ const portalBase = buildPortalBasePath({ app: portalBaseApp ?? app, appPublicPath, portal });
69
68
  const portalParentDir = path.join(storagePath, 'portals', app);
70
69
  const portalDir = path.join(portalParentDir, portal);
71
70
  const mode = options.env.kind;
@@ -75,9 +74,6 @@ export async function destroyPortalWorkspace(options) {
75
74
  const destroyMode = mode;
76
75
  assertPortalDirIsInsideParent(portalParentDir, portalDir);
77
76
  const workspaceExists = await pathExists(portalDir);
78
- if (!workspaceExists && !options.force) {
79
- throw new Error(portalDestroyText('errors.workspaceMissing', { portalDir, portal }, `Portal does not exist: ${portalDir}\nPass --force to ignore missing local files.`));
80
- }
81
77
  const recordDeleted = await destroyMultiPortalRecord({
82
78
  portal,
83
79
  envName: options.envName,
@@ -9,7 +9,7 @@
9
9
  import { stat } from 'node:fs/promises';
10
10
  import path from 'node:path';
11
11
  import { translateCli } from './cli-locale.js';
12
- import { buildPortalBasePath, resolvePortalAppFromApiBaseUrl, resolvePortalStoragePath, validatePortalSlug, } from './portal-create.js';
12
+ import { buildPortalBasePath, resolvePortalAppContext, resolvePortalStoragePath, validatePortalSlug, } from './portal-create.js';
13
13
  import { buildPortalCommandEnv } from './portal-command-env.js';
14
14
  import { updatePortalEnvFiles } from './portal-env-files.js';
15
15
  import { run, runPnpmCommand } from './run-npm.js';
@@ -45,8 +45,8 @@ export async function devPortalWorkspace(options) {
45
45
  throw new Error(portalDevText('errors.sshUnsupported', undefined, 'Cannot start a portal in dev mode for ssh envs in the first version.'));
46
46
  }
47
47
  const storagePath = resolvePortalStoragePath(options.env);
48
- const { app, appPublicPath } = resolvePortalAppFromApiBaseUrl(apiBaseUrl, options.env.config.appPublicPath);
49
- const portalBase = buildPortalBasePath({ app, appPublicPath, portal });
48
+ const { app, appPublicPath, portalBaseApp } = await resolvePortalAppContext(options);
49
+ const portalBase = buildPortalBasePath({ app: portalBaseApp ?? app, appPublicPath, portal });
50
50
  const portalDir = path.join(storagePath, 'portals', app, portal);
51
51
  if (!(await pathExists(portalDir))) {
52
52
  throw new Error(portalDevText('errors.workspaceMissing', { portalDir, portal }, `Portal does not exist: ${portalDir}\nRun \`nb portal create ${portal}\` first.`));
@@ -42,8 +42,9 @@ export async function upsertPortalEnvFile(filePath, values) {
42
42
  await writeFile(filePath, upsertEnvContent(content, values), 'utf-8');
43
43
  }
44
44
  export async function updatePortalEnvFiles(params) {
45
+ const envApiUrl = resolvePortalEnvApiUrl(params.apiBaseUrl);
45
46
  await upsertPortalEnvFile(path.join(params.portalDir, '.env'), {
46
- NOCOBASE_API_URL: resolvePortalEnvApiUrl(params.apiBaseUrl),
47
+ NOCOBASE_API_URL: envApiUrl,
47
48
  NOCOBASE_PORTAL_BASE: params.portalBase,
48
49
  });
49
50
  await upsertPortalEnvFile(path.join(params.portalDir, '.env.local'), {
@@ -11,7 +11,7 @@ import path from 'node:path';
11
11
  import { appendAppPublicPath } from './app-public-path.js';
12
12
  import { executeApiRequest } from './api-client.js';
13
13
  import { translateCli } from './cli-locale.js';
14
- import { buildPortalBasePath, resolvePortalAppFromApiBaseUrl, resolvePortalStoragePath, } from './portal-create.js';
14
+ import { buildPortalBasePath, resolvePortalAppContext, resolvePortalStoragePath, } from './portal-create.js';
15
15
  const portalListText = (key, values, fallback) => translateCli(`commands.portalList.${key}`, values, { fallback });
16
16
  const LIST_PORTALS_OPERATION = {
17
17
  method: 'GET',
@@ -159,8 +159,9 @@ async function listMultiPortalRecords(params) {
159
159
  export async function listPortalWorkspaces(options) {
160
160
  const apiBaseUrl = trimValue(options.env.apiBaseUrl);
161
161
  const storagePath = resolvePortalStoragePath(options.env);
162
- const { app, appPublicPath } = resolvePortalAppFromApiBaseUrl(apiBaseUrl, options.env.config.appPublicPath);
162
+ const { app, appPublicPath, portalBaseApp } = options.appContext ?? (await resolvePortalAppContext(options));
163
163
  const mode = options.env.kind;
164
+ const baseApp = portalBaseApp ?? app;
164
165
  if (mode !== 'local' && mode !== 'docker' && mode !== 'http') {
165
166
  throw new Error(portalListText('errors.unsupportedEnvKind', { kind: mode }, `Cannot list portals for ${mode} envs in the first version.`));
166
167
  }
@@ -195,8 +196,8 @@ export async function listPortalWorkspaces(options) {
195
196
  options,
196
197
  portalUrl: enabled
197
198
  ? buildPortalAccessUrl(apiBaseUrl, isAi
198
- ? buildPortalBasePath({ app, appPublicPath, portal: portalName })
199
- : buildNoCodePortalBasePath({ app, appPublicPath, routePath }))
199
+ ? buildPortalBasePath({ app: baseApp, appPublicPath, portal: portalName })
200
+ : buildNoCodePortalBasePath({ app: baseApp, appPublicPath, routePath }))
200
201
  : '',
201
202
  portalDir,
202
203
  localSynced: isAi ? await pathExists(portalDir) : null,
@@ -14,12 +14,13 @@ import path from 'node:path';
14
14
  import * as tar from 'tar';
15
15
  import { executeApiRequest } from './api-client.js';
16
16
  import { translateCli } from './cli-locale.js';
17
+ import { ensurePortalBuildHtmlReadsEnvOnly } from './portal-build-html.js';
17
18
  import { buildPortalConfig, buildPortalConfigFromOptions, DEFAULT_PORTAL_GIT_PATH, readPortalConfig, syncPortalConfigToRemote, writePortalConfig, } from './portal-config.js';
18
19
  import { buildPortalCommandEnv } from './portal-command-env.js';
19
- import { buildPortalBasePath, resolvePortalAppFromApiBaseUrl, resolvePortalStoragePath, validatePortalSlug, } from './portal-create.js';
20
+ import { buildPortalBasePath, resolvePortalAppContext, resolvePortalStoragePath, validatePortalSlug, } from './portal-create.js';
20
21
  import { listPortalWorkspaces } from './portal-list.js';
21
22
  import { findPortalListItem } from './portal-info.js';
22
- import { run, runPnpmCommand } from './run-npm.js';
23
+ import { resolvePnpmInstallCommand, run, runPnpmInstallCommand } from './run-npm.js';
23
24
  const execFileAsync = promisify(execFile);
24
25
  const portalSourceText = (key, values, fallback) => translateCli(`commands.portalSource.${key}`, values, { fallback });
25
26
  const PULL_SOURCE_OPERATION = {
@@ -128,6 +129,20 @@ async function packPortalSource(portalDir) {
128
129
  },
129
130
  };
130
131
  }
132
+ async function replaceExistingPortalDirectory(params) {
133
+ if (!(await pathExists(path.join(params.portalDir, '.git')))) {
134
+ await rm(params.portalDir, { recursive: true, force: true });
135
+ await rename(params.sourceDir, params.portalDir);
136
+ return;
137
+ }
138
+ const existingEntries = await readdir(params.portalDir);
139
+ await Promise.all(existingEntries
140
+ .filter((entry) => entry !== '.git')
141
+ .map((entry) => rm(path.join(params.portalDir, entry), { recursive: true, force: true })));
142
+ const sourceEntries = await readdir(params.sourceDir);
143
+ await Promise.all(sourceEntries.map((entry) => rename(path.join(params.sourceDir, entry), path.join(params.portalDir, entry))));
144
+ await rm(params.sourceDir, { recursive: true, force: true });
145
+ }
131
146
  async function replacePortalSourceFromArchive(params) {
132
147
  const targetExists = await pathExists(params.portalDir);
133
148
  if (targetExists && !params.force) {
@@ -143,7 +158,11 @@ async function replacePortalSourceFromArchive(params) {
143
158
  filter: validatePortalSourceTarEntry,
144
159
  });
145
160
  if (targetExists) {
146
- await rm(params.portalDir, { recursive: true, force: true });
161
+ await replaceExistingPortalDirectory({
162
+ sourceDir: tempDir,
163
+ portalDir: params.portalDir,
164
+ });
165
+ return;
147
166
  }
148
167
  await rename(tempDir, params.portalDir);
149
168
  }
@@ -165,7 +184,11 @@ async function replacePortalSourceFromDirectory(params) {
165
184
  filter: (source) => shouldPackPortalSourceEntry(path.relative(params.sourceDir, source)),
166
185
  });
167
186
  if (targetExists) {
168
- await rm(params.portalDir, { recursive: true, force: true });
187
+ await replaceExistingPortalDirectory({
188
+ sourceDir: tempDir,
189
+ portalDir: params.portalDir,
190
+ });
191
+ return;
169
192
  }
170
193
  await rename(tempDir, params.portalDir);
171
194
  }
@@ -174,6 +197,13 @@ async function replacePortalSourceFromDirectory(params) {
174
197
  throw error;
175
198
  }
176
199
  }
200
+ async function assertPortalDirectoryCanBeReplaced(params) {
201
+ const targetExists = await pathExists(params.portalDir);
202
+ if (targetExists && !params.force) {
203
+ throw new Error(portalSourceText('errors.workspaceExists', { portalDir: params.portalDir }, `Portal already exists: ${params.portalDir}\nPass --force to delete it and pull again.`));
204
+ }
205
+ return targetExists;
206
+ }
177
207
  async function runGit(args, cwd) {
178
208
  return await execFileAsync('git', args, {
179
209
  cwd,
@@ -185,24 +215,37 @@ async function installPortalDependencies(params) {
185
215
  return {
186
216
  dependenciesInstalled: false,
187
217
  installSkipped: true,
218
+ installFailed: false,
188
219
  };
189
220
  }
190
221
  if (!(await isFile(path.join(params.portalDir, 'package.json')))) {
191
222
  return {
192
223
  dependenciesInstalled: false,
193
224
  installSkipped: true,
225
+ installFailed: false,
194
226
  };
195
227
  }
196
228
  const runCommand = params.runCommand ?? run;
197
- await runPnpmCommand(runCommand, ['install'], {
198
- cwd: params.portalDir,
199
- env: buildPortalCommandEnv(),
200
- envMode: 'replace',
201
- errorName: 'pnpm install',
202
- });
229
+ const installCommand = await resolvePnpmInstallCommand(params.portalDir);
230
+ try {
231
+ await runPnpmInstallCommand(runCommand, installCommand.args, {
232
+ cwd: params.portalDir,
233
+ env: buildPortalCommandEnv(),
234
+ envMode: 'replace',
235
+ errorName: installCommand.errorName,
236
+ });
237
+ }
238
+ catch {
239
+ return {
240
+ dependenciesInstalled: false,
241
+ installSkipped: false,
242
+ installFailed: true,
243
+ };
244
+ }
203
245
  return {
204
246
  dependenciesInstalled: true,
205
247
  installSkipped: false,
248
+ installFailed: false,
206
249
  };
207
250
  }
208
251
  function readSourceRevision(data) {
@@ -219,10 +262,11 @@ async function resolvePortalSourceContext(options) {
219
262
  const portal = validatePortalSlug(options.portal);
220
263
  const apiBaseUrl = trimValue(options.env.apiBaseUrl);
221
264
  const storagePath = resolvePortalStoragePath(options.env);
222
- const { app, appPublicPath } = resolvePortalAppFromApiBaseUrl(apiBaseUrl, options.env.config.appPublicPath);
223
- const portalDir = path.join(storagePath, 'portals', app, portal);
224
- const portalBase = buildPortalBasePath({ app, appPublicPath, portal });
225
265
  const mode = options.env.kind;
266
+ const appContext = await resolvePortalAppContext(options);
267
+ const { app, appPublicPath, portalBaseApp } = appContext;
268
+ const portalDir = path.join(storagePath, 'portals', app, portal);
269
+ const portalBase = buildPortalBasePath({ app: portalBaseApp ?? app, appPublicPath, portal });
226
270
  if (mode !== 'local' && mode !== 'docker' && mode !== 'http') {
227
271
  throw new Error(portalSourceText('errors.unsupportedEnvKind', { kind: mode }, `Cannot sync portal source for ${mode} envs in the first version.`));
228
272
  }
@@ -231,6 +275,7 @@ async function resolvePortalSourceContext(options) {
231
275
  envName: options.envName,
232
276
  cliVersion: options.cliVersion,
233
277
  apiRequest: options.apiRequest,
278
+ appContext,
234
279
  });
235
280
  const item = findPortalListItem(list.items, portal);
236
281
  if (!item) {
@@ -319,8 +364,76 @@ async function cloneGitSource(params) {
319
364
  }
320
365
  return repoDir;
321
366
  }
367
+ async function setGitOriginRepository(params) {
368
+ try {
369
+ await runGit(['remote', 'get-url', 'origin'], params.repoDir);
370
+ await runGit(['remote', 'set-url', 'origin', params.repo], params.repoDir);
371
+ }
372
+ catch {
373
+ await runGit(['remote', 'add', 'origin', params.repo], params.repoDir);
374
+ }
375
+ }
376
+ async function checkoutExistingGitRepository(params) {
377
+ await setGitOriginRepository({
378
+ repoDir: params.repoDir,
379
+ repo: params.repo,
380
+ });
381
+ try {
382
+ await runGit(['fetch', 'origin', params.branch], params.repoDir);
383
+ await runGit(['checkout', '-f', '-B', params.branch, 'FETCH_HEAD'], params.repoDir);
384
+ }
385
+ catch (error) {
386
+ await runGit(['fetch', 'origin'], params.repoDir);
387
+ await runGit(['checkout', '-f', '-B', params.branch], params.repoDir);
388
+ }
389
+ await runGit(['clean', '-fdx'], params.repoDir);
390
+ }
391
+ async function pullGitRepositoryRootPortalSource(params) {
392
+ const targetExists = await assertPortalDirectoryCanBeReplaced({
393
+ portalDir: params.context.portalDir,
394
+ force: params.force,
395
+ });
396
+ await mkdir(path.dirname(params.context.portalDir), { recursive: true });
397
+ if (targetExists && (await pathExists(path.join(params.context.portalDir, '.git')))) {
398
+ await checkoutExistingGitRepository({
399
+ repoDir: params.context.portalDir,
400
+ repo: params.repo,
401
+ branch: params.branch,
402
+ });
403
+ return;
404
+ }
405
+ if (targetExists) {
406
+ await rm(params.context.portalDir, { recursive: true, force: true });
407
+ }
408
+ const tempDir = await mkdtemp(path.join(path.dirname(params.context.portalDir), `.${path.basename(params.context.portalDir)}-git-pull-`));
409
+ try {
410
+ const repoDir = await cloneGitSource({
411
+ repo: params.repo,
412
+ branch: params.branch,
413
+ cwd: tempDir,
414
+ createBranch: true,
415
+ });
416
+ await rename(repoDir, params.context.portalDir);
417
+ }
418
+ catch (error) {
419
+ await rm(params.context.portalDir, { recursive: true, force: true });
420
+ throw error;
421
+ }
422
+ finally {
423
+ await rm(tempDir, { recursive: true, force: true });
424
+ }
425
+ }
322
426
  async function pullGitPortalSource(params) {
323
427
  const git = assertGitSourceConfig(params.context);
428
+ if (isGitRepositoryRootPath(git.gitPath)) {
429
+ await pullGitRepositoryRootPortalSource({
430
+ context: params.context,
431
+ repo: git.repo,
432
+ branch: git.branch,
433
+ force: params.force,
434
+ });
435
+ return;
436
+ }
324
437
  const tempDir = await mkdtemp(path.join(os.tmpdir(), 'nocobase-cli-portal-git-pull-'));
325
438
  try {
326
439
  const repoDir = await cloneGitSource({
@@ -390,6 +503,7 @@ export async function pullPortalSource(options) {
390
503
  context: sourceContext,
391
504
  force: options.force,
392
505
  });
506
+ await ensurePortalBuildHtmlReadsEnvOnly(sourceContext.portalDir);
393
507
  await writePortalConfig(sourceContext.portalDir, portalConfig);
394
508
  const installResult = await installPortalDependencies({
395
509
  portalDir: sourceContext.portalDir,
@@ -437,6 +551,7 @@ export async function pullPortalSource(options) {
437
551
  portalDir: sourceContext.portalDir,
438
552
  force: options.force,
439
553
  });
554
+ await ensurePortalBuildHtmlReadsEnvOnly(sourceContext.portalDir);
440
555
  await writePortalConfig(sourceContext.portalDir, portalConfig);
441
556
  const installResult = await installPortalDependencies({
442
557
  portalDir: sourceContext.portalDir,
@@ -105,6 +105,52 @@ export async function runPnpmCommand(runCommand, args, options) {
105
105
  throw createMissingCommandError('pnpm', options.errorName ?? `pnpm ${args.join(' ')}`.trim(), error) ?? error;
106
106
  }
107
107
  }
108
+ function createInstallArgsWithoutTrustLockfile(args) {
109
+ if (!args.includes('install') || !args.includes('--trust-lockfile')) {
110
+ return undefined;
111
+ }
112
+ return args.filter((arg) => arg !== '--trust-lockfile');
113
+ }
114
+ function isFriendlyMissingPnpmError(error) {
115
+ const message = error instanceof Error ? error.message : String(error);
116
+ return message.includes('because the pnpm executable could not be found');
117
+ }
118
+ function formatPnpmLabel(args) {
119
+ return `pnpm ${args.join(' ')}`.trim();
120
+ }
121
+ export async function resolvePnpmInstallCommand(cwd) {
122
+ const hasLockfile = await fsp
123
+ .stat(path.join(cwd, 'pnpm-lock.yaml'))
124
+ .then((stats) => stats.isFile())
125
+ .catch(() => false);
126
+ if (hasLockfile) {
127
+ const args = ['install', '--frozen-lockfile', '--trust-lockfile'];
128
+ return {
129
+ args,
130
+ errorName: formatPnpmLabel(args),
131
+ };
132
+ }
133
+ const args = ['install'];
134
+ return {
135
+ args,
136
+ errorName: formatPnpmLabel(args),
137
+ };
138
+ }
139
+ export async function runPnpmInstallCommand(runCommand, args, options) {
140
+ try {
141
+ await runPnpmCommand(runCommand, args, options);
142
+ }
143
+ catch (error) {
144
+ const fallbackArgs = createInstallArgsWithoutTrustLockfile(args);
145
+ if (!fallbackArgs || isFriendlyMissingPnpmError(error)) {
146
+ throw error;
147
+ }
148
+ await runPnpmCommand(runCommand, fallbackArgs, {
149
+ ...options,
150
+ errorName: formatPnpmLabel(fallbackArgs),
151
+ });
152
+ }
153
+ }
108
154
  function isDockerDaemonUnavailableError(error) {
109
155
  const message = error instanceof Error ? error.message : String(error);
110
156
  return DOCKER_DAEMON_UNAVAILABLE_PATTERNS.some((pattern) => pattern.test(message));
@@ -192,7 +192,8 @@
192
192
  "created": "Portal \"{{portal}}\" created at {{portalDir}}.",
193
193
  "app": "App: {{app}}",
194
194
  "base": "Base: {{base}}",
195
- "sourceStorage": "Source storage: {{sourceStorage}}"
195
+ "sourceStorage": "Source storage: {{sourceStorage}}",
196
+ "installFailed": "Dependency installation did not finish successfully. Run `pnpm install` manually in {{portalDir}}."
196
197
  }
197
198
  },
198
199
  "portalConfig": {
@@ -259,6 +260,17 @@
259
260
  "localSynced": "Local synced"
260
261
  }
261
262
  },
263
+ "portalPull": {
264
+ "errors": {
265
+ "envNotConfigured": "Env \"{{envName}}\" is not configured. Run `nb env add {{envName}} --api-base-url <url>` first.",
266
+ "noEnvConfigured": "No NocoBase env is configured yet. Run `nb init --ui` to create one first."
267
+ },
268
+ "messages": {
269
+ "noop": "No pull is needed.",
270
+ "pulled": "Pulled portal source \"{{portal}}\" into {{portalDir}}.",
271
+ "installFailed": "Dependency installation did not finish successfully. Run `pnpm install` manually in {{portalDir}}."
272
+ }
273
+ },
262
274
  "swagger": {
263
275
  "errors": {
264
276
  "pluginDisabled": "The API documentation plugin is not enabled. Enable it before requesting Swagger documents.",
@@ -192,7 +192,8 @@
192
192
  "created": "Portal \"{{portal}}\" 已创建:{{portalDir}}。",
193
193
  "app": "App:{{app}}",
194
194
  "base": "Base:{{base}}",
195
- "sourceStorage": "源码存储:{{sourceStorage}}"
195
+ "sourceStorage": "源码存储:{{sourceStorage}}",
196
+ "installFailed": "依赖安装没有成功完成。请在 {{portalDir}} 中手动运行 `pnpm install`。"
196
197
  }
197
198
  },
198
199
  "portalConfig": {
@@ -259,6 +260,17 @@
259
260
  "localSynced": "本地已同步"
260
261
  }
261
262
  },
263
+ "portalPull": {
264
+ "errors": {
265
+ "envNotConfigured": "env \"{{envName}}\" 尚未配置。请先运行 `nb env add {{envName}} --api-base-url <url>`。",
266
+ "noEnvConfigured": "还没有配置 NocoBase env。请先运行 `nb init --ui` 创建一个。"
267
+ },
268
+ "messages": {
269
+ "noop": "无需执行 pull。",
270
+ "pulled": "Portal 源码 \"{{portal}}\" 已拉取到 {{portalDir}}。",
271
+ "installFailed": "依赖安装没有成功完成。请在 {{portalDir}} 中手动运行 `pnpm install`。"
272
+ }
273
+ },
262
274
  "swagger": {
263
275
  "errors": {
264
276
  "pluginDisabled": "API 文档插件尚未启用。请先启用该插件,再获取 Swagger 文档。",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/cli",
3
- "version": "3.0.0-alpha.4",
3
+ "version": "3.0.0-alpha.5",
4
4
  "description": "NocoBase Command Line Tool",
5
5
  "type": "module",
6
6
  "main": "dist/generated/command-registry.js",
@@ -147,5 +147,5 @@
147
147
  "type": "git",
148
148
  "url": "git+https://github.com/nocobase/nocobase.git"
149
149
  },
150
- "gitHead": "6658768567378382de758c4e814ac510d688400c"
150
+ "gitHead": "82c7ed1cdb2f247c190582e34ee37f154cac0968"
151
151
  }