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