@selvajs/cli 4.8.0-beta.11 → 4.8.0-beta.12
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/package.json +1 -1
- package/src/checks/config.js +100 -0
- package/src/checks/runtime.js +3 -1
- package/src/commands/create.js +16 -2
- package/src/commands/doctor.js +59 -32
- package/src/commands/init.js +5 -1
- package/src/commands/migrate.js +51 -6
- package/src/deployment-package.js +113 -2
- package/src/env.js +185 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@selvajs/cli",
|
|
3
|
-
"version": "4.8.0-beta.
|
|
3
|
+
"version": "4.8.0-beta.12",
|
|
4
4
|
"description": "Scaffold and operate a Selva white-label deployment. `npx @selvajs/cli <dir>` to bootstrap, `selva <cmd>` to manage.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "VektorNode",
|
package/src/checks/config.js
CHANGED
|
@@ -65,6 +65,106 @@ export function checkOrigin(env) {
|
|
|
65
65
|
return green(`ORIGIN=${env.ORIGIN}`);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// Duplicated from HeaderAuthProvider.DEFAULT_HEADERS to avoid loading the runtime here.
|
|
69
|
+
// A test in providers/header-auth pins both sides, so drift surfaces in CI.
|
|
70
|
+
export const DEFAULT_HEADER_NAMES = {
|
|
71
|
+
upn: 'SELVA-UserPrincipalName',
|
|
72
|
+
email: 'SELVA-Email',
|
|
73
|
+
displayName: 'SELVA-DisplayName'
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// oauth2-proxy's `set_xauthrequest` names. Two proxy styles are documented and
|
|
77
|
+
// both are correct, but they need opposite env config:
|
|
78
|
+
//
|
|
79
|
+
// rewrite — Caddy maps X-Auth-Request-* onto SELVA-* before proxying.
|
|
80
|
+
// Selva sees SELVA-*, so the overrides must be UNSET.
|
|
81
|
+
// copy-through — Caddy forwards X-Auth-Request-* unchanged.
|
|
82
|
+
// Selva sees those names, so all three must be overridden.
|
|
83
|
+
//
|
|
84
|
+
// Mixing them is the failure this check exists to catch: a name Selva reads but
|
|
85
|
+
// the proxy never sends yields `null` from identifyFromHeaders (for the UPN, a
|
|
86
|
+
// total login failure) with nothing in the logs pointing at the env var.
|
|
87
|
+
const OAUTH2_PROXY_HEADERS = new Set([
|
|
88
|
+
'x-auth-request-user',
|
|
89
|
+
'x-auth-request-email',
|
|
90
|
+
'x-auth-request-preferred-username'
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Reports the header names the provider will actually read, and flags the
|
|
95
|
+
* combinations that can't match any documented proxy config.
|
|
96
|
+
*
|
|
97
|
+
* Deliberately not a "did you override all three" count: with the rewrite
|
|
98
|
+
* style, zero overrides is the correct answer, so counting alone would push
|
|
99
|
+
* operators toward breaking a working deployment.
|
|
100
|
+
*/
|
|
101
|
+
export function checkHeaderNames(env) {
|
|
102
|
+
const resolved = {
|
|
103
|
+
upn: env.HEADER_AUTH_UPN_HEADER || DEFAULT_HEADER_NAMES.upn,
|
|
104
|
+
email: env.HEADER_AUTH_EMAIL_HEADER || DEFAULT_HEADER_NAMES.email,
|
|
105
|
+
displayName: env.HEADER_AUTH_DISPLAY_NAME_HEADER || DEFAULT_HEADER_NAMES.displayName
|
|
106
|
+
};
|
|
107
|
+
const list = `UPN=${resolved.upn}, Email=${resolved.email}, DisplayName=${resolved.displayName}`;
|
|
108
|
+
|
|
109
|
+
const oauth2 = Object.values(resolved).filter((h) =>
|
|
110
|
+
OAUTH2_PROXY_HEADERS.has(h.toLowerCase())
|
|
111
|
+
).length;
|
|
112
|
+
const defaults = Object.entries(resolved).filter(
|
|
113
|
+
([slot, name]) => name === DEFAULT_HEADER_NAMES[slot]
|
|
114
|
+
).length;
|
|
115
|
+
|
|
116
|
+
if (oauth2 === 0) {
|
|
117
|
+
return defaults === 3
|
|
118
|
+
? green(`header names (bundled defaults): ${list}`)
|
|
119
|
+
: green(`header names (custom): ${list}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Some X-Auth-Request-*, some SELVA-* defaults — neither proxy style emits
|
|
123
|
+
// that mix, so whichever slots are still on a default are being read from a
|
|
124
|
+
// header nothing sets.
|
|
125
|
+
if (defaults > 0) {
|
|
126
|
+
const stale = Object.entries(resolved)
|
|
127
|
+
.filter(([slot, name]) => name === DEFAULT_HEADER_NAMES[slot])
|
|
128
|
+
.map(([slot, name]) => `${slot}=${name}`)
|
|
129
|
+
.join(', ');
|
|
130
|
+
return yellow(
|
|
131
|
+
`header names mix oauth2-proxy and bundled-default names: ${list} — ` +
|
|
132
|
+
`${stale} will never arrive if your proxy forwards X-Auth-Request-* unchanged. ` +
|
|
133
|
+
`Either override all three with the X-Auth-Request-* names, or map them to SELVA-* ` +
|
|
134
|
+
`in the proxy and unset all three overrides.`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return green(`header names (oauth2-proxy pass-through): ${list}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Below this, an annotated .env is just a slightly chatty config file; above it
|
|
142
|
+
// the operator is scrolling past screens of prose that no command can correct.
|
|
143
|
+
const COMMENT_LINE_BUDGET = 40;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Flags a `.env` still carrying the shipped documentation block.
|
|
147
|
+
*
|
|
148
|
+
* The block is a snapshot of the release the deployment was installed at.
|
|
149
|
+
* `migrate` rewrites keys but never prose, so those comments keep describing
|
|
150
|
+
* vars the code has since renamed or retired — confidently wrong instructions
|
|
151
|
+
* sitting in the file an operator reaches for first. Nothing breaks, which is
|
|
152
|
+
* why this is yellow, and why it needs saying at all.
|
|
153
|
+
*/
|
|
154
|
+
export function checkEnvDocumentation(commentLines, fix) {
|
|
155
|
+
if (commentLines <= COMMENT_LINE_BUDGET) {
|
|
156
|
+
return green('.env is values-only');
|
|
157
|
+
}
|
|
158
|
+
return yellow(
|
|
159
|
+
`.env carries ~${commentLines} lines of shipped documentation. Comments are ` +
|
|
160
|
+
`never updated by \`selva migrate\`, so they still describe the release this ` +
|
|
161
|
+
`deployment was installed at — including variables the code no longer reads. ` +
|
|
162
|
+
`Strip them (values and your own inline notes are kept, .env.bak written):\n ` +
|
|
163
|
+
`selva doctor --fix`,
|
|
164
|
+
fix
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
68
168
|
// A rename is auto-fixable (`selva migrate` rewrites the key). A replacement
|
|
69
169
|
// encodes a value in the new name, so migrate leaves it alone rather than
|
|
70
170
|
// guessing — it's reported here and nowhere else.
|
package/src/checks/runtime.js
CHANGED
|
@@ -112,7 +112,9 @@ function checkNodeProvenance(env) {
|
|
|
112
112
|
?.trim();
|
|
113
113
|
|
|
114
114
|
if (!onPath) {
|
|
115
|
-
return yellow(
|
|
115
|
+
return yellow(
|
|
116
|
+
`Node v${process.versions.node} at ${running} (${source}) — not resolvable on PATH`
|
|
117
|
+
);
|
|
116
118
|
}
|
|
117
119
|
|
|
118
120
|
if (env.realpath(onPath) === running) {
|
package/src/commands/create.js
CHANGED
|
@@ -15,7 +15,11 @@ import {
|
|
|
15
15
|
runtimeTemplatePath,
|
|
16
16
|
scaffoldVersion
|
|
17
17
|
} from '../paths.js';
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
buildDeploymentPackageJson,
|
|
20
|
+
npmDistTagVersion,
|
|
21
|
+
resolveSelvaPins
|
|
22
|
+
} from '../deployment-package.js';
|
|
19
23
|
import { satisfiesNodeRange } from '../node-range.js';
|
|
20
24
|
|
|
21
25
|
export async function runCreate(argv) {
|
|
@@ -57,9 +61,19 @@ export async function runCreate(argv) {
|
|
|
57
61
|
|
|
58
62
|
const deployName = basename(targetDir);
|
|
59
63
|
|
|
64
|
+
// Resolve the dist-tags to concrete versions before writing. A stored
|
|
65
|
+
// `"latest"` re-resolves on every later `npm install`, so the deployment
|
|
66
|
+
// would follow the tag instead of the version it was scaffolded against —
|
|
67
|
+
// and `selva doctor` reports a floating pin as drift.
|
|
68
|
+
const { pins } = resolveSelvaPins({}, npmDistTagVersion);
|
|
69
|
+
|
|
60
70
|
// package.json first: npm install needs it, and the runtime templates below
|
|
61
71
|
// only exist once @selvajs/selva is installed into node_modules.
|
|
62
|
-
const pkgJson = JSON.stringify(
|
|
72
|
+
const pkgJson = JSON.stringify(
|
|
73
|
+
buildDeploymentPackageJson({ name: deployName, dependencies: pins }),
|
|
74
|
+
null,
|
|
75
|
+
2
|
|
76
|
+
);
|
|
63
77
|
writeFileSync(join(targetDir, 'package.json'), pkgJson + '\n', 'utf8');
|
|
64
78
|
|
|
65
79
|
if (!skipInstall) {
|
package/src/commands/doctor.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
import {
|
|
9
9
|
existsSync,
|
|
10
10
|
readFileSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
copyFileSync,
|
|
11
13
|
readdirSync,
|
|
12
14
|
accessSync,
|
|
13
15
|
constants,
|
|
@@ -18,7 +20,7 @@ import { join, resolve, dirname } from 'node:path';
|
|
|
18
20
|
import { fileURLToPath } from 'node:url';
|
|
19
21
|
import * as p from '@clack/prompts';
|
|
20
22
|
import pc from 'picocolors';
|
|
21
|
-
import { readEnvFile } from '../env.js';
|
|
23
|
+
import { readEnvFile, parseEnv, countEnvCommentLines, stripEnvComments } from '../env.js';
|
|
22
24
|
import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
|
|
23
25
|
import { satisfiesNodeRange } from '../node-range.js';
|
|
24
26
|
import { green, yellow, red, fixable } from '../checks/result.js';
|
|
@@ -26,6 +28,8 @@ import { checkBootPersistence } from '../checks/boot.js';
|
|
|
26
28
|
import { checkRuntimeEnvironment } from '../checks/runtime.js';
|
|
27
29
|
import {
|
|
28
30
|
checkDeprecatedEnv,
|
|
31
|
+
checkEnvDocumentation,
|
|
32
|
+
checkHeaderNames,
|
|
29
33
|
checkOrigin,
|
|
30
34
|
checkProviders,
|
|
31
35
|
checkSecret,
|
|
@@ -78,6 +82,7 @@ export async function runDoctor(argv = []) {
|
|
|
78
82
|
checks.push(...checkBootPersistence(dir));
|
|
79
83
|
checks.push(checkOrigin(env));
|
|
80
84
|
checks.push(...checkDeprecatedEnv(env));
|
|
85
|
+
checks.push(checkEnvDocs(dir));
|
|
81
86
|
|
|
82
87
|
// ── Render ─────────────────────────────────────────────────────────
|
|
83
88
|
const resolved = await Promise.all(checks);
|
|
@@ -372,6 +377,58 @@ function checkMigrationLeftovers(dir) {
|
|
|
372
377
|
);
|
|
373
378
|
}
|
|
374
379
|
|
|
380
|
+
// Reads the raw file rather than the parsed env: the finding IS the comments,
|
|
381
|
+
// which parseEnv discards. The repair is attached here (not in checks/config.js)
|
|
382
|
+
// to keep that module filesystem-free.
|
|
383
|
+
function checkEnvDocs(dir) {
|
|
384
|
+
const envPath = join(dir, '.env');
|
|
385
|
+
if (!existsSync(envPath)) return green('.env documentation check skipped (no .env)');
|
|
386
|
+
|
|
387
|
+
let text;
|
|
388
|
+
try {
|
|
389
|
+
text = readFileSync(envPath, 'utf8');
|
|
390
|
+
} catch (err) {
|
|
391
|
+
return yellow(`.env unreadable (${err instanceof Error ? err.message : err})`);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return checkEnvDocumentation(
|
|
395
|
+
countEnvCommentLines(text),
|
|
396
|
+
fixable('strip the shipped documentation from .env (backup: .env.bak)', () => {
|
|
397
|
+
try {
|
|
398
|
+
// Re-read at repair time: doctor may have run minutes ago in --fix's
|
|
399
|
+
// confirmation loop, and rewriting from a stale snapshot would silently
|
|
400
|
+
// revert an edit made in between.
|
|
401
|
+
const current = readFileSync(envPath, 'utf8');
|
|
402
|
+
const { text: strippedText, removed } = stripEnvComments(current);
|
|
403
|
+
// A rewrite that changes which settings are live is a bug, not a
|
|
404
|
+
// cleanup — refuse rather than hand back a subtly different deployment.
|
|
405
|
+
const before = parseEnv(current);
|
|
406
|
+
const after = parseEnv(strippedText);
|
|
407
|
+
const drift = diffEnvKeys(before, after);
|
|
408
|
+
if (drift) return red(`refused to strip .env — ${drift}`);
|
|
409
|
+
|
|
410
|
+
copyFileSync(envPath, envPath + '.bak');
|
|
411
|
+
writeFileSync(envPath, strippedText, 'utf8');
|
|
412
|
+
return green(`stripped ${removed} comment lines from .env (backup: .env.bak)`);
|
|
413
|
+
} catch (err) {
|
|
414
|
+
return red(`could not rewrite .env: ${err instanceof Error ? err.message : err}`);
|
|
415
|
+
}
|
|
416
|
+
})
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Names the first discrepancy between two parsed envs, or null when identical.
|
|
421
|
+
function diffEnvKeys(before, after) {
|
|
422
|
+
for (const key of Object.keys(before)) {
|
|
423
|
+
if (!(key in after)) return `${key} would be lost`;
|
|
424
|
+
if (before[key] !== after[key]) return `${key} would change value`;
|
|
425
|
+
}
|
|
426
|
+
for (const key of Object.keys(after)) {
|
|
427
|
+
if (!(key in before)) return `${key} would be added`;
|
|
428
|
+
}
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
|
|
375
432
|
function checkLayoutDrift(dir) {
|
|
376
433
|
const pkgPath = join(dir, 'package.json');
|
|
377
434
|
if (!existsSync(pkgPath)) return yellow('package.json missing — cannot check layout');
|
|
@@ -389,14 +446,6 @@ function checkLayoutDrift(dir) {
|
|
|
389
446
|
);
|
|
390
447
|
}
|
|
391
448
|
|
|
392
|
-
// Duplicated from HeaderAuthProvider.DEFAULT_HEADERS to avoid loading the runtime here.
|
|
393
|
-
// A test in providers/header-auth pins both sides, so drift surfaces in CI.
|
|
394
|
-
const DEFAULT_HEADER_NAMES = {
|
|
395
|
-
upn: 'SELVA-UserPrincipalName',
|
|
396
|
-
email: 'SELVA-Email',
|
|
397
|
-
displayName: 'SELVA-DisplayName'
|
|
398
|
-
};
|
|
399
|
-
|
|
400
449
|
// Static config only — runtime invariants like spoofing protection can't be checked from here.
|
|
401
450
|
function checkHeaderAuth(dir, env, dataProvider) {
|
|
402
451
|
const out = [];
|
|
@@ -466,29 +515,7 @@ function checkHeaderAuth(dir, env, dataProvider) {
|
|
|
466
515
|
out.push(green(`BOOTSTRAP_INSTANCE_ADMIN_EMAIL=${env.BOOTSTRAP_INSTANCE_ADMIN_EMAIL}`));
|
|
467
516
|
}
|
|
468
517
|
|
|
469
|
-
|
|
470
|
-
upn: env.HEADER_AUTH_UPN_HEADER || DEFAULT_HEADER_NAMES.upn,
|
|
471
|
-
email: env.HEADER_AUTH_EMAIL_HEADER || DEFAULT_HEADER_NAMES.email,
|
|
472
|
-
displayName: env.HEADER_AUTH_DISPLAY_NAME_HEADER || DEFAULT_HEADER_NAMES.displayName
|
|
473
|
-
};
|
|
474
|
-
const overrides = [
|
|
475
|
-
Boolean(env.HEADER_AUTH_UPN_HEADER),
|
|
476
|
-
Boolean(env.HEADER_AUTH_EMAIL_HEADER),
|
|
477
|
-
Boolean(env.HEADER_AUTH_DISPLAY_NAME_HEADER)
|
|
478
|
-
].filter(Boolean).length;
|
|
479
|
-
const headerList = `UPN=${resolved.upn}, Email=${resolved.email}, DisplayName=${resolved.displayName}`;
|
|
480
|
-
if (overrides === 0) {
|
|
481
|
-
out.push(green(`header names (bundled defaults): ${headerList}`));
|
|
482
|
-
} else if (overrides === 3) {
|
|
483
|
-
out.push(green(`header names (all overridden): ${headerList}`));
|
|
484
|
-
} else {
|
|
485
|
-
out.push(
|
|
486
|
-
yellow(
|
|
487
|
-
`header names partially overridden (${overrides}/3 set): ${headerList} — ` +
|
|
488
|
-
`a partial override is usually a typo. Set all three or none.`
|
|
489
|
-
)
|
|
490
|
-
);
|
|
491
|
-
}
|
|
518
|
+
out.push(checkHeaderNames(env));
|
|
492
519
|
|
|
493
520
|
return out;
|
|
494
521
|
}
|
package/src/commands/init.js
CHANGED
|
@@ -42,7 +42,11 @@ export async function runInit() {
|
|
|
42
42
|
p.log.warn('SELVA_AT_REST_KEY was missing — generated a fresh one.');
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
// A values-only rewrite drops anything it isn't handed, and the prompts cover
|
|
46
|
+
// only the vars they ask about — every tuned knob the operator set by hand
|
|
47
|
+
// (BODY_SIZE_LIMIT, COMPUTE_*, LOG_LEVEL, …) lives outside that set. Carry
|
|
48
|
+
// the existing file forward and let the prompt answers win on top.
|
|
49
|
+
writeEnvFile(envPath, readEnvTemplate(dir), { ...current, ...values });
|
|
46
50
|
|
|
47
51
|
p.outro(
|
|
48
52
|
[
|
package/src/commands/migrate.js
CHANGED
|
@@ -13,8 +13,12 @@ import {
|
|
|
13
13
|
buildDeploymentPackageJson,
|
|
14
14
|
CANONICAL_FIELDS,
|
|
15
15
|
DEPENDENCIES,
|
|
16
|
+
isFloatingPin,
|
|
16
17
|
LEGACY_DEPENDENCIES,
|
|
17
|
-
|
|
18
|
+
npmDistTagVersion,
|
|
19
|
+
OVERRIDES,
|
|
20
|
+
resolveSelvaPins,
|
|
21
|
+
SELVA_PACKAGES
|
|
18
22
|
} from '../deployment-package.js';
|
|
19
23
|
import { APP_NAME, runPm2 } from './pm2.js';
|
|
20
24
|
|
|
@@ -34,7 +38,8 @@ export async function runMigrate(
|
|
|
34
38
|
// predates the release being migrated to.
|
|
35
39
|
install = (cwd) => execSync('npm install --prefer-online', { cwd, stdio: 'pipe' }),
|
|
36
40
|
confirm = (message) => p.confirm({ message, initialValue: true }),
|
|
37
|
-
pm2 = runPm2
|
|
41
|
+
pm2 = runPm2,
|
|
42
|
+
resolveVersion = npmDistTagVersion
|
|
38
43
|
} = {}
|
|
39
44
|
) {
|
|
40
45
|
const dir = injectedDir ?? resolveDeploymentDir();
|
|
@@ -49,7 +54,7 @@ export async function runMigrate(
|
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
const before = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
52
|
-
const target = buildTargetPackageJson(before);
|
|
57
|
+
const { pkg: target, notes: pinNotes } = buildTargetPackageJson(before, resolveVersion);
|
|
53
58
|
const pkgDiff = diffPackageJson(before, target);
|
|
54
59
|
|
|
55
60
|
const configPath = join(dir, 'selva.config.js');
|
|
@@ -94,6 +99,23 @@ export async function runMigrate(
|
|
|
94
99
|
for (const line of pkgDiff) console.log(' ' + line);
|
|
95
100
|
for (const line of sideFileChanges) console.log(' ' + line);
|
|
96
101
|
|
|
102
|
+
// The diff shows the pin that will be written; these explain why it is that
|
|
103
|
+
// pin. A preserved beta and a pin that couldn't be resolved both look like
|
|
104
|
+
// "no change" in the diff, and the second one is a stale pin, not a stable one.
|
|
105
|
+
for (const note of pinNotes) {
|
|
106
|
+
if (note.kind === 'prerelease') {
|
|
107
|
+
p.log.info(
|
|
108
|
+
`${note.name} stays on ${note.pin} — a prerelease pin is kept, since \`latest\` ` +
|
|
109
|
+
`means newest stable and would move this deployment backwards.`
|
|
110
|
+
);
|
|
111
|
+
} else {
|
|
112
|
+
p.log.warn(
|
|
113
|
+
`Could not resolve the current version of ${note.name} (${note.reason}). ` +
|
|
114
|
+
`Keeping ${JSON.stringify(note.pin ?? null)}; re-run migrate when the registry is reachable.`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
97
119
|
const confirmed = await confirm('Apply these changes, reinstall, and restart?');
|
|
98
120
|
if (p.isCancel(confirmed) || !confirmed) {
|
|
99
121
|
p.cancel('Cancelled.');
|
|
@@ -242,12 +264,15 @@ function restartAfterRollback(dir, pm2 = runPm2) {
|
|
|
242
264
|
}
|
|
243
265
|
}
|
|
244
266
|
|
|
245
|
-
function buildTargetPackageJson(current) {
|
|
246
|
-
|
|
267
|
+
function buildTargetPackageJson(current, resolveVersion = npmDistTagVersion) {
|
|
268
|
+
const { pins, notes } = resolveSelvaPins(current.dependencies, resolveVersion);
|
|
269
|
+
const pkg = buildDeploymentPackageJson({
|
|
247
270
|
name: current.name ?? 'selva-deployment',
|
|
248
271
|
version: current.version ?? '0.1.0',
|
|
249
|
-
engines: current.engines
|
|
272
|
+
engines: current.engines,
|
|
273
|
+
dependencies: pins
|
|
250
274
|
});
|
|
275
|
+
return { pkg, notes };
|
|
251
276
|
}
|
|
252
277
|
|
|
253
278
|
// Format package.json diff for confirmation prompt (concise, not full diff).
|
|
@@ -331,6 +356,20 @@ export function detectDrift(pkgJson, dir) {
|
|
|
331
356
|
reasons.push(`pm2 is pinned to ${deps.pm2} (current scaffold pins ${DEPENDENCIES.pm2})`);
|
|
332
357
|
}
|
|
333
358
|
|
|
359
|
+
// A stored dist-tag re-resolves on every `npm install`, so the deployment
|
|
360
|
+
// follows the tag instead of a version an operator chose. Worse on a
|
|
361
|
+
// prerelease line, where `latest` points at the older stable release and the
|
|
362
|
+
// "upgrade" is a downgrade. Migrate wrote these before it learned to resolve
|
|
363
|
+
// tags, so existing deployments carry one and nothing else reports it.
|
|
364
|
+
for (const name of SELVA_PACKAGES) {
|
|
365
|
+
if (deps[name] && isFloatingPin(deps[name])) {
|
|
366
|
+
reasons.push(
|
|
367
|
+
`${name} is pinned to the floating tag ${JSON.stringify(deps[name])} ` +
|
|
368
|
+
`(re-resolves on every install; migrate writes a concrete version)`
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
334
373
|
// Overrides are security shims for vulnerable transitive deps — a
|
|
335
374
|
// deployment without them installs the vulnerable version on its next
|
|
336
375
|
// `npm install`, silently.
|
|
@@ -370,8 +409,14 @@ export function buildPm2UpgradeNotice(beforeVersion, afterVersion) {
|
|
|
370
409
|
`still the old version. Finish the upgrade (brief restart of managed processes):`,
|
|
371
410
|
``,
|
|
372
411
|
` npx pm2 update`,
|
|
412
|
+
` npx pm2 list # ${APP_NAME} must be listed and 'online' before the next step`,
|
|
373
413
|
` npx pm2 save`,
|
|
374
414
|
``,
|
|
415
|
+
`Do not run \`pm2 save\` until the list looks right. \`pm2 update\` empties the`,
|
|
416
|
+
`process table before restoring it, so saving a failed restore overwrites`,
|
|
417
|
+
`~/.pm2/dump.pm2 — the only record of what to bring back. If the list is empty,`,
|
|
418
|
+
`recover with \`npx pm2 resurrect\`, or \`npx pm2 start ecosystem.config.cjs\`.`,
|
|
419
|
+
``,
|
|
375
420
|
`If doctor reports a pm2 outside this deployment (global or apt install), follow`,
|
|
376
421
|
`the full procedure in the docs instead: self-hosting → deployment → prerequisites.`
|
|
377
422
|
].join('\n');
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// exactly the field where a silent difference costs the most.
|
|
5
5
|
|
|
6
6
|
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { execSync } from 'node:child_process';
|
|
7
8
|
import { join, dirname } from 'node:path';
|
|
8
9
|
import { fileURLToPath } from 'node:url';
|
|
9
10
|
|
|
@@ -51,6 +52,11 @@ const SCRIPTS = {
|
|
|
51
52
|
* Packages a deployment carries. Providers are bundled into `@selvajs/selva`;
|
|
52
53
|
* the CLI is a dependency so `npm run start` resolves the `selva` bin locally
|
|
53
54
|
* instead of relying on a global install.
|
|
55
|
+
*
|
|
56
|
+
* The `latest` entries are what `create` resolves at scaffold time, never what
|
|
57
|
+
* gets written: `resolveSelvaPins` turns them into concrete versions. A stored
|
|
58
|
+
* `"latest"` re-resolves on every `npm install`, so the deployment follows the
|
|
59
|
+
* dist-tag forever and can change under the operator with no migrate at all.
|
|
54
60
|
*/
|
|
55
61
|
const DEPENDENCIES = {
|
|
56
62
|
'@selvajs/cli': 'latest',
|
|
@@ -58,6 +64,34 @@ const DEPENDENCIES = {
|
|
|
58
64
|
pm2: PM2_VERSION
|
|
59
65
|
};
|
|
60
66
|
|
|
67
|
+
/** The `@selvajs/*` packages whose pin belongs to the operator, not the scaffold. */
|
|
68
|
+
export const SELVA_PACKAGES = ['@selvajs/cli', '@selvajs/selva'];
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* True for a pin that names no version — a dist-tag (`latest`, `beta`) or the
|
|
72
|
+
* empty-ish values npm also accepts (`*`, `""`). These are the pins that move
|
|
73
|
+
* on their own, which is what makes them unfit to store.
|
|
74
|
+
*/
|
|
75
|
+
export function isFloatingPin(pin) {
|
|
76
|
+
if (typeof pin !== 'string') return true;
|
|
77
|
+
const trimmed = pin.trim();
|
|
78
|
+
if (trimmed === '' || trimmed === '*' || trimmed === 'x') return true;
|
|
79
|
+
// A range still names versions; only a bare tag has no digits at all.
|
|
80
|
+
return !/\d/.test(trimmed);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* True for a pin on a prerelease line (`^4.8.0-beta.11`).
|
|
85
|
+
*
|
|
86
|
+
* These are preserved across a migration. An operator on a beta channel chose
|
|
87
|
+
* it, and npm's `latest` tag points at the newest *stable* release — so
|
|
88
|
+
* resolving their pin through `latest` is a downgrade that reads as an upgrade
|
|
89
|
+
* in the confirmation diff.
|
|
90
|
+
*/
|
|
91
|
+
export function isPrereleasePin(pin) {
|
|
92
|
+
return typeof pin === 'string' && /\d+\.\d+\.\d+-/.test(pin);
|
|
93
|
+
}
|
|
94
|
+
|
|
61
95
|
/**
|
|
62
96
|
* npm `overrides` forced onto every deployment — security patches for
|
|
63
97
|
* transitive deps whose direct parent hasn't picked up the fix yet. Each entry
|
|
@@ -100,21 +134,98 @@ export const LEGACY_DEPENDENCIES = {
|
|
|
100
134
|
* deliberately, and dropping it removes a guard whose absence shows up only
|
|
101
135
|
* under real traffic (issue #176). Everything else a migration discards is
|
|
102
136
|
* listed by `diffPackageJson` before the operator confirms.
|
|
137
|
+
*
|
|
138
|
+
* `dependencies` is the second exception, for the same reason: a version pin is
|
|
139
|
+
* an operator choice too. `migrate` passes the resolved `@selvajs/*` pins in
|
|
140
|
+
* here; a fresh scaffold passes none and inherits the defaults.
|
|
103
141
|
*/
|
|
104
|
-
export function buildDeploymentPackageJson({ name, version = '0.1.0', engines }) {
|
|
142
|
+
export function buildDeploymentPackageJson({ name, version = '0.1.0', engines, dependencies }) {
|
|
105
143
|
const pkg = {
|
|
106
144
|
name: sanitizePackageName(name),
|
|
107
145
|
version,
|
|
108
146
|
private: true,
|
|
109
147
|
type: 'module',
|
|
110
148
|
scripts: { ...SCRIPTS },
|
|
111
|
-
dependencies: { ...DEPENDENCIES },
|
|
149
|
+
dependencies: { ...DEPENDENCIES, ...dependencies },
|
|
112
150
|
overrides: { ...OVERRIDES }
|
|
113
151
|
};
|
|
114
152
|
if (engines && typeof engines === 'object') pkg.engines = { ...engines };
|
|
115
153
|
return pkg;
|
|
116
154
|
}
|
|
117
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Ask npm what a dist-tag currently points at. Kept beside the pin logic it
|
|
158
|
+
* serves, and injected rather than called directly so tests never hit the
|
|
159
|
+
* network.
|
|
160
|
+
*/
|
|
161
|
+
export function npmDistTagVersion(name, tag) {
|
|
162
|
+
const out = execSync(`npm view ${name}@${tag} version`, {
|
|
163
|
+
encoding: 'utf8',
|
|
164
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
165
|
+
});
|
|
166
|
+
const version = String(out).trim().split('\n').pop()?.trim();
|
|
167
|
+
if (!version || !/^\d+\.\d+\.\d+/.test(version)) {
|
|
168
|
+
throw new Error(`npm view returned no usable version for ${name}@${tag}`);
|
|
169
|
+
}
|
|
170
|
+
return version;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Decide the `@selvajs/*` pins a migration should write.
|
|
175
|
+
*
|
|
176
|
+
* Two rules, both aimed at the same failure: a migration silently moving a
|
|
177
|
+
* deployment to a version the operator didn't ask for.
|
|
178
|
+
*
|
|
179
|
+
* - A prerelease pin is kept as-is. `latest` means newest stable, so
|
|
180
|
+
* resolving a beta pin through it walks the deployment backwards.
|
|
181
|
+
* - Anything else resolves to a concrete version via `resolveVersion`, so
|
|
182
|
+
* `"latest"` never reaches disk and the diff can show what it resolved to.
|
|
183
|
+
*
|
|
184
|
+
* `resolveVersion` hits the network. When it fails — offline, registry down, a
|
|
185
|
+
* package with no such tag — the existing pin is kept and the reason returned,
|
|
186
|
+
* so migrate can report it rather than inventing a version or aborting a
|
|
187
|
+
* migration whose other half (layout, overrides, env renames) is still valid.
|
|
188
|
+
*/
|
|
189
|
+
export function resolveSelvaPins(currentDeps, resolveVersion) {
|
|
190
|
+
const pins = {};
|
|
191
|
+
const notes = [];
|
|
192
|
+
|
|
193
|
+
for (const name of SELVA_PACKAGES) {
|
|
194
|
+
const current = currentDeps?.[name];
|
|
195
|
+
|
|
196
|
+
if (isPrereleasePin(current)) {
|
|
197
|
+
pins[name] = current;
|
|
198
|
+
notes.push({ name, kind: 'prerelease', pin: current });
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
let resolved = null;
|
|
203
|
+
try {
|
|
204
|
+
resolved = resolveVersion(name, 'latest');
|
|
205
|
+
} catch (err) {
|
|
206
|
+
notes.push({ name, kind: 'unresolved', pin: current, reason: err?.message ?? String(err) });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (resolved) {
|
|
210
|
+
pins[name] = `^${resolved}`;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Nothing resolvable and nothing worth keeping — fall back to the tag so
|
|
215
|
+
// the install still succeeds. Rare: offline *and* a floating pin.
|
|
216
|
+
if (isFloatingPin(current)) {
|
|
217
|
+
pins[name] = DEPENDENCIES[name];
|
|
218
|
+
if (!notes.some((n) => n.name === name)) {
|
|
219
|
+
notes.push({ name, kind: 'unresolved', pin: current, reason: 'no version returned' });
|
|
220
|
+
}
|
|
221
|
+
} else {
|
|
222
|
+
pins[name] = current;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return { pins, notes };
|
|
227
|
+
}
|
|
228
|
+
|
|
118
229
|
/**
|
|
119
230
|
* Top-level keys the canonical package.json defines. Anything outside this set
|
|
120
231
|
* that an operator added is dropped by `migrate` — `diffPackageJson` reports
|
package/src/env.js
CHANGED
|
@@ -19,6 +19,142 @@ export const REPLACED_ENV_VARS = {
|
|
|
19
19
|
SELVA_FLAG_COMPUTE_DEBUG_VERBOSE: 'SELVA_FLAG_COMPUTE_DEBUG=verbose'
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
+
// Header written above the settings in a stripped .env. The pointer matters:
|
|
23
|
+
// without it the operator has no way back to what a var means, and the reflex
|
|
24
|
+
// is to paste the old annotated template back in.
|
|
25
|
+
export const ENV_HEADER = [
|
|
26
|
+
'# Selva deployment configuration.',
|
|
27
|
+
'#',
|
|
28
|
+
'# Values only — every variable is documented in the runtime template at',
|
|
29
|
+
'# node_modules/@selvajs/selva/templates/.env.example, which is refreshed on',
|
|
30
|
+
'# every update. Do not paste that template in here: a copy stops tracking',
|
|
31
|
+
'# the release and starts describing variables the code no longer reads.'
|
|
32
|
+
].join('\n');
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Strip a deployment `.env` down to its live settings, dropping the shipped
|
|
36
|
+
* documentation block.
|
|
37
|
+
*
|
|
38
|
+
* A comment block copied onto a server is a snapshot with no update path —
|
|
39
|
+
* `migrate` rewrites keys but never prose, so a deployment keeps documenting
|
|
40
|
+
* the release it was installed at, describing vars the code has since renamed
|
|
41
|
+
* or retired. Deleting it is what makes the file navigable AND unable to lie.
|
|
42
|
+
*
|
|
43
|
+
* Operator comments are NOT prose to be discarded: a short `#` run directly
|
|
44
|
+
* above a setting (no blank line between) is that setting's note and rides
|
|
45
|
+
* along with it. Everything else goes.
|
|
46
|
+
*
|
|
47
|
+
* "Short" is doing real work here. The shipped template also writes prose
|
|
48
|
+
* immediately above a setting — its banner blocks run 40+ lines and end with a
|
|
49
|
+
* bare `#` — so proximity alone would keep the very documentation this exists
|
|
50
|
+
* to remove. A hand-written note is a line or two; a block longer than
|
|
51
|
+
* ATTACHED_NOTE_MAX_LINES, or one containing a `# ====` banner rule, is the
|
|
52
|
+
* template's and is dropped.
|
|
53
|
+
*
|
|
54
|
+
* Returns `{ text, removed }`, `removed` being the count of dropped comment
|
|
55
|
+
* lines. `removed === 0` means the file was already values-only.
|
|
56
|
+
*/
|
|
57
|
+
export function stripEnvComments(text, { header = ENV_HEADER } = {}) {
|
|
58
|
+
// Our own header is re-emitted below, so counting it as prose we removed
|
|
59
|
+
// would make every run report work it didn't do — and break idempotency,
|
|
60
|
+
// which is what lets doctor call a stripped file clean. Matched as a whole
|
|
61
|
+
// leading block, not line-by-line: the header contains a bare `#`, and
|
|
62
|
+
// excluding that by value would silently stop counting an operator's own
|
|
63
|
+
// `#` separators anywhere in the file.
|
|
64
|
+
let lines = text.split(/\r?\n/);
|
|
65
|
+
if (header) {
|
|
66
|
+
const headerLines = header.split('\n');
|
|
67
|
+
const leads = headerLines.every((h, i) => lines[i]?.trim() === h.trim());
|
|
68
|
+
if (leads) lines = lines.slice(headerLines.length);
|
|
69
|
+
}
|
|
70
|
+
const out = [];
|
|
71
|
+
let pending = [];
|
|
72
|
+
let removed = 0;
|
|
73
|
+
let sawSetting = false;
|
|
74
|
+
// Whether a blank line or dropped prose separated the last setting from the
|
|
75
|
+
// next one — the original file's own grouping, worth carrying over.
|
|
76
|
+
let groupBreak = false;
|
|
77
|
+
|
|
78
|
+
const flushDropped = () => {
|
|
79
|
+
const dropped = pending.filter((l) => l.trim().startsWith('#'));
|
|
80
|
+
removed += dropped.length;
|
|
81
|
+
// A discarded banner marked a section boundary in the original; keep that
|
|
82
|
+
// boundary as a blank line so the result still reads as grouped settings.
|
|
83
|
+
if (dropped.length > 0 && sawSetting) groupBreak = true;
|
|
84
|
+
pending = [];
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
for (const line of lines) {
|
|
88
|
+
const stripped = line.trim();
|
|
89
|
+
|
|
90
|
+
if (stripped.startsWith('#')) {
|
|
91
|
+
pending.push(line);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// A blank line ends whatever comment block was accumulating: those
|
|
96
|
+
// comments float free rather than annotating the next setting.
|
|
97
|
+
if (!stripped) {
|
|
98
|
+
if (sawSetting) groupBreak = true;
|
|
99
|
+
flushDropped();
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (stripped.indexOf('=') === -1) {
|
|
104
|
+
flushDropped();
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Comments still pending sit directly above this setting. Keep them only
|
|
109
|
+
// if they read as a hand-written note rather than a template banner.
|
|
110
|
+
const note = isAttachedNote(pending);
|
|
111
|
+
// Blank line only where it earns one: before a kept note, or where the
|
|
112
|
+
// original had a break. Separating every key would just trade a wall of
|
|
113
|
+
// prose for a file twice as long as it needs to be.
|
|
114
|
+
if (sawSetting && (note || groupBreak)) out.push('');
|
|
115
|
+
if (note) {
|
|
116
|
+
out.push(...pending);
|
|
117
|
+
} else {
|
|
118
|
+
flushDropped();
|
|
119
|
+
}
|
|
120
|
+
pending = [];
|
|
121
|
+
groupBreak = false;
|
|
122
|
+
out.push(line);
|
|
123
|
+
sawSetting = true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Trailing comments annotate nothing.
|
|
127
|
+
flushDropped();
|
|
128
|
+
|
|
129
|
+
const body = out.join('\n');
|
|
130
|
+
return {
|
|
131
|
+
text: header ? `${header}\n\n${body}\n` : `${body}\n`,
|
|
132
|
+
removed
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// A hand-written note is a line or two. Longer than this and it is the shipped
|
|
137
|
+
// template's prose, which is what stripping exists to remove.
|
|
138
|
+
const ATTACHED_NOTE_MAX_LINES = 3;
|
|
139
|
+
|
|
140
|
+
// A `# ====` rule is the template's section-banner style; nothing hand-written
|
|
141
|
+
// above a single setting uses it.
|
|
142
|
+
const BANNER_RULE = /^#\s*[=-]{4,}/;
|
|
143
|
+
|
|
144
|
+
function isAttachedNote(pending) {
|
|
145
|
+
if (pending.length === 0 || pending.length > ATTACHED_NOTE_MAX_LINES) return false;
|
|
146
|
+
return !pending.some((l) => BANNER_RULE.test(l.trim()));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* How many lines of the file are shipped documentation rather than config.
|
|
151
|
+
* Used by `selva doctor` to flag an annotated `.env` without rewriting it.
|
|
152
|
+
*/
|
|
153
|
+
export function countEnvCommentLines(text) {
|
|
154
|
+
const { removed } = stripEnvComments(text);
|
|
155
|
+
return removed;
|
|
156
|
+
}
|
|
157
|
+
|
|
22
158
|
export function parseEnv(text) {
|
|
23
159
|
const out = {};
|
|
24
160
|
for (const rawLine of text.split(/\r?\n/)) {
|
|
@@ -88,8 +224,55 @@ export function mergeEnv(template, values) {
|
|
|
88
224
|
return out.join('\n') + '\n';
|
|
89
225
|
}
|
|
90
226
|
|
|
91
|
-
|
|
92
|
-
|
|
227
|
+
/**
|
|
228
|
+
* Render a values-only `.env` — the scaffold's default shape.
|
|
229
|
+
*
|
|
230
|
+
* The annotated template is genuinely useful while you are deciding what to
|
|
231
|
+
* set, which is why the runtime still ships it; it stops being useful the
|
|
232
|
+
* moment it is written to a server and cannot be corrected there. So the
|
|
233
|
+
* template stays a reference and the deployment gets values, in the template's
|
|
234
|
+
* own key order (it groups related settings) with anything unknown appended.
|
|
235
|
+
*/
|
|
236
|
+
export function renderEnvValues(template, values, { header = ENV_HEADER } = {}) {
|
|
237
|
+
const ordered = [];
|
|
238
|
+
const seen = new Set();
|
|
239
|
+
|
|
240
|
+
// The template names a var three ways — live (`KEY=v`), commented-out
|
|
241
|
+
// (`# KEY=v`), and as a bare mention in prose (`# KEY single | multi`).
|
|
242
|
+
// All three signal where the var belongs, so the first token of any line is
|
|
243
|
+
// enough; anything that isn't a known key is filtered out below.
|
|
244
|
+
for (const rawLine of template.split(/\r?\n/)) {
|
|
245
|
+
const stripped = rawLine.trim().replace(/^#\s*/, '');
|
|
246
|
+
const key = stripped.split(/[\s=]/)[0]?.trim();
|
|
247
|
+
if (!key || !/^[A-Z][A-Z0-9_]{2,}$/.test(key)) continue;
|
|
248
|
+
if (seen.has(key)) continue;
|
|
249
|
+
if (!Object.prototype.hasOwnProperty.call(values, key)) continue;
|
|
250
|
+
seen.add(key);
|
|
251
|
+
ordered.push(key);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
for (const key of Object.keys(values)) {
|
|
255
|
+
if (!seen.has(key)) ordered.push(key);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const lines = [];
|
|
259
|
+
for (const key of ordered) {
|
|
260
|
+
const value = values[key];
|
|
261
|
+
if (value === undefined || value === null || value === '') continue;
|
|
262
|
+
lines.push(`${key}=${quoteIfNeeded(value)}`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return header ? `${header}\n\n${lines.join('\n')}\n` : `${lines.join('\n')}\n`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Writes the deployment `.env`. Values-only by default — pass
|
|
270
|
+
* `{ annotated: true }` to merge into the template's comments instead, which
|
|
271
|
+
* only an operator who explicitly wants the old shape should do.
|
|
272
|
+
*/
|
|
273
|
+
export function writeEnvFile(path, template, values, { annotated = false } = {}) {
|
|
274
|
+
const text = annotated ? mergeEnv(template, values) : renderEnvValues(template, values);
|
|
275
|
+
writeFileSync(path, text, 'utf8');
|
|
93
276
|
}
|
|
94
277
|
|
|
95
278
|
/**
|