@nocobase/cli 2.3.0-beta.6 → 2.4.0-alpha.1

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 (56) hide show
  1. package/dist/commands/api/swagger/get.js +96 -0
  2. package/dist/commands/api/swagger/index.js +20 -0
  3. package/dist/commands/api/swagger/list.js +92 -0
  4. package/dist/commands/config/set.js +1 -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 +51 -5
  8. package/dist/commands/install.js +60 -3
  9. package/dist/commands/portal/config.js +99 -0
  10. package/dist/commands/portal/create.js +96 -0
  11. package/dist/commands/portal/deploy.js +81 -0
  12. package/dist/commands/portal/destroy.js +126 -0
  13. package/dist/commands/portal/dev.js +73 -0
  14. package/dist/commands/portal/index.js +20 -0
  15. package/dist/commands/portal/info.js +82 -0
  16. package/dist/commands/portal/list.js +98 -0
  17. package/dist/commands/portal/pull.js +113 -0
  18. package/dist/commands/portal/push.js +79 -0
  19. package/dist/commands/source/dev.js +1 -1
  20. package/dist/lib/api-client.js +35 -9
  21. package/dist/lib/app-client-entry-mode.js +28 -0
  22. package/dist/lib/app-managed-resources.js +2 -0
  23. package/dist/lib/auth-store.js +55 -2
  24. package/dist/lib/bootstrap.js +3 -1
  25. package/dist/lib/cli-config.js +20 -1
  26. package/dist/lib/env-auth.js +2 -36
  27. package/dist/lib/env-command-config.js +1 -0
  28. package/dist/lib/env-config.js +11 -0
  29. package/dist/lib/env-portal-config.js +30 -0
  30. package/dist/lib/env-proxy.js +154 -7
  31. package/dist/lib/managed-env-file.js +119 -9
  32. package/dist/lib/managed-init-env.js +4 -1
  33. package/dist/lib/portal-build-html.js +27 -0
  34. package/dist/lib/portal-command-env.js +31 -0
  35. package/dist/lib/portal-config.js +119 -0
  36. package/dist/lib/portal-configure.js +110 -0
  37. package/dist/lib/portal-create.js +515 -0
  38. package/dist/lib/portal-deploy.js +266 -0
  39. package/dist/lib/portal-destroy.js +114 -0
  40. package/dist/lib/portal-dev.js +78 -0
  41. package/dist/lib/portal-env-files.js +54 -0
  42. package/dist/lib/portal-info.js +28 -0
  43. package/dist/lib/portal-list.js +205 -0
  44. package/dist/lib/portal-path-safety.js +76 -0
  45. package/dist/lib/portal-source.js +692 -0
  46. package/dist/lib/prompt-catalog-core.js +2 -2
  47. package/dist/lib/prompt-catalog-terminal.js +4 -5
  48. package/dist/lib/prompt-web-ui.js +12 -6
  49. package/dist/lib/proxy-caddy.js +2 -0
  50. package/dist/lib/proxy-nginx.js +1 -0
  51. package/dist/lib/run-npm.js +85 -20
  52. package/dist/lib/swagger-command.js +52 -0
  53. package/dist/lib/ui.js +28 -1
  54. package/dist/locale/en-US.json +245 -1
  55. package/dist/locale/zh-CN.json +245 -1
  56. package/package.json +5 -2
