sitevision-cli 1.0.0-beta.3 → 1.0.0-beta.31
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/CHANGELOG.md +57 -0
- package/dist/app.d.ts +1 -1
- package/dist/app.js +59 -8
- package/dist/cli.js +125 -40
- package/dist/commands/build.js +1 -1
- package/dist/commands/deploy.d.ts +2 -2
- package/dist/commands/deploy.js +137 -28
- package/dist/commands/dev.d.ts +8 -10
- package/dist/commands/dev.js +70 -371
- package/dist/commands/watch.js +5 -23
- package/dist/components/AnimatedLogo.js +8 -2
- package/dist/components/AuthLoginScreen.d.ts +21 -0
- package/dist/components/AuthLoginScreen.js +90 -0
- package/dist/components/DevPropertiesForm.d.ts +2 -1
- package/dist/components/DevPropertiesForm.js +198 -33
- package/dist/components/MainMenu.js +5 -0
- package/dist/components/PasswordInput.js +2 -1
- package/dist/components/SetupFlow.d.ts +2 -1
- package/dist/components/SetupFlow.js +99 -10
- package/dist/shell/AddonPicker.d.ts +14 -0
- package/dist/shell/AddonPicker.js +54 -0
- package/dist/shell/Changelog.d.ts +10 -0
- package/dist/shell/Changelog.js +44 -0
- package/dist/shell/CommandPalette.d.ts +12 -0
- package/dist/shell/CommandPalette.js +63 -0
- package/dist/shell/ConfigForm.d.ts +36 -0
- package/dist/shell/ConfigForm.js +558 -0
- package/dist/shell/Frame.d.ts +59 -0
- package/dist/shell/Frame.js +135 -0
- package/dist/shell/Help.d.ts +9 -0
- package/dist/shell/Help.js +95 -0
- package/dist/shell/Popover.d.ts +12 -0
- package/dist/shell/Popover.js +15 -0
- package/dist/shell/Settings.d.ts +6 -0
- package/dist/shell/Settings.js +96 -0
- package/dist/shell/Shell.d.ts +10 -0
- package/dist/shell/Shell.js +622 -0
- package/dist/shell/Tabs.d.ts +36 -0
- package/dist/shell/Tabs.js +90 -0
- package/dist/shell/actions.d.ts +48 -0
- package/dist/shell/actions.js +0 -0
- package/dist/types/index.d.ts +37 -3
- package/dist/utils/config.d.ts +10 -0
- package/dist/utils/config.js +14 -0
- package/dist/utils/environments.d.ts +20 -0
- package/dist/utils/environments.js +74 -0
- package/dist/utils/i18n.d.ts +12 -0
- package/dist/utils/i18n.js +314 -0
- package/dist/utils/jsonc.d.ts +19 -0
- package/dist/utils/jsonc.js +74 -0
- package/dist/utils/keychain.d.ts +9 -0
- package/dist/utils/keychain.js +54 -0
- package/dist/utils/oauth2-auth.d.ts +64 -0
- package/dist/utils/oauth2-auth.js +242 -0
- package/dist/utils/password-prompt.d.ts +5 -0
- package/dist/utils/password-prompt.js +28 -0
- package/dist/utils/process-runner.d.ts +13 -4
- package/dist/utils/process-runner.js +63 -37
- package/dist/utils/project-detection.d.ts +90 -4
- package/dist/utils/project-detection.js +389 -63
- package/dist/utils/session-cookie-auth.d.ts +35 -0
- package/dist/utils/session-cookie-auth.js +99 -0
- package/dist/utils/sitevision-api.d.ts +72 -5
- package/dist/utils/sitevision-api.js +220 -38
- package/dist/utils/sitevision-scripts-runner.d.ts +2 -1
- package/dist/utils/sitevision-scripts-runner.js +18 -7
- package/dist/utils/tasks.d.ts +50 -0
- package/dist/utils/tasks.js +439 -0
- package/dist/utils/workspace.d.ts +17 -0
- package/dist/utils/workspace.js +67 -0
- package/package.json +5 -2
- package/readme.md +103 -121
|
@@ -1,6 +1,8 @@
|
|
|
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
|
+
import { parseJsonc } from './jsonc.js';
|
|
5
|
+
import { getLanguage } from './i18n.js';
|
|
4
6
|
// =============================================================================
|
|
5
7
|
// LOCALIZED TEXT
|
|
6
8
|
// =============================================================================
|
|
@@ -13,7 +15,7 @@ import { getDeployPassword, setDeployPassword } from './keychain.js';
|
|
|
13
15
|
* raw object as a React child, which Sitevision's localized manifests would
|
|
14
16
|
* otherwise trigger.
|
|
15
17
|
*/
|
|
16
|
-
export function localizedText(value, preferred =
|
|
18
|
+
export function localizedText(value, preferred = getLanguage()) {
|
|
17
19
|
if (!value)
|
|
18
20
|
return '';
|
|
19
21
|
if (typeof value === 'string')
|
|
@@ -60,6 +62,93 @@ export function findDevPropertiesPath(root) {
|
|
|
60
62
|
export function getDefaultDevPropertiesPath(root) {
|
|
61
63
|
return path.join(root, '.dev_properties.json');
|
|
62
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Ancestor directories of `root` (outermost first) up to and including the
|
|
67
|
+
* nearest one containing `.git`, or the filesystem root. Each may carry a
|
|
68
|
+
* `.dev_properties.json` whose values the app inherits (nearest wins).
|
|
69
|
+
*/
|
|
70
|
+
function ancestorDirs(root) {
|
|
71
|
+
const dirs = [];
|
|
72
|
+
let dir = path.dirname(root);
|
|
73
|
+
while (true) {
|
|
74
|
+
dirs.unshift(dir);
|
|
75
|
+
if (fs.existsSync(path.join(dir, '.git')))
|
|
76
|
+
break;
|
|
77
|
+
const parent = path.dirname(dir);
|
|
78
|
+
if (parent === dir)
|
|
79
|
+
break;
|
|
80
|
+
dir = parent;
|
|
81
|
+
}
|
|
82
|
+
return dirs;
|
|
83
|
+
}
|
|
84
|
+
/** A bare host: no scheme, no path, no stray spaces. */
|
|
85
|
+
export function normalizeDomain(value) {
|
|
86
|
+
const host = value
|
|
87
|
+
.trim()
|
|
88
|
+
.replace(/^[a-z]+:\/\//i, '')
|
|
89
|
+
.replaceAll(/\s/g, '');
|
|
90
|
+
const slash = host.indexOf('/');
|
|
91
|
+
return slash === -1 ? host : host.slice(0, slash);
|
|
92
|
+
}
|
|
93
|
+
function readDevPropertiesFile(dir) {
|
|
94
|
+
const file = findDevPropertiesPath(dir);
|
|
95
|
+
if (!file)
|
|
96
|
+
return null;
|
|
97
|
+
try {
|
|
98
|
+
const dev = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
99
|
+
// ponytail: top level only; a hand-edited environment override keeps its
|
|
100
|
+
// scheme until it is saved from the config form.
|
|
101
|
+
dev.domain &&= normalizeDomain(dev.domain);
|
|
102
|
+
return dev;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The dev properties a workspace root defines (its own file merged over any
|
|
110
|
+
* ancestors'), with the deploy password resolved from the keychain like an
|
|
111
|
+
* app's would be. Used to edit shared config from the shell.
|
|
112
|
+
*/
|
|
113
|
+
export function readWorkspaceDevProperties(root) {
|
|
114
|
+
const merged = {
|
|
115
|
+
...readInheritedDevProperties(root),
|
|
116
|
+
...readDevPropertiesFile(root),
|
|
117
|
+
};
|
|
118
|
+
if (merged.domain && merged.username && !merged.password) {
|
|
119
|
+
merged.password =
|
|
120
|
+
process.env['SITEVISION_DEPLOY_PASSWORD'] ??
|
|
121
|
+
getDeployPassword(merged.domain, merged.username) ??
|
|
122
|
+
undefined;
|
|
123
|
+
}
|
|
124
|
+
return merged;
|
|
125
|
+
}
|
|
126
|
+
/** Ancestor directories' .dev_properties.json merged, nearest wins (no own file). */
|
|
127
|
+
export function readAncestorDevProperties(root) {
|
|
128
|
+
let merged = {};
|
|
129
|
+
for (const dir of ancestorDirs(root)) {
|
|
130
|
+
merged = { ...merged, ...readDevPropertiesFile(dir) };
|
|
131
|
+
}
|
|
132
|
+
return merged;
|
|
133
|
+
}
|
|
134
|
+
/** Shared defaults from package.json: the workspace root's first, the app's on top. */
|
|
135
|
+
export function readPackageDefaultsChain(root) {
|
|
136
|
+
let merged = {};
|
|
137
|
+
for (const dir of [...ancestorDirs(root), root]) {
|
|
138
|
+
merged = { ...merged, ...readPackageDefaults(dir) };
|
|
139
|
+
}
|
|
140
|
+
return merged;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Everything an app's own .dev_properties.json sits on top of: package.json
|
|
144
|
+
* defaults (root, then app), then ancestor .dev_properties.json files.
|
|
145
|
+
*/
|
|
146
|
+
export function readInheritedDevProperties(root) {
|
|
147
|
+
return {
|
|
148
|
+
...readPackageDefaultsChain(root),
|
|
149
|
+
...readAncestorDevProperties(root),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
63
152
|
/**
|
|
64
153
|
* Get app ID configuration from environment or defaults
|
|
65
154
|
*/
|
|
@@ -103,6 +192,20 @@ export function getZipPath(projectRoot, manifest) {
|
|
|
103
192
|
export function getSignedZipPath(projectRoot, manifest) {
|
|
104
193
|
return path.join(projectRoot, 'dist', getSignedZipFilename(manifest));
|
|
105
194
|
}
|
|
195
|
+
/**
|
|
196
|
+
* The zip a non-production deploy uploads: the signed one when it is at least
|
|
197
|
+
* as new as the build, since some sites reject unsigned apps even on dev.
|
|
198
|
+
*/
|
|
199
|
+
export function getDeployZipPath(projectRoot, manifest) {
|
|
200
|
+
const zip = getZipPath(projectRoot, manifest);
|
|
201
|
+
const signed = getSignedZipPath(projectRoot, manifest);
|
|
202
|
+
const mtime = (file) => fs.statSync(file, { throwIfNoEntry: false })?.mtimeMs;
|
|
203
|
+
const signedAt = mtime(signed);
|
|
204
|
+
if (signedAt === undefined)
|
|
205
|
+
return zip;
|
|
206
|
+
const builtAt = mtime(zip);
|
|
207
|
+
return builtAt === undefined || signedAt >= builtAt ? signed : zip;
|
|
208
|
+
}
|
|
106
209
|
// =============================================================================
|
|
107
210
|
// API ENDPOINT UTILITIES
|
|
108
211
|
// =============================================================================
|
|
@@ -126,6 +229,11 @@ export function getApiEndpoints(appType) {
|
|
|
126
229
|
addon: 'headlesscustommodule',
|
|
127
230
|
import: 'restAppImport',
|
|
128
231
|
};
|
|
232
|
+
case 'mcp':
|
|
233
|
+
return {
|
|
234
|
+
addon: 'mcpServerCustomModule',
|
|
235
|
+
import: 'mcpServerImport',
|
|
236
|
+
};
|
|
129
237
|
}
|
|
130
238
|
}
|
|
131
239
|
/**
|
|
@@ -166,38 +274,44 @@ export class ManifestParseError extends Error {
|
|
|
166
274
|
this.name = 'ManifestParseError';
|
|
167
275
|
}
|
|
168
276
|
}
|
|
277
|
+
/**
|
|
278
|
+
* Read manifest.json from its supported locations (root, static/, src/).
|
|
279
|
+
* Throws ManifestParseError on malformed JSON.
|
|
280
|
+
*/
|
|
281
|
+
export function readManifest(cwd) {
|
|
282
|
+
const manifestPaths = [
|
|
283
|
+
path.join(cwd, 'manifest.json'),
|
|
284
|
+
path.join(cwd, 'static', 'manifest.json'),
|
|
285
|
+
path.join(cwd, 'src', 'manifest.json'),
|
|
286
|
+
];
|
|
287
|
+
for (const manifestPath of manifestPaths) {
|
|
288
|
+
if (!fs.existsSync(manifestPath)) {
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
// Manifests may contain comments (Sitevision's own docs show them), so
|
|
292
|
+
// parse as JSONC.
|
|
293
|
+
try {
|
|
294
|
+
return {
|
|
295
|
+
manifestPath,
|
|
296
|
+
manifest: parseJsonc(fs.readFileSync(manifestPath, 'utf-8')),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
throw new ManifestParseError(manifestPath, error);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
169
305
|
/**
|
|
170
306
|
* Detect if the current directory is a Sitevision project
|
|
171
307
|
*/
|
|
172
308
|
export function detectProject(cwd = process.cwd()) {
|
|
173
309
|
try {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
path.join(cwd, 'manifest.json'),
|
|
177
|
-
path.join(cwd, 'static', 'manifest.json'),
|
|
178
|
-
path.join(cwd, 'src', 'manifest.json'),
|
|
179
|
-
];
|
|
180
|
-
let manifestPath = null;
|
|
181
|
-
let manifest = null;
|
|
182
|
-
for (const p of manifestPaths) {
|
|
183
|
-
if (fs.existsSync(p)) {
|
|
184
|
-
manifestPath = p;
|
|
185
|
-
// A present-but-unparseable manifest is a real, fixable error (a stray
|
|
186
|
-
// comment, a trailing comma, …). Surface it rather than silently
|
|
187
|
-
// reporting "Not a Sitevision project". JSON has no comments — strip
|
|
188
|
-
// any `//` annotations from manifest.json.
|
|
189
|
-
try {
|
|
190
|
-
manifest = JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
191
|
-
}
|
|
192
|
-
catch (error) {
|
|
193
|
-
throw new ManifestParseError(p, error);
|
|
194
|
-
}
|
|
195
|
-
break;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
if (!manifest || !manifestPath) {
|
|
310
|
+
const found = readManifest(cwd);
|
|
311
|
+
if (!found) {
|
|
199
312
|
return null;
|
|
200
313
|
}
|
|
314
|
+
const { manifestPath, manifest } = found;
|
|
201
315
|
// Check for package.json
|
|
202
316
|
const packageJsonPath = path.join(cwd, 'package.json');
|
|
203
317
|
if (!fs.existsSync(packageJsonPath)) {
|
|
@@ -210,33 +324,23 @@ export function detectProject(cwd = process.cwd()) {
|
|
|
210
324
|
// Check for node_modules
|
|
211
325
|
const nodeModulesPath = path.join(cwd, 'node_modules');
|
|
212
326
|
const hasNodeModules = fs.existsSync(nodeModulesPath);
|
|
213
|
-
//
|
|
327
|
+
// Dev properties: ancestor files (workspace root) merged under the app's
|
|
328
|
+
// own file, so shared site/auth config lives once at the repo root.
|
|
214
329
|
const devPropertiesPath = findDevPropertiesPath(cwd);
|
|
330
|
+
const inherited = readInheritedDevProperties(cwd);
|
|
331
|
+
const own = devPropertiesPath ? readDevPropertiesFile(cwd) : null;
|
|
332
|
+
const inheritedKeys = Object.keys(inherited).filter(key => !own || !Object.hasOwn(own, key));
|
|
215
333
|
let devProperties;
|
|
216
|
-
|
|
334
|
+
const hasDevProperties = Boolean(own) || inheritedKeys.length > 0;
|
|
217
335
|
let hasLegacyPassword = false;
|
|
218
|
-
if (
|
|
219
|
-
hasDevProperties = true;
|
|
336
|
+
if (hasDevProperties) {
|
|
220
337
|
try {
|
|
221
|
-
const parsed =
|
|
338
|
+
const parsed = { ...inherited, ...own };
|
|
222
339
|
hasLegacyPassword =
|
|
223
340
|
typeof parsed.password === 'string' && parsed.password.length > 0;
|
|
224
341
|
devProperties = parsed;
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
devProperties.domain &&
|
|
228
|
-
devProperties.username) {
|
|
229
|
-
const envPassword = process.env['SITEVISION_DEPLOY_PASSWORD'];
|
|
230
|
-
if (envPassword) {
|
|
231
|
-
devProperties.password = envPassword;
|
|
232
|
-
}
|
|
233
|
-
else {
|
|
234
|
-
const stored = getDeployPassword(devProperties.domain, devProperties.username);
|
|
235
|
-
if (stored) {
|
|
236
|
-
devProperties.password = stored;
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
}
|
|
342
|
+
if (!hasLegacyPassword)
|
|
343
|
+
resolveRuntimeSecrets(devProperties);
|
|
240
344
|
}
|
|
241
345
|
catch {
|
|
242
346
|
// Invalid dev properties file
|
|
@@ -253,6 +357,7 @@ export function detectProject(cwd = process.cwd()) {
|
|
|
253
357
|
hasSigningProperties,
|
|
254
358
|
hasLegacyPassword,
|
|
255
359
|
devProperties,
|
|
360
|
+
inheritedKeys,
|
|
256
361
|
packageJson,
|
|
257
362
|
hasSitevisionScripts,
|
|
258
363
|
hasNodeModules,
|
|
@@ -268,6 +373,29 @@ export function detectProject(cwd = process.cwd()) {
|
|
|
268
373
|
return null;
|
|
269
374
|
}
|
|
270
375
|
}
|
|
376
|
+
/**
|
|
377
|
+
* Fill the runtime-only credential fields for the given domain/username:
|
|
378
|
+
* deploy password (env var > keychain), OAuth2 access token (env var), and
|
|
379
|
+
* session cookie (env var > keychain). Mutates and returns `dev`.
|
|
380
|
+
*/
|
|
381
|
+
export function resolveRuntimeSecrets(dev) {
|
|
382
|
+
if (dev.domain && dev.username) {
|
|
383
|
+
dev.password =
|
|
384
|
+
process.env['SITEVISION_DEPLOY_PASSWORD'] ??
|
|
385
|
+
getDeployPassword(dev.domain, dev.username) ??
|
|
386
|
+
undefined;
|
|
387
|
+
}
|
|
388
|
+
if (dev.authMethod === 'oauth2') {
|
|
389
|
+
dev.accessToken = process.env['SITEVISION_ACCESS_TOKEN'] ?? undefined;
|
|
390
|
+
}
|
|
391
|
+
if (dev.authMethod === 'cookie' && dev.domain) {
|
|
392
|
+
dev.sessionCookie =
|
|
393
|
+
process.env['SITEVISION_SESSION_COOKIE'] ??
|
|
394
|
+
getSessionCookie(dev.domain, dev.username) ??
|
|
395
|
+
undefined;
|
|
396
|
+
}
|
|
397
|
+
return dev;
|
|
398
|
+
}
|
|
271
399
|
/**
|
|
272
400
|
* Validate that we're in a Sitevision project directory
|
|
273
401
|
*/
|
|
@@ -279,20 +407,31 @@ export function requireProject(cwd) {
|
|
|
279
407
|
return project;
|
|
280
408
|
}
|
|
281
409
|
/**
|
|
282
|
-
*
|
|
410
|
+
* The app type (web, widget, rest, mcp), or undefined for a manifest type
|
|
411
|
+
* this CLI does not know. Display code uses this so one odd app never takes
|
|
412
|
+
* the whole shell down.
|
|
283
413
|
*/
|
|
284
|
-
export function
|
|
414
|
+
export function appTypeOf(manifest) {
|
|
285
415
|
const type = manifest.type.toLowerCase();
|
|
286
|
-
if (type.startsWith('web'))
|
|
416
|
+
if (type.startsWith('web'))
|
|
287
417
|
return 'web';
|
|
288
|
-
|
|
289
|
-
if (type.startsWith('widget')) {
|
|
418
|
+
if (type.startsWith('widget'))
|
|
290
419
|
return 'widget';
|
|
291
|
-
|
|
292
|
-
if (type.startsWith('rest')) {
|
|
420
|
+
if (type.startsWith('rest'))
|
|
293
421
|
return 'rest';
|
|
294
|
-
|
|
295
|
-
|
|
422
|
+
if (type.startsWith('mcp'))
|
|
423
|
+
return 'mcp';
|
|
424
|
+
return undefined;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Get the app type (web, widget, rest, mcp). Throws for unknown types, since
|
|
428
|
+
* build and deploy cannot proceed without knowing the endpoints.
|
|
429
|
+
*/
|
|
430
|
+
export function getAppType(manifest) {
|
|
431
|
+
const type = appTypeOf(manifest);
|
|
432
|
+
if (!type)
|
|
433
|
+
throw new Error(`Unknown app type: ${manifest.type}`);
|
|
434
|
+
return type;
|
|
296
435
|
}
|
|
297
436
|
/**
|
|
298
437
|
* Check if the app uses webpack bundling
|
|
@@ -316,14 +455,187 @@ export function readDevProperties(projectRoot) {
|
|
|
316
455
|
}
|
|
317
456
|
}
|
|
318
457
|
/**
|
|
319
|
-
* Write dev properties to file.
|
|
320
|
-
*
|
|
458
|
+
* Write dev properties to file. Secrets are never persisted — `password`,
|
|
459
|
+
* `accessToken` and `sessionCookie` are held in the OS keychain / resolved at
|
|
460
|
+
* runtime instead.
|
|
321
461
|
*/
|
|
322
|
-
export function writeDevProperties(projectRoot, properties
|
|
462
|
+
export function writeDevProperties(projectRoot, properties,
|
|
463
|
+
// App mode: write every value, so plain sitevision-scripts finds them all.
|
|
464
|
+
{ complete = false } = {}) {
|
|
323
465
|
const devPropertiesPath = findDevPropertiesPath(projectRoot) ||
|
|
324
466
|
getDefaultDevPropertiesPath(projectRoot);
|
|
325
|
-
const { password: _password, ...persisted } = properties;
|
|
326
|
-
|
|
467
|
+
const { password: _password, accessToken: _accessToken, sessionCookie: _sessionCookie, environmentName: _environmentName, productionEnvironment: _productionEnvironment, ...persisted } = properties;
|
|
468
|
+
// Keep the app file minimal: values identical to the inherited ones stay
|
|
469
|
+
// at the workspace root instead of being copied into every app. An empty
|
|
470
|
+
// string means "unset", so it is dropped rather than written as an override
|
|
471
|
+
// that would shadow the inherited value.
|
|
472
|
+
const inherited = complete
|
|
473
|
+
? {}
|
|
474
|
+
: readInheritedDevProperties(projectRoot);
|
|
475
|
+
const own = Object.fromEntries(Object.entries(persisted).filter(([key, value]) => value !== '' &&
|
|
476
|
+
(!Object.hasOwn(inherited, key) ||
|
|
477
|
+
JSON.stringify(inherited[key]) !== JSON.stringify(value))));
|
|
478
|
+
fs.writeFileSync(devPropertiesPath, JSON.stringify(own, null, 2));
|
|
479
|
+
}
|
|
480
|
+
export function readSvcConfig(projectRoot) {
|
|
481
|
+
try {
|
|
482
|
+
return parseJsonc(fs.readFileSync(path.join(projectRoot, '.svcconfig'), 'utf-8'));
|
|
483
|
+
}
|
|
484
|
+
catch {
|
|
485
|
+
return {};
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
export function writeSvcConfig(projectRoot, updates) {
|
|
489
|
+
const merged = { ...readSvcConfig(projectRoot), ...updates };
|
|
490
|
+
fs.writeFileSync(path.join(projectRoot, '.svcconfig'), JSON.stringify(merged, null, 2) + '\n');
|
|
491
|
+
}
|
|
492
|
+
// =============================================================================
|
|
493
|
+
// PACKAGE.JSON SYNC
|
|
494
|
+
// =============================================================================
|
|
495
|
+
/**
|
|
496
|
+
* Values tied to the person running svc. They stay in .dev_properties.json and
|
|
497
|
+
* never go into package.json.
|
|
498
|
+
*/
|
|
499
|
+
export const USER_KEYS = ['username', 'signingUsername', 'certificateName'];
|
|
500
|
+
// Shared values package.json can hold: three top-level fields, the rest under "svc".
|
|
501
|
+
const PACKAGE_TOP_KEYS = {
|
|
502
|
+
domain: 'developmentDomain',
|
|
503
|
+
siteName: 'siteName',
|
|
504
|
+
addonName: 'addonName',
|
|
505
|
+
};
|
|
506
|
+
const PACKAGE_SVC_KEYS = [
|
|
507
|
+
'authMethod',
|
|
508
|
+
'oauth2',
|
|
509
|
+
'sessionLoginUrl',
|
|
510
|
+
'useHTTPForDevDeploy',
|
|
511
|
+
'baseEnvironment',
|
|
512
|
+
'production',
|
|
513
|
+
'environments',
|
|
514
|
+
];
|
|
515
|
+
function readPackageJson(projectRoot) {
|
|
516
|
+
try {
|
|
517
|
+
return JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf-8'));
|
|
518
|
+
}
|
|
519
|
+
catch {
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
/** The shared defaults one directory's package.json provides. */
|
|
524
|
+
export function readPackageDefaults(dir) {
|
|
525
|
+
const packageJson = readPackageJson(dir);
|
|
526
|
+
if (!packageJson)
|
|
527
|
+
return {};
|
|
528
|
+
const defaults = {};
|
|
529
|
+
for (const [devKey, packageKey] of Object.entries(PACKAGE_TOP_KEYS)) {
|
|
530
|
+
const value = packageJson[packageKey];
|
|
531
|
+
if (typeof value === 'string' && value !== '')
|
|
532
|
+
defaults[devKey] = value;
|
|
533
|
+
}
|
|
534
|
+
const svc = packageJson['svc'];
|
|
535
|
+
for (const key of PACKAGE_SVC_KEYS) {
|
|
536
|
+
if (svc?.[key] !== undefined)
|
|
537
|
+
defaults[key] = svc[key];
|
|
538
|
+
}
|
|
539
|
+
if (typeof defaults['domain'] === 'string') {
|
|
540
|
+
defaults['domain'] = normalizeDomain(defaults['domain']);
|
|
541
|
+
}
|
|
542
|
+
return defaults;
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Shared values in this directory's own .dev_properties.json that package.json
|
|
546
|
+
* does not already provide, here or further up. User values never qualify.
|
|
547
|
+
*/
|
|
548
|
+
function pendingSync(dir) {
|
|
549
|
+
const own = readDevPropertiesFile(dir);
|
|
550
|
+
if (!own)
|
|
551
|
+
return [];
|
|
552
|
+
const defaults = readPackageDefaultsChain(dir);
|
|
553
|
+
const pending = [];
|
|
554
|
+
for (const key of [...Object.keys(PACKAGE_TOP_KEYS), ...PACKAGE_SVC_KEYS]) {
|
|
555
|
+
let value = own[key];
|
|
556
|
+
if (key === 'environments' && value) {
|
|
557
|
+
value = Object.fromEntries(Object.entries(value).map(([name, override]) => [
|
|
558
|
+
name,
|
|
559
|
+
Object.fromEntries(Object.entries(override).filter(([field]) => !USER_KEYS.includes(field))),
|
|
560
|
+
]));
|
|
561
|
+
}
|
|
562
|
+
if (value === undefined || value === '')
|
|
563
|
+
continue;
|
|
564
|
+
if (JSON.stringify(value) ===
|
|
565
|
+
JSON.stringify(defaults[key] ?? IMPLICIT_VALUES[key]))
|
|
566
|
+
continue;
|
|
567
|
+
pending.push({ key, value });
|
|
568
|
+
}
|
|
569
|
+
return pending;
|
|
570
|
+
}
|
|
571
|
+
/** What an unset field means, so spelling out the default is not a change. */
|
|
572
|
+
export const IMPLICIT_VALUES = {
|
|
573
|
+
authMethod: 'basic',
|
|
574
|
+
useHTTPForDevDeploy: false,
|
|
575
|
+
production: false,
|
|
576
|
+
};
|
|
577
|
+
const packageLabel = (key) => PACKAGE_TOP_KEYS[key] ?? `svc.${key}`;
|
|
578
|
+
const display = (value) => typeof value === 'string' ? value : JSON.stringify(value);
|
|
579
|
+
/** What syncing this directory would change in its package.json. */
|
|
580
|
+
export function getPackageJsonSyncChanges(dir) {
|
|
581
|
+
const current = readPackageDefaults(dir);
|
|
582
|
+
return pendingSync(dir).map(({ key, value }) => current[key] === undefined
|
|
583
|
+
? { key: packageLabel(key), to: display(value) }
|
|
584
|
+
: {
|
|
585
|
+
key: packageLabel(key),
|
|
586
|
+
from: display(current[key]),
|
|
587
|
+
to: display(value),
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
export function hasPackageJson(dir) {
|
|
591
|
+
return fs.existsSync(path.join(dir, 'package.json'));
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Copy the pending shared values from .dev_properties.json into package.json,
|
|
595
|
+
* creating package.json when the directory has none. Throws if it cannot be
|
|
596
|
+
* read or written.
|
|
597
|
+
*/
|
|
598
|
+
export function syncDevPropertiesToPackageJson(dir) {
|
|
599
|
+
const pending = pendingSync(dir);
|
|
600
|
+
if (pending.length === 0 && hasPackageJson(dir))
|
|
601
|
+
return false;
|
|
602
|
+
updatePackageJson(dir, packageJson => {
|
|
603
|
+
for (const { key, value } of pending) {
|
|
604
|
+
const topKey = PACKAGE_TOP_KEYS[key];
|
|
605
|
+
if (topKey) {
|
|
606
|
+
packageJson[topKey] = value;
|
|
607
|
+
}
|
|
608
|
+
else {
|
|
609
|
+
packageJson['svc'] = {
|
|
610
|
+
...packageJson['svc'],
|
|
611
|
+
[key]: value,
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
return true;
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Edit package.json in place, keeping its indentation and trailing newline. A
|
|
620
|
+
* missing file is created; an unreadable or invalid one throws, so the caller
|
|
621
|
+
* can warn instead of dropping the change silently.
|
|
622
|
+
*/
|
|
623
|
+
export function updatePackageJson(dir, mutate) {
|
|
624
|
+
const packageJsonPath = path.join(dir, 'package.json');
|
|
625
|
+
// A new file is private, so a workspace root is never published by accident.
|
|
626
|
+
let raw = '{\n\t"private": true\n}\n';
|
|
627
|
+
try {
|
|
628
|
+
if (hasPackageJson(dir))
|
|
629
|
+
raw = fs.readFileSync(packageJsonPath, 'utf-8');
|
|
630
|
+
const packageJson = JSON.parse(raw);
|
|
631
|
+
mutate(packageJson);
|
|
632
|
+
const indent = /^(?<indent>[\t ]+)/m.exec(raw)?.groups?.['indent'] ?? '\t';
|
|
633
|
+
const newline = raw.endsWith('\n') ? '\n' : '';
|
|
634
|
+
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, indent) + newline);
|
|
635
|
+
}
|
|
636
|
+
catch (error) {
|
|
637
|
+
throw new Error(`Could not update ${packageJsonPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
638
|
+
}
|
|
327
639
|
}
|
|
328
640
|
/**
|
|
329
641
|
* Move a plaintext password from .dev_properties.json into the OS keychain and
|
|
@@ -340,8 +652,22 @@ export function migrateLegacyPassword(project) {
|
|
|
340
652
|
return false;
|
|
341
653
|
if (!setDeployPassword(domain, username, password))
|
|
342
654
|
return false;
|
|
343
|
-
//
|
|
344
|
-
|
|
655
|
+
// Remove only the password, from whichever file holds it; every other value
|
|
656
|
+
// stays as written.
|
|
657
|
+
for (const dir of [project.root, ...ancestorDirs(project.root)]) {
|
|
658
|
+
const file = findDevPropertiesPath(dir);
|
|
659
|
+
if (!file)
|
|
660
|
+
continue;
|
|
661
|
+
try {
|
|
662
|
+
const { password: stored, ...rest } = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
663
|
+
if (stored !== undefined) {
|
|
664
|
+
fs.writeFileSync(file, JSON.stringify(rest, null, 2));
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
catch {
|
|
668
|
+
// Unreadable file: nothing to migrate there.
|
|
669
|
+
}
|
|
670
|
+
}
|
|
345
671
|
project.hasLegacyPassword = false;
|
|
346
672
|
return true;
|
|
347
673
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { DevProperties } from '../types/index.js';
|
|
2
|
+
interface RawCookie {
|
|
3
|
+
name: string;
|
|
4
|
+
value: string;
|
|
5
|
+
domain: string;
|
|
6
|
+
}
|
|
7
|
+
export interface CaptureResult {
|
|
8
|
+
cookie?: string;
|
|
9
|
+
note?: string;
|
|
10
|
+
error?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface CookieLoginSession {
|
|
13
|
+
capture: () => Promise<CaptureResult>;
|
|
14
|
+
close: () => Promise<void>;
|
|
15
|
+
loginUrl: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Pick the session from a cookie jar: find JSESSIONID (preferring the deploy
|
|
19
|
+
* host), then return every cookie on that host as a `Cookie:` header. On miss,
|
|
20
|
+
* return diagnostics naming the domains actually seen. Pure — no keychain, no
|
|
21
|
+
* browser — so it's unit-testable.
|
|
22
|
+
*/
|
|
23
|
+
export declare function selectSessionCookie(all: RawCookie[], siteDomain: string): CaptureResult;
|
|
24
|
+
/**
|
|
25
|
+
* Launch a real browser at the login URL for an interactive SAML/SSO login and
|
|
26
|
+
* return handles to capture the session and close the browser. UI-agnostic: the
|
|
27
|
+
* Ink login screen decides when to `capture()` (on the user's keypress) and
|
|
28
|
+
* `close()`. Returns null if the browser can't be launched.
|
|
29
|
+
*
|
|
30
|
+
* `capture()` reads the whole cookie jar via CDP (httponly and secure included),
|
|
31
|
+
* stores the session in the keychain on success, and otherwise returns
|
|
32
|
+
* diagnostics naming the cookie domains it actually saw.
|
|
33
|
+
*/
|
|
34
|
+
export declare function beginCookieLogin(dev: DevProperties): Promise<CookieLoginSession | null>;
|
|
35
|
+
export {};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { setSessionCookie } from './keychain.js';
|
|
2
|
+
function bareDomain(domain) {
|
|
3
|
+
return domain.replace(/^\./, '');
|
|
4
|
+
}
|
|
5
|
+
/** Related if either host is the other or a subdomain of it (both directions). */
|
|
6
|
+
function domainRelated(a, b) {
|
|
7
|
+
const x = bareDomain(a);
|
|
8
|
+
const y = bareDomain(b);
|
|
9
|
+
return x === y || x.endsWith(`.${y}`) || y.endsWith(`.${x}`);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Pick the session from a cookie jar: find JSESSIONID (preferring the deploy
|
|
13
|
+
* host), then return every cookie on that host as a `Cookie:` header. On miss,
|
|
14
|
+
* return diagnostics naming the domains actually seen. Pure — no keychain, no
|
|
15
|
+
* browser — so it's unit-testable.
|
|
16
|
+
*/
|
|
17
|
+
export function selectSessionCookie(all, siteDomain) {
|
|
18
|
+
const sessions = all.filter(c => c.name === 'JSESSIONID');
|
|
19
|
+
if (sessions.length === 0) {
|
|
20
|
+
const domains = [...new Set(all.map(c => bareDomain(c.domain)))];
|
|
21
|
+
return {
|
|
22
|
+
error: `No JSESSIONID among ${all.length} cookies. Domains seen: ${domains.join(', ') || 'none'}. If these are only your IdP, open a Sitevision page in the browser, then press Enter again.`,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
const chosen = sessions.find(c => domainRelated(c.domain, siteDomain)) ?? sessions[0];
|
|
26
|
+
const cookies = all.filter(c => domainRelated(c.domain, chosen.domain));
|
|
27
|
+
return {
|
|
28
|
+
cookie: cookies.map(c => `${c.name}=${c.value}`).join('; '),
|
|
29
|
+
note: `Captured session on ${bareDomain(chosen.domain)} (${cookies.length} cookies).`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** Read every cookie in the browser jar (httponly and secure included). */
|
|
33
|
+
async function readAllCookies(browser, page) {
|
|
34
|
+
// puppeteer >= 22 exposes the whole jar directly.
|
|
35
|
+
if (typeof browser.cookies === 'function') {
|
|
36
|
+
try {
|
|
37
|
+
return (await browser.cookies());
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Fall through to CDP.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const client = await page.createCDPSession();
|
|
44
|
+
const { cookies } = await client.send('Network.getAllCookies');
|
|
45
|
+
return cookies;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Launch a real browser at the login URL for an interactive SAML/SSO login and
|
|
49
|
+
* return handles to capture the session and close the browser. UI-agnostic: the
|
|
50
|
+
* Ink login screen decides when to `capture()` (on the user's keypress) and
|
|
51
|
+
* `close()`. Returns null if the browser can't be launched.
|
|
52
|
+
*
|
|
53
|
+
* `capture()` reads the whole cookie jar via CDP (httponly and secure included),
|
|
54
|
+
* stores the session in the keychain on success, and otherwise returns
|
|
55
|
+
* diagnostics naming the cookie domains it actually saw.
|
|
56
|
+
*/
|
|
57
|
+
export async function beginCookieLogin(dev) {
|
|
58
|
+
const { domain, username } = dev;
|
|
59
|
+
if (!domain)
|
|
60
|
+
return null;
|
|
61
|
+
let puppeteer;
|
|
62
|
+
try {
|
|
63
|
+
({ default: puppeteer } = await import('puppeteer-core'));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
let browser;
|
|
69
|
+
try {
|
|
70
|
+
browser = await puppeteer.launch({ headless: false, channel: 'chrome' });
|
|
71
|
+
const page = await browser.newPage();
|
|
72
|
+
const loginUrl = dev.sessionLoginUrl || `https://${domain}/`;
|
|
73
|
+
await page.goto(loginUrl, { waitUntil: 'domcontentloaded' }).catch(() => {
|
|
74
|
+
// A SAML redirect may abort the initial navigation — that's fine.
|
|
75
|
+
});
|
|
76
|
+
const capture = async () => {
|
|
77
|
+
const all = await readAllCookies(browser, page);
|
|
78
|
+
const result = selectSessionCookie(all, domain);
|
|
79
|
+
if (result.cookie) {
|
|
80
|
+
setSessionCookie(domain, username, result.cookie);
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
83
|
+
};
|
|
84
|
+
const close = async () => {
|
|
85
|
+
await browser.close().catch(() => {
|
|
86
|
+
// Best-effort close.
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
return { capture, close, loginUrl };
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
if (browser) {
|
|
93
|
+
await browser.close().catch(() => {
|
|
94
|
+
// Best-effort close.
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|