sitevision-cli 1.0.0-beta.5 → 1.0.0-beta.7
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 +6 -0
- package/dist/components/DevPropertiesForm.js +160 -31
- package/dist/components/SetupFlow.js +35 -2
- 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 +3 -2
- package/dist/utils/project-detection.js +24 -4
- package/dist/utils/session-cookie-auth.d.ts +9 -0
- package/dist/utils/session-cookie-auth.js +96 -0
- package/dist/utils/sitevision-api.d.ts +29 -5
- package/dist/utils/sitevision-api.js +90 -23
- package/package.json +2 -1
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
|
*/
|
|
@@ -98,8 +98,9 @@ export declare function isBundledApp(manifest: SitevisionManifest): boolean;
|
|
|
98
98
|
*/
|
|
99
99
|
export declare function readDevProperties(projectRoot: string): DevProperties | null;
|
|
100
100
|
/**
|
|
101
|
-
* Write dev properties to file.
|
|
102
|
-
*
|
|
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.
|
|
103
104
|
*/
|
|
104
105
|
export declare function writeDevProperties(projectRoot: string, properties: DevProperties): void;
|
|
105
106
|
/**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import { getDeployPassword, setDeployPassword } from './keychain.js';
|
|
3
|
+
import { getDeployPassword, setDeployPassword, getSessionCookie, } from './keychain.js';
|
|
4
4
|
import { parseJsonc } from './jsonc.js';
|
|
5
5
|
// =============================================================================
|
|
6
6
|
// LOCALIZED TEXT
|
|
@@ -244,6 +244,25 @@ export function detectProject(cwd = process.cwd()) {
|
|
|
244
244
|
}
|
|
245
245
|
}
|
|
246
246
|
}
|
|
247
|
+
// Resolve an OAuth2 access token: env var > keychain refresh.
|
|
248
|
+
// The env var is the manual/CI path; the interactive login stores a
|
|
249
|
+
// refresh token in the keychain and mints access tokens from it.
|
|
250
|
+
if (devProperties.authMethod === 'oauth2') {
|
|
251
|
+
const envToken = process.env['SITEVISION_ACCESS_TOKEN'];
|
|
252
|
+
if (envToken) {
|
|
253
|
+
devProperties.accessToken = envToken;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// Resolve a session cookie: env var > keychain (captured at login).
|
|
257
|
+
if (devProperties.authMethod === 'cookie' &&
|
|
258
|
+
devProperties.domain &&
|
|
259
|
+
devProperties.username) {
|
|
260
|
+
const envCookie = process.env['SITEVISION_SESSION_COOKIE'];
|
|
261
|
+
devProperties.sessionCookie =
|
|
262
|
+
envCookie ??
|
|
263
|
+
getSessionCookie(devProperties.domain, devProperties.username) ??
|
|
264
|
+
undefined;
|
|
265
|
+
}
|
|
247
266
|
}
|
|
248
267
|
catch {
|
|
249
268
|
// Invalid dev properties file
|
|
@@ -323,13 +342,14 @@ export function readDevProperties(projectRoot) {
|
|
|
323
342
|
}
|
|
324
343
|
}
|
|
325
344
|
/**
|
|
326
|
-
* Write dev properties to file.
|
|
327
|
-
*
|
|
345
|
+
* Write dev properties to file. Secrets are never persisted — `password`,
|
|
346
|
+
* `accessToken` and `sessionCookie` are held in the OS keychain / resolved at
|
|
347
|
+
* runtime instead.
|
|
328
348
|
*/
|
|
329
349
|
export function writeDevProperties(projectRoot, properties) {
|
|
330
350
|
const devPropertiesPath = findDevPropertiesPath(projectRoot) ||
|
|
331
351
|
getDefaultDevPropertiesPath(projectRoot);
|
|
332
|
-
const { password: _password, ...persisted } = properties;
|
|
352
|
+
const { password: _password, accessToken: _accessToken, sessionCookie: _sessionCookie, ...persisted } = properties;
|
|
333
353
|
fs.writeFileSync(devPropertiesPath, JSON.stringify(persisted, null, 2));
|
|
334
354
|
}
|
|
335
355
|
export function readSvcConfig(projectRoot) {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { DevProperties } from '../types/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Return a usable session cookie, or null. Order: keychain (a prior capture),
|
|
4
|
+
* then (when `interactive`) a browser login. Pass `interactive: false` from the
|
|
5
|
+
* Ink menu, which can't own the terminal for the "press Enter" handoff.
|
|
6
|
+
*/
|
|
7
|
+
export declare function resolveSessionCookie(dev: DevProperties, options?: {
|
|
8
|
+
interactive?: boolean;
|
|
9
|
+
}): Promise<string | null>;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { getSessionCookie, setSessionCookie } from './keychain.js';
|
|
2
|
+
import { promptEnter } from './password-prompt.js';
|
|
3
|
+
function bareDomain(domain) {
|
|
4
|
+
return domain.replace(/^\./, '');
|
|
5
|
+
}
|
|
6
|
+
/** Related if either host is the other or a subdomain of it (both directions). */
|
|
7
|
+
function domainRelated(a, b) {
|
|
8
|
+
const x = bareDomain(a);
|
|
9
|
+
const y = bareDomain(b);
|
|
10
|
+
return x === y || x.endsWith(`.${y}`) || y.endsWith(`.${x}`);
|
|
11
|
+
}
|
|
12
|
+
/** Read every cookie in the browser jar (httponly and secure included). */
|
|
13
|
+
async function readAllCookies(browser, page) {
|
|
14
|
+
// puppeteer >= 22 exposes the whole jar directly.
|
|
15
|
+
if (typeof browser.cookies === 'function') {
|
|
16
|
+
try {
|
|
17
|
+
return (await browser.cookies());
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// Fall through to CDP.
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const client = await page.createCDPSession();
|
|
24
|
+
const { cookies } = await client.send('Network.getAllCookies');
|
|
25
|
+
return cookies;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Open a real browser at the login URL, let the user complete SSO, then read
|
|
29
|
+
* the session cookies via CDP — which returns httponly, secure cookies that
|
|
30
|
+
* page JavaScript can't see. Returns a `Cookie:` header value, or null.
|
|
31
|
+
*/
|
|
32
|
+
async function captureViaBrowser(loginUrl, siteDomain) {
|
|
33
|
+
let puppeteer;
|
|
34
|
+
try {
|
|
35
|
+
({ default: puppeteer } = await import('puppeteer-core'));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
console.log('\x1b[31mpuppeteer-core is not installed. Run `npm i puppeteer-core`, or pass --cookie / set SITEVISION_SESSION_COOKIE.\x1b[0m');
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
let browser;
|
|
42
|
+
try {
|
|
43
|
+
browser = await puppeteer.launch({ headless: false, channel: 'chrome' });
|
|
44
|
+
const page = await browser.newPage();
|
|
45
|
+
await page.goto(loginUrl, { waitUntil: 'domcontentloaded' }).catch(() => {
|
|
46
|
+
// A SAML redirect may abort the initial navigation — that's fine.
|
|
47
|
+
});
|
|
48
|
+
await promptEnter('\nLog in in the browser this tool opened, then press Enter here to capture the session: ');
|
|
49
|
+
const all = await readAllCookies(browser, page);
|
|
50
|
+
const sessions = all.filter(c => c.name === 'JSESSIONID');
|
|
51
|
+
if (sessions.length === 0) {
|
|
52
|
+
const domains = [...new Set(all.map(c => bareDomain(c.domain)))];
|
|
53
|
+
console.log(`\x1b[31mNo JSESSIONID among ${all.length} cookies.\x1b[0m`);
|
|
54
|
+
console.log(`Cookie domains seen: ${domains.join(', ') || '(none — was the login done in the browser this tool opened?)'}`);
|
|
55
|
+
console.log('If those are only your IdP and not the Sitevision site, open a Sitevision page/editor in that same browser (so it issues a session), then run this again.');
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
// Prefer the JSESSIONID on the deploy host; else take the only/first one.
|
|
59
|
+
const chosen = sessions.find(c => domainRelated(c.domain, siteDomain)) ?? sessions[0];
|
|
60
|
+
const cookies = all.filter(c => domainRelated(c.domain, chosen.domain));
|
|
61
|
+
console.log(`\x1b[32mCaptured session on ${bareDomain(chosen.domain)} (${cookies.length} cookies).\x1b[0m`);
|
|
62
|
+
return cookies.map(c => `${c.name}=${c.value}`).join('; ');
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
console.log(`\x1b[31mBrowser login failed: ${error instanceof Error ? error.message : String(error)}\x1b[0m`);
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
if (browser) {
|
|
70
|
+
await browser.close().catch(() => {
|
|
71
|
+
// Best-effort close.
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Return a usable session cookie, or null. Order: keychain (a prior capture),
|
|
78
|
+
* then (when `interactive`) a browser login. Pass `interactive: false` from the
|
|
79
|
+
* Ink menu, which can't own the terminal for the "press Enter" handoff.
|
|
80
|
+
*/
|
|
81
|
+
export async function resolveSessionCookie(dev, options = {}) {
|
|
82
|
+
const { interactive = true } = options;
|
|
83
|
+
const { domain, username } = dev;
|
|
84
|
+
if (!domain || !username)
|
|
85
|
+
return null;
|
|
86
|
+
const stored = getSessionCookie(domain, username);
|
|
87
|
+
if (stored)
|
|
88
|
+
return stored;
|
|
89
|
+
if (!interactive || !process.stdin.isTTY)
|
|
90
|
+
return null;
|
|
91
|
+
const loginUrl = dev.sessionLoginUrl || `https://${domain}/`;
|
|
92
|
+
const cookie = await captureViaBrowser(loginUrl, domain);
|
|
93
|
+
if (cookie)
|
|
94
|
+
setSessionCookie(domain, username, cookie);
|
|
95
|
+
return cookie;
|
|
96
|
+
}
|
|
@@ -13,6 +13,27 @@ import type { SigningCredentials, DeployConfig, ProductionDeployConfig, SigningR
|
|
|
13
13
|
* Create Basic Auth header value
|
|
14
14
|
*/
|
|
15
15
|
declare function createBasicAuth(username: string, password: string): string;
|
|
16
|
+
type RequestAuth = {
|
|
17
|
+
username: string;
|
|
18
|
+
password: string;
|
|
19
|
+
} | {
|
|
20
|
+
token: string;
|
|
21
|
+
} | {
|
|
22
|
+
cookie: string;
|
|
23
|
+
};
|
|
24
|
+
type AuthKind = 'basic' | 'bearer' | 'cookie';
|
|
25
|
+
/** Single source of the 401 message, worded for the auth kind actually used. */
|
|
26
|
+
declare function unauthorizedMessage(kind: AuthKind): string;
|
|
27
|
+
/** Pick cookie > bearer > basic based on what the deploy config carries. */
|
|
28
|
+
declare function configAuth(config: {
|
|
29
|
+
username: string;
|
|
30
|
+
password?: string;
|
|
31
|
+
accessToken?: string;
|
|
32
|
+
sessionCookie?: string;
|
|
33
|
+
}): {
|
|
34
|
+
auth: RequestAuth;
|
|
35
|
+
kind: AuthKind;
|
|
36
|
+
};
|
|
16
37
|
/**
|
|
17
38
|
* Make an HTTP/HTTPS request
|
|
18
39
|
*/
|
|
@@ -20,10 +41,7 @@ export declare function makeRequest(url: string, options: {
|
|
|
20
41
|
method: string;
|
|
21
42
|
headers?: Record<string, string>;
|
|
22
43
|
body?: Buffer;
|
|
23
|
-
auth?:
|
|
24
|
-
username: string;
|
|
25
|
-
password: string;
|
|
26
|
-
};
|
|
44
|
+
auth?: RequestAuth;
|
|
27
45
|
timeoutMs?: number;
|
|
28
46
|
}): Promise<{
|
|
29
47
|
statusCode: number;
|
|
@@ -45,6 +63,12 @@ export declare function summarizeErrorBody(body: Buffer, headers: Record<string,
|
|
|
45
63
|
* the signing endpoint returns an error page with HTTP 200.
|
|
46
64
|
*/
|
|
47
65
|
export declare function looksLikeZip(body: Buffer): boolean;
|
|
66
|
+
/**
|
|
67
|
+
* A stale Sitevision session usually answers with a redirect to the login page
|
|
68
|
+
* or a 200 carrying an HTML login form — not a clean 401. Detect both so cookie
|
|
69
|
+
* auth can drop the dead session and re-login instead of showing a generic error.
|
|
70
|
+
*/
|
|
71
|
+
export declare function looksLikeAuthExpired(statusCode: number, body: Buffer, headers: Record<string, string>): boolean;
|
|
48
72
|
/**
|
|
49
73
|
* Sign an app via developer.sitevision.se
|
|
50
74
|
*
|
|
@@ -85,4 +109,4 @@ export declare function createAddon(config: DeployConfig, appType: SimpleAppType
|
|
|
85
109
|
* @param appType - The app type (web, widget, rest)
|
|
86
110
|
*/
|
|
87
111
|
export declare function activateApp(executableId: string, config: DeployConfig, _appType: SimpleAppType): Promise<ActivationResponse>;
|
|
88
|
-
export { createBasicAuth };
|
|
112
|
+
export { createBasicAuth, configAuth, unauthorizedMessage };
|