@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.
@@ -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, resolvePortalAppFromApiBaseUrl, 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) {
@@ -159,8 +149,9 @@ async function listMultiPortalRecords(params) {
159
149
  export async function listPortalWorkspaces(options) {
160
150
  const apiBaseUrl = trimValue(options.env.apiBaseUrl);
161
151
  const storagePath = resolvePortalStoragePath(options.env);
162
- const { app, appPublicPath } = resolvePortalAppFromApiBaseUrl(apiBaseUrl, options.env.config.appPublicPath);
152
+ const { app, appPublicPath, portalBaseApp } = options.appContext ?? (await resolvePortalAppContext(options));
163
153
  const mode = options.env.kind;
154
+ const baseApp = portalBaseApp ?? app;
164
155
  if (mode !== 'local' && mode !== 'docker' && mode !== 'http') {
165
156
  throw new Error(portalListText('errors.unsupportedEnvKind', { kind: mode }, `Cannot list portals for ${mode} envs in the first version.`));
166
157
  }
@@ -170,38 +161,41 @@ export async function listPortalWorkspaces(options) {
170
161
  cliVersion: options.cliVersion,
171
162
  apiRequest: options.apiRequest,
172
163
  });
173
- const items = await Promise.all(records.map(async (record) => {
164
+ const items = records.map((record) => {
174
165
  const uid = readRecordString(record, 'uid');
175
166
  const portalName = readRecordString(record, 'portalName') || uid;
176
167
  const routePath = readRecordString(record, 'routePath') || `/${portalName}`;
177
168
  const portalType = readRecordString(record, 'portalType');
178
169
  const enabled = readRecordBoolean(record, 'enabled');
179
- const options = readRecordObject(record, 'options');
180
- const git = readRecordObject(options, 'git');
181
- 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';
182
174
  const isAi = portalType === 'ai';
183
- 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 }) : '';
184
177
  return {
185
178
  uid,
186
179
  portalName,
187
180
  routePath,
188
181
  portalType,
189
182
  enabled,
183
+ isDefault,
190
184
  sourceStorage,
191
185
  gitRepo: trimValue(git.repo) || readRecordString(record, 'gitRepo'),
192
186
  gitBranch: trimValue(git.branch) || readRecordString(record, 'gitBranch'),
193
187
  gitPath: trimValue(git.path) || readRecordString(record, 'gitPath'),
194
- sourceRevision: trimValue(options.sourceRevision) || readRecordString(record, 'sourceRevision'),
195
- options,
188
+ sourceRevision: trimValue(recordOptions.sourceRevision) || readRecordString(record, 'sourceRevision'),
189
+ options: recordOptions,
196
190
  portalUrl: enabled
197
191
  ? buildPortalAccessUrl(apiBaseUrl, isAi
198
- ? buildPortalBasePath({ app, appPublicPath, portal: portalName })
199
- : buildNoCodePortalBasePath({ app, appPublicPath, routePath }))
192
+ ? buildPortalBasePath({ app: baseApp, appPublicPath, portal: portalName })
193
+ : buildNoCodePortalBasePath({ app: baseApp, appPublicPath, routePath }))
200
194
  : '',
201
195
  portalDir,
202
- localSynced: isAi ? await pathExists(portalDir) : null,
196
+ deployDir,
203
197
  };
204
- }));
198
+ });
205
199
  return {
206
200
  app,
207
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
+ }
@@ -14,12 +14,15 @@ 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 { buildPortalConfig, buildPortalConfigFromOptions, DEFAULT_PORTAL_GIT_PATH, readPortalConfig, syncPortalConfigToRemote, writePortalConfig, } from './portal-config.js';
17
+ import { ensurePortalBuildHtmlReadsEnvOnly } from './portal-build-html.js';
18
+ import { buildPortalConfig, buildPortalConfigFromOptions, DEFAULT_PORTAL_GIT_PATH, } 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 { canReplacePortalDirectory } from './portal-path-safety.js';
21
+ import { updatePortalEnvFiles } from './portal-env-files.js';
22
+ import { buildPortalBasePath, resolvePortalAppContext, resolvePortalDeployPath, resolvePortalSourcePath, resolveSavedPortalSourcePath, resolvePortalStoragePath, validatePortalSlug, } from './portal-create.js';
20
23
  import { listPortalWorkspaces } from './portal-list.js';
21
24
  import { findPortalListItem } from './portal-info.js';
22
- import { run, runPnpmCommand } from './run-npm.js';
25
+ import { resolvePnpmInstallCommand, run, runPnpmInstallCommand } from './run-npm.js';
23
26
  const execFileAsync = promisify(execFile);
