@nocobase/cli 3.0.0-alpha.4 → 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,7 +6,6 @@
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 { readFile, writeFile } from 'node:fs/promises';
10
9
  import path from 'node:path';
11
10
  import { executeApiRequest } from './api-client.js';
12
11
  import { translateCli } from './cli-locale.js';
@@ -19,9 +18,10 @@ const UPDATE_PORTAL_OPERATION = {
19
18
  bodyRequired: true,
20
19
  parameters: [
21
20
  {
22
- name: 'filterByTk',
23
- flagName: 'filterByTk',
21
+ name: 'filter',
22
+ flagName: 'filter',
24
23
  in: 'query',
24
+ type: 'object',
25
25
  required: true,
26
26
  },
27
27
  ],
@@ -98,29 +98,15 @@ export function mergePortalConfigIntoOptions(config, currentOptions) {
98
98
  }
99
99
  return nextOptions;
100
100
  }
101
- export async function readPortalConfig(portalDir) {
102
- const configPath = path.join(portalDir, 'portal.config.json');
103
- const data = JSON.parse(await readFile(configPath, 'utf-8'));
104
- const config = readObject(data);
105
- const git = readObject(config.git);
106
- return buildPortalConfig({
107
- portal: path.basename(portalDir),
108
- sourceStorage: trimValue(config.sourceStorage),
109
- gitRepo: trimValue(git.repo),
110
- gitBranch: trimValue(git.branch),
111
- gitPath: trimValue(git.path),
112
- });
113
- }
114
- export async function writePortalConfig(portalDir, config) {
115
- await writeFile(path.join(portalDir, 'portal.config.json'), `${JSON.stringify(config, null, 2)}\n`, 'utf-8');
116
- }
117
101
  export async function syncPortalConfigToRemote(options) {
118
102
  const apiRequest = options.apiRequest ?? executeApiRequest;
119
103
  const response = await apiRequest({
120
104
  cliVersion: options.cliVersion ?? '',
121
105
  envName: options.envName,
122
106
  flags: {
123
- filterByTk: options.portal,
107
+ filter: {
108
+ portalName: options.portal,
109
+ },
124
110
  body: JSON.stringify({
125
111
  options: mergePortalConfigIntoOptions(options.config, options.currentOptions),
126
112
  }),
@@ -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 { resolvePortalAppFromApiBaseUrl, 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,34 +40,49 @@ 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 apiBaseUrl = trimValue(options.env.apiBaseUrl);
68
- const storagePath = resolvePortalStoragePath(options.env);
69
- const { app } = resolvePortalAppFromApiBaseUrl(apiBaseUrl, options.env.config.appPublicPath);
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
- }
49
+ const portalDir = hasPathChange
50
+ ? resolvePortalSourcePath(portal, options.sourcePath)
51
+ : resolveSavedPortalSourcePath(options.env, portal) ?? '';
52
+ const appContext = await resolvePortalAppContext(options);
53
+ const { app } = appContext;
54
+ let config;
55
+ let remoteSynced = false;
74
56
  const list = await listPortalWorkspaces({
75
57
  env: options.env,
76
58
  envName: options.envName,
77
59
  cliVersion: options.cliVersion,
78
60
  apiRequest: options.apiRequest,
61
+ appContext,
79
62
  });
80
63
  const remoteItem = findPortalListItem(list.items, portal);
81
- const existingConfig = (await readExistingConfig(portalDir)) ??
82
- 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,
83
70
  portal,
84
- options: remoteItem?.options,
85
- sourceStorage: remoteItem?.sourceStorage,
86
- gitRepo: remoteItem?.gitRepo,
87
- gitBranch: remoteItem?.gitBranch,
88
- gitPath: remoteItem?.gitPath,
89
- });
90
- 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({
91
86
  portal,
92
87
  sourceStorage: options.sourceStorage,
93
88
  gitRepo: options.gitRepo,
@@ -95,23 +90,21 @@ export async function configurePortalWorkspace(options) {
95
90
  gitPath: options.gitPath,
96
91
  existingConfig,
97
92
  });
98
- await mkdir(portalDir, { recursive: true });
99
- await writePortalConfig(portalDir, config);
100
- if (remoteItem) {
101
- await syncPortalConfigToRemote({
102
- portal,
103
- config,
104
- currentOptions: remoteItem.options,
105
- envName: options.envName,
106
- cliVersion: options.cliVersion,
107
- apiRequest: options.apiRequest,
108
- });
109
- }
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;
110
102
  return {
111
103
  app,
112
104
  portal,
113
105
  portalDir,
114
106
  config,
115
- remoteSynced: Boolean(remoteItem),
107
+ remoteSynced,
108
+ pathUpdated: hasPathChange,
116
109
  };
117
110
  }
@@ -17,11 +17,14 @@ 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';
23
+ import { ensurePortalBuildHtmlReadsEnvOnly } from './portal-build-html.js';
22
24
  import { buildPortalCommandEnv } from './portal-command-env.js';
23
- import { buildPortalConfig, mergePortalConfigIntoOptions, writePortalConfig, } from './portal-config.js';
24
- import { run, runPnpmCommand } from './run-npm.js';
25
+ import { canReplacePortalDirectory } from './portal-path-safety.js';
26
+ import { buildPortalConfig, mergePortalConfigIntoOptions, } from './portal-config.js';
27
+ import { resolvePnpmInstallCommand, run, runPnpmInstallCommand } from './run-npm.js';
25
28
  const DEFAULT_PORTAL_TEMPLATE = '@nocobase/portal-template-default';
26
29
  const DEFAULT_PORTAL_APP_NAME = 'main';
27
30
  const TEMPLATE_COPY_EXCLUDED_NAMES = new Set(['.git', 'node_modules', '.DS_Store']);
@@ -42,6 +45,11 @@ const FIRST_OR_CREATE_PORTAL_OPERATION = {
42
45
  },
43
46
  ],
44
47
  };
