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,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
|
|
@@ -167,38 +167,44 @@ export class ManifestParseError extends Error {
|
|
|
167
167
|
this.name = 'ManifestParseError';
|
|
168
168
|
}
|
|
169
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* Read manifest.json from its supported locations (root, static/, src/).
|
|
172
|
+
* Throws ManifestParseError on malformed JSON.
|
|
173
|
+
*/
|
|
174
|
+
export function readManifest(cwd) {
|
|
175
|
+
const manifestPaths = [
|
|
176
|
+
path.join(cwd, 'manifest.json'),
|
|
177
|
+
path.join(cwd, 'static', 'manifest.json'),
|
|
178
|
+
path.join(cwd, 'src', 'manifest.json'),
|
|
179
|
+
];
|
|
180
|
+
for (const manifestPath of manifestPaths) {
|
|
181
|
+
if (!fs.existsSync(manifestPath)) {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
// Manifests may contain comments (Sitevision's own docs show them), so
|
|
185
|
+
// parse as JSONC.
|
|
186
|
+
try {
|
|
187
|
+
return {
|
|
188
|
+
manifestPath,
|
|
189
|
+
manifest: parseJsonc(fs.readFileSync(manifestPath, 'utf-8')),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
throw new ManifestParseError(manifestPath, error);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
170
198
|
/**
|
|
171
199
|
* Detect if the current directory is a Sitevision project
|
|
172
200
|
*/
|
|
173
201
|
export function detectProject(cwd = process.cwd()) {
|
|
174
202
|
try {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
path.join(cwd, 'manifest.json'),
|
|
178
|
-
path.join(cwd, 'static', 'manifest.json'),
|
|
179
|
-
path.join(cwd, 'src', 'manifest.json'),
|
|
180
|
-
];
|
|
181
|
-
let manifestPath = null;
|
|
182
|
-
let manifest = null;
|
|
183
|
-
for (const p of manifestPaths) {
|
|
184
|
-
if (fs.existsSync(p)) {
|
|
185
|
-
manifestPath = p;
|
|
186
|
-
// Manifests may contain comments (Sitevision's own docs show them), so
|
|
187
|
-
// parse as JSONC. A still-unparseable manifest is a real, fixable
|
|
188
|
-
// error — surface it rather than silently reporting "Not a Sitevision
|
|
189
|
-
// project".
|
|
190
|
-
try {
|
|
191
|
-
manifest = parseJsonc(fs.readFileSync(p, 'utf-8'));
|
|
192
|
-
}
|
|
193
|
-
catch (error) {
|
|
194
|
-
throw new ManifestParseError(p, error);
|
|
195
|
-
}
|
|
196
|
-
break;
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
if (!manifest || !manifestPath) {
|
|
203
|
+
const found = readManifest(cwd);
|
|
204
|
+
if (!found) {
|
|
200
205
|
return null;
|
|
201
206
|
}
|
|
207
|
+
const { manifestPath, manifest } = found;
|
|
202
208
|
// Check for package.json
|
|
203
209
|
const packageJsonPath = path.join(cwd, 'package.json');
|
|
204
210
|
if (!fs.existsSync(packageJsonPath)) {
|
|
@@ -238,6 +244,25 @@ export function detectProject(cwd = process.cwd()) {
|
|
|
238
244
|
}
|
|
239
245
|
}
|
|
240
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
|
+
}
|
|
241
266
|
}
|
|
242
267
|
catch {
|
|
243
268
|
// Invalid dev properties file
|
|
@@ -317,15 +342,102 @@ export function readDevProperties(projectRoot) {
|
|
|
317
342
|
}
|
|
318
343
|
}
|
|
319
344
|
/**
|
|
320
|
-
* Write dev properties to file.
|
|
321
|
-
*
|
|
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.
|
|
322
348
|
*/
|
|
323
349
|
export function writeDevProperties(projectRoot, properties) {
|
|
324
350
|
const devPropertiesPath = findDevPropertiesPath(projectRoot) ||
|
|
325
351
|
getDefaultDevPropertiesPath(projectRoot);
|
|
326
|
-
const { password: _password, ...persisted } = properties;
|
|
352
|
+
const { password: _password, accessToken: _accessToken, sessionCookie: _sessionCookie, ...persisted } = properties;
|
|
327
353
|
fs.writeFileSync(devPropertiesPath, JSON.stringify(persisted, null, 2));
|
|
328
354
|
}
|
|
355
|
+
export function readSvcConfig(projectRoot) {
|
|
356
|
+
try {
|
|
357
|
+
return parseJsonc(fs.readFileSync(path.join(projectRoot, '.svcconfig'), 'utf-8'));
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
return {};
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
export function writeSvcConfig(projectRoot, updates) {
|
|
364
|
+
const merged = { ...readSvcConfig(projectRoot), ...updates };
|
|
365
|
+
fs.writeFileSync(path.join(projectRoot, '.svcconfig'), JSON.stringify(merged, null, 2) + '\n');
|
|
366
|
+
}
|
|
367
|
+
// =============================================================================
|
|
368
|
+
// PACKAGE.JSON SYNC
|
|
369
|
+
// =============================================================================
|
|
370
|
+
/**
|
|
371
|
+
* Fields duplicated between .dev_properties.json and package.json, where
|
|
372
|
+
* sitevision-scripts reads them under different names.
|
|
373
|
+
*/
|
|
374
|
+
const PACKAGE_JSON_SYNC_KEYS = [
|
|
375
|
+
{ packageKey: 'developmentDomain', devKey: 'domain' },
|
|
376
|
+
{ packageKey: 'siteName', devKey: 'siteName' },
|
|
377
|
+
{ packageKey: 'addonName', devKey: 'addonName' },
|
|
378
|
+
];
|
|
379
|
+
function readPackageJson(projectRoot) {
|
|
380
|
+
try {
|
|
381
|
+
return JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf-8'));
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Which of the shared fields package.json is missing or disagrees on, relative
|
|
389
|
+
* to the given dev properties. Reads package.json from disk — an earlier
|
|
390
|
+
* `npm install` in the same session may have rewritten it.
|
|
391
|
+
*/
|
|
392
|
+
export function getPackageJsonSyncChanges(projectRoot, properties) {
|
|
393
|
+
const packageJson = readPackageJson(projectRoot);
|
|
394
|
+
if (!packageJson)
|
|
395
|
+
return [];
|
|
396
|
+
const changes = [];
|
|
397
|
+
for (const { packageKey, devKey } of PACKAGE_JSON_SYNC_KEYS) {
|
|
398
|
+
const to = properties[devKey];
|
|
399
|
+
if (typeof to !== 'string' || to === '')
|
|
400
|
+
continue;
|
|
401
|
+
const from = packageJson[packageKey];
|
|
402
|
+
if (from !== to) {
|
|
403
|
+
changes.push(from === undefined
|
|
404
|
+
? { key: packageKey, to }
|
|
405
|
+
: { key: packageKey, from, to });
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return changes;
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Copy the shared fields from dev properties into package.json, preserving the
|
|
412
|
+
* file's existing indentation and trailing newline.
|
|
413
|
+
*/
|
|
414
|
+
export function syncDevPropertiesToPackageJson(projectRoot, properties) {
|
|
415
|
+
const packageJsonPath = path.join(projectRoot, 'package.json');
|
|
416
|
+
let raw;
|
|
417
|
+
try {
|
|
418
|
+
raw = fs.readFileSync(packageJsonPath, 'utf-8');
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
let packageJson;
|
|
424
|
+
try {
|
|
425
|
+
packageJson = JSON.parse(raw);
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
return false;
|
|
429
|
+
}
|
|
430
|
+
for (const { packageKey, devKey } of PACKAGE_JSON_SYNC_KEYS) {
|
|
431
|
+
const value = properties[devKey];
|
|
432
|
+
if (typeof value === 'string' && value !== '') {
|
|
433
|
+
packageJson[packageKey] = value;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
const indent = /^(?<indent>[\t ]+)/m.exec(raw)?.groups?.['indent'] ?? '\t';
|
|
437
|
+
const newline = raw.endsWith('\n') ? '\n' : '';
|
|
438
|
+
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, indent) + newline);
|
|
439
|
+
return true;
|
|
440
|
+
}
|
|
329
441
|
/**
|
|
330
442
|
* Move a plaintext password from .dev_properties.json into the OS keychain and
|
|
331
443
|
* strip it from the file. Returns true if the password was migrated.
|
|
@@ -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,72 @@
|
|
|
1
|
+
import { getSessionCookie, setSessionCookie } from './keychain.js';
|
|
2
|
+
import { promptEnter } from './password-prompt.js';
|
|
3
|
+
/** Match cookies set on the site host or any parent domain. */
|
|
4
|
+
function domainMatches(cookieDomain, siteDomain) {
|
|
5
|
+
const bare = cookieDomain.replace(/^\./, '');
|
|
6
|
+
return siteDomain === bare || siteDomain.endsWith(`.${bare}`);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Open a real browser at the login URL, let the user complete SSO, then read
|
|
10
|
+
* the session cookies (JSESSIONID and any siblings) via CDP — which returns
|
|
11
|
+
* httponly cookies that page JavaScript can't see. Returns a `Cookie:` header
|
|
12
|
+
* value, or null if capture failed.
|
|
13
|
+
*/
|
|
14
|
+
async function captureViaBrowser(loginUrl, siteDomain) {
|
|
15
|
+
let puppeteer;
|
|
16
|
+
try {
|
|
17
|
+
({ default: puppeteer } = await import('puppeteer-core'));
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
console.log('\x1b[31mpuppeteer-core is not installed. Run `npm i puppeteer-core`, or pass --cookie / set SITEVISION_SESSION_COOKIE.\x1b[0m');
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
let browser;
|
|
24
|
+
try {
|
|
25
|
+
browser = await puppeteer.launch({ headless: false, channel: 'chrome' });
|
|
26
|
+
const page = await browser.newPage();
|
|
27
|
+
await page.goto(loginUrl, { waitUntil: 'domcontentloaded' }).catch(() => {
|
|
28
|
+
// A SAML redirect may abort the initial navigation — that's fine.
|
|
29
|
+
});
|
|
30
|
+
await promptEnter('\nLog in in the opened browser, then press Enter here to capture the session: ');
|
|
31
|
+
const client = await page.createCDPSession();
|
|
32
|
+
const { cookies } = await client.send('Network.getAllCookies');
|
|
33
|
+
const wanted = cookies.filter(c => domainMatches(c.domain, siteDomain));
|
|
34
|
+
if (wanted.every(c => c.name !== 'JSESSIONID')) {
|
|
35
|
+
console.log('\x1b[31mNo JSESSIONID found for this site. Was the login completed?\x1b[0m');
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
return wanted.map(c => `${c.name}=${c.value}`).join('; ');
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
console.log(`\x1b[31mBrowser login failed: ${error instanceof Error ? error.message : String(error)}\x1b[0m`);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
if (browser) {
|
|
46
|
+
await browser.close().catch(() => {
|
|
47
|
+
// Best-effort close.
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Return a usable session cookie, or null. Order: keychain (a prior capture),
|
|
54
|
+
* then (when `interactive`) a browser login. Pass `interactive: false` from the
|
|
55
|
+
* Ink menu, which can't own the terminal for the "press Enter" handoff.
|
|
56
|
+
*/
|
|
57
|
+
export async function resolveSessionCookie(dev, options = {}) {
|
|
58
|
+
const { interactive = true } = options;
|
|
59
|
+
const { domain, username } = dev;
|
|
60
|
+
if (!domain || !username)
|
|
61
|
+
return null;
|
|
62
|
+
const stored = getSessionCookie(domain, username);
|
|
63
|
+
if (stored)
|
|
64
|
+
return stored;
|
|
65
|
+
if (!interactive || !process.stdin.isTTY)
|
|
66
|
+
return null;
|
|
67
|
+
const loginUrl = dev.sessionLoginUrl || `https://${domain}/`;
|
|
68
|
+
const cookie = await captureViaBrowser(loginUrl, domain);
|
|
69
|
+
if (cookie)
|
|
70
|
+
setSessionCookie(domain, username, cookie);
|
|
71
|
+
return cookie;
|
|
72
|
+
}
|
|
@@ -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 };
|
|
@@ -33,6 +33,30 @@ const RETRY_BASE_DELAY_MS = 1000;
|
|
|
33
33
|
function createBasicAuth(username, password) {
|
|
34
34
|
return `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
|
35
35
|
}
|
|
36
|
+
/** Single source of the 401 message, worded for the auth kind actually used. */
|
|
37
|
+
function unauthorizedMessage(kind) {
|
|
38
|
+
switch (kind) {
|
|
39
|
+
case 'bearer':
|
|
40
|
+
return 'Unauthorized. The access token was rejected or has expired.';
|
|
41
|
+
case 'cookie':
|
|
42
|
+
return 'Unauthorized. The session cookie was rejected or has expired — log in again.';
|
|
43
|
+
default:
|
|
44
|
+
return 'Unauthorized. Check username and password.';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Pick cookie > bearer > basic based on what the deploy config carries. */
|
|
48
|
+
function configAuth(config) {
|
|
49
|
+
if (config.sessionCookie) {
|
|
50
|
+
return { auth: { cookie: config.sessionCookie }, kind: 'cookie' };
|
|
51
|
+
}
|
|
52
|
+
if (config.accessToken) {
|
|
53
|
+
return { auth: { token: config.accessToken }, kind: 'bearer' };
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
auth: { username: config.username, password: config.password ?? '' },
|
|
57
|
+
kind: 'basic',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
36
60
|
/**
|
|
37
61
|
* Generate a random boundary for multipart form data
|
|
38
62
|
*/
|
|
@@ -71,7 +95,18 @@ export function makeRequest(url, options) {
|
|
|
71
95
|
...options.headers,
|
|
72
96
|
};
|
|
73
97
|
if (options.auth) {
|
|
74
|
-
|
|
98
|
+
if ('cookie' in options.auth) {
|
|
99
|
+
headers['Cookie'] = options.auth.cookie;
|
|
100
|
+
// Session-authenticated state-changing calls typically need this to
|
|
101
|
+
// pass Sitevision's CSRF guard, unlike Basic-auth requests.
|
|
102
|
+
headers['X-Requested-With'] ??= 'XMLHttpRequest';
|
|
103
|
+
}
|
|
104
|
+
else if ('token' in options.auth) {
|
|
105
|
+
headers['Authorization'] = `Bearer ${options.auth.token}`;
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
headers['Authorization'] = createBasicAuth(options.auth.username, options.auth.password);
|
|
109
|
+
}
|
|
75
110
|
}
|
|
76
111
|
const requestOptions = {
|
|
77
112
|
hostname: parsedUrl.hostname,
|
|
@@ -145,6 +180,31 @@ const ZIP_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
|
|
|
145
180
|
export function looksLikeZip(body) {
|
|
146
181
|
return body.length >= 4 && body.subarray(0, 4).equals(ZIP_MAGIC);
|
|
147
182
|
}
|
|
183
|
+
/**
|
|
184
|
+
* A stale Sitevision session usually answers with a redirect to the login page
|
|
185
|
+
* or a 200 carrying an HTML login form — not a clean 401. Detect both so cookie
|
|
186
|
+
* auth can drop the dead session and re-login instead of showing a generic error.
|
|
187
|
+
*/
|
|
188
|
+
export function looksLikeAuthExpired(statusCode, body, headers) {
|
|
189
|
+
if (statusCode === 401)
|
|
190
|
+
return true;
|
|
191
|
+
if (statusCode >= 300 && statusCode < 400)
|
|
192
|
+
return true;
|
|
193
|
+
if (statusCode === 200) {
|
|
194
|
+
const contentType = headers['content-type'] ?? '';
|
|
195
|
+
if (contentType.includes('html'))
|
|
196
|
+
return true;
|
|
197
|
+
const head = body
|
|
198
|
+
.subarray(0, 64)
|
|
199
|
+
.toString('utf8')
|
|
200
|
+
.trimStart()
|
|
201
|
+
.toLowerCase();
|
|
202
|
+
if (head.startsWith('<!doctype html') || head.startsWith('<html')) {
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
148
208
|
// =============================================================================
|
|
149
209
|
// SIGNING API
|
|
150
210
|
// =============================================================================
|
|
@@ -211,7 +271,7 @@ export async function signApp(zipPath, credentials, outputPath) {
|
|
|
211
271
|
// Auth failures will not resolve on retry.
|
|
212
272
|
return {
|
|
213
273
|
success: false,
|
|
214
|
-
error: '
|
|
274
|
+
error: unauthorizedMessage('basic'),
|
|
215
275
|
};
|
|
216
276
|
}
|
|
217
277
|
lastError = `Signing failed with status ${response.statusCode}: ${summarizeErrorBody(response.body, response.headers)}`;
|
|
@@ -259,6 +319,7 @@ export async function deployApp(zipPath, config, appType, force = false) {
|
|
|
259
319
|
// Create multipart form data
|
|
260
320
|
const boundary = generateBoundary();
|
|
261
321
|
const { body, contentType } = createMultipartFormData(zipPath, 'file', boundary);
|
|
322
|
+
const { auth, kind } = configAuth(config);
|
|
262
323
|
try {
|
|
263
324
|
const response = await makeRequest(url, {
|
|
264
325
|
method: 'POST',
|
|
@@ -267,11 +328,25 @@ export async function deployApp(zipPath, config, appType, force = false) {
|
|
|
267
328
|
'Content-Length': String(body.length),
|
|
268
329
|
},
|
|
269
330
|
body,
|
|
270
|
-
auth
|
|
271
|
-
username: config.username,
|
|
272
|
-
password: config.password,
|
|
273
|
-
},
|
|
331
|
+
auth,
|
|
274
332
|
});
|
|
333
|
+
if (response.statusCode === 401) {
|
|
334
|
+
return {
|
|
335
|
+
success: false,
|
|
336
|
+
error: unauthorizedMessage(kind),
|
|
337
|
+
authExpired: true,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
// A cookie session fails without a clean 401: a redirect to login or a
|
|
341
|
+
// 200 carrying an HTML login page. Flag it so the caller re-authenticates.
|
|
342
|
+
if (kind === 'cookie' &&
|
|
343
|
+
looksLikeAuthExpired(response.statusCode, response.body, response.headers)) {
|
|
344
|
+
return {
|
|
345
|
+
success: false,
|
|
346
|
+
error: unauthorizedMessage('cookie'),
|
|
347
|
+
authExpired: true,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
275
350
|
if (response.statusCode === 200) {
|
|
276
351
|
// Try to parse response for executable ID
|
|
277
352
|
let executableId;
|
|
@@ -288,12 +363,6 @@ export async function deployApp(zipPath, config, appType, force = false) {
|
|
|
288
363
|
message: 'Deployment successful',
|
|
289
364
|
};
|
|
290
365
|
}
|
|
291
|
-
if (response.statusCode === 401) {
|
|
292
|
-
return {
|
|
293
|
-
success: false,
|
|
294
|
-
error: 'Unauthorized. Check username and password.',
|
|
295
|
-
};
|
|
296
|
-
}
|
|
297
366
|
if (response.statusCode === 409) {
|
|
298
367
|
return {
|
|
299
368
|
success: false,
|
|
@@ -358,6 +427,7 @@ export async function createAddon(config, appType) {
|
|
|
358
427
|
name: config.addonName,
|
|
359
428
|
category: 'Other',
|
|
360
429
|
});
|
|
430
|
+
const { auth, kind } = configAuth(config);
|
|
361
431
|
try {
|
|
362
432
|
const response = await makeRequest(url, {
|
|
363
433
|
method: 'POST',
|
|
@@ -366,10 +436,7 @@ export async function createAddon(config, appType) {
|
|
|
366
436
|
'Content-Length': String(Buffer.byteLength(body)),
|
|
367
437
|
},
|
|
368
438
|
body: Buffer.from(body),
|
|
369
|
-
auth
|
|
370
|
-
username: config.username,
|
|
371
|
-
password: config.password,
|
|
372
|
-
},
|
|
439
|
+
auth,
|
|
373
440
|
});
|
|
374
441
|
if (response.statusCode === 200 || response.statusCode === 201) {
|
|
375
442
|
let addonId;
|
|
@@ -388,7 +455,8 @@ export async function createAddon(config, appType) {
|
|
|
388
455
|
if (response.statusCode === 401) {
|
|
389
456
|
return {
|
|
390
457
|
success: false,
|
|
391
|
-
error:
|
|
458
|
+
error: unauthorizedMessage(kind),
|
|
459
|
+
authExpired: true,
|
|
392
460
|
};
|
|
393
461
|
}
|
|
394
462
|
if (response.statusCode === 409) {
|
|
@@ -422,6 +490,7 @@ export async function activateApp(executableId, config, _appType) {
|
|
|
422
490
|
const body = JSON.stringify({
|
|
423
491
|
executableId,
|
|
424
492
|
});
|
|
493
|
+
const { auth, kind } = configAuth(config);
|
|
425
494
|
try {
|
|
426
495
|
const response = await makeRequest(url, {
|
|
427
496
|
method: 'PUT',
|
|
@@ -430,10 +499,7 @@ export async function activateApp(executableId, config, _appType) {
|
|
|
430
499
|
'Content-Length': String(Buffer.byteLength(body)),
|
|
431
500
|
},
|
|
432
501
|
body: Buffer.from(body),
|
|
433
|
-
auth
|
|
434
|
-
username: config.username,
|
|
435
|
-
password: config.password,
|
|
436
|
-
},
|
|
502
|
+
auth,
|
|
437
503
|
});
|
|
438
504
|
if (response.statusCode === 200) {
|
|
439
505
|
return { success: true };
|
|
@@ -441,7 +507,8 @@ export async function activateApp(executableId, config, _appType) {
|
|
|
441
507
|
if (response.statusCode === 401) {
|
|
442
508
|
return {
|
|
443
509
|
success: false,
|
|
444
|
-
error:
|
|
510
|
+
error: unauthorizedMessage(kind),
|
|
511
|
+
authExpired: true,
|
|
445
512
|
};
|
|
446
513
|
}
|
|
447
514
|
return {
|
|
@@ -459,4 +526,4 @@ export async function activateApp(executableId, config, _appType) {
|
|
|
459
526
|
// =============================================================================
|
|
460
527
|
// HELPER EXPORTS
|
|
461
528
|
// =============================================================================
|
|
462
|
-
export { createBasicAuth };
|
|
529
|
+
export { createBasicAuth, configAuth, unauthorizedMessage };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sitevision-cli",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.6",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"bin": {
|
|
6
6
|
"svc": "dist/cli.js"
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"ink": "^7.1.0",
|
|
25
25
|
"ink-spinner": "^5.0.0",
|
|
26
26
|
"meow": "^14.1.0",
|
|
27
|
+
"puppeteer-core": "^25.10.0",
|
|
27
28
|
"react": "^19.2.7"
|
|
28
29
|
},
|
|
29
30
|
"devDependencies": {
|
package/readme.md
CHANGED
|
@@ -126,6 +126,29 @@ Create this file in your project root for deployment configuration:
|
|
|
126
126
|
}
|
|
127
127
|
```
|
|
128
128
|
|
|
129
|
+
### Keeping `package.json` in sync
|
|
130
|
+
|
|
131
|
+
`sitevision-scripts` reads `developmentDomain`, `siteName` and `addonName`
|
|
132
|
+
from `package.json`, which duplicates three fields of
|
|
133
|
+
`.dev_properties.json`. When they disagree — or when a fresh setup has just
|
|
134
|
+
written `.dev_properties.json` — `svc` shows the differences and offers to
|
|
135
|
+
update `package.json` from `.dev_properties.json`. Nothing is written without
|
|
136
|
+
confirmation, and `.dev_properties.json` is always the source of truth for
|
|
137
|
+
the copy. Existing indentation and unrelated fields are left alone.
|
|
138
|
+
|
|
139
|
+
After answering, `svc` offers to remember the choice in a `.svcconfig` file
|
|
140
|
+
in the project root:
|
|
141
|
+
|
|
142
|
+
```json
|
|
143
|
+
{
|
|
144
|
+
"syncPackageJson": true
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
With `true`, `svc` updates `package.json` automatically without asking; with
|
|
149
|
+
`false`, the check is skipped entirely. Delete the key (or the file) to be
|
|
150
|
+
asked again. The file contains no secrets, so it is safe to commit.
|
|
151
|
+
|
|
129
152
|
### Password storage
|
|
130
153
|
|
|
131
154
|
Passwords are stored in the OS-native secret store (macOS Keychain, Windows
|