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

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 +35 -0
  26. package/dist/shell/ConfigForm.js +499 -0
  27. package/dist/shell/Frame.d.ts +59 -0
  28. package/dist/shell/Frame.js +136 -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 +576 -0
  33. package/dist/shell/Tabs.d.ts +36 -0
  34. package/dist/shell/Tabs.js +85 -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 +277 -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 +81 -4
  53. package/dist/utils/project-detection.js +298 -51
  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 +99 -24
@@ -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,75 @@ 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
+ /** Dev properties inherited from ancestor directories only (no own file). */
127
+ export function readInheritedDevProperties(root) {
128
+ let merged = {};
129
+ for (const dir of ancestorDirs(root)) {
130
+ merged = { ...merged, ...readDevPropertiesFile(dir) };
131
+ }
132
+ return merged;
133
+ }
44
134
  /**
45
135
  * Get app ID configuration from environment or defaults
46
136
  */
@@ -107,6 +197,11 @@ export function getApiEndpoints(appType) {
107
197
  addon: 'headlesscustommodule',
108
198
  import: 'restAppImport',
109
199
  };
200
+ case 'mcp':
201
+ return {
202
+ addon: 'mcpServerCustomModule',
203
+ import: 'mcpServerImport',
204
+ };
110
205
  }
111
206
  }
112
207
  /**
@@ -135,29 +230,56 @@ export function buildImportEndpointUrl(domain, siteName, addonName, appType, use
135
230
  // =============================================================================
136
231
  // PROJECT DETECTION
137
232
  // =============================================================================
233
+ /**
234
+ * Thrown when a manifest.json is present but cannot be parsed. Kept distinct from
235
+ * a plain "no project here" (null) so the CLI can tell the user their manifest is
236
+ * malformed instead of the misleading "Not a Sitevision project".
237
+ */
238
+ export class ManifestParseError extends Error {
239
+ constructor(manifestPath, cause) {
240
+ const reason = cause instanceof Error ? cause.message : String(cause);
241
+ super(`${manifestPath} is not valid JSON: ${reason}`);
242
+ this.name = 'ManifestParseError';
243
+ }
244
+ }
245
+ /**
246
+ * Read manifest.json from its supported locations (root, static/, src/).
247
+ * Throws ManifestParseError on malformed JSON.
248
+ */
249
+ export function readManifest(cwd) {
250
+ const manifestPaths = [
251
+ path.join(cwd, 'manifest.json'),
252
+ path.join(cwd, 'static', 'manifest.json'),
253
+ path.join(cwd, 'src', 'manifest.json'),
254
+ ];
255
+ for (const manifestPath of manifestPaths) {
256
+ if (!fs.existsSync(manifestPath)) {
257
+ continue;
258
+ }
259
+ // Manifests may contain comments (Sitevision's own docs show them), so
260
+ // parse as JSONC.
261
+ try {
262
+ return {
263
+ manifestPath,
264
+ manifest: parseJsonc(fs.readFileSync(manifestPath, 'utf-8')),
265
+ };
266
+ }
267
+ catch (error) {
268
+ throw new ManifestParseError(manifestPath, error);
269
+ }
270
+ }
271
+ return null;
272
+ }
138
273
  /**
139
274
  * Detect if the current directory is a Sitevision project
140
275
  */
