sitevision-cli 1.0.0-beta.4 → 1.0.0-beta.6

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,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useState, useEffect } from 'react';
3
3
  import { Box, Text, useInput } from 'ink';
4
- import { getAppType, localizedText, migrateLegacyPassword, } from '../utils/project-detection.js';
4
+ import { getAppType, localizedText, migrateLegacyPassword, getPackageJsonSyncChanges, syncDevPropertiesToPackageJson, readSvcConfig, writeSvcConfig, writeDevProperties, } from '../utils/project-detection.js';
5
5
  import { ProcessRunner } from '../utils/process-runner.js';
6
6
  import { ProcessOutputComponent } from './ProcessOutput.js';
7
7
  import { StatusIndicator } from './StatusIndicator.js';
@@ -11,6 +11,11 @@ export function SetupFlow({ project, onReload, onComplete }) {
11
11
  const [step, setStep] = useState('check-node-modules');
12
12
  const [runner, setRunner] = useState(null);
13
13
  const [commandStatus, setCommandStatus] = useState('running');
14
+ const [syncChanges, setSyncChanges] = useState([]);
15
+ const [syncDecision, setSyncDecision] = useState(false);
16
+ // Set when the user picks OAuth2 for an existing config, so the form opens in
17
+ // the OAuth2 branch without mutating the shared project object.
18
+ const [pendingAuthMethod, setPendingAuthMethod] = useState(undefined);
14
19
  const appType = getAppType(project.manifest);
15
20
  // Auto-advance through checks
16
21
  useEffect(() => {
@@ -27,14 +32,36 @@ export function SetupFlow({ project, onReload, onComplete }) {
27
32
  if (project.hasLegacyPassword) {
28
33
  setStep('confirm-password-migration');
29
34
  }
35
+ else if (project.devProperties?.authMethod === undefined) {
36
+ setStep('confirm-auth-method');
37
+ }
30
38
  else {
31
- setStep('check-signing-properties');
39
+ setStep('check-package-sync');
32
40
  }
33
41
  }
34
42
  else {
35
43
  setStep('confirm-dev-setup');
36
44
  }
37
45
  }
46
+ else if (step === 'check-package-sync') {
47
+ const preference = readSvcConfig(project.root).syncPackageJson;
48
+ const properties = project.devProperties;
49
+ const changes = preference !== false && properties
50
+ ? getPackageJsonSyncChanges(project.root, properties)
51
+ : [];
52
+ if (changes.length === 0 || !properties) {
53
+ setStep('check-signing-properties');
54
+ }
55
+ else if (preference === true) {
56
+ syncDevPropertiesToPackageJson(project.root, properties);
57
+ onReload();
58
+ setStep('check-signing-properties');
59
+ }
60
+ else {
61
+ setSyncChanges(changes);
62
+ setStep('confirm-package-sync');
63
+ }
64
+ }
38
65
  else if (step === 'check-signing-properties') {
39
66
  if (project.hasSigningProperties) {
40
67
  setStep('show-info');
@@ -87,6 +114,26 @@ export function SetupFlow({ project, onReload, onComplete }) {
87
114
  // The file was rewritten (plaintext stripped, password moved to
88
115
  // keychain) — re-detect so hasLegacyPassword/password reflect that.
89
116
  onReload();
117
+ setStep('check-package-sync');
118
+ }
119
+ else if (input === 'n' || input === 'N') {
120
+ setStep('check-package-sync');
121
+ }
122
+ }
123
+ else if (step === 'confirm-package-sync') {
124
+ if (['y', 'Y', 'n', 'N'].includes(input)) {
125
+ const accepted = input.toLowerCase() === 'y';
126
+ if (accepted && project.devProperties) {
127
+ syncDevPropertiesToPackageJson(project.root, project.devProperties);
128
+ onReload();
129
+ }
130
+ setSyncDecision(accepted);
131
+ setStep('confirm-save-sync-choice');
132
+ }
133
+ }
134
+ else if (step === 'confirm-save-sync-choice') {
135
+ if (input === 'y' || input === 'Y') {
136
+ writeSvcConfig(project.root, { syncPackageJson: syncDecision });
90
137
  setStep('check-signing-properties');
91
138
  }
92
139
  else if (input === 'n' || input === 'N') {
@@ -101,15 +148,38 @@ export function SetupFlow({ project, onReload, onComplete }) {
101
148
  setStep('show-info');
102
149
  }
103
150
  }
151
+ else if (step === 'confirm-auth-method') {
152
+ if (input === 'o' || input === 'O') {
153
+ // Choosing OAuth2 needs endpoints — collect them in the full form.
154
+ setPendingAuthMethod('oauth2');
155
+ setStep('setup-dev-properties');
156
+ }
157
+ else if (input === 'c' || input === 'C') {
158
+ setPendingAuthMethod('cookie');
159
+ setStep('setup-dev-properties');
160
+ }
161
+ else if (['b', 'B', '\r'].includes(input)) {
162
+ if (project.devProperties) {
163
+ writeDevProperties(project.root, {
164
+ ...project.devProperties,
165
+ authMethod: 'basic',
166
+ });
167
+ onReload();
168
+ }
169
+ setStep('check-package-sync');
170
+ }
171
+ }
104
172
  });
105
173
  // Setup Dev Properties Form
106
174
  if (step === 'setup-dev-properties') {
107
- return (_jsx(DevPropertiesForm, { projectRoot: project.root, initialProperties: project.devProperties, packageJson: project.packageJson, onComplete: () => {
175
+ return (_jsx(DevPropertiesForm, { projectRoot: project.root, initialProperties: pendingAuthMethod && project.devProperties
176
+ ? { ...project.devProperties, authMethod: pendingAuthMethod }
177
+ : project.devProperties, packageJson: project.packageJson, onComplete: () => {
108
178
  // Re-detect from disk/keychain so devProperties (incl. the keychain
109
179
  // password) populate in memory — otherwise the rest of this flow and
110
180
  // the menu would see stale state until the CLI is restarted.
111
181
  onReload();
112
- setStep('check-signing-properties');
182
+ setStep('check-package-sync');
113
183
  }, onCancel: () => setStep('check-signing-properties') }));
114
184
  }
115
185
  // Setup Signing Properties Form
@@ -137,6 +207,20 @@ export function SetupFlow({ project, onReload, onComplete }) {
137
207
  if (step === 'confirm-password-migration') {
138
208
  return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Sitevision CLI" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "yellow", children: "\u26A0 Plaintext password found in .dev_properties.json" }) }), _jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsx(Text, { children: "Move it to the OS keychain and remove it from the file? (y/n)" }), _jsx(Text, { dimColor: true, children: "Recommended \u2014 storing passwords in project files is insecure." })] })] }));
139
209
  }
210
+ // Ask which auth method an existing config should use (setting is missing)
211
+ if (step === 'confirm-auth-method') {
212
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Sitevision CLI" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "yellow", children: "\u26A0 Deploy authentication method not set" }) }), _jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsx(Text, { children: "Which method should deploys use? [B]asic / [O]Auth2 / [C]ookie" }), _jsx(Text, { dimColor: true, children: "B = Basic (default), O = OAuth2 bearer, C = session cookie (SSO)." })] })] }));
213
+ }
214
+ // Confirm package.json sync
215
+ if (step === 'confirm-package-sync') {
216
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Sitevision CLI" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "yellow", children: "\u26A0 package.json is out of sync with .dev_properties.json" }) }), _jsx(Box, { marginBottom: 1, flexDirection: "column", marginLeft: 2, children: syncChanges.map(change => (_jsxs(Box, { children: [_jsx(Text, { color: change.from === undefined ? 'green' : 'yellow', children: change.from === undefined ? '+ ' : '~ ' }), _jsxs(Text, { bold: true, children: [change.key, ": "] }), change.from !== undefined && (_jsxs(Text, { dimColor: true, children: [change.from, " \u2192 "] })), _jsx(Text, { children: change.to })] }, change.key))) }), _jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsx(Text, { children: "Update package.json from .dev_properties.json? (y/n)" }), _jsx(Text, { dimColor: true, children: "sitevision-scripts reads these fields from package.json." })] })] }));
217
+ }
218
+ // Offer to persist the sync decision in .svcconfig
219
+ if (step === 'confirm-save-sync-choice') {
220
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Sitevision CLI" }) }), _jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsx(Text, { children: "Remember this choice in .svcconfig? (y/n)" }), _jsx(Text, { dimColor: true, children: syncDecision
221
+ ? 'svc will update package.json automatically from now on.'
222
+ : 'svc will stop asking about package.json sync.' })] })] }));
223
+ }
140
224
  // Confirm signing setup
