sitevision-cli 1.0.0-beta.0 → 1.0.0-beta.10

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.
@@ -1,6 +1,26 @@
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
+ // =============================================================================
6
+ // LOCALIZED TEXT
7
+ // =============================================================================
8
+ /**
9
+ * Resolve a manifest text field that may be a plain string or a localized
10
+ * object (e.g. `{sv: 'Namn', en: 'Name'}`) to a single display string.
11
+ *
12
+ * Preference order: Swedish, then English, then any available language. Returns
13
+ * an empty string for missing/empty values. This guards the UI from rendering a
14
+ * raw object as a React child, which Sitevision's localized manifests would
15
+ * otherwise trigger.
16
+ */
17
+ export function localizedText(value, preferred = 'sv') {
18
+ if (!value)
19
+ return '';
20
+ if (typeof value === 'string')
21
+ return value;
22
+ return value[preferred] ?? value['en'] ?? Object.values(value)[0] ?? '';
23
+ }
4
24
  // =============================================================================
5
25
  // PATH UTILITIES
6
26
  // =============================================================================
@@ -135,29 +155,56 @@ export function buildImportEndpointUrl(domain, siteName, addonName, appType, use
135
155
  // =============================================================================
136
156
  // PROJECT DETECTION
137
157
  // =============================================================================
158
+ /**
159
+ * Thrown when a manifest.json is present but cannot be parsed. Kept distinct from
160
+ * a plain "no project here" (null) so the CLI can tell the user their manifest is
161
+ * malformed instead of the misleading "Not a Sitevision project".
162
+ */
163
+ export class ManifestParseError extends Error {
164
+ constructor(manifestPath, cause) {
165
+ const reason = cause instanceof Error ? cause.message : String(cause);
166
+ super(`${manifestPath} is not valid JSON: ${reason}`);
167
+ this.name = 'ManifestParseError';
168
+ }
169
+ }
170
+ /**
171
+ * Read manifest.json from its supported locations (root, static/, src/).
172
+ * Throws ManifestParseError on malformed JSON.
173
+ */
174
+ export function readManifest(cwd) {
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
+ for (const manifestPath of manifestPaths) {
181
+ if (!fs.existsSync(manifestPath)) {
182
+ continue;
183
+ }
184
+ // Manifests may contain comments (Sitevision's own docs show them), so
185
+ // parse as JSONC.
186
+ try {
187
+ return {
188
+ manifestPath,
189
+ manifest: parseJsonc(fs.readFileSync(manifestPath, 'utf-8')),
190
+ };
191
+ }
192
+ catch (error) {
193
+ throw new ManifestParseError(manifestPath, error);
194
+ }
195
+ }
196
+ return null;
197
+ }
138
198
  /**
139
199
  * Detect if the current directory is a Sitevision project
140
200
  */
141
201
  export function detectProject(cwd = process.cwd()) {
142
202
  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) {
203
+ const found = readManifest(cwd);
204
+ if (!found) {
159
205
  return null;
160
206
  }
207
+ const { manifestPath, manifest } = found;
161
208
  // Check for package.json
162
209
  const packageJsonPath = path.join(cwd, 'package.json');
163
210
  if (!fs.existsSync(packageJsonPath)) {
@@ -197,6 +244,25 @@ export function detectProject(cwd = process.cwd()) {
197
244
  }
198
245
  }
199
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
+ }
200
266
  }
201
267
  catch {
202
268
  // Invalid dev properties file
@@ -219,7 +285,12 @@ export function detectProject(cwd = process.cwd()) {
219
285
  paths,
220
286
  };
221
287
  }
222
- catch {
288
+ catch (error) {
289
+ // A malformed manifest is a real error the user should see; everything else
290
+ // (missing files, unreadable optional config) just means "no project here".
291
+ if (error instanceof ManifestParseError) {
292
+ throw error;
293
+ }
223
294
  return null;
224
295
  }
225
296
  }
@@ -271,15 +342,102 @@ export function readDevProperties(projectRoot) {
271
342
  }
272
343
  }
273
344
  /**
274
- * Write dev properties to file. The `password` field is never persisted —
275
- * it is held in the OS keychain instead.
345
+ * Write dev properties to file. Secrets are never persisted — `password`,
346
+ * `accessToken` and `sessionCookie` are held in the OS keychain / resolved at
347
+ * runtime instead.
276
348
  */