141
276
  export function detectProject(cwd = process.cwd()) {
142
277
  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) {
278
+ const found = readManifest(cwd);
279
+ if (!found) {
159
280
  return null;
160
281
  }
282
+ const { manifestPath, manifest } = found;
161
283
  // Check for package.json
162
284
  const packageJsonPath = path.join(cwd, 'package.json');
163
285
  if (!fs.existsSync(packageJsonPath)) {
@@ -170,33 +292,23 @@ export function detectProject(cwd = process.cwd()) {
170
292
  // Check for node_modules
171
293
  const nodeModulesPath = path.join(cwd, 'node_modules');
172
294
  const hasNodeModules = fs.existsSync(nodeModulesPath);
173
- // Check for dev properties
295
+ // Dev properties: ancestor files (workspace root) merged under the app's
296
+ // own file, so shared site/auth config lives once at the repo root.
174
297
  const devPropertiesPath = findDevPropertiesPath(cwd);
298
+ const inherited = readInheritedDevProperties(cwd);
299
+ const own = devPropertiesPath ? readDevPropertiesFile(cwd) : null;
300
+ const inheritedKeys = Object.keys(inherited).filter(key => !own || !Object.hasOwn(own, key));
175
301
  let devProperties;
176
- let hasDevProperties = false;
302
+ const hasDevProperties = Boolean(own) || inheritedKeys.length > 0;
177
303
  let hasLegacyPassword = false;
178
- if (devPropertiesPath) {
179
- hasDevProperties = true;
304
+ if (hasDevProperties) {
180
305
  try {
181
- const parsed = JSON.parse(fs.readFileSync(devPropertiesPath, 'utf-8'));
306
+ const parsed = { ...inherited, ...own };
182
307
  hasLegacyPassword =
183
308
  typeof parsed.password === 'string' && parsed.password.length > 0;
184
309
  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
- }
310
+ if (!hasLegacyPassword)
311
+ resolveRuntimeSecrets(devProperties);
200
312
  }
201
313
  catch {
202
314
  // Invalid dev properties file
@@ -213,16 +325,45 @@ export function detectProject(cwd = process.cwd()) {
213
325
  hasSigningProperties,
214
326
  hasLegacyPassword,
215
327
  devProperties,
328
+ inheritedKeys,
216
329
  packageJson,
217
330
  hasSitevisionScripts,
218
331
  hasNodeModules,
219
332
  paths,
220
333
  };
221
334
  }
222
- catch {
335
+ catch (error) {
336
+ // A malformed manifest is a real error the user should see; everything else
337
+ // (missing files, unreadable optional config) just means "no project here".
338
+ if (error instanceof ManifestParseError) {
339
+ throw error;
340
+ }
223
341
  return null;
224
342
  }
225
343
  }
344
+ /**
345
+ * Fill the runtime-only credential fields for the given domain/username:
346
+ * deploy password (env var > keychain), OAuth2 access token (env var), and
347
+ * session cookie (env var > keychain). Mutates and returns `dev`.
348
+ */
349
+ export function resolveRuntimeSecrets(dev) {
350
+ if (dev.domain && dev.username) {
351
+ dev.password =
352
+ process.env['SITEVISION_DEPLOY_PASSWORD'] ??
353
+ getDeployPassword(dev.domain, dev.username) ??
354
+ undefined;
355
+ }
356
+ if (dev.authMethod === 'oauth2') {
357
+ dev.accessToken = process.env['SITEVISION_ACCESS_TOKEN'] ?? undefined;
358
+ }
359
+ if (dev.authMethod === 'cookie' && dev.domain) {
360
+ dev.sessionCookie =
361
+ process.env['SITEVISION_SESSION_COOKIE'] ??
362
+ getSessionCookie(dev.domain, dev.username) ??
363
+ undefined;
364
+ }
365
+ return dev;
366
+ }
226
367
  /**
227
368
  * Validate that we're in a Sitevision project directory
228
369
  */
@@ -234,20 +375,31 @@ export function requireProject(cwd) {
234
375
  return project;
235
376
  }
236
377
  /**
237
- * Get the app type (web, widget, rest)
378
+ * The app type (web, widget, rest, mcp), or undefined for a manifest type
379
+ * this CLI does not know. Display code uses this so one odd app never takes
380
+ * the whole shell down.
238
381
  */
239
- export function getAppType(manifest) {
382
+ export function appTypeOf(manifest) {
240
383
  const type = manifest.type.toLowerCase();
241
- if (type.startsWith('web')) {
384
+ if (type.startsWith('web'))
242
385
  return 'web';
243
- }
244
- if (type.startsWith('widget')) {
386
+ if (type.startsWith('widget'))
245
387
  return 'widget';
246
- }
247
- if (type.startsWith('rest')) {
388
+ if (type.startsWith('rest'))
248
389
  return 'rest';
249
- }
250
- throw new Error(`Unknown app type: ${manifest.type}`);
390
+ if (type.startsWith('mcp'))
391
+ return 'mcp';
392
+ return undefined;
393
+ }
394
+ /**
395
+ * Get the app type (web, widget, rest, mcp). Throws for unknown types, since
396
+ * build and deploy cannot proceed without knowing the endpoints.
397
+ */
398
+ export function getAppType(manifest) {
399
+ const type = appTypeOf(manifest);
400
+ if (!type)
401
+ throw new Error(`Unknown app type: ${manifest.type}`);
402
+ return type;
251
403
  }
252
404
  /**
253
405
  * Check if the app uses webpack bundling
@@ -271,14 +423,109 @@ export function readDevProperties(projectRoot) {
271
423
  }
272
424
  }
273
425
  /**
274
- * Write dev properties to file. The `password` field is never persisted —
275
- * it is held in the OS keychain instead.
426
+ * Write dev properties to file. Secrets are never persisted — `password`,
427
+ * `accessToken` and `sessionCookie` are held in the OS keychain / resolved at
428
+ * runtime instead.
276
429
  */
277
430
  export function writeDevProperties(projectRoot, properties) {
278
431
  const devPropertiesPath = findDevPropertiesPath(projectRoot) ||
279
432
  getDefaultDevPropertiesPath(projectRoot);
280
- const { password: _password, ...persisted } = properties;
281
- fs.writeFileSync(devPropertiesPath, JSON.stringify(persisted, null, 2));
433
+ const { password: _password, accessToken: _accessToken, sessionCookie: _sessionCookie, environmentName: _environmentName, productionEnvironment: _productionEnvironment, ...persisted } = properties;
434
+ // Keep the app file minimal: values identical to the inherited ones stay
435
+ // at the workspace root instead of being copied into every app. An empty
436
+ // string means "unset", so it is dropped rather than written as an override
437
+ // that would shadow the inherited value.
438
+ const inherited = readInheritedDevProperties(projectRoot);
439
+ const own = Object.fromEntries(Object.entries(persisted).filter(([key, value]) => value !== '' &&
440
+ (!Object.hasOwn(inherited, key) ||
441
+ JSON.stringify(inherited[key]) !== JSON.stringify(value))));
442
+ fs.writeFileSync(devPropertiesPath, JSON.stringify(own, null, 2));
443
+ }
444
+ export function readSvcConfig(projectRoot) {
445
+ try {
446
+ return parseJsonc(fs.readFileSync(path.join(projectRoot, '.svcconfig'), 'utf-8'));
447
+ }
448
+ catch {
449
+ return {};
450
+ }
451
+ }
452
+ export function writeSvcConfig(projectRoot, updates) {
453
+ const merged = { ...readSvcConfig(projectRoot), ...updates };
454
+ fs.writeFileSync(path.join(projectRoot, '.svcconfig'), JSON.stringify(merged, null, 2) + '\n');
455
+ }
456
+ // =============================================================================
457
+ // PACKAGE.JSON SYNC
458
+ // =============================================================================
459
+ /**
460
+ * Fields duplicated between .dev_properties.json and package.json, where
461
+ * sitevision-scripts reads them under different names.
462
+ */
463
+ const PACKAGE_JSON_SYNC_KEYS = [
464
+ { packageKey: 'developmentDomain', devKey: 'domain' },
465
+ { packageKey: 'siteName', devKey: 'siteName' },
466
+ { packageKey: 'addonName', devKey: 'addonName' },
467
+ ];
468
+ function readPackageJson(projectRoot) {
469
+ try {
470
+ return JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf-8'));
471
+ }
472
+ catch {
473
+ return null;
474
+ }
475
+ }
476
+ /**
477
+ * Which of the shared fields package.json is missing or disagrees on, relative
478
+ * to the given dev properties. Reads package.json from disk — an earlier
479
+ * `npm install` in the same session may have rewritten it.
480
+ */
481
+ export function getPackageJsonSyncChanges(projectRoot, properties) {
482
+ const packageJson = readPackageJson(projectRoot);
483
+ if (!packageJson)
484
+ return [];
485
+ const changes = [];
486
+ for (const { packageKey, devKey } of PACKAGE_JSON_SYNC_KEYS) {
487
+ const to = properties[devKey];
488
+ if (typeof to !== 'string' || to === '')
489
+ continue;
490
+ const from = packageJson[packageKey];
491
+ if (from !== to) {
492
+ changes.push(from === undefined
493
+ ? { key: packageKey, to }
494
+ : { key: packageKey, from, to });
495
+ }
496
+ }
497
+ return changes;
498
+ }
499
+ /**
500
+ * Copy the shared fields from dev properties into package.json, preserving the
501
+ * file's existing indentation and trailing newline.
502
+ */
503
+ export function syncDevPropertiesToPackageJson(projectRoot, properties) {
504
+ const packageJsonPath = path.join(projectRoot, 'package.json');
505
+ let raw;
506
+ try {
507
+ raw = fs.readFileSync(packageJsonPath, 'utf-8');
508
+ }
509
+ catch {
510
+ return false;
511
+ }
512
+ let packageJson;
513
+ try {
514
+ packageJson = JSON.parse(raw);
515
+ }
516
+ catch {
517
+ return false;
518
+ }
519
+ for (const { packageKey, devKey } of PACKAGE_JSON_SYNC_KEYS) {
520
+ const value = properties[devKey];
521
+ if (typeof value === 'string' && value !== '') {
522
+ packageJson[packageKey] = value;
523
+ }
524
+ }
525
+ const indent = /^(?<indent>[\t ]+)/m.exec(raw)?.groups?.['indent'] ?? '\t';
526
+ const newline = raw.endsWith('\n') ? '\n' : '';
527
+ fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, indent) + newline);
528
+ return true;
282
529
  }
283
530
  /**
284
531
  * Move a plaintext password from .dev_properties.json into the OS keychain and
@@ -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
+ }
@@ -13,6 +13,27 @@ import type { SigningCredentials, DeployConfig, ProductionDeployConfig, SigningR
13
13
  * Create Basic Auth header value
14
14
  */
15
15
  declare function createBasicAuth(username: string, password: string): string;
16
+ type RequestAuth = {
17
+ username: string;
18
+ password: string;
19
+ } | {
20
+ token: string;
21
+ } | {
22
+ cookie: string;
23
+ };
24
+ type AuthKind = 'basic' | 'bearer' | 'cookie';
25
+ /** Single source of the 401 message, worded for the auth kind actually used. */
26
+ declare function unauthorizedMessage(kind: AuthKind): string;
27
+ /** Pick cookie > bearer > basic based on what the deploy config carries. */
28
+ declare function configAuth(config: {
29
+ username: string;
30
+ password?: string;
31
+ accessToken?: string;
32
+ sessionCookie?: string;
33
+ }): {
34
+ auth: RequestAuth;
35
+ kind: AuthKind;
36
+ };
16
37
  /**
17
38
  * Make an HTTP/HTTPS request
18
39
  */
@@ -20,10 +41,7 @@ export declare function makeRequest(url: string, options: {
20
41
  method: string;
21
42
  headers?: Record<string, string>;
22
43
  body?: Buffer;
23
- auth?: {
24
- username: string;
25
- password: string;
26
- };
44
+ auth?: RequestAuth;
27
45
  timeoutMs?: number;
28
46
  }): Promise<{
29
47
  statusCode: number;
@@ -45,6 +63,12 @@ export declare function summarizeErrorBody(body: Buffer, headers: Record<string,
45
63
  * the signing endpoint returns an error page with HTTP 200.
46
64
  */
47
65
  export declare function looksLikeZip(body: Buffer): boolean;
66
+ /**
67
+ * A stale Sitevision session usually answers with a redirect to the login page
68
+ * or a 200 carrying an HTML login form — not a clean 401. Detect both so cookie
69
+ * auth can drop the dead session and re-login instead of showing a generic error.
70
+ */
71
+ export declare function looksLikeAuthExpired(statusCode: number, body: Buffer, headers: Record<string, string>): boolean;
48
72
  /**
49
73
  * Sign an app via developer.sitevision.se
50
74
  *
@@ -85,4 +109,39 @@ export declare function createAddon(config: DeployConfig, appType: SimpleAppType
85
109
  * @param appType - The app type (web, widget, rest)
86
110
  */
87
111
  export declare function activateApp(executableId: string, config: DeployConfig, _appType: SimpleAppType): Promise<ActivationResponse>;
88
- export { createBasicAuth };
112
+ /**
113
+ * A version uploaded to a custom module, as reported by the
114
+ * ActivateCustomModuleExecutable GET endpoint.
115
+ */
116
+ export interface Executable {
117
+ id: string;
118
+ name: string;
119
+ appIdentifier: string;
120
+ appVersion: string;
121
+ active: boolean;
122
+ }
123
+ export interface ListExecutablesResponse {
124
+ success: boolean;
125
+ executables?: Executable[];
126
+ error?: string;
127
+ authExpired?: boolean;
128
+ }
129
+ /**
130
+ * List the executables (uploaded versions) of the configured addon.
131
+ */
132
+ export declare function listExecutables(config: DeployConfig): Promise<ListExecutablesResponse>;
133
+ export interface AddonNode {
134
+ id: string;
135
+ name: string;
136
+ type: string;
137
+ appType?: SimpleAppType;
138
+ }
139
+ /**
140
+ * List the addons (custom modules) in the site's Addon Repository.
141
+ */
142
+ export declare function listAddons(config: DeployConfig): Promise<{
143
+ success: boolean;
144
+ addons?: AddonNode[];
145
+ error?: string;
146
+ }>;
147
+ export { createBasicAuth, configAuth, unauthorizedMessage };