@selvajs/cli 4.8.0-beta.8 → 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.
@@ -2,22 +2,41 @@
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`.
7
-
8
- import { existsSync, readFileSync, readdirSync, accessSync, constants, statSync } from 'node:fs';
9
- import { spawnSync } from 'node:child_process';
10
- import { join, resolve, dirname, delimiter } from 'node:path';
5
+ // checks, each behind its own confirmation — only fixes needing no root and
6
+ // no runtime restart exist. See applyFixes.
7
+
8
+ import {
9
+ existsSync,
10
+ readFileSync,
11
+ writeFileSync,
12
+ copyFileSync,
13
+ readdirSync,
14
+ accessSync,
15
+ constants,
16
+ statSync,
17
+ rmSync
18
+ } from 'node:fs';
19
+ import { join, resolve, dirname } from 'node:path';
11
20
  import { fileURLToPath } from 'node:url';
12
- import { homedir } from 'node:os';
13
21
  import * as p from '@clack/prompts';
14
22
  import pc from 'picocolors';
15
- import { readEnvFile, RENAMED_ENV_VARS, REPLACED_ENV_VARS } from '../env.js';
23
+ import { readEnvFile, parseEnv, countEnvCommentLines, stripEnvComments } from '../env.js';
16
24
  import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
17
- import { detectDrift } from './migrate.js';
18
-
19
- const HEX_64 = /^[0-9a-f]{64}$/i;
20
- const PLACEHOLDER = 'replace-this-with-a-random-32-byte-hex-key';
25
+ import { satisfiesNodeRange } from '../node-range.js';
26
+ import { green, yellow, red, fixable } from '../checks/result.js';
27
+ import { checkBootPersistence } from '../checks/boot.js';
28
+ import { checkRuntimeEnvironment } from '../checks/runtime.js';
29
+ import {
30
+ checkDeprecatedEnv,
31
+ checkEnvDocumentation,
32
+ checkHeaderNames,
33
+ checkOrigin,
34
+ checkProviders,
35
+ checkSecret,
36
+ checkTenancy,
37
+ resolveProviders
38
+ } from '../checks/config.js';
39
+ import { BACKUP_AGE_DAYS, classifyBackups, detectDrift, NODE_MODULES_STASH } from './migrate.js';
21
40
 
22
41
  export async function runDoctor(argv = []) {
23
42
  const fix = argv.includes('--fix');
@@ -32,28 +51,13 @@ export async function runDoctor(argv = []) {
32
51
  checks.push(checkFile(join(dir, '.env'), '.env present'));
33
52
  checks.push(checkFile(join(dir, 'ecosystem.config.cjs'), 'ecosystem.config.cjs present'));
34
53
  checks.push(checkLayoutDrift(dir));
54
+ checks.push(checkMigrationLeftovers(dir));
55
+ checks.push(checkAgedBackups(dir));
35
56
  checks.push(checkSecret(env.SELVA_HMAC_KEY, 'SELVA_HMAC_KEY is a 32-byte hex string'));
36
57
  checks.push(checkSecret(env.SELVA_AT_REST_KEY, 'SELVA_AT_REST_KEY is a 32-byte hex string'));
37
58
 
38
- // Header-auth: auth only; data/storage must be local or supabase.
39
- const providers = {
40
- auth: (env.SELVA_AUTH_PROVIDER ?? 'local').toLowerCase(),
41
- data: (env.SELVA_DATA_PROVIDER ?? 'local').toLowerCase(),
42
- storage: (env.SELVA_STORAGE_PROVIDER ?? 'local').toLowerCase()
43
- };
44
-
45
- const validForAuth = new Set(['local', 'supabase', 'header']);
46
- const validForData = new Set(['local', 'supabase']);
47
-
48
- if (!validForAuth.has(providers.auth)) {
49
- checks.push(red(`SELVA_AUTH_PROVIDER="${providers.auth}" — expected local|supabase|header`));
50
- }
51
- if (!validForData.has(providers.data)) {
52
- checks.push(red(`SELVA_DATA_PROVIDER="${providers.data}" — expected local|supabase`));
53
- }
54
- if (!validForData.has(providers.storage)) {
55
- checks.push(red(`SELVA_STORAGE_PROVIDER="${providers.storage}" — expected local|supabase`));
56
- }
59
+ const providers = resolveProviders(env);
60
+ checks.push(...checkProviders(providers));
57
61
 
58
62
  const used = new Set(Object.values(providers));
59
63
 
@@ -70,42 +74,16 @@ export async function runDoctor(argv = []) {
70
74
  checks.push(...checkHeaderAuth(dir, env, providers.data));
71
75
  }
72
76
 
73
- const tenancy = (env.SELVA_TENANCY ?? 'single').toLowerCase();
74
- if (tenancy !== 'single' && tenancy !== 'multi') {
75
- checks.push(red(`SELVA_TENANCY="${tenancy}" — expected single|multi`));
76
- } else {
77
- checks.push(green(`SELVA_TENANCY=${tenancy}`));
78
- }
77
+ checks.push(checkTenancy(env));
79
78
 
80
79
  checks.push(checkPackage(dir, '@selvajs/selva'));
81
80
  checks.push(checkNodeEngine(dir));
82
81
  checks.push(checkCliRuntimeAlignment(dir));
82
+ checks.push(...checkRuntimeEnvironment(dir));
83
83
  checks.push(...checkBootPersistence(dir));
84
- if (env.ORIGIN) {
85
- try {
86
- new URL(env.ORIGIN);
87
- checks.push(green(`ORIGIN=${env.ORIGIN}`));
88
- } catch {
89
- checks.push(red(`ORIGIN="${env.ORIGIN}" is not a valid URL`));
90
- }
91
- } else {
92
- checks.push(yellow('ORIGIN unset — required behind a reverse proxy'));
93
- }
94
-
95
- for (const [oldName, newName] of Object.entries(RENAMED_ENV_VARS)) {
96
- if (env[oldName] === undefined) continue;
97
- checks.push(
98
- env[newName] === undefined
99
- ? yellow(`${oldName} is deprecated — \`selva migrate\` renames it to ${newName}`)
100
- : yellow(`${oldName} is deprecated and ignored — ${newName} is set and wins`)
101
- );
102
- }
103
-
104
- // Not auto-fixable: the replacement encodes a value, so migrate won't guess.
105
- for (const [oldName, replacement] of Object.entries(REPLACED_ENV_VARS)) {
106
- if (env[oldName] === undefined) continue;
107
- checks.push(yellow(`${oldName} is deprecated — replace it with ${replacement}`));
108
- }
84
+ checks.push(checkOrigin(env));
85
+ checks.push(...checkDeprecatedEnv(env));
86
+ checks.push(checkEnvDocs(dir));
109
87
 
