sitevision-cli 1.0.0-beta.2 → 1.0.0-beta.20
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 +35 -0
- package/dist/shell/ConfigForm.js +499 -0
- package/dist/shell/Frame.d.ts +59 -0
- package/dist/shell/Frame.js +136 -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 +576 -0
- package/dist/shell/Tabs.d.ts +36 -0
- package/dist/shell/Tabs.js +85 -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 +277 -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 +81 -4
- package/dist/utils/project-detection.js +298 -51
- 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 +99 -24
|
@@ -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,75 @@ 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
|
+
/** Dev properties inherited from ancestor directories only (no own file). */
|
|
127
|
+
export function readInheritedDevProperties(root) {
|
|
128
|
+
let merged = {};
|
|
129
|
+
for (const dir of ancestorDirs(root)) {
|
|
130
|
+
merged = { ...merged, ...readDevPropertiesFile(dir) };
|
|
131
|
+
}
|
|
132
|
+
return merged;
|
|
133
|
+
}
|
|
44
134
|
/**
|
|
45
135
|
* Get app ID configuration from environment or defaults
|
|
46
136
|
*/
|
|
@@ -107,6 +197,11 @@ export function getApiEndpoints(appType) {
|
|
|
107
197
|
addon: 'headlesscustommodule',
|
|
108
198
|
import: 'restAppImport',
|
|
109
199
|
};
|
|
200
|
+
case 'mcp':
|
|
201
|
+
return {
|
|
202
|
+
addon: 'mcpServerCustomModule',
|
|
203
|
+
import: 'mcpServerImport',
|
|
204
|
+
};
|
|
110
205
|
}
|
|
111
206
|
}
|
|
112
207
|
/**
|
|
@@ -135,29 +230,56 @@ export function buildImportEndpointUrl(domain, siteName, addonName, appType, use
|
|
|
135
230
|
// =============================================================================
|
|
136
231
|
// PROJECT DETECTION
|
|
137
232
|
// =============================================================================
|
|
233
|
+
/**
|
|
234
|
+
* Thrown when a manifest.json is present but cannot be parsed. Kept distinct from
|
|
235
|
+
* a plain "no project here" (null) so the CLI can tell the user their manifest is
|
|
236
|
+
* malformed instead of the misleading "Not a Sitevision project".
|
|
237
|
+
*/
|
|
238
|
+
export class ManifestParseError extends Error {
|
|
239
|
+
constructor(manifestPath, cause) {
|
|
240
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
241
|
+
super(`${manifestPath} is not valid JSON: ${reason}`);
|
|
242
|
+
this.name = 'ManifestParseError';
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Read manifest.json from its supported locations (root, static/, src/).
|
|
247
|
+
* Throws ManifestParseError on malformed JSON.
|
|
248
|
+
*/
|
|
249
|
+
export function readManifest(cwd) {
|
|
250
|
+
const manifestPaths = [
|
|
251
|
+
path.join(cwd, 'manifest.json'),
|
|
252
|
+
path.join(cwd, 'static', 'manifest.json'),
|
|
253
|
+
path.join(cwd, 'src', 'manifest.json'),
|
|
254
|
+
];
|
|
255
|
+
for (const manifestPath of manifestPaths) {
|
|
256
|
+
if (!fs.existsSync(manifestPath)) {
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
// Manifests may contain comments (Sitevision's own docs show them), so
|
|
260
|
+
// parse as JSONC.
|
|
261
|
+
try {
|
|
262
|
+
return {
|
|
263
|
+
manifestPath,
|
|
264
|
+
manifest: parseJsonc(fs.readFileSync(manifestPath, 'utf-8')),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
throw new ManifestParseError(manifestPath, error);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
138
273
|
/**
|
|
139
274
|
* Detect if the current directory is a Sitevision project
|
|
140
275
|
*/
|
|
141
276
|
export function detectProject(cwd = process.cwd()) {
|
|
142
277
|
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) {
|
|
278
|
+
const found = readManifest(cwd);
|
|
279
|
+
if (!found) {
|
|
159
280
|
return null;
|
|
160
281
|
}
|
|
282
|
+
const { manifestPath, manifest } = found;
|
|
161
283
|
// Check for package.json
|
|
162
284
|
const packageJsonPath = path.join(cwd, 'package.json');
|
|
163
285
|
if (!fs.existsSync(packageJsonPath)) {
|
|
@@ -170,33 +292,23 @@ export function detectProject(cwd = process.cwd()) {
|
|
|
170
292
|
// Check for node_modules
|
|
171
293
|
const nodeModulesPath = path.join(cwd, 'node_modules');
|
|
172
294
|
const hasNodeModules = fs.existsSync(nodeModulesPath);
|
|
173
|
-
//
|
|
295
|
+
// Dev properties: ancestor files (workspace root) merged under the app's
|
|
296
|
+
// own file, so shared site/auth config lives once at the repo root.
|
|
174
297
|
const devPropertiesPath = findDevPropertiesPath(cwd);
|
|
298
|
+
const inherited = readInheritedDevProperties(cwd);
|
|
299
|
+
const own = devPropertiesPath ? readDevPropertiesFile(cwd) : null;
|
|
300
|
+
const inheritedKeys = Object.keys(inherited).filter(key => !own || !Object.hasOwn(own, key));
|
|
175
301
|
let devProperties;
|
|
176
|
-
|
|
302
|
+
const hasDevProperties = Boolean(own) || inheritedKeys.length > 0;
|
|
177
303
|
let hasLegacyPassword = false;
|
|
178
|
-
if (
|
|
179
|
-
hasDevProperties = true;
|
|
304
|
+
if (hasDevProperties) {
|
|
180
305
|
try {
|
|
181
|
-
const parsed =
|
|
306
|
+
const parsed = { ...inherited, ...own };
|
|
182
307
|
hasLegacyPassword =
|
|
183
308
|
typeof parsed.password === 'string' && parsed.password.length > 0;
|
|
184
309
|
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
|
-
}
|
|
310
|
+
if (!hasLegacyPassword)
|
|
311
|
+
resolveRuntimeSecrets(devProperties);
|
|
200
312
|
}
|
|
201
313
|
catch {
|
|
202
314
|
// Invalid dev properties file
|
|
@@ -213,16 +325,45 @@ export function detectProject(cwd = process.cwd()) {
|
|
|
213
325
|
hasSigningProperties,
|
|
214
326
|
hasLegacyPassword,
|
|
215
327
|
devProperties,
|
|
328
|
+
inheritedKeys,
|
|
216
329
|
packageJson,
|
|
217
330
|
hasSitevisionScripts,
|
|
218
331
|
hasNodeModules,
|
|
219
332
|
paths,
|
|
220
333
|
};
|
|
221
334
|
}
|
|
222
|
-
catch {
|
|
335
|
+
catch (error) {
|
|
336
|
+
// A malformed manifest is a real error the user should see; everything else
|
|
337
|
+
// (missing files, unreadable optional config) just means "no project here".
|
|
338
|
+
if (error instanceof ManifestParseError) {
|
|
339
|
+
throw error;
|
|
340
|
+
}
|
|
223
341
|
return null;
|
|
224
342
|
}
|
|
225
343
|
}
|
|
344
|
+
/**
|
|
345
|
+
* Fill the runtime-only credential fields for the given domain/username:
|
|
346
|
+
* deploy password (env var > keychain), OAuth2 access token (env var), and
|
|
347
|
+
* session cookie (env var > keychain). Mutates and returns `dev`.
|
|
348
|
+
*/
|
|
349
|
+
export function resolveRuntimeSecrets(dev) {
|
|
350
|
+
if (dev.domain && dev.username) {
|
|
351
|
+
dev.password =
|
|
352
|
+
process.env['SITEVISION_DEPLOY_PASSWORD'] ??
|
|
353
|
+
getDeployPassword(dev.domain, dev.username) ??
|
|
354
|
+
undefined;
|
|
355
|
+
}
|
|
356
|
+
if (dev.authMethod === 'oauth2') {
|
|
357
|
+
dev.accessToken = process.env['SITEVISION_ACCESS_TOKEN'] ?? undefined;
|
|
358
|
+
}
|
|
359
|
+
if (dev.authMethod === 'cookie' && dev.domain) {
|
|
360
|
+
dev.sessionCookie =
|
|
361
|
+
process.env['SITEVISION_SESSION_COOKIE'] ??
|
|
362
|
+
getSessionCookie(dev.domain, dev.username) ??
|
|
363
|
+
undefined;
|
|
364
|
+
}
|
|
365
|
+
return dev;
|
|
366
|
+
}
|
|
226
367
|
/**
|
|
227
368
|
* Validate that we're in a Sitevision project directory
|
|
228
369
|
*/
|
|
@@ -234,20 +375,31 @@ export function requireProject(cwd) {
|
|
|
234
375
|
return project;
|
|
235
376
|
}
|
|
236
377
|
/**
|
|
237
|
-
*
|
|
378
|
+
* The app type (web, widget, rest, mcp), or undefined for a manifest type
|
|
379
|
+
* this CLI does not know. Display code uses this so one odd app never takes
|
|
380
|
+
* the whole shell down.
|
|
238
381
|
*/
|
|
239
|
-
export function
|
|
382
|
+
export function appTypeOf(manifest) {
|
|
240
383
|
const type = manifest.type.toLowerCase();
|
|
241
|
-
if (type.startsWith('web'))
|
|
384
|
+
if (type.startsWith('web'))
|
|
242
385
|
return 'web';
|
|
243
|
-
|
|
244
|
-
if (type.startsWith('widget')) {
|
|
386
|
+
if (type.startsWith('widget'))
|
|
245
387
|
return 'widget';
|
|
246
|
-
|
|
247
|
-
if (type.startsWith('rest')) {
|
|
388
|
+
if (type.startsWith('rest'))
|
|
248
389
|
return 'rest';
|
|
249
|
-
|
|
250
|
-
|
|
390
|
+
if (type.startsWith('mcp'))
|
|
391
|
+
return 'mcp';
|
|
392
|
+
return undefined;
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Get the app type (web, widget, rest, mcp). Throws for unknown types, since
|
|
396
|
+
* build and deploy cannot proceed without knowing the endpoints.
|
|
397
|
+
*/
|
|
398
|
+
export function getAppType(manifest) {
|
|
399
|
+
const type = appTypeOf(manifest);
|
|
400
|
+
if (!type)
|
|
401
|
+
throw new Error(`Unknown app type: ${manifest.type}`);
|
|
402
|
+
return type;
|
|
251
403
|
}
|
|
252
404
|
/**
|
|
253
405
|
* Check if the app uses webpack bundling
|
|
@@ -271,14 +423,109 @@ export function readDevProperties(projectRoot) {
|
|
|
271
423
|
}
|
|
272
424
|
}
|
|
273
425
|
/**
|
|
274
|
-
* Write dev properties to file.
|
|
275
|
-
*
|
|
426
|
+
* Write dev properties to file. Secrets are never persisted — `password`,
|
|
427
|
+
* `accessToken` and `sessionCookie` are held in the OS keychain / resolved at
|
|
428
|
+
* runtime instead.
|
|
276
429
|
*/
|
|
277
430
|
export function writeDevProperties(projectRoot, properties) {
|
|
278
431
|
const devPropertiesPath = findDevPropertiesPath(projectRoot) ||
|
|
279
432
|
getDefaultDevPropertiesPath(projectRoot);
|
|
280
|
-
const { password: _password, ...persisted } = properties;
|
|
281
|
-
|
|
433
|
+
const { password: _password, accessToken: _accessToken, sessionCookie: _sessionCookie, environmentName: _environmentName, productionEnvironment: _productionEnvironment, ...persisted } = properties;
|
|
434
|
+
// Keep the app file minimal: values identical to the inherited ones stay
|
|
435
|
+
// at the workspace root instead of being copied into every app. An empty
|
|
436
|
+
// string means "unset", so it is dropped rather than written as an override
|
|
437
|
+
// that would shadow the inherited value.
|
|
438
|
+
const inherited = readInheritedDevProperties(projectRoot);
|
|
439
|
+
const own = Object.fromEntries(Object.entries(persisted).filter(([key, value]) => value !== '' &&
|
|
440
|
+
(!Object.hasOwn(inherited, key) ||
|
|
441
|
+
JSON.stringify(inherited[key]) !== JSON.stringify(value))));
|
|
442
|
+
fs.writeFileSync(devPropertiesPath, JSON.stringify(own, null, 2));
|
|
443
|
+
}
|
|
444
|
+
export function readSvcConfig(projectRoot) {
|
|
445
|
+
try {
|
|
446
|
+
return parseJsonc(fs.readFileSync(path.join(projectRoot, '.svcconfig'), 'utf-8'));
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
return {};
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
export function writeSvcConfig(projectRoot, updates) {
|
|
453
|
+
const merged = { ...readSvcConfig(projectRoot), ...updates };
|
|
454
|
+
fs.writeFileSync(path.join(projectRoot, '.svcconfig'), JSON.stringify(merged, null, 2) + '\n');
|
|
455
|
+
}
|
|
456
|
+
// =============================================================================
|
|
457
|
+
// PACKAGE.JSON SYNC
|
|
458
|
+
// =============================================================================
|
|
459
|
+
/**
|
|
460
|
+
* Fields duplicated between .dev_properties.json and package.json, where
|
|
461
|
+
* sitevision-scripts reads them under different names.
|
|
462
|
+
*/
|
|
463
|
+
const PACKAGE_JSON_SYNC_KEYS = [
|
|
464
|
+
{ packageKey: 'developmentDomain', devKey: 'domain' },
|
|
465
|
+
{ packageKey: 'siteName', devKey: 'siteName' },
|
|
466
|
+
{ packageKey: 'addonName', devKey: 'addonName' },
|
|
467
|
+
];
|
|
468
|
+
function readPackageJson(projectRoot) {
|
|
469
|
+
try {
|
|
470
|
+
return JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf-8'));
|
|
471
|
+
}
|
|
472
|
+
catch {
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Which of the shared fields package.json is missing or disagrees on, relative
|
|
478
|
+
* to the given dev properties. Reads package.json from disk — an earlier
|
|
479
|
+
* `npm install` in the same session may have rewritten it.
|
|
480
|
+
*/
|
|
481
|
+
export function getPackageJsonSyncChanges(projectRoot, properties) {
|
|
482
|
+
const packageJson = readPackageJson(projectRoot);
|
|
483
|
+
if (!packageJson)
|
|
484
|
+
return [];
|
|
485
|
+
const changes = [];
|
|
486
|
+
for (const { packageKey, devKey } of PACKAGE_JSON_SYNC_KEYS) {
|
|
487
|
+
const to = properties[devKey];
|
|
488
|
+
if (typeof to !== 'string' || to === '')
|
|
489
|
+
continue;
|
|
490
|
+
const from = packageJson[packageKey];
|
|
491
|
+
if (from !== to) {
|
|
492
|
+
changes.push(from === undefined
|
|
493
|
+
? { key: packageKey, to }
|
|
494
|
+
: { key: packageKey, from, to });
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return changes;
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Copy the shared fields from dev properties into package.json, preserving the
|
|
501
|
+
* file's existing indentation and trailing newline.
|
|
502
|
+
*/
|
|
503
|
+
export function syncDevPropertiesToPackageJson(projectRoot, properties) {
|
|
504
|
+
const packageJsonPath = path.join(projectRoot, 'package.json');
|
|
505
|
+
let raw;
|
|
506
|
+
try {
|
|
507
|
+
raw = fs.readFileSync(packageJsonPath, 'utf-8');
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
return false;
|
|
511
|
+
}
|
|
512
|
+
let packageJson;
|
|
513
|
+
try {
|
|
514
|
+
packageJson = JSON.parse(raw);
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
return false;
|
|
518
|
+
}
|
|
519
|
+
for (const { packageKey, devKey } of PACKAGE_JSON_SYNC_KEYS) {
|
|
520
|
+
const value = properties[devKey];
|
|
521
|
+
if (typeof value === 'string' && value !== '') {
|
|
522
|
+
packageJson[packageKey] = value;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
const indent = /^(?<indent>[\t ]+)/m.exec(raw)?.groups?.['indent'] ?? '\t';
|
|
526
|
+
const newline = raw.endsWith('\n') ? '\n' : '';
|
|
527
|
+
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, indent) + newline);
|
|
528
|
+
return true;
|
|
282
529
|
}
|
|
283
530
|
/**
|
|
284
531
|
* Move a plaintext password from .dev_properties.json into the OS keychain and
|
|
@@ -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
|
+
}
|
|
@@ -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,39 @@ 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
|
-
|
|
112
|
+
/**
|
|
113
|
+
* A version uploaded to a custom module, as reported by the
|
|
114
|
+
* ActivateCustomModuleExecutable GET endpoint.
|
|
115
|
+
*/
|
|
116
|
+
export interface Executable {
|
|
117
|
+
id: string;
|
|
118
|
+
name: string;
|
|
119
|
+
appIdentifier: string;
|
|
120
|
+
appVersion: string;
|
|
121
|
+
active: boolean;
|
|
122
|
+
}
|
|
123
|
+
export interface ListExecutablesResponse {
|
|
124
|
+
success: boolean;
|
|
125
|
+
executables?: Executable[];
|
|
126
|
+
error?: string;
|
|
127
|
+
authExpired?: boolean;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* List the executables (uploaded versions) of the configured addon.
|
|
131
|
+
*/
|
|
132
|
+
export declare function listExecutables(config: DeployConfig): Promise<ListExecutablesResponse>;
|
|
133
|
+
export interface AddonNode {
|
|
134
|
+
id: string;
|
|
135
|
+
name: string;
|
|
136
|
+
type: string;
|
|
137
|
+
appType?: SimpleAppType;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* List the addons (custom modules) in the site's Addon Repository.
|
|
141
|
+
*/
|
|
142
|
+
export declare function listAddons(config: DeployConfig): Promise<{
|
|
143
|
+
success: boolean;
|
|
144
|
+
addons?: AddonNode[];
|
|
145
|
+
error?: string;
|
|
146
|
+
}>;
|
|
147
|
+
export { createBasicAuth, configAuth, unauthorizedMessage };
|