sitevision-cli 1.0.0-beta.9 → 1.0.0
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/CHANGELOG.md +138 -0
- package/LICENSE +21 -0
- package/dist/cli.js +194 -70
- package/dist/commands/TaskScreen.js +32 -0
- package/dist/commands/build.js +6 -185
- package/dist/commands/deploy.js +95 -212
- package/dist/commands/dev.js +87 -416
- package/dist/commands/info.js +1 -1
- package/dist/commands/setup-signing.js +50 -33
- package/dist/commands/sign.js +9 -86
- package/dist/commands/use-finish.js +17 -0
- package/dist/commands/watch.js +5 -31
- package/dist/components/AnimatedLogo.js +8 -2
- package/dist/components/AuthLoginScreen.js +33 -15
- package/dist/components/PasswordInput.js +2 -1
- package/dist/scaffold/inquirer-stub.js +119 -0
- package/dist/scaffold/register.js +15 -0
- package/dist/shell/AddonPicker.js +54 -0
- package/dist/shell/Changelog.js +44 -0
- package/dist/shell/CommandPalette.js +63 -0
- package/dist/shell/ConfigForm.js +703 -0
- package/dist/shell/Frame.js +138 -0
- package/dist/shell/Help.js +96 -0
- package/dist/shell/Popover.js +15 -0
- package/dist/shell/Settings.js +149 -0
- package/dist/shell/Shell.js +953 -0
- package/dist/shell/Tabs.js +96 -0
- package/dist/shell/actions.js +493 -0
- package/dist/utils/config.js +62 -2
- package/dist/utils/create-app.js +231 -0
- package/dist/utils/debug.js +32 -0
- package/dist/utils/environments.js +74 -0
- package/dist/utils/i18n.js +369 -0
- package/dist/utils/jsonc.js +2 -1
- package/dist/utils/keychain.js +54 -6
- package/dist/utils/oauth2-auth.js +146 -43
- package/dist/utils/password-prompt.js +37 -3
- package/dist/utils/process-runner.js +77 -38
- package/dist/utils/project-detection.js +457 -112
- package/dist/utils/session-cookie-auth.js +10 -6
- package/dist/utils/sitevision-api.js +134 -25
- package/dist/utils/sitevision-scripts-runner.js +50 -22
- package/dist/utils/tasks.js +510 -0
- package/dist/utils/version-check.js +3 -3
- package/dist/utils/webpack-runner.js +9 -5
- package/dist/utils/workspace.js +105 -0
- package/dist/utils/zip.js +18 -19
- package/package.json +27 -4
- package/readme.md +113 -141
- package/dist/app.d.ts +0 -6
- package/dist/app.js +0 -307
- package/dist/cli.d.ts +0 -2
- package/dist/commands/build.d.ts +0 -12
- package/dist/commands/deploy.d.ts +0 -16
- package/dist/commands/dev.d.ts +0 -16
- package/dist/commands/index.d.ts +0 -4
- package/dist/commands/info.d.ts +0 -2
- package/dist/commands/setup-signing.d.ts +0 -2
- package/dist/commands/sign.d.ts +0 -14
- package/dist/commands/types.d.ts +0 -18
- package/dist/commands/watch.d.ts +0 -2
- package/dist/components/AnimatedLogo.d.ts +0 -10
- package/dist/components/AuthLoginScreen.d.ts +0 -21
- package/dist/components/DevPropertiesForm.d.ts +0 -10
- package/dist/components/DevPropertiesForm.js +0 -210
- package/dist/components/InfoScreen.d.ts +0 -7
- package/dist/components/InfoScreen.js +0 -14
- package/dist/components/KeychainPasswordChoice.d.ts +0 -8
- package/dist/components/KeychainPasswordChoice.js +0 -30
- package/dist/components/MainMenu.d.ts +0 -7
- package/dist/components/MainMenu.js +0 -83
- package/dist/components/PasswordInput.d.ts +0 -10
- package/dist/components/ProcessOutput.d.ts +0 -6
- package/dist/components/ProcessOutput.js +0 -30
- package/dist/components/SetupFlow.d.ts +0 -8
- package/dist/components/SetupFlow.js +0 -233
- package/dist/components/SigningPropertiesForm.d.ts +0 -7
- package/dist/components/SigningPropertiesForm.js +0 -38
- package/dist/components/StatusIndicator.d.ts +0 -8
- package/dist/components/TextInput.d.ts +0 -10
- package/dist/components/TextInput.js +0 -29
- package/dist/components/WelcomeScreen.d.ts +0 -14
- package/dist/types/index.d.ts +0 -284
- package/dist/utils/branding.d.ts +0 -28
- package/dist/utils/config.d.ts +0 -17
- package/dist/utils/jsonc.d.ts +0 -19
- package/dist/utils/keychain.d.ts +0 -15
- package/dist/utils/oauth2-auth.d.ts +0 -27
- package/dist/utils/password-prompt.d.ts +0 -14
- package/dist/utils/process-runner.d.ts +0 -22
- package/dist/utils/project-detection.d.ts +0 -151
- package/dist/utils/session-cookie-auth.d.ts +0 -34
- package/dist/utils/signing-password.d.ts +0 -12
- package/dist/utils/signing-step.d.ts +0 -15
- package/dist/utils/signing-step.js +0 -16
- package/dist/utils/sitevision-api.d.ts +0 -112
- package/dist/utils/sitevision-scripts-runner.d.ts +0 -83
- package/dist/utils/version-check.d.ts +0 -9
- package/dist/utils/webpack-runner.d.ts +0 -76
- package/dist/utils/zip.d.ts +0 -71
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { getDeployPassword, setDeployPassword, getSessionCookie, } from './keychain.js';
|
|
4
|
-
import { parseJsonc } from './jsonc.js';
|
|
4
|
+
import { parseJsonc, stripJsonComments } from './jsonc.js';
|
|
5
|
+
import { getLanguage } from './i18n.js';
|
|
6
|
+
import { getGlobalSigning } from './config.js';
|
|
7
|
+
import { debug } from './debug.js';
|
|
5
8
|
// =============================================================================
|
|
6
9
|
// LOCALIZED TEXT
|
|
7
10
|
// =============================================================================
|
|
@@ -14,13 +17,27 @@ import { parseJsonc } from './jsonc.js';
|
|
|
14
17
|
* raw object as a React child, which Sitevision's localized manifests would
|
|
15
18
|
* otherwise trigger.
|
|
16
19
|
*/
|
|
17
|
-
export function localizedText(value, preferred =
|
|
20
|
+
export function localizedText(value, preferred = getLanguage()) {
|
|
18
21
|
if (!value)
|
|
19
22
|
return '';
|
|
20
23
|
if (typeof value === 'string')
|
|
21
24
|
return value;
|
|
22
25
|
return value[preferred] ?? value['en'] ?? Object.values(value)[0] ?? '';
|
|
23
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* The manifest's names when the addon name is none of them. The two are
|
|
29
|
+
* separate things (the addon is an object on the site, the name a label in the
|
|
30
|
+
* zip), so a difference is only ever pointed out, never "fixed".
|
|
31
|
+
*/
|
|
32
|
+
export function addonNameDrift(addonName, manifest) {
|
|
33
|
+
const same = (text) => text.trim().toLowerCase();
|
|
34
|
+
const names = (typeof manifest?.name === 'string'
|
|
35
|
+
? [manifest.name]
|
|
36
|
+
: Object.values(manifest?.name ?? {})).filter(name => name.trim() !== '');
|
|
37
|
+
if (!addonName?.trim() || names.length === 0)
|
|
38
|
+
return undefined;
|
|
39
|
+
return names.some(name => same(name) === same(addonName)) ? undefined : names;
|
|
40
|
+
}
|
|
24
41
|
// =============================================================================
|
|
25
42
|
// PATH UTILITIES
|
|
26
43
|
// =============================================================================
|
|
@@ -61,13 +78,111 @@ export function findDevPropertiesPath(root) {
|
|
|
61
78
|
export function getDefaultDevPropertiesPath(root) {
|
|
62
79
|
return path.join(root, '.dev_properties.json');
|
|
63
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Ancestor directories of `root` (outermost first) up to and including the
|
|
83
|
+
* nearest one containing `.git`, or the filesystem root. Each may carry a
|
|
84
|
+
* `.dev_properties.json` whose values the app inherits (nearest wins).
|
|
85
|
+
*/
|
|
86
|
+
function ancestorDirs(root) {
|
|
87
|
+
const dirs = [];
|
|
88
|
+
let dir = path.dirname(root);
|
|
89
|
+
while (true) {
|
|
90
|
+
dirs.unshift(dir);
|
|
91
|
+
if (fs.existsSync(path.join(dir, '.git')))
|
|
92
|
+
break;
|
|
93
|
+
const parent = path.dirname(dir);
|
|
94
|
+
if (parent === dir)
|
|
95
|
+
break;
|
|
96
|
+
dir = parent;
|
|
97
|
+
}
|
|
98
|
+
return dirs;
|
|
99
|
+
}
|
|
100
|
+
/** A bare host: no scheme, no path, no stray spaces. */
|
|
101
|
+
export function normalizeDomain(value) {
|
|
102
|
+
const host = value
|
|
103
|
+
.trim()
|
|
104
|
+
.replace(/^[a-z]+:\/\//i, '')
|
|
105
|
+
.replaceAll(/\s/g, '');
|
|
106
|
+
const slash = host.indexOf('/');
|
|
107
|
+
return slash === -1 ? host : host.slice(0, slash);
|
|
108
|
+
}
|
|
109
|
+
function readDevPropertiesFile(dir) {
|
|
110
|
+
const file = findDevPropertiesPath(dir);
|
|
111
|
+
if (!file)
|
|
112
|
+
return null;
|
|
113
|
+
try {
|
|
114
|
+
const dev = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
115
|
+
// ponytail: top level only; a hand-edited environment override keeps its
|
|
116
|
+
// scheme until it is saved from the config form.
|
|
117
|
+
dev.domain &&= normalizeDomain(dev.domain);
|
|
118
|
+
return dev;
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Merge config layers, later ones winning. `environments` merges per name, so a
|
|
126
|
+
* layer that overrides one field of an environment keeps the rest of it.
|
|
127
|
+
*/
|
|
128
|
+
export function mergeDevLayers(...layers) {
|
|
129
|
+
let merged = {};
|
|
130
|
+
for (const layer of layers) {
|
|
131
|
+
if (!layer)
|
|
132
|
+
continue;
|
|
133
|
+
const environments = { ...merged.environments };
|
|
134
|
+
for (const [name, override] of Object.entries(layer.environments ?? {})) {
|
|
135
|
+
environments[name] = { ...environments[name], ...override };
|
|
136
|
+
}
|
|
137
|
+
merged = { ...merged, ...layer };
|
|
138
|
+
if (Object.keys(environments).length > 0) {
|
|
139
|
+
merged.environments = environments;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return merged;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The dev properties a workspace root defines (its own file merged over any
|
|
146
|
+
* ancestors'), with the deploy password resolved from the keychain like an
|
|
147
|
+
* app's would be. Used to edit shared config from the shell.
|
|
148
|
+
*/
|
|
149
|
+
export function readWorkspaceDevProperties(root) {
|
|
150
|
+
const merged = mergeDevLayers(readInheritedDevProperties(root), readDevPropertiesFile(root));
|
|
151
|
+
if (merged.domain && merged.username && !merged.password) {
|
|
152
|
+
merged.password =
|
|
153
|
+
process.env['SITEVISION_DEPLOY_PASSWORD'] ??
|
|
154
|
+
getDeployPassword(merged.domain, merged.username) ??
|
|
155
|
+
undefined;
|
|
156
|
+
}
|
|
157
|
+
return merged;
|
|
158
|
+
}
|
|
159
|
+
/** Ancestor directories' .dev_properties.json merged, nearest wins (no own file). */
|
|
160
|
+
export function readAncestorDevProperties(root) {
|
|
161
|
+
return mergeDevLayers(...ancestorDirs(root).map(dir => readDevPropertiesFile(dir)));
|
|
162
|
+
}
|
|
163
|
+
/** Shared defaults from package.json: the workspace root's first, the app's on top. */
|
|
164
|
+
export function readPackageDefaultsChain(root) {
|
|
165
|
+
return mergeDevLayers(...[...ancestorDirs(root), root].map(dir => readPackageDefaults(dir)));
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Everything an app's own .dev_properties.json sits on top of: package.json
|
|
169
|
+
* defaults (root, then app), then ancestor .dev_properties.json files.
|
|
170
|
+
*/
|
|
171
|
+
export function readInheritedDevProperties(root) {
|
|
172
|
+
return mergeDevLayers(readPackageDefaultsChain(root), readAncestorDevProperties(root));
|
|
173
|
+
}
|
|
64
174
|
/**
|
|
65
175
|
* Get app ID configuration from environment or defaults
|
|
66
176
|
*/
|
|
67
|
-
function getAppIdConfig() {
|
|
177
|
+
export function getAppIdConfig() {
|
|
68
178
|
return {
|
|
69
|
-
|
|
70
|
-
|
|
179
|
+
// svc's own names first, then the ones sitevision-scripts reads.
|
|
180
|
+
prefix: process.env['SITEVISION_APP_ID_PREFIX'] ||
|
|
181
|
+
process.env['APP_ID_PREFIX'] ||
|
|
182
|
+
'',
|
|
183
|
+
suffix: process.env['SITEVISION_APP_ID_SUFFIX'] ||
|
|
184
|
+
process.env['APP_ID_SUFFIX'] ||
|
|
185
|
+
'',
|
|
71
186
|
};
|
|
72
187
|
}
|
|
73
188
|
/**
|
|
@@ -104,6 +219,20 @@ export function getZipPath(projectRoot, manifest) {
|
|
|
104
219
|
export function getSignedZipPath(projectRoot, manifest) {
|
|
105
220
|
return path.join(projectRoot, 'dist', getSignedZipFilename(manifest));
|
|
106
221
|
}
|
|
222
|
+
/**
|
|
223
|
+
* The zip a non-production deploy uploads: the signed one when it is at least
|
|
224
|
+
* as new as the build, since some sites reject unsigned apps even on dev.
|
|
225
|
+
*/
|
|
226
|
+
export function getDeployZipPath(projectRoot, manifest) {
|
|
227
|
+
const zip = getZipPath(projectRoot, manifest);
|
|
228
|
+
const signed = getSignedZipPath(projectRoot, manifest);
|
|
229
|
+
const mtime = (file) => fs.statSync(file, { throwIfNoEntry: false })?.mtimeMs;
|
|
230
|
+
const signedAt = mtime(signed);
|
|
231
|
+
if (signedAt === undefined)
|
|
232
|
+
return zip;
|
|
233
|
+
const builtAt = mtime(zip);
|
|
234
|
+
return builtAt === undefined || signedAt >= builtAt ? signed : zip;
|
|
235
|
+
}
|
|
107
236
|
// =============================================================================
|
|
108
237
|
// API ENDPOINT UTILITIES
|
|
109
238
|
// =============================================================================
|
|
@@ -127,6 +256,11 @@ export function getApiEndpoints(appType) {
|
|
|
127
256
|
addon: 'headlesscustommodule',
|
|
128
257
|
import: 'restAppImport',
|
|
129
258
|
};
|
|
259
|
+
case 'mcp':
|
|
260
|
+
return {
|
|
261
|
+
addon: 'mcpServerCustomModule',
|
|
262
|
+
import: 'mcpServerImport',
|
|
263
|
+
};
|
|
130
264
|
}
|
|
131
265
|
}
|
|
132
266
|
/**
|
|
@@ -163,7 +297,7 @@ export function buildImportEndpointUrl(domain, siteName, addonName, appType, use
|
|
|
163
297
|
export class ManifestParseError extends Error {
|
|
164
298
|
constructor(manifestPath, cause) {
|
|
165
299
|
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
166
|
-
super(`${manifestPath}
|
|
300
|
+
super(`${manifestPath} ${reason}`);
|
|
167
301
|
this.name = 'ManifestParseError';
|
|
168
302
|
}
|
|
169
303
|
}
|
|
@@ -183,18 +317,66 @@ export function readManifest(cwd) {
|
|
|
183
317
|
}
|
|
184
318
|
// Manifests may contain comments (Sitevision's own docs show them), so
|
|
185
319
|
// parse as JSONC.
|
|
320
|
+
let manifest;
|
|
186
321
|
try {
|
|
187
|
-
|
|
188
|
-
manifestPath,
|
|
189
|
-
manifest: parseJsonc(fs.readFileSync(manifestPath, 'utf-8')),
|
|
190
|
-
};
|
|
322
|
+
manifest = parseJsonc(fs.readFileSync(manifestPath, 'utf-8'));
|
|
191
323
|
}
|
|
192
324
|
catch (error) {
|
|
193
|
-
throw new ManifestParseError(manifestPath, error);
|
|
325
|
+
throw new ManifestParseError(manifestPath, `is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
326
|
+
}
|
|
327
|
+
if (typeof manifest !== 'object' || manifest === null) {
|
|
328
|
+
throw new ManifestParseError(manifestPath, 'must be a JSON object');
|
|
194
329
|
}
|
|
330
|
+
// Zip names, endpoints and the build all derive from these.
|
|
331
|
+
for (const key of ['id', 'version', 'type']) {
|
|
332
|
+
if (typeof manifest[key] !== 'string' || manifest[key] === '') {
|
|
333
|
+
throw new ManifestParseError(manifestPath, `is missing "${key}"`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return { manifestPath, manifest };
|
|
195
337
|
}
|
|
196
338
|
return null;
|
|
197
339
|
}
|
|
340
|
+
const JSON_STRING = String.raw `"(?:[^"\\]|\\.)*"`;
|
|
341
|
+
const escapeRegExp = (s) => s.replaceAll(/[$()*+.?[\\\]^{|}]/g, String.raw `\$&`);
|
|
342
|
+
/**
|
|
343
|
+
* Set one text field of manifest.json, or one language of a localized field.
|
|
344
|
+
* An empty value removes the key. A value that is already a string is replaced
|
|
345
|
+
* in the text, so comments and formatting survive; anything else rewrites the
|
|
346
|
+
* file, which is refused when that would drop comments.
|
|
347
|
+
*/
|
|
348
|
+
export function writeManifestField(manifestPath, key, value, lang) {
|
|
349
|
+
debug('config', `write ${manifestPath} ${key}`);
|
|
350
|
+
const raw = fs.readFileSync(manifestPath, 'utf-8');
|
|
351
|
+
const wanted = parseJsonc(raw);
|
|
352
|
+
const existing = wanted[key];
|
|
353
|
+
if (lang && typeof existing !== 'object') {
|
|
354
|
+
// A plain string becomes the English text of a multilingual value.
|
|
355
|
+
wanted[key] = existing ? { en: existing } : {};
|
|
356
|
+
}
|
|
357
|
+
const holder = lang ? wanted[key] : wanted;
|
|
358
|
+
const leaf = lang ?? key;
|
|
359
|
+
// JSON.stringify drops an undefined key.
|
|
360
|
+
holder[leaf] = value || undefined;
|
|
361
|
+
const prefix = lang
|
|
362
|
+
? String.raw `"${escapeRegExp(key)}"\s*:\s*\{[^}]*?"${escapeRegExp(lang)}"\s*:\s*`
|
|
363
|
+
: String.raw `"${escapeRegExp(key)}"\s*:\s*`;
|
|
364
|
+
const patched = raw.replace(new RegExp(`(${prefix})${JSON_STRING}`), (_, before) => before + JSON.stringify(value));
|
|
365
|
+
let patchedOk = false;
|
|
366
|
+
try {
|
|
367
|
+
patchedOk = JSON.stringify(parseJsonc(patched)) === JSON.stringify(wanted);
|
|
368
|
+
}
|
|
369
|
+
catch { }
|
|
370
|
+
if (patchedOk) {
|
|
371
|
+
fs.writeFileSync(manifestPath, patched);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (stripJsonComments(raw) !== raw) {
|
|
375
|
+
throw new Error(`${manifestPath} has comments; add or remove "${leaf}" by hand`);
|
|
376
|
+
}
|
|
377
|
+
const indent = /^(?<indent>[\t ]+)/m.exec(raw)?.groups?.['indent'] ?? '\t';
|
|
378
|
+
fs.writeFileSync(manifestPath, JSON.stringify(wanted, null, indent) + (raw.endsWith('\n') ? '\n' : ''));
|
|
379
|
+
}
|
|
198
380
|
/**
|
|
199
381
|
* Detect if the current directory is a Sitevision project
|
|
200
382
|
*/
|
|
@@ -217,52 +399,25 @@ export function detectProject(cwd = process.cwd()) {
|
|
|
217
399
|
// Check for node_modules
|
|
218
400
|
const nodeModulesPath = path.join(cwd, 'node_modules');
|
|
219
401
|
const hasNodeModules = fs.existsSync(nodeModulesPath);
|
|
220
|
-
//
|
|
402
|
+
// Dev properties: ancestor files (workspace root) merged under the app's
|
|
403
|
+
// own file, so shared site/auth config lives once at the repo root.
|
|
221
404
|
const devPropertiesPath = findDevPropertiesPath(cwd);
|
|
405
|
+
const inherited = readInheritedDevProperties(cwd);
|
|
406
|
+
const own = devPropertiesPath ? readDevPropertiesFile(cwd) : null;
|
|
407
|
+
const inheritedKeys = Object.keys(inherited).filter(key => !own || !Object.hasOwn(own, key));
|
|
222
408
|
let devProperties;
|
|
223
|
-
|
|
409
|
+
const hasDevProperties = Boolean(own) || inheritedKeys.length > 0;
|
|
224
410
|
let hasLegacyPassword = false;
|
|
225
|
-
if (
|
|
226
|
-
hasDevProperties = true;
|
|
411
|
+
if (hasDevProperties) {
|
|
227
412
|
try {
|
|
228
|
-
|
|
413
|
+
// ponytail: the global signing identity only reaches a project that
|
|
414
|
+
// has some config of its own, so "configured?" checks stay truthful.
|
|
415
|
+
const parsed = mergeDevLayers(getGlobalSigning(), inherited, own);
|
|
229
416
|
hasLegacyPassword =
|
|
230
417
|
typeof parsed.password === 'string' && parsed.password.length > 0;
|
|
231
418
|
devProperties = parsed;
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
devProperties.domain &&
|
|
235
|
-
devProperties.username) {
|
|
236
|
-
const envPassword = process.env['SITEVISION_DEPLOY_PASSWORD'];
|
|
237
|
-
if (envPassword) {
|
|
238
|
-
devProperties.password = envPassword;
|
|
239
|
-
}
|
|
240
|
-
else {
|
|
241
|
-
const stored = getDeployPassword(devProperties.domain, devProperties.username);
|
|
242
|
-
if (stored) {
|
|
243
|
-
devProperties.password = stored;
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
// Resolve an OAuth2 access token: env var > keychain refresh.
|
|
248
|
-
// The env var is the manual/CI path; the interactive login stores a
|
|
249
|
-
// refresh token in the keychain and mints access tokens from it.
|
|
250
|
-
if (devProperties.authMethod === 'oauth2') {
|
|
251
|
-
const envToken = process.env['SITEVISION_ACCESS_TOKEN'];
|
|
252
|
-
if (envToken) {
|
|
253
|
-
devProperties.accessToken = envToken;
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
// Resolve a session cookie: env var > keychain (captured at login).
|
|
257
|
-
if (devProperties.authMethod === 'cookie' &&
|
|
258
|
-
devProperties.domain &&
|
|
259
|
-
devProperties.username) {
|
|
260
|
-
const envCookie = process.env['SITEVISION_SESSION_COOKIE'];
|
|
261
|
-
devProperties.sessionCookie =
|
|
262
|
-
envCookie ??
|
|
263
|
-
getSessionCookie(devProperties.domain, devProperties.username) ??
|
|
264
|
-
undefined;
|
|
265
|
-
}
|
|
419
|
+
if (!hasLegacyPassword)
|
|
420
|
+
resolveRuntimeSecrets(devProperties);
|
|
266
421
|
}
|
|
267
422
|
catch {
|
|
268
423
|
// Invalid dev properties file
|
|
@@ -279,6 +434,7 @@ export function detectProject(cwd = process.cwd()) {
|
|
|
279
434
|
hasSigningProperties,
|
|
280
435
|
hasLegacyPassword,
|
|
281
436
|
devProperties,
|
|
437
|
+
inheritedKeys,
|
|
282
438
|
packageJson,
|
|
283
439
|
hasSitevisionScripts,
|
|
284
440
|
hasNodeModules,
|
|
@@ -294,6 +450,29 @@ export function detectProject(cwd = process.cwd()) {
|
|
|
294
450
|
return null;
|
|
295
451
|
}
|
|
296
452
|
}
|
|
453
|
+
/**
|
|
454
|
+
* Fill the runtime-only credential fields for the given domain/username:
|
|
455
|
+
* deploy password (env var > keychain), OAuth2 access token (env var), and
|
|
456
|
+
* session cookie (env var > keychain). Mutates and returns `dev`.
|
|
457
|
+
*/
|
|
458
|
+
export function resolveRuntimeSecrets(dev) {
|
|
459
|
+
if (dev.domain && dev.username) {
|
|
460
|
+
dev.password =
|
|
461
|
+
process.env['SITEVISION_DEPLOY_PASSWORD'] ??
|
|
462
|
+
getDeployPassword(dev.domain, dev.username) ??
|
|
463
|
+
undefined;
|
|
464
|
+
}
|
|
465
|
+
if (dev.authMethod === 'oauth2') {
|
|
466
|
+
dev.accessToken = process.env['SITEVISION_ACCESS_TOKEN'] ?? undefined;
|
|
467
|
+
}
|
|
468
|
+
if (dev.authMethod === 'cookie' && dev.domain) {
|
|
469
|
+
dev.sessionCookie =
|
|
470
|
+
process.env['SITEVISION_SESSION_COOKIE'] ??
|
|
471
|
+
getSessionCookie(dev.domain, dev.username) ??
|
|
472
|
+
undefined;
|
|
473
|
+
}
|
|
474
|
+
return dev;
|
|
475
|
+
}
|
|
297
476
|
/**
|
|
298
477
|
* Validate that we're in a Sitevision project directory
|
|
299
478
|
*/
|
|
@@ -305,20 +484,31 @@ export function requireProject(cwd) {
|
|
|
305
484
|
return project;
|
|
306
485
|
}
|
|
307
486
|
/**
|
|
308
|
-
*
|
|
487
|
+
* The app type (web, widget, rest, mcp), or undefined for a manifest type
|
|
488
|
+
* this CLI does not know. Display code uses this so one odd app never takes
|
|
489
|
+
* the whole shell down.
|
|
309
490
|
*/
|
|
310
|
-
export function
|
|
311
|
-
const type = manifest.type.toLowerCase();
|
|
312
|
-
if (type.startsWith('web'))
|
|
491
|
+
export function appTypeOf(manifest) {
|
|
492
|
+
const type = String(manifest.type ?? '').toLowerCase();
|
|
493
|
+
if (type.startsWith('web'))
|
|
313
494
|
return 'web';
|
|
314
|
-
|
|
315
|
-
if (type.startsWith('widget')) {
|
|
495
|
+
if (type.startsWith('widget'))
|
|
316
496
|
return 'widget';
|
|
317
|
-
|
|
318
|
-
if (type.startsWith('rest')) {
|
|
497
|
+
if (type.startsWith('rest'))
|
|
319
498
|
return 'rest';
|
|
320
|
-
|
|
321
|
-
|
|
499
|
+
if (type.startsWith('mcp'))
|
|
500
|
+
return 'mcp';
|
|
501
|
+
return undefined;
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Get the app type (web, widget, rest, mcp). Throws for unknown types, since
|
|
505
|
+
* build and deploy cannot proceed without knowing the endpoints.
|
|
506
|
+
*/
|
|
507
|
+
export function getAppType(manifest) {
|
|
508
|
+
const type = appTypeOf(manifest);
|
|
509
|
+
if (!type)
|
|
510
|
+
throw new Error(`Unknown app type: ${manifest.type}`);
|
|
511
|
+
return type;
|
|
322
512
|
}
|
|
323
513
|
/**
|
|
324
514
|
* Check if the app uses webpack bundling
|
|
@@ -346,11 +536,32 @@ export function readDevProperties(projectRoot) {
|
|
|
346
536
|
* `accessToken` and `sessionCookie` are held in the OS keychain / resolved at
|
|
347
537
|
* runtime instead.
|
|
348
538
|
*/
|
|
349
|
-
export function writeDevProperties(projectRoot, properties
|
|
539
|
+
export function writeDevProperties(projectRoot, properties,
|
|
540
|
+
// App mode: write every value, so plain sitevision-scripts finds them all.
|
|
541
|
+
{ complete = false } = {}) {
|
|
350
542
|
const devPropertiesPath = findDevPropertiesPath(projectRoot) ||
|
|
351
543
|
getDefaultDevPropertiesPath(projectRoot);
|
|
352
|
-
|
|
353
|
-
|
|
544
|
+
debug('config', `write ${devPropertiesPath} (${Object.keys(properties).join(', ')})`);
|
|
545
|
+
const { password: _password, accessToken: _accessToken, sessionCookie: _sessionCookie, environmentName: _environmentName, productionEnvironment: _productionEnvironment, ...persisted } = properties;
|
|
546
|
+
// Keep the app file minimal: values identical to the inherited ones stay
|
|
547
|
+
// at the workspace root instead of being copied into every app. An empty
|
|
548
|
+
// string means "unset", so it is dropped rather than written as an override
|
|
549
|
+
// that would shadow the inherited value.
|
|
550
|
+
const inherited = complete
|
|
551
|
+
? {}
|
|
552
|
+
: mergeDevLayers(getGlobalSigning(), readInheritedDevProperties(projectRoot));
|
|
553
|
+
const own = Object.fromEntries(Object.entries(persisted).filter(([key, value]) => value !== '' &&
|
|
554
|
+
(!Object.hasOwn(inherited, key) ||
|
|
555
|
+
JSON.stringify(inherited[key]) !== JSON.stringify(value))));
|
|
556
|
+
// A plaintext password already in the file (sitevision-scripts puts it
|
|
557
|
+
// there) moves to the keychain; if that fails it stays rather than be lost.
|
|
558
|
+
const legacy = readDevPropertiesFile(projectRoot)?.password;
|
|
559
|
+
if (legacy) {
|
|
560
|
+
const { domain, username } = { ...inherited, ...persisted };
|
|
561
|
+
if (!setDeployPassword(domain, username, legacy))
|
|
562
|
+
own['password'] = legacy;
|
|
563
|
+
}
|
|
564
|
+
fs.writeFileSync(devPropertiesPath, JSON.stringify(own, null, 2));
|
|
354
565
|
}
|
|
355
566
|
export function readSvcConfig(projectRoot) {
|
|
356
567
|
try {
|
|
@@ -361,6 +572,7 @@ export function readSvcConfig(projectRoot) {
|
|
|
361
572
|
}
|
|
362
573
|
}
|
|
363
574
|
export function writeSvcConfig(projectRoot, updates) {
|
|
575
|
+
debug('config', `write .svcconfig ${JSON.stringify(updates)}`);
|
|
364
576
|
const merged = { ...readSvcConfig(projectRoot), ...updates };
|
|
365
577
|
fs.writeFileSync(path.join(projectRoot, '.svcconfig'), JSON.stringify(merged, null, 2) + '\n');
|
|
366
578
|
}
|
|
@@ -368,13 +580,24 @@ export function writeSvcConfig(projectRoot, updates) {
|
|
|
368
580
|
// PACKAGE.JSON SYNC
|
|
369
581
|
// =============================================================================
|
|
370
582
|
/**
|
|
371
|
-
*
|
|
372
|
-
*
|
|
583
|
+
* Values tied to the person running svc. They stay in .dev_properties.json and
|
|
584
|
+
* never go into package.json.
|
|
373
585
|
*/
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
586
|
+
export const USER_KEYS = ['username', 'signingUsername', 'certificateName'];
|
|
587
|
+
// Shared values package.json can hold: three top-level fields, the rest under "svc".
|
|
588
|
+
const PACKAGE_TOP_KEYS = {
|
|
589
|
+
domain: 'developmentDomain',
|
|
590
|
+
siteName: 'siteName',
|
|
591
|
+
addonName: 'addonName',
|
|
592
|
+
};
|
|
593
|
+
const PACKAGE_SVC_KEYS = [
|
|
594
|
+
'authMethod',
|
|
595
|
+
'oauth2',
|
|
596
|
+
'sessionLoginUrl',
|
|
597
|
+
'useHTTPForDevDeploy',
|
|
598
|
+
'baseEnvironment',
|
|
599
|
+
'production',
|
|
600
|
+
'environments',
|
|
378
601
|
];
|
|
379
602
|
function readPackageJson(projectRoot) {
|
|
380
603
|
try {
|
|
@@ -384,60 +607,166 @@ function readPackageJson(projectRoot) {
|
|
|
384
607
|
return null;
|
|
385
608
|
}
|
|
386
609
|
}
|
|
610
|
+
/** The shared defaults one directory's package.json provides. */
|
|
611
|
+
export function readPackageDefaults(dir) {
|
|
612
|
+
const packageJson = readPackageJson(dir);
|
|
613
|
+
if (!packageJson)
|
|
614
|
+
return {};
|
|
615
|
+
const defaults = {};
|
|
616
|
+
for (const [devKey, packageKey] of Object.entries(PACKAGE_TOP_KEYS)) {
|
|
617
|
+
const value = packageJson[packageKey];
|
|
618
|
+
if (typeof value === 'string' && value !== '')
|
|
619
|
+
defaults[devKey] = value;
|
|
620
|
+
}
|
|
621
|
+
const svc = packageJson['svc'];
|
|
622
|
+
for (const key of PACKAGE_SVC_KEYS) {
|
|
623
|
+
if (svc?.[key] !== undefined)
|
|
624
|
+
defaults[key] = svc[key];
|
|
625
|
+
}
|
|
626
|
+
if (typeof defaults['domain'] === 'string') {
|
|
627
|
+
defaults['domain'] = normalizeDomain(defaults['domain']);
|
|
628
|
+
}
|
|
629
|
+
return defaults;
|
|
630
|
+
}
|
|
387
631
|
/**
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
* `npm install` in the same session may have rewritten it.
|
|
632
|
+
* Shared values in this directory's own .dev_properties.json that package.json
|
|
633
|
+
* does not already provide, here or further up. User values never qualify.
|
|
391
634
|
*/
|
|
392
|
-
|
|
393
|
-
const
|
|
394
|
-
if (!
|
|
635
|
+
function pendingSync(dir) {
|
|
636
|
+
const own = readDevPropertiesFile(dir);
|
|
637
|
+
if (!own)
|
|
395
638
|
return [];
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
: { key: packageKey, from, to });
|
|
639
|
+
const defaults = readPackageDefaultsChain(dir);
|
|
640
|
+
const pending = [];
|
|
641
|
+
for (const key of [...Object.keys(PACKAGE_TOP_KEYS), ...PACKAGE_SVC_KEYS]) {
|
|
642
|
+
let value = own[key];
|
|
643
|
+
if (key === 'environments' && value) {
|
|
644
|
+
value = Object.fromEntries(Object.entries(value).map(([name, override]) => [
|
|
645
|
+
name,
|
|
646
|
+
Object.fromEntries(Object.entries(override).filter(([field]) => !USER_KEYS.includes(field))),
|
|
647
|
+
]));
|
|
406
648
|
}
|
|
649
|
+
if (value === undefined || value === '')
|
|
650
|
+
continue;
|
|
651
|
+
if (JSON.stringify(value) ===
|
|
652
|
+
JSON.stringify(defaults[key] ?? IMPLICIT_VALUES[key]))
|
|
653
|
+
continue;
|
|
654
|
+
pending.push({ key, value });
|
|
407
655
|
}
|
|
408
|
-
return
|
|
656
|
+
return pending;
|
|
409
657
|
}
|
|
658
|
+
// Manifest fields package.json mirrors under the same name; the manifest wins.
|
|
659
|
+
const MANIFEST_PACKAGE_KEYS = ['version', 'description', 'author'];
|
|
410
660
|
/**
|
|
411
|
-
*
|
|
412
|
-
* file
|
|
661
|
+
* Manifest values the app's own package.json does not mirror yet. Nothing when
|
|
662
|
+
* either file is missing, so syncing never creates a package.json for this.
|
|
413
663
|
*/
|
|
414
|
-
|
|
415
|
-
const
|
|
416
|
-
|
|
664
|
+
function pendingManifestSync(dir) {
|
|
665
|
+
const packageJson = readPackageJson(dir);
|
|
666
|
+
if (!packageJson)
|
|
667
|
+
return [];
|
|
668
|
+
let manifest;
|
|
417
669
|
try {
|
|
418
|
-
|
|
419
|
-
}
|
|
420
|
-
catch {
|
|
421
|
-
return false;
|
|
670
|
+
manifest = readManifest(dir)?.manifest;
|
|
422
671
|
}
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
672
|
+
catch { }
|
|
673
|
+
if (!manifest)
|
|
674
|
+
return [];
|
|
675
|
+
const pending = [];
|
|
676
|
+
for (const key of MANIFEST_PACKAGE_KEYS) {
|
|
677
|
+
const value = key === 'description'
|
|
678
|
+
? localizedText(manifest.description, 'en')
|
|
679
|
+
: manifest[key];
|
|
680
|
+
const current = packageJson[key];
|
|
681
|
+
// ponytail: an author object ({name, email}) is left alone.
|
|
682
|
+
if (!value || typeof value !== 'string' || value === current)
|
|
683
|
+
continue;
|
|
684
|
+
if (current !== undefined && typeof current !== 'string')
|
|
685
|
+
continue;
|
|
686
|
+
pending.push({ key, from: current, value });
|
|
426
687
|
}
|
|
427
|
-
|
|
688
|
+
return pending;
|
|
689
|
+
}
|
|
690
|
+
/** What an unset field means, so spelling out the default is not a change. */
|
|
691
|
+
export const IMPLICIT_VALUES = {
|
|
692
|
+
authMethod: 'basic',
|
|
693
|
+
useHTTPForDevDeploy: false,
|
|
694
|
+
production: false,
|
|
695
|
+
};
|
|
696
|
+
const packageLabel = (key) => PACKAGE_TOP_KEYS[key] ?? `svc.${key}`;
|
|
697
|
+
const display = (value) => typeof value === 'string' ? value : JSON.stringify(value);
|
|
698
|
+
/** What syncing this directory would change in its package.json. */
|
|
699
|
+
export function getPackageJsonSyncChanges(dir) {
|
|
700
|
+
const current = readPackageDefaults(dir);
|
|
701
|
+
return [
|
|
702
|
+
...pendingSync(dir).map(({ key, value }) => current[key] === undefined
|
|
703
|
+
? { key: packageLabel(key), to: display(value) }
|
|
704
|
+
: {
|
|
705
|
+
key: packageLabel(key),
|
|
706
|
+
from: display(current[key]),
|
|
707
|
+
to: display(value),
|
|
708
|
+
}),
|
|
709
|
+
...pendingManifestSync(dir).map(({ key, from, value }) => ({
|
|
710
|
+
key,
|
|
711
|
+
...(from !== undefined && { from }),
|
|
712
|
+
to: value,
|
|
713
|
+
})),
|
|
714
|
+
];
|
|
715
|
+
}
|
|
716
|
+
export function hasPackageJson(dir) {
|
|
717
|
+
return fs.existsSync(path.join(dir, 'package.json'));
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Copy the pending shared values from .dev_properties.json, and the manifest
|
|
721
|
+
* fields package.json mirrors, into package.json, creating package.json when
|
|
722
|
+
* the directory has none. Throws if it cannot be
|
|
723
|
+
* read or written.
|
|
724
|
+
*/
|
|
725
|
+
export function syncDevPropertiesToPackageJson(dir) {
|
|
726
|
+
const pending = pendingSync(dir);
|
|
727
|
+
const mirrored = pendingManifestSync(dir);
|
|
728
|
+
if (pending.length + mirrored.length === 0 && hasPackageJson(dir))
|
|
428
729
|
return false;
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
730
|
+
updatePackageJson(dir, packageJson => {
|
|
731
|
+
for (const { key, value } of mirrored)
|
|
732
|
+
packageJson[key] = value;
|
|
733
|
+
for (const { key, value } of pending) {
|
|
734
|
+
const topKey = PACKAGE_TOP_KEYS[key];
|
|
735
|
+
if (topKey) {
|
|
736
|
+
packageJson[topKey] = value;
|
|
737
|
+
}
|
|
738
|
+
else {
|
|
739
|
+
packageJson['svc'] = {
|
|
740
|
+
...packageJson['svc'],
|
|
741
|
+
[key]: value,
|
|
742
|
+
};
|
|
743
|
+
}
|
|
434
744
|
}
|
|
435
|
-
}
|
|
436
|
-
const indent = /^(?<indent>[\t ]+)/m.exec(raw)?.groups?.['indent'] ?? '\t';
|
|
437
|
-
const newline = raw.endsWith('\n') ? '\n' : '';
|
|
438
|
-
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, indent) + newline);
|
|
745
|
+
});
|
|
439
746
|
return true;
|
|
440
747
|
}
|
|
748
|
+
/**
|
|
749
|
+
* Edit package.json in place, keeping its indentation and trailing newline. A
|
|
750
|
+
* missing file is created; an unreadable or invalid one throws, so the caller
|
|
751
|
+
* can warn instead of dropping the change silently.
|
|
752
|
+
*/
|
|
753
|
+
export function updatePackageJson(dir, mutate) {
|
|
754
|
+
const packageJsonPath = path.join(dir, 'package.json');
|
|
755
|
+
// A new file is private, so a workspace root is never published by accident.
|
|
756
|
+
let raw = '{\n\t"private": true\n}\n';
|
|
757
|
+
try {
|
|
758
|
+
if (hasPackageJson(dir))
|
|
759
|
+
raw = fs.readFileSync(packageJsonPath, 'utf-8');
|
|
760
|
+
const packageJson = JSON.parse(raw);
|
|
761
|
+
mutate(packageJson);
|
|
762
|
+
const indent = /^(?<indent>[\t ]+)/m.exec(raw)?.groups?.['indent'] ?? '\t';
|
|
763
|
+
const newline = raw.endsWith('\n') ? '\n' : '';
|
|
764
|
+
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, indent) + newline);
|
|
765
|
+
}
|
|
766
|
+
catch (error) {
|
|
767
|
+
throw new Error(`Could not update ${packageJsonPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
441
770
|
/**
|
|
442
771
|
* Move a plaintext password from .dev_properties.json into the OS keychain and
|
|
443
772
|
* strip it from the file. Returns true if the password was migrated.
|
|
@@ -453,10 +782,26 @@ export function migrateLegacyPassword(project) {
|
|
|
453
782
|
return false;
|
|
454
783
|
if (!setDeployPassword(domain, username, password))
|
|
455
784
|
return false;
|
|
456
|
-
//
|
|
457
|
-
|
|
458
|
-
project.
|
|
459
|
-
|
|
785
|
+
// Remove only the password, from whichever file holds it; every other value
|
|
786
|
+
// stays as written.
|
|
787
|
+
for (const dir of [project.root, ...ancestorDirs(project.root)]) {
|
|
788
|
+
const file = findDevPropertiesPath(dir);
|
|
789
|
+
if (!file)
|
|
790
|
+
continue;
|
|
791
|
+
try {
|
|
792
|
+
const { password: stored, ...rest } = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
793
|
+
if (stored !== undefined) {
|
|
794
|
+
fs.writeFileSync(file, JSON.stringify(rest, null, 2));
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
catch {
|
|
798
|
+
// Unreadable file: nothing to migrate there. Unwritable: checked below.
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
// Moved only if no file still holds it.
|
|
802
|
+
const left = [project.root, ...ancestorDirs(project.root)].some(dir => readDevPropertiesFile(dir)?.password);
|
|
803
|
+
project.hasLegacyPassword = left;
|
|
804
|
+
return !left;
|
|
460
805
|
}
|
|
461
806
|
/**
|
|
462
807
|
* Ensure the dist directory exists
|