110
88
  // ── Render ─────────────────────────────────────────────────────────
111
89
  const resolved = await Promise.all(checks);
@@ -122,7 +100,7 @@ export async function runDoctor(argv = []) {
122
100
  if (repairable > 0) {
123
101
  p.log.info(
124
102
  `${repairable} issue${repairable === 1 ? '' : 's'} can be repaired automatically — ` +
125
- `re-run with \`selva doctor --fix\`.`
103
+ `re-run with \`npx selva doctor --fix\`.`
126
104
  );
127
105
  }
128
106
  }
@@ -135,15 +113,9 @@ export async function runDoctor(argv = []) {
135
113
  }
136
114
  }
137
115
 
138
- /**
139
- * Apply the repairs attached to failing checks, each behind its own
140
- * confirmation. Returns the updated failure count.
141
- *
142
- * Nothing here needs root: anything privileged (the systemd unit) or that would
143
- * restart this process (a Node upgrade) stays a printed instruction, because a
144
- * fixer that dies halfway through its own runtime leaves the operator with no
145
- * obvious path back.
146
- */
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.
147
119
  async function applyFixes(resolved, failures) {
148
120
  const repairs = resolved.filter((c) => c.fix && c.severity !== 'green');
149
121
  if (repairs.length === 0) {
@@ -175,39 +147,10 @@ async function applyFixes(resolved, failures) {
175
147
  return failures;
176
148
  }
177
149
 
178
- function green(text) {
179
- return { severity: 'green', line: `${pc.green('✓')} ${text}` };
180
- }
181
- function yellow(text, fix) {
182
- return { severity: 'yellow', line: `${pc.yellow('!')} ${text}`, fix };
183
- }
184
- function red(text, fix) {
185
- return { severity: 'red', line: `${pc.red('✗')} ${text}`, fix };
186
- }
187
-
188
- /**
189
- * A repair `--fix` may run. `label` is what the operator is asked to approve;
190
- * `run()` performs it and returns a result line.
191
- *
192
- * Only attach one where the repair is unambiguous and reversible-ish. Anything
193
- * needing root, or that restarts the runtime running this process, stays a
194
- * printed instruction — a half-applied privileged fix is worse than none.
195
- */
196
- function fixable(label, run) {
197
- return { label, run };
198
- }
199
-
200
150
  function checkFile(path, label) {
201
151
  return existsSync(path) ? green(label) : red(`${label} (missing: ${path})`);
202
152
  }
203
153
 
204
- function checkSecret(value, label) {
205
- if (!value) return red(`${label} — unset`);
206
- if (value === PLACEHOLDER) return red(`${label} — still the placeholder`);
207
- if (!HEX_64.test(value)) return red(`${label} — not 64 hex chars`);
208
- return green(label);
209
- }
210
-
211
154
  function checkDataPath(dir, dataPath) {
212
155
  const absolute = resolve(dir, dataPath);
213
156
  try {
@@ -242,8 +185,7 @@ async function checkSupabase(env) {
242
185
  return red(`SUPABASE_URL="${env.SUPABASE_URL}" is not a valid URL`);
243
186
  }
244
187
 
245
- // Ping the Supabase health endpoint. Soft-fail to yellow on network
246
- // errors — operators may be offline at install time.
188
+ // Network errors go yellow, not red — operators may be offline at install time.
247
189
  try {
248
190
  const controller = new AbortController();
249
191
  const timer = setTimeout(() => controller.abort(), 4000);
@@ -258,11 +200,10 @@ async function checkSupabase(env) {
258
200
  }
259
201
  }
260
202
 
261
- // App↔DB schema handshake (audit O3). Expected head = newest migration shipped
262
- // by the installed @selvajs/supabase-provider; actual = what the database
263
- // reports via the selva.migration_head() RPC. Red on skew — the app degrades
264
- // /api/health to 503 at boot in the same state, so catching it here saves a
265
- // 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.
266
207
  async function checkSupabaseMigrations(dir, env) {
267
208
  if (!env.SUPABASE_URL || !env.SUPABASE_SERVICE_ROLE_KEY) {
268
209
  return yellow('Migration-head check skipped — Supabase env incomplete');
@@ -323,6 +264,7 @@ async function checkSupabaseMigrations(dir, env) {
323
264
  `Database reports no applied migrations (expected head ${expected}). Sync + run: npx supabase db push`
324
265
  );
325
266
  }
267
+ // String comparison — relies on both heads being fixed-width, zero-padded timestamps.
326
268
  if (actual < expected) {
327
269
  return red(
328
270
  `Database migration head ${actual} is behind the installed provider (${expected}). Sync + run: npx supabase db push`
@@ -330,7 +272,6 @@ async function checkSupabaseMigrations(dir, env) {
330
272
  }
331
273
  return green(`Database migration head ${actual} matches the installed provider`);
332
274
  } catch (err) {
333
- // Soft-fail on network errors, same as checkSupabase.
334
275
  return yellow(`Migration-head check skipped (${err.message ?? err})`);
335
276
  }
336
277
  }
@@ -354,7 +295,7 @@ function majorOf(version) {
354
295
  return m ? Number(m[1]) : null;
355
296
  }
356
297
 
357
- // 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.
358
299
  function checkCliRuntimeAlignment(dir) {
359
300
  const here = dirname(fileURLToPath(import.meta.url));
360
301
  const cliVersion = readPackageVersion(join(here, '..', '..', 'package.json'));
@@ -362,7 +303,7 @@ function checkCliRuntimeAlignment(dir) {
362
303
  join(dir, 'node_modules', '@selvajs', 'selva', 'package.json')
363
304
  );
364
305
 
365
- // Runtime missing is already reported red by checkPackage; stay quiet here.
306
+ // checkPackage already reports missing runtime as red; stay quiet here.
366
307
  if (!runtimeVersion) return yellow('CLI/runtime version check skipped (runtime not installed)');
367
308
  if (!cliVersion) return yellow('CLI/runtime version check skipped (could not read CLI version)');
368
309
 
@@ -383,16 +324,10 @@ function checkCliRuntimeAlignment(dir) {
383
324
  );
384
325
  }
385
326
 
386
- // Verify VM reboot will resurrect app: dump.pm2 saved, systemd unit installed, no stray global pm2.
387
- /**
388
- * Does this host's Node satisfy the installed runtime's `engines.node`?
389
- *
390
- * npm only enforces engines under `engine-strict=true`, which no deployment
391
- * sets, so a mismatched install succeeds silently and `/api/health` still
392
- * returns 200 — routes using newer APIs throw only under real traffic
393
- * (issue #176). Deliberately NOT auto-fixable: upgrading Node is
394
- * distro-specific and would restart the process running this check.
395
- */
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.
396
331
  function checkNodeEngine(dir) {
397
332
  let required;
398
333
  try {
@@ -422,182 +357,111 @@ function checkNodeEngine(dir) {
422
357
  );
423
358
  }
424
359
 
425
- /**
426
- * Narrow `engines.node` range check — `>=X`, `^`, `~`, `||`, and bare/x-ranges.
427
- * Returns null when unparseable so a misread never reports a false failure.
428
- *
429
- * Mirrors `satisfiesRange` in @selvajs/server/ops. Duplicated because the CLI is
430
- * dependency-free by design (it scaffolds the deployment that installs the
431
- * runtime, so it cannot import from it).
432
- */
433
- export function satisfiesNodeRange(version, range) {
434
- const core = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(version).trim());
435
- if (!core) return null;
436
- const v = [Number(core[1]), Number(core[2]), Number(core[3])];
437
-
438
- let anyParsed = false;
439
- for (const alt of String(range).split('||')) {
440
- const clauses = alt.trim().split(/\s+/).filter(Boolean);
441
- if (!clauses.length) continue;
442
-
443
- let all = true;
444
- let parsed = true;
445
- for (const clause of clauses) {
446
- const m = /^(>=|<=|>|<|\^|~|=)?\s*v?(\d+)(?:\.(\d+|x|\*))?(?:\.(\d+|x|\*))?/.exec(clause);
447
- if (!m) {
448
- parsed = false;
449
- break;
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.
364
+ function checkMigrationLeftovers(dir) {
365
+ const stash = join(dir, NODE_MODULES_STASH);
366
+ if (!existsSync(stash)) return green('no interrupted migration left behind');
367
+ return yellow(
368
+ `${NODE_MODULES_STASH} exists — a \`selva migrate\` was interrupted. If the app ` +
369
+ `is healthy, delete it: rm -rf ${stash}`,
370
+ fixable(`delete the leftover ${NODE_MODULES_STASH}`, () => {
371
+ try {
372
+ rmSync(stash, { recursive: true, force: true });
373
+ return green(`removed ${NODE_MODULES_STASH}`);
374
+ } catch (err) {
375
+ return red(`could not remove ${stash}: ${err instanceof Error ? err.message : err}`);
450
376
  }
451
- const op = m[1] ?? '=';
452
- const num = (s) => (s == null || s === 'x' || s === '*' ? 0 : Number(s));
453
- const t = [Number(m[2]), num(m[3]), num(m[4])];
454
- let cmp = 0;
455
- for (let i = 0; i < 3; i++) {
456
- if (v[i] > t[i]) {
457
- cmp = 1;
458
- break;
459
- }
460
- if (v[i] < t[i]) {
461
- cmp = -1;
462
- break;
377
+ })
378
+ );
379
+ }
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})`);
463
409
  }
464
410
  }
465
- let ok;
466
- if (op === '>=') ok = cmp >= 0;
467
- else if (op === '>') ok = cmp > 0;
468
- else if (op === '<=') ok = cmp <= 0;
469
- else if (op === '<') ok = cmp < 0;
470
- else if (op === '^') ok = cmp >= 0 && v[0] === t[0];
471
- else if (op === '~') ok = cmp >= 0 && v[0] === t[0] && v[1] === t[1];
472
- else
473
- ok =
474
- v[0] === t[0] &&
475
- (m[3] == null || m[3] === 'x' || m[3] === '*' || v[1] === t[1]) &&
476
- (m[4] == null || m[4] === 'x' || m[4] === '*' || v[2] === t[2]);
477
- if (!ok) {
478
- all = false;
479
- break;
480
- }
481
- }
482
- if (!parsed) continue;
483
- anyParsed = true;
484
- if (all) return true;
485
- }
486
- return anyParsed ? false : null;
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
+ );
487
415
  }
488
416
 
489
- function checkBootPersistence(dir) {
490
- // pm2's boot integration is Linux/systemd-specific. On macOS it's launchd
491
- // (different unit path) and on Windows pm2 boot persistence isn't a thing —
492
- // stay silent rather than emit misleading checks.
493
- if (process.platform !== 'linux') return [];
494
-
495
- const out = [];
496
-
497
- // dump.pm2 (what pm2 startup resurrects).
498
- const pm2Home = process.env.PM2_HOME ?? join(homedir(), '.pm2');
499
- const dumpPath = join(pm2Home, 'dump.pm2');
500
- if (existsSync(dumpPath)) {
501
- out.push(green('pm2 process list saved (dump.pm2 present)'));
502
- } else {
503
- out.push(
504
- yellow(
505
- 'pm2 process list not saved — run `npx pm2 save` so a reboot can ' +
506
- 'resurrect the app (nothing to restore without it)',
507
- fixable('run `pm2 save` to persist the current process list', () => {
508
- const bin = join(dir, 'node_modules', '.bin', 'pm2');
509
- if (!existsSync(bin)) return red('pm2 not installed in this deployment');
510
- const r = spawnSync(bin, ['save'], { cwd: dir, encoding: 'utf8' });
511
- return (r.status ?? 1) === 0
512
- ? green('pm2 process list saved')
513
- : red(`pm2 save failed: ${(r.stderr || r.stdout || '').trim()}`);
514
- })
515
- )
516
- );
517
- }
518
-
519
- // systemd unit: present and pointing at deployment-local pm2.
520
- const user = process.env.USER ?? process.env.LOGNAME;
521
- const unitPath = user ? `/etc/systemd/system/pm2-${user}.service` : null;
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)');
522
423
 
523
- if (unitPath && existsSync(unitPath)) {
524
- const localPm2 = join(dir, 'node_modules', 'pm2', 'bin', 'pm2');
525
- let unit = '';
526
- try {
527
- unit = readFileSync(unitPath, 'utf8');
528
- } catch {
529
- out.push(yellow(`pm2 systemd unit present but unreadable (${unitPath})`));
530
- return out;
531
- }
532
- const execStart = /^ExecStart=(.+)$/m.exec(unit)?.[1] ?? '';
533
- if (execStart.includes(localPm2)) {
534
- out.push(green('pm2 systemd boot unit installed (uses deployment-local pm2)'));
535
- } else {
536
- out.push(
537
- red(
538
- `pm2 systemd boot unit points at a different pm2 than this deployment's.\n ` +
539
- `ExecStart: ${execStart || '(not found)'}\n ` +
540
- `expected: ${localPm2} resurrect\n ` +
541
- `Reboots will resurrect via the wrong pm2 (version skew). Re-run startup ` +
542
- `with the local binary:\n ` +
543
- `sudo env PATH=$PATH:${join(dir, 'node_modules', '.bin')} ${localPm2} ` +
544
- `startup systemd -u $USER --hp $HOME`
545
- )
546
- );
547
- }
548
- } else {
549
- out.push(
550
- yellow(
551
- 'pm2 systemd boot unit not installed — the app will NOT restart after a ' +
552
- 'reboot. Run `npx pm2 startup systemd -u $USER --hp $HOME` and paste the ' +
553
- 'printed command (point it at this deployment’s pm2).'
554
- )
555
- );
556
- }
557
-
558
- // Warn if global pm2 is on PATH (root cause of version skew).
559
- const globalPm2 = findGlobalPm2(dir);
560
- if (globalPm2) {
561
- // Only offer the removal when we could actually perform it. A global
562
- // install under /usr is root-owned; attempting it would half-fail and
563
- // leave the operator worse informed than a printed instruction.
564
- let writable = false;
565
- try {
566
- accessSync(dirname(globalPm2), constants.W_OK);
567
- writable = true;
568
- } catch {
569
- writable = false;
570
- }
571
-
572
- out.push(
573
- yellow(
574
- `a pm2 outside this deployment is on PATH (${globalPm2}) — it can fork a ` +
575
- `mismatched daemon and trigger skew. Prefer \`npm run\` wrappers / \`npx pm2\` ` +
576
- `from this directory; consider \`npm uninstall -g pm2\`.` +
577
- (writable ? '' : `\n (${dirname(globalPm2)} is not writable — needs sudo)`),
578
- writable
579
- ? fixable(`uninstall the global pm2 at ${globalPm2}`, () => {
580
- const r = spawnSync('npm', ['uninstall', '-g', 'pm2'], { encoding: 'utf8' });
581
- return (r.status ?? 1) === 0
582
- ? green('global pm2 uninstalled')
583
- : red(`npm uninstall -g pm2 failed: ${(r.stderr || r.stdout || '').trim()}`);
584
- })
585
- : undefined
586
- )
587
- );
588
- }
589
-
590
- return out;
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
+ );
591
455
  }
592
456
 
593
- // Scan PATH for stray pm2 binary (read-only).
594
- function findGlobalPm2(dir) {
595
- const localBin = resolve(dir, 'node_modules', '.bin');
596
- const dirs = (process.env.PATH ?? '').split(delimiter).filter(Boolean);
597
- for (const d of dirs) {
598
- if (resolve(d) === localBin) continue;
599
- const candidate = join(d, 'pm2');
600
- if (existsSync(candidate)) return candidate;
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`;
601
465
  }
602
466
  return null;
603
467
  }
@@ -619,19 +483,10 @@ function checkLayoutDrift(dir) {
619
483
  );
620
484
  }
621
485
 
622
- // Must match HeaderAuthProvider.DEFAULT_HEADERS (duplicated to avoid loading runtime).
623
- // Smoke test in providers/header-auth pins them; divergence surfaces in CI.
624
- const DEFAULT_HEADER_NAMES = {
625
- upn: 'SELVA-UserPrincipalName',
626
- email: 'SELVA-Email',
627
- displayName: 'SELVA-DisplayName'
628
- };
629
-
630
- // 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.
631
487
  function checkHeaderAuth(dir, env, dataProvider) {
632
488
  const out = [];
633
489
 
634
- // Allowlist file location.
635
490
  const allowlistDir = env.HEADER_AUTH_DATA_DIR ?? env.DATA_PATH;
636
491
  if (!allowlistDir) {
637
492
  out.push(red('HEADER_AUTH_DATA_DIR (or DATA_PATH) unset — provider will fail to start'));
@@ -641,21 +496,21 @@ function checkHeaderAuth(dir, env, dataProvider) {
641
496
  if (existsSync(allowlistPath)) {
642
497
  out.push(green(`header-allowlist.json present (${allowlistDir}/header-allowlist.json)`));
643
498
  } else {
644
- // 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.
645
501
  out.push(
646
502
  yellow(
647
503
  `header-allowlist.json not found at ${allowlistDir}/ — no users will be allowed in until one is added`
648
504
  )
649
505
  );
650
506
 
651
- // Check writability only if explicitly set (DATA_PATH checked separately).
507
+ // DATA_PATH's own writability is checked separately (checkDataPath).
652
508
  if (env.HEADER_AUTH_DATA_DIR) {
653
509
  out.push(checkDirWritable(allowlistAbsDir, `HEADER_AUTH_DATA_DIR=${allowlistDir}`));
654
510
  }
655
511
  }
656
512
  }
657
513
 
658
- // HOST binding (loopback recommended for header-auth).
659
514
  const host = env.HOST ?? '0.0.0.0';
660
515
  if (host === '127.0.0.1' || host === 'localhost') {
661
516
  out.push(green(`HOST=${host} (loopback-only)`));
@@ -668,12 +523,11 @@ function checkHeaderAuth(dir, env, dataProvider) {
668
523
  );
669
524
  }
670
525
 
671
- // 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.
672
527
  if (!env.ORIGIN) {
673
528
  out.push(red('ORIGIN unset — required for header-auth (always behind a proxy)'));
674
529
  }
675
530
 
676
- // Non-local data provider: HEADER_AUTH_DATA_DIR must be explicit (no DATA_PATH fallback).
677
531
  if (dataProvider !== 'local' && !env.HEADER_AUTH_DATA_DIR) {
678
532
  out.push(
679
533
  red(
@@ -683,10 +537,10 @@ function checkHeaderAuth(dir, env, dataProvider) {
683
537
  );
684
538
  }
685
539
 
686
- // 5. Bootstrap admin email. Without it, the first proxy-authenticated
687
- // visitor's UPN is rejected (not in allowlist) and the operator has to
688
- // hand-write JSON to claim admin. With it, the first matching visit is
689
- // 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.
690
544
  if (!env.BOOTSTRAP_INSTANCE_ADMIN_EMAIL) {
691
545
  out.push(
692
546
  red(
@@ -698,30 +552,7 @@ function checkHeaderAuth(dir, env, dataProvider) {
698
552
  out.push(green(`BOOTSTRAP_INSTANCE_ADMIN_EMAIL=${env.BOOTSTRAP_INSTANCE_ADMIN_EMAIL}`));
699
553
  }
700
554
 
701
- // Resolved header names (print for diffing against proxy config; partial override = typo risk).
702
- const resolved = {
703
- upn: env.HEADER_AUTH_UPN_HEADER || DEFAULT_HEADER_NAMES.upn,
704
- email: env.HEADER_AUTH_EMAIL_HEADER || DEFAULT_HEADER_NAMES.email,
705
- displayName: env.HEADER_AUTH_DISPLAY_NAME_HEADER || DEFAULT_HEADER_NAMES.displayName
706
- };
707
- const overrides = [
708
- Boolean(env.HEADER_AUTH_UPN_HEADER),
709
- Boolean(env.HEADER_AUTH_EMAIL_HEADER),
710
- Boolean(env.HEADER_AUTH_DISPLAY_NAME_HEADER)
711
- ].filter(Boolean).length;
712
- const headerList = `UPN=${resolved.upn}, Email=${resolved.email}, DisplayName=${resolved.displayName}`;
713
- if (overrides === 0) {
714
- out.push(green(`header names (bundled defaults): ${headerList}`));
715
- } else if (overrides === 3) {
716
- out.push(green(`header names (all overridden): ${headerList}`));
717
- } else {
718
- out.push(
719
- yellow(
720
- `header names partially overridden (${overrides}/3 set): ${headerList} — ` +
721
- `a partial override is usually a typo. Set all three or none.`
722
- )
723
- );
724
- }
555
+ out.push(checkHeaderNames(env));
725
556
 
726
557
  return out;
727
558
  }