sitevision-cli 1.0.0-beta.2 → 1.0.0-beta.21
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.d.ts +1 -1
- package/dist/app.js +59 -8
- package/dist/cli.js +96 -39
- package/dist/commands/build.js +1 -1
- package/dist/commands/deploy.d.ts +2 -2
- package/dist/commands/deploy.js +135 -25
- package/dist/commands/dev.d.ts +8 -10
- package/dist/commands/dev.js +77 -366
- package/dist/commands/info.js +2 -2
- 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/InfoScreen.js +2 -2
- package/dist/components/MainMenu.js +7 -2
- package/dist/components/PasswordInput.js +2 -1
- package/dist/components/SetupFlow.d.ts +2 -1
- package/dist/components/SetupFlow.js +100 -11
- package/dist/shell/AddonPicker.d.ts +14 -0
- package/dist/shell/AddonPicker.js +54 -0
- package/dist/shell/CommandPalette.d.ts +8 -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 +134 -0
- package/dist/shell/Settings.d.ts +6 -0
- package/dist/shell/Settings.js +96 -0
- package/dist/shell/Shell.d.ts +9 -0
- package/dist/shell/Shell.js +586 -0
- package/dist/shell/Tabs.d.ts +36 -0
- package/dist/shell/Tabs.js +90 -0
- package/dist/shell/actions.d.ts +45 -0
- package/dist/shell/actions.js +0 -0
- package/dist/types/index.d.ts +44 -5
- 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 +279 -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/project-detection.d.ts +105 -6
- package/dist/utils/project-detection.js +411 -54
- 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 +64 -5
- package/dist/utils/sitevision-api.js +195 -33
- package/dist/utils/tasks.d.ts +48 -0
- package/dist/utils/tasks.js +371 -0
- package/dist/utils/workspace.d.ts +17 -0
- package/dist/utils/workspace.js +67 -0
- package/package.json +3 -1
- package/readme.md +102 -121
|
@@ -1,6 +1,27 @@
|
|
|
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';
|
|
6
|
+
// =============================================================================
|
|
7
|
+
// LOCALIZED TEXT
|
|
8
|
+
// =============================================================================
|
|
9
|
+
/**
|
|
10
|
+
* Resolve a manifest text field that may be a plain string or a localized
|
|
11
|
+
* object (e.g. `{sv: 'Namn', en: 'Name'}`) to a single display string.
|
|
12
|
+
*
|
|
13
|
+
* Preference order: Swedish, then English, then any available language. Returns
|
|
14
|
+
* an empty string for missing/empty values. This guards the UI from rendering a
|
|
15
|
+
* raw object as a React child, which Sitevision's localized manifests would
|
|
16
|
+
* otherwise trigger.
|
|
17
|
+
*/
|
|
18
|
+
export function localizedText(value, preferred = getLanguage()) {
|
|
19
|
+
if (!value)
|
|
20
|
+
return '';
|
|
21
|
+
if (typeof value === 'string')
|
|
22
|
+
return value;
|
|
23
|
+
return value[preferred] ?? value['en'] ?? Object.values(value)[0] ?? '';
|
|
24
|
+
}
|
|
4
25
|
// =============================================================================
|
|
5
26
|
// PATH UTILITIES
|
|
6
27
|
// =============================================================================
|
|
@@ -41,6 +62,93 @@ export function findDevPropertiesPath(root) {
|
|
|
41
62
|
export function getDefaultDevPropertiesPath(root) {
|
|
42
63
|
return path.join(root, '.dev_properties.json');
|
|
43
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
|
+
}
|
|
44
152
|
/**
|
|
45
153
|
* Get app ID configuration from environment or defaults
|
|
46
154
|
*/
|
|
@@ -107,6 +215,11 @@ export function getApiEndpoints(appType) {
|
|
|
107
215
|
addon: 'headlesscustommodule',
|
|
108
216
|
import: 'restAppImport',
|
|
109
217
|
};
|
|
218
|
+
case 'mcp':
|
|
219
|
+
return {
|
|
220
|
+
addon: 'mcpServerCustomModule',
|
|
221
|
+
import: 'mcpServerImport',
|
|
222
|
+
};
|
|
110
223
|
}
|
|
111
224
|
}
|
|
112
225
|
/**
|
|
@@ -135,29 +248,56 @@ export function buildImportEndpointUrl(domain, siteName, addonName, appType, use
|
|
|
135
248
|
// =============================================================================
|
|
136
249
|
// PROJECT DETECTION
|
|
137
250
|
// =============================================================================
|
|
251
|
+
/**
|
|
252
|
+
* Thrown when a manifest.json is present but cannot be parsed. Kept distinct from
|
|
253
|
+
* a plain "no project here" (null) so the CLI can tell the user their manifest is
|
|
254
|
+
* malformed instead of the misleading "Not a Sitevision project".
|
|
255
|
+
*/
|
|
256
|
+
export class ManifestParseError extends Error {
|
|
257
|
+
constructor(manifestPath, cause) {
|
|
258
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
259
|
+
super(`${manifestPath} is not valid JSON: ${reason}`);
|
|
260
|
+
this.name = 'ManifestParseError';
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Read manifest.json from its supported locations (root, static/, src/).
|
|
265
|
+
* Throws ManifestParseError on malformed JSON.
|
|
266
|
+
*/
|
|
267
|
+
export function readManifest(cwd) {
|
|
268
|
+
const manifestPaths = [
|
|
269
|
+
path.join(cwd, 'manifest.json'),
|
|
270
|
+
path.join(cwd, 'static', 'manifest.json'),
|
|
271
|
+
path.join(cwd, 'src', 'manifest.json'),
|
|
272
|
+
];
|
|
273
|
+
for (const manifestPath of manifestPaths) {
|
|
274
|
+
if (!fs.existsSync(manifestPath)) {
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
// Manifests may contain comments (Sitevision's own docs show them), so
|
|
278
|
+
// parse as JSONC.
|
|
279
|
+
try {
|
|
280
|
+
return {
|
|
281
|
+
manifestPath,
|
|
282
|
+
manifest: parseJsonc(fs.readFileSync(manifestPath, 'utf-8')),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
throw new ManifestParseError(manifestPath, error);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
138
291
|
/**
|
|
139
292
|
* Detect if the current directory is a Sitevision project
|
|
140
293
|
*/
|
|
141
294
|
export function detectProject(cwd = process.cwd()) {
|
|
142
295
|
try {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
path.join(cwd, 'manifest.json'),
|
|
146
|
-
path.join(cwd, 'static', 'manifest.json'),
|
|
147
|
-
path.join(cwd, 'src', 'manifest.json'),
|
|
148
|
-
];
|
|
149
|
-
let manifestPath = null;
|
|
150
|
-
let manifest = null;
|
|
151
|
-
for (const p of manifestPaths) {
|
|
152
|
-
if (fs.existsSync(p)) {
|
|
153
|
-
manifestPath = p;
|
|
154
|
-
manifest = JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
155
|
-
break;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
if (!manifest || !manifestPath) {
|
|
296
|
+
const found = readManifest(cwd);
|
|
297
|
+
if (!found) {
|
|
159
298
|
return null;
|
|
160
299
|
}
|
|
300
|
+
const { manifestPath, manifest } = found;
|
|
161
301
|
// Check for package.json
|
|
162
302
|
const packageJsonPath = path.join(cwd, 'package.json');
|
|
163
303
|
if (!fs.existsSync(packageJsonPath)) {
|
|
@@ -170,33 +310,23 @@ export function detectProject(cwd = process.cwd()) {
|
|
|
170
310
|
// Check for node_modules
|
|
171
311
|
const nodeModulesPath = path.join(cwd, 'node_modules');
|
|
172
312
|
const hasNodeModules = fs.existsSync(nodeModulesPath);
|
|
173
|
-
//
|
|
313
|
+
// Dev properties: ancestor files (workspace root) merged under the app's
|
|
314
|
+
// own file, so shared site/auth config lives once at the repo root.
|
|
174
315
|
const devPropertiesPath = findDevPropertiesPath(cwd);
|
|
316
|
+
const inherited = readInheritedDevProperties(cwd);
|
|
317
|
+
const own = devPropertiesPath ? readDevPropertiesFile(cwd) : null;
|
|
318
|
+
const inheritedKeys = Object.keys(inherited).filter(key => !own || !Object.hasOwn(own, key));
|
|
175
319
|
let devProperties;
|
|
176
|
-
|
|
320
|
+
const hasDevProperties = Boolean(own) || inheritedKeys.length > 0;
|
|
177
321
|
let hasLegacyPassword = false;
|
|
178
|
-
if (
|
|
179
|
-
hasDevProperties = true;
|
|
322
|
+
if (hasDevProperties) {
|
|
180
323
|
try {
|
|
181
|
-
const parsed =
|
|
324
|
+
const parsed = { ...inherited, ...own };
|
|
182
325
|
hasLegacyPassword =
|
|
183
326
|
typeof parsed.password === 'string' && parsed.password.length > 0;
|
|
184
327
|
devProperties = parsed;
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
devProperties.domain &&
|
|
188
|
-
devProperties.username) {
|
|
189
|
-
const envPassword = process.env['SITEVISION_DEPLOY_PASSWORD'];
|
|
190
|
-
if (envPassword) {
|
|
191
|
-
devProperties.password = envPassword;
|
|
192
|
-
}
|
|
193
|
-
else {
|
|
194
|
-
const stored = getDeployPassword(devProperties.domain, devProperties.username);
|
|
195
|
-
if (stored) {
|
|
196
|
-
devProperties.password = stored;
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
}
|
|
328
|
+
if (!hasLegacyPassword)
|
|
329
|
+
resolveRuntimeSecrets(devProperties);
|
|
200
330
|
}
|
|
201
331
|
catch {
|
|
202
332
|
// Invalid dev properties file
|
|
@@ -213,16 +343,45 @@ export function detectProject(cwd = process.cwd()) {
|
|
|
213
343
|
hasSigningProperties,
|
|
214
344
|
hasLegacyPassword,
|
|
215
345
|
devProperties,
|
|
346
|
+
inheritedKeys,
|
|
216
347
|
packageJson,
|
|
217
348
|
hasSitevisionScripts,
|
|
218
349
|
hasNodeModules,
|
|
219
350
|
paths,
|
|
220
351
|
};
|
|
221
352
|
}
|
|
222
|
-
catch {
|
|
353
|
+
catch (error) {
|
|
354
|
+
// A malformed manifest is a real error the user should see; everything else
|
|
355
|
+
// (missing files, unreadable optional config) just means "no project here".
|
|
356
|
+
if (error instanceof ManifestParseError) {
|
|
357
|
+
throw error;
|
|
358
|
+
}
|
|
223
359
|
return null;
|
|
224
360
|
}
|
|
225
361
|
}
|
|
362
|
+
/**
|
|
363
|
+
* Fill the runtime-only credential fields for the given domain/username:
|
|
364
|
+
* deploy password (env var > keychain), OAuth2 access token (env var), and
|
|
365
|
+
* session cookie (env var > keychain). Mutates and returns `dev`.
|
|
366
|
+
*/
|
|
367
|
+
export function resolveRuntimeSecrets(dev) {
|
|
368
|
+
if (dev.domain && dev.username) {
|
|
369
|
+
dev.password =
|
|
370
|
+
process.env['SITEVISION_DEPLOY_PASSWORD'] ??
|
|
371
|
+
getDeployPassword(dev.domain, dev.username) ??
|
|
372
|
+
undefined;
|
|
373
|
+
}
|
|
374
|
+
if (dev.authMethod === 'oauth2') {
|
|
375
|
+
dev.accessToken = process.env['SITEVISION_ACCESS_TOKEN'] ?? undefined;
|
|
376
|
+
}
|
|
377
|
+
if (dev.authMethod === 'cookie' && dev.domain) {
|
|
378
|
+
dev.sessionCookie =
|
|
379
|
+
process.env['SITEVISION_SESSION_COOKIE'] ??
|
|
380
|
+
getSessionCookie(dev.domain, dev.username) ??
|
|
381
|
+
undefined;
|
|
382
|
+
}
|
|
383
|
+
return dev;
|
|
384
|
+
}
|
|
226
385
|
/**
|
|
227
386
|
* Validate that we're in a Sitevision project directory
|
|
228
387
|
*/
|
|
@@ -234,20 +393,31 @@ export function requireProject(cwd) {
|
|
|
234
393
|
return project;
|
|
235
394
|
}
|
|
236
395
|
/**
|
|
237
|
-
*
|
|
396
|
+
* The app type (web, widget, rest, mcp), or undefined for a manifest type
|
|
397
|
+
* this CLI does not know. Display code uses this so one odd app never takes
|
|
398
|
+
* the whole shell down.
|
|
238
399
|
*/
|
|
239
|
-
export function
|
|
400
|
+
export function appTypeOf(manifest) {
|
|
240
401
|
const type = manifest.type.toLowerCase();
|
|
241
|
-
if (type.startsWith('web'))
|
|
402
|
+
if (type.startsWith('web'))
|
|
242
403
|
return 'web';
|
|
243
|
-
|
|
244
|
-
if (type.startsWith('widget')) {
|
|
404
|
+
if (type.startsWith('widget'))
|
|
245
405
|
return 'widget';
|
|
246
|
-
|
|
247
|
-
if (type.startsWith('rest')) {
|
|
406
|
+
if (type.startsWith('rest'))
|
|
248
407
|
return 'rest';
|
|
249
|
-
|
|
250
|
-
|
|
408
|
+
if (type.startsWith('mcp'))
|
|
409
|
+
return 'mcp';
|
|
410
|
+
return undefined;
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Get the app type (web, widget, rest, mcp). Throws for unknown types, since
|
|
414
|
+
* build and deploy cannot proceed without knowing the endpoints.
|
|
415
|
+
*/
|
|
416
|
+
export function getAppType(manifest) {
|
|
417
|
+
const type = appTypeOf(manifest);
|
|
418
|
+
if (!type)
|
|
419
|
+
throw new Error(`Unknown app type: ${manifest.type}`);
|
|
420
|
+
return type;
|
|
251
421
|
}
|
|
252
422
|
/**
|
|
253
423
|
* Check if the app uses webpack bundling
|
|
@@ -271,14 +441,187 @@ export function readDevProperties(projectRoot) {
|
|
|
271
441
|
}
|
|
272
442
|
}
|
|
273
443
|
/**
|
|
274
|
-
* Write dev properties to file.
|
|
275
|
-
*
|
|
444
|
+
* Write dev properties to file. Secrets are never persisted — `password`,
|
|
445
|
+
* `accessToken` and `sessionCookie` are held in the OS keychain / resolved at
|
|
446
|
+
* runtime instead.
|
|
276
447
|
*/
|
|
277
|
-
export function writeDevProperties(projectRoot, properties
|
|
448
|
+
export function writeDevProperties(projectRoot, properties,
|
|
449
|
+
// App mode: write every value, so plain sitevision-scripts finds them all.
|
|
450
|
+
{ complete = false } = {}) {
|
|
278
451
|
const devPropertiesPath = findDevPropertiesPath(projectRoot) ||
|
|
279
452
|
getDefaultDevPropertiesPath(projectRoot);
|
|
280
|
-
const { password: _password, ...persisted } = properties;
|
|
281
|
-
|
|
453
|
+
const { password: _password, accessToken: _accessToken, sessionCookie: _sessionCookie, environmentName: _environmentName, productionEnvironment: _productionEnvironment, ...persisted } = properties;
|
|
454
|
+
// Keep the app file minimal: values identical to the inherited ones stay
|
|
455
|
+
// at the workspace root instead of being copied into every app. An empty
|
|
456
|
+
// string means "unset", so it is dropped rather than written as an override
|
|
457
|
+
// that would shadow the inherited value.
|
|
458
|
+
const inherited = complete
|
|
459
|
+
? {}
|
|
460
|
+
: readInheritedDevProperties(projectRoot);
|
|
461
|
+
const own = Object.fromEntries(Object.entries(persisted).filter(([key, value]) => value !== '' &&
|
|
462
|
+
(!Object.hasOwn(inherited, key) ||
|
|
463
|
+
JSON.stringify(inherited[key]) !== JSON.stringify(value))));
|
|
464
|
+
fs.writeFileSync(devPropertiesPath, JSON.stringify(own, null, 2));
|
|
465
|
+
}
|
|
466
|
+
export function readSvcConfig(projectRoot) {
|
|
467
|
+
try {
|
|
468
|
+
return parseJsonc(fs.readFileSync(path.join(projectRoot, '.svcconfig'), 'utf-8'));
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
return {};
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
export function writeSvcConfig(projectRoot, updates) {
|
|
475
|
+
const merged = { ...readSvcConfig(projectRoot), ...updates };
|
|
476
|
+
fs.writeFileSync(path.join(projectRoot, '.svcconfig'), JSON.stringify(merged, null, 2) + '\n');
|
|
477
|
+
}
|
|
478
|
+
// =============================================================================
|
|
479
|
+
// PACKAGE.JSON SYNC
|
|
480
|
+
// =============================================================================
|
|
481
|
+
/**
|
|
482
|
+
* Values tied to the person running svc. They stay in .dev_properties.json and
|
|
483
|
+
* never go into package.json.
|
|
484
|
+
*/
|
|
485
|
+
export const USER_KEYS = ['username', 'signingUsername', 'certificateName'];
|
|
486
|
+
// Shared values package.json can hold: three top-level fields, the rest under "svc".
|
|
487
|
+
const PACKAGE_TOP_KEYS = {
|
|
488
|
+
domain: 'developmentDomain',
|
|
489
|
+
siteName: 'siteName',
|
|
490
|
+
addonName: 'addonName',
|
|
491
|
+
};
|
|
492
|
+
const PACKAGE_SVC_KEYS = [
|
|
493
|
+
'authMethod',
|
|
494
|
+
'oauth2',
|
|
495
|
+
'sessionLoginUrl',
|
|
496
|
+
'useHTTPForDevDeploy',
|
|
497
|
+
'baseEnvironment',
|
|
498
|
+
'production',
|
|
499
|
+
'environments',
|
|
500
|
+
];
|
|
501
|
+
function readPackageJson(projectRoot) {
|
|
502
|
+
try {
|
|
503
|
+
return JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf-8'));
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
/** The shared defaults one directory's package.json provides. */
|
|
510
|
+
export function readPackageDefaults(dir) {
|
|
511
|
+
const packageJson = readPackageJson(dir);
|
|
512
|
+
if (!packageJson)
|
|
513
|
+
return {};
|
|
514
|
+
const defaults = {};
|
|
515
|
+
for (const [devKey, packageKey] of Object.entries(PACKAGE_TOP_KEYS)) {
|
|
516
|
+
const value = packageJson[packageKey];
|
|
517
|
+
if (typeof value === 'string' && value !== '')
|
|
518
|
+
defaults[devKey] = value;
|
|
519
|
+
}
|
|
520
|
+
const svc = packageJson['svc'];
|
|
521
|
+
for (const key of PACKAGE_SVC_KEYS) {
|
|
522
|
+
if (svc?.[key] !== undefined)
|
|
523
|
+
defaults[key] = svc[key];
|
|
524
|
+
}
|
|
525
|
+
if (typeof defaults['domain'] === 'string') {
|
|
526
|
+
defaults['domain'] = normalizeDomain(defaults['domain']);
|
|
527
|
+
}
|
|
528
|
+
return defaults;
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Shared values in this directory's own .dev_properties.json that package.json
|
|
532
|
+
* does not already provide, here or further up. User values never qualify.
|
|
533
|
+
*/
|
|
534
|
+
function pendingSync(dir) {
|
|
535
|
+
const own = readDevPropertiesFile(dir);
|
|
536
|
+
if (!own)
|
|
537
|
+
return [];
|
|
538
|
+
const defaults = readPackageDefaultsChain(dir);
|
|
539
|
+
const pending = [];
|
|
540
|
+
for (const key of [...Object.keys(PACKAGE_TOP_KEYS), ...PACKAGE_SVC_KEYS]) {
|
|
541
|
+
let value = own[key];
|
|
542
|
+
if (key === 'environments' && value) {
|
|
543
|
+
value = Object.fromEntries(Object.entries(value).map(([name, override]) => [
|
|
544
|
+
name,
|
|
545
|
+
Object.fromEntries(Object.entries(override).filter(([field]) => !USER_KEYS.includes(field))),
|
|
546
|
+
]));
|
|
547
|
+
}
|
|
548
|
+
if (value === undefined || value === '')
|
|
549
|
+
continue;
|
|
550
|
+
if (JSON.stringify(value) ===
|
|
551
|
+
JSON.stringify(defaults[key] ?? IMPLICIT_VALUES[key]))
|
|
552
|
+
continue;
|
|
553
|
+
pending.push({ key, value });
|
|
554
|
+
}
|
|
555
|
+
return pending;
|
|
556
|
+
}
|
|
557
|
+
/** What an unset field means, so spelling out the default is not a change. */
|
|
558
|
+
export const IMPLICIT_VALUES = {
|
|
559
|
+
authMethod: 'basic',
|
|
560
|
+
useHTTPForDevDeploy: false,
|
|
561
|
+
production: false,
|
|
562
|
+
};
|
|
563
|
+
const packageLabel = (key) => PACKAGE_TOP_KEYS[key] ?? `svc.${key}`;
|
|
564
|
+
const display = (value) => typeof value === 'string' ? value : JSON.stringify(value);
|
|
565
|
+
/** What syncing this directory would change in its package.json. */
|
|
566
|
+
export function getPackageJsonSyncChanges(dir) {
|
|
567
|
+
const current = readPackageDefaults(dir);
|
|
568
|
+
return pendingSync(dir).map(({ key, value }) => current[key] === undefined
|
|
569
|
+
? { key: packageLabel(key), to: display(value) }
|
|
570
|
+
: {
|
|
571
|
+
key: packageLabel(key),
|
|
572
|
+
from: display(current[key]),
|
|
573
|
+
to: display(value),
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
export function hasPackageJson(dir) {
|
|
577
|
+
return fs.existsSync(path.join(dir, 'package.json'));
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Copy the pending shared values from .dev_properties.json into package.json,
|
|
581
|
+
* creating package.json when the directory has none. Throws if it cannot be
|
|
582
|
+
* read or written.
|
|
583
|
+
*/
|
|
584
|
+
export function syncDevPropertiesToPackageJson(dir) {
|
|
585
|
+
const pending = pendingSync(dir);
|
|
586
|
+
if (pending.length === 0 && hasPackageJson(dir))
|
|
587
|
+
return false;
|
|
588
|
+
updatePackageJson(dir, packageJson => {
|
|
589
|
+
for (const { key, value } of pending) {
|
|
590
|
+
const topKey = PACKAGE_TOP_KEYS[key];
|
|
591
|
+
if (topKey) {
|
|
592
|
+
packageJson[topKey] = value;
|
|
593
|
+
}
|
|
594
|
+
else {
|
|
595
|
+
packageJson['svc'] = {
|
|
596
|
+
...packageJson['svc'],
|
|
597
|
+
[key]: value,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
});
|
|
602
|
+
return true;
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Edit package.json in place, keeping its indentation and trailing newline. A
|
|
606
|
+
* missing file is created; an unreadable or invalid one throws, so the caller
|
|
607
|
+
* can warn instead of dropping the change silently.
|
|
608
|
+
*/
|
|
609
|
+
export function updatePackageJson(dir, mutate) {
|
|
610
|
+
const packageJsonPath = path.join(dir, 'package.json');
|
|
611
|
+
// A new file is private, so a workspace root is never published by accident.
|
|
612
|
+
let raw = '{\n\t"private": true\n}\n';
|
|
613
|
+
try {
|
|
614
|
+
if (hasPackageJson(dir))
|
|
615
|
+
raw = fs.readFileSync(packageJsonPath, 'utf-8');
|
|
616
|
+
const packageJson = JSON.parse(raw);
|
|
617
|
+
mutate(packageJson);
|
|
618
|
+
const indent = /^(?<indent>[\t ]+)/m.exec(raw)?.groups?.['indent'] ?? '\t';
|
|
619
|
+
const newline = raw.endsWith('\n') ? '\n' : '';
|
|
620
|
+
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, indent) + newline);
|
|
621
|
+
}
|
|
622
|
+
catch (error) {
|
|
623
|
+
throw new Error(`Could not update ${packageJsonPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
624
|
+
}
|
|
282
625
|
}
|
|
283
626
|
/**
|
|
284
627
|
* Move a plaintext password from .dev_properties.json into the OS keychain and
|
|
@@ -295,8 +638,22 @@ export function migrateLegacyPassword(project) {
|
|
|
295
638
|
return false;
|
|
296
639
|
if (!setDeployPassword(domain, username, password))
|
|
297
640
|
return false;
|
|
298
|
-
//
|
|
299
|
-
|
|
641
|
+
// Remove only the password, from whichever file holds it; every other value
|
|
642
|
+
// stays as written.
|
|
643
|
+
for (const dir of [project.root, ...ancestorDirs(project.root)]) {
|
|
644
|
+
const file = findDevPropertiesPath(dir);
|
|
645
|
+
if (!file)
|
|
646
|
+
continue;
|
|
647
|
+
try {
|
|
648
|
+
const { password: stored, ...rest } = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
649
|
+
if (stored !== undefined) {
|
|
650
|
+
fs.writeFileSync(file, JSON.stringify(rest, null, 2));
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
// Unreadable file: nothing to migrate there.
|
|
655
|
+
}
|
|
656
|
+
}
|
|
300
657
|
project.hasLegacyPassword = false;
|
|
301
658
|
return true;
|
|
302
659
|
}
|
|
@@ -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
|
+
}
|