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.
- package/dist/app.js +22 -1
- package/dist/cli.js +22 -0
- package/dist/commands/deploy.js +81 -3
- package/dist/commands/dev.js +29 -3
- package/dist/components/DevPropertiesForm.js +160 -31
- package/dist/components/SetupFlow.js +88 -4
- package/dist/types/index.d.ts +24 -1
- package/dist/utils/keychain.d.ts +9 -0
- package/dist/utils/keychain.js +54 -0
- package/dist/utils/oauth2-auth.d.ts +20 -0
- package/dist/utils/oauth2-auth.js +160 -0
- package/dist/utils/password-prompt.d.ts +5 -0
- package/dist/utils/password-prompt.js +28 -0
- package/dist/utils/project-detection.d.ts +37 -2
- package/dist/utils/project-detection.js +141 -29
- package/dist/utils/session-cookie-auth.d.ts +9 -0
- package/dist/utils/session-cookie-auth.js +72 -0
- package/dist/utils/sitevision-api.d.ts +29 -5
- package/dist/utils/sitevision-api.js +90 -23
- package/package.json +2 -1
- package/readme.md +23 -0
|
@@ -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-
|
|
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:
|
|
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-
|
|
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)" }) })] }));
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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
|
|
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
|
package/dist/utils/keychain.d.ts
CHANGED
|
@@ -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;
|
package/dist/utils/keychain.js
CHANGED
|
@@ -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.
|
|
94
|
-
*
|
|
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.
|