24
27
  const portalSourceText = (key, values, fallback) => translateCli(`commands.portalSource.${key}`, values, { fallback });
25
28
  const PULL_SOURCE_OPERATION = {
@@ -128,11 +131,25 @@ async function packPortalSource(portalDir) {
128
131
  },
129
132
  };
130
133
  }
131
- async function replacePortalSourceFromArchive(params) {
132
- const targetExists = await pathExists(params.portalDir);
133
- if (targetExists && !params.force) {
134
- throw new Error(portalSourceText('errors.workspaceExists', { portalDir: params.portalDir }, `Portal already exists: ${params.portalDir}\nPass --force to delete it and pull again.`));
134
+ async function replaceExistingPortalDirectory(params) {
135
+ if (!(await pathExists(path.join(params.portalDir, '.git')))) {
136
+ await rm(params.portalDir, { recursive: true, force: true });
137
+ await rename(params.sourceDir, params.portalDir);
138
+ return;
135
139
  }
140
+ const existingEntries = await readdir(params.portalDir);
141
+ await Promise.all(existingEntries
142
+ .filter((entry) => entry !== '.git')
143
+ .map((entry) => rm(path.join(params.portalDir, entry), { recursive: true, force: true })));
144
+ const sourceEntries = await readdir(params.sourceDir);
145
+ await Promise.all(sourceEntries.map((entry) => rename(path.join(params.sourceDir, entry), path.join(params.portalDir, entry))));
146
+ await rm(params.sourceDir, { recursive: true, force: true });
147
+ }
148
+ async function replacePortalSourceFromArchive(params) {
149
+ const targetExists = await assertPortalDirectoryCanBeReplaced({
150
+ portalDir: params.portalDir,
151
+ force: params.force,
152
+ });
136
153
  const parentDir = path.dirname(params.portalDir);
137
154
  const tempDir = await mkdtemp(path.join(parentDir, `.${path.basename(params.portalDir)}-pull-`));
138
155
  try {
@@ -143,7 +160,11 @@ async function replacePortalSourceFromArchive(params) {
143
160
  filter: validatePortalSourceTarEntry,
144
161
  });
145
162
  if (targetExists) {
146
- await rm(params.portalDir, { recursive: true, force: true });
163
+ await replaceExistingPortalDirectory({
164
+ sourceDir: tempDir,
165
+ portalDir: params.portalDir,
166
+ });
167
+ return;
147
168
  }
148
169
  await rename(tempDir, params.portalDir);
149
170
  }
@@ -153,10 +174,10 @@ async function replacePortalSourceFromArchive(params) {
153
174
  }
154
175
  }
155
176
  async function replacePortalSourceFromDirectory(params) {
156
- const targetExists = await pathExists(params.portalDir);
157
- if (targetExists && !params.force) {
158
- throw new Error(portalSourceText('errors.workspaceExists', { portalDir: params.portalDir }, `Portal already exists: ${params.portalDir}\nPass --force to delete it and pull again.`));
159
- }
177
+ const targetExists = await assertPortalDirectoryCanBeReplaced({
178
+ portalDir: params.portalDir,
179
+ force: params.force,
180
+ });
160
181
  const parentDir = path.dirname(params.portalDir);
161
182
  const tempDir = await mkdtemp(path.join(parentDir, `.${path.basename(params.portalDir)}-pull-`));
162
183
  try {
@@ -165,7 +186,11 @@ async function replacePortalSourceFromDirectory(params) {
165
186
  filter: (source) => shouldPackPortalSourceEntry(path.relative(params.sourceDir, source)),
166
187
  });
167
188
  if (targetExists) {
168
- await rm(params.portalDir, { recursive: true, force: true });
189
+ await replaceExistingPortalDirectory({
190
+ sourceDir: tempDir,
191
+ portalDir: params.portalDir,
192
+ });
193
+ return;
169
194
  }
170
195
  await rename(tempDir, params.portalDir);
171
196
  }
@@ -174,6 +199,16 @@ async function replacePortalSourceFromDirectory(params) {
174
199
  throw error;
175
200
  }
176
201
  }