48
+ const APP_INFO_OPERATION = {
49
+ method: 'GET',
50
+ pathTemplate: '/app:getInfo',
51
+ parameters: [],
52
+ };
45
53
  function trimValue(value) {
46
54
  return String(value ?? '').trim();
47
55
  }
@@ -319,6 +327,54 @@ export function resolvePortalAppFromApiBaseUrl(apiBaseUrl, appPublicPath) {
319
327
  appPublicPath: resolveAppPublicPath(configuredPublicPath || inferredPublicPath),
320
328
  };
321
329
  }
330
+ function readAppNameFromInfo(data) {
331
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
332
+ return undefined;
333
+ }
334
+ const direct = data.name;
335
+ if (typeof direct === 'string' && direct.trim()) {
336
+ return direct.trim();
337
+ }
338
+ return readAppNameFromInfo(data.data);
339
+ }
340
+ function isValidPortalAppName(value) {
341
+ return /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(value);
342
+ }
343
+ async function resolvePortalAppFromServer(options) {
344
+ const apiRequest = options.apiRequest ?? executeApiRequest;
345
+ try {
346
+ const response = await apiRequest({
347
+ cliVersion: options.cliVersion ?? '',
348
+ envName: options.envName,
349
+ flags: {},
350
+ operation: APP_INFO_OPERATION,
351
+ });
352
+ if (!response.ok) {
353
+ return undefined;
354
+ }
355
+ const appName = readAppNameFromInfo(response.data);
356
+ return appName && isValidPortalAppName(appName) ? appName : undefined;
357
+ }
358
+ catch {
359
+ return undefined;
360
+ }
361
+ }
362
+ export async function resolvePortalAppContext(options) {
363
+ const apiBaseUrl = trimValue(options.env.apiBaseUrl);
364
+ const resolvedApp = resolvePortalAppFromApiBaseUrl(apiBaseUrl, options.env.config.appPublicPath);
365
+ if (options.env.kind !== 'http') {
366
+ return {
367
+ ...resolvedApp,
368
+ portalBaseApp: resolvedApp.app,
369
+ };
370
+ }
371
+ const serverApp = await resolvePortalAppFromServer(options);
372
+ return {
373
+ app: serverApp ?? resolvedApp.app,
374
+ appPublicPath: resolvedApp.appPublicPath,
375
+ portalBaseApp: resolvedApp.app,
376
+ };
377
+ }
322
378
  export function buildPortalBasePath(params) {
323
379
  const segment = params.app === DEFAULT_PORTAL_APP_NAME
324
380
  ? `x/${params.portal}`
@@ -350,6 +406,19 @@ export function resolvePortalStoragePath(env) {
350
406
  }
351
407
  return path.resolve(process.cwd(), 'storage');
352
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
+ }
353
422
  export async function createPortalWorkspace(options) {
354
423
  const portal = validatePortalSlug(options.portal);
355
424
  const title = trimValue(options.title) || titleFromPortalSlug(portal);
@@ -362,16 +431,18 @@ export async function createPortalWorkspace(options) {
362
431
  });
