@selvajs/cli 4.8.0-beta.9 → 4.8.0

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/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',
@@ -67,11 +127,19 @@ function printHelp() {
67
127
  ' stop pm2 stop selva-compute',
68
128
  ' restart pm2 restart selva-compute --update-env',
69
129
  ' logs pm2 logs selva-compute',
70
- ' update npm update @selvajs/selva + restart',
130
+ ' update npm update @selvajs/cli + @selvajs/selva, then restart',
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
  }
@@ -1,6 +1,5 @@
1
- // Scaffold a fresh deployment: write .env (merged from template + prompts),
2
- // ecosystem.config.cjs, package.json, and bootstrap node_modules. Runtime
3
- // templates are the source of truth; providers are env-driven.
1
+ // Scaffolds a fresh deployment: package.json, node_modules, .env (merged from
2
+ // template + prompts), and ecosystem.config.cjs.
4
3
 
5
4
  import { writeFileSync, existsSync, mkdirSync, readFileSync, cpSync } from 'node:fs';
6
5
  import { resolve, join, basename, dirname } from 'node:path';
@@ -16,18 +15,20 @@ import {
16
15
  runtimeTemplatePath,
17
16
  scaffoldVersion
18
17
  } from '../paths.js';
19
- import { buildDeploymentPackageJson } from '../deployment-package.js';
18
+ import {
19
+ buildDeploymentPackageJson,
20
+ npmDistTagVersion,
21
+ resolveSelvaPins
22
+ } from '../deployment-package.js';
20
23
  import { satisfiesNodeRange } from '../node-range.js';
21
24
 
