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.
@@ -0,0 +1,74 @@
1
+ /**
2
+ * JSONC helpers.
3
+ *
4
+ * Sitevision's own manifest documentation shows manifest.json with line and
5
+ * block comments (e.g. `"name": { // Multilingual-manifest requires SV 10.1`),
6
+ * so real-world manifests copied from the docs contain them. Strict JSON.parse
7
+ * rejects those, so we strip comments before parsing.
8
+ */
9
+ /**
10
+ * Remove line comments (//...) and block comments from a JSON string, leaving
11
+ * everything inside string literals untouched (so values like
12
+ * `"https://example.com"` survive).
13
+ */
14
+ export function stripJsonComments(input) {
15
+ let result = '';
16
+ let inString = false;
17
+ let inLineComment = false;
18
+ let inBlockComment = false;
19
+ for (let i = 0; i < input.length; i++) {
20
+ const char = input[i];
21
+ const next = input[i + 1];
22
+ if (inLineComment) {
23
+ if (char === '\n') {
24
+ inLineComment = false;
25
+ result += char;
26
+ }
27
+ continue;
28
+ }
29
+ if (inBlockComment) {
30
+ if (char === '*' && next === '/') {
31
+ inBlockComment = false;
32
+ i++;
33
+ }
34
+ continue;
35
+ }
36
+ if (inString) {
37
+ result += char;
38
+ // Copy escaped characters verbatim so an escaped quote (\") does not
39
+ // end the string early.
40
+ if (char === '\\') {
41
+ result += next ?? '';
42
+ i++;
43
+ }
44
+ else if (char === '"') {
45
+ inString = false;
46
+ }
47
+ continue;
48
+ }
49
+ if (char === '"') {
50
+ inString = true;
51
+ result += char;
52
+ continue;
53
+ }
54
+ if (char === '/' && next === '/') {
55
+ inLineComment = true;
56
+ i++;
57
+ continue;
58
+ }
59
+ if (char === '/' && next === '*') {
60
+ inBlockComment = true;
61
+ i++;
62
+ continue;
63
+ }
64
+ result += char;
65
+ }
66
+ return result;
67
+ }
68
+ /**
69
+ * Parse a JSON string that may contain comments (JSONC). Throws the underlying
70
+ * SyntaxError if the content is invalid even after comments are removed.
71
+ */
72
+ export function parseJsonc(input) {
73
+ return JSON.parse(stripJsonComments(input));
74
+ }
@@ -4,3 +4,12 @@ export declare function deleteDeployPassword(domain: string, username: string):
4
4
  export declare function getSigningPassword(username: string): string | null;
5
5
  export declare function setSigningPassword(username: string, password: string): boolean;
6
6
  export declare function deleteSigningPassword(username: string): void;
