sitevision-cli 1.0.0-beta.8 → 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.
Files changed (100) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/LICENSE +21 -0
  3. package/dist/cli.js +194 -70
  4. package/dist/commands/TaskScreen.js +32 -0
  5. package/dist/commands/build.js +6 -185
  6. package/dist/commands/deploy.js +95 -212
  7. package/dist/commands/dev.js +87 -416
  8. package/dist/commands/info.js +1 -1
  9. package/dist/commands/setup-signing.js +50 -33
  10. package/dist/commands/sign.js +9 -86
  11. package/dist/commands/use-finish.js +17 -0
  12. package/dist/commands/watch.js +5 -31
  13. package/dist/components/AnimatedLogo.js +8 -2
  14. package/dist/components/AuthLoginScreen.js +33 -15
  15. package/dist/components/PasswordInput.js +2 -1
  16. package/dist/scaffold/inquirer-stub.js +119 -0
  17. package/dist/scaffold/register.js +15 -0
  18. package/dist/shell/AddonPicker.js +54 -0
  19. package/dist/shell/Changelog.js +44 -0
  20. package/dist/shell/CommandPalette.js +63 -0
  21. package/dist/shell/ConfigForm.js +703 -0
  22. package/dist/shell/Frame.js +138 -0
  23. package/dist/shell/Help.js +96 -0
  24. package/dist/shell/Popover.js +15 -0
  25. package/dist/shell/Settings.js +149 -0
  26. package/dist/shell/Shell.js +953 -0
  27. package/dist/shell/Tabs.js +96 -0
  28. package/dist/shell/actions.js +493 -0
  29. package/dist/utils/config.js +62 -2
  30. package/dist/utils/create-app.js +231 -0
  31. package/dist/utils/debug.js +32 -0
  32. package/dist/utils/environments.js +74 -0
  33. package/dist/utils/i18n.js +369 -0
  34. package/dist/utils/jsonc.js +2 -1
  35. package/dist/utils/keychain.js +54 -6
  36. package/dist/utils/oauth2-auth.js +146 -43
  37. package/dist/utils/password-prompt.js +37 -3
  38. package/dist/utils/process-runner.js +77 -38
  39. package/dist/utils/project-detection.js +457 -112
  40. package/dist/utils/session-cookie-auth.js +10 -6
  41. package/dist/utils/sitevision-api.js +162 -32
  42. package/dist/utils/sitevision-scripts-runner.js +50 -22
  43. package/dist/utils/tasks.js +510 -0
  44. package/dist/utils/version-check.js +3 -3
  45. package/dist/utils/webpack-runner.js +9 -5
  46. package/dist/utils/workspace.js +105 -0
  47. package/dist/utils/zip.js +18 -19
  48. package/package.json +27 -4
  49. package/readme.md +113 -141
  50. package/dist/app.d.ts +0 -6
  51. package/dist/app.js +0 -307
  52. package/dist/cli.d.ts +0 -2
  53. package/dist/commands/build.d.ts +0 -12
  54. package/dist/commands/deploy.d.ts +0 -16
  55. package/dist/commands/dev.d.ts +0 -16
  56. package/dist/commands/index.d.ts +0 -4
  57. package/dist/commands/info.d.ts +0 -2
  58. package/dist/commands/setup-signing.d.ts +0 -2
  59. package/dist/commands/sign.d.ts +0 -14
  60. package/dist/commands/types.d.ts +0 -18
  61. package/dist/commands/watch.d.ts +0 -2
  62. package/dist/components/AnimatedLogo.d.ts +0 -10
  63. package/dist/components/AuthLoginScreen.d.ts +0 -21
  64. package/dist/components/DevPropertiesForm.d.ts +0 -10
  65. package/dist/components/DevPropertiesForm.js +0 -210
  66. package/dist/components/InfoScreen.d.ts +0 -7
  67. package/dist/components/InfoScreen.js +0 -14
  68. package/dist/components/KeychainPasswordChoice.d.ts +0 -8
  69. package/dist/components/KeychainPasswordChoice.js +0 -30
  70. package/dist/components/MainMenu.d.ts +0 -7
  71. package/dist/components/MainMenu.js +0 -83
  72. package/dist/components/PasswordInput.d.ts +0 -10
  73. package/dist/components/ProcessOutput.d.ts +0 -6
  74. package/dist/components/ProcessOutput.js +0 -30
  75. package/dist/components/SetupFlow.d.ts +0 -8
  76. package/dist/components/SetupFlow.js +0 -233
  77. package/dist/components/SigningPropertiesForm.d.ts +0 -7
  78. package/dist/components/SigningPropertiesForm.js +0 -38
  79. package/dist/components/StatusIndicator.d.ts +0 -8
  80. package/dist/components/TextInput.d.ts +0 -10
  81. package/dist/components/TextInput.js +0 -29
  82. package/dist/components/WelcomeScreen.d.ts +0 -14
  83. package/dist/types/index.d.ts +0 -284
  84. package/dist/utils/branding.d.ts +0 -28
  85. package/dist/utils/config.d.ts +0 -17
  86. package/dist/utils/jsonc.d.ts +0 -19
  87. package/dist/utils/keychain.d.ts +0 -15
  88. package/dist/utils/oauth2-auth.d.ts +0 -27
  89. package/dist/utils/password-prompt.d.ts +0 -14
  90. package/dist/utils/process-runner.d.ts +0 -22
  91. package/dist/utils/project-detection.d.ts +0 -151
  92. package/dist/utils/session-cookie-auth.d.ts +0 -34
  93. package/dist/utils/signing-password.d.ts +0 -12
  94. package/dist/utils/signing-step.d.ts +0 -15
  95. package/dist/utils/signing-step.js +0 -16
  96. package/dist/utils/sitevision-api.d.ts +0 -112
  97. package/dist/utils/sitevision-scripts-runner.d.ts +0 -83
  98. package/dist/utils/version-check.d.ts +0 -9
  99. package/dist/utils/webpack-runner.d.ts +0 -76
  100. 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 = 'sv') {
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
- prefix: process.env['SITEVISION_APP_ID_PREFIX'] || '',
70
- suffix: process.env['SITEVISION_APP_ID_SUFFIX'] || '',
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} is not valid JSON: ${reason}`);
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
- return {
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
- // Check for dev properties
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
- let hasDevProperties = false;
409
+ const hasDevProperties = Boolean(own) || inheritedKeys.length > 0;
224
410
  let hasLegacyPassword = false;
225
- if (devPropertiesPath) {
226
- hasDevProperties = true;
411
+ if (hasDevProperties) {
227
412
  try {
228
- const parsed = JSON.parse(fs.readFileSync(devPropertiesPath, 'utf-8'));
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
- // Resolve deploy password: env var > keychain (file is legacy-only)
233
- if (!hasLegacyPassword &&
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
- * Get the app type (web, widget, rest)
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 getAppType(manifest) {
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
- throw new Error(`Unknown app type: ${manifest.type}`);
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
- const { password: _password, accessToken: _accessToken, sessionCookie: _sessionCookie, ...persisted } = properties;
353
- fs.writeFileSync(devPropertiesPath, JSON.stringify(persisted, null, 2));
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
- * Fields duplicated between .dev_properties.json and package.json, where
372
- * sitevision-scripts reads them under different names.
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 PACKAGE_JSON_SYNC_KEYS = [
375
- { packageKey: 'developmentDomain', devKey: 'domain' },
376
- { packageKey: 'siteName', devKey: 'siteName' },
377
- { packageKey: 'addonName', devKey: 'addonName' },
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
- * Which of the shared fields package.json is missing or disagrees on, relative
389
- * to the given dev properties. Reads package.json from disk — an earlier
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
- export function getPackageJsonSyncChanges(projectRoot, properties) {
393
- const packageJson = readPackageJson(projectRoot);
394
- if (!packageJson)
635
+ function pendingSync(dir) {
636
+ const own = readDevPropertiesFile(dir);
637
+ if (!own)
395
638
  return [];
396
- const changes = [];
397
- for (const { packageKey, devKey } of PACKAGE_JSON_SYNC_KEYS) {
398
- const to = properties[devKey];
399
- if (typeof to !== 'string' || to === '')
400
- continue;
401
- const from = packageJson[packageKey];
402
- if (from !== to) {
403
- changes.push(from === undefined
404
- ? { key: packageKey, to }
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 changes;
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
- * Copy the shared fields from dev properties into package.json, preserving the
412
- * file's existing indentation and trailing newline.
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
- export function syncDevPropertiesToPackageJson(projectRoot, properties) {
415
- const packageJsonPath = path.join(projectRoot, 'package.json');
416
- let raw;
664
+ function pendingManifestSync(dir) {
665
+ const packageJson = readPackageJson(dir);
666
+ if (!packageJson)
667
+ return [];
668
+ let manifest;
417
669
  try {
418
- raw = fs.readFileSync(packageJsonPath, 'utf-8');
419
- }
420
- catch {
421
- return false;
670
+ manifest = readManifest(dir)?.manifest;
422
671
  }
423
- let packageJson;
424
- try {
425
- packageJson = JSON.parse(raw);
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
- catch {
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
- for (const { packageKey, devKey } of PACKAGE_JSON_SYNC_KEYS) {
431
- const value = properties[devKey];
432
- if (typeof value === 'string' && value !== '') {
433
- packageJson[packageKey] = value;
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
- // writeDevProperties strips `password` defensively; keep the in-memory value.
457
- writeDevProperties(project.root, project.devProperties);
458
- project.hasLegacyPassword = false;
459
- return true;
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