141
225
  if (step === 'confirm-signing-setup') {
142
226
  return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Sitevision CLI" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "yellow", children: "\u26A0 signing credentials not configured" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Signing credentials are required for signing apps on developer.sitevision.se" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Would you like to set up signing credentials? (y/n)" }) })] }));
@@ -48,6 +48,24 @@ export interface DevProperties {
48
48
  useHTTPForDevDeploy?: boolean;
49
49
  signingUsername?: string;
50
50
  certificateName?: string;
51
+ authMethod?: 'basic' | 'oauth2' | 'cookie';
52
+ oauth2?: OAuth2Config;
53
+ sessionLoginUrl?: string;
54
+ accessToken?: string;
55
+ sessionCookie?: string;
56
+ }
57
+ /**
58
+ * OAuth2 provider config for bearer-token deploys.
59
+ *
60
+ * Deliberately secret-free: the client secret and refresh token live in the OS
61
+ * keychain, so nothing here is unsafe to write to .dev_properties.json.
62
+ */
63
+ export interface OAuth2Config {
64
+ authorizationEndpoint: string;
65
+ tokenEndpoint: string;
66
+ clientId: string;
67
+ scopes?: string[];
68
+ redirectPort?: number;
51
69
  }
52
70
  /**
53
71
  * Signing credentials (password is runtime-only, not persisted)
@@ -65,7 +83,9 @@ export interface DeployConfig {
65
83
  siteName: string;
66
84
  addonName: string;
67
85
  username: string;
68
- password: string;
86
+ password?: string;
87
+ accessToken?: string;
88
+ sessionCookie?: string;
69
89
  useHTTP?: boolean;
70
90
  }
71
91
  /**
@@ -149,6 +169,7 @@ export interface DeployResponse {
149
169
  executableId?: string;
150
170
  message?: string;
151
171
  error?: string;
172
+ authExpired?: boolean;
152
173
  }
153
174
  /**
154
175
  * API response from addon creation
@@ -157,6 +178,7 @@ export interface CreateAddonResponse {
157
178
  success: boolean;
158
179
  addonId?: string;
159
180
  error?: string;
181
+ authExpired?: boolean;
160
182
  }
161
183
  /**
162
184
  * API response from activation
@@ -164,6 +186,7 @@ export interface CreateAddonResponse {
164
186
  export interface ActivationResponse {
165
187
  success: boolean;
166
188
  error?: string;
189
+ authExpired?: boolean;
167
190
  }
168
191
  /**
169
192
  * Build mode
@@ -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,20 @@
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
+ /**
10
+ * Return a usable OAuth2 access token, or null if one can't be obtained.
11
+ *
12
+ * Order: silent refresh from the keychain refresh token, then (when
13
+ * `interactive`) a browser login. The access token is never persisted; the
14
+ * refresh token is stored in the keychain for next time. Pass
15
+ * `interactive: false` from contexts that can't own the terminal (the Ink
16
+ * menu) to get refresh-only resolution with no browser.
17
+ */
18
+ export declare function resolveOAuth2AccessToken(dev: DevProperties, options?: {
19
+ interactive?: boolean;
20
+ }): Promise<string | null>;
@@ -0,0 +1,160 @@
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
+ 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
+ /** Serve the loopback redirect once, resolving the authorization code. */
59
+ function waitForCode(port, state) {
60
+ return new Promise(resolve => {
61
+ let settled = false;
62
+ const finish = (code) => {
63
+ if (settled)
64
+ return;
65
+ settled = true;
66
+ clearTimeout(timer);
67
+ server.close();
68
+ resolve(code);
69
+ };
70
+ const server = http.createServer((req, res) => {
71
+ const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`);
72
+ if (url.pathname !== '/callback') {
73
+ res.writeHead(404).end();
74
+ return;
75
+ }
76
+ const ok = url.searchParams.get('state') === state;
77
+ const code = url.searchParams.get('code');
78
+ const message = ok && code
79
+ ? 'Login complete. You can close this window and return to the terminal.'
80
+ : 'Login failed. Check the terminal.';
81
+ res.writeHead(200, { 'Content-Type': 'text/html' });
82
+ res.end(`<!doctype html><meta charset="utf-8"><p>${message}</p>`);
83
+ finish(ok ? code : null);
84
+ });
85
+ const timer = setTimeout(() => finish(null), LOGIN_TIMEOUT_MS);
86
+ server.on('error', () => finish(null));
87
+ server.listen(port, '127.0.0.1');
88
+ });
89
+ }
90
+ async function interactiveLogin(config, secret) {
91
+ const port = config.redirectPort ?? DEFAULT_REDIRECT_PORT;
92
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
93
+ const { verifier, challenge } = createPkcePair();
94
+ const state = base64url(crypto.randomBytes(16));
95
+ const authUrl = new URL(config.authorizationEndpoint);
96
+ authUrl.searchParams.set('response_type', 'code');
97
+ authUrl.searchParams.set('client_id', config.clientId);
98
+ authUrl.searchParams.set('redirect_uri', redirectUri);
99
+ authUrl.searchParams.set('state', state);
100
+ authUrl.searchParams.set('code_challenge', challenge);
101
+ authUrl.searchParams.set('code_challenge_method', 'S256');
102
+ if (config.scopes?.length) {
103
+ authUrl.searchParams.set('scope', config.scopes.join(' '));
104
+ }
105
+ const codePromise = waitForCode(port, state);
106
+ openBrowser(authUrl.href);
107
+ console.log(`\nOpening browser to log in. If it doesn't open, visit:\n${authUrl.href}\n`);
108
+ const code = await codePromise;
109
+ if (!code)
110
+ return null;
111
+ return postToken(config, {
112
+ grant_type: 'authorization_code',
113
+ code,
114
+ redirect_uri: redirectUri,
115
+ client_id: config.clientId,
116
+ code_verifier: verifier,
117
+ }, secret);
118
+ }
119
+ /**
120
+ * Return a usable OAuth2 access token, or null if one can't be obtained.
121
+ *
122
+ * Order: silent refresh from the keychain refresh token, then (when
123
+ * `interactive`) a browser login. The access token is never persisted; the
124
+ * refresh token is stored in the keychain for next time. Pass
125
+ * `interactive: false` from contexts that can't own the terminal (the Ink
126
+ * menu) to get refresh-only resolution with no browser.
127
+ */
128
+ export async function resolveOAuth2AccessToken(dev, options = {}) {
129
+ const { interactive = true } = options;
130
+ const config = dev.oauth2;
131
+ if (!hasOAuth2Config(config))
132
+ return null;
133
+ const { domain } = dev;
134
+ const secret = getOAuth2ClientSecret(domain, config.clientId) ?? undefined;
135
+ const storedRefresh = getOAuth2RefreshToken(domain, config.clientId);
136
+ if (storedRefresh) {
137
+ const tokens = await postToken(config, {
138
+ grant_type: 'refresh_token',
139
+ refresh_token: storedRefresh,
140
+ client_id: config.clientId,
141
+ }, secret);
142
+ if (tokens?.access_token) {
143
+ if (tokens.refresh_token) {
144
+ setOAuth2RefreshToken(domain, config.clientId, tokens.refresh_token);
145
+ }
146
+ return tokens.access_token;
147
+ }
148
+ // Stale/expired refresh token — drop it and log in fresh.
149
+ deleteOAuth2RefreshToken(domain, config.clientId);
150
+ }
151
+ if (!interactive || !process.stdin.isTTY)
152
+ return null;
153
+ const tokens = await interactiveLogin(config, secret);
154
+ if (!tokens?.access_token)
155
+ return null;
156
+ if (tokens.refresh_token) {
157
+ setOAuth2RefreshToken(domain, config.clientId, tokens.refresh_token);
158
+ }
159
+ return tokens.access_token;
160
+ }
@@ -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
  */
@@ -69,6 +69,14 @@ export declare function buildImportEndpointUrl(domain: string, siteName: string,
69
69
  export declare class ManifestParseError extends Error {
70
70
  constructor(manifestPath: string, cause: unknown);
71
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;
72
80
  /**
73
81
  * Detect if the current directory is a Sitevision project
74
82
  */
@@ -90,10 +98,37 @@ export declare function isBundledApp(manifest: SitevisionManifest): boolean;
90
98
  */
91
99
  export declare function readDevProperties(projectRoot: string): DevProperties | null;
92
100
  /**
93
- * Write dev properties to file. The `password` field is never persisted —
94
- * 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.
95
104
  */
96
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;
97
132
  /**
98
133
  * Move a plaintext password from .dev_properties.json into the OS keychain and
99
134
  * strip it from the file. Returns true if the password was migrated.