sitevision-cli 1.0.0-beta.2 → 1.0.0-beta.21

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 (63) hide show
  1. package/dist/app.d.ts +1 -1
  2. package/dist/app.js +59 -8
  3. package/dist/cli.js +96 -39
  4. package/dist/commands/build.js +1 -1
  5. package/dist/commands/deploy.d.ts +2 -2
  6. package/dist/commands/deploy.js +135 -25
  7. package/dist/commands/dev.d.ts +8 -10
  8. package/dist/commands/dev.js +77 -366
  9. package/dist/commands/info.js +2 -2
  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/InfoScreen.js +2 -2
  17. package/dist/components/MainMenu.js +7 -2
  18. package/dist/components/PasswordInput.js +2 -1
  19. package/dist/components/SetupFlow.d.ts +2 -1
  20. package/dist/components/SetupFlow.js +100 -11
  21. package/dist/shell/AddonPicker.d.ts +14 -0
  22. package/dist/shell/AddonPicker.js +54 -0
  23. package/dist/shell/CommandPalette.d.ts +8 -0
  24. package/dist/shell/CommandPalette.js +63 -0
  25. package/dist/shell/ConfigForm.d.ts +36 -0
  26. package/dist/shell/ConfigForm.js +558 -0
  27. package/dist/shell/Frame.d.ts +59 -0
  28. package/dist/shell/Frame.js +134 -0
  29. package/dist/shell/Settings.d.ts +6 -0
  30. package/dist/shell/Settings.js +96 -0
  31. package/dist/shell/Shell.d.ts +9 -0
  32. package/dist/shell/Shell.js +586 -0
  33. package/dist/shell/Tabs.d.ts +36 -0
  34. package/dist/shell/Tabs.js +90 -0
  35. package/dist/shell/actions.d.ts +45 -0
  36. package/dist/shell/actions.js +0 -0
  37. package/dist/types/index.d.ts +44 -5
  38. package/dist/utils/config.d.ts +10 -0
  39. package/dist/utils/config.js +14 -0
  40. package/dist/utils/environments.d.ts +20 -0
  41. package/dist/utils/environments.js +74 -0
  42. package/dist/utils/i18n.d.ts +12 -0
  43. package/dist/utils/i18n.js +279 -0
  44. package/dist/utils/jsonc.d.ts +19 -0
  45. package/dist/utils/jsonc.js +74 -0
  46. package/dist/utils/keychain.d.ts +9 -0
  47. package/dist/utils/keychain.js +54 -0
  48. package/dist/utils/oauth2-auth.d.ts +64 -0
  49. package/dist/utils/oauth2-auth.js +242 -0
  50. package/dist/utils/password-prompt.d.ts +5 -0
  51. package/dist/utils/password-prompt.js +28 -0
  52. package/dist/utils/project-detection.d.ts +105 -6
  53. package/dist/utils/project-detection.js +411 -54
  54. package/dist/utils/session-cookie-auth.d.ts +35 -0
  55. package/dist/utils/session-cookie-auth.js +99 -0
  56. package/dist/utils/sitevision-api.d.ts +64 -5
  57. package/dist/utils/sitevision-api.js +195 -33
  58. package/dist/utils/tasks.d.ts +48 -0
  59. package/dist/utils/tasks.js +371 -0
  60. package/dist/utils/workspace.d.ts +17 -0
  61. package/dist/utils/workspace.js +67 -0
  62. package/package.json +3 -1
  63. package/readme.md +102 -121
@@ -1,6 +1,27 @@
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';
6
+ // =============================================================================
7
+ // LOCALIZED TEXT
8
+ // =============================================================================
9
+ /**
10
+ * Resolve a manifest text field that may be a plain string or a localized
11
+ * object (e.g. `{sv: 'Namn', en: 'Name'}`) to a single display string.
12
+ *
13
+ * Preference order: Swedish, then English, then any available language. Returns
14
+ * an empty string for missing/empty values. This guards the UI from rendering a
15
+ * raw object as a React child, which Sitevision's localized manifests would
16
+ * otherwise trigger.
17
+ */
18
+ export function localizedText(value, preferred = getLanguage()) {
19
+ if (!value)
20
+ return '';
21
+ if (typeof value === 'string')
22
+ return value;
23
+ return value[preferred] ?? value['en'] ?? Object.values(value)[0] ?? '';
24
+ }
4
25
  // =============================================================================