277
349
  export function writeDevProperties(projectRoot, properties) {
278
350
  const devPropertiesPath = findDevPropertiesPath(projectRoot) ||
279
351
  getDefaultDevPropertiesPath(projectRoot);
280
- const { password: _password, ...persisted } = properties;
352
+ const { password: _password, accessToken: _accessToken, sessionCookie: _sessionCookie, ...persisted } = properties;
281
353
  fs.writeFileSync(devPropertiesPath, JSON.stringify(persisted, null, 2));
282
354
  }
355
+ export function readSvcConfig(projectRoot) {
356
+ try {
357
+ return parseJsonc(fs.readFileSync(path.join(projectRoot, '.svcconfig'), 'utf-8'));
358
+ }
359
+ catch {
360
+ return {};
361
+ }
362
+ }
363
+ export function writeSvcConfig(projectRoot, updates) {
364
+ const merged = { ...readSvcConfig(projectRoot), ...updates };
365
+ fs.writeFileSync(path.join(projectRoot, '.svcconfig'), JSON.stringify(merged, null, 2) + '\n');
366
+ }
367
+ // =============================================================================
368
+ // PACKAGE.JSON SYNC
369
+ // =============================================================================
370
+ /**
371
+ * Fields duplicated between .dev_properties.json and package.json, where
372
+ * sitevision-scripts reads them under different names.
373
+ */
374
+ const PACKAGE_JSON_SYNC_KEYS = [
375
+ { packageKey: 'developmentDomain', devKey: 'domain' },
376
+ { packageKey: 'siteName', devKey: 'siteName' },
377
+ { packageKey: 'addonName', devKey: 'addonName' },
378
+ ];
379
+ function readPackageJson(projectRoot) {
380
+ try {
381
+ return JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf-8'));
382
+ }
383
+ catch {
384
+ return null;
385
+ }
386
+ }
387
+ /**
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.
391
+ */
392
+ export function getPackageJsonSyncChanges(projectRoot, properties) {
393
+ const packageJson = readPackageJson(projectRoot);
394
+ if (!packageJson)
395
+ 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 });
406
+ }
407
+ }
408
+ return changes;
409
+ }
410
+ /**
411
+ * Copy the shared fields from dev properties into package.json, preserving the
412
+ * file's existing indentation and trailing newline.
413
+ */
414
+ export function syncDevPropertiesToPackageJson(projectRoot, properties) {
415
+ const packageJsonPath = path.join(projectRoot, 'package.json');
416
+ let raw;
417
+ try {
418
+ raw = fs.readFileSync(packageJsonPath, 'utf-8');
419
+ }
420
+ catch {
421
+ return false;
422
+ }
423
+ let packageJson;
424
+ try {
425
+ packageJson = JSON.parse(raw);
426
+ }
427
+ catch {
428
+ 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;
434
+ }
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);
439
+ return true;
440
+ }
283
441
  /**
284
442
  * Move a plaintext password from .dev_properties.json into the OS keychain and
285
443
  * strip it from the file. Returns true if the password was migrated.
@@ -0,0 +1,34 @@
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
+ }
16
+ /**
17
+ * Pick the session from a cookie jar: find JSESSIONID (preferring the deploy
18
+ * host), then return every cookie on that host as a `Cookie:` header. On miss,
19
+ * return diagnostics naming the domains actually seen. Pure — no keychain, no
20
+ * browser — so it's unit-testable.
21
+ */
22
+ export declare function selectSessionCookie(all: RawCookie[], siteDomain: string): CaptureResult;
23
+ /**
24
+ * Launch a real browser at the login URL for an interactive SAML/SSO login and
25
+ * return handles to capture the session and close the browser. UI-agnostic: the
26
+ * Ink login screen decides when to `capture()` (on the user's keypress) and
27
+ * `close()`. Returns null if the browser can't be launched.
28
+ *
29
+ * `capture()` reads the whole cookie jar via CDP (httponly and secure included),
30
+ * stores the session in the keychain on success, and otherwise returns
31
+ * diagnostics naming the cookie domains it actually saw.
32
+ */
33
+ export declare function beginCookieLogin(dev: DevProperties): Promise<CookieLoginSession | null>;
34
+ 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 || !username)
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 };
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,4 @@ 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
+ export { createBasicAuth, configAuth, unauthorizedMessage };