7
+ export declare function getOAuth2RefreshToken(domain: string, clientId: string): string | null;
8
+ export declare function setOAuth2RefreshToken(domain: string, clientId: string, token: string): boolean;
9
+ export declare function deleteOAuth2RefreshToken(domain: string, clientId: string): void;
10
+ export declare function getOAuth2ClientSecret(domain: string, clientId: string): string | null;
11
+ export declare function setOAuth2ClientSecret(domain: string, clientId: string, secret: string): boolean;
12
+ export declare function deleteOAuth2ClientSecret(domain: string, clientId: string): void;
13
+ export declare function getSessionCookie(domain: string, username: string): string | null;
14
+ export declare function setSessionCookie(domain: string, username: string, cookie: string): boolean;
15
+ export declare function deleteSessionCookie(domain: string, username: string): void;
@@ -6,6 +6,15 @@ function deployAccount(domain, username) {
6
6
  function signingAccount(username) {
7
7
  return `signing:${username}`;
8
8
  }
9
+ function oauthRefreshAccount(domain, clientId) {
10
+ return `oauth2-refresh:${clientId}@${domain}`;
11
+ }
12
+ function oauthSecretAccount(domain, clientId) {
13
+ return `oauth2-secret:${clientId}@${domain}`;
14
+ }
15
+ function sessionCookieAccount(domain, username) {
16
+ return `session:${username}@${domain}`;
17
+ }
9
18
  function safeGet(account) {
10
19
  try {
11
20
  return new Entry(SERVICE, account).getPassword();
@@ -61,3 +70,48 @@ export function deleteSigningPassword(username) {
61
70
  return;
62
71
  safeDelete(signingAccount(username));
63
72
  }
73
+ export function getOAuth2RefreshToken(domain, clientId) {
74
+ if (!domain || !clientId)
75
+ return null;
76
+ return safeGet(oauthRefreshAccount(domain, clientId));
77
+ }
78
+ export function setOAuth2RefreshToken(domain, clientId, token) {
79
+ if (!domain || !clientId || !token)
80
+ return false;
81
+ return safeSet(oauthRefreshAccount(domain, clientId), token);
82
+ }
83
+ export function deleteOAuth2RefreshToken(domain, clientId) {
84
+ if (!domain || !clientId)
85
+ return;
86
+ safeDelete(oauthRefreshAccount(domain, clientId));
87
+ }
88
+ export function getOAuth2ClientSecret(domain, clientId) {
89
+ if (!domain || !clientId)
90
+ return null;
91
+ return safeGet(oauthSecretAccount(domain, clientId));
92
+ }
93
+ export function setOAuth2ClientSecret(domain, clientId, secret) {
94
+ if (!domain || !clientId || !secret)
95
+ return false;
96
+ return safeSet(oauthSecretAccount(domain, clientId), secret);
97
+ }
98
+ export function deleteOAuth2ClientSecret(domain, clientId) {
99
+ if (!domain || !clientId)
100
+ return;
101
+ safeDelete(oauthSecretAccount(domain, clientId));
102
+ }
103
+ export function getSessionCookie(domain, username) {
104
+ if (!domain || !username)
105
+ return null;
106
+ return safeGet(sessionCookieAccount(domain, username));
107
+ }
108
+ export function setSessionCookie(domain, username, cookie) {
109
+ if (!domain || !username || !cookie)
110
+ return false;
111
+ return safeSet(sessionCookieAccount(domain, username), cookie);
112
+ }
113
+ export function deleteSessionCookie(domain, username) {
114
+ if (!domain || !username)
115
+ return;
116
+ safeDelete(sessionCookieAccount(domain, username));
117
+ }
@@ -0,0 +1,27 @@
1
+ import type { DevProperties } from '../types/index.js';
2
+ /** Default loopback port. Fixed so a single redirect URI can be whitelisted. */
3
+ export declare const DEFAULT_REDIRECT_PORT = 8137;
4
+ /** RFC 7636 S256 pair. Exported for testing the challenge derivation. */
5
+ export declare function createPkcePair(): {
6
+ verifier: string;
7
+ challenge: string;
8
+ };
9
+ export declare function openBrowser(url: string): void;
10
+ /**
11
+ * Start an interactive OAuth2 login. Returns the authorize URL to open and a
12
+ * `complete()` that awaits the loopback redirect, exchanges the code, stores the
13
+ * refresh token, and resolves the access token. UI-agnostic, so an Ink screen
14
+ * can drive it without owning the terminal.
15
+ */
16
+ export declare function beginOAuth2Login(dev: DevProperties): {
17
+ authUrl: string;
18
+ complete: () => Promise<string | null>;
19
+ cancel: () => void;
20
+ } | null;
21
+ /**
22
+ * Silently resolve an access token by refreshing the keychain refresh token.
23
+ * Returns null when there's no refresh token or it's expired/revoked (in which
24
+ * case the stale token is dropped). Interactive login lives in `beginOAuth2Login`,
25
+ * driven by the Ink login screen — the access token is never persisted.
26
+ */
27
+ export declare function resolveOAuth2AccessToken(dev: DevProperties): Promise<string | null>;
@@ -0,0 +1,172 @@
1
+ import http from 'http';
2
+ import crypto from 'crypto';
3
+ import { spawn } from 'child_process';
4
+ import { makeRequest } from './sitevision-api.js';
5
+ import { getOAuth2RefreshToken, setOAuth2RefreshToken, deleteOAuth2RefreshToken, getOAuth2ClientSecret, } from './keychain.js';
6
+ /** Default loopback port. Fixed so a single redirect URI can be whitelisted. */
7
+ export const DEFAULT_REDIRECT_PORT = 8137;
8
+ /** Seconds to wait for the user to finish logging in before giving up. */
9
+ const LOGIN_TIMEOUT_MS = 300_000;
10
+ function base64url(buffer) {
11
+ return buffer
12
+ .toString('base64')
13
+ .replaceAll('+', '-')
14
+ .replaceAll('/', '_')
15
+ .replaceAll('=', '');
16
+ }
17
+ /** RFC 7636 S256 pair. Exported for testing the challenge derivation. */
18
+ export function createPkcePair() {
19
+ const verifier = base64url(crypto.randomBytes(32));
20
+ const challenge = base64url(crypto.createHash('sha256').update(verifier).digest());
21
+ return { verifier, challenge };
22
+ }
23
+ function hasOAuth2Config(config) {
24
+ return Boolean(config?.authorizationEndpoint && config.tokenEndpoint && config.clientId);
25
+ }
26
+ async function postToken(config, params, secret) {
27
+ const body = Buffer.from(new URLSearchParams(params).toString());
28
+ try {
29
+ const response = await makeRequest(config.tokenEndpoint, {
30
+ method: 'POST',
31
+ headers: {
32
+ 'Content-Type': 'application/x-www-form-urlencoded',
33
+ 'Content-Length': String(body.length),
34
+ },
35
+ body,
36
+ // client_secret_basic when confidential; public+PKCE clients omit it.
37
+ auth: secret ? { username: config.clientId, password: secret } : undefined,
38
+ });
39
+ if (response.statusCode !== 200)
40
+ return null;
41
+ return JSON.parse(response.body.toString());
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ export function openBrowser(url) {
48
+ const isWin = process.platform === 'win32';
49
+ const cmd = process.platform === 'darwin' ? 'open' : isWin ? 'cmd' : 'xdg-open';
50
+ const args = isWin ? ['/c', 'start', '', url] : [url];
51
+ try {
52
+ spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
53
+ }
54
+ catch {
55
+ // Fall back to the printed URL.
56
+ }
57
+ }
58
+ /**
59
+ * Serve the loopback redirect once. Returns the awaited code and a `close()`
60
+ * that shuts the server down (freeing the port) if the login is cancelled — so
61
+ * a retry doesn't hit an EADDRINUSE on the fixed redirect port.
62
+ */
63
+ function startLoopback(port, state) {
64
+ let finish;
65
+ let settled = false;
66
+ const code = new Promise(resolve => {
67
+ finish = (value) => {
68
+ if (settled)
69
+ return;
70
+ settled = true;
71
+ clearTimeout(timer);
72
+ server.close();
73
+ resolve(value);
74
+ };
75
+ });
76
+ const server = http.createServer((req, res) => {
77
+ const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`);
78
+ if (url.pathname !== '/callback') {
79
+ res.writeHead(404).end();
80
+ return;
81
+ }
82
+ const ok = url.searchParams.get('state') === state;
83
+ const authCode = url.searchParams.get('code');
84
+ const message = ok && authCode
85
+ ? 'Login complete. You can close this window and return to the terminal.'
86
+ : 'Login failed. Check the terminal.';
87
+ res.writeHead(200, { 'Content-Type': 'text/html' });
88
+ res.end(`<!doctype html><meta charset="utf-8"><p>${message}</p>`);
89
+ finish(ok ? authCode : null);
90
+ });
91
+ const timer = setTimeout(() => finish(null), LOGIN_TIMEOUT_MS);
92
+ server.on('error', () => finish(null));
93
+ server.listen(port, '127.0.0.1');
94
+ return { code, close: () => finish(null) };
95
+ }
96
+ /**
97
+ * Start an interactive OAuth2 login. Returns the authorize URL to open and a
98
+ * `complete()` that awaits the loopback redirect, exchanges the code, stores the
99
+ * refresh token, and resolves the access token. UI-agnostic, so an Ink screen
100
+ * can drive it without owning the terminal.
101
+ */
102
+ export function beginOAuth2Login(dev) {
103
+ const config = dev.oauth2;
104
+ if (!hasOAuth2Config(config))
105
+ return null;
106
+ const { domain } = dev;
107
+ const secret = getOAuth2ClientSecret(domain, config.clientId) ?? undefined;
108
+ const port = config.redirectPort ?? DEFAULT_REDIRECT_PORT;
109
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
110
+ const { verifier, challenge } = createPkcePair();
111
+ const state = base64url(crypto.randomBytes(16));
112
+ const url = new URL(config.authorizationEndpoint);
113
+ url.searchParams.set('response_type', 'code');
114
+ url.searchParams.set('client_id', config.clientId);
115
+ url.searchParams.set('redirect_uri', redirectUri);
116
+ url.searchParams.set('state', state);
117
+ url.searchParams.set('code_challenge', challenge);
118
+ url.searchParams.set('code_challenge_method', 'S256');
119
+ if (config.scopes?.length) {
120
+ url.searchParams.set('scope', config.scopes.join(' '));
121
+ }
122
+ const loopback = startLoopback(port, state);
123
+ const complete = async () => {
124
+ const code = await loopback.code;
125
+ if (!code)
126
+ return null;
127
+ const tokens = await postToken(config, {
128
+ grant_type: 'authorization_code',
129
+ code,
130
+ redirect_uri: redirectUri,
131
+ client_id: config.clientId,
132
+ code_verifier: verifier,
133
+ }, secret);
134
+ if (!tokens?.access_token)
135
+ return null;
136
+ if (tokens.refresh_token) {
137
+ setOAuth2RefreshToken(domain, config.clientId, tokens.refresh_token);
138
+ }
139
+ return tokens.access_token;
140
+ };
141
+ return { authUrl: url.href, complete, cancel: loopback.close };
142
+ }
143
+ /**
144
+ * Silently resolve an access token by refreshing the keychain refresh token.
145
+ * Returns null when there's no refresh token or it's expired/revoked (in which
146
+ * case the stale token is dropped). Interactive login lives in `beginOAuth2Login`,
147
+ * driven by the Ink login screen — the access token is never persisted.
148
+ */
149
+ export async function resolveOAuth2AccessToken(dev) {
150
+ const config = dev.oauth2;
151
+ if (!hasOAuth2Config(config))
152
+ return null;
153
+ const { domain } = dev;
154
+ const secret = getOAuth2ClientSecret(domain, config.clientId) ?? undefined;
155
+ const storedRefresh = getOAuth2RefreshToken(domain, config.clientId);
156
+ if (!storedRefresh)
157
+ return null;
158
+ const tokens = await postToken(config, {
159
+ grant_type: 'refresh_token',
160
+ refresh_token: storedRefresh,
161
+ client_id: config.clientId,
162
+ }, secret);
163
+ if (tokens?.access_token) {
164
+ if (tokens.refresh_token) {
165
+ setOAuth2RefreshToken(domain, config.clientId, tokens.refresh_token);
166
+ }
167
+ return tokens.access_token;
168
+ }
169
+ // Stale/expired refresh token — drop it so the next run logs in fresh.
170
+ deleteOAuth2RefreshToken(domain, config.clientId);
171
+ return null;
172
+ }
@@ -3,6 +3,11 @@
3
3
  * Pressing Enter (empty answer) returns `defaultYes` (default: false).
4
4
  */
5
5
  export declare function promptYesNo(prompt: string, defaultYes?: boolean): Promise<boolean>;
6
+ /**
7
+ * Wait for the user to press Enter (or Ctrl+C). Used to hand control to an
8
+ * external browser and resume once the user says they're done.
9
+ */
10
+ export declare function promptEnter(prompt: string): Promise<void>;
6
11
  /**
7
12
  * Prompt for password input with masked display
8
13
  */
@@ -29,6 +29,34 @@ export function promptYesNo(prompt, defaultYes = false) {
29
29
  stdin.on('data', onData);
30
30
  });
31
31
  }
32
+ /**
33
+ * Wait for the user to press Enter (or Ctrl+C). Used to hand control to an
34
+ * external browser and resume once the user says they're done.
35
+ */
36
+ export function promptEnter(prompt) {
37
+ return new Promise(resolve => {
38
+ process.stdout.write(prompt);
39
+ const stdin = process.stdin;
40
+ stdin.setRawMode(true);
41
+ stdin.resume();
42
+ stdin.setEncoding('utf8');
43
+ const onData = (data) => {
44
+ const char = data[0] || '';
45
+ const charCode = char.charCodeAt(0);
46
+ if (charCode === 3) {
47
+ process.exit();
48
+ }
49
+ if (char === '' || charCode === 13 || charCode === 10) {
50
+ stdin.setRawMode(false);
51
+ stdin.removeListener('data', onData);
52
+ stdin.pause();
53
+ process.stdout.write('\n');
54
+ resolve();
55
+ }
56
+ };
57
+ stdin.on('data', onData);
58
+ });
59
+ }
32
60
  /**
33
61
  * Prompt for password input with masked display
34
62
  */
@@ -1,5 +1,15 @@
1
- import type { SitevisionManifest, DevProperties, ProjectInfo, ProjectPaths, SimpleAppType, ApiEndpoints } from '../types/index.js';
1
+ import type { SitevisionManifest, DevProperties, ProjectInfo, ProjectPaths, SimpleAppType, ApiEndpoints, LocalizedString } from '../types/index.js';
2
2
  export type { SitevisionManifest, DevProperties, ProjectInfo, } from '../types/index.js';
3
+ /**
4
+ * Resolve a manifest text field that may be a plain string or a localized
5
+ * object (e.g. `{sv: 'Namn', en: 'Name'}`) to a single display string.
6
+ *
7
+ * Preference order: Swedish, then English, then any available language. Returns
8
+ * an empty string for missing/empty values. This guards the UI from rendering a
9
+ * raw object as a React child, which Sitevision's localized manifests would
10
+ * otherwise trigger.
11
+ */
12
+ export declare function localizedText(value: LocalizedString | undefined, preferred?: string): string;
3
13
  /**
4
14
  * Get standard project paths for a given root directory
5
15
  */
@@ -51,6 +61,22 @@ export declare function buildAddonEndpointUrl(domain: string, siteName: string,
51
61
  * Build the import endpoint URL
52
62
  */
53
63
  export declare function buildImportEndpointUrl(domain: string, siteName: string, addonName: string, appType: SimpleAppType, useHTTP?: boolean): string;
64
+ /**
65
+ * Thrown when a manifest.json is present but cannot be parsed. Kept distinct from
66
+ * a plain "no project here" (null) so the CLI can tell the user their manifest is
67
+ * malformed instead of the misleading "Not a Sitevision project".
68
+ */
69
+ export declare class ManifestParseError extends Error {
70
+ constructor(manifestPath: string, cause: unknown);
71
+ }
72
+ /**
73
+ * Read manifest.json from its supported locations (root, static/, src/).
74
+ * Throws ManifestParseError on malformed JSON.
75
+ */
76
+ export declare function readManifest(cwd: string): {
77
+ manifestPath: string;
78
+ manifest: SitevisionManifest;
79
+ } | null;
54
80
  /**
55
81
  * Detect if the current directory is a Sitevision project
56
82
  */
@@ -72,10 +98,37 @@ export declare function isBundledApp(manifest: SitevisionManifest): boolean;
72
98
  */
73
99
  export declare function readDevProperties(projectRoot: string): DevProperties | null;
74
100
  /**
75
- * Write dev properties to file. The `password` field is never persisted —
76
- * it is held in the OS keychain instead.
101
+ * Write dev properties to file. Secrets are never persisted — `password`,
102
+ * `accessToken` and `sessionCookie` are held in the OS keychain / resolved at
103
+ * runtime instead.
77
104
  */
78
105
  export declare function writeDevProperties(projectRoot: string, properties: DevProperties): void;
106
+ /**
107
+ * CLI preferences stored in .svcconfig at the project root. Unknown keys are
108
+ * preserved on write so hand-edited entries survive.
109
+ */
110
+ export interface SvcConfig {
111
+ syncPackageJson?: boolean;
112
+ [key: string]: unknown;
113
+ }
114
+ export declare function readSvcConfig(projectRoot: string): SvcConfig;
115
+ export declare function writeSvcConfig(projectRoot: string, updates: SvcConfig): void;
116
+ export interface PackageJsonSyncChange {
117
+ key: string;
118
+ from?: string;
119
+ to: string;
120
+ }
121
+ /**
122
+ * Which of the shared fields package.json is missing or disagrees on, relative
123
+ * to the given dev properties. Reads package.json from disk — an earlier
124
+ * `npm install` in the same session may have rewritten it.
125
+ */
126
+ export declare function getPackageJsonSyncChanges(projectRoot: string, properties: DevProperties): PackageJsonSyncChange[];
127
+ /**
128
+ * Copy the shared fields from dev properties into package.json, preserving the
129
+ * file's existing indentation and trailing newline.
130
+ */
131
+ export declare function syncDevPropertiesToPackageJson(projectRoot: string, properties: DevProperties): boolean;
79
132
  /**
80
133
  * Move a plaintext password from .dev_properties.json into the OS keychain and
81
134
  * strip it from the file. Returns true if the password was migrated.