5
26
  // PATH UTILITIES
6
27
  // =============================================================================
@@ -41,6 +62,93 @@ export function findDevPropertiesPath(root) {
41
62
  export function getDefaultDevPropertiesPath(root) {
42
63
  return path.join(root, '.dev_properties.json');
43
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
+ }
44
152
  /**
45
153
  * Get app ID configuration from environment or defaults
46
154
  */
@@ -107,6 +215,11 @@ export function getApiEndpoints(appType) {
107
215
  addon: 'headlesscustommodule',
108
216
  import: 'restAppImport',
109
217
  };
218
+ case 'mcp':
219
+ return {
220
+ addon: 'mcpServerCustomModule',
221
+ import: 'mcpServerImport',
222
+ };
110
223
  }
111
224
  }
112
225
  /**
@@ -135,29 +248,56 @@ export function buildImportEndpointUrl(domain, siteName, addonName, appType, use
135
248
  // =============================================================================
136
249
  // PROJECT DETECTION
137
250
  // =============================================================================
251
+ /**
252
+ * Thrown when a manifest.json is present but cannot be parsed. Kept distinct from
253
+ * a plain "no project here" (null) so the CLI can tell the user their manifest is
254
+ * malformed instead of the misleading "Not a Sitevision project".
255
+ */
256
+ export class ManifestParseError extends Error {
257
+ constructor(manifestPath, cause) {
258
+ const reason = cause instanceof Error ? cause.message : String(cause);
259
+ super(`${manifestPath} is not valid JSON: ${reason}`);
260
+ this.name = 'ManifestParseError';
261
+ }
262
+ }
263
+ /**
264
+ * Read manifest.json from its supported locations (root, static/, src/).
265
+ * Throws ManifestParseError on malformed JSON.
266
+ */
267
+ export function readManifest(cwd) {
268
+ const manifestPaths = [
269
+ path.join(cwd, 'manifest.json'),
270
+ path.join(cwd, 'static', 'manifest.json'),
271
+ path.join(cwd, 'src', 'manifest.json'),
272
+ ];
273
+ for (const manifestPath of manifestPaths) {
274
+ if (!fs.existsSync(manifestPath)) {
275
+ continue;
276
+ }
277
+ // Manifests may contain comments (Sitevision's own docs show them), so
278
+ // parse as JSONC.
279
+ try {
280
+ return {
281
+ manifestPath,
282
+ manifest: parseJsonc(fs.readFileSync(manifestPath, 'utf-8')),
283
+ };
284
+ }
285
+ catch (error) {
286
+ throw new ManifestParseError(manifestPath, error);
287
+ }
288
+ }
289
+ return null;
290
+ }
138
291
  /**
139
292
  * Detect if the current directory is a Sitevision project
140
293
  */
