@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selvajs/cli",
3
- "version": "4.8.0-beta.11",
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",
@@ -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.
@@ -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(`Node v${process.versions.node} at ${running} (${source}) — not resolvable on PATH`);
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('To scaffold a new deployment: ') + pc.cyan('npx @selvajs/cli <dir>')
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
  }
@@ -15,7 +15,11 @@ import {
15
15
  runtimeTemplatePath,
16
16
  scaffoldVersion
17
17
  } from '../paths.js';
18
- import { buildDeploymentPackageJson } from '../deployment-package.js';
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(buildDeploymentPackageJson({ name: deployName }), null, 2);
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;
@@ -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
- const resolved = {
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
  }
@@ -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
- writeEnvFile(envPath, readEnvTemplate(dir), values);
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
  [
@@ -13,8 +13,12 @@ import {
13
13
  buildDeploymentPackageJson,
14
14
  CANONICAL_FIELDS,
15
15
  DEPENDENCIES,
16
+ isFloatingPin,
16
17
  LEGACY_DEPENDENCIES,
17
- OVERRIDES
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
- const bakPath = pkgPath + '.bak';
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, configPath + '.bak');
218
+ copyFileSync(configPath, configBak);
122
219
  rmSync(configPath, { force: true });
123
220
  }
124
221
 
125
222
  if (ecoHasStaleRuntime) {
126
- copyFileSync(ecoPath, ecoPath + '.bak');
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, envPath + '.bak');
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(configPath + '.bak')) {
164
- copyFileSync(configPath + '.bak', configPath);
260
+ if (hasStaleConfig && existsSync(configBak)) {
261
+ copyFileSync(configBak, configPath);
165
262
  }
166
- if (ecoHasStaleRuntime && existsSync(ecoPath + '.bak')) {
167
- copyFileSync(ecoPath + '.bak', ecoPath);
263
+ if (ecoHasStaleRuntime && existsSync(ecoBak)) {
264
+ copyFileSync(ecoBak, ecoPath);
168
265
  }
169
- if (envRename.changes.length > 0 && existsSync(envPath + '.bak')) {
170
- copyFileSync(envPath + '.bak', envPath);
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 = ['package.json.bak'];
206
- if (hasStaleConfig) backupHints.push('selva.config.js.bak');
207
- if (ecoHasStaleRuntime) backupHints.push('ecosystem.config.cjs.bak');
208
- if (envRename.changes.length > 0) backupHints.push('.env.bak');
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
- return buildDeploymentPackageJson({
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
- export function writeEnvFile(path, template, values) {
92
- writeFileSync(path, mergeEnv(template, values), 'utf8');
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
  /**