@@ -0,0 +1,515 @@
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 { createReadStream } from 'node:fs';
10
+ import { cp, mkdir, mkdtemp, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import { createRequire } from 'node:module';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { pipeline } from 'node:stream/promises';
16
+ import { createGunzip } from 'node:zlib';
17
+ import * as tar from 'tar';
18
+ import { appendAppPublicPath, resolveAppPublicPath } from './app-public-path.js';
19
+ import { executeApiRequest } from './api-client.js';
20
+ import { resolveEnvPortalPath } from './auth-store.js';
21
+ import { resolveEnvRelativePath } from './cli-home.js';
22
+ import { translateCli } from './cli-locale.js';
23
+ import { ensurePortalBuildHtmlReadsEnvOnly } from './portal-build-html.js';
24
+ import { buildPortalCommandEnv } from './portal-command-env.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';
28
+ const DEFAULT_PORTAL_TEMPLATE = '@nocobase/portal-template-default';
29
+ const DEFAULT_PORTAL_APP_NAME = 'main';
30
+ const TEMPLATE_COPY_EXCLUDED_NAMES = new Set(['.git', 'node_modules', '.DS_Store']);
31
+ const NPM_PACK_TIMEOUT_MS = 30_000;
32
+ const portalCreateText = (key, values, fallback) => translateCli(`commands.portalCreate.${key}`, values, { fallback });
33
+ const FIRST_OR_CREATE_PORTAL_OPERATION = {
34
+ method: 'POST',
35
+ pathTemplate: '/multiPortals:firstOrCreate',
36
+ hasBody: true,
37
+ bodyRequired: true,
38
+ parameters: [
39
+ {
40
+ name: 'filterKeys[]',
41
+ flagName: 'filterKeys',
42
+ in: 'query',
43
+ required: true,
44
+ isArray: true,
45
+ },
46
+ ],
47
+ };
48
+ const APP_INFO_OPERATION = {
49
+ method: 'GET',
50
+ pathTemplate: '/app:getInfo',
51
+ parameters: [],
52
+ };
53
+ function trimValue(value) {
54
+ return String(value ?? '').trim();
55
+ }
56
+ async function pathExists(target) {
57
+ try {
58
+ await stat(target);
59
+ return true;
60
+ }
61
+ catch {
62
+ return false;
63
+ }
64
+ }
65
+ async function isDirectory(target) {
66
+ try {
67
+ return (await stat(target)).isDirectory();
68
+ }
69
+ catch {
70
+ return false;
71
+ }
72
+ }
73
+ function ensureTrailingSlash(value) {
74
+ return value.endsWith('/') ? value : `${value}/`;
75
+ }
76
+ function normalizeUrlPathname(pathname) {
77
+ const normalized = pathname.replace(/\/+/g, '/');
78
+ return normalized === '/' ? normalized : normalized.replace(/\/+$/, '');
79
+ }
80
+ function resolveApiBaseUrlPathname(apiBaseUrl) {
81
+ const normalizedApiBaseUrl = trimValue(apiBaseUrl);
82
+ try {
83
+ return normalizeUrlPathname(new URL(normalizedApiBaseUrl).pathname);
84
+ }
85
+ catch {
86
+ const [pathname] = normalizedApiBaseUrl.split(/[?#]/, 1);
87
+ const withLeadingSlash = pathname?.startsWith('/') ? pathname : `/${pathname || 'api'}`;
88
+ return normalizeUrlPathname(withLeadingSlash);
89
+ }
90
+ }
91
+ export function resolvePortalEnvApiUrl(apiBaseUrl) {
92
+ return resolveApiBaseUrlPathname(apiBaseUrl);
93
+ }
94
+ function decodeAppSegment(value) {
95
+ try {
96
+ return decodeURIComponent(value);
97
+ }
98
+ catch {
99
+ return value;
100
+ }
101
+ }
102
+ function safeTempPrefix(parentDir, portal) {
103
+ return path.join(parentDir, `.${portal}-create-`);
104
+ }
105
+ function assertPortalDirIsInsideParent(parentDir, portalDir) {
106
+ const relative = path.relative(parentDir, portalDir);
107
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
108
+ throw new Error(portalCreateText('errors.outsideParent', { parentDir, portalDir }, `Refusing to modify a portal outside ${parentDir}: ${portalDir}`));
109
+ }
110
+ }
111
+ function shouldCopyTemplateEntry(templateDir, source) {
112
+ const relative = path.relative(templateDir, source);
113
+ if (!relative) {
114
+ return true;
115
+ }
116
+ return !relative.split(path.sep).some((segment) => TEMPLATE_COPY_EXCLUDED_NAMES.has(segment));
117
+ }
118
+ async function copyTemplate(sourceDir, targetDir) {
119
+ await cp(sourceDir, targetDir, {
120
+ recursive: true,
121
+ filter: (source) => shouldCopyTemplateEntry(sourceDir, source),
122
+ });
123
+ }
124
+ function looksLikeLocalTemplateSource(source) {
125
+ return path.isAbsolute(source) || source.startsWith('./') || source.startsWith('../') || source.startsWith('file://');
126
+ }
127
+ function normalizeNpmRegistry(value) {
128
+ const text = trimValue(value);
129
+ return text ? text.replace(/\/+$/, '') : undefined;
130
+ }
131
+ async function resolvePackedTemplateTarball(packRoot, sourceLabel) {
132
+ const entries = await readdir(packRoot, { withFileTypes: true });
133
+ const tarballs = entries
134
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.tgz'))
135
+ .map((entry) => path.join(packRoot, entry.name))
136
+ .sort();
137
+ if (tarballs.length === 1) {
138
+ return tarballs[0];
139
+ }
140
+ if (tarballs.length === 0) {
141
+ throw new Error(portalCreateText('errors.npmPackNoTarball', { source: sourceLabel }, `npm pack did not produce a local tarball for ${sourceLabel}.`));
142
+ }
143
+ throw new Error(portalCreateText('errors.npmPackMultipleTarballs', { source: sourceLabel }, `npm pack produced multiple tarballs for ${sourceLabel}.`));
144
+ }
145
+ async function downloadNpmTemplatePackage(params) {
146
+ const packRoot = await mkdtemp(path.join(os.tmpdir(), 'nocobase-cli-portal-template-pack-'));
147
+ const extractRoot = await mkdtemp(path.join(os.tmpdir(), 'nocobase-cli-portal-template-extract-'));
148
+ const args = ['pack', '--silent'];
149
+ const registry = normalizeNpmRegistry(params.npmRegistry);
150
+ let shouldCleanupPackRoot = true;
151
+ let shouldCleanupExtractRoot = true;
152
+ let stdout = '';
153
+ let stderr = '';
154
+ if (registry) {
155
+ args.push(`--registry=${registry}`);
156
+ }
157
+ args.push(params.source);
158
+ try {
159
+ await params.runCommand('npm', args, {
160
+ cwd: packRoot,
161
+ stdio: 'pipe',
162
+ errorName: 'npm pack',
163
+ timeoutMs: NPM_PACK_TIMEOUT_MS,
164
+ onStdout: (chunk) => {
165
+ stdout += chunk;
166
+ },
167
+ onStderr: (chunk) => {
168
+ stderr += chunk;
169
+ },
170
+ });
171
+ const tarballPath = await resolvePackedTemplateTarball(packRoot, params.source);
172
+ try {
173
+ await pipeline(createReadStream(tarballPath), createGunzip(), tar.extract({ cwd: extractRoot, strip: 1 }));
174
+ }
175
+ catch (error) {
176
+ const message = error instanceof Error ? error.message : String(error);
177
+ throw new Error(portalCreateText('errors.templateExtractFailed', { source: params.source, details: message }, `Failed to extract portal template "${params.source}": ${message}`));
178
+ }
179
+ if (!(await pathExists(path.join(extractRoot, 'package.json')))) {
180
+ throw new Error(portalCreateText('errors.templateMissingPackageJson', { source: params.source }, `Portal template "${params.source}" is invalid: package.json is missing.`));
181
+ }
182
+ shouldCleanupPackRoot = false;
183
+ shouldCleanupExtractRoot = false;
184
+ return {
185
+ dir: extractRoot,
186
+ source: params.source,
187
+ type: 'package',
188
+ cleanup: async () => {
189
+ await rm(packRoot, { recursive: true, force: true });
190
+ await rm(extractRoot, { recursive: true, force: true });
191
+ },
192
+ };
193
+ }
194
+ catch (error) {
195
+ const details = trimValue(stderr) || trimValue(stdout) || (error instanceof Error ? error.message : String(error));
196
+ throw new Error(portalCreateText('errors.templateDownloadFailed', { source: params.source, details }, `Failed to download portal template "${params.source}" with npm pack. ${details}`));
197
+ }
198
+ finally {
199
+ if (shouldCleanupPackRoot) {
200
+ await rm(packRoot, { recursive: true, force: true });
201
+ }
202
+ if (shouldCleanupExtractRoot) {
203
+ await rm(extractRoot, { recursive: true, force: true });
204
+ }
205
+ }
206
+ }
207
+ async function resolveLocalTemplateDir(source) {
208
+ if (source.startsWith('file://')) {
209
+ const filePath = fileURLToPath(source);
210
+ if (!(await isDirectory(filePath))) {
211
+ throw new Error(portalCreateText('errors.templateInvalidDirectory', { source }, `Portal template "${source}" is invalid: expected a directory.`));
212
+ }
213
+ return filePath;
214
+ }
215
+ const candidate = path.isAbsolute(source) ? source : path.resolve(process.cwd(), source);
216
+ if (!(await pathExists(candidate))) {
217
+ return undefined;
218
+ }
219
+ if (!(await isDirectory(candidate))) {
220
+ throw new Error(portalCreateText('errors.templateInvalidDirectory', { source }, `Portal template "${source}" is invalid: expected a directory.`));
221
+ }
222
+ return candidate;
223
+ }
224
+ export async function resolvePortalTemplate(source = DEFAULT_PORTAL_TEMPLATE, options = {}) {
225
+ const normalizedSource = trimValue(source) || DEFAULT_PORTAL_TEMPLATE;
226
+ const localTemplateDir = await resolveLocalTemplateDir(normalizedSource);
227
+ if (localTemplateDir) {
228
+ return {
229
+ dir: localTemplateDir,
230
+ source: normalizedSource,
231
+ type: 'local',
232
+ };
233
+ }
234
+ if (looksLikeLocalTemplateSource(normalizedSource)) {
235
+ throw new Error(portalCreateText('errors.localTemplateMissing', { source: normalizedSource }, `Portal template directory does not exist: ${normalizedSource}`));
236
+ }
237
+ try {
238
+ const require = createRequire(import.meta.url);
239
+ const packageJsonPath = require.resolve(path.join(normalizedSource, 'package.json'), {
240
+ paths: [process.cwd()],
241
+ });
242
+ return {
243
+ dir: path.dirname(packageJsonPath),
244
+ source: normalizedSource,
245
+ type: 'package',
246
+ };
247
+ }
248
+ catch (error) {
249
+ return await downloadNpmTemplatePackage({
250
+ source: normalizedSource,
251
+ npmRegistry: options.npmRegistry,
252
+ runCommand: options.runCommand ?? run,
253
+ });
254
+ }
255
+ }
256
+ export function validatePortalSlug(value) {
257
+ const portal = trimValue(value);
258
+ if (!/^[a-z0-9][a-z0-9_-]*$/.test(portal)) {
259
+ throw new Error(portalCreateText('errors.invalidPortalName', { value }, `Invalid portal name "${value}". Use lowercase letters, numbers, underscores, or hyphens, ` +
260
+ 'and start with a lowercase letter or number.'));
261
+ }
262
+ return portal;
263
+ }
264
+ async function syncMultiPortalRecord(params) {
265
+ const apiRequest = params.apiRequest ?? executeApiRequest;
266
+ const response = await apiRequest({
267
+ cliVersion: params.cliVersion ?? '',
268
+ envName: params.envName,
269
+ flags: {
270
+ filterKeys: ['portalName'],
271
+ body: JSON.stringify({
272
+ uid: params.portal,
273
+ title: params.title,
274
+ portalType: 'ai',
275
+ portalName: params.portal,
276
+ routePath: `/${params.portal}`,
277
+ authCheck: true,
278
+ enabled: true,
279
+ uiLayoutUid: 'admin-layout-model',
280
+ skipCreatePortalDirectory: true,
281
+ options: mergePortalConfigIntoOptions(params.config),
282
+ }),
283
+ },
284
+ operation: FIRST_OR_CREATE_PORTAL_OPERATION,
285
+ });
286
+ if (!response.ok) {
287
+ throw new Error(portalCreateText('errors.recordSyncFailed', { status: response.status, details: JSON.stringify(response.data, null, 2) }, `Portal record sync failed with status ${response.status}\n${JSON.stringify(response.data, null, 2)}`));
288
+ }
289
+ }
290
+ function validatePortalAppName(value) {
291
+ const app = trimValue(value);
292
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(app)) {
293
+ throw new Error(portalCreateText('errors.invalidPortalAppName', { value }, `Invalid portal app name "${value}" from apiBaseUrl. Use letters, numbers, underscores, or hyphens, ` +
294
+ 'and start with a letter or number.'));
295
+ }
296
+ return app;
297
+ }
298
+ export function titleFromPortalSlug(portal) {
299
+ return portal
300
+ .split(/[-_]+/)
301
+ .filter(Boolean)
302
+ .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
303
+ .join(' ');
304
+ }
305
+ export function resolvePortalAppFromApiBaseUrl(apiBaseUrl, appPublicPath) {
306
+ const normalizedApiBaseUrl = trimValue(apiBaseUrl);
307
+ if (!normalizedApiBaseUrl) {
308
+ throw new Error(portalCreateText('errors.missingApiBaseUrl', undefined, 'Cannot create a portal because the selected env has no apiBaseUrl.'));
309
+ }
310
+ const configuredPublicPath = trimValue(appPublicPath);
311
+ let inferredPublicPath = '/';
312
+ let app = DEFAULT_PORTAL_APP_NAME;
313
+ const pathname = resolveApiBaseUrlPathname(normalizedApiBaseUrl);
314
+ const subappMatch = pathname.match(/^(.*)\/api\/__app\/([^/]+)$/);
315
+ if (subappMatch) {
316
+ inferredPublicPath = ensureTrailingSlash(subappMatch[1] || '/');
317
+ app = validatePortalAppName(decodeAppSegment(subappMatch[2] ?? DEFAULT_PORTAL_APP_NAME) || DEFAULT_PORTAL_APP_NAME);
318
+ }
319
+ else {
320
+ const mainAppMatch = pathname.match(/^(.*)\/api$/);
321
+ if (mainAppMatch) {
322
+ inferredPublicPath = ensureTrailingSlash(mainAppMatch[1] || '/');
323
+ }
324
+ }
325
+ return {
326
+ app,
327
+ appPublicPath: resolveAppPublicPath(configuredPublicPath || inferredPublicPath),
328
+ };
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
+ }
378
+ export function buildPortalBasePath(params) {
379
+ const segment = params.app === DEFAULT_PORTAL_APP_NAME
380
+ ? `x/${params.portal}`
381
+ : `x/apps/${params.app}/${params.portal}`;
382
+ return appendAppPublicPath(params.appPublicPath, segment, { trailingSlash: true });
383
+ }
384
+ export function resolvePortalStoragePath(env) {
385
+ if (env.kind === 'ssh') {
386
+ throw new Error(portalCreateText('errors.sshUnsupported', undefined, 'Cannot create a portal for ssh envs in the first version.'));
387
+ }
388
+ if (env.kind === 'http' && !trimValue(env.config.storagePath)) {
389
+ const envName = trimValue(env.name);
390
+ if (envName) {
391
+ return path.join(resolveEnvRelativePath(envName), 'source', 'storage');
392
+ }
393
+ const envStoragePath = trimValue(process.env.STORAGE_PATH);
394
+ if (envStoragePath) {
395
+ return path.isAbsolute(envStoragePath) ? envStoragePath : path.resolve(process.cwd(), envStoragePath);
396
+ }
397
+ return path.resolve(process.cwd(), 'storage');
398
+ }
399
+ const storagePath = trimValue(env.storagePath);
400
+ if (storagePath) {
401
+ return storagePath;
402
+ }
403
+ const envStoragePath = trimValue(process.env.STORAGE_PATH);
404
+ if (envStoragePath) {
405
+ return path.isAbsolute(envStoragePath) ? envStoragePath : path.resolve(process.cwd(), envStoragePath);
406
+ }
407
+ return path.resolve(process.cwd(), 'storage');
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
+ }
422
+ export async function createPortalWorkspace(options) {
423
+ const portal = validatePortalSlug(options.portal);
424
+ const title = trimValue(options.title) || titleFromPortalSlug(portal);
425
+ const portalConfig = buildPortalConfig({
426
+ portal,
427
+ sourceStorage: options.sourceStorage,
428
+ gitRepo: options.gitRepo,
429
+ gitBranch: options.gitBranch,
430
+ gitPath: options.gitPath,
431
+ });
432
+ const apiBaseUrl = trimValue(options.env.apiBaseUrl);
433
+ const envApiUrl = resolvePortalEnvApiUrl(apiBaseUrl);
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);
438
+ assertPortalDirIsInsideParent(portalParentDir, portalDir);
439
+ const targetExists = await pathExists(portalDir);
440
+ if (targetExists && !options.force) {
441
+ throw new Error(portalCreateText('errors.workspaceExists', { portalDir }, `Portal already exists: ${portalDir}\nPass --force to delete it and create a new portal.`));
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
+ }
446
+ const template = await resolvePortalTemplate(options.template, {
447
+ npmRegistry: trimValue(options.env.config.npmRegistry),
448
+ runCommand: options.runCommand,
449
+ });
450
+ await mkdir(portalParentDir, { recursive: true });
451
+ const tempDir = await mkdtemp(safeTempPrefix(portalParentDir, portal));
452
+ let shouldCleanupTempDir = true;
453
+ try {
454
+ await copyTemplate(template.dir, tempDir);
455
+ await ensurePortalBuildHtmlReadsEnvOnly(tempDir);
456
+ await writeFile(path.join(tempDir, '.env'), [`NOCOBASE_API_URL=${envApiUrl}`, `NOCOBASE_PORTAL_BASE=${portalBase}`].join('\n') + '\n', 'utf-8');
457
+ await writeFile(path.join(tempDir, '.env.local'), [`NOCOBASE_API_URL=${apiBaseUrl}`, `NOCOBASE_PORTAL_BASE=${portalBase}`].join('\n') + '\n', 'utf-8');
458
+ if (targetExists) {
459
+ await rm(portalDir, { recursive: true, force: true });
460
+ }
461
+ await rename(tempDir, portalDir);
462
+ shouldCleanupTempDir = false;
463
+ const hasPackageJson = await pathExists(path.join(portalDir, 'package.json'));
464
+ let dependenciesInstalled = false;
465
+ let installFailed = false;
466
+ if (hasPackageJson) {
467
+ const runCommand = options.runCommand ?? run;
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
+ }
481
+ }
482
+ else {
483
+ options.onSkipInstall?.(portalCreateText('messages.skipInstall', { portalDir }, `Skipped pnpm install because package.json was not found in ${portalDir}.`));
484
+ }
485
+ if (options.apiRequest || options.cliVersion !== undefined || options.envName !== undefined) {
486
+ await syncMultiPortalRecord({
487
+ portal,
488
+ title,
489
+ config: portalConfig,
490
+ envName: options.envName,
491
+ cliVersion: options.cliVersion,
492
+ apiRequest: options.apiRequest,
493
+ });
494
+ }
495
+ return {
496
+ portalDir,
497
+ app,
498
+ portal,
499
+ title,
500
+ apiBaseUrl,
501
+ portalBase,
502
+ template,
503
+ installSkipped: !hasPackageJson,
504
+ dependenciesInstalled,
505
+ installFailed,
506
+ sourceStorage: portalConfig.sourceStorage,
507
+ };
508
+ }
509
+ finally {
510
+ if (shouldCleanupTempDir) {
511
+ await rm(tempDir, { recursive: true, force: true });
512
+ }
513
+ await template.cleanup?.();
514
+ }
515
+ }