141
294
  export function detectProject(cwd = process.cwd()) {
142
295
  try {
143
- // Look for manifest.json in multiple locations (current, static/, src/)
144
- const manifestPaths = [
145
- path.join(cwd, 'manifest.json'),
146
- path.join(cwd, 'static', 'manifest.json'),
147
- path.join(cwd, 'src', 'manifest.json'),
148
- ];
149
- let manifestPath = null;
150
- let manifest = null;
151
- for (const p of manifestPaths) {
152
- if (fs.existsSync(p)) {
153
- manifestPath = p;
154
- manifest = JSON.parse(fs.readFileSync(p, 'utf-8'));
155
- break;
156
- }
157
- }
158
- if (!manifest || !manifestPath) {
296
+ const found = readManifest(cwd);
297
+ if (!found) {
159
298
  return null;
160
299
  }
300
+ const { manifestPath, manifest } = found;
161
301
  // Check for package.json
162
302
  const packageJsonPath = path.join(cwd, 'package.json');
163
303
  if (!fs.existsSync(packageJsonPath)) {
@@ -170,33 +310,23 @@ export function detectProject(cwd = process.cwd()) {
170
310
  // Check for node_modules
171
311
  const nodeModulesPath = path.join(cwd, 'node_modules');
172
312
  const hasNodeModules = fs.existsSync(nodeModulesPath);
173
- // Check for dev properties
313
+ // Dev properties: ancestor files (workspace root) merged under the app's
314
+ // own file, so shared site/auth config lives once at the repo root.
174
315
  const devPropertiesPath = findDevPropertiesPath(cwd);
316
+ const inherited = readInheritedDevProperties(cwd);
317
+ const own = devPropertiesPath ? readDevPropertiesFile(cwd) : null;
318
+ const inheritedKeys = Object.keys(inherited).filter(key => !own || !Object.hasOwn(own, key));
175
319
  let devProperties;
176
- let hasDevProperties = false;
320
+ const hasDevProperties = Boolean(own) || inheritedKeys.length > 0;
177
321
  let hasLegacyPassword = false;
178
- if (devPropertiesPath) {
179
- hasDevProperties = true;
322
+ if (hasDevProperties) {
180
323
  try {
181
- const parsed = JSON.parse(fs.readFileSync(devPropertiesPath, 'utf-8'));
324
+ const parsed = { ...inherited, ...own };
182
325
  hasLegacyPassword =
183
326
  typeof parsed.password === 'string' && parsed.password.length > 0;
184
327
  devProperties = parsed;
185
- // Resolve deploy password: env var > keychain (file is legacy-only)
186
- if (!hasLegacyPassword &&
187
- devProperties.domain &&
188
- devProperties.username) {
189
- const envPassword = process.env['SITEVISION_DEPLOY_PASSWORD'];
190
- if (envPassword) {
191
- devProperties.password = envPassword;
192
- }
193
- else {
194
- const stored = getDeployPassword(devProperties.domain, devProperties.username);
195
- if (stored) {
196
- devProperties.password = stored;
197
- }
198
- }
199
- }
328
+ if (!hasLegacyPassword)
329
+ resolveRuntimeSecrets(devProperties);
200
330
  }
201
331
  catch {
202
332
  // Invalid dev properties file
@@ -213,16 +343,45 @@ export function detectProject(cwd = process.cwd()) {
213
343
  hasSigningProperties,
214
344
  hasLegacyPassword,
215
345
  devProperties,
346
+ inheritedKeys,
216
347
  packageJson,
217
348
  hasSitevisionScripts,
218
349
  hasNodeModules,
219
350
  paths,
220
351
  };
221
352
  }
222
- catch {
353
+ catch (error) {
354
+ // A malformed manifest is a real error the user should see; everything else
355
+ // (missing files, unreadable optional config) just means "no project here".
356
+ if (error instanceof ManifestParseError) {
357
+ throw error;
358
+ }
223
359
  return null;
224
360
  }
225
361
  }
362
+ /**
363
+ * Fill the runtime-only credential fields for the given domain/username:
364
+ * deploy password (env var > keychain), OAuth2 access token (env var), and
365
+ * session cookie (env var > keychain). Mutates and returns `dev`.
366
+ */
367
+ export function resolveRuntimeSecrets(dev) {
368
+ if (dev.domain && dev.username) {
369
+ dev.password =
370
+ process.env['SITEVISION_DEPLOY_PASSWORD'] ??
371
+ getDeployPassword(dev.domain, dev.username) ??
372
+ undefined;
373
+ }
374
+ if (dev.authMethod === 'oauth2') {
375
+ dev.accessToken = process.env['SITEVISION_ACCESS_TOKEN'] ?? undefined;
376
+ }
377
+ if (dev.authMethod === 'cookie' && dev.domain) {
378
+ dev.sessionCookie =
379
+ process.env['SITEVISION_SESSION_COOKIE'] ??
380
+ getSessionCookie(dev.domain, dev.username) ??
381
+ undefined;
382
+ }
383
+ return dev;
384
+ }
226
385
  /**
227
386
  * Validate that we're in a Sitevision project directory
228
387
  */
@@ -234,20 +393,31 @@ export function requireProject(cwd) {
234
393
  return project;
235
394
  }
236
395
  /**
237
- * Get the app type (web, widget, rest)
396
+ * The app type (web, widget, rest, mcp), or undefined for a manifest type
397
+ * this CLI does not know. Display code uses this so one odd app never takes
398
+ * the whole shell down.
238
399
  */
239
- export function getAppType(manifest) {
400
+ export function appTypeOf(manifest) {
240
401
  const type = manifest.type.toLowerCase();
241
- if (type.startsWith('web')) {
402
+ if (type.startsWith('web'))
242
403
  return 'web';
243
- }
244
- if (type.startsWith('widget')) {
404
+ if (type.startsWith('widget'))
245
405
  return 'widget';
246
- }
247
- if (type.startsWith('rest')) {
406
+ if (type.startsWith('rest'))
248
407
  return 'rest';
249
- }
250
- throw new Error(`Unknown app type: ${manifest.type}`);
408
+ if (type.startsWith('mcp'))
409
+ return 'mcp';
410
+ return undefined;
411
+ }
412
+ /**
413
+ * Get the app type (web, widget, rest, mcp). Throws for unknown types, since
414
+ * build and deploy cannot proceed without knowing the endpoints.
415
+ */
416
+ export function getAppType(manifest) {
417
+ const type = appTypeOf(manifest);
418
+ if (!type)
419
+ throw new Error(`Unknown app type: ${manifest.type}`);
420
+ return type;
251
421
  }
252
422
  /**
253
423
  * Check if the app uses webpack bundling
@@ -271,14 +441,187 @@ export function readDevProperties(projectRoot) {
271
441
  }
272
442
  }
273
443
  /**
274
- * Write dev properties to file. The `password` field is never persisted —
275
- * it is held in the OS keychain instead.
444
+ * Write dev properties to file. Secrets are never persisted — `password`,
445
+ * `accessToken` and `sessionCookie` are held in the OS keychain / resolved at
446
+ * runtime instead.
276
447
  */
277
- export function writeDevProperties(projectRoot, properties) {
448
+ export function writeDevProperties(projectRoot, properties,
449
+ // App mode: write every value, so plain sitevision-scripts finds them all.
450
+ { complete = false } = {}) {
278
451
  const devPropertiesPath = findDevPropertiesPath(projectRoot) ||
279
452
  getDefaultDevPropertiesPath(projectRoot);
280
- const { password: _password, ...persisted } = properties;
281
- fs.writeFileSync(devPropertiesPath, JSON.stringify(persisted, null, 2));
453
+ const { password: _password, accessToken: _accessToken, sessionCookie: _sessionCookie, environmentName: _environmentName, productionEnvironment: _productionEnvironment, ...persisted } = properties;
454
+ // Keep the app file minimal: values identical to the inherited ones stay
455
+ // at the workspace root instead of being copied into every app. An empty
456
+ // string means "unset", so it is dropped rather than written as an override
457
+ // that would shadow the inherited value.
458
+ const inherited = complete
459
+ ? {}
460
+ : readInheritedDevProperties(projectRoot);
461
+ const own = Object.fromEntries(Object.entries(persisted).filter(([key, value]) => value !== '' &&
462
+ (!Object.hasOwn(inherited, key) ||
463
+ JSON.stringify(inherited[key]) !== JSON.stringify(value))));
464
+ fs.writeFileSync(devPropertiesPath, JSON.stringify(own, null, 2));
465
+ }
466
+ export function readSvcConfig(projectRoot) {
467
+ try {
468
+ return parseJsonc(fs.readFileSync(path.join(projectRoot, '.svcconfig'), 'utf-8'));
469
+ }
470
+ catch {
471
+ return {};
472
+ }
473
+ }
474
+ export function writeSvcConfig(projectRoot, updates) {
475
+ const merged = { ...readSvcConfig(projectRoot), ...updates };
476
+ fs.writeFileSync(path.join(projectRoot, '.svcconfig'), JSON.stringify(merged, null, 2) + '\n');
477
+ }
478
+ // =============================================================================
479
+ // PACKAGE.JSON SYNC
480
+ // =============================================================================
481
+ /**
482
+ * Values tied to the person running svc. They stay in .dev_properties.json and
483
+ * never go into package.json.
484
+ */
485
+ export const USER_KEYS = ['username', 'signingUsername', 'certificateName'];
486
+ // Shared values package.json can hold: three top-level fields, the rest under "svc".
487
+ const PACKAGE_TOP_KEYS = {
488
+ domain: 'developmentDomain',
489
+ siteName: 'siteName',
490
+ addonName: 'addonName',
491
+ };
492
+ const PACKAGE_SVC_KEYS = [
493
+ 'authMethod',
494
+ 'oauth2',
495
+ 'sessionLoginUrl',
496
+ 'useHTTPForDevDeploy',
497
+ 'baseEnvironment',
498
+ 'production',
499
+ 'environments',
500
+ ];
501
+ function readPackageJson(projectRoot) {
502
+ try {
503
+ return JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf-8'));
504
+ }
505
+ catch {
506
+ return null;
507
+ }
508
+ }
509
+ /** The shared defaults one directory's package.json provides. */
510
+ export function readPackageDefaults(dir) {
511
+ const packageJson = readPackageJson(dir);
512
+ if (!packageJson)
513
+ return {};
514
+ const defaults = {};
515
+ for (const [devKey, packageKey] of Object.entries(PACKAGE_TOP_KEYS)) {
516
+ const value = packageJson[packageKey];
517
+ if (typeof value === 'string' && value !== '')
518
+ defaults[devKey] = value;
519
+ }
520
+ const svc = packageJson['svc'];
521
+ for (const key of PACKAGE_SVC_KEYS) {
522
+ if (svc?.[key] !== undefined)
523
+ defaults[key] = svc[key];
524
+ }
525
+ if (typeof defaults['domain'] === 'string') {
526
+ defaults['domain'] = normalizeDomain(defaults['domain']);
527
+ }
528
+ return defaults;
529
+ }
530
+ /**
531
+ * Shared values in this directory's own .dev_properties.json that package.json
532
+ * does not already provide, here or further up. User values never qualify.
533
+ */
534
+ function pendingSync(dir) {
535
+ const own = readDevPropertiesFile(dir);
536
+ if (!own)
537
+ return [];
538
+ const defaults = readPackageDefaultsChain(dir);
539
+ const pending = [];
540
+ for (const key of [...Object.keys(PACKAGE_TOP_KEYS), ...PACKAGE_SVC_KEYS]) {
541
+ let value = own[key];
542
+ if (key === 'environments' && value) {
543
+ value = Object.fromEntries(Object.entries(value).map(([name, override]) => [
544
+ name,
545
+ Object.fromEntries(Object.entries(override).filter(([field]) => !USER_KEYS.includes(field))),
546
+ ]));
547
+ }
548
+ if (value === undefined || value === '')
549
+ continue;
550
+ if (JSON.stringify(value) ===
551
+ JSON.stringify(defaults[key] ?? IMPLICIT_VALUES[key]))
552
+ continue;
553
+ pending.push({ key, value });
554
+ }
555
+ return pending;
556
+ }
557
+ /** What an unset field means, so spelling out the default is not a change. */
558
+ export const IMPLICIT_VALUES = {
559
+ authMethod: 'basic',
560
+ useHTTPForDevDeploy: false,
561
+ production: false,
562
+ };
563
+ const packageLabel = (key) => PACKAGE_TOP_KEYS[key] ?? `svc.${key}`;
564
+ const display = (value) => typeof value === 'string' ? value : JSON.stringify(value);
565
+ /** What syncing this directory would change in its package.json. */
566
+ export function getPackageJsonSyncChanges(dir) {
567
+ const current = readPackageDefaults(dir);
568
+ return pendingSync(dir).map(({ key, value }) => current[key] === undefined
569
+ ? { key: packageLabel(key), to: display(value) }
570
+ : {
571
+ key: packageLabel(key),
572
+ from: display(current[key]),
573
+ to: display(value),
574
+ });
575
+ }
576
+ export function hasPackageJson(dir) {
577
+ return fs.existsSync(path.join(dir, 'package.json'));
578
+ }
579
+ /**
580
+ * Copy the pending shared values from .dev_properties.json into package.json,
581
+ * creating package.json when the directory has none. Throws if it cannot be
582
+ * read or written.
583
+ */
584
+ export function syncDevPropertiesToPackageJson(dir) {
585
+ const pending = pendingSync(dir);
586
+ if (pending.length === 0 && hasPackageJson(dir))
587
+ return false;
588
+ updatePackageJson(dir, packageJson => {
589
+ for (const { key, value } of pending) {
590
+ const topKey = PACKAGE_TOP_KEYS[key];
591
+ if (topKey) {
592
+ packageJson[topKey] = value;
593
+ }
594
+ else {
595
+ packageJson['svc'] = {
596
+ ...packageJson['svc'],
597
+ [key]: value,
598
+ };
599
+ }
600
+ }
601
+ });
602
+ return true;
603
+ }
604
+ /**
605
+ * Edit package.json in place, keeping its indentation and trailing newline. A
606
+ * missing file is created; an unreadable or invalid one throws, so the caller
607
+ * can warn instead of dropping the change silently.
608
+ */
609
+ export function updatePackageJson(dir, mutate) {
610
+ const packageJsonPath = path.join(dir, 'package.json');
611
+ // A new file is private, so a workspace root is never published by accident.
612
+ let raw = '{\n\t"private": true\n}\n';
613
+ try {
614
+ if (hasPackageJson(dir))
615
+ raw = fs.readFileSync(packageJsonPath, 'utf-8');
616
+ const packageJson = JSON.parse(raw);
617
+ mutate(packageJson);
618
+ const indent = /^(?<indent>[\t ]+)/m.exec(raw)?.groups?.['indent'] ?? '\t';
619
+ const newline = raw.endsWith('\n') ? '\n' : '';
620
+ fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, indent) + newline);
621
+ }
622
+ catch (error) {
623
+ throw new Error(`Could not update ${packageJsonPath}: ${error instanceof Error ? error.message : String(error)}`);
624
+ }
282
625
  }
283
626
  /**
284
627
  * Move a plaintext password from .dev_properties.json into the OS keychain and
@@ -295,8 +638,22 @@ export function migrateLegacyPassword(project) {
295
638
  return false;
296
639
  if (!setDeployPassword(domain, username, password))
297
640
  return false;
298
- // writeDevProperties strips `password` defensively; keep the in-memory value.
299
- writeDevProperties(project.root, project.devProperties);
641
+ // Remove only the password, from whichever file holds it; every other value
642
+ // stays as written.
643
+ for (const dir of [project.root, ...ancestorDirs(project.root)]) {
644
+ const file = findDevPropertiesPath(dir);
645
+ if (!file)
646
+ continue;
647
+ try {
648
+ const { password: stored, ...rest } = JSON.parse(fs.readFileSync(file, 'utf8'));
649
+ if (stored !== undefined) {
650
+ fs.writeFileSync(file, JSON.stringify(rest, null, 2));
651
+ }
652
+ }
653
+ catch {
654
+ // Unreadable file: nothing to migrate there.
655
+ }
656
+ }
300
657
  project.hasLegacyPassword = false;
301
658
  return true;
302
659
  }
@@ -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
+ }