@nocobase/cli 3.0.0-alpha.1 → 3.0.0-alpha.11

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.
Files changed (52) hide show
  1. package/dist/commands/api/resource/create.js +11 -2
  2. package/dist/commands/api/swagger/get.js +96 -0
  3. package/dist/commands/api/swagger/index.js +20 -0
  4. package/dist/commands/api/swagger/list.js +92 -0
  5. package/dist/commands/env/add.js +6 -0
  6. package/dist/commands/env/update.js +13 -0
  7. package/dist/commands/init.js +38 -0
  8. package/dist/commands/install.js +18 -62
  9. package/dist/commands/portal/config.js +16 -5
  10. package/dist/commands/portal/create.js +17 -26
  11. package/dist/commands/portal/destroy.js +28 -6
  12. package/dist/commands/portal/dev.js +2 -0
  13. package/dist/commands/portal/list.js +5 -5
  14. package/dist/commands/portal/pull.js +32 -3
  15. package/dist/lib/api-client.js +35 -9
  16. package/dist/lib/app-client-entry-mode.js +28 -0
  17. package/dist/lib/app-managed-resources.js +2 -0
  18. package/dist/lib/auth-store.js +52 -1
  19. package/dist/lib/bootstrap.js +3 -1
  20. package/dist/lib/browser.js +29 -0
  21. package/dist/lib/env-auth.js +2 -36
  22. package/dist/lib/env-command-config.js +1 -0
  23. package/dist/lib/env-config.js +10 -2
  24. package/dist/lib/env-portal-config.js +30 -0
  25. package/dist/lib/env-proxy.js +25 -4
  26. package/dist/lib/generated-command.js +81 -0
  27. package/dist/lib/managed-env-file.js +67 -13
  28. package/dist/lib/managed-init-env.js +0 -2
  29. package/dist/lib/plugin-import.js +30 -8
  30. package/dist/lib/portal-build-html.js +27 -0
  31. package/dist/lib/portal-config.js +6 -20
  32. package/dist/lib/portal-configure.js +49 -56
  33. package/dist/lib/portal-create.js +96 -14
  34. package/dist/lib/portal-deploy.js +30 -47
  35. package/dist/lib/portal-destroy.js +34 -20
  36. package/dist/lib/portal-dev.js +6 -7
  37. package/dist/lib/portal-env-files.js +2 -1
  38. package/dist/lib/portal-info.js +2 -5
  39. package/dist/lib/portal-list.js +20 -26
  40. package/dist/lib/portal-path-safety.js +76 -0
  41. package/dist/lib/portal-source.js +227 -58
  42. package/dist/lib/prompt-catalog-core.js +2 -2
  43. package/dist/lib/prompt-catalog-terminal.js +4 -5
  44. package/dist/lib/prompt-web-ui.js +12 -6
  45. package/dist/lib/resource-command.js +18 -2
  46. package/dist/lib/resource-request.js +8 -0
  47. package/dist/lib/run-npm.js +68 -4
  48. package/dist/lib/runtime-generator.js +28 -1
  49. package/dist/lib/swagger-command.js +52 -0
  50. package/dist/locale/en-US.json +81 -15
  51. package/dist/locale/zh-CN.json +81 -15
  52. package/package.json +2 -2
@@ -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,13 +14,20 @@ 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 } from './run-npm.js';
25
+ import { resolvePnpmInstallCommand, run, runPnpmInstallCommand } from './run-npm.js';
23
26
  const execFileAsync = promisify(execFile);
27
+ const NOCOBASE_CLI_GIT_IDENTITY = {
28
+ name: 'NocoBase CLI',
29
+ email: '314549027+nocobase-cli@users.noreply.github.com',
30
+ };
24
31
  const portalSourceText = (key, values, fallback) => translateCli(`commands.portalSource.${key}`, values, { fallback });
25
32
  const PULL_SOURCE_OPERATION = {
26
33
  method: 'POST',
@@ -128,11 +135,25 @@ async function packPortalSource(portalDir) {
128
135
  },
129
136
  };
