@nocobase/cli 3.0.0-alpha.5 → 3.0.0-alpha.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.
@@ -6,43 +6,23 @@
6
6
  * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
- import { mkdir, stat } from 'node:fs/promises';
10
- import path from 'node:path';
11
9
  import { translateCli } from './cli-locale.js';
12
- import { resolvePortalAppContext, resolvePortalStoragePath, validatePortalSlug, } from './portal-create.js';
13
- import { buildPortalConfig, buildPortalConfigFromOptions, readPortalConfig, syncPortalConfigToRemote, writePortalConfig, } from './portal-config.js';
10
+ import { resolvePortalAppContext, resolvePortalSourcePath, resolveSavedPortalSourcePath, validatePortalSlug, } from './portal-create.js';
11
+ import { buildPortalConfig, buildPortalConfigFromOptions, syncPortalConfigToRemote, } from './portal-config.js';
14
12
  import { findPortalListItem } from './portal-info.js';
15
13
  import { listPortalWorkspaces } from './portal-list.js';
16
14
  const portalConfigureText = (key, values, fallback) => translateCli(`commands.portalConfigure.${key}`, values, { fallback });
17
15
  function trimValue(value) {
18
16
  return String(value ?? '').trim();
19
17
  }
20
- async function pathExists(target) {
21
- try {
22
- await stat(target);
23
- return true;
24
- }
25
- catch {
26
- return false;
27
- }
28
- }
29
- function hasConfigurationChange(options) {
18
+ function hasSourceConfigurationChange(options) {
30
19
  return Boolean(options.sourceStorage !== undefined ||
31
20
  trimValue(options.gitRepo) ||
32
21
  trimValue(options.gitBranch) ||
33
22
  trimValue(options.gitPath));
34
23
  }
35
- async function readExistingConfig(portalDir) {
36
- try {
37
- return await readPortalConfig(portalDir);
38
- }
39
- catch (error) {
40
- const code = error.code;
41
- if (code === 'ENOENT') {
42
- return undefined;
43
- }
44
- throw error;
45
- }
24
+ function hasPathConfigurationChange(options) {
25
+ return Boolean(trimValue(options.sourcePath));
46
26
  }
47
27
  function buildConfigFromRemoteOptions(params) {
48
28
  if (params.options && Object.keys(params.options).length > 0) {
@@ -60,17 +40,19 @@ function buildConfigFromRemoteOptions(params) {
60
40
  });
61
41
  }
62
42
  export async function configurePortalWorkspace(options) {
63
- if (!hasConfigurationChange(options)) {
64
- throw new Error(portalConfigureText('errors.noChanges', undefined, 'No portal configuration changes were provided. Pass --source-storage or a --git-* flag.'));
43
+ const hasSourceChange = hasSourceConfigurationChange(options);
44
+ const hasPathChange = hasPathConfigurationChange(options);
45
+ if (!hasSourceChange && !hasPathChange) {
46
+ throw new Error(portalConfigureText('errors.noChanges', undefined, 'No portal configuration changes were provided. Pass --path, --source-storage, or a --git-* flag.'));
65
47
  }
66
48
  const portal = validatePortalSlug(options.portal);
67
- const storagePath = resolvePortalStoragePath(options.env);
49
+ const portalDir = hasPathChange
50
+ ? resolvePortalSourcePath(portal, options.sourcePath)
51
+ : resolveSavedPortalSourcePath(options.env, portal) ?? '';
68
52
  const appContext = await resolvePortalAppContext(options);
69
53
  const { app } = appContext;
70
- const portalDir = path.join(storagePath, 'portals', app, portal);
71
- if (!(await pathExists(portalDir))) {
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.`));
73
- }
54
+ let config;
55
+ let remoteSynced = false;
74
56
  const list = await listPortalWorkspaces({
75
57
  env: options.env,
76
58
  envName: options.envName,
@@ -79,16 +61,28 @@ export async function configurePortalWorkspace(options) {
79
61
  appContext,
80
62
  });
81
63
  const remoteItem = findPortalListItem(list.items, portal);
82
- const existingConfig = (await readExistingConfig(portalDir)) ??
83
- buildConfigFromRemoteOptions({
64
+ if (!remoteItem) {
65
+ throw new Error(portalConfigureText('errors.notFound', { portal }, `Portal "${portal}" was not found. Run \`nb portal list\` to see available portals.`));
66
+ }
67
+ if (!hasSourceChange) {
68
+ return {
69
+ app,
84
70
  portal,
85
- options: remoteItem?.options,
86
- sourceStorage: remoteItem?.sourceStorage,
87
- gitRepo: remoteItem?.gitRepo,
88
- gitBranch: remoteItem?.gitBranch,
89
- gitPath: remoteItem?.gitPath,
90
- });
91
- const config = buildPortalConfig({
71
+ portalDir,
72
+ config: undefined,
73
+ remoteSynced: false,
74
+ pathUpdated: hasPathChange,
75
+ };
76
+ }
77
+ const existingConfig = buildConfigFromRemoteOptions({
78
+ portal,
79
+ options: remoteItem.options,
80
+ sourceStorage: remoteItem.sourceStorage,
81
+ gitRepo: remoteItem.gitRepo,
82
+ gitBranch: remoteItem.gitBranch,
83
+ gitPath: remoteItem.gitPath,
84
+ });
85
+ config = buildPortalConfig({
92
86
  portal,
93
87
  sourceStorage: options.sourceStorage,
94
88
  gitRepo: options.gitRepo,
@@ -96,23 +90,21 @@ export async function configurePortalWorkspace(options) {
96
90
  gitPath: options.gitPath,
97
91
  existingConfig,
98
92
  });
99
- await mkdir(portalDir, { recursive: true });
100
- await writePortalConfig(portalDir, config);
101
- if (remoteItem) {
102
- await syncPortalConfigToRemote({
103
- portal,
104
- config,
105
- currentOptions: remoteItem.options,
106
- envName: options.envName,
107
- cliVersion: options.cliVersion,
108
- apiRequest: options.apiRequest,
109
- });
110
- }
93
+ await syncPortalConfigToRemote({
94
+ portal,
95
+ config,
96
+ currentOptions: remoteItem.options,
97
+ envName: options.envName,
98
+ cliVersion: options.cliVersion,
99
+ apiRequest: options.apiRequest,
100
+ });
101
+ remoteSynced = true;
111
102
  return {
112
103
  app,
113
104
  portal,
114
105
  portalDir,
115
106
  config,
116
- remoteSynced: Boolean(remoteItem),
107
+ remoteSynced,
108
+ pathUpdated: hasPathChange,
117
109
  };
118
110
  }
@@ -17,11 +17,13 @@ import { createGunzip } from 'node:zlib';
17
17
  import * as tar from 'tar';
18
18
  import { appendAppPublicPath, resolveAppPublicPath } from './app-public-path.js';
19
19
  import { executeApiRequest } from './api-client.js';
20
+ import { resolveEnvPortalPath } from './auth-store.js';
20
21
  import { resolveEnvRelativePath } from './cli-home.js';
21
22
  import { translateCli } from './cli-locale.js';
22
23
  import { ensurePortalBuildHtmlReadsEnvOnly } from './portal-build-html.js';
23
24
  import { buildPortalCommandEnv } from './portal-command-env.js';
24
- import { buildPortalConfig, mergePortalConfigIntoOptions, writePortalConfig, } from './portal-config.js';
25
+ import { canReplacePortalDirectory } from './portal-path-safety.js';
26
+ import { buildPortalConfig, mergePortalConfigIntoOptions, } from './portal-config.js';
25
27
  import { resolvePnpmInstallCommand, run, runPnpmInstallCommand } from './run-npm.js';
26
28
  const DEFAULT_PORTAL_TEMPLATE = '@nocobase/portal-template-default';
27
29
  const DEFAULT_PORTAL_APP_NAME = 'main';
@@ -404,6 +406,19 @@ export function resolvePortalStoragePath(env) {
404
406
  }
405
407
  return path.resolve(process.cwd(), 'storage');
406
408
  }
409
+ function resolveAbsolutePath(value) {
410
+ return path.isAbsolute(value) ? value : path.resolve(process.cwd(), value);
411
+ }
412
+ export function resolvePortalSourcePath(portal, sourcePath) {
413
+ return resolveAbsolutePath(trimValue(sourcePath) || portal);
414
+ }
415
+ export function resolveSavedPortalSourcePath(env, portal) {
416
+ const sourcePath = resolveEnvPortalPath(env.config, portal);
417
+ return sourcePath ? resolveAbsolutePath(sourcePath) : undefined;
418
+ }
419
+ export function resolvePortalDeployPath(params) {
420
+ return path.join(params.storagePath, 'portals', params.app, params.portal);
421
+ }
407
422
  export async function createPortalWorkspace(options) {
408
423
  const portal = validatePortalSlug(options.portal);
409
424
  const title = trimValue(options.title) || titleFromPortalSlug(portal);
@@ -416,16 +431,18 @@ export async function createPortalWorkspace(options) {
416
431
  });
417
432
  const apiBaseUrl = trimValue(options.env.apiBaseUrl);
418
433
  const envApiUrl = resolvePortalEnvApiUrl(apiBaseUrl);
419
- const storagePath = resolvePortalStoragePath(options.env);
420
434
  const { app, appPublicPath, portalBaseApp } = await resolvePortalAppContext(options);
421
435
  const portalBase = buildPortalBasePath({ app: portalBaseApp ?? app, appPublicPath, portal });
422
- const portalParentDir = path.join(storagePath, 'portals', app);
423
- const portalDir = path.join(portalParentDir, portal);
436
+ const portalDir = resolvePortalSourcePath(portal, options.sourcePath);
437
+ const portalParentDir = path.dirname(portalDir);
424
438
  assertPortalDirIsInsideParent(portalParentDir, portalDir);
425
439
  const targetExists = await pathExists(portalDir);
426
440
  if (targetExists && !options.force) {
427
441
  throw new Error(portalCreateText('errors.workspaceExists', { portalDir }, `Portal already exists: ${portalDir}\nPass --force to delete it and create a new portal.`));
428
442
  }
443
+ if (targetExists && options.force && !(await canReplacePortalDirectory(portalDir))) {
444
+ throw new Error(portalCreateText('errors.workspaceNotReplaceable', { portalDir }, `Refusing to replace a non-portal directory: ${portalDir}`));
445
+ }
429
446
  const template = await resolvePortalTemplate(options.template, {
430
447
  npmRegistry: trimValue(options.env.config.npmRegistry),
431
448
  runCommand: options.runCommand,
@@ -438,7 +455,6 @@ export async function createPortalWorkspace(options) {
438
455
  await ensurePortalBuildHtmlReadsEnvOnly(tempDir);
439
456
  await writeFile(path.join(tempDir, '.env'), [`NOCOBASE_API_URL=${envApiUrl}`, `NOCOBASE_PORTAL_BASE=${portalBase}`].join('\n') + '\n', 'utf-8');
440
457
  await writeFile(path.join(tempDir, '.env.local'), [`NOCOBASE_API_URL=${apiBaseUrl}`, `NOCOBASE_PORTAL_BASE=${portalBase}`].join('\n') + '\n', 'utf-8');
441
- await writePortalConfig(tempDir, portalConfig);
442
458
  if (targetExists) {
443
459
  await rm(portalDir, { recursive: true, force: true });
444
460
  }
@@ -13,10 +13,9 @@ import * as tar from 'tar';
13
13
  import { executeApiRequest } from './api-client.js';
14
14
  import { translateCli } from './cli-locale.js';
15
15
  import { ensurePortalBuildHtmlReadsEnvOnly } from './portal-build-html.js';
16
- import { buildPortalBasePath, resolvePortalAppContext, resolvePortalEnvApiUrl, resolvePortalStoragePath, titleFromPortalSlug, validatePortalSlug, } from './portal-create.js';
16
+ import { buildPortalBasePath, resolvePortalAppContext, resolvePortalEnvApiUrl, resolveSavedPortalSourcePath, resolvePortalSourcePath, titleFromPortalSlug, validatePortalSlug, } from './portal-create.js';
17
17
  import { buildPortalCommandEnv } from './portal-command-env.js';
18
18
  import { updatePortalEnvFiles } from './portal-env-files.js';
19
- import { mergePortalConfigIntoOptions, readPortalConfig } from './portal-config.js';
20
19
  import { resolvePnpmInstallCommand, run, runPnpmCommand, runPnpmInstallCommand } from './run-npm.js';
21
20
  const portalDeployText = (key, values, fallback) => translateCli(`commands.portalDeploy.${key}`, values, { fallback });
22
21
  const DEPLOY_OPERATION = {
@@ -107,12 +106,6 @@ async function chmodPortalDistTree(targetDir) {
107
106
  }
108
107
  }));
109
108
  }
110
- async function ensurePortalDistPublicReadable(params) {
111
- await chmod(path.join(params.storagePath, 'portals'), PORTAL_PUBLIC_DIR_MODE);
112
- await chmod(path.join(params.storagePath, 'portals', params.app), PORTAL_PUBLIC_DIR_MODE);
113
- await chmod(params.portalDir, PORTAL_PUBLIC_DIR_MODE);
114
- await chmodPortalDistTree(params.distDir);
115
- }
116
109
  async function assertFileExists(filePath, message) {
117
110
  try {
118
111
  const fileStat = await stat(filePath);
@@ -174,9 +167,6 @@ async function syncMultiPortalRecord(params) {
174
167
  uiLayoutUid: DEFAULT_PORTAL_UI_LAYOUT_UID,
175
168
  skipCreatePortalDirectory: true,
176
169
  };
177
- if (params.config) {
178
- body.options = mergePortalConfigIntoOptions(params.config);
179
- }
180
170
  const response = await apiRequest({
181
171
  cliVersion: params.cliVersion ?? '',
182
172
  envName: params.envName,
@@ -194,17 +184,15 @@ export async function deployPortalWorkspace(options) {
194
184
  const portal = validatePortalSlug(options.portal);
195
185
  const apiBaseUrl = trimValue(options.env.apiBaseUrl);
196
186
  const envApiUrl = resolvePortalEnvApiUrl(apiBaseUrl);
197
- const storagePath = resolvePortalStoragePath(options.env);
198
187
  const { app, appPublicPath, portalBaseApp } = await resolvePortalAppContext(options);
199
188
  const portalBase = buildPortalBasePath({ app: portalBaseApp ?? app, appPublicPath, portal });
200
189
  const deployBase = buildPortalBasePath({ app, appPublicPath, portal });
201
- const portalDir = path.join(storagePath, 'portals', app, portal);
190
+ const portalDir = resolveSavedPortalSourcePath(options.env, portal) ?? resolvePortalSourcePath(portal);
202
191
  const distDir = path.join(portalDir, 'dist');
203
192
  if (!(await pathExists(portalDir))) {
204
193
  throw new Error(portalDeployText('errors.workspaceMissing', { portalDir, portal }, `Portal does not exist: ${portalDir}\nRun \`nb portal create ${portal}\` first.`));
205
194
  }
206
195
  await assertFileExists(path.join(portalDir, 'package.json'), portalDeployText('errors.packageJsonMissing', { portalDir }, `Portal is invalid: package.json is missing in ${portalDir}.`));
207
- const portalConfig = await readPortalConfig(portalDir);
208
196
  await updatePortalEnvFiles({
209
197
  portalDir,
210
198
  apiBaseUrl,
@@ -238,32 +226,8 @@ export async function deployPortalWorkspace(options) {
238
226
  errorName: 'pnpm build:html',
239
227
  });
240
228
  await assertFileExists(path.join(distDir, 'index.html'), portalDeployText('errors.distMissing', { distDir }, `Portal build did not produce ${path.join(distDir, 'index.html')}.`));
241
- await ensurePortalDistPublicReadable({
242
- storagePath,
243
- app,
244
- portalDir,
245
- distDir,
246
- });
247
- if (options.env.kind === 'local' || options.env.kind === 'docker') {
248
- await syncMultiPortalRecord({
249
- portal,
250
- config: portalConfig,
251
- envName: options.envName,
252
- cliVersion: options.cliVersion,
253
- apiRequest: options.apiRequest,
254
- });
255
- return {
256
- app,
257
- portal,
258
- portalDir,
259
- portalBase,
260
- distDir,
261
- mode: options.env.kind,
262
- uploaded: false,
263
- recordSynced: true,
264
- };
265
- }
266
- if (options.env.kind !== 'http') {
229
+ await chmodPortalDistTree(distDir);
230
+ if (options.env.kind !== 'local' && options.env.kind !== 'docker' && options.env.kind !== 'http') {
267
231
  throw new Error(portalDeployText('errors.unsupportedEnvKind', { kind: options.env.kind }, `Cannot deploy a portal for ${options.env.kind} envs in the first version.`));
268
232
  }
269
233
  const archive = await packPortalDist(distDir);
@@ -284,7 +248,6 @@ export async function deployPortalWorkspace(options) {
284
248
  }
285
249
  await syncMultiPortalRecord({
286
250
  portal,
287
- config: portalConfig,
288
251
  envName: options.envName,
289
252
  cliVersion: options.cliVersion,
290
253
  apiRequest: options.apiRequest,
@@ -296,7 +259,7 @@ export async function deployPortalWorkspace(options) {
296
259
  portalBase,
297
260
  distDir,
298
261
  serverDistPath: uploadResult.distPath,
299
- mode: 'http',
262
+ mode: options.env.kind,
300
263
  uploaded: true,
301
264
  recordSynced: true,
302
265
  };
@@ -10,7 +10,8 @@ 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, resolvePortalAppContext, resolvePortalStoragePath, validatePortalSlug, } from './portal-create.js';
13
+ import { isUnsafePortalDeletePath } from './portal-path-safety.js';
14
+ import { buildPortalBasePath, resolvePortalAppContext, resolvePortalDeployPath, resolveSavedPortalSourcePath, resolvePortalStoragePath, validatePortalSlug, } from './portal-create.js';
14
15
  const portalDestroyText = (key, values, fallback) => translateCli(`commands.portalDestroy.${key}`, values, { fallback });
15
16
  const DESTROY_PORTAL_OPERATION = {
16
17
  method: 'POST',
@@ -65,15 +66,27 @@ export async function destroyPortalWorkspace(options) {
65
66
  const storagePath = resolvePortalStoragePath(options.env);
66
67
  const { app, appPublicPath, portalBaseApp } = await resolvePortalAppContext(options);
67
68
  const portalBase = buildPortalBasePath({ app: portalBaseApp ?? app, appPublicPath, portal });
68
- const portalParentDir = path.join(storagePath, 'portals', app);
69
- const portalDir = path.join(portalParentDir, portal);
69
+ const portalDeployDir = resolvePortalDeployPath({ storagePath, app, portal });
70
+ const portalDevDir = resolveSavedPortalSourcePath(options.env, portal) ?? '';
70
71
  const mode = options.env.kind;
71
72
  if (mode !== 'local' && mode !== 'docker' && mode !== 'http') {
72
73
  throw new Error(portalDestroyText('errors.unsupportedEnvKind', { kind: mode }, `Cannot destroy a portal for ${mode} envs in the first version.`));
73
74
  }
74
75
  const destroyMode = mode;
75
- assertPortalDirIsInsideParent(portalParentDir, portalDir);
76
- const workspaceExists = await pathExists(portalDir);
76
+ if (portalDevDir) {
77
+ assertPortalDirIsInsideParent(path.dirname(portalDevDir), portalDevDir);
78
+ }
79
+ assertPortalDirIsInsideParent(path.dirname(portalDeployDir), portalDeployDir);
80
+ const developmentPathIsDeploymentPath = portalDevDir
81
+ ? path.resolve(portalDevDir) === path.resolve(portalDeployDir)
82
+ : false;
83
+ const deploymentPathExists = await pathExists(portalDeployDir);
84
+ const developmentPathExists = options.deleteDevPath && portalDevDir && !developmentPathIsDeploymentPath
85
+ ? await pathExists(portalDevDir)
86
+ : false;
87
+ if (developmentPathExists && (await isUnsafePortalDeletePath(portalDevDir))) {
88
+ throw new Error(portalDestroyText('errors.unsafeDevelopmentPath', { portalDir: portalDevDir }, `Refusing to delete an unsafe portal development path: ${portalDevDir}`));
89
+ }
77
90
  const recordDeleted = await destroyMultiPortalRecord({
78
91
  portal,
79
92
  envName: options.envName,
@@ -81,16 +94,21 @@ export async function destroyPortalWorkspace(options) {
81
94
  force: options.force,
82
95
  apiRequest: options.apiRequest,
83
96
  });
84
- if (workspaceExists) {
85
- await rm(portalDir, { recursive: true, force: true });
97
+ if (deploymentPathExists) {
98
+ await rm(portalDeployDir, { recursive: true, force: true });
99
+ }
100
+ if (developmentPathExists) {
101
+ await rm(portalDevDir, { recursive: true, force: true });
86
102
  }
87
103
  return {
88
104
  app,
89
105
  portal,
90
- portalDir,
106
+ developmentPath: portalDevDir,
107
+ deploymentPath: portalDeployDir,
91
108
  portalBase,
92
109
  mode: destroyMode,
93
110
  recordDeleted,
94
- workspaceDeleted: workspaceExists,
111
+ developmentPathDeleted: developmentPathExists || (developmentPathIsDeploymentPath && deploymentPathExists),
112
+ deploymentPathDeleted: deploymentPathExists,
95
113
  };
96
114
  }
@@ -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, resolvePortalAppContext, resolvePortalStoragePath, validatePortalSlug, } from './portal-create.js';
12
+ import { buildPortalBasePath, resolvePortalAppContext, resolveSavedPortalSourcePath, resolvePortalSourcePath, 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';
@@ -44,10 +44,9 @@ export async function devPortalWorkspace(options) {
44
44
  if (options.env.kind === 'ssh') {
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
- const storagePath = resolvePortalStoragePath(options.env);
48
47
  const { app, appPublicPath, portalBaseApp } = await resolvePortalAppContext(options);
49
48
  const portalBase = buildPortalBasePath({ app: portalBaseApp ?? app, appPublicPath, portal });
50
- const portalDir = path.join(storagePath, 'portals', app, portal);
49
+ const portalDir = resolveSavedPortalSourcePath(options.env, portal) ?? resolvePortalSourcePath(portal);
51
50
  if (!(await pathExists(portalDir))) {
52
51
  throw new Error(portalDevText('errors.workspaceMissing', { portalDir, portal }, `Portal does not exist: ${portalDir}\nRun \`nb portal create ${portal}\` first.`));
53
52
  }
@@ -10,9 +10,6 @@ import { translateCli } from './cli-locale.js';
10
10
  import { toPortalOutputItem } from './portal-list.js';
11
11
  const portalInfoText = (key, values, fallback) => translateCli(`commands.portalInfo.${key}`, values, { fallback });
12
12
  function formatBoolean(value) {
13
- if (value === null) {
14
- return '';
15
- }
16
13
  return value ? 'yes' : 'no';
17
14
  }
18
15
  export function findPortalListItem(items, portal) {
@@ -24,8 +21,8 @@ export function formatPortalInfo(item) {
24
21
  `${portalInfoText('fields.name', undefined, 'Name')}: ${outputItem.name}`,
25
22
  `${portalInfoText('fields.url', undefined, 'URL')}: ${outputItem.url}`,
26
23
  `${portalInfoText('fields.portalType', undefined, 'Portal type')}: ${outputItem.portalType}`,
27
- `${portalInfoText('fields.path', undefined, 'Local path')}: ${outputItem.localPath}`,
24
+ `${portalInfoText('fields.developmentPath', undefined, 'Development path')}: ${outputItem.developmentPath}`,
25
+ `${portalInfoText('fields.deploymentPath', undefined, 'Deployment path')}: ${outputItem.deploymentPath}`,
28
26
  `${portalInfoText('fields.enabled', undefined, 'Enabled')}: ${formatBoolean(outputItem.enabled)}`,
29
- `${portalInfoText('fields.localSynced', undefined, 'Local synced')}: ${formatBoolean(outputItem.localSynced)}`,
30
27
  ].join('\n');
31
28
  }
@@ -6,12 +6,10 @@
6
6
  * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
- import { stat } from 'node:fs/promises';
10
- import path from 'node:path';
11
9
  import { appendAppPublicPath } from './app-public-path.js';
12
10
  import { executeApiRequest } from './api-client.js';
13
11
  import { translateCli } from './cli-locale.js';
14
- import { buildPortalBasePath, resolvePortalAppContext, resolvePortalStoragePath, } from './portal-create.js';
12
+ import { buildPortalBasePath, resolvePortalDeployPath, resolvePortalAppContext, resolveSavedPortalSourcePath, resolvePortalStoragePath, } from './portal-create.js';
15
13
  const portalListText = (key, values, fallback) => translateCli(`commands.portalList.${key}`, values, { fallback });
16
14
  const LIST_PORTALS_OPERATION = {
17
15
  method: 'GET',
@@ -57,15 +55,6 @@ function readListData(data) {
57
55
  }
58
56
  return readListData(directData);
59
57
  }
60
- async function pathExists(target) {
61
- try {
62
- await stat(target);
63
- return true;
64
- }
65
- catch {
66
- return false;
67
- }
68
- }
69
58
  function buildPortalAccessUrl(apiBaseUrl, portalBase) {
70
59
  try {
71
60
  const baseUrl = new URL(apiBaseUrl);
@@ -134,10 +123,11 @@ export function toPortalOutputItem(item) {
134
123
  name: item.portalName,
135
124
  url: item.portalUrl,
136
125
  portalType: item.portalType,
137
- localPath: item.localSynced === true ? item.portalDir : '',
126
+ developmentPath: item.portalDir,
127
+ deploymentPath: item.deployDir,
138
128
  enabled: item.enabled,
129
+ isDefault: item.isDefault,
139
130
  sourceStorage: item.sourceStorage,
140
- localSynced: item.localSynced,
141
131
  };
142
132
  }
143
133
  async function listMultiPortalRecords(params) {
@@ -171,38 +161,41 @@ export async function listPortalWorkspaces(options) {
171
161
  cliVersion: options.cliVersion,
172
162
  apiRequest: options.apiRequest,
173
163
  });
174
- const items = await Promise.all(records.map(async (record) => {
164
+ const items = records.map((record) => {
175
165
  const uid = readRecordString(record, 'uid');
176
166
  const portalName = readRecordString(record, 'portalName') || uid;
177
167
  const routePath = readRecordString(record, 'routePath') || `/${portalName}`;
178
168
  const portalType = readRecordString(record, 'portalType');
179
169
  const enabled = readRecordBoolean(record, 'enabled');
180
- const options = readRecordObject(record, 'options');
181
- const git = readRecordObject(options, 'git');
182
- const sourceStorage = trimValue(options.sourceStorage) || readRecordString(record, 'sourceStorage') || 'nocobase';
170
+ const isDefault = readRecordBoolean(record, 'isDefault');
171
+ const recordOptions = readRecordObject(record, 'options');
172
+ const git = readRecordObject(recordOptions, 'git');
173
+ const sourceStorage = trimValue(recordOptions.sourceStorage) || readRecordString(record, 'sourceStorage') || 'nocobase';
183
174
  const isAi = portalType === 'ai';
184
- const portalDir = isAi ? path.join(storagePath, 'portals', app, portalName) : '';
175
+ const portalDir = isAi ? resolveSavedPortalSourcePath(options.env, portalName) ?? '' : '';
176
+ const deployDir = isAi ? resolvePortalDeployPath({ storagePath, app, portal: portalName }) : '';
185
177
  return {
186
178
  uid,
187
179
  portalName,
188
180
  routePath,
189
181
  portalType,
190
182
  enabled,
183
+ isDefault,
191
184
  sourceStorage,
192
185
  gitRepo: trimValue(git.repo) || readRecordString(record, 'gitRepo'),
193
186
  gitBranch: trimValue(git.branch) || readRecordString(record, 'gitBranch'),
194
187
  gitPath: trimValue(git.path) || readRecordString(record, 'gitPath'),
195
- sourceRevision: trimValue(options.sourceRevision) || readRecordString(record, 'sourceRevision'),
196
- options,
188
+ sourceRevision: trimValue(recordOptions.sourceRevision) || readRecordString(record, 'sourceRevision'),
189
+ options: recordOptions,
197
190
  portalUrl: enabled
198
191
  ? buildPortalAccessUrl(apiBaseUrl, isAi
199
192
  ? buildPortalBasePath({ app: baseApp, appPublicPath, portal: portalName })
200
193
  : buildNoCodePortalBasePath({ app: baseApp, appPublicPath, routePath }))
201
194
  : '',
202
195
  portalDir,
203
- localSynced: isAi ? await pathExists(portalDir) : null,
196
+ deployDir,
204
197
  };
205
- }));
198
+ });
206
199
  return {
207
200
  app,
208
201
  mode: listMode,
@@ -0,0 +1,76 @@
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 { readdir, readFile, realpath, stat } from 'node:fs/promises';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ async function resolveExistingPath(target) {
13
+ const resolved = path.resolve(target);
14
+ try {
15
+ return await realpath(resolved);
16
+ }
17
+ catch {
18
+ return resolved;
19
+ }
20
+ }
21
+ function isSameOrAncestor(candidate, child) {
22
+ const relative = path.relative(candidate, child);
23
+ return !relative || (!relative.startsWith('..') && !path.isAbsolute(relative));
24
+ }
25
+ export async function isUnsafePortalDeletePath(target) {
26
+ const resolvedTarget = await resolveExistingPath(target);
27
+ const root = path.parse(resolvedTarget).root;
28
+ if (resolvedTarget === root) {
29
+ return true;
30
+ }
31
+ const homeDir = await resolveExistingPath(os.homedir());
32
+ if (resolvedTarget === homeDir) {
33
+ return true;
34
+ }
35
+ const cwd = await resolveExistingPath(process.cwd());
36
+ return isSameOrAncestor(resolvedTarget, cwd);
37
+ }
38
+ async function isDirectory(target) {
39
+ try {
40
+ return (await stat(target)).isDirectory();
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
46
+ async function isEmptyDirectory(target) {
47
+ try {
48
+ return (await readdir(target)).length === 0;
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
54
+ async function hasNocoBasePackageField(target) {
55
+ try {
56
+ const data = JSON.parse(await readFile(path.join(target, 'package.json'), 'utf-8'));
57
+ return (!!data &&
58
+ typeof data === 'object' &&
59
+ !Array.isArray(data) &&
60
+ Object.prototype.hasOwnProperty.call(data, 'nocobase'));
61
+ }
62
+ catch {
63
+ return false;
64
+ }
65
+ }
66
+ export async function canReplacePortalDirectory(target) {
67
+ const resolvedTarget = await resolveExistingPath(target);
68
+ const root = path.parse(resolvedTarget).root;
69
+ if (resolvedTarget === root || resolvedTarget === (await resolveExistingPath(os.homedir()))) {
70
+ return false;
71
+ }
72
+ if (!(await isDirectory(resolvedTarget))) {
73
+ return false;
74
+ }
75
+ return (await isEmptyDirectory(resolvedTarget)) || (await hasNocoBasePackageField(resolvedTarget));
76
+ }