sitevision-cli 1.0.0-beta.3 → 1.0.0-beta.31

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 (72) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/dist/app.d.ts +1 -1
  3. package/dist/app.js +59 -8
  4. package/dist/cli.js +125 -40
  5. package/dist/commands/build.js +1 -1
  6. package/dist/commands/deploy.d.ts +2 -2
  7. package/dist/commands/deploy.js +137 -28
  8. package/dist/commands/dev.d.ts +8 -10
  9. package/dist/commands/dev.js +70 -371
  10. package/dist/commands/watch.js +5 -23
  11. package/dist/components/AnimatedLogo.js +8 -2
  12. package/dist/components/AuthLoginScreen.d.ts +21 -0
  13. package/dist/components/AuthLoginScreen.js +90 -0
  14. package/dist/components/DevPropertiesForm.d.ts +2 -1
  15. package/dist/components/DevPropertiesForm.js +198 -33
  16. package/dist/components/MainMenu.js +5 -0
  17. package/dist/components/PasswordInput.js +2 -1
  18. package/dist/components/SetupFlow.d.ts +2 -1
  19. package/dist/components/SetupFlow.js +99 -10
  20. package/dist/shell/AddonPicker.d.ts +14 -0
  21. package/dist/shell/AddonPicker.js +54 -0
  22. package/dist/shell/Changelog.d.ts +10 -0
  23. package/dist/shell/Changelog.js +44 -0
  24. package/dist/shell/CommandPalette.d.ts +12 -0
  25. package/dist/shell/CommandPalette.js +63 -0
  26. package/dist/shell/ConfigForm.d.ts +36 -0
  27. package/dist/shell/ConfigForm.js +558 -0
  28. package/dist/shell/Frame.d.ts +59 -0
  29. package/dist/shell/Frame.js +135 -0
  30. package/dist/shell/Help.d.ts +9 -0
  31. package/dist/shell/Help.js +95 -0
  32. package/dist/shell/Popover.d.ts +12 -0
  33. package/dist/shell/Popover.js +15 -0
  34. package/dist/shell/Settings.d.ts +6 -0
  35. package/dist/shell/Settings.js +96 -0
  36. package/dist/shell/Shell.d.ts +10 -0
  37. package/dist/shell/Shell.js +622 -0
  38. package/dist/shell/Tabs.d.ts +36 -0
  39. package/dist/shell/Tabs.js +90 -0
  40. package/dist/shell/actions.d.ts +48 -0
  41. package/dist/shell/actions.js +0 -0
  42. package/dist/types/index.d.ts +37 -3
  43. package/dist/utils/config.d.ts +10 -0
  44. package/dist/utils/config.js +14 -0
  45. package/dist/utils/environments.d.ts +20 -0
  46. package/dist/utils/environments.js +74 -0
  47. package/dist/utils/i18n.d.ts +12 -0
  48. package/dist/utils/i18n.js +314 -0
  49. package/dist/utils/jsonc.d.ts +19 -0
  50. package/dist/utils/jsonc.js +74 -0
  51. package/dist/utils/keychain.d.ts +9 -0
  52. package/dist/utils/keychain.js +54 -0
  53. package/dist/utils/oauth2-auth.d.ts +64 -0
  54. package/dist/utils/oauth2-auth.js +242 -0
  55. package/dist/utils/password-prompt.d.ts +5 -0
  56. package/dist/utils/password-prompt.js +28 -0
  57. package/dist/utils/process-runner.d.ts +13 -4
  58. package/dist/utils/process-runner.js +63 -37
  59. package/dist/utils/project-detection.d.ts +90 -4
  60. package/dist/utils/project-detection.js +389 -63
  61. package/dist/utils/session-cookie-auth.d.ts +35 -0
  62. package/dist/utils/session-cookie-auth.js +99 -0
  63. package/dist/utils/sitevision-api.d.ts +72 -5
  64. package/dist/utils/sitevision-api.js +220 -38
  65. package/dist/utils/sitevision-scripts-runner.d.ts +2 -1
  66. package/dist/utils/sitevision-scripts-runner.js +18 -7
  67. package/dist/utils/tasks.d.ts +50 -0
  68. package/dist/utils/tasks.js +439 -0
  69. package/dist/utils/workspace.d.ts +17 -0
  70. package/dist/utils/workspace.js +67 -0
  71. package/package.json +5 -2
  72. package/readme.md +103 -121
