@nocobase/cli 2.3.0-alpha.1 → 3.0.0-alpha.2

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