363
432
  const apiBaseUrl = trimValue(options.env.apiBaseUrl);
364
433
  const envApiUrl = resolvePortalEnvApiUrl(apiBaseUrl);
365
- const storagePath = resolvePortalStoragePath(options.env);
366
- const { app, appPublicPath } = resolvePortalAppFromApiBaseUrl(apiBaseUrl, options.env.config.appPublicPath);
367
- const portalBase = buildPortalBasePath({ app, appPublicPath, portal });
368
- const portalParentDir = path.join(storagePath, 'portals', app);
369
- const portalDir = path.join(portalParentDir, portal);
434
+ const { app, appPublicPath, portalBaseApp } = await resolvePortalAppContext(options);
435
+ const portalBase = buildPortalBasePath({ app: portalBaseApp ?? app, appPublicPath, portal });
436
+ const portalDir = resolvePortalSourcePath(portal, options.sourcePath);
437
+ const portalParentDir = path.dirname(portalDir);
370
438
  assertPortalDirIsInsideParent(portalParentDir, portalDir);
371
439
  const targetExists = await pathExists(portalDir);
372
440
  if (targetExists && !options.force) {
373
441
  throw new Error(portalCreateText('errors.workspaceExists', { portalDir }, `Portal already exists: ${portalDir}\nPass --force to delete it and create a new portal.`));
374
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
+ }
375
446
  const template = await resolvePortalTemplate(options.template, {
376
447
  npmRegistry: trimValue(options.env.config.npmRegistry),
377
448
  runCommand: options.runCommand,
@@ -381,23 +452,32 @@ export async function createPortalWorkspace(options) {
381
452
  let shouldCleanupTempDir = true;
382
453
  try {
383
454
  await copyTemplate(template.dir, tempDir);
455
+ await ensurePortalBuildHtmlReadsEnvOnly(tempDir);
384
456
  await writeFile(path.join(tempDir, '.env'), [`NOCOBASE_API_URL=${envApiUrl}`, `NOCOBASE_PORTAL_BASE=${portalBase}`].join('\n') + '\n', 'utf-8');
385
457
  await writeFile(path.join(tempDir, '.env.local'), [`NOCOBASE_API_URL=${apiBaseUrl}`, `NOCOBASE_PORTAL_BASE=${portalBase}`].join('\n') + '\n', 'utf-8');
386
- await writePortalConfig(tempDir, portalConfig);
387
458
  if (targetExists) {
388
459
  await rm(portalDir, { recursive: true, force: true });
389
460
  }
390
461
  await rename(tempDir, portalDir);
391
462
  shouldCleanupTempDir = false;
392
463
  const hasPackageJson = await pathExists(path.join(portalDir, 'package.json'));
464
+ let dependenciesInstalled = false;
465
+ let installFailed = false;
393
466
  if (hasPackageJson) {
394
467
  const runCommand = options.runCommand ?? run;
395
- await runPnpmCommand(runCommand, ['install'], {
396
- cwd: portalDir,
397
- env: buildPortalCommandEnv(),
398
- envMode: 'replace',
399
- errorName: 'pnpm install',
400
- });
468
+ const installCommand = await resolvePnpmInstallCommand(portalDir);
469
+ try {
470
+ await runPnpmInstallCommand(runCommand, installCommand.args, {
471
+ cwd: portalDir,
472
+ env: buildPortalCommandEnv(),
473
+ envMode: 'replace',
474
+ errorName: installCommand.errorName,
475
+ });
476
+ dependenciesInstalled = true;
477
+ }
478
+ catch {
479
+ installFailed = true;
480
+ }
401
481
  }
402
482
  else {
403
483
  options.onSkipInstall?.(portalCreateText('messages.skipInstall', { portalDir }, `Skipped pnpm install because package.json was not found in ${portalDir}.`));
@@ -421,6 +501,8 @@ export async function createPortalWorkspace(options) {
421
501
  portalBase,
422
502
  template,
423
503
  installSkipped: !hasPackageJson,
504
+ dependenciesInstalled,
505
+ installFailed,
424
506
  sourceStorage: portalConfig.sourceStorage,
425
507
  };
426
508
  }
@@ -12,11 +12,11 @@ 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, resolveSavedPortalSourcePath, resolvePortalSourcePath, 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
- import { mergePortalConfigIntoOptions, readPortalConfig } from './portal-config.js';
19
- import { run, runPnpmCommand } from './run-npm.js';
19
+ import { resolvePnpmInstallCommand, run, runPnpmCommand, runPnpmInstallCommand } from './run-npm.js';
20
20
  const portalDeployText = (key, values, fallback) => translateCli(`commands.portalDeploy.${key}`, values, { fallback });
21
21
  const DEPLOY_OPERATION = {
22
22
  method: 'POST',
@@ -106,12 +106,6 @@ async function chmodPortalDistTree(targetDir) {
106
106
  }
107
107
  }));
108
108
  }
109
- async function ensurePortalDistPublicReadable(params) {
110
- await chmod(path.join(params.storagePath, 'portals'), PORTAL_PUBLIC_DIR_MODE);
111
- await chmod(path.join(params.storagePath, 'portals', params.app), PORTAL_PUBLIC_DIR_MODE);
112
- await chmod(params.portalDir, PORTAL_PUBLIC_DIR_MODE);
113
- await chmodPortalDistTree(params.distDir);
114
- }
115
109
  async function assertFileExists(filePath, message) {
116
110
  try {
117
111
  const fileStat = await stat(filePath);
@@ -173,9 +167,6 @@ async function syncMultiPortalRecord(params) {
173
167
  uiLayoutUid: DEFAULT_PORTAL_UI_LAYOUT_UID,
174
168
  skipCreatePortalDirectory: true,
175
169
  };
176
- if (params.config) {
177
- body.options = mergePortalConfigIntoOptions(params.config);
178
- }
179
170
  const response = await apiRequest({
180
171
  cliVersion: params.cliVersion ?? '',
181
172
  envName: params.envName,
@@ -192,27 +183,29 @@ async function syncMultiPortalRecord(params) {
192
183
  export async function deployPortalWorkspace(options) {
193
184
  const portal = validatePortalSlug(options.portal);
194
185
  const apiBaseUrl = trimValue(options.env.apiBaseUrl);
195
- const storagePath = resolvePortalStoragePath(options.env);
196
- const { app, appPublicPath } = resolvePortalAppFromApiBaseUrl(apiBaseUrl, options.env.config.appPublicPath);
197
- const portalBase = buildPortalBasePath({ app, appPublicPath, portal });
198
- const portalDir = path.join(storagePath, 'portals', app, portal);
186
+ const envApiUrl = resolvePortalEnvApiUrl(apiBaseUrl);
187
+ const { app, appPublicPath, portalBaseApp } = await resolvePortalAppContext(options);
188
+ const portalBase = buildPortalBasePath({ app: portalBaseApp ?? app, appPublicPath, portal });
189
+ const deployBase = buildPortalBasePath({ app, appPublicPath, portal });
190
+ const portalDir = resolveSavedPortalSourcePath(options.env, portal) ?? resolvePortalSourcePath(portal);
199
191
  const distDir = path.join(portalDir, 'dist');
200
192
  if (!(await pathExists(portalDir))) {
201
193
  throw new Error(portalDeployText('errors.workspaceMissing', { portalDir, portal }, `Portal does not exist: ${portalDir}\nRun \`nb portal create ${portal}\` first.`));
202
194
  }
203
195
  await assertFileExists(path.join(portalDir, 'package.json'), portalDeployText('errors.packageJsonMissing', { portalDir }, `Portal is invalid: package.json is missing in ${portalDir}.`));
204
- const portalConfig = await readPortalConfig(portalDir);
205
196
  await updatePortalEnvFiles({
206
197
  portalDir,
207
198
  apiBaseUrl,
208
199
  portalBase,
209
200
  });
201
+ await ensurePortalBuildHtmlReadsEnvOnly(portalDir);
210
202
  const runCommand = options.runCommand ?? run;
211
- await runPnpmCommand(runCommand, ['install'], {
203
+ const installCommand = await resolvePnpmInstallCommand(portalDir);
204
+ await runPnpmInstallCommand(runCommand, installCommand.args, {
212
205
  cwd: portalDir,
213
206
  env: buildPortalCommandEnv(),
214
207
  envMode: 'replace',
215
- errorName: 'pnpm install',
208
+ errorName: installCommand.errorName,
216
209
  });
217
210
  await runPnpmCommand(runCommand, ['build'], {
218
211
  cwd: portalDir,
@@ -226,39 +219,15 @@ export async function deployPortalWorkspace(options) {
226
219
  await runPnpmCommand(runCommand, ['build:html'], {
227
220
  cwd: portalDir,
228
221
  env: buildPortalCommandEnv({
229
- NOCOBASE_API_URL: apiBaseUrl,
222
+ NOCOBASE_API_URL: envApiUrl,
230
223
  NOCOBASE_PORTAL_BASE: portalBase,
231
224
  }),
232
225
  envMode: 'replace',
233
226
  errorName: 'pnpm build:html',
234
227
  });
235
228
  await assertFileExists(path.join(distDir, 'index.html'), portalDeployText('errors.distMissing', { distDir }, `Portal build did not produce ${path.join(distDir, 'index.html')}.`));
236
- await ensurePortalDistPublicReadable({
237
- storagePath,
238
- app,
239
- portalDir,
240
- distDir,
241
- });
242
- if (options.env.kind === 'local' || options.env.kind === 'docker') {
243
- await syncMultiPortalRecord({
244
- portal,
245
- config: portalConfig,
246
- envName: options.envName,
247
- cliVersion: options.cliVersion,
248
- apiRequest: options.apiRequest,
249
- });
250
- return {
251
- app,
252
- portal,
253
- portalDir,
254
- portalBase,
255
- distDir,
256
- mode: options.env.kind,
257
- uploaded: false,
258
- recordSynced: true,
259
- };
260
- }
261
- if (options.env.kind !== 'http') {
229
+ await chmodPortalDistTree(distDir);
230
+ if (options.env.kind !== 'local' && options.env.kind !== 'docker' && options.env.kind !== 'http') {
262
231
  throw new Error(portalDeployText('errors.unsupportedEnvKind', { kind: options.env.kind }, `Cannot deploy a portal for ${options.env.kind} envs in the first version.`));
263
232
  }
264
233
  const archive = await packPortalDist(distDir);
@@ -268,7 +237,7 @@ export async function deployPortalWorkspace(options) {
268
237
  archivePath: archive.archivePath,
269
238
  app,
270
239
  portal,
271
- portalBase,
240
+ portalBase: deployBase,
272
241
  envName: options.envName,
273
242
  cliVersion: options.cliVersion,
274
243
  apiRequest: options.apiRequest,
@@ -279,7 +248,6 @@ export async function deployPortalWorkspace(options) {
279
248
  }
280
249
  await syncMultiPortalRecord({
281
250
  portal,
282
- config: portalConfig,
283
251
  envName: options.envName,
284
252
  cliVersion: options.cliVersion,
285
253
  apiRequest: options.apiRequest,
@@ -291,7 +259,7 @@ export async function deployPortalWorkspace(options) {
291
259
  portalBase,
292
260
  distDir,
293
261
  serverDistPath: uploadResult.distPath,
294
- mode: 'http',
262
+ mode: options.env.kind,
295
263
  uploaded: true,
296
264
  recordSynced: true,
297
265
  };
@@ -10,23 +10,22 @@ 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 { 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',
17
18
  pathTemplate: '/multiPortals:destroy',
18
19
  parameters: [
19
20
  {
20
- name: 'filterByTk',
21
- flagName: 'filterByTk',
21
+ name: 'filter',
22
+ flagName: 'filter',
22
23
  in: 'query',
24
+ type: 'object',
23
25
  required: true,
24
26
  },
25
27
  ],
26
28
  };
27
- function trimValue(value) {
28
- return String(value ?? '').trim();
29
- }
30
29
  async function pathExists(target) {
31
30
  try {
32
31
  await stat(target);
@@ -48,7 +47,9 @@ async function destroyMultiPortalRecord(params) {
48
47
  cliVersion: params.cliVersion ?? '',
49
48
  envName: params.envName,
50
49
  flags: {
51
- filterByTk: params.portal,
50
+ filter: {
51
+ portalName: params.portal,
52
+ },
52
53
  },
53
54
  operation: DESTROY_PORTAL_OPERATION,
54
55
  });
@@ -62,21 +63,29 @@ async function destroyMultiPortalRecord(params) {
62
63
  }
63
64
  export async function destroyPortalWorkspace(options) {
64
65
  const portal = validatePortalSlug(options.portal);
65
- const apiBaseUrl = trimValue(options.env.apiBaseUrl);
66
66
  const storagePath = resolvePortalStoragePath(options.env);
67
- const { app, appPublicPath } = resolvePortalAppFromApiBaseUrl(apiBaseUrl, options.env.config.appPublicPath);
68
- const portalBase = buildPortalBasePath({ app, appPublicPath, portal });
69
- const portalParentDir = path.join(storagePath, 'portals', app);
70
- const portalDir = path.join(portalParentDir, portal);
67
+ const { app, appPublicPath, portalBaseApp } = await resolvePortalAppContext(options);
68
+ const portalBase = buildPortalBasePath({ app: portalBaseApp ?? app, appPublicPath, portal });
69
+ const portalDeployDir = resolvePortalDeployPath({ storagePath, app, portal });
70
+ const portalDevDir = resolveSavedPortalSourcePath(options.env, portal) ?? '';
71
71
  const mode = options.env.kind;
72
72
  if (mode !== 'local' && mode !== 'docker' && mode !== 'http') {
73
73
  throw new Error(portalDestroyText('errors.unsupportedEnvKind', { kind: mode }, `Cannot destroy a portal for ${mode} envs in the first version.`));
74
74
  }
75
75
  const destroyMode = mode;
76
- assertPortalDirIsInsideParent(portalParentDir, portalDir);
77
- 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.`));
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}`));
80
89
  }
81
90
  const recordDeleted = await destroyMultiPortalRecord({
82
91
  portal,
@@ -85,16 +94,21 @@ export async function destroyPortalWorkspace(options) {
85
94
  force: options.force,
86
95
  apiRequest: options.apiRequest,
87
96
  });
88
- if (workspaceExists) {
89
- 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 });
90
102
  }
91
103
  return {
92
104
  app,
93
105
  portal,
94
- portalDir,
106
+ developmentPath: portalDevDir,
107
+ deploymentPath: portalDeployDir,
95
108
  portalBase,
96
109
  mode: destroyMode,
97
110
  recordDeleted,
98
- workspaceDeleted: workspaceExists,
111
+ developmentPathDeleted: developmentPathExists || (developmentPathIsDeploymentPath && deploymentPathExists),
112
+ deploymentPathDeleted: deploymentPathExists,
99
113
  };
100
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, resolvePortalAppFromApiBaseUrl, 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
- const { app, appPublicPath } = resolvePortalAppFromApiBaseUrl(apiBaseUrl, options.env.config.appPublicPath);
49
- const portalBase = buildPortalBasePath({ app, appPublicPath, portal });
50
- const portalDir = path.join(storagePath, 'portals', app, portal);
47
+ const { app, appPublicPath, portalBaseApp } = await resolvePortalAppContext(options);
48
+ const portalBase = buildPortalBasePath({ app: portalBaseApp ?? app, appPublicPath, 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
  }
@@ -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'), {