@@ -1,6 +1,8 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import { getDeployPassword, setDeployPassword } from './keychain.js';
3
+ import { getDeployPassword, setDeployPassword, getSessionCookie, } from './keychain.js';
4
+ import { parseJsonc } from './jsonc.js';
5
+ import { getLanguage } from './i18n.js';
4
6
  // =============================================================================
5
7
  // LOCALIZED TEXT
6
8
  // =============================================================================
@@ -13,7 +15,7 @@ import { getDeployPassword, setDeployPassword } from './keychain.js';
13
15
  * raw object as a React child, which Sitevision's localized manifests would
14
16
  * otherwise trigger.
15
17
  */
16
- export function localizedText(value, preferred = 'sv') {
18
+ export function localizedText(value, preferred = getLanguage()) {
17
19
  if (!value)
18
20
  return '';
19
21
  if (typeof value === 'string')
@@ -60,6 +62,93 @@ export function findDevPropertiesPath(root) {
60
62
  export function getDefaultDevPropertiesPath(root) {
61
63
  return path.join(root, '.dev_properties.json');
62
64
  }
65
+ /**
66
+ * Ancestor directories of `root` (outermost first) up to and including the
67
+ * nearest one containing `.git`, or the filesystem root. Each may carry a
68
+ * `.dev_properties.json` whose values the app inherits (nearest wins).
69
+ */
70
+ function ancestorDirs(root) {
71
+ const dirs = [];
72
+ let dir = path.dirname(root);
73
+ while (true) {
74
+ dirs.unshift(dir);
75
+ if (fs.existsSync(path.join(dir, '.git')))
76
+ break;
77
+ const parent = path.dirname(dir);
78
+ if (parent === dir)
79
+ break;
80
+ dir = parent;
81
+ }
82
+ return dirs;
83
+ }
84
+ /** A bare host: no scheme, no path, no stray spaces. */
85
+ export function normalizeDomain(value) {
86
+ const host = value
87
+ .trim()
88
+ .replace(/^[a-z]+:\/\//i, '')
89
+ .replaceAll(/\s/g, '');
90
+ const slash = host.indexOf('/');
91
+ return slash === -1 ? host : host.slice(0, slash);
92
+ }
93
+ function readDevPropertiesFile(dir) {
94
+ const file = findDevPropertiesPath(dir);
95
+ if (!file)
96
+ return null;
97
+ try {
98
+ const dev = JSON.parse(fs.readFileSync(file, 'utf-8'));
99
+ // ponytail: top level only; a hand-edited environment override keeps its
100
+ // scheme until it is saved from the config form.
101
+ dev.domain &&= normalizeDomain(dev.domain);
102
+ return dev;
103
+ }
104
+ catch {
105
+ return null;
106
+ }
107
+ }
108
+ /**
109
+ * The dev properties a workspace root defines (its own file merged over any
110
+ * ancestors'), with the deploy password resolved from the keychain like an
111
+ * app's would be. Used to edit shared config from the shell.
112
+ */
113
+ export function readWorkspaceDevProperties(root) {
114
+ const merged = {
115
+ ...readInheritedDevProperties(root),
116
+ ...readDevPropertiesFile(root),
117
+ };
118
+ if (merged.domain && merged.username && !merged.password) {
119
+ merged.password =
120
+ process.env['SITEVISION_DEPLOY_PASSWORD'] ??
121
+ getDeployPassword(merged.domain, merged.username) ??
122
+ undefined;
123
+ }
124
+ return merged;
125
+ }
126
+ /** Ancestor directories' .dev_properties.json merged, nearest wins (no own file). */
127
+ export function readAncestorDevProperties(root) {
128
+ let merged = {};
129
+ for (const dir of ancestorDirs(root)) {
130
+ merged = { ...merged, ...readDevPropertiesFile(dir) };
131
+ }
132
+ return merged;
133
+ }
134
+ /** Shared defaults from package.json: the workspace root's first, the app's on top. */
135
+ export function readPackageDefaultsChain(root) {
136
+ let merged = {};
137
+ for (const dir of [...ancestorDirs(root), root]) {
138
+ merged = { ...merged, ...readPackageDefaults(dir) };
139
+ }
140
+ return merged;
141
+ }
142
+ /**
143
+ * Everything an app's own .dev_properties.json sits on top of: package.json
144
+ * defaults (root, then app), then ancestor .dev_properties.json files.
145
+ */
146
+ export function readInheritedDevProperties(root) {
147
+ return {
148
+ ...readPackageDefaultsChain(root),
149
+ ...readAncestorDevProperties(root),
150
+ };
151
+ }
63
152
  /**
64
153
  * Get app ID configuration from environment or defaults
65
154
  */
@@ -103,6 +192,20 @@ export function getZipPath(projectRoot, manifest) {
103
192
  export function getSignedZipPath(projectRoot, manifest) {
104
193
  return path.join(projectRoot, 'dist', getSignedZipFilename(manifest));
105
194
  }
195
+ /**
196
+ * The zip a non-production deploy uploads: the signed one when it is at least
197
+ * as new as the build, since some sites reject unsigned apps even on dev.
198
+ */
199
+ export function getDeployZipPath(projectRoot, manifest) {
200
+ const zip = getZipPath(projectRoot, manifest);
201
+ const signed = getSignedZipPath(projectRoot, manifest);
202
+ const mtime = (file) => fs.statSync(file, { throwIfNoEntry: false })?.mtimeMs;
203
+ const signedAt = mtime(signed);
204
+ if (signedAt === undefined)
205
+ return zip;
206
+ const builtAt = mtime(zip);
207
+ return builtAt === undefined || signedAt >= builtAt ? signed : zip;
208
+ }
106
209
  // =============================================================================
107
210
  // API ENDPOINT UTILITIES
108
211
  // =============================================================================
@@ -126,6 +229,11 @@ export function getApiEndpoints(appType) {
126
229
  addon: 'headlesscustommodule',
127
230
  import: 'restAppImport',
128
231
  };
232
+ case 'mcp':
233
+ return {
234
+ addon: 'mcpServerCustomModule',
235
+ import: 'mcpServerImport',
236
+ };
129
237
  }
130
238
  }
131
239
  /**
@@ -166,38 +274,44 @@ export class ManifestParseError extends Error {
166
274
  this.name = 'ManifestParseError';
167
275
  }
168
276
  }
277
+ /**
278
+ * Read manifest.json from its supported locations (root, static/, src/).
279
+ * Throws ManifestParseError on malformed JSON.
280
+ */
281
+ export function readManifest(cwd) {
282
+ const manifestPaths = [
283
+ path.join(cwd, 'manifest.json'),
284
+ path.join(cwd, 'static', 'manifest.json'),
285
+ path.join(cwd, 'src', 'manifest.json'),
286
+ ];
287
+ for (const manifestPath of manifestPaths) {
288
+ if (!fs.existsSync(manifestPath)) {
289
+ continue;
290
+ }
291
+ // Manifests may contain comments (Sitevision's own docs show them), so
292
+ // parse as JSONC.
293
+ try {
294
+ return {
295
+ manifestPath,
296
+ manifest: parseJsonc(fs.readFileSync(manifestPath, 'utf-8')),
297
+ };
298
+ }
299
+ catch (error) {
300
+ throw new ManifestParseError(manifestPath, error);
301
+ }
302
+ }
303
+ return null;
304
+ }
169
305
  /**
170
306
  * Detect if the current directory is a Sitevision project
171
307
  */
172
308
  export function detectProject(cwd = process.cwd()) {
173
309
  try {
174
- // Look for manifest.json in multiple locations (current, static/, src/)
175
- const manifestPaths = [
176
- path.join(cwd, 'manifest.json'),
177
- path.join(cwd, 'static', 'manifest.json'),
178
- path.join(cwd, 'src', 'manifest.json'),
179
- ];
180
- let manifestPath = null;
181
- let manifest = null;
182
- for (const p of manifestPaths) {
183
- if (fs.existsSync(p)) {
184
- manifestPath = p;
185
- // A present-but-unparseable manifest is a real, fixable error (a stray
186
- // comment, a trailing comma, …). Surface it rather than silently
187
- // reporting "Not a Sitevision project". JSON has no comments — strip
188
- // any `//` annotations from manifest.json.
189
- try {
190
- manifest = JSON.parse(fs.readFileSync(p, 'utf-8'));
191
- }
192
- catch (error) {
193
- throw new ManifestParseError(p, error);
194
- }
195
- break;
196
- }
197
- }
198
- if (!manifest || !manifestPath) {
310
+ const found = readManifest(cwd);
311
+ if (!found) {
199
312
  return null;
200
313
  }
314
+ const { manifestPath, manifest } = found;
201
315
  // Check for package.json
202
316
  const packageJsonPath = path.join(cwd, 'package.json');
203
317
  if (!fs.existsSync(packageJsonPath)) {
@@ -210,33 +324,23 @@ export function detectProject(cwd = process.cwd()) {
210
324
  // Check for node_modules
211
325
  const nodeModulesPath = path.join(cwd, 'node_modules');
212
326
  const hasNodeModules = fs.existsSync(nodeModulesPath);
213
- // Check for dev properties
327
+ // Dev properties: ancestor files (workspace root) merged under the app's
328
+ // own file, so shared site/auth config lives once at the repo root.
214
329
  const devPropertiesPath = findDevPropertiesPath(cwd);
330
+ const inherited = readInheritedDevProperties(cwd);
331
+ const own = devPropertiesPath ? readDevPropertiesFile(cwd) : null;
332
+ const inheritedKeys = Object.keys(inherited).filter(key => !own || !Object.hasOwn(own, key));
215
333
  let devProperties;
216
- let hasDevProperties = false;
334
+ const hasDevProperties = Boolean(own) || inheritedKeys.length > 0;
217
335
  let hasLegacyPassword = false;
218
- if (devPropertiesPath) {
219
- hasDevProperties = true;
336
+ if (hasDevProperties) {
220
337
  try {
221
- const parsed = JSON.parse(fs.readFileSync(devPropertiesPath, 'utf-8'));
338
+ const parsed = { ...inherited, ...own };
222
339
  hasLegacyPassword =
223
340
  typeof parsed.password === 'string' && parsed.password.length > 0;
224
341
  devProperties = parsed;
225
- // Resolve deploy password: env var > keychain (file is legacy-only)
226
- if (!hasLegacyPassword &&
227
- devProperties.domain &&
228
- devProperties.username) {
229
- const envPassword = process.env['SITEVISION_DEPLOY_PASSWORD'];
230
- if (envPassword) {
231
- devProperties.password = envPassword;
232
- }
233
- else {
234
- const stored = getDeployPassword(devProperties.domain, devProperties.username);
235
- if (stored) {
236
- devProperties.password = stored;
237
- }
238
- }
239
- }
342
+ if (!hasLegacyPassword)
343
+ resolveRuntimeSecrets(devProperties);
240
344
  }
241
345
  catch {
242
346
  // Invalid dev properties file
@@ -253,6 +357,7 @@ export function detectProject(cwd = process.cwd()) {
253
357
  hasSigningProperties,
254
358
  hasLegacyPassword,
255
359
  devProperties,
360
+ inheritedKeys,
256
361
  packageJson,
257
362
  hasSitevisionScripts,
258
363
  hasNodeModules,
@@ -268,6 +373,29 @@ export function detectProject(cwd = process.cwd()) {
268
373
  return null;
269
374
  }
270
375
  }
376
+ /**
377
+ * Fill the runtime-only credential fields for the given domain/username:
378
+ * deploy password (env var > keychain), OAuth2 access token (env var), and
379
+ * session cookie (env var > keychain). Mutates and returns `dev`.
380
+ */
381
+ export function resolveRuntimeSecrets(dev) {
382
+ if (dev.domain && dev.username) {
383
+ dev.password =
384
+ process.env['SITEVISION_DEPLOY_PASSWORD'] ??
385
+ getDeployPassword(dev.domain, dev.username) ??
386
+ undefined;
387
+ }
388
+ if (dev.authMethod === 'oauth2') {
389
+ dev.accessToken = process.env['SITEVISION_ACCESS_TOKEN'] ?? undefined;
390
+ }
391
+ if (dev.authMethod === 'cookie' && dev.domain) {
392
+ dev.sessionCookie =
393
+ process.env['SITEVISION_SESSION_COOKIE'] ??
394
+ getSessionCookie(dev.domain, dev.username) ??
395
+ undefined;
396
+ }
397
+ return dev;
398
+ }
271
399
  /**
272
400
  * Validate that we're in a Sitevision project directory
273
401
  */
@@ -279,20 +407,31 @@ export function requireProject(cwd) {
279
407
  return project;
280
408
  }
281
409
  /**
282
- * Get the app type (web, widget, rest)
410
+ * The app type (web, widget, rest, mcp), or undefined for a manifest type
411
+ * this CLI does not know. Display code uses this so one odd app never takes
412
+ * the whole shell down.
283
413
  */
284
- export function getAppType(manifest) {
414
+ export function appTypeOf(manifest) {
285
415
  const type = manifest.type.toLowerCase();
286
- if (type.startsWith('web')) {
416
+ if (type.startsWith('web'))
287
417
  return 'web';
288
- }
289
- if (type.startsWith('widget')) {
418
+ if (type.startsWith('widget'))
290
419
  return 'widget';
291
- }
292
- if (type.startsWith('rest')) {
420
+ if (type.startsWith('rest'))
293
421
  return 'rest';
294
- }
295
- throw new Error(`Unknown app type: ${manifest.type}`);
422
+ if (type.startsWith('mcp'))
423
+ return 'mcp';
424
+ return undefined;
425
+ }
426
+ /**
427
+ * Get the app type (web, widget, rest, mcp). Throws for unknown types, since
428
+ * build and deploy cannot proceed without knowing the endpoints.
429
+ */
430
+ export function getAppType(manifest) {
431
+ const type = appTypeOf(manifest);
432
+ if (!type)
433
+ throw new Error(`Unknown app type: ${manifest.type}`);
434
+ return type;
296
435
  }
297
436
  /**
298
437
  * Check if the app uses webpack bundling
@@ -316,14 +455,187 @@ export function readDevProperties(projectRoot) {
316
455
  }
317
456
  }
318
457
  /**
319
- * Write dev properties to file. The `password` field is never persisted —
320
- * it is held in the OS keychain instead.
458
+ * Write dev properties to file. Secrets are never persisted — `password`,
459
+ * `accessToken` and `sessionCookie` are held in the OS keychain / resolved at
460
+ * runtime instead.
321
461
  */
322
- export function writeDevProperties(projectRoot, properties) {
462
+ export function writeDevProperties(projectRoot, properties,
463
+ // App mode: write every value, so plain sitevision-scripts finds them all.
464
+ { complete = false } = {}) {
323
465
  const devPropertiesPath = findDevPropertiesPath(projectRoot) ||
324
466
  getDefaultDevPropertiesPath(projectRoot);
325
- const { password: _password, ...persisted } = properties;
326
- fs.writeFileSync(devPropertiesPath, JSON.stringify(persisted, null, 2));
467
+ const { password: _password, accessToken: _accessToken, sessionCookie: _sessionCookie, environmentName: _environmentName, productionEnvironment: _productionEnvironment, ...persisted } = properties;
468
+ // Keep the app file minimal: values identical to the inherited ones stay
469
+ // at the workspace root instead of being copied into every app. An empty
470
+ // string means "unset", so it is dropped rather than written as an override
471
+ // that would shadow the inherited value.
472
+ const inherited = complete
473
+ ? {}
474
+ : readInheritedDevProperties(projectRoot);
475
+ const own = Object.fromEntries(Object.entries(persisted).filter(([key, value]) => value !== '' &&
476
+ (!Object.hasOwn(inherited, key) ||
477
+ JSON.stringify(inherited[key]) !== JSON.stringify(value))));
478
+ fs.writeFileSync(devPropertiesPath, JSON.stringify(own, null, 2));
479
+ }
480
+ export function readSvcConfig(projectRoot) {
481
+ try {
482
+ return parseJsonc(fs.readFileSync(path.join(projectRoot, '.svcconfig'), 'utf-8'));
483
+ }
484
+ catch {
485
+ return {};
486
+ }
487
+ }
488
+ export function writeSvcConfig(projectRoot, updates) {
489
+ const merged = { ...readSvcConfig(projectRoot), ...updates };
490
+ fs.writeFileSync(path.join(projectRoot, '.svcconfig'), JSON.stringify(merged, null, 2) + '\n');
491
+ }
492
+ // =============================================================================
493
+ // PACKAGE.JSON SYNC
494
+ // =============================================================================
495
+ /**
496
+ * Values tied to the person running svc. They stay in .dev_properties.json and
497
+ * never go into package.json.
498
+ */
499
+ export const USER_KEYS = ['username', 'signingUsername', 'certificateName'];
500
+ // Shared values package.json can hold: three top-level fields, the rest under "svc".
501
+ const PACKAGE_TOP_KEYS = {
502
+ domain: 'developmentDomain',
503
+ siteName: 'siteName',
504
+ addonName: 'addonName',
505
+ };
506
+ const PACKAGE_SVC_KEYS = [
507
+ 'authMethod',
508
+ 'oauth2',
509
+ 'sessionLoginUrl',
510
+ 'useHTTPForDevDeploy',
511
+ 'baseEnvironment',
512
+ 'production',
513
+ 'environments',
514
+ ];
515
+ function readPackageJson(projectRoot) {
516
+ try {
517
+ return JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf-8'));
518
+ }
519
+ catch {
520
+ return null;
521
+ }
522
+ }
523
+ /** The shared defaults one directory's package.json provides. */
524
+ export function readPackageDefaults(dir) {
525
+ const packageJson = readPackageJson(dir);
526
+ if (!packageJson)
527
+ return {};
528
+ const defaults = {};
529
+ for (const [devKey, packageKey] of Object.entries(PACKAGE_TOP_KEYS)) {
530
+ const value = packageJson[packageKey];
531
+ if (typeof value === 'string' && value !== '')
532
+ defaults[devKey] = value;
533
+ }
534
+ const svc = packageJson['svc'];
535
+ for (const key of PACKAGE_SVC_KEYS) {
536
+ if (svc?.[key] !== undefined)
537
+ defaults[key] = svc[key];
538
+ }
539
+ if (typeof defaults['domain'] === 'string') {
540
+ defaults['domain'] = normalizeDomain(defaults['domain']);
541
+ }
542
+ return defaults;
543
+ }
544
+ /**
545
+ * Shared values in this directory's own .dev_properties.json that package.json
546
+ * does not already provide, here or further up. User values never qualify.
547
+ */
548
+ function pendingSync(dir) {
549
+ const own = readDevPropertiesFile(dir);
550
+ if (!own)
551
+ return [];
552
+ const defaults = readPackageDefaultsChain(dir);
553
+ const pending = [];
554
+ for (const key of [...Object.keys(PACKAGE_TOP_KEYS), ...PACKAGE_SVC_KEYS]) {
555
+ let value = own[key];
556
+ if (key === 'environments' && value) {
557
+ value = Object.fromEntries(Object.entries(value).map(([name, override]) => [
558
+ name,
559
+ Object.fromEntries(Object.entries(override).filter(([field]) => !USER_KEYS.includes(field))),
560
+ ]));
561
+ }
562
+ if (value === undefined || value === '')
563
+ continue;
564
+ if (JSON.stringify(value) ===
565
+ JSON.stringify(defaults[key] ?? IMPLICIT_VALUES[key]))
566
+ continue;
567
+ pending.push({ key, value });
568
+ }
569
+ return pending;
570
+ }
571
+ /** What an unset field means, so spelling out the default is not a change. */
572
+ export const IMPLICIT_VALUES = {
573
+ authMethod: 'basic',
574
+ useHTTPForDevDeploy: false,
575
+ production: false,
576
+ };
577
+ const packageLabel = (key) => PACKAGE_TOP_KEYS[key] ?? `svc.${key}`;
578
+ const display = (value) => typeof value === 'string' ? value : JSON.stringify(value);
579
+ /** What syncing this directory would change in its package.json. */
580
+ export function getPackageJsonSyncChanges(dir) {
581
+ const current = readPackageDefaults(dir);
582
+ return pendingSync(dir).map(({ key, value }) => current[key] === undefined
583
+ ? { key: packageLabel(key), to: display(value) }
584
+ : {
585
+ key: packageLabel(key),
586
+ from: display(current[key]),
587
+ to: display(value),
588
+ });
589
+ }
590
+ export function hasPackageJson(dir) {
591
+ return fs.existsSync(path.join(dir, 'package.json'));
592
+ }
593
+ /**
594
+ * Copy the pending shared values from .dev_properties.json into package.json,
595
+ * creating package.json when the directory has none. Throws if it cannot be
596
+ * read or written.
597
+ */
598
+ export function syncDevPropertiesToPackageJson(dir) {
599
+ const pending = pendingSync(dir);
600
+ if (pending.length === 0 && hasPackageJson(dir))
601
+ return false;
602
+ updatePackageJson(dir, packageJson => {
603
+ for (const { key, value } of pending) {
604
+ const topKey = PACKAGE_TOP_KEYS[key];
605
+ if (topKey) {
606
+ packageJson[topKey] = value;
607
+ }
608
+ else {
609
+ packageJson['svc'] = {
610
+ ...packageJson['svc'],
611
+ [key]: value,
612
+ };
613
+ }
614
+ }
615
+ });
616
+ return true;
617
+ }
618
+ /**
619
+ * Edit package.json in place, keeping its indentation and trailing newline. A
620
+ * missing file is created; an unreadable or invalid one throws, so the caller
621
+ * can warn instead of dropping the change silently.
622
+ */
623
+ export function updatePackageJson(dir, mutate) {
624
+ const packageJsonPath = path.join(dir, 'package.json');
625
+ // A new file is private, so a workspace root is never published by accident.
626
+ let raw = '{\n\t"private": true\n}\n';
627
+ try {
628
+ if (hasPackageJson(dir))
629
+ raw = fs.readFileSync(packageJsonPath, 'utf-8');
630
+ const packageJson = JSON.parse(raw);
631
+ mutate(packageJson);
632
+ const indent = /^(?<indent>[\t ]+)/m.exec(raw)?.groups?.['indent'] ?? '\t';
633
+ const newline = raw.endsWith('\n') ? '\n' : '';
634
+ fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, indent) + newline);
635
+ }
636
+ catch (error) {
637
+ throw new Error(`Could not update ${packageJsonPath}: ${error instanceof Error ? error.message : String(error)}`);
638
+ }
327
639
  }
328
640
  /**
329
641
  * Move a plaintext password from .dev_properties.json into the OS keychain and
@@ -340,8 +652,22 @@ export function migrateLegacyPassword(project) {
340
652
  return false;
341
653
  if (!setDeployPassword(domain, username, password))
342
654
  return false;
343
- // writeDevProperties strips `password` defensively; keep the in-memory value.
344
- writeDevProperties(project.root, project.devProperties);
655
+ // Remove only the password, from whichever file holds it; every other value
656
+ // stays as written.
657
+ for (const dir of [project.root, ...ancestorDirs(project.root)]) {
658
+ const file = findDevPropertiesPath(dir);
659
+ if (!file)
660
+ continue;
661
+ try {
662
+ const { password: stored, ...rest } = JSON.parse(fs.readFileSync(file, 'utf8'));
663
+ if (stored !== undefined) {
664
+ fs.writeFileSync(file, JSON.stringify(rest, null, 2));
665
+ }
666
+ }
667
+ catch {
668
+ // Unreadable file: nothing to migrate there.
669
+ }
670
+ }
345
671
  project.hasLegacyPassword = false;
346
672
  return true;
347
673
  }
@@ -0,0 +1,35 @@
1
+ import type { DevProperties } from '../types/index.js';
2
+ interface RawCookie {
3
+ name: string;
4
+ value: string;
5
+ domain: string;
6
+ }
7
+ export interface CaptureResult {
8
+ cookie?: string;
9
+ note?: string;
10
+ error?: string;
11
+ }
12
+ export interface CookieLoginSession {
13
+ capture: () => Promise<CaptureResult>;
14
+ close: () => Promise<void>;
15
+ loginUrl: string;
16
+ }
17
+ /**
18
+ * Pick the session from a cookie jar: find JSESSIONID (preferring the deploy
19
+ * host), then return every cookie on that host as a `Cookie:` header. On miss,
20
+ * return diagnostics naming the domains actually seen. Pure — no keychain, no
21
+ * browser — so it's unit-testable.
22
+ */
23
+ export declare function selectSessionCookie(all: RawCookie[], siteDomain: string): CaptureResult;
24
+ /**
25
+ * Launch a real browser at the login URL for an interactive SAML/SSO login and
26
+ * return handles to capture the session and close the browser. UI-agnostic: the
27
+ * Ink login screen decides when to `capture()` (on the user's keypress) and
28
+ * `close()`. Returns null if the browser can't be launched.
29
+ *
30
+ * `capture()` reads the whole cookie jar via CDP (httponly and secure included),
31
+ * stores the session in the keychain on success, and otherwise returns
32
+ * diagnostics naming the cookie domains it actually saw.
33
+ */
34
+ export declare function beginCookieLogin(dev: DevProperties): Promise<CookieLoginSession | null>;
35
+ export {};
@@ -0,0 +1,99 @@
1
+ import { setSessionCookie } from './keychain.js';
2
+ function bareDomain(domain) {
3
+ return domain.replace(/^\./, '');
4
+ }
5
+ /** Related if either host is the other or a subdomain of it (both directions). */
6
+ function domainRelated(a, b) {
7
+ const x = bareDomain(a);
8
+ const y = bareDomain(b);
9
+ return x === y || x.endsWith(`.${y}`) || y.endsWith(`.${x}`);
10
+ }
11
+ /**
12
+ * Pick the session from a cookie jar: find JSESSIONID (preferring the deploy
13
+ * host), then return every cookie on that host as a `Cookie:` header. On miss,
14
+ * return diagnostics naming the domains actually seen. Pure — no keychain, no
15
+ * browser — so it's unit-testable.
16
+ */
17
+ export function selectSessionCookie(all, siteDomain) {
18
+ const sessions = all.filter(c => c.name === 'JSESSIONID');
19
+ if (sessions.length === 0) {
20
+ const domains = [...new Set(all.map(c => bareDomain(c.domain)))];
21
+ return {
22
+ error: `No JSESSIONID among ${all.length} cookies. Domains seen: ${domains.join(', ') || 'none'}. If these are only your IdP, open a Sitevision page in the browser, then press Enter again.`,
23
+ };
24
+ }
25
+ const chosen = sessions.find(c => domainRelated(c.domain, siteDomain)) ?? sessions[0];
26
+ const cookies = all.filter(c => domainRelated(c.domain, chosen.domain));
27
+ return {
28
+ cookie: cookies.map(c => `${c.name}=${c.value}`).join('; '),
29
+ note: `Captured session on ${bareDomain(chosen.domain)} (${cookies.length} cookies).`,
30
+ };
31
+ }
32
+ /** Read every cookie in the browser jar (httponly and secure included). */
33
+ async function readAllCookies(browser, page) {
34
+ // puppeteer >= 22 exposes the whole jar directly.
35
+ if (typeof browser.cookies === 'function') {
36
+ try {
37
+ return (await browser.cookies());
38
+ }
39
+ catch {
40
+ // Fall through to CDP.
41
+ }
42
+ }
43
+ const client = await page.createCDPSession();
44
+ const { cookies } = await client.send('Network.getAllCookies');
45
+ return cookies;
46
+ }
47
+ /**
48
+ * Launch a real browser at the login URL for an interactive SAML/SSO login and
49
+ * return handles to capture the session and close the browser. UI-agnostic: the
50
+ * Ink login screen decides when to `capture()` (on the user's keypress) and
51
+ * `close()`. Returns null if the browser can't be launched.
52
+ *
53
+ * `capture()` reads the whole cookie jar via CDP (httponly and secure included),
54
+ * stores the session in the keychain on success, and otherwise returns
55
+ * diagnostics naming the cookie domains it actually saw.
56
+ */
57
+ export async function beginCookieLogin(dev) {
58
+ const { domain, username } = dev;
59
+ if (!domain)
60
+ return null;
61
+ let puppeteer;
62
+ try {
63
+ ({ default: puppeteer } = await import('puppeteer-core'));
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ let browser;
69
+ try {
70
+ browser = await puppeteer.launch({ headless: false, channel: 'chrome' });
71
+ const page = await browser.newPage();
72
+ const loginUrl = dev.sessionLoginUrl || `https://${domain}/`;
73
+ await page.goto(loginUrl, { waitUntil: 'domcontentloaded' }).catch(() => {
74
+ // A SAML redirect may abort the initial navigation — that's fine.
75
+ });
76
+ const capture = async () => {
77
+ const all = await readAllCookies(browser, page);
78
+ const result = selectSessionCookie(all, domain);
79
+ if (result.cookie) {
80
+ setSessionCookie(domain, username, result.cookie);
81
+ }
82
+ return result;
83
+ };
84
+ const close = async () => {
85
+ await browser.close().catch(() => {
86
+ // Best-effort close.
87
+ });
88
+ };
89
+ return { capture, close, loginUrl };
90
+ }
91
+ catch {
92
+ if (browser) {
93
+ await browser.close().catch(() => {
94
+ // Best-effort close.
95
+ });
96
+ }
97
+ return null;
98
+ }
99
+ }