202
+ async function assertPortalDirectoryCanBeReplaced(params) {
203
+ const targetExists = await pathExists(params.portalDir);
204
+ if (targetExists && !params.force) {
205
+ throw new Error(portalSourceText('errors.workspaceExists', { portalDir: params.portalDir }, `Portal already exists: ${params.portalDir}\nPass --force to delete it and pull again.`));
206
+ }
207
+ if (targetExists && params.force && !(await canReplacePortalDirectory(params.portalDir))) {
208
+ throw new Error(portalSourceText('errors.workspaceNotReplaceable', { portalDir: params.portalDir }, `Refusing to replace a non-portal directory: ${params.portalDir}`));
209
+ }
210
+ return targetExists;
211
+ }
177
212
  async function runGit(args, cwd) {
178
213
  return await execFileAsync('git', args, {
179
214
  cwd,
@@ -185,24 +220,37 @@ async function installPortalDependencies(params) {
185
220
  return {
186
221
  dependenciesInstalled: false,
187
222
  installSkipped: true,
223
+ installFailed: false,
188
224
  };
189
225
  }
190
226
  if (!(await isFile(path.join(params.portalDir, 'package.json')))) {
191
227
  return {
192
228
  dependenciesInstalled: false,
193
229
  installSkipped: true,
230
+ installFailed: false,
194
231
  };
195
232
  }
196
233
  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
- });
234
+ const installCommand = await resolvePnpmInstallCommand(params.portalDir);
235
+ try {
236
+ await runPnpmInstallCommand(runCommand, installCommand.args, {
237
+ cwd: params.portalDir,
238
+ env: buildPortalCommandEnv(),
239
+ envMode: 'replace',
240
+ errorName: installCommand.errorName,
241
+ });
242
+ }
243
+ catch {
244
+ return {
245
+ dependenciesInstalled: false,
246
+ installSkipped: false,
247
+ installFailed: true,
248
+ };
249
+ }
203
250
  return {
204
251
  dependenciesInstalled: true,
205
252
  installSkipped: false,
253
+ installFailed: false,
206
254
  };
207
255
  }
208
256
  function readSourceRevision(data) {
@@ -219,10 +267,15 @@ async function resolvePortalSourceContext(options) {
219
267
  const portal = validatePortalSlug(options.portal);
220
268
  const apiBaseUrl = trimValue(options.env.apiBaseUrl);
221
269
  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
270
  const mode = options.env.kind;
271
+ const appContext = await resolvePortalAppContext(options);
272
+ const { app, appPublicPath, portalBaseApp } = appContext;
273
+ const portalDeployDir = resolvePortalDeployPath({ storagePath, app, portal });
274
+ const portalDir = options.sourcePath
275
+ ? resolvePortalSourcePath(portal, options.sourcePath)
276
+ : resolveSavedPortalSourcePath(options.env, portal) ??
277
+ (options.defaultSourcePath ? resolvePortalSourcePath(portal) : portalDeployDir);
278
+ const portalBase = buildPortalBasePath({ app: portalBaseApp ?? app, appPublicPath, portal });
226
279
  if (mode !== 'local' && mode !== 'docker' && mode !== 'http') {
227
280
  throw new Error(portalSourceText('errors.unsupportedEnvKind', { kind: mode }, `Cannot sync portal source for ${mode} envs in the first version.`));
228
281
  }
@@ -231,6 +284,7 @@ async function resolvePortalSourceContext(options) {
231
284
  envName: options.envName,
232
285
  cliVersion: options.cliVersion,
233
286
  apiRequest: options.apiRequest,
287
+ appContext,
234
288
  });
235
289
  const item = findPortalListItem(list.items, portal);
236
290
  if (!item) {
@@ -241,6 +295,7 @@ async function resolvePortalSourceContext(options) {
241
295
  portal,
242
296
  portalDir,
243
297
  portalBase,
298
+ apiBaseUrl,
244
299
  mode,
245
300
  sourceStorage: item.sourceStorage || 'nocobase',
246
301
  gitRepo: item.gitRepo,
@@ -271,6 +326,25 @@ function applyPortalConfigToContext(context, config) {
271
326
  gitPath: config.git?.path ?? DEFAULT_PORTAL_GIT_PATH,
272
327
  };
273
328
  }
329
+ function getTemporaryGitPortalConfig(options) {
330
+ const gitRepo = trimValue(options.gitRepo);
331
+ if (!gitRepo) {
332
+ if (trimValue(options.gitBranch) || trimValue(options.gitPath)) {
333
+ throw new Error(portalSourceText('errors.gitRepoRequiredForTemporaryPull', undefined, [
334
+ '--git-branch and --git-path require --git-repo for a temporary Git pull.',
335
+ 'To update the portal configuration, use `nb portal config`.',
336
+ ].join(' ')));
337
+ }
338
+ return undefined;
339
+ }
340
+ return buildPortalConfig({
341
+ portal: options.portal,
342
+ sourceStorage: 'git',
343
+ gitRepo,
344
+ gitBranch: options.gitBranch,
345
+ gitPath: options.gitPath,
346
+ });
347
+ }
274
348
  function assertGitSourceConfig(context) {
275
349
  if (!context.gitRepo) {
276
350
  throw new Error(portalSourceText('errors.gitRepoMissing', { portal: context.portal }, `Portal "${context.portal}" uses Git source storage, but gitRepo is missing.`));
@@ -319,8 +393,76 @@ async function cloneGitSource(params) {
319
393
  }
320
394
  return repoDir;
321
395
  }
396
+ async function setGitOriginRepository(params) {
397
+ try {
398
+ await runGit(['remote', 'get-url', 'origin'], params.repoDir);
399
+ await runGit(['remote', 'set-url', 'origin', params.repo], params.repoDir);
400
+ }
401
+ catch {
402
+ await runGit(['remote', 'add', 'origin', params.repo], params.repoDir);
403
+ }
404
+ }
405
+ async function checkoutExistingGitRepository(params) {
406
+ await setGitOriginRepository({
407
+ repoDir: params.repoDir,
408
+ repo: params.repo,
409
+ });
410
+ try {
411
+ await runGit(['fetch', 'origin', params.branch], params.repoDir);
412
+ await runGit(['checkout', '-f', '-B', params.branch, 'FETCH_HEAD'], params.repoDir);
413
+ }
414
+ catch (error) {
415
+ await runGit(['fetch', 'origin'], params.repoDir);
416
+ await runGit(['checkout', '-f', '-B', params.branch], params.repoDir);
417
+ }
418
+ await runGit(['clean', '-fdx'], params.repoDir);
419
+ }
420
+ async function pullGitRepositoryRootPortalSource(params) {
421
+ const targetExists = await assertPortalDirectoryCanBeReplaced({
422
+ portalDir: params.context.portalDir,
423
+ force: params.force,
424
+ });
425
+ await mkdir(path.dirname(params.context.portalDir), { recursive: true });
426
+ if (targetExists && (await pathExists(path.join(params.context.portalDir, '.git')))) {
427
+ await checkoutExistingGitRepository({
428
+ repoDir: params.context.portalDir,
429
+ repo: params.repo,
430
+ branch: params.branch,
431
+ });
432
+ return;
433
+ }
434
+ if (targetExists) {
435
+ await rm(params.context.portalDir, { recursive: true, force: true });
436
+ }
437
+ const tempDir = await mkdtemp(path.join(path.dirname(params.context.portalDir), `.${path.basename(params.context.portalDir)}-git-pull-`));
438
+ try {
439
+ const repoDir = await cloneGitSource({
440
+ repo: params.repo,
441
+ branch: params.branch,
442
+ cwd: tempDir,
443
+ createBranch: true,
444
+ });
445
+ await rename(repoDir, params.context.portalDir);
446
+ }
447
+ catch (error) {
448
+ await rm(params.context.portalDir, { recursive: true, force: true });
449
+ throw error;
450
+ }
451
+ finally {
452
+ await rm(tempDir, { recursive: true, force: true });
453
+ }
454
+ }
322
455
  async function pullGitPortalSource(params) {
323
456
  const git = assertGitSourceConfig(params.context);
457
+ if (isGitRepositoryRootPath(git.gitPath)) {
458
+ await pullGitRepositoryRootPortalSource({
459
+ context: params.context,
460
+ repo: git.repo,
461
+ branch: git.branch,
462
+ force: params.force,
463
+ });
464
+ return;
465
+ }
324
466
  const tempDir = await mkdtemp(path.join(os.tmpdir(), 'nocobase-cli-portal-git-pull-'));
325
467
  try {
326
468
  const repoDir = await cloneGitSource({
@@ -382,15 +524,23 @@ async function pushGitPortalSource(params) {
382
524
  }
383
525
  }
384
526
  export async function pullPortalSource(options) {
385
- const context = await resolvePortalSourceContext(options);
386
- const portalConfig = buildPortalConfigFromContext(context);
527
+ const context = await resolvePortalSourceContext({
528
+ ...options,
529
+ defaultSourcePath: true,
530
+ });
531
+ const portalConfig = getTemporaryGitPortalConfig(options) ?? buildPortalConfigFromContext(context);
387
532
  const sourceContext = applyPortalConfigToContext(context, portalConfig);
388
533
  if (sourceContext.sourceStorage === 'git') {
389
534
  await pullGitPortalSource({
390
535
  context: sourceContext,
391
536
  force: options.force,
392
537
  });
393
- await writePortalConfig(sourceContext.portalDir, portalConfig);
538
+ await ensurePortalBuildHtmlReadsEnvOnly(sourceContext.portalDir);
539
+ await updatePortalEnvFiles({
540
+ portalDir: sourceContext.portalDir,
541
+ apiBaseUrl: sourceContext.apiBaseUrl,
542
+ portalBase: sourceContext.portalBase,
543
+ });
394
544
  const installResult = await installPortalDependencies({
395
545
  portalDir: sourceContext.portalDir,
396
546
  installDependencies: options.installDependencies,
@@ -405,15 +555,6 @@ export async function pullPortalSource(options) {
405
555
  if (sourceContext.sourceStorage !== 'nocobase') {
406
556
  throw new Error(portalSourceText('errors.unsupportedSourceStorage', { sourceStorage: sourceContext.sourceStorage }, `Unsupported portal source storage: ${sourceContext.sourceStorage}`));
407
557
  }
408
- if (sourceContext.mode === 'local' || sourceContext.mode === 'docker') {
409
- return {
410
- ...sourceContext,
411
- changed: false,
412
- noopReason: sourceContext.mode === 'local'
413
- ? portalSourceText('messages.localPullNoop', undefined, 'Portal source is already local.')
414
- : portalSourceText('messages.dockerPullNoop', undefined, 'Portal source is already available through the Docker volume.'),
415
- };
416
- }
417
558
  const tempDir = await mkdtemp(path.join(os.tmpdir(), 'nocobase-cli-portal-pull-'));
418
559
  const archivePath = path.join(tempDir, 'source.tar.gz');
419
560
  const apiRequest = options.apiRequest ?? executeApiRequest;
@@ -437,7 +578,12 @@ export async function pullPortalSource(options) {
437
578
  portalDir: sourceContext.portalDir,
438
579
  force: options.force,
439
580
  });
440
- await writePortalConfig(sourceContext.portalDir, portalConfig);
581
+ await ensurePortalBuildHtmlReadsEnvOnly(sourceContext.portalDir);
582
+ await updatePortalEnvFiles({
583
+ portalDir: sourceContext.portalDir,
584
+ apiBaseUrl: sourceContext.apiBaseUrl,
585
+ portalBase: sourceContext.portalBase,
586
+ });
441
587
  const installResult = await installPortalDependencies({
442
588
  portalDir: sourceContext.portalDir,
443
589
  installDependencies: options.installDependencies,
@@ -454,19 +600,14 @@ export async function pullPortalSource(options) {
454
600
  }
455
601
  }
456
602
  export async function pushPortalSource(options) {
457
- const context = await resolvePortalSourceContext(options);
603
+ const context = await resolvePortalSourceContext({
604
+ ...options,
605
+ defaultSourcePath: true,
606
+ });
458
607
  if (!(await pathExists(context.portalDir))) {
459
608
  throw new Error(portalSourceText('errors.workspaceMissing', { portalDir: context.portalDir, portal: context.portal }, `Portal does not exist: ${context.portalDir}\nRun \`nb portal create ${context.portal}\` first.`));
460
609
  }
461
- const portalConfig = await readPortalConfig(context.portalDir);
462
- await syncPortalConfigToRemote({
463
- portal: context.portal,
464
- config: portalConfig,
465
- currentOptions: context.options,
466
- envName: options.envName,
467
- cliVersion: options.cliVersion,
468
- apiRequest: options.apiRequest,
469
- });
610
+ const portalConfig = buildPortalConfigFromContext(context);
470
611
  const sourceContext = applyPortalConfigToContext(context, portalConfig);
471
612
  if (sourceContext.sourceStorage === 'git') {
472
613
  const revision = await pushGitPortalSource({
@@ -485,15 +626,6 @@ export async function pushPortalSource(options) {
485
626
  if (sourceContext.sourceStorage !== 'nocobase') {
486
627
  throw new Error(portalSourceText('errors.unsupportedSourceStorage', { sourceStorage: sourceContext.sourceStorage }, `Unsupported portal source storage: ${sourceContext.sourceStorage}`));
487
628
  }
488
- if (sourceContext.mode === 'local' || sourceContext.mode === 'docker') {
489
- return {
490
- ...sourceContext,
491
- changed: false,
492
- noopReason: sourceContext.mode === 'local'
493
- ? portalSourceText('messages.localPushNoop', undefined, 'Portal source is already local.')
494
- : portalSourceText('messages.dockerPushNoop', undefined, 'Portal source is already available through the Docker volume.'),
495
- };
496
- }
497
629
  const archive = await packPortalSource(sourceContext.portalDir);
498
630
  const apiRequest = options.apiRequest ?? executeApiRequest;
499
631
  try {
@@ -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));