130
137
  }
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.`));
138
+ async function replaceExistingPortalDirectory(params) {
139
+ if (!(await pathExists(path.join(params.portalDir, '.git')))) {
140
+ await rm(params.portalDir, { recursive: true, force: true });
141
+ await rename(params.sourceDir, params.portalDir);
142
+ return;
135
143
  }
144
+ const existingEntries = await readdir(params.portalDir);
145
+ await Promise.all(existingEntries
146
+ .filter((entry) => entry !== '.git')
147
+ .map((entry) => rm(path.join(params.portalDir, entry), { recursive: true, force: true })));
148
+ const sourceEntries = await readdir(params.sourceDir);
149
+ await Promise.all(sourceEntries.map((entry) => rename(path.join(params.sourceDir, entry), path.join(params.portalDir, entry))));
150
+ await rm(params.sourceDir, { recursive: true, force: true });
151
+ }
152
+ async function replacePortalSourceFromArchive(params) {
153
+ const targetExists = await assertPortalDirectoryCanBeReplaced({
154
+ portalDir: params.portalDir,
155
+ force: params.force,
156
+ });
136
157
  const parentDir = path.dirname(params.portalDir);
137
158
  const tempDir = await mkdtemp(path.join(parentDir, `.${path.basename(params.portalDir)}-pull-`));
138
159
  try {
@@ -143,7 +164,11 @@ async function replacePortalSourceFromArchive(params) {
143
164
  filter: validatePortalSourceTarEntry,
144
165
  });
145
166
  if (targetExists) {
146
- await rm(params.portalDir, { recursive: true, force: true });
167
+ await replaceExistingPortalDirectory({
168
+ sourceDir: tempDir,
169
+ portalDir: params.portalDir,
170
+ });
171
+ return;
147
172
  }
148
173
  await rename(tempDir, params.portalDir);
149
174
  }
@@ -153,10 +178,10 @@ async function replacePortalSourceFromArchive(params) {
153
178
  }
154
179
  }
155
180
  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
- }
181
+ const targetExists = await assertPortalDirectoryCanBeReplaced({
182
+ portalDir: params.portalDir,
183
+ force: params.force,
184
+ });
160
185
  const parentDir = path.dirname(params.portalDir);
161
186
  const tempDir = await mkdtemp(path.join(parentDir, `.${path.basename(params.portalDir)}-pull-`));
162
187
  try {
@@ -165,7 +190,11 @@ async function replacePortalSourceFromDirectory(params) {
165
190
  filter: (source) => shouldPackPortalSourceEntry(path.relative(params.sourceDir, source)),
166
191
  });
167
192
  if (targetExists) {
168
- await rm(params.portalDir, { recursive: true, force: true });
193
+ await replaceExistingPortalDirectory({
194
+ sourceDir: tempDir,
195
+ portalDir: params.portalDir,
196
+ });
197
+ return;
169
198
  }
170
199
  await rename(tempDir, params.portalDir);
171
200
  }
@@ -174,35 +203,85 @@ async function replacePortalSourceFromDirectory(params) {
174
203
  throw error;
175
204
  }
176
205
  }
206
+ async function assertPortalDirectoryCanBeReplaced(params) {
207
+ const targetExists = await pathExists(params.portalDir);
208
+ if (targetExists && !params.force) {
209
+ throw new Error(portalSourceText('errors.workspaceExists', { portalDir: params.portalDir }, `Portal already exists: ${params.portalDir}\nPass --force to delete it and pull again.`));
210
+ }
211
+ if (targetExists && params.force && !(await canReplacePortalDirectory(params.portalDir))) {
212
+ throw new Error(portalSourceText('errors.workspaceNotReplaceable', { portalDir: params.portalDir }, `Refusing to replace a non-portal directory: ${params.portalDir}`));
213
+ }
214
+ return targetExists;
215
+ }
177
216
  async function runGit(args, cwd) {
178
217
  return await execFileAsync('git', args, {
179
218
  cwd,
180
219
  maxBuffer: 10 * 1024 * 1024,
181
220
  });
182
221
  }
222
+ function isValidGitIdentity(identity) {
223
+ return (!/[\r\n<>]/.test(identity.name) &&
224
+ !/[\r\n<>\s]/.test(identity.email) &&
225
+ identity.email.includes('@'));
226
+ }
227
+ async function resolveLocalGitIdentity(cwd) {
228
+ let output;
229
+ try {
230
+ output = (await runGit(['var', 'GIT_AUTHOR_IDENT'], cwd)).stdout;
231
+ }
232
+ catch {
233
+ return undefined;
234
+ }
235
+ const match = /^(.*) <([^<>]+)> -?\d+ [+-]\d{4}$/.exec(trimValue(output));
236
+ if (!match) {
237
+ return undefined;
238
+ }
239
+ const identity = { name: match[1], email: match[2] };
240
+ if (!isValidGitIdentity(identity)) {
241
+ return undefined;
242
+ }
243
+ if (identity.name === NOCOBASE_CLI_GIT_IDENTITY.name &&
244
+ identity.email === NOCOBASE_CLI_GIT_IDENTITY.email) {
245
+ return undefined;
246
+ }
247
+ return identity;
248
+ }
183
249
  async function installPortalDependencies(params) {
184
250
  if (params.installDependencies === false) {
185
251
  return {
186
252
  dependenciesInstalled: false,
187
253
  installSkipped: true,
254
+ installFailed: false,
188
255
  };
189
256
  }
190
257
  if (!(await isFile(path.join(params.portalDir, 'package.json')))) {
191
258
  return {
192
259
  dependenciesInstalled: false,
193
260
  installSkipped: true,
261
+ installFailed: false,
194
262
  };
195
263
  }
196
264
  const runCommand = params.runCommand ?? run;
197
- await runCommand('pnpm', ['install'], {
198
- cwd: params.portalDir,
199
- env: buildPortalCommandEnv(),
200
- envMode: 'replace',
201
- errorName: 'pnpm install',
202
- });
265
+ const installCommand = await resolvePnpmInstallCommand(params.portalDir);
266
+ try {
267
+ await runPnpmInstallCommand(runCommand, installCommand.args, {
268
+ cwd: params.portalDir,
269
+ env: buildPortalCommandEnv(),
270
+ envMode: 'replace',
271
+ errorName: installCommand.errorName,
272
+ });
273
+ }
274
+ catch {
275
+ return {
276
+ dependenciesInstalled: false,
277
+ installSkipped: false,
278
+ installFailed: true,
279
+ };
280
+ }
203
281
  return {
204
282
  dependenciesInstalled: true,
205
283
  installSkipped: false,
284
+ installFailed: false,
206
285
  };
207
286
  }
208
287
  function readSourceRevision(data) {
@@ -219,10 +298,15 @@ async function resolvePortalSourceContext(options) {
219
298
  const portal = validatePortalSlug(options.portal);
220
299
  const apiBaseUrl = trimValue(options.env.apiBaseUrl);
221
300
  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
301
  const mode = options.env.kind;
302
+ const appContext = await resolvePortalAppContext(options);
303
+ const { app, appPublicPath, portalBaseApp } = appContext;
304
+ const portalDeployDir = resolvePortalDeployPath({ storagePath, app, portal });
305
+ const portalDir = options.sourcePath
306
+ ? resolvePortalSourcePath(portal, options.sourcePath)
307
+ : resolveSavedPortalSourcePath(options.env, portal) ??
308
+ (options.defaultSourcePath ? resolvePortalSourcePath(portal) : portalDeployDir);
309
+ const portalBase = buildPortalBasePath({ app: portalBaseApp ?? app, appPublicPath, portal });
226
310
  if (mode !== 'local' && mode !== 'docker' && mode !== 'http') {
227
311
  throw new Error(portalSourceText('errors.unsupportedEnvKind', { kind: mode }, `Cannot sync portal source for ${mode} envs in the first version.`));
228
312
  }
@@ -231,6 +315,7 @@ async function resolvePortalSourceContext(options) {
231
315
  envName: options.envName,
232
316
  cliVersion: options.cliVersion,
233
317
  apiRequest: options.apiRequest,
318
+ appContext,
234
319
  });
235
320
  const item = findPortalListItem(list.items, portal);
236
321
  if (!item) {
@@ -241,6 +326,7 @@ async function resolvePortalSourceContext(options) {
241
326
  portal,
242
327
  portalDir,
243
328
  portalBase,
329
+ apiBaseUrl,
244
330
  mode,
245
331
  sourceStorage: item.sourceStorage || 'nocobase',
246
332
  gitRepo: item.gitRepo,
@@ -271,6 +357,25 @@ function applyPortalConfigToContext(context, config) {
271
357
  gitPath: config.git?.path ?? DEFAULT_PORTAL_GIT_PATH,
272
358
  };
273
359
  }
360
+ function getTemporaryGitPortalConfig(options) {
361
+ const gitRepo = trimValue(options.gitRepo);
362
+ if (!gitRepo) {
363
+ if (trimValue(options.gitBranch) || trimValue(options.gitPath)) {
364
+ throw new Error(portalSourceText('errors.gitRepoRequiredForTemporaryPull', undefined, [
365
+ '--git-branch and --git-path require --git-repo for a temporary Git pull.',
366
+ 'To update the portal configuration, use `nb portal config`.',
367
+ ].join(' ')));
368
+ }
369
+ return undefined;
370
+ }
371
+ return buildPortalConfig({
372
+ portal: options.portal,
373
+ sourceStorage: 'git',
374
+ gitRepo,
375
+ gitBranch: options.gitBranch,
376
+ gitPath: options.gitPath,
377
+ });
378
+ }
274
379
  function assertGitSourceConfig(context) {
275
380
  if (!context.gitRepo) {
276
381
  throw new Error(portalSourceText('errors.gitRepoMissing', { portal: context.portal }, `Portal "${context.portal}" uses Git source storage, but gitRepo is missing.`));
@@ -319,8 +424,76 @@ async function cloneGitSource(params) {
319
424
  }
320
425
  return repoDir;
321
426
  }
427
+ async function setGitOriginRepository(params) {
428
+ try {
429
+ await runGit(['remote', 'get-url', 'origin'], params.repoDir);
430
+ await runGit(['remote', 'set-url', 'origin', params.repo], params.repoDir);
431
+ }
432
+ catch {
433
+ await runGit(['remote', 'add', 'origin', params.repo], params.repoDir);
434
+ }
435
+ }
436
+ async function checkoutExistingGitRepository(params) {
437
+ await setGitOriginRepository({
438
+ repoDir: params.repoDir,
439
+ repo: params.repo,
440
+ });
441
+ try {
442
+ await runGit(['fetch', 'origin', params.branch], params.repoDir);
443
+ await runGit(['checkout', '-f', '-B', params.branch, 'FETCH_HEAD'], params.repoDir);
444
+ }
445
+ catch (error) {
446
+ await runGit(['fetch', 'origin'], params.repoDir);
447
+ await runGit(['checkout', '-f', '-B', params.branch], params.repoDir);
448
+ }
449
+ await runGit(['clean', '-fdx'], params.repoDir);
450
+ }
451
+ async function pullGitRepositoryRootPortalSource(params) {
452
+ const targetExists = await assertPortalDirectoryCanBeReplaced({
453
+ portalDir: params.context.portalDir,
454
+ force: params.force,
455
+ });
456
+ await mkdir(path.dirname(params.context.portalDir), { recursive: true });
457
+ if (targetExists && (await pathExists(path.join(params.context.portalDir, '.git')))) {
458
+ await checkoutExistingGitRepository({
459
+ repoDir: params.context.portalDir,
460
+ repo: params.repo,
461
+ branch: params.branch,
462
+ });
463
+ return;
464
+ }
465
+ if (targetExists) {
466
+ await rm(params.context.portalDir, { recursive: true, force: true });
467
+ }
468
+ const tempDir = await mkdtemp(path.join(path.dirname(params.context.portalDir), `.${path.basename(params.context.portalDir)}-git-pull-`));
469
+ try {
470
+ const repoDir = await cloneGitSource({
471
+ repo: params.repo,
472
+ branch: params.branch,
473
+ cwd: tempDir,
474
+ createBranch: true,
475
+ });
476
+ await rename(repoDir, params.context.portalDir);
477
+ }
478
+ catch (error) {
479
+ await rm(params.context.portalDir, { recursive: true, force: true });
480
+ throw error;
481
+ }
482
+ finally {
483
+ await rm(tempDir, { recursive: true, force: true });
484
+ }
485
+ }
322
486
  async function pullGitPortalSource(params) {
323
487
  const git = assertGitSourceConfig(params.context);
488
+ if (isGitRepositoryRootPath(git.gitPath)) {
489
+ await pullGitRepositoryRootPortalSource({
490
+ context: params.context,
491
+ repo: git.repo,
492
+ branch: git.branch,
493
+ force: params.force,
494
+ });
495
+ return;
496
+ }
324
497
  const tempDir = await mkdtemp(path.join(os.tmpdir(), 'nocobase-cli-portal-git-pull-'));
325
498
  try {
326
499
  const repoDir = await cloneGitSource({
@@ -346,6 +519,7 @@ async function pullGitPortalSource(params) {
346
519
  }
347
520
  async function pushGitPortalSource(params) {
348
521
  const git = assertGitSourceConfig(params.context);
522
+ const localIdentity = await resolveLocalGitIdentity(params.context.portalDir);
349
523
  const tempDir = await mkdtemp(path.join(os.tmpdir(), 'nocobase-cli-portal-git-push-'));
350
524
  try {
351
525
  const repoDir = await cloneGitSource({
@@ -364,15 +538,20 @@ async function pushGitPortalSource(params) {
364
538
  if (!status.stdout.trim()) {
365
539
  return undefined;
366
540
  }
367
- await runGit([
541
+ const commitIdentity = localIdentity ?? NOCOBASE_CLI_GIT_IDENTITY;
542
+ const commitArgs = [
368
543
  '-c',
369
- 'user.name=NocoBase CLI',
544
+ `user.name=${commitIdentity.name}`,
370
545
  '-c',
371
- 'user.email=nocobase-cli@localhost',
546
+ `user.email=${commitIdentity.email}`,
372
547
  'commit',
373
548
  '-m',
374
549
  trimValue(params.message) || `chore(portal): update ${params.context.portal}`,
375
- ], repoDir);
550
+ ];
551
+ if (localIdentity) {
552
+ commitArgs.push('-m', `Co-authored-by: ${NOCOBASE_CLI_GIT_IDENTITY.name} <${NOCOBASE_CLI_GIT_IDENTITY.email}>`);
553
+ }
554
+ await runGit(commitArgs, repoDir);
376
555
  await runGit(['push', 'origin', git.branch], repoDir);
377
556
  const revision = await runGit(['rev-parse', 'HEAD'], repoDir);
378
557
  return revision.stdout.trim();
@@ -382,15 +561,23 @@ async function pushGitPortalSource(params) {
382
561
  }
383
562
  }
384
563
  export async function pullPortalSource(options) {
385
- const context = await resolvePortalSourceContext(options);
386
- const portalConfig = buildPortalConfigFromContext(context);
564
+ const context = await resolvePortalSourceContext({
565
+ ...options,
566
+ defaultSourcePath: true,
567
+ });
568
+ const portalConfig = getTemporaryGitPortalConfig(options) ?? buildPortalConfigFromContext(context);
387
569
  const sourceContext = applyPortalConfigToContext(context, portalConfig);
388
570
  if (sourceContext.sourceStorage === 'git') {
389
571
  await pullGitPortalSource({
390
572
  context: sourceContext,
391
573
  force: options.force,
392
574
  });
393
- await writePortalConfig(sourceContext.portalDir, portalConfig);
575
+ await ensurePortalBuildHtmlReadsEnvOnly(sourceContext.portalDir);
576
+ await updatePortalEnvFiles({
577
+ portalDir: sourceContext.portalDir,
578
+ apiBaseUrl: sourceContext.apiBaseUrl,
579
+ portalBase: sourceContext.portalBase,
580
+ });
394
581
  const installResult = await installPortalDependencies({
395
582
  portalDir: sourceContext.portalDir,
396
583
  installDependencies: options.installDependencies,
@@ -405,15 +592,6 @@ export async function pullPortalSource(options) {
405
592
  if (sourceContext.sourceStorage !== 'nocobase') {
406
593
  throw new Error(portalSourceText('errors.unsupportedSourceStorage', { sourceStorage: sourceContext.sourceStorage }, `Unsupported portal source storage: ${sourceContext.sourceStorage}`));
407
594
  }
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
595
  const tempDir = await mkdtemp(path.join(os.tmpdir(), 'nocobase-cli-portal-pull-'));
418
596
  const archivePath = path.join(tempDir, 'source.tar.gz');
419
597
  const apiRequest = options.apiRequest ?? executeApiRequest;
@@ -437,7 +615,12 @@ export async function pullPortalSource(options) {
437
615
  portalDir: sourceContext.portalDir,
438
616
  force: options.force,
439
617
  });
440
- await writePortalConfig(sourceContext.portalDir, portalConfig);
618
+ await ensurePortalBuildHtmlReadsEnvOnly(sourceContext.portalDir);
619
+ await updatePortalEnvFiles({
620
+ portalDir: sourceContext.portalDir,
621
+ apiBaseUrl: sourceContext.apiBaseUrl,
622
+ portalBase: sourceContext.portalBase,
623
+ });
441
624
  const installResult = await installPortalDependencies({
442
625
  portalDir: sourceContext.portalDir,
443
626
  installDependencies: options.installDependencies,
@@ -454,19 +637,14 @@ export async function pullPortalSource(options) {
454
637
  }
455
638
  }
456
639
  export async function pushPortalSource(options) {
457
- const context = await resolvePortalSourceContext(options);
640
+ const context = await resolvePortalSourceContext({
641
+ ...options,
642
+ defaultSourcePath: true,
643
+ });
458
644
  if (!(await pathExists(context.portalDir))) {
459
645
  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
646
  }
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
- });
647
+ const portalConfig = buildPortalConfigFromContext(context);
470
648
  const sourceContext = applyPortalConfigToContext(context, portalConfig);
471
649
  if (sourceContext.sourceStorage === 'git') {
472
650
  const revision = await pushGitPortalSource({
@@ -485,15 +663,6 @@ export async function pushPortalSource(options) {
485
663
  if (sourceContext.sourceStorage !== 'nocobase') {
486
664
  throw new Error(portalSourceText('errors.unsupportedSourceStorage', { sourceStorage: sourceContext.sourceStorage }, `Unsupported portal source storage: ${sourceContext.sourceStorage}`));
487
665
  }
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
666
  const archive = await packPortalSource(sourceContext.portalDir);
498
667
  const apiRequest = options.apiRequest ?? executeApiRequest;
499
668
  try {
@@ -60,7 +60,7 @@ export function mergedBoolean(key, def, iv, useYesInitial) {
60
60
  }
61
61
  return def.initialValue ?? true;
62
62
  }
63
- export function mergedSelect(key, def, iv, useYesInitial) {
63
+ export function mergedSelect(key, def, iv, useYesInitial, valuesSoFar = {}) {
64
64
  const enabledValueList = enabledSelectOptionValues(def.options);
65
65
  if (hasIvKey(iv, key)) {
66
66
  const s = String(iv[key]);
@@ -72,7 +72,7 @@ export function mergedSelect(key, def, iv, useYesInitial) {
72
72
  if (useYesInitial && def.yesInitialValue !== undefined && enabledValueList.includes(def.yesInitialValue)) {
73
73
  return def.yesInitialValue;
74
74
  }
75
- const d = def.initialValue;
75
+ const d = typeof def.initialValue === 'function' ? def.initialValue(valuesSoFar) : def.initialValue;
76
76
  if (d !== undefined && enabledValueList.includes(d)) {
77
77
  return d;
78
78
  }