22
25
  export async function runCreate(argv) {
23
26
  const { dir: rawDir, force, skipInstall, yes } = parseArgs(argv);
24
27
 
25
- // Scaffolding under too old a Node produces a deployment that installs
26
- // cleanly and passes its health check, then fails only under real traffic —
27
- // npm treats an engine mismatch as a warning unless engine-strict is set,
28
- // which no deployment sets (issue #176). Refuse at the one moment the
29
- // operator is still choosing their environment. The floor comes from this
30
- // CLI's own engines.node, so a bump can't leave a stale copy behind.
28
+ // Scaffolding under too old a Node installs and passes the health check,
29
+ // then fails only under real traffic — npm treats an engine mismatch as a
30
+ // warning, not an error, unless engine-strict is set, which no deployment
31
+ // sets. Refuse now, while the operator is still choosing their environment.
31
32
  const required = requiredNodeRange();
32
33
  if (required && satisfiesNodeRange(process.versions.node, required) === false) {
33
34
  console.error(
@@ -50,20 +51,29 @@ export async function runCreate(argv) {
50
51
  }
51
52
  mkdirSync(targetDir, { recursive: true });
52
53
 
53
- // --yes or CI=1 skips prompts (unattended bootstrap).
54
54
  const nonInteractive = yes || envBool(process.env.CI);
55
55
  const values = nonInteractive
56
56
  ? collectConfigFromEnv(process.env)
57
57
  : await collectConfig({ defaults: {}, mode: 'create' });
58
58
 
59
- // Generate fresh secrets (stable across restarts; init refuses to regen).
60
59
  values.SELVA_HMAC_KEY = generateKey();
61
60
  values.SELVA_AT_REST_KEY = generateKey();
62
61
 
63
62
  const deployName = basename(targetDir);
64
63
 
65
- // Write package.json, then install (need templates from @selvajs/selva).
66
- const pkgJson = JSON.stringify(buildDeploymentPackageJson({ name: deployName }), null, 2);
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
+
70
+ // package.json first: npm install needs it, and the runtime templates below
71
+ // only exist once @selvajs/selva is installed into node_modules.
72
+ const pkgJson = JSON.stringify(
73
+ buildDeploymentPackageJson({ name: deployName, dependencies: pins }),
74
+ null,
75
+ 2
76
+ );
67
77
  writeFileSync(join(targetDir, 'package.json'), pkgJson + '\n', 'utf8');
68
78
 
69
79
  if (!skipInstall) {
@@ -74,7 +84,6 @@ export async function runCreate(argv) {
74
84
  );
75
85
  }
76
86
 
77
- // Copy templates from installed runtime.
78
87
  const envTemplatePath = runtimeTemplatePath(targetDir, '.env.example');
79
88
  const ecosystemTemplatePath = runtimeTemplatePath(targetDir, 'ecosystem.config.cjs');
80
89
  if (skipInstall || !existsSync(envTemplatePath)) {
@@ -110,21 +119,17 @@ export async function runCreate(argv) {
110
119
  );
111
120
  }
112
121
 
113
- // Stream npm output with live progress; on failure, show tail for debugging.
114
- // Cache-bust hint: surface broken versions like @selvajs/selva@0.10.2.
115
122
  function runNpmInstall(cwd) {
116
123
  return new Promise((resolveP, rejectP) => {
117
124
  const s = p.spinner();
118
125
  s.start('Installing dependencies (this can take a minute)');
119
126
 
120
- // Keep last 80 lines for failure output.
121
127
  const tail = [];
122
128
  const remember = (line) => {
123
129
  tail.push(line);
124
130
  if (tail.length > 80) tail.shift();
125
131
  };
126
132
 
127
- // Extract progress milestones (reify: lines, package count).
128
133
  const updateProgress = (line) => {
129
134
  const trimmed = line.trim();
130
135
  if (!trimmed) return;
@@ -140,7 +145,7 @@ function runNpmInstall(cwd) {
140
145
  if (/^npm (WARN|error|notice)/.test(trimmed)) return;
141
146
  };
142
147
 
143
- // Spawn with --loglevel=info for reify: progress; pipe stdout/stderr for live updates.
148
+ // --loglevel=info is what makes npm print the reify: lines updateProgress matches on.
144
149
  const child = spawn('npm', ['install', '--loglevel=info'], {
145
150
  cwd,
146
151
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -184,8 +189,8 @@ function runNpmInstall(cwd) {
184
189
  }
185
190
  s.stop(pc.red(`npm install failed (exit ${code})`));
186
191
 
187
- // Show what npm actually said. Without this the operator has to
188
- // dig through ~/.npm/_logs/*-debug-0.log to find a single line.
192
+ // Without this the operator has to dig through ~/.npm/_logs/*-debug-0.log
193
+ // to find out what actually went wrong.
189
194
  console.error('');
190
195
  console.error(pc.dim('── last lines of npm output ──'));
191
196
  for (const line of tail) console.error(line);
@@ -210,11 +215,37 @@ function runNpmInstall(cwd) {
210
215
  });
211
216
  }
212
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
+
213
235
  function parseArgs(argv) {
214
236
  let dir;
215
237
  let force = false;
216
238
  let skipInstall = false;
217
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
+
218
249
  for (const arg of argv) {
219
250
  if (arg === '--force') force = true;
220
251
  else if (arg === '--skip-install') skipInstall = true;
@@ -2,12 +2,14 @@
2
2
  // Node engine, boot persistence. Yellow warnings don't fail, red failures exit 1.
3
3
  //
4
4
  // Read-only by default. `--fix` applies the repairs attached to individual
5
- // checks, each behind its own confirmation — and only those needing no root and
6
- // no runtime restart. See `applyFixes`.
5
+ // checks, each behind its own confirmation — only fixes needing no root and
6
+ // no runtime restart exist. See applyFixes.
7
7
 
8
8
  import {
9
9
  existsSync,
10
10
  readFileSync,
11
+ writeFileSync,
12
+ copyFileSync,
11
13
  readdirSync,
12
14
  accessSync,
13
15
  constants,
@@ -18,20 +20,23 @@ 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';
25
27
  import { checkBootPersistence } from '../checks/boot.js';
28
+ import { checkRuntimeEnvironment } from '../checks/runtime.js';
26
29
  import {
27
30
  checkDeprecatedEnv,
31
+ checkEnvDocumentation,
32
+ checkHeaderNames,
28
33
  checkOrigin,
29
34
  checkProviders,
30
35
  checkSecret,
31
36
  checkTenancy,
32
37
  resolveProviders
33
38
  } from '../checks/config.js';
34
- import { detectDrift, NODE_MODULES_STASH } from './migrate.js';
39
+ import { BACKUP_AGE_DAYS, classifyBackups, detectDrift, NODE_MODULES_STASH } from './migrate.js';
35
40
 
36
41
  export async function runDoctor(argv = []) {
37
42
  const fix = argv.includes('--fix');
@@ -47,6 +52,7 @@ export async function runDoctor(argv = []) {
47
52
  checks.push(checkFile(join(dir, 'ecosystem.config.cjs'), 'ecosystem.config.cjs present'));
48
53
  checks.push(checkLayoutDrift(dir));
49
54
  checks.push(checkMigrationLeftovers(dir));
55
+ checks.push(checkAgedBackups(dir));
50
56
  checks.push(checkSecret(env.SELVA_HMAC_KEY, 'SELVA_HMAC_KEY is a 32-byte hex string'));
51
57
  checks.push(checkSecret(env.SELVA_AT_REST_KEY, 'SELVA_AT_REST_KEY is a 32-byte hex string'));
52
58
 
@@ -73,9 +79,11 @@ export async function runDoctor(argv = []) {
73
79
  checks.push(checkPackage(dir, '@selvajs/selva'));
74
80
  checks.push(checkNodeEngine(dir));
75
81
  checks.push(checkCliRuntimeAlignment(dir));
82
+ checks.push(...checkRuntimeEnvironment(dir));
76
83
  checks.push(...checkBootPersistence(dir));
77
84
  checks.push(checkOrigin(env));
78
85
  checks.push(...checkDeprecatedEnv(env));
86
+ checks.push(checkEnvDocs(dir));
79
87
 
80
88
  // ── Render ─────────────────────────────────────────────────────────
81
89
  const resolved = await Promise.all(checks);
@@ -92,7 +100,7 @@ export async function runDoctor(argv = []) {
92
100
  if (repairable > 0) {
93
101
  p.log.info(
94
102
  `${repairable} issue${repairable === 1 ? '' : 's'} can be repaired automatically — ` +
95
- `re-run with \`selva doctor --fix\`.`
103
+ `re-run with \`npx selva doctor --fix\`.`
96
104
  );
97
105
  }
98
106
  }
@@ -105,15 +113,9 @@ export async function runDoctor(argv = []) {
105
113
  }
106
114
  }
107
115
 
108
- /**
109
- * Apply the repairs attached to failing checks, each behind its own
110
- * confirmation. Returns the updated failure count.
111
- *
112
- * Nothing here needs root: anything privileged (the systemd unit) or that would
113
- * restart this process (a Node upgrade) stays a printed instruction, because a
114
- * fixer that dies halfway through its own runtime leaves the operator with no
115
- * obvious path back.
116
- */
116
+ // Anything privileged (the systemd unit) or that would restart this process
117
+ // (a Node upgrade) stays a printed instruction instead of a fixer — one that
118
+ // dies halfway through its own runtime leaves the operator with no way back.
117
119
  async function applyFixes(resolved, failures) {
118
120
  const repairs = resolved.filter((c) => c.fix && c.severity !== 'green');
119
121
  if (repairs.length === 0) {
@@ -183,8 +185,7 @@ async function checkSupabase(env) {
183
185
  return red(`SUPABASE_URL="${env.SUPABASE_URL}" is not a valid URL`);
184
186
  }
185
187
 
186
- // Ping the Supabase health endpoint. Soft-fail to yellow on network
187
- // errors — operators may be offline at install time.
188
+ // Network errors go yellow, not red — operators may be offline at install time.
188
189
  try {
189
190
  const controller = new AbortController();
190
191
  const timer = setTimeout(() => controller.abort(), 4000);
@@ -199,11 +200,10 @@ async function checkSupabase(env) {
199
200
  }
200
201
  }
201
202
 
202
- // App↔DB schema handshake (audit O3). Expected head = newest migration shipped
203
- // by the installed @selvajs/supabase-provider; actual = what the database
204
- // reports via the selva.migration_head() RPC. Red on skew — the app degrades
205
- // /api/health to 503 at boot in the same state, so catching it here saves a
206
- // confusing deploy.
203
+ // Compares the newest migration shipped by @selvajs/supabase-provider against
204
+ // what the database reports via selva.migration_head(). Red on skew — the app
205
+ // degrades /api/health to 503 at boot in the same state, so catching it here
206
+ // saves a confusing deploy.
207
207
  async function checkSupabaseMigrations(dir, env) {
208
208
  if (!env.SUPABASE_URL || !env.SUPABASE_SERVICE_ROLE_KEY) {
209
209
  return yellow('Migration-head check skipped — Supabase env incomplete');
@@ -264,6 +264,7 @@ async function checkSupabaseMigrations(dir, env) {
264
264
  `Database reports no applied migrations (expected head ${expected}). Sync + run: npx supabase db push`
265
265
  );
266
266
  }
267
+ // String comparison — relies on both heads being fixed-width, zero-padded timestamps.
267
268
  if (actual < expected) {
268
269
  return red(
269
270
  `Database migration head ${actual} is behind the installed provider (${expected}). Sync + run: npx supabase db push`
@@ -271,7 +272,6 @@ async function checkSupabaseMigrations(dir, env) {
271
272
  }
272
273
  return green(`Database migration head ${actual} matches the installed provider`);
273
274
  } catch (err) {
274
- // Soft-fail on network errors, same as checkSupabase.
275
275
  return yellow(`Migration-head check skipped (${err.message ?? err})`);
276
276
  }
277
277
  }
@@ -295,7 +295,7 @@ function majorOf(version) {
295
295
  return m ? Number(m[1]) : null;
296
296
  }
297
297
 
298
- // CLI and runtime release together (fixed group); major skew = stale CLI pin.
298
+ // CLI and runtime release as a fixed group; major-version skew means a stale CLI pin.
299
299
  function checkCliRuntimeAlignment(dir) {
300
300
  const here = dirname(fileURLToPath(import.meta.url));
301
301
  const cliVersion = readPackageVersion(join(here, '..', '..', 'package.json'));
@@ -303,7 +303,7 @@ function checkCliRuntimeAlignment(dir) {
303
303
  join(dir, 'node_modules', '@selvajs', 'selva', 'package.json')
304
304
  );
305
305
 
306
- // Runtime missing is already reported red by checkPackage; stay quiet here.
306
+ // checkPackage already reports missing runtime as red; stay quiet here.
307
307
  if (!runtimeVersion) return yellow('CLI/runtime version check skipped (runtime not installed)');
308
308
  if (!cliVersion) return yellow('CLI/runtime version check skipped (could not read CLI version)');
309
309
 
@@ -324,16 +324,10 @@ function checkCliRuntimeAlignment(dir) {
324
324
  );
325
325
  }
326
326
 
327
- // Verify VM reboot will resurrect app: dump.pm2 saved, systemd unit installed, no stray global pm2.
328
- /**
329
- * Does this host's Node satisfy the installed runtime's `engines.node`?
330
- *
331
- * npm only enforces engines under `engine-strict=true`, which no deployment
332
- * sets, so a mismatched install succeeds silently and `/api/health` still
333
- * returns 200 — routes using newer APIs throw only under real traffic
334
- * (issue #176). Deliberately NOT auto-fixable: upgrading Node is
335
- * distro-specific and would restart the process running this check.
336
- */
327
+ // npm only enforces engines.node under engine-strict=true, which no deployment
328
+ // sets, so a mismatched install succeeds silently and /api/health still returns
329
+ // 200 — routes using newer APIs throw only under real traffic. Not auto-fixable:
330
+ // upgrading Node is distro-specific and would restart the process running this check.
337
331
  function checkNodeEngine(dir) {
338
332
  let required;
339
333
  try {
@@ -363,12 +357,10 @@ function checkNodeEngine(dir) {
363
357
  );
364
358
  }
365
359
 
366
- /**
367
- * `selva migrate` parks the old node_modules aside so a failed install can be
368
- * rolled back, and removes it on either outcome. One surviving here means the
369
- * migration was killed mid-flight — worth saying, because it is a full copy of
370
- * the dependency tree and the deployment may be running on the wrong one.
371
- */
360
+ // `selva migrate` parks the old node_modules aside so a failed install can be
361
+ // rolled back, and removes it on either outcome. One surviving here means the
362
+ // migration was killed mid-flight — worth flagging since it's a full copy of
363
+ // the dependency tree and the deployment may be running on the wrong one.
372
364
  function checkMigrationLeftovers(dir) {
373
365
  const stash = join(dir, NODE_MODULES_STASH);
374
366
  if (!existsSync(stash)) return green('no interrupted migration left behind');
@@ -386,6 +378,94 @@ function checkMigrationLeftovers(dir) {
386
378
  );
387
379
  }
388
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
+
389
469
  function checkLayoutDrift(dir) {
390
470
  const pkgPath = join(dir, 'package.json');
391
471
  if (!existsSync(pkgPath)) return yellow('package.json missing — cannot check layout');
@@ -403,19 +483,10 @@ function checkLayoutDrift(dir) {
403
483
  );
404
484
  }
405
485
 
406
- // Must match HeaderAuthProvider.DEFAULT_HEADERS (duplicated to avoid loading runtime).
407
- // Smoke test in providers/header-auth pins them; divergence surfaces in CI.
408
- const DEFAULT_HEADER_NAMES = {
409
- upn: 'SELVA-UserPrincipalName',
410
- email: 'SELVA-Email',
411
- displayName: 'SELVA-DisplayName'
412
- };
413
-
414
- // Header-auth checks (read-only; runtime invariants like spoofing can't be verified).
486
+ // Static config only — runtime invariants like spoofing protection can't be checked from here.
415
487
  function checkHeaderAuth(dir, env, dataProvider) {
416
488
  const out = [];
417
489
 
418
- // Allowlist file location.
419
490
  const allowlistDir = env.HEADER_AUTH_DATA_DIR ?? env.DATA_PATH;
420
491
  if (!allowlistDir) {
421
492
  out.push(red('HEADER_AUTH_DATA_DIR (or DATA_PATH) unset — provider will fail to start'));
@@ -425,21 +496,21 @@ function checkHeaderAuth(dir, env, dataProvider) {
425
496
  if (existsSync(allowlistPath)) {
426
497
  out.push(green(`header-allowlist.json present (${allowlistDir}/header-allowlist.json)`));
427
498
  } else {
428
- // Provider creates lazily, but missing file locks everyone out.
499
+ // The provider creates this file lazily, but a missing file locks everyone out
500
+ // until one exists — worth a warning even though nothing is technically broken.
429
501
  out.push(
430
502
  yellow(
431
503
  `header-allowlist.json not found at ${allowlistDir}/ — no users will be allowed in until one is added`
432
504
  )
433
505
  );
434
506
 
435
- // Check writability only if explicitly set (DATA_PATH checked separately).
507
+ // DATA_PATH's own writability is checked separately (checkDataPath).
436
508
  if (env.HEADER_AUTH_DATA_DIR) {
437
509
  out.push(checkDirWritable(allowlistAbsDir, `HEADER_AUTH_DATA_DIR=${allowlistDir}`));
438
510
  }
439
511
  }
440
512
  }
441
513
 
442
- // HOST binding (loopback recommended for header-auth).
443
514
  const host = env.HOST ?? '0.0.0.0';
444
515
  if (host === '127.0.0.1' || host === 'localhost') {
445
516
  out.push(green(`HOST=${host} (loopback-only)`));
@@ -452,12 +523,11 @@ function checkHeaderAuth(dir, env, dataProvider) {
452
523
  );
453
524
  }
454
525
 
455
- // 3. ORIGIN — header-auth implies a reverse proxy, so ORIGIN is required.
526
+ // header-auth always sits behind a reverse proxy, so ORIGIN is required.
456
527
  if (!env.ORIGIN) {
457
528
  out.push(red('ORIGIN unset — required for header-auth (always behind a proxy)'));
458
529
  }
459
530
 
460
- // Non-local data provider: HEADER_AUTH_DATA_DIR must be explicit (no DATA_PATH fallback).
461
531
  if (dataProvider !== 'local' && !env.HEADER_AUTH_DATA_DIR) {
462
532
  out.push(
463
533
  red(
@@ -467,10 +537,10 @@ function checkHeaderAuth(dir, env, dataProvider) {
467
537
  );
468
538
  }
469
539
 
470
- // 5. Bootstrap admin email. Without it, the first proxy-authenticated
471
- // visitor's UPN is rejected (not in allowlist) and the operator has to
472
- // hand-write JSON to claim admin. With it, the first matching visit is
473
- // auto-allowlisted and granted instance_admin in one step.
540
+ // Without this, the first proxy-authenticated visitor's UPN is rejected
541
+ // (not in the allowlist) and the operator has to hand-write JSON to claim
542
+ // admin. With it, the first matching visit auto-allowlists and grants
543
+ // instance_admin in one step.
474
544
  if (!env.BOOTSTRAP_INSTANCE_ADMIN_EMAIL) {
475
545
  out.push(
476
546
  red(
@@ -482,30 +552,7 @@ function checkHeaderAuth(dir, env, dataProvider) {
482
552
  out.push(green(`BOOTSTRAP_INSTANCE_ADMIN_EMAIL=${env.BOOTSTRAP_INSTANCE_ADMIN_EMAIL}`));
483
553
  }
484
554
 
485
- // Resolved header names (print for diffing against proxy config; partial override = typo risk).
486
- const resolved = {
487
- upn: env.HEADER_AUTH_UPN_HEADER || DEFAULT_HEADER_NAMES.upn,
488
- email: env.HEADER_AUTH_EMAIL_HEADER || DEFAULT_HEADER_NAMES.email,
489
- displayName: env.HEADER_AUTH_DISPLAY_NAME_HEADER || DEFAULT_HEADER_NAMES.displayName
490
- };
491
- const overrides = [
492
- Boolean(env.HEADER_AUTH_UPN_HEADER),
493
- Boolean(env.HEADER_AUTH_EMAIL_HEADER),
494
- Boolean(env.HEADER_AUTH_DISPLAY_NAME_HEADER)
495
- ].filter(Boolean).length;
496
- const headerList = `UPN=${resolved.upn}, Email=${resolved.email}, DisplayName=${resolved.displayName}`;
497
- if (overrides === 0) {
498
- out.push(green(`header names (bundled defaults): ${headerList}`));
499
- } else if (overrides === 3) {
500
- out.push(green(`header names (all overridden): ${headerList}`));
501
- } else {
502
- out.push(
503
- yellow(
504
- `header names partially overridden (${overrides}/3 set): ${headerList} — ` +
505
- `a partial override is usually a typo. Set all three or none.`
506
- )
507
- );
508
- }
555
+ out.push(checkHeaderNames(env));
509
556
 
510
557
  return out;
511
558
  }