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
@@ -0,0 +1,242 @@
1
+ import http from 'http';
2
+ import crypto from 'crypto';
3
+ import open from 'open';
4
+ import { makeRequest, summarizeErrorBody } 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
+ // Sitevision's provider grants everything under ALL; offline_access adds the
9
+ // refresh token so later runs log in silently.
10
+ export const DEFAULT_SCOPES = ['ALL', 'offline_access'];
11
+ /** Seconds to wait for the user to finish logging in before giving up. */
12
+ const LOGIN_TIMEOUT_MS = 300_000;
13
+ function base64url(buffer) {
14
+ return buffer
15
+ .toString('base64')
16
+ .replaceAll('+', '-')
17
+ .replaceAll('/', '_')
18
+ .replaceAll('=', '');
19
+ }
20
+ /** RFC 7636 S256 pair. Exported for testing the challenge derivation. */
21
+ export function createPkcePair() {
22
+ const verifier = base64url(crypto.randomBytes(32));
23
+ const challenge = base64url(crypto.createHash('sha256').update(verifier).digest());
24
+ return { verifier, challenge };
25
+ }
26
+ function hasOAuth2Config(config) {
27
+ return Boolean(config?.authorizationEndpoint && config.tokenEndpoint && config.clientId);
28
+ }
29
+ async function postToken(config, params, secret) {
30
+ const body = Buffer.from(new URLSearchParams(params).toString());
31
+ try {
32
+ const response = await makeRequest(config.tokenEndpoint, {
33
+ method: 'POST',
34
+ headers: {
35
+ 'Content-Type': 'application/x-www-form-urlencoded',
36
+ 'Content-Length': String(body.length),
37
+ },
38
+ body,
39
+ // client_secret_basic when confidential; public+PKCE clients omit it.
40
+ auth: secret ? { username: config.clientId, password: secret } : undefined,
41
+ });
42
+ if (response.statusCode !== 200) {
43
+ return {
44
+ error: `Token endpoint returned ${response.statusCode}: ${summarizeErrorBody(response.body, response.headers)}`,
45
+ };
46
+ }
47
+ return { tokens: JSON.parse(response.body.toString()) };
48
+ }
49
+ catch (error) {
50
+ return {
51
+ error: `Token request failed: ${error instanceof Error ? error.message : String(error)}`,
52
+ };
53
+ }
54
+ }
55
+ /** OpenID configuration path (published at the issuer root once the provider is saved). */
56
+ const DISCOVERY_PATH = '/.well-known/openid-configuration';
57
+ /**
58
+ * Fetch the site's OpenID configuration (unauthenticated) to auto-fill the
59
+ * authorization/token endpoints. Returns null if it isn't published (provider
60
+ * not enabled) or the response isn't a valid config, so callers fall back to
61
+ * manual entry.
62
+ */
63
+ export async function discoverOAuth2Config(domain, useHTTP = false) {
64
+ if (!domain)
65
+ return null;
66
+ const protocol = useHTTP ? 'http' : 'https';
67
+ try {
68
+ const response = await makeRequest(`${protocol}://${domain}${DISCOVERY_PATH}`, { method: 'GET' });
69
+ if (response.statusCode !== 200)
70
+ return null;
71
+ const doc = JSON.parse(response.body.toString());
72
+ if (!doc.authorization_endpoint || !doc.token_endpoint)
73
+ return null;
74
+ return {
75
+ authorizationEndpoint: doc.authorization_endpoint,
76
+ tokenEndpoint: doc.token_endpoint,
77
+ scopesSupported: Array.isArray(doc.scopes_supported)
78
+ ? doc.scopes_supported
79
+ : undefined,
80
+ };
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ }
86
+ export function openBrowser(url) {
87
+ // Preserve the full OAuth URL, including & and percent-encoded parameters,
88
+ // through Windows shell parsing. `open` handles platform-specific escaping.
89
+ void open(url).catch(() => {
90
+ // Fall back to the printed URL.
91
+ });
92
+ }
93
+ /**
94
+ * Turn a redirect's query params into a result, prioritizing the provider's own
95
+ * error (the most useful reason) over a generic "no code". Exported for testing.
96
+ */
97
+ export function classifyRedirect(params) {
98
+ if (params.error) {
99
+ return {
100
+ error: `The OAuth2 provider rejected the login: ${params.errorDescription
101
+ ? `${params.error} — ${params.errorDescription}`
102
+ : params.error}`,
103
+ };
104
+ }
105
+ if (params.state !== params.expectedState) {
106
+ return {
107
+ error: 'State mismatch — the login response did not match this request (a stale browser tab, or the wrong window).',
108
+ };
109
+ }
110
+ if (params.code) {
111
+ return { code: params.code };
112
+ }
113
+ return { error: 'No authorization code was returned by the provider.' };
114
+ }
115
+ function escapeHtml(text) {
116
+ return text
117
+ .replaceAll('&', '&')
118
+ .replaceAll('<', '&lt;')
119
+ .replaceAll('>', '&gt;');
120
+ }
121
+ function startLoopback(port, state) {
122
+ let finish;
123
+ let settled = false;
124
+ const result = new Promise(resolve => {
125
+ finish = (value) => {
126
+ if (settled)
127
+ return;
128
+ settled = true;
129
+ clearTimeout(timer);
130
+ server.close();
131
+ resolve(value);
132
+ };
133
+ });
134
+ const server = http.createServer((req, res) => {
135
+ const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`);
136
+ if (url.pathname !== '/callback') {
137
+ res.writeHead(404).end();
138
+ return;
139
+ }
140
+ const outcome = classifyRedirect({
141
+ expectedState: state,
142
+ state: url.searchParams.get('state'),
143
+ error: url.searchParams.get('error'),
144
+ errorDescription: url.searchParams.get('error_description'),
145
+ code: url.searchParams.get('code'),
146
+ });
147
+ const message = outcome.code
148
+ ? 'Login complete. You can close this window and return to the terminal.'
149
+ : `Login failed: ${outcome.error}`;
150
+ res.writeHead(200, { 'Content-Type': 'text/html' });
151
+ res.end(`<!doctype html><meta charset="utf-8"><p>${escapeHtml(message)}</p>`);
152
+ finish(outcome);
153
+ });
154
+ const timer = setTimeout(() => finish({ error: 'Timed out waiting for the login to complete.' }), LOGIN_TIMEOUT_MS);
155
+ server.on('error', error => finish({
156
+ error: `Local login server error: ${error instanceof Error ? error.message : String(error)}`,
157
+ }));
158
+ server.listen(port, '127.0.0.1');
159
+ return {
160
+ result,
161
+ close: (reason) => finish({ error: reason ?? 'Login cancelled.' }),
162
+ };
163
+ }
164
+ /**
165
+ * Start an interactive OAuth2 login. Returns the authorize URL to open and a
166
+ * `complete()` that awaits the loopback redirect, exchanges the code, stores the
167
+ * refresh token, and resolves the access token. UI-agnostic, so an Ink screen
168
+ * can drive it without owning the terminal.
169
+ */
170
+ export function beginOAuth2Login(dev) {
171
+ const config = dev.oauth2;
172
+ if (!hasOAuth2Config(config))
173
+ return null;
174
+ const { domain } = dev;
175
+ const secret = getOAuth2ClientSecret(domain, config.clientId) ?? undefined;
176
+ const port = config.redirectPort ?? DEFAULT_REDIRECT_PORT;
177
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
178
+ const { verifier, challenge } = createPkcePair();
179
+ const state = base64url(crypto.randomBytes(16));
180
+ const url = new URL(config.authorizationEndpoint);
181
+ url.searchParams.set('response_type', 'code');
182
+ url.searchParams.set('client_id', config.clientId);
183
+ url.searchParams.set('redirect_uri', redirectUri);
184
+ url.searchParams.set('state', state);
185
+ url.searchParams.set('code_challenge', challenge);
186
+ url.searchParams.set('code_challenge_method', 'S256');
187
+ url.searchParams.set('scope', (config.scopes ?? DEFAULT_SCOPES).join(' '));
188
+ const loopback = startLoopback(port, state);
189
+ const complete = async () => {
190
+ const redirect = await loopback.result;
191
+ if (redirect.error || !redirect.code) {
192
+ return { error: redirect.error ?? 'Login failed.' };
193
+ }
194
+ const { tokens, error } = await postToken(config, {
195
+ grant_type: 'authorization_code',
196
+ code: redirect.code,
197
+ redirect_uri: redirectUri,
198
+ client_id: config.clientId,
199
+ code_verifier: verifier,
200
+ }, secret);
201
+ if (error)
202
+ return { error };
203
+ if (!tokens?.access_token) {
204
+ return { error: 'The token endpoint did not return an access token.' };
205
+ }
206
+ if (tokens.refresh_token) {
207
+ setOAuth2RefreshToken(domain, config.clientId, tokens.refresh_token);
208
+ }
209
+ return { token: tokens.access_token };
210
+ };
211
+ return { authUrl: url.href, complete, cancel: () => loopback.close() };
212
+ }
213
+ /**
214
+ * Silently resolve an access token by refreshing the keychain refresh token.
215
+ * Returns null when there's no refresh token or it's expired/revoked (in which
216
+ * case the stale token is dropped). Interactive login lives in `beginOAuth2Login`,
217
+ * driven by the Ink login screen — the access token is never persisted.
218
+ */
219
+ export async function resolveOAuth2AccessToken(dev) {
220
+ const config = dev.oauth2;
221
+ if (!hasOAuth2Config(config))
222
+ return null;
223
+ const { domain } = dev;
224
+ const secret = getOAuth2ClientSecret(domain, config.clientId) ?? undefined;
225
+ const storedRefresh = getOAuth2RefreshToken(domain, config.clientId);
226
+ if (!storedRefresh)
227
+ return null;
228
+ const { tokens } = await postToken(config, {
229
+ grant_type: 'refresh_token',
230
+ refresh_token: storedRefresh,
231
+ client_id: config.clientId,
232
+ }, secret);
233
+ if (tokens?.access_token) {
234
+ if (tokens.refresh_token) {
235
+ setOAuth2RefreshToken(domain, config.clientId, tokens.refresh_token);
236
+ }
237
+ return tokens.access_token;
238
+ }
239
+ // Stale/expired refresh token — drop it so the next run logs in fresh.
240
+ deleteOAuth2RefreshToken(domain, config.clientId);
241
+ return null;
242
+ }
@@ -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
  */
@@ -12,6 +22,23 @@ export declare function findDevPropertiesPath(root: string): string | null;
12
22
  * Get the default dev properties path (for creating new files)
13
23
  */
14
24
  export declare function getDefaultDevPropertiesPath(root: string): string;
25
+ /** A bare host: no scheme, no path, no stray spaces. */
26
+ export declare function normalizeDomain(value: string): string;
27
+ /**
28
+ * The dev properties a workspace root defines (its own file merged over any
29
+ * ancestors'), with the deploy password resolved from the keychain like an
30
+ * app's would be. Used to edit shared config from the shell.
31
+ */
32
+ export declare function readWorkspaceDevProperties(root: string): Partial<DevProperties>;
33
+ /** Ancestor directories' .dev_properties.json merged, nearest wins (no own file). */
34
+ export declare function readAncestorDevProperties(root: string): Partial<DevProperties>;
35
+ /** Shared defaults from package.json: the workspace root's first, the app's on top. */
36
+ export declare function readPackageDefaultsChain(root: string): Partial<DevProperties>;
37
+ /**
38
+ * Everything an app's own .dev_properties.json sits on top of: package.json
39
+ * defaults (root, then app), then ancestor .dev_properties.json files.
40
+ */
41
+ export declare function readInheritedDevProperties(root: string): Partial<DevProperties>;
15
42
  /**
16
43
  * Get the full app ID including any prefix/suffix from environment
17
44
  *
@@ -51,16 +78,45 @@ export declare function buildAddonEndpointUrl(domain: string, siteName: string,
51
78
  * Build the import endpoint URL
52
79
  */
53
80
  export declare function buildImportEndpointUrl(domain: string, siteName: string, addonName: string, appType: SimpleAppType, useHTTP?: boolean): string;
81
+ /**
82
+ * Thrown when a manifest.json is present but cannot be parsed. Kept distinct from
83
+ * a plain "no project here" (null) so the CLI can tell the user their manifest is
84
+ * malformed instead of the misleading "Not a Sitevision project".
85
+ */
86
+ export declare class ManifestParseError extends Error {
87
+ constructor(manifestPath: string, cause: unknown);
88
+ }
89
+ /**
90
+ * Read manifest.json from its supported locations (root, static/, src/).
91
+ * Throws ManifestParseError on malformed JSON.
92
+ */
93
+ export declare function readManifest(cwd: string): {
94
+ manifestPath: string;
95
+ manifest: SitevisionManifest;
96
+ } | null;
54
97
  /**
55
98
  * Detect if the current directory is a Sitevision project
56
99
  */
57
100
  export declare function detectProject(cwd?: string): ProjectInfo | null;
101
+ /**
102
+ * Fill the runtime-only credential fields for the given domain/username:
103
+ * deploy password (env var > keychain), OAuth2 access token (env var), and
104
+ * session cookie (env var > keychain). Mutates and returns `dev`.
105
+ */
106
+ export declare function resolveRuntimeSecrets(dev: DevProperties): DevProperties;
58
107
  /**
59
108
  * Validate that we're in a Sitevision project directory
60
109
  */
61
110
  export declare function requireProject(cwd?: string): ProjectInfo;
62
111
  /**
63
- * Get the app type (web, widget, rest)
112
+ * The app type (web, widget, rest, mcp), or undefined for a manifest type
113
+ * this CLI does not know. Display code uses this so one odd app never takes
114
+ * the whole shell down.
115
+ */
116
+ export declare function appTypeOf(manifest: SitevisionManifest): SimpleAppType | undefined;
117
+ /**
118
+ * Get the app type (web, widget, rest, mcp). Throws for unknown types, since
119
+ * build and deploy cannot proceed without knowing the endpoints.
64
120
  */
65
121
  export declare function getAppType(manifest: SitevisionManifest): SimpleAppType;
66
122
  /**
@@ -72,10 +128,53 @@ export declare function isBundledApp(manifest: SitevisionManifest): boolean;
72
128
  */
73
129
  export declare function readDevProperties(projectRoot: string): DevProperties | null;
74
130
  /**
75
- * Write dev properties to file. The `password` field is never persisted —
76
- * it is held in the OS keychain instead.
77
- */
78
- export declare function writeDevProperties(projectRoot: string, properties: DevProperties): void;
131
+ * Write dev properties to file. Secrets are never persisted — `password`,
132
+ * `accessToken` and `sessionCookie` are held in the OS keychain / resolved at
133
+ * runtime instead.
134
+ */
135
+ export declare function writeDevProperties(projectRoot: string, properties: DevProperties, { complete }?: {
136
+ complete?: boolean;
137
+ }): void;
138
+ /**
139
+ * CLI preferences stored in .svcconfig at the project root. Unknown keys are
140
+ * preserved on write so hand-edited entries survive.
141
+ */
142
+ export interface SvcConfig {
143
+ syncPackageJson?: boolean;
144
+ environment?: string;
145
+ [key: string]: unknown;
146
+ }
147
+ export declare function readSvcConfig(projectRoot: string): SvcConfig;
148
+ export declare function writeSvcConfig(projectRoot: string, updates: SvcConfig): void;
149
+ /**
150
+ * Values tied to the person running svc. They stay in .dev_properties.json and
151
+ * never go into package.json.
152
+ */
153
+ export declare const USER_KEYS: string[];
154
+ export interface PackageJsonSyncChange {
155
+ key: string;
156
+ from?: string;
157
+ to: string;
158
+ }
159
+ /** The shared defaults one directory's package.json provides. */
160
+ export declare function readPackageDefaults(dir: string): Partial<DevProperties>;
161
+ /** What an unset field means, so spelling out the default is not a change. */
162
+ export declare const IMPLICIT_VALUES: Record<string, unknown>;
163
+ /** What syncing this directory would change in its package.json. */
164
+ export declare function getPackageJsonSyncChanges(dir: string): PackageJsonSyncChange[];
165
+ export declare function hasPackageJson(dir: string): boolean;
166
+ /**
167
+ * Copy the pending shared values from .dev_properties.json into package.json,
168
+ * creating package.json when the directory has none. Throws if it cannot be
169
+ * read or written.
170
+ */
171
+ export declare function syncDevPropertiesToPackageJson(dir: string): boolean;
172
+ /**
173
+ * Edit package.json in place, keeping its indentation and trailing newline. A
174
+ * missing file is created; an unreadable or invalid one throws, so the caller
175
+ * can warn instead of dropping the change silently.
176
+ */
177
+ export declare function updatePackageJson(dir: string, mutate: (packageJson: Record<string, unknown>) => void): void;
79
178
  /**
80
179
  * Move a plaintext password from .dev_properties.json into the OS keychain and
81
180
  * strip it from the file. Returns true if the password was migrated.