@selvajs/cli 4.8.0-beta.10 → 4.8.0-beta.11

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,8 +2,8 @@
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,
@@ -23,6 +23,7 @@ import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
23
23
  import { satisfiesNodeRange } from '../node-range.js';
24
24
  import { green, yellow, red, fixable } from '../checks/result.js';
25
25
  import { checkBootPersistence } from '../checks/boot.js';
26
+ import { checkRuntimeEnvironment } from '../checks/runtime.js';
26
27
  import {
27
28
  checkDeprecatedEnv,
28
29
  checkOrigin,
@@ -73,6 +74,7 @@ export async function runDoctor(argv = []) {
73
74
  checks.push(checkPackage(dir, '@selvajs/selva'));
74
75
  checks.push(checkNodeEngine(dir));
75
76
  checks.push(checkCliRuntimeAlignment(dir));
77
+ checks.push(...checkRuntimeEnvironment(dir));
76
78
  checks.push(...checkBootPersistence(dir));
77
79
  checks.push(checkOrigin(env));
78
80
  checks.push(...checkDeprecatedEnv(env));
@@ -105,15 +107,9 @@ export async function runDoctor(argv = []) {
105
107
  }
106
108
  }
107
109
 
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
- */
110
+ // Anything privileged (the systemd unit) or that would restart this process
111
+ // (a Node upgrade) stays a printed instruction instead of a fixer — one that
112
+ // dies halfway through its own runtime leaves the operator with no way back.
117
113
  async function applyFixes(resolved, failures) {
118
114
  const repairs = resolved.filter((c) => c.fix && c.severity !== 'green');
119
115
  if (repairs.length === 0) {
@@ -183,8 +179,7 @@ async function checkSupabase(env) {
183
179
  return red(`SUPABASE_URL="${env.SUPABASE_URL}" is not a valid URL`);
184
180
  }
185
181
 
186
- // Ping the Supabase health endpoint. Soft-fail to yellow on network
187
- // errors — operators may be offline at install time.
182
+ // Network errors go yellow, not red — operators may be offline at install time.
188
183
  try {
189
184
  const controller = new AbortController();
190
185
  const timer = setTimeout(() => controller.abort(), 4000);
@@ -199,11 +194,10 @@ async function checkSupabase(env) {
199
194
  }
200
195
  }
201
196
 
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.
197
+ // Compares the newest migration shipped by @selvajs/supabase-provider against
198
+ // what the database reports via selva.migration_head(). Red on skew — the app
199
+ // degrades /api/health to 503 at boot in the same state, so catching it here
200
+ // saves a confusing deploy.
207
201
  async function checkSupabaseMigrations(dir, env) {
208
202
  if (!env.SUPABASE_URL || !env.SUPABASE_SERVICE_ROLE_KEY) {
209
203
  return yellow('Migration-head check skipped — Supabase env incomplete');
@@ -264,6 +258,7 @@ async function checkSupabaseMigrations(dir, env) {
264
258
  `Database reports no applied migrations (expected head ${expected}). Sync + run: npx supabase db push`
265
259
  );
266
260
  }
261
+ // String comparison — relies on both heads being fixed-width, zero-padded timestamps.
267
262
  if (actual < expected) {
268
263
  return red(
269
264
  `Database migration head ${actual} is behind the installed provider (${expected}). Sync + run: npx supabase db push`
@@ -271,7 +266,6 @@ async function checkSupabaseMigrations(dir, env) {
271
266
  }
272
267
  return green(`Database migration head ${actual} matches the installed provider`);
273
268
  } catch (err) {
274
- // Soft-fail on network errors, same as checkSupabase.
275
269
  return yellow(`Migration-head check skipped (${err.message ?? err})`);
276
270
  }
277
271
  }
@@ -295,7 +289,7 @@ function majorOf(version) {
295
289
  return m ? Number(m[1]) : null;
296
290
  }
297
291
 
298
- // CLI and runtime release together (fixed group); major skew = stale CLI pin.
292
+ // CLI and runtime release as a fixed group; major-version skew means a stale CLI pin.
299
293
  function checkCliRuntimeAlignment(dir) {
300
294
  const here = dirname(fileURLToPath(import.meta.url));
301
295
  const cliVersion = readPackageVersion(join(here, '..', '..', 'package.json'));
@@ -303,7 +297,7 @@ function checkCliRuntimeAlignment(dir) {
303
297
  join(dir, 'node_modules', '@selvajs', 'selva', 'package.json')
304
298
  );
305
299
 
306
- // Runtime missing is already reported red by checkPackage; stay quiet here.
300
+ // checkPackage already reports missing runtime as red; stay quiet here.
307
301
  if (!runtimeVersion) return yellow('CLI/runtime version check skipped (runtime not installed)');
308
302
  if (!cliVersion) return yellow('CLI/runtime version check skipped (could not read CLI version)');
309
303
 
@@ -324,16 +318,10 @@ function checkCliRuntimeAlignment(dir) {
324
318
  );
325
319
  }
326
320
 
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
- */
321
+ // npm only enforces engines.node under engine-strict=true, which no deployment
322
+ // sets, so a mismatched install succeeds silently and /api/health still returns
323
+ // 200 — routes using newer APIs throw only under real traffic. Not auto-fixable:
324
+ // upgrading Node is distro-specific and would restart the process running this check.
337
325
  function checkNodeEngine(dir) {
338
326
  let required;
339
327
  try {
@@ -363,12 +351,10 @@ function checkNodeEngine(dir) {
363
351
  );
364
352
  }
365
353
 
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
- */
354
+ // `selva migrate` parks the old node_modules aside so a failed install can be
355
+ // rolled back, and removes it on either outcome. One surviving here means the
356
+ // migration was killed mid-flight — worth flagging since it's a full copy of
357
+ // the dependency tree and the deployment may be running on the wrong one.
372
358
  function checkMigrationLeftovers(dir) {
373
359
  const stash = join(dir, NODE_MODULES_STASH);
374
360
  if (!existsSync(stash)) return green('no interrupted migration left behind');
@@ -403,19 +389,18 @@ function checkLayoutDrift(dir) {
403
389
  );
404
390
  }
405
391
 
406
- // Must match HeaderAuthProvider.DEFAULT_HEADERS (duplicated to avoid loading runtime).
407
- // Smoke test in providers/header-auth pins them; divergence surfaces in CI.
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.
408
394
  const DEFAULT_HEADER_NAMES = {
409
395
  upn: 'SELVA-UserPrincipalName',
410
396
  email: 'SELVA-Email',
411
397
  displayName: 'SELVA-DisplayName'
412
398
  };
413
399
 
414
- // Header-auth checks (read-only; runtime invariants like spoofing can't be verified).
400
+ // Static config only — runtime invariants like spoofing protection can't be checked from here.
415
401
  function checkHeaderAuth(dir, env, dataProvider) {
416
402
  const out = [];
417
403
 
418
- // Allowlist file location.
419
404
  const allowlistDir = env.HEADER_AUTH_DATA_DIR ?? env.DATA_PATH;
420
405
  if (!allowlistDir) {
421
406
  out.push(red('HEADER_AUTH_DATA_DIR (or DATA_PATH) unset — provider will fail to start'));
@@ -425,21 +410,21 @@ function checkHeaderAuth(dir, env, dataProvider) {
425
410
  if (existsSync(allowlistPath)) {
426
411
  out.push(green(`header-allowlist.json present (${allowlistDir}/header-allowlist.json)`));
427
412
  } else {
428
- // Provider creates lazily, but missing file locks everyone out.
413
+ // The provider creates this file lazily, but a missing file locks everyone out
414
+ // until one exists — worth a warning even though nothing is technically broken.
429
415
  out.push(
430
416
  yellow(
431
417
  `header-allowlist.json not found at ${allowlistDir}/ — no users will be allowed in until one is added`
432
418
  )
433
419
  );
434
420
 
435
- // Check writability only if explicitly set (DATA_PATH checked separately).
421
+ // DATA_PATH's own writability is checked separately (checkDataPath).
436
422
  if (env.HEADER_AUTH_DATA_DIR) {
437
423
  out.push(checkDirWritable(allowlistAbsDir, `HEADER_AUTH_DATA_DIR=${allowlistDir}`));
438
424
  }
439
425
  }
440
426
  }
441
427
 
442
- // HOST binding (loopback recommended for header-auth).
443
428
  const host = env.HOST ?? '0.0.0.0';
444
429
  if (host === '127.0.0.1' || host === 'localhost') {
445
430
  out.push(green(`HOST=${host} (loopback-only)`));
@@ -452,12 +437,11 @@ function checkHeaderAuth(dir, env, dataProvider) {
452
437
  );
453
438
  }
454
439
 
455
- // 3. ORIGIN — header-auth implies a reverse proxy, so ORIGIN is required.
440
+ // header-auth always sits behind a reverse proxy, so ORIGIN is required.
456
441
  if (!env.ORIGIN) {
457
442
  out.push(red('ORIGIN unset — required for header-auth (always behind a proxy)'));
458
443
  }
459
444
 
460
- // Non-local data provider: HEADER_AUTH_DATA_DIR must be explicit (no DATA_PATH fallback).
461
445
  if (dataProvider !== 'local' && !env.HEADER_AUTH_DATA_DIR) {
462
446
  out.push(
463
447
  red(
@@ -467,10 +451,10 @@ function checkHeaderAuth(dir, env, dataProvider) {
467
451
  );
468
452
  }
469
453
 
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.
454
+ // Without this, the first proxy-authenticated visitor's UPN is rejected
455
+ // (not in the allowlist) and the operator has to hand-write JSON to claim
456
+ // admin. With it, the first matching visit auto-allowlists and grants
457
+ // instance_admin in one step.
474
458
  if (!env.BOOTSTRAP_INSTANCE_ADMIN_EMAIL) {
475
459
  out.push(
476
460
  red(
@@ -482,7 +466,6 @@ function checkHeaderAuth(dir, env, dataProvider) {
482
466
  out.push(green(`BOOTSTRAP_INSTANCE_ADMIN_EMAIL=${env.BOOTSTRAP_INSTANCE_ADMIN_EMAIL}`));
483
467
  }
484
468
 
485
- // Resolved header names (print for diffing against proxy config; partial override = typo risk).
486
469
  const resolved = {
487
470
  upn: env.HEADER_AUTH_UPN_HEADER || DEFAULT_HEADER_NAMES.upn,
488
471
  email: env.HEADER_AUTH_EMAIL_HEADER || DEFAULT_HEADER_NAMES.email,
@@ -1,11 +1,6 @@
1
- // `selva init` — reconfigure an existing deployment.
2
- //
3
- // Differences from `create`:
4
- // • Reads current .env values; uses them as prompt defaults.
5
- // • Never regenerates SELVA_HMAC_KEY / SELVA_AT_REST_KEY if they're set.
6
- // Rotating those invalidates sessions and at-rest encryption — that's
7
- // `selva keys rotate`'s job, not init's.
8
- // • Doesn't touch package.json or run npm install.
1
+ // `selva init` — reconfigures an existing deployment. Unlike `create`, it reads
2
+ // the current .env as prompt defaults and never touches package.json or runs
3
+ // npm install.
9
4
 
10
5
  import { join } from 'node:path';
11
6
  import * as p from '@clack/prompts';
@@ -23,10 +18,10 @@ export async function runInit() {
23
18
  const current = readEnvFile(envPath);
24
19
  const values = await collectConfig({ defaults: current, mode: 'init' });
25
20
 
26
- // Preserve secrets. The only safe time to generate them is at install
27
- // time (no sessions to invalidate, no encrypted data to lose). If the
28
- // existing .env doesn't have them — which can happen if a previous
29
- // scaffold bailed mid-flight — generate now and warn.
21
+ // Never regenerate an existing key: rotating SELVA_HMAC_KEY or
22
+ // SELVA_AT_REST_KEY invalidates sessions and at-rest encryption — that's
23
+ // `selva keys rotate`'s job, not init's. Missing keys (a scaffold that
24
+ // bailed mid-flight) are the one case it's safe to generate here.
30
25
  if (
31
26
  current.SELVA_HMAC_KEY &&
32
27
  current.SELVA_HMAC_KEY !== 'replace-this-with-a-random-32-byte-hex-key'
@@ -1,5 +1,5 @@
1
- // Migrate deployment to current layout: rewrite package.json (drop legacy
2
- // provider packages), remove stale selva.config.js, update ecosystem.config.cjs.
1
+ // Migrates a deployment to the current layout: rewrites package.json (drops legacy
2
+ // provider packages), removes stale selva.config.js, updates ecosystem.config.cjs.
3
3
  // Idempotent; mirrors update's lifecycle (stop/mutate/start with rollback on failure).
4
4
 
5
5
  import { existsSync, readFileSync, writeFileSync, copyFileSync, rmSync, renameSync } from 'node:fs';
@@ -13,29 +13,25 @@ import {
13
13
  buildDeploymentPackageJson,
14
14
  CANONICAL_FIELDS,
15
15
  DEPENDENCIES,
16
- LEGACY_DEPENDENCIES
16
+ LEGACY_DEPENDENCIES,
17
+ OVERRIDES
17
18
  } from '../deployment-package.js';
18
19
  import { APP_NAME, runPm2 } from './pm2.js';
19
20
 
20
- /**
21
- * Where `migrate` parks the old node_modules while npm installs the new one.
22
- * Left behind only if the process is killed mid-migration — `detectDrift`
23
- * reports it, because a stray copy of the dependency tree is both confusing and
24
- * large enough to matter on a small VM.
25
- */
21
+ // Where migrate parks the old node_modules while npm installs the new one.
22
+ // Left behind only if the process is killed mid-migration; `selva doctor`
23
+ // flags a surviving stash as an interrupted migration.
26
24
  export const NODE_MODULES_STASH = 'node_modules.selva-migrate-bak';
27
25
 
28
- /**
29
- * @param {object} [deps] Seams for tests. The rollback path destroys and
30
- * restores a real dependency tree, so it has to be exercised against a real
31
- * directory — but not against a real npm or a real pm2 daemon.
32
- */
26
+ // `deps` are seams for tests: the rollback path destroys and restores a real
27
+ // dependency tree, so it has to run against a real directory — but not against
28
+ // a real npm or pm2 daemon.
33
29
  export async function runMigrate(
34
30
  _argv,
35
31
  {
36
32
  dir: injectedDir,
37
- // --prefer-online bypasses the stale-packument trap: npm can otherwise
38
- // resolve a cached packument that predates the release being migrated to.
33
+ // --prefer-online: npm can otherwise resolve a cached packument that
34
+ // predates the release being migrated to.
39
35
  install = (cwd) => execSync('npm install --prefer-online', { cwd, stdio: 'pipe' }),
40
36
  confirm = (message) => p.confirm({ message, initialValue: true }),
41
37
  pm2 = runPm2
@@ -56,8 +52,6 @@ export async function runMigrate(
56
52
  const target = buildTargetPackageJson(before);
57
53
  const pkgDiff = diffPackageJson(before, target);
58
54
 
59
- // Side-files: selva.config.js (now unused) and ecosystem.config.cjs
60
- // (must point at @selvajs/selva, not @selvajs/runtime).
61
55
  const configPath = join(dir, 'selva.config.js');
62
56
  const hasStaleConfig = existsSync(configPath);
63
57
 
@@ -65,9 +59,9 @@ export async function runMigrate(
65
59
  const ecoHasStaleRuntime =
66
60
  existsSync(ecoPath) && readFileSync(ecoPath, 'utf8').includes('@selvajs/runtime');
67
61
 
68
- // Deprecated env keys. The server reads the old names for one more minor
69
- // version, so this is a warning today and a silent fallback to defaults once
70
- // that shim goes — rewriting now is what keeps a tuned value tuned.
62
+ // The server drops its read-old-name shim after one minor version, so
63
+ // rewriting now is what keeps a tuned value tuned instead of silently
64
+ // reverting to defaults later.
71
65
  const envPath = join(dir, '.env');
72
66
  const envBefore = existsSync(envPath) ? readFileSync(envPath, 'utf8') : null;
73
67
  const envRename = envBefore
@@ -106,9 +100,10 @@ export async function runMigrate(
106
100
  return;
107
101
  }
108
102
 
109
- // Stop pm2 before npm rewrites (SvelteKit lazy-loads chunks; see update command).
103
+ // Stop pm2 before npm rewrites — SvelteKit lazy-loads chunks, so a rewrite
104
+ // under a running server serves a broken page (same reason `update` stops first).
110
105
  // A legacy deployment may predate the local-pm2 layout, so a missing binary
111
- // is not fatal here — there is nothing running for us to stop.
106
+ // isn't fatal here: there's nothing running for us to stop.
112
107
  try {
113
108
  const stopStatus = pm2(dir, ['stop', APP_NAME], { inherit: false });
114
109
  if (stopStatus !== 0) {
@@ -118,7 +113,6 @@ export async function runMigrate(
118
113
  p.log.warn('No deployment-local pm2 to stop — continuing.');
119
114
  }
120
115
 
121
- // Back up for rollback on npm-install failure.
122
116
  const bakPath = pkgPath + '.bak';
123
117
  copyFileSync(pkgPath, bakPath);
124
118
  writeFileSync(pkgPath, JSON.stringify(target, null, 2) + '\n', 'utf8');
@@ -130,7 +124,7 @@ export async function runMigrate(
130
124
 
131
125
  if (ecoHasStaleRuntime) {
132
126
  copyFileSync(ecoPath, ecoPath + '.bak');
133
- // Rewrite @selvajs/runtime → @selvajs/selva only (preserve customizations).
127
+ // Text substitution, not regeneration, so any operator customizations to the file survive.
134
128
  const ecoContent = readFileSync(ecoPath, 'utf8').replace(
135
129
  /@selvajs\/runtime/g,
136
130
  '@selvajs/selva'
@@ -143,11 +137,11 @@ export async function runMigrate(
143
137
  writeFileSync(envPath, envRename.text, 'utf8');
144
138
  }
145
139
 
146
- // A clean install is required — a legacy lockfile pins the old package set
147
- // across a major bump. But node_modules is also where pm2 lives, and the
148
- // rollback below has to restart the app, so deleting it outright leaves a
149
- // failed migration with no way back up. Rename instead: atomic, keeps the
150
- // .bin symlinks intact, and restorable until the install succeeds.
140
+ // A legacy lockfile pins the old package set across a major bump, so this needs
141
+ // a clean install. But node_modules is also where pm2 lives, and rollback has to
142
+ // restart the app — deleting it outright would leave a failed migration with no
143
+ // way back up. Rename instead: atomic, keeps .bin symlinks intact, restorable
144
+ // until the install succeeds.
151
145
  const nodeModules = join(dir, 'node_modules');
152
146
  const nodeModulesBak = join(dir, NODE_MODULES_STASH);
153
147
  const lockPath = join(dir, 'package-lock.json');
@@ -176,8 +170,8 @@ export async function runMigrate(
176
170
  copyFileSync(envPath + '.bak', envPath);
177
171
  }
178
172
 
179
- // Restore the dependency tree the app was running on. A half-installed
180
- // node_modules from the failed attempt is worse than the old one.
173
+ // A half-installed node_modules from the failed attempt is worse than the old
174
+ // one, so clear it before restoring the stash.
181
175
  rmSync(nodeModules, { recursive: true, force: true });
182
176
  if (existsSync(nodeModulesBak)) renameSync(nodeModulesBak, nodeModules);
183
177
  if (lockBak !== null) writeFileSync(lockPath, lockBak, 'utf8');
@@ -187,6 +181,10 @@ export async function runMigrate(
187
181
  process.exit(1);
188
182
  }
189
183
 
184
+ // Migration itself already succeeded at this point, so a failed pm2 start is reported
185
+ // but doesn't exit non-zero or trigger rollback — the on-disk state is fine, only the
186
+ // running process isn't. Contrast restartAfterRollback, which does exit 1: that path
187
+ // runs after a rollback, where a failed restart leaves nothing serving traffic at all.
190
188
  let status;
191
189
  try {
192
190
  status = pm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
@@ -194,6 +192,16 @@ export async function runMigrate(
194
192
  status = 1;
195
193
  p.log.error(`Could not invoke pm2: ${err instanceof Error ? err.message : err}`);
196
194
  }
195
+ // A pin change means the just-installed pm2 CLI and the still-running
196
+ // daemon are now different versions. That's the repairable skew direction
197
+ // (daemon older), but it stays skewed until the operator acts — say so
198
+ // here, once, instead of letting doctor nag forever. Deliberately NOT
199
+ // automated: `pm2 update` recycles the daemon, and doing that unattended
200
+ // inside a migration converts a mid-flight failure into an outage (see
201
+ // plans/fixes/host-prerequisites-and-pm2-audit.md).
202
+ const pm2Notice = buildPm2UpgradeNotice(before.dependencies?.pm2, target.dependencies.pm2);
203
+ if (pm2Notice) p.log.warn(pm2Notice);
204
+
197
205
  const backupHints = ['package.json.bak'];
198
206
  if (hasStaleConfig) backupHints.push('selva.config.js.bak');
199
207
  if (ecoHasStaleRuntime) backupHints.push('ecosystem.config.cjs.bak');
@@ -212,14 +220,9 @@ export async function runMigrate(
212
220
  }
213
221
  }
214
222
 
215
- /**
216
- * Bring the app back up after a failed migration.
217
- *
218
- * Never throws: the migration is already aborting, and an exception here would
219
- * replace the rollback's diagnosis with a stack trace. A failure to restart is
220
- * the operator's problem to act on, so it must be stated, not swallowed — the
221
- * old code returned a bare 1 and printed nothing.
222
- */
223
+ // Never throws: the migration is already aborting, and an exception here would
224
+ // replace the rollback's diagnosis with a stack trace. A failure to restart is
225
+ // the operator's problem to act on, so it gets logged, not swallowed.
223
226
  function restartAfterRollback(dir, pm2 = runPm2) {
224
227
  try {
225
228
  const status = pm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
@@ -263,6 +266,19 @@ function diffPackageJson(before, after) {
263
266
  else if (a !== b) lines.push(`${pc.yellow('~')} ${name} ${pc.dim(a + ' → ' + b)}`);
264
267
  }
265
268
 
269
+ // Without this section, an overrides-only change reads as "already on the
270
+ // current layout" and the security shim never reaches existing deployments.
271
+ const beforeOverrides = before.overrides ?? {};
272
+ const afterOverrides = after.overrides ?? {};
273
+ const allOverrides = new Set([...Object.keys(beforeOverrides), ...Object.keys(afterOverrides)]);
274
+ for (const name of [...allOverrides].sort()) {
275
+ const a = beforeOverrides[name];
276
+ const b = afterOverrides[name];
277
+ if (a && !b) lines.push(`${pc.red('-')} overrides.${name} ${pc.dim(a)}`);
278
+ else if (!a && b) lines.push(`${pc.green('+')} overrides.${name} ${pc.dim(b)}`);
279
+ else if (a !== b) lines.push(`${pc.yellow('~')} overrides.${name} ${pc.dim(a + ' → ' + b)}`);
280
+ }
281
+
266
282
  const beforeScripts = before.scripts ?? {};
267
283
  const afterScripts = after.scripts ?? {};
268
284
  const allScripts = new Set([...Object.keys(beforeScripts), ...Object.keys(afterScripts)]);
@@ -276,9 +292,9 @@ function diffPackageJson(before, after) {
276
292
  }
277
293
  }
278
294
 
279
- // Anything outside the canonical set is discarded by the rewrite.
280
- // devDependencies and description used to vanish without appearing here, so
281
- // the operator confirmed a change they were never shown.
295
+ // Anything outside the canonical set is discarded by the rewrite — show it here
296
+ // too, or the operator confirms a change (e.g. losing devDependencies) they
297
+ // never saw.
282
298
  for (const key of Object.keys(before).sort()) {
283
299
  if (CANONICAL_FIELDS.has(key)) continue;
284
300
  lines.push(`${pc.red('-')} ${key} ${pc.dim(summarize(before[key]))}`);
@@ -307,6 +323,23 @@ export function detectDrift(pkgJson, dir) {
307
323
  if (!deps[name]) reasons.push(`${name} is missing`);
308
324
  }
309
325
 
326
+ // pm2 is the one dependency pinned exactly, so a version difference is drift
327
+ // rather than an operator preference. Presence alone was checked above and
328
+ // isn't enough: a deployment scaffolded against an older CLI keeps its old
329
+ // pm2 forever, which is how the pin reached a two-major gap unnoticed.
330
+ if (deps.pm2 && deps.pm2 !== DEPENDENCIES.pm2) {
331
+ reasons.push(`pm2 is pinned to ${deps.pm2} (current scaffold pins ${DEPENDENCIES.pm2})`);
332
+ }
333
+
334
+ // Overrides are security shims for vulnerable transitive deps — a
335
+ // deployment without them installs the vulnerable version on its next
336
+ // `npm install`, silently.
337
+ for (const [name, version] of Object.entries(OVERRIDES)) {
338
+ if (pkgJson?.overrides?.[name] !== version) {
339
+ reasons.push(`overrides.${name} is missing or outdated (security shim, expects ${version})`);
340
+ }
341
+ }
342
+
310
343
  if (dir) {
311
344
  const configPath = join(dir, 'selva.config.js');
312
345
  if (existsSync(configPath)) {
@@ -328,4 +361,20 @@ export function detectDrift(pkgJson, dir) {
328
361
  return reasons;
329
362
  }
330
363
 
364
+ // Null when the pin didn't change. Exported for its test — the wording is the
365
+ // operator's only pointer to finish the upgrade.
366
+ export function buildPm2UpgradeNotice(beforeVersion, afterVersion) {
367
+ if (!beforeVersion || beforeVersion === afterVersion) return null;
368
+ return [
369
+ `pm2 was upgraded ${beforeVersion} → ${afterVersion}, but the running pm2 daemon is`,
370
+ `still the old version. Finish the upgrade (brief restart of managed processes):`,
371
+ ``,
372
+ ` npx pm2 update`,
373
+ ` npx pm2 save`,
374
+ ``,
375
+ `If doctor reports a pm2 outside this deployment (global or apt install), follow`,
376
+ `the full procedure in the docs instead: self-hosting → deployment → prerequisites.`
377
+ ].join('\n');
378
+ }
379
+
331
380
  export { buildTargetPackageJson, diffPackageJson };
@@ -1,6 +1,6 @@
1
- // Thin wrappers around PM2 commands: always pass --update-env (ignore edits),
2
- // resolve to deployment-local pm2 (avoid daemon version skew with global install),
3
- // and resync daemon before state changes.
1
+ // Thin wrappers around PM2 commands: resolve to the deployment-local pm2, resync
2
+ // the daemon before state changes, and pass --update-env on restart so edits to
3
+ // .env actually take effect.
4
4
 
5
5
  import { existsSync, readFileSync } from 'node:fs';
6
6
  import { join } from 'node:path';
@@ -11,7 +11,6 @@ import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
11
11
 
12
12
  export const APP_NAME = 'selva-compute';
13
13
 
14
- // Resolve to deployment-local pm2 (no global fallback; prevents version skew).
15
14
  export function pm2Bin(dir) {
16
15
  const local = join(dir, 'node_modules', '.bin', process.platform === 'win32' ? 'pm2.cmd' : 'pm2');
17
16
  if (!existsSync(local)) {
@@ -25,11 +24,10 @@ export function pm2Bin(dir) {
25
24
  return local;
26
25
  }
27
26
 
28
- // Parse the two versions pm2 prints alongside its out-of-date warning:
27
+ // Parses the two versions pm2 prints alongside its out-of-date warning:
29
28
  // In memory PM2 version: 6.0.14
30
29
  // Local PM2 version: 5.4.3
31
- // Returns nulls when the shape changes — callers then fall back to resyncing,
32
- // which is the pre-existing behaviour.
30
+ // Returns nulls when the shape doesn't match, so callers fall back to resyncing.
33
31
  export function parsePm2Skew(output) {
34
32
  // eslint-disable-next-line no-control-regex -- pm2 colourises via chalk on a TTY
35
33
  const clean = output.replace(/\[[0-9;]*m/g, '');
@@ -38,12 +36,10 @@ export function parsePm2Skew(output) {
38
36
  return { daemon: daemon?.[1] ?? null, local: local?.[1] ?? null };
39
37
  }
40
38
 
41
- /**
42
- * True when the running daemon is newer than the deployment-local pm2 — i.e. a
43
- * foreign global pm2 owns it. `pm2 update` cannot fix that direction: it
44
- * downgrades the daemon and hands it a dump from a newer version, which drops
45
- * the process table so selva-compute is never re-registered (issue #118).
46
- */
39
+ // True when the running daemon is newer than the deployment-local pm2 — i.e. a
40
+ // foreign global pm2 owns it. `pm2 update` can't fix that direction: it would
41
+ // downgrade the daemon and hand it a dump from a newer version, dropping the
42
+ // process table so selva-compute never gets re-registered.
47
43
  export function daemonOutranksCli(daemon, local) {
48
44
  if (!daemon || !local) return false;
49
45
  const [a, b] = [daemon.split('.').map(Number), local.split('.').map(Number)];
@@ -54,7 +50,6 @@ export function daemonOutranksCli(daemon, local) {
54
50
  return false;
55
51
  }
56
52
 
57
- // Check for daemon/CLI version mismatch; run `pm2 update` only when it can help.
58
53
  function ensurePm2InSync(dir) {
59
54
  const bin = pm2Bin(dir);
60
55
  const probe = spawnSync(bin, ['ping'], {
@@ -92,13 +87,9 @@ function ensurePm2InSync(dir) {
92
87
  }
93
88
  }
94
89
 
95
- /**
96
- * Run the deployment-local pm2.
97
- *
98
- * `bin` overrides where pm2 is resolved from — `selva migrate` wipes
99
- * node_modules mid-flight and passes the binary it stashed beforehand, because
100
- * resolving from the deployment at that moment would find nothing.
101
- */
90
+ // `bin` lets a caller pass an already-resolved pm2 path instead of resolving
91
+ // fresh from `dir` — needed when node_modules won't exist at call time (e.g.
92
+ // `selva migrate` wipes it mid-flight and passes the binary it stashed first).
102
93
  export function runPm2(dir, args, { inherit = true, bin = pm2Bin(dir) } = {}) {
103
94
  const result = spawnSync(bin, args, {
104
95
  cwd: dir,