@selvajs/cli 4.8.0-beta.11 → 4.8.0-beta.13
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 +81 -3
- package/src/cli.js +69 -1
- package/src/commands/create.js +42 -2
- package/src/commands/doctor.js +98 -34
- package/src/commands/init.js +5 -1
- package/src/commands/migrate.js +140 -20
- 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.13",
|
|
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
|
+
`npx 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
|
@@ -15,7 +15,7 @@ import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
|
15
15
|
import { spawnSync } from 'node:child_process';
|
|
16
16
|
import { join } from 'node:path';
|
|
17
17
|
import { homedir } from 'node:os';
|
|
18
|
-
import { green, red, yellow } from './result.js';
|
|
18
|
+
import { fixable, green, red, yellow } from './result.js';
|
|
19
19
|
import { PM2_VERSION } from '../deployment-package.js';
|
|
20
20
|
|
|
21
21
|
/** The real OS. Tests pass a stand-in with the same shape. */
|
|
@@ -56,7 +56,8 @@ export function checkRuntimeEnvironment(dir, env = runtimeEnv) {
|
|
|
56
56
|
checkNpm(env),
|
|
57
57
|
checkNodeProvenance(env),
|
|
58
58
|
...checkPm2Install(dir, env),
|
|
59
|
-
...checkPm2Daemon(dir, env)
|
|
59
|
+
...checkPm2Daemon(dir, env),
|
|
60
|
+
...checkPm2LogRotation(dir, env)
|
|
60
61
|
];
|
|
61
62
|
}
|
|
62
63
|
|
|
@@ -112,7 +113,9 @@ function checkNodeProvenance(env) {
|
|
|
112
113
|
?.trim();
|
|
113
114
|
|
|
114
115
|
if (!onPath) {
|
|
115
|
-
return yellow(
|
|
116
|
+
return yellow(
|
|
117
|
+
`Node v${process.versions.node} at ${running} (${source}) — not resolvable on PATH`
|
|
118
|
+
);
|
|
116
119
|
}
|
|
117
120
|
|
|
118
121
|
if (env.realpath(onPath) === running) {
|
|
@@ -268,6 +271,81 @@ function checkPm2Daemon(dir, env) {
|
|
|
268
271
|
];
|
|
269
272
|
}
|
|
270
273
|
|
|
274
|
+
// ── pm2 log rotation ────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
// pm2 appends to `$PM2_HOME/logs/*.log` forever. Nothing in a default install
|
|
277
|
+
// truncates them, and the app writes a line per solve — so the only bound is
|
|
278
|
+
// the disk. It fails late and all at once: the partition fills, and pm2 and the
|
|
279
|
+
// app go down together on a box that looked healthy the day before.
|
|
280
|
+
//
|
|
281
|
+
// Detected from the filesystem, never by running `pm2 ls`: an invocation with
|
|
282
|
+
// no live daemon spawns one as a side effect, which doctor must not do (see
|
|
283
|
+
// checkPm2Daemon). Installed modules live in `$PM2_HOME/node_modules`.
|
|
284
|
+
function checkPm2LogRotation(dir, env) {
|
|
285
|
+
const bin = join(dir, 'node_modules', '.bin', env.platform() === 'win32' ? 'pm2.cmd' : 'pm2');
|
|
286
|
+
if (!env.exists(bin)) return [];
|
|
287
|
+
|
|
288
|
+
const pm2Home = env.env().PM2_HOME ?? join(env.homedir(), '.pm2');
|
|
289
|
+
if (env.exists(join(pm2Home, 'node_modules', 'pm2-logrotate'))) {
|
|
290
|
+
return [green('pm2-logrotate installed — pm2 logs are rotated')];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return [
|
|
294
|
+
yellow(
|
|
295
|
+
'pm2-logrotate is not installed — pm2 logs grow without bound and are only\n ' +
|
|
296
|
+
'capped by free disk. Install it (takes effect immediately, no restart):\n ' +
|
|
297
|
+
'npx pm2 install pm2-logrotate\n ' +
|
|
298
|
+
`npx pm2 set pm2-logrotate:rotateInterval "${LOGROTATE_SETTINGS.rotateInterval}"\n ` +
|
|
299
|
+
`npx pm2 set pm2-logrotate:retain ${LOGROTATE_SETTINGS.retain}\n ` +
|
|
300
|
+
`npx pm2 set pm2-logrotate:compress ${LOGROTATE_SETTINGS.compress}`,
|
|
301
|
+
fixable('install pm2-logrotate (weekly rotation, 8 kept, compressed)', () =>
|
|
302
|
+
installLogRotate(bin, dir, env)
|
|
303
|
+
)
|
|
304
|
+
)
|
|
305
|
+
];
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Weekly on Sunday 00:00, keeping 8 — about two months of history, compressed.
|
|
310
|
+
*
|
|
311
|
+
* Weekly rather than pm2's daily default: a Selva deployment writes a line per
|
|
312
|
+
* solve, so daily files are mostly idle days, and two months of them is a lot
|
|
313
|
+
* of near-empty archives to page through when tracing an incident.
|
|
314
|
+
*/
|
|
315
|
+
const LOGROTATE_SETTINGS = {
|
|
316
|
+
rotateInterval: '0 0 * * 0',
|
|
317
|
+
retain: 8,
|
|
318
|
+
compress: true
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
// `pm2 install` needs no root and doesn't restart managed processes, which is
|
|
322
|
+
// what makes it eligible as a doctor fixer at all. The settings are applied
|
|
323
|
+
// after: failing to set one leaves rotation on at pm2's defaults, which still
|
|
324
|
+
// beats unbounded growth, so a failed `set` is reported, not fatal.
|
|
325
|
+
function installLogRotate(bin, dir, env) {
|
|
326
|
+
const install = env.run(bin, ['install', 'pm2-logrotate'], { cwd: dir });
|
|
327
|
+
if ((install.status ?? 1) !== 0) {
|
|
328
|
+
const detail = String(install.stderr ?? install.stdout ?? '')
|
|
329
|
+
.trim()
|
|
330
|
+
.split('\n')
|
|
331
|
+
.pop();
|
|
332
|
+
return red(`\`pm2 install pm2-logrotate\` failed${detail ? `: ${detail}` : ''}`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const failed = [];
|
|
336
|
+
for (const [key, value] of Object.entries(LOGROTATE_SETTINGS)) {
|
|
337
|
+
const set = env.run(bin, ['set', `pm2-logrotate:${key}`, String(value)], { cwd: dir });
|
|
338
|
+
if ((set.status ?? 1) !== 0) failed.push(key);
|
|
339
|
+
}
|
|
340
|
+
if (failed.length > 0) {
|
|
341
|
+
return yellow(
|
|
342
|
+
`pm2-logrotate installed, but could not set: ${failed.join(', ')}. ` +
|
|
343
|
+
`Rotation is active at pm2's defaults; set them by hand with \`npx pm2 set\`.`
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
return green('pm2-logrotate installed — weekly rotation, 8 kept, compressed');
|
|
347
|
+
}
|
|
348
|
+
|
|
271
349
|
function parseSkew(output) {
|
|
272
350
|
// eslint-disable-next-line no-control-regex -- pm2 colourises via chalk on a TTY
|
|
273
351
|
const clean = output.replace(/\[[0-9;]*m/g, '');
|
package/src/cli.js
CHANGED
|
@@ -18,6 +18,59 @@ const COMMANDS = {
|
|
|
18
18
|
keys: () => import('./commands/keys.js').then((m) => keysDispatch(m))
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
+
// `selva doctor --help` used to run the full doctor: commands own their argv,
|
|
22
|
+
// and only `doctor` reads a flag at all, so every other command silently
|
|
23
|
+
// ignored `--help` and did its work. Intercepting here keeps `--help` uniform
|
|
24
|
+
// across the whole surface without each command re-implementing it.
|
|
25
|
+
const USAGE = {
|
|
26
|
+
init: {
|
|
27
|
+
usage: 'selva init',
|
|
28
|
+
blurb:
|
|
29
|
+
'Reconfigure this deployment. Prompts for the same values as scaffolding\nand rewrites .env; generated secrets are left alone.'
|
|
30
|
+
},
|
|
31
|
+
doctor: {
|
|
32
|
+
usage: 'selva doctor [--fix]',
|
|
33
|
+
blurb:
|
|
34
|
+
'Validate .env, providers, Node engine, pm2 boot persistence, and package\npins. Read-only without --fix.',
|
|
35
|
+
flags: [['--fix', 'Apply the repairs doctor reports as automatic. Prompts before each.']]
|
|
36
|
+
},
|
|
37
|
+
start: { usage: 'selva start', blurb: 'pm2 start ecosystem.config.cjs' },
|
|
38
|
+
stop: { usage: 'selva stop', blurb: 'pm2 stop selva-compute' },
|
|
39
|
+
restart: {
|
|
40
|
+
usage: 'selva restart',
|
|
41
|
+
blurb:
|
|
42
|
+
'pm2 restart selva-compute --update-env. The --update-env is why this\nexists: without it a pm2 restart keeps the old .env in memory.'
|
|
43
|
+
},
|
|
44
|
+
logs: { usage: 'selva logs', blurb: 'pm2 logs selva-compute' },
|
|
45
|
+
update: {
|
|
46
|
+
usage: 'selva update',
|
|
47
|
+
blurb:
|
|
48
|
+
'npm update @selvajs/cli + @selvajs/selva, then restart. Rolls the\ndependency tree back if the install fails.'
|
|
49
|
+
},
|
|
50
|
+
migrate: {
|
|
51
|
+
usage: 'selva migrate',
|
|
52
|
+
blurb:
|
|
53
|
+
'Bring package.json and the deployment layout onto the current release.\nRewrites keys, never comments.'
|
|
54
|
+
},
|
|
55
|
+
keys: {
|
|
56
|
+
usage: 'selva keys rotate <hmac|at-rest>',
|
|
57
|
+
blurb:
|
|
58
|
+
'Rotate one secret in .env. Destructive and not reversible — the command\nspells out the blast radius and asks before writing.'
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function printCommandHelp(command) {
|
|
63
|
+
const entry = USAGE[command];
|
|
64
|
+
const lines = [pc.bold(entry.usage), '', entry.blurb];
|
|
65
|
+
if (entry.flags) {
|
|
66
|
+
lines.push('', pc.bold('Flags:'));
|
|
67
|
+
for (const [flag, description] of entry.flags) {
|
|
68
|
+
lines.push(` ${flag.padEnd(22)}${description}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
console.log(lines.join('\n'));
|
|
72
|
+
}
|
|
73
|
+
|
|
21
74
|
function keysDispatch(m) {
|
|
22
75
|
return async (argv) => {
|
|
23
76
|
const sub = argv[0];
|
|
@@ -51,6 +104,11 @@ export async function runSelva(argv) {
|
|
|
51
104
|
process.exit(1);
|
|
52
105
|
}
|
|
53
106
|
|
|
107
|
+
if (rest.includes('--help') || rest.includes('-h')) {
|
|
108
|
+
printCommandHelp(command);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
54
112
|
const run = await loader();
|
|
55
113
|
await run(rest);
|
|
56
114
|
}
|
|
@@ -60,6 +118,8 @@ function printHelp() {
|
|
|
60
118
|
[
|
|
61
119
|
pc.bold('selva') + ' — operate a Selva deployment',
|
|
62
120
|
'',
|
|
121
|
+
pc.bold('Usage:') + ' npx selva <command>',
|
|
122
|
+
'',
|
|
63
123
|
pc.bold('Commands:'),
|
|
64
124
|
' init Reconfigure this deployment (prompts again)',
|
|
65
125
|
' doctor [--fix] Validate env, providers, Node engine, and packages',
|
|
@@ -71,7 +131,15 @@ function printHelp() {
|
|
|
71
131
|
' migrate Bring package.json onto the current layout',
|
|
72
132
|
' keys rotate <hmac|at-rest> Rotate a secret in .env (destructive)',
|
|
73
133
|
'',
|
|
74
|
-
pc.dim('
|
|
134
|
+
pc.dim('`npx selva <command> --help` explains one command.'),
|
|
135
|
+
'',
|
|
136
|
+
pc.bold('Two commands, two jobs:'),
|
|
137
|
+
' ' + pc.cyan('npx @selvajs/cli <dir>') + ' scaffold a NEW deployment into <dir>',
|
|
138
|
+
' ' + pc.cyan('npx selva <command>') + ' operate the deployment you are standing in',
|
|
139
|
+
'',
|
|
140
|
+
pc.dim('`npx selva` works in a deployment directory because @selvajs/cli is one of'),
|
|
141
|
+
pc.dim('its dependencies. The package.json scripts (`npm run doctor`, `npm start`)'),
|
|
142
|
+
pc.dim('are thin aliases for the same commands.')
|
|
75
143
|
].join('\n')
|
|
76
144
|
);
|
|
77
145
|
}
|
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) {
|
|
@@ -201,11 +215,37 @@ function runNpmInstall(cwd) {
|
|
|
201
215
|
});
|
|
202
216
|
}
|
|
203
217
|
|
|
218
|
+
// `npx @selvajs/cli` resolves to the `cli` bin — the scaffolder — while every
|
|
219
|
+
// operate command lives on the sibling `selva` bin. So `npx @selvajs/cli doctor
|
|
220
|
+
// --fix` lands here, where "doctor" is just a directory name and `--fix` an
|
|
221
|
+
// unknown flag. Name the bin split rather than letting the operator conclude
|
|
222
|
+
// their CLI is too old for a flag it has shipped for releases.
|
|
223
|
+
const OPERATE_COMMANDS = new Set([
|
|
224
|
+
'doctor',
|
|
225
|
+
'start',
|
|
226
|
+
'stop',
|
|
227
|
+
'restart',
|
|
228
|
+
'logs',
|
|
229
|
+
'update',
|
|
230
|
+
'migrate',
|
|
231
|
+
'keys',
|
|
232
|
+
'init'
|
|
233
|
+
]);
|
|
234
|
+
|
|
204
235
|
function parseArgs(argv) {
|
|
205
236
|
let dir;
|
|
206
237
|
let force = false;
|
|
207
238
|
let skipInstall = false;
|
|
208
239
|
let yes = false;
|
|
240
|
+
|
|
241
|
+
if (OPERATE_COMMANDS.has(argv[0])) {
|
|
242
|
+
throw new Error(
|
|
243
|
+
`\`${argv[0]}\` is an operate command, but \`npx @selvajs/cli\` runs the scaffolder.\n` +
|
|
244
|
+
` Run it through the deployment's own CLI instead:\n` +
|
|
245
|
+
` npx selva ${argv.join(' ')}`
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
209
249
|
for (const arg of argv) {
|
|
210
250
|
if (arg === '--force') force = true;
|
|
211
251
|
else if (arg === '--skip-install') skipInstall = true;
|
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,13 +28,15 @@ 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,
|
|
32
36
|
checkTenancy,
|
|
33
37
|
resolveProviders
|
|
34
38
|
} from '../checks/config.js';
|
|
35
|
-
import { detectDrift, NODE_MODULES_STASH } from './migrate.js';
|
|
39
|
+
import { BACKUP_AGE_DAYS, classifyBackups, detectDrift, NODE_MODULES_STASH } from './migrate.js';
|
|
36
40
|
|
|
37
41
|
export async function runDoctor(argv = []) {
|
|
38
42
|
const fix = argv.includes('--fix');
|
|
@@ -48,6 +52,7 @@ export async function runDoctor(argv = []) {
|
|
|
48
52
|
checks.push(checkFile(join(dir, 'ecosystem.config.cjs'), 'ecosystem.config.cjs present'));
|
|
49
53
|
checks.push(checkLayoutDrift(dir));
|
|
50
54
|
checks.push(checkMigrationLeftovers(dir));
|
|
55
|
+
checks.push(checkAgedBackups(dir));
|
|
51
56
|
checks.push(checkSecret(env.SELVA_HMAC_KEY, 'SELVA_HMAC_KEY is a 32-byte hex string'));
|
|
52
57
|
checks.push(checkSecret(env.SELVA_AT_REST_KEY, 'SELVA_AT_REST_KEY is a 32-byte hex string'));
|
|
53
58
|
|
|
@@ -78,6 +83,7 @@ export async function runDoctor(argv = []) {
|
|
|
78
83
|
checks.push(...checkBootPersistence(dir));
|
|
79
84
|
checks.push(checkOrigin(env));
|
|
80
85
|
checks.push(...checkDeprecatedEnv(env));
|
|
86
|
+
checks.push(checkEnvDocs(dir));
|
|
81
87
|
|
|
82
88
|
// ── Render ─────────────────────────────────────────────────────────
|
|
83
89
|
const resolved = await Promise.all(checks);
|
|
@@ -94,7 +100,7 @@ export async function runDoctor(argv = []) {
|
|
|
94
100
|
if (repairable > 0) {
|
|
95
101
|
p.log.info(
|
|
96
102
|
`${repairable} issue${repairable === 1 ? '' : 's'} can be repaired automatically — ` +
|
|
97
|
-
`re-run with \`selva doctor --fix\`.`
|
|
103
|
+
`re-run with \`npx selva doctor --fix\`.`
|
|
98
104
|
);
|
|
99
105
|
}
|
|
100
106
|
}
|
|
@@ -372,6 +378,94 @@ function checkMigrationLeftovers(dir) {
|
|
|
372
378
|
);
|
|
373
379
|
}
|
|
374
380
|
|
|
381
|
+
// Migration backups are timestamped so a later migration can't overwrite an
|
|
382
|
+
// earlier one's (#184), which means they accumulate. Deleting them silently
|
|
383
|
+
// would throw away the operator's only copy of a pre-migration config, so this
|
|
384
|
+
// reports and lets `--fix` opt in. `classifyBackups` always keeps the newest
|
|
385
|
+
// run, so the escape hatch is never emptied — only superseded generations go.
|
|
386
|
+
function checkAgedBackups(dir) {
|
|
387
|
+
let names;
|
|
388
|
+
try {
|
|
389
|
+
names = readdirSync(dir);
|
|
390
|
+
} catch {
|
|
391
|
+
return green('no aged migration backups');
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const { aged } = classifyBackups(names);
|
|
395
|
+
if (aged.length === 0) return green('no aged migration backups');
|
|
396
|
+
|
|
397
|
+
const shown = aged.slice(0, 3).join(', ');
|
|
398
|
+
const more = aged.length > 3 ? `, +${aged.length - 3} more` : '';
|
|
399
|
+
return yellow(
|
|
400
|
+
`${aged.length} migration backup${aged.length === 1 ? '' : 's'} older than ` +
|
|
401
|
+
`${BACKUP_AGE_DAYS} days (${shown}${more}). The newest set is kept either way.`,
|
|
402
|
+
fixable(`delete ${aged.length} aged migration backup${aged.length === 1 ? '' : 's'}`, () => {
|
|
403
|
+
const failed = [];
|
|
404
|
+
for (const name of aged) {
|
|
405
|
+
try {
|
|
406
|
+
rmSync(join(dir, name), { force: true });
|
|
407
|
+
} catch (err) {
|
|
408
|
+
failed.push(`${name} (${err instanceof Error ? err.message : err})`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (failed.length > 0) return red(`could not remove: ${failed.join(', ')}`);
|
|
412
|
+
return green(`removed ${aged.length} aged backup${aged.length === 1 ? '' : 's'}`);
|
|
413
|
+
})
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Reads the raw file rather than the parsed env: the finding IS the comments,
|
|
418
|
+
// which parseEnv discards. The repair is attached here (not in checks/config.js)
|
|
419
|
+
// to keep that module filesystem-free.
|
|
420
|
+
function checkEnvDocs(dir) {
|
|
421
|
+
const envPath = join(dir, '.env');
|
|
422
|
+
if (!existsSync(envPath)) return green('.env documentation check skipped (no .env)');
|
|
423
|
+
|
|
424
|
+
let text;
|
|
425
|
+
try {
|
|
426
|
+
text = readFileSync(envPath, 'utf8');
|
|
427
|
+
} catch (err) {
|
|
428
|
+
return yellow(`.env unreadable (${err instanceof Error ? err.message : err})`);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return checkEnvDocumentation(
|
|
432
|
+
countEnvCommentLines(text),
|
|
433
|
+
fixable('strip the shipped documentation from .env (backup: .env.bak)', () => {
|
|
434
|
+
try {
|
|
435
|
+
// Re-read at repair time: doctor may have run minutes ago in --fix's
|
|
436
|
+
// confirmation loop, and rewriting from a stale snapshot would silently
|
|
437
|
+
// revert an edit made in between.
|
|
438
|
+
const current = readFileSync(envPath, 'utf8');
|
|
439
|
+
const { text: strippedText, removed } = stripEnvComments(current);
|
|
440
|
+
// A rewrite that changes which settings are live is a bug, not a
|
|
441
|
+
// cleanup — refuse rather than hand back a subtly different deployment.
|
|
442
|
+
const before = parseEnv(current);
|
|
443
|
+
const after = parseEnv(strippedText);
|
|
444
|
+
const drift = diffEnvKeys(before, after);
|
|
445
|
+
if (drift) return red(`refused to strip .env — ${drift}`);
|
|
446
|
+
|
|
447
|
+
copyFileSync(envPath, envPath + '.bak');
|
|
448
|
+
writeFileSync(envPath, strippedText, 'utf8');
|
|
449
|
+
return green(`stripped ${removed} comment lines from .env (backup: .env.bak)`);
|
|
450
|
+
} catch (err) {
|
|
451
|
+
return red(`could not rewrite .env: ${err instanceof Error ? err.message : err}`);
|
|
452
|
+
}
|
|
453
|
+
})
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Names the first discrepancy between two parsed envs, or null when identical.
|
|
458
|
+
function diffEnvKeys(before, after) {
|
|
459
|
+
for (const key of Object.keys(before)) {
|
|
460
|
+
if (!(key in after)) return `${key} would be lost`;
|
|
461
|
+
if (before[key] !== after[key]) return `${key} would change value`;
|
|
462
|
+
}
|
|
463
|
+
for (const key of Object.keys(after)) {
|
|
464
|
+
if (!(key in before)) return `${key} would be added`;
|
|
465
|
+
}
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
|
|
375
469
|
function checkLayoutDrift(dir) {
|
|
376
470
|
const pkgPath = join(dir, 'package.json');
|
|
377
471
|
if (!existsSync(pkgPath)) return yellow('package.json missing — cannot check layout');
|
|
@@ -389,14 +483,6 @@ function checkLayoutDrift(dir) {
|
|
|
389
483
|
);
|
|
390
484
|
}
|
|
391
485
|
|
|
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
486
|
// Static config only — runtime invariants like spoofing protection can't be checked from here.
|
|
401
487
|
function checkHeaderAuth(dir, env, dataProvider) {
|
|
402
488
|
const out = [];
|
|
@@ -466,29 +552,7 @@ function checkHeaderAuth(dir, env, dataProvider) {
|
|
|
466
552
|
out.push(green(`BOOTSTRAP_INSTANCE_ADMIN_EMAIL=${env.BOOTSTRAP_INSTANCE_ADMIN_EMAIL}`));
|
|
467
553
|
}
|
|
468
554
|
|
|
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
|
-
}
|
|
555
|
+
out.push(checkHeaderNames(env));
|
|
492
556
|
|
|
493
557
|
return out;
|
|
494
558
|
}
|
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
|
|
|
@@ -23,6 +27,74 @@ import { APP_NAME, runPm2 } from './pm2.js';
|
|
|
23
27
|
// flags a surviving stash as an interrupted migration.
|
|
24
28
|
export const NODE_MODULES_STASH = 'node_modules.selva-migrate-bak';
|
|
25
29
|
|
|
30
|
+
// Matches the backups migrate writes: `.env.2026-08-10T05-43-12.bak`. Anchored
|
|
31
|
+
// at both ends so an operator's own `notes.bak` is never picked up as ours.
|
|
32
|
+
export const BACKUP_PATTERN = /\.(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2})\.bak$/;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Colons are illegal in Windows filenames, so this is ISO with `:` swapped for
|
|
36
|
+
* `-` and the fractional seconds dropped: 2026-08-10T05-43-12.
|
|
37
|
+
*/
|
|
38
|
+
export function backupStamp(now = new Date()) {
|
|
39
|
+
return now
|
|
40
|
+
.toISOString()
|
|
41
|
+
.replace(/\.\d+Z$/, '')
|
|
42
|
+
.replace(/:/g, '-');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A migration used to write `package.json.bak` at a fixed path, so the second
|
|
47
|
+
* substantive migration overwrote the first's copy — and since each run backs
|
|
48
|
+
* up the *current* file, that left the operator's original config recoverable
|
|
49
|
+
* from nowhere (#184). One stamp per run keeps a whole migration's backups
|
|
50
|
+
* together and makes them sortable.
|
|
51
|
+
*
|
|
52
|
+
* Not auto-pruned here: deleting an operator's only copy of their old config is
|
|
53
|
+
* worse than a few small files accumulating. `selva doctor` reports aged ones
|
|
54
|
+
* with a `--fix`, which keeps the decision theirs.
|
|
55
|
+
*/
|
|
56
|
+
export function backupPathFor(filePath, stamp) {
|
|
57
|
+
return `${filePath}.${stamp}.bak`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Old enough that the migration which wrote it is long settled.
|
|
61
|
+
export const BACKUP_AGE_DAYS = 30;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Split migration backups into what to keep and what `doctor --fix` may delete.
|
|
65
|
+
*
|
|
66
|
+
* The newest run's backups are always kept, however old they are — on a
|
|
67
|
+
* deployment that migrates once a year, age alone would delete the only copy of
|
|
68
|
+
* the pre-migration config, which is the whole point of keeping them. So the
|
|
69
|
+
* rule is: keep the newest stamp, offer to delete stamps older than
|
|
70
|
+
* `BACKUP_AGE_DAYS`.
|
|
71
|
+
*
|
|
72
|
+
* `names` are basenames; the stamp is read from the filename rather than mtime,
|
|
73
|
+
* which a copy or restore would reset.
|
|
74
|
+
*/
|
|
75
|
+
export function classifyBackups(names, now = new Date(), maxAgeDays = BACKUP_AGE_DAYS) {
|
|
76
|
+
const found = [];
|
|
77
|
+
for (const name of names) {
|
|
78
|
+
const m = BACKUP_PATTERN.exec(name);
|
|
79
|
+
if (m) found.push({ name, stamp: m[1] });
|
|
80
|
+
}
|
|
81
|
+
if (found.length === 0) return { keep: [], aged: [], newest: null };
|
|
82
|
+
|
|
83
|
+
// Stamps are fixed-width and zero-padded, so lexical order is chronological.
|
|
84
|
+
const newest = found.reduce((a, b) => (b.stamp > a.stamp ? b : a)).stamp;
|
|
85
|
+
const cutoff = now.getTime() - maxAgeDays * 24 * 60 * 60 * 1000;
|
|
86
|
+
|
|
87
|
+
const keep = [];
|
|
88
|
+
const aged = [];
|
|
89
|
+
for (const entry of found) {
|
|
90
|
+
// `2026-08-10T05-43-12` → parseable by restoring the colons.
|
|
91
|
+
const at = Date.parse(entry.stamp.replace(/T(\d{2})-(\d{2})-(\d{2})$/, 'T$1:$2:$3') + 'Z');
|
|
92
|
+
if (entry.stamp === newest || !Number.isFinite(at) || at >= cutoff) keep.push(entry.name);
|
|
93
|
+
else aged.push(entry.name);
|
|
94
|
+
}
|
|
95
|
+
return { keep, aged, newest };
|
|
96
|
+
}
|
|
97
|
+
|
|
26
98
|
// `deps` are seams for tests: the rollback path destroys and restores a real
|
|
27
99
|
// dependency tree, so it has to run against a real directory — but not against
|
|
28
100
|
// a real npm or pm2 daemon.
|
|
@@ -34,7 +106,8 @@ export async function runMigrate(
|
|
|
34
106
|
// predates the release being migrated to.
|
|
35
107
|
install = (cwd) => execSync('npm install --prefer-online', { cwd, stdio: 'pipe' }),
|
|
36
108
|
confirm = (message) => p.confirm({ message, initialValue: true }),
|
|
37
|
-
pm2 = runPm2
|
|
109
|
+
pm2 = runPm2,
|
|
110
|
+
resolveVersion = npmDistTagVersion
|
|
38
111
|
} = {}
|
|
39
112
|
) {
|
|
40
113
|
const dir = injectedDir ?? resolveDeploymentDir();
|
|
@@ -49,7 +122,7 @@ export async function runMigrate(
|
|
|
49
122
|
}
|
|
50
123
|
|
|
51
124
|
const before = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
52
|
-
const target = buildTargetPackageJson(before);
|
|
125
|
+
const { pkg: target, notes: pinNotes } = buildTargetPackageJson(before, resolveVersion);
|
|
53
126
|
const pkgDiff = diffPackageJson(before, target);
|
|
54
127
|
|
|
55
128
|
const configPath = join(dir, 'selva.config.js');
|
|
@@ -94,6 +167,23 @@ export async function runMigrate(
|
|
|
94
167
|
for (const line of pkgDiff) console.log(' ' + line);
|
|
95
168
|
for (const line of sideFileChanges) console.log(' ' + line);
|
|
96
169
|
|
|
170
|
+
// The diff shows the pin that will be written; these explain why it is that
|
|
171
|
+
// pin. A preserved beta and a pin that couldn't be resolved both look like
|
|
172
|
+
// "no change" in the diff, and the second one is a stale pin, not a stable one.
|
|
173
|
+
for (const note of pinNotes) {
|
|
174
|
+
if (note.kind === 'prerelease') {
|
|
175
|
+
p.log.info(
|
|
176
|
+
`${note.name} stays on ${note.pin} — a prerelease pin is kept, since \`latest\` ` +
|
|
177
|
+
`means newest stable and would move this deployment backwards.`
|
|
178
|
+
);
|
|
179
|
+
} else {
|
|
180
|
+
p.log.warn(
|
|
181
|
+
`Could not resolve the current version of ${note.name} (${note.reason}). ` +
|
|
182
|
+
`Keeping ${JSON.stringify(note.pin ?? null)}; re-run migrate when the registry is reachable.`
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
97
187
|
const confirmed = await confirm('Apply these changes, reinstall, and restart?');
|
|
98
188
|
if (p.isCancel(confirmed) || !confirmed) {
|
|
99
189
|
p.cancel('Cancelled.');
|
|
@@ -113,17 +203,24 @@ export async function runMigrate(
|
|
|
113
203
|
p.log.warn('No deployment-local pm2 to stop — continuing.');
|
|
114
204
|
}
|
|
115
205
|
|
|
116
|
-
|
|
206
|
+
// One stamp for the whole run, so a migration's backups sort together and a
|
|
207
|
+
// later migration cannot overwrite this one's (#184).
|
|
208
|
+
const stamp = backupStamp();
|
|
209
|
+
const bakPath = backupPathFor(pkgPath, stamp);
|
|
210
|
+
const configBak = backupPathFor(configPath, stamp);
|
|
211
|
+
const ecoBak = backupPathFor(ecoPath, stamp);
|
|
212
|
+
const envBak = backupPathFor(envPath, stamp);
|
|
213
|
+
|
|
117
214
|
copyFileSync(pkgPath, bakPath);
|
|
118
215
|
writeFileSync(pkgPath, JSON.stringify(target, null, 2) + '\n', 'utf8');
|
|
119
216
|
|
|
120
217
|
if (hasStaleConfig) {
|
|
121
|
-
copyFileSync(configPath,
|
|
218
|
+
copyFileSync(configPath, configBak);
|
|
122
219
|
rmSync(configPath, { force: true });
|
|
123
220
|
}
|
|
124
221
|
|
|
125
222
|
if (ecoHasStaleRuntime) {
|
|
126
|
-
copyFileSync(ecoPath,
|
|
223
|
+
copyFileSync(ecoPath, ecoBak);
|
|
127
224
|
// Text substitution, not regeneration, so any operator customizations to the file survive.
|
|
128
225
|
const ecoContent = readFileSync(ecoPath, 'utf8').replace(
|
|
129
226
|
/@selvajs\/runtime/g,
|
|
@@ -133,7 +230,7 @@ export async function runMigrate(
|
|
|
133
230
|
}
|
|
134
231
|
|
|
135
232
|
if (envRename.changes.length > 0) {
|
|
136
|
-
copyFileSync(envPath,
|
|
233
|
+
copyFileSync(envPath, envBak);
|
|
137
234
|
writeFileSync(envPath, envRename.text, 'utf8');
|
|
138
235
|
}
|
|
139
236
|
|
|
@@ -160,14 +257,14 @@ export async function runMigrate(
|
|
|
160
257
|
} catch (err) {
|
|
161
258
|
s.stop(pc.red('npm install failed — rolling back'));
|
|
162
259
|
copyFileSync(bakPath, pkgPath);
|
|
163
|
-
if (hasStaleConfig && existsSync(
|
|
164
|
-
copyFileSync(
|
|
260
|
+
if (hasStaleConfig && existsSync(configBak)) {
|
|
261
|
+
copyFileSync(configBak, configPath);
|
|
165
262
|
}
|
|
166
|
-
if (ecoHasStaleRuntime && existsSync(
|
|
167
|
-
copyFileSync(
|
|
263
|
+
if (ecoHasStaleRuntime && existsSync(ecoBak)) {
|
|
264
|
+
copyFileSync(ecoBak, ecoPath);
|
|
168
265
|
}
|
|
169
|
-
if (envRename.changes.length > 0 && existsSync(
|
|
170
|
-
copyFileSync(
|
|
266
|
+
if (envRename.changes.length > 0 && existsSync(envBak)) {
|
|
267
|
+
copyFileSync(envBak, envPath);
|
|
171
268
|
}
|
|
172
269
|
|
|
173
270
|
// A half-installed node_modules from the failed attempt is worse than the old
|
|
@@ -202,10 +299,10 @@ export async function runMigrate(
|
|
|
202
299
|
const pm2Notice = buildPm2UpgradeNotice(before.dependencies?.pm2, target.dependencies.pm2);
|
|
203
300
|
if (pm2Notice) p.log.warn(pm2Notice);
|
|
204
301
|
|
|
205
|
-
const backupHints = [
|
|
206
|
-
if (hasStaleConfig) backupHints.push(
|
|
207
|
-
if (ecoHasStaleRuntime) backupHints.push(
|
|
208
|
-
if (envRename.changes.length > 0) backupHints.push(
|
|
302
|
+
const backupHints = [`package.json.${stamp}.bak`];
|
|
303
|
+
if (hasStaleConfig) backupHints.push(`selva.config.js.${stamp}.bak`);
|
|
304
|
+
if (ecoHasStaleRuntime) backupHints.push(`ecosystem.config.cjs.${stamp}.bak`);
|
|
305
|
+
if (envRename.changes.length > 0) backupHints.push(`.env.${stamp}.bak`);
|
|
209
306
|
|
|
210
307
|
if (status === 0) {
|
|
211
308
|
p.outro(
|
|
@@ -242,12 +339,15 @@ function restartAfterRollback(dir, pm2 = runPm2) {
|
|
|
242
339
|
}
|
|
243
340
|
}
|
|
244
341
|
|
|
245
|
-
function buildTargetPackageJson(current) {
|
|
246
|
-
|
|
342
|
+
function buildTargetPackageJson(current, resolveVersion = npmDistTagVersion) {
|
|
343
|
+
const { pins, notes } = resolveSelvaPins(current.dependencies, resolveVersion);
|
|
344
|
+
const pkg = buildDeploymentPackageJson({
|
|
247
345
|
name: current.name ?? 'selva-deployment',
|
|
248
346
|
version: current.version ?? '0.1.0',
|
|
249
|
-
engines: current.engines
|
|
347
|
+
engines: current.engines,
|
|
348
|
+
dependencies: pins
|
|
250
349
|
});
|
|
350
|
+
return { pkg, notes };
|
|
251
351
|
}
|
|
252
352
|
|
|
253
353
|
// Format package.json diff for confirmation prompt (concise, not full diff).
|
|
@@ -331,6 +431,20 @@ export function detectDrift(pkgJson, dir) {
|
|
|
331
431
|
reasons.push(`pm2 is pinned to ${deps.pm2} (current scaffold pins ${DEPENDENCIES.pm2})`);
|
|
332
432
|
}
|
|
333
433
|
|
|
434
|
+
// A stored dist-tag re-resolves on every `npm install`, so the deployment
|
|
435
|
+
// follows the tag instead of a version an operator chose. Worse on a
|
|
436
|
+
// prerelease line, where `latest` points at the older stable release and the
|
|
437
|
+
// "upgrade" is a downgrade. Migrate wrote these before it learned to resolve
|
|
438
|
+
// tags, so existing deployments carry one and nothing else reports it.
|
|
439
|
+
for (const name of SELVA_PACKAGES) {
|
|
440
|
+
if (deps[name] && isFloatingPin(deps[name])) {
|
|
441
|
+
reasons.push(
|
|
442
|
+
`${name} is pinned to the floating tag ${JSON.stringify(deps[name])} ` +
|
|
443
|
+
`(re-resolves on every install; migrate writes a concrete version)`
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
334
448
|
// Overrides are security shims for vulnerable transitive deps — a
|
|
335
449
|
// deployment without them installs the vulnerable version on its next
|
|
336
450
|
// `npm install`, silently.
|
|
@@ -370,8 +484,14 @@ export function buildPm2UpgradeNotice(beforeVersion, afterVersion) {
|
|
|
370
484
|
`still the old version. Finish the upgrade (brief restart of managed processes):`,
|
|
371
485
|
``,
|
|
372
486
|
` npx pm2 update`,
|
|
487
|
+
` npx pm2 list # ${APP_NAME} must be listed and 'online' before the next step`,
|
|
373
488
|
` npx pm2 save`,
|
|
374
489
|
``,
|
|
490
|
+
`Do not run \`pm2 save\` until the list looks right. \`pm2 update\` empties the`,
|
|
491
|
+
`process table before restoring it, so saving a failed restore overwrites`,
|
|
492
|
+
`~/.pm2/dump.pm2 — the only record of what to bring back. If the list is empty,`,
|
|
493
|
+
`recover with \`npx pm2 resurrect\`, or \`npx pm2 start ecosystem.config.cjs\`.`,
|
|
494
|
+
``,
|
|
375
495
|
`If doctor reports a pm2 outside this deployment (global or apt install), follow`,
|
|
376
496
|
`the full procedure in the docs instead: self-hosting → deployment → prerequisites.`
|
|
377
497
|
].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
|
/**
|