@selvajs/cli 4.8.0-beta.9 → 4.8.1

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.
@@ -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'
@@ -47,7 +42,11 @@ export async function runInit() {
47
42
  p.log.warn('SELVA_AT_REST_KEY was missing — generated a fresh one.');
48
43
  }
49
44
 
50
- 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 });
51
50
 
52
51
  p.outro(
53
52
  [
@@ -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,32 +13,101 @@ import {
13
13
  buildDeploymentPackageJson,
14
14
  CANONICAL_FIELDS,
15
15
  DEPENDENCIES,
16
- LEGACY_DEPENDENCIES
16
+ isFloatingPin,
17
+ LEGACY_DEPENDENCIES,
18
+ npmDistTagVersion,
19
+ OVERRIDES,
20
+ resolveSelvaPins,
21
+ SELVA_PACKAGES
17
22
  } from '../deployment-package.js';
18
23
  import { APP_NAME, runPm2 } from './pm2.js';
19
24
 
25
+ // Where migrate parks the old node_modules while npm installs the new one.
26
+ // Left behind only if the process is killed mid-migration; `selva doctor`
27
+ // flags a surviving stash as an interrupted migration.
28
+ export const NODE_MODULES_STASH = 'node_modules.selva-migrate-bak';
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
+
20
34
  /**
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.
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.
25
37
  */
26
- export const NODE_MODULES_STASH = 'node_modules.selva-migrate-bak';
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;
27
62
 
28
63
  /**
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.
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.
32
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
+
98
+ // `deps` are seams for tests: the rollback path destroys and restores a real
99
+ // dependency tree, so it has to run against a real directory — but not against
100
+ // a real npm or pm2 daemon.
33
101
  export async function runMigrate(
34
102
  _argv,
35
103
  {
36
104
  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.
105
+ // --prefer-online: npm can otherwise resolve a cached packument that
106
+ // predates the release being migrated to.
39
107
  install = (cwd) => execSync('npm install --prefer-online', { cwd, stdio: 'pipe' }),
40
108
  confirm = (message) => p.confirm({ message, initialValue: true }),
41
- pm2 = runPm2
109
+ pm2 = runPm2,
110
+ resolveVersion = npmDistTagVersion
42
111
  } = {}
43
112
  ) {
44
113
  const dir = injectedDir ?? resolveDeploymentDir();
@@ -53,11 +122,9 @@ export async function runMigrate(
53
122
  }
54
123
 
55
124
  const before = JSON.parse(readFileSync(pkgPath, 'utf8'));
56
- const target = buildTargetPackageJson(before);
125
+ const { pkg: target, notes: pinNotes } = buildTargetPackageJson(before, resolveVersion);
57
126
  const pkgDiff = diffPackageJson(before, target);
58
127
 
59
- // Side-files: selva.config.js (now unused) and ecosystem.config.cjs
60
- // (must point at @selvajs/selva, not @selvajs/runtime).
61
128
  const configPath = join(dir, 'selva.config.js');
62
129
  const hasStaleConfig = existsSync(configPath);
63
130
 
@@ -65,9 +132,9 @@ export async function runMigrate(
65
132
  const ecoHasStaleRuntime =
66
133
  existsSync(ecoPath) && readFileSync(ecoPath, 'utf8').includes('@selvajs/runtime');
67
134
 
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.
135
+ // The server drops its read-old-name shim after one minor version, so
136
+ // rewriting now is what keeps a tuned value tuned instead of silently
137
+ // reverting to defaults later.
71
138
  const envPath = join(dir, '.env');
72
139
  const envBefore = existsSync(envPath) ? readFileSync(envPath, 'utf8') : null;
73
140
  const envRename = envBefore
@@ -100,15 +167,33 @@ export async function runMigrate(
100
167
  for (const line of pkgDiff) console.log(' ' + line);
101
168
  for (const line of sideFileChanges) console.log(' ' + line);
102
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
+
103
187
  const confirmed = await confirm('Apply these changes, reinstall, and restart?');
104
188
  if (p.isCancel(confirmed) || !confirmed) {
105
189
  p.cancel('Cancelled.');
106
190
  return;
107
191
  }
108
192
 
109
- // Stop pm2 before npm rewrites (SvelteKit lazy-loads chunks; see update command).
193
+ // Stop pm2 before npm rewrites — SvelteKit lazy-loads chunks, so a rewrite
194
+ // under a running server serves a broken page (same reason `update` stops first).
110
195
  // 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.
196
+ // isn't fatal here: there's nothing running for us to stop.
112
197
  try {
113
198
  const stopStatus = pm2(dir, ['stop', APP_NAME], { inherit: false });
114
199
  if (stopStatus !== 0) {
@@ -118,19 +203,25 @@ export async function runMigrate(
118
203
  p.log.warn('No deployment-local pm2 to stop — continuing.');
119
204
  }
120
205
 
121
- // Back up for rollback on npm-install failure.
122
- 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
+
123
214
  copyFileSync(pkgPath, bakPath);
124
215
  writeFileSync(pkgPath, JSON.stringify(target, null, 2) + '\n', 'utf8');
125
216
 
126
217
  if (hasStaleConfig) {
127
- copyFileSync(configPath, configPath + '.bak');
218
+ copyFileSync(configPath, configBak);
128
219
  rmSync(configPath, { force: true });
129
220
  }
130
221
 
131
222
  if (ecoHasStaleRuntime) {
132
- copyFileSync(ecoPath, ecoPath + '.bak');
133
- // Rewrite @selvajs/runtime → @selvajs/selva only (preserve customizations).
223
+ copyFileSync(ecoPath, ecoBak);
224
+ // Text substitution, not regeneration, so any operator customizations to the file survive.
134
225
  const ecoContent = readFileSync(ecoPath, 'utf8').replace(
135
226
  /@selvajs\/runtime/g,
136
227
  '@selvajs/selva'
@@ -139,15 +230,15 @@ export async function runMigrate(
139
230
  }
140
231
 
141
232
  if (envRename.changes.length > 0) {
142
- copyFileSync(envPath, envPath + '.bak');
233
+ copyFileSync(envPath, envBak);
143
234
  writeFileSync(envPath, envRename.text, 'utf8');
144
235
  }
145
236
 
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.
237
+ // A legacy lockfile pins the old package set across a major bump, so this needs
238
+ // a clean install. But node_modules is also where pm2 lives, and rollback has to
239
+ // restart the app — deleting it outright would leave a failed migration with no
240
+ // way back up. Rename instead: atomic, keeps .bin symlinks intact, restorable
241
+ // until the install succeeds.
151
242
  const nodeModules = join(dir, 'node_modules');
152
243
  const nodeModulesBak = join(dir, NODE_MODULES_STASH);
153
244
  const lockPath = join(dir, 'package-lock.json');
@@ -166,18 +257,18 @@ export async function runMigrate(
166
257
  } catch (err) {
167
258
  s.stop(pc.red('npm install failed — rolling back'));
168
259
  copyFileSync(bakPath, pkgPath);
169
- if (hasStaleConfig && existsSync(configPath + '.bak')) {
170
- copyFileSync(configPath + '.bak', configPath);
260
+ if (hasStaleConfig && existsSync(configBak)) {
261
+ copyFileSync(configBak, configPath);
171
262
  }
172
- if (ecoHasStaleRuntime && existsSync(ecoPath + '.bak')) {
173
- copyFileSync(ecoPath + '.bak', ecoPath);
263
+ if (ecoHasStaleRuntime && existsSync(ecoBak)) {
264
+ copyFileSync(ecoBak, ecoPath);
174
265
  }
175
- if (envRename.changes.length > 0 && existsSync(envPath + '.bak')) {
176
- copyFileSync(envPath + '.bak', envPath);
266
+ if (envRename.changes.length > 0 && existsSync(envBak)) {
267
+ copyFileSync(envBak, envPath);
177
268
  }
178
269
 
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.
270
+ // A half-installed node_modules from the failed attempt is worse than the old
271
+ // one, so clear it before restoring the stash.
181
272
  rmSync(nodeModules, { recursive: true, force: true });
182
273
  if (existsSync(nodeModulesBak)) renameSync(nodeModulesBak, nodeModules);
183
274
  if (lockBak !== null) writeFileSync(lockPath, lockBak, 'utf8');
@@ -187,6 +278,10 @@ export async function runMigrate(
187
278
  process.exit(1);
188
279
  }
189
280
 
281
+ // Migration itself already succeeded at this point, so a failed pm2 start is reported
282
+ // but doesn't exit non-zero or trigger rollback — the on-disk state is fine, only the
283
+ // running process isn't. Contrast restartAfterRollback, which does exit 1: that path
284
+ // runs after a rollback, where a failed restart leaves nothing serving traffic at all.
190
285
  let status;
191
286
  try {
192
287
  status = pm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
@@ -194,10 +289,20 @@ export async function runMigrate(
194
289
  status = 1;
195
290
  p.log.error(`Could not invoke pm2: ${err instanceof Error ? err.message : err}`);
196
291
  }
197
- const backupHints = ['package.json.bak'];
198
- if (hasStaleConfig) backupHints.push('selva.config.js.bak');
199
- if (ecoHasStaleRuntime) backupHints.push('ecosystem.config.cjs.bak');
200
- if (envRename.changes.length > 0) backupHints.push('.env.bak');
292
+ // A pin change means the just-installed pm2 CLI and the still-running
293
+ // daemon are now different versions. That's the repairable skew direction
294
+ // (daemon older), but it stays skewed until the operator acts — say so
295
+ // here, once, instead of letting doctor nag forever. Deliberately NOT
296
+ // automated: `pm2 update` recycles the daemon, and doing that unattended
297
+ // inside a migration converts a mid-flight failure into an outage (see
298
+ // plans/fixes/host-prerequisites-and-pm2-audit.md).
299
+ const pm2Notice = buildPm2UpgradeNotice(before.dependencies?.pm2, target.dependencies.pm2);
300
+ if (pm2Notice) p.log.warn(pm2Notice);
301
+
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`);
201
306
 
202
307
  if (status === 0) {
203
308
  p.outro(
@@ -212,14 +317,9 @@ export async function runMigrate(
212
317
  }
213
318
  }
214
319
 
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
- */
320
+ // Never throws: the migration is already aborting, and an exception here would
321
+ // replace the rollback's diagnosis with a stack trace. A failure to restart is
322
+ // the operator's problem to act on, so it gets logged, not swallowed.
223
323
  function restartAfterRollback(dir, pm2 = runPm2) {
224
324
  try {
225
325
  const status = pm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
@@ -239,12 +339,15 @@ function restartAfterRollback(dir, pm2 = runPm2) {
239
339
  }
240
340
  }
241
341
 
242
- function buildTargetPackageJson(current) {
243
- return buildDeploymentPackageJson({
342
+ function buildTargetPackageJson(current, resolveVersion = npmDistTagVersion) {
343
+ const { pins, notes } = resolveSelvaPins(current.dependencies, resolveVersion);
344
+ const pkg = buildDeploymentPackageJson({
244
345
  name: current.name ?? 'selva-deployment',
245
346
  version: current.version ?? '0.1.0',
246
- engines: current.engines
347
+ engines: current.engines,
348
+ dependencies: pins
247
349
  });
350
+ return { pkg, notes };
248
351
  }
249
352
 
250
353
  // Format package.json diff for confirmation prompt (concise, not full diff).
@@ -263,6 +366,19 @@ function diffPackageJson(before, after) {
263
366
  else if (a !== b) lines.push(`${pc.yellow('~')} ${name} ${pc.dim(a + ' → ' + b)}`);
264
367
  }
265
368
 
369
+ // Without this section, an overrides-only change reads as "already on the
370
+ // current layout" and the security shim never reaches existing deployments.
371
+ const beforeOverrides = before.overrides ?? {};
372
+ const afterOverrides = after.overrides ?? {};
373
+ const allOverrides = new Set([...Object.keys(beforeOverrides), ...Object.keys(afterOverrides)]);
374
+ for (const name of [...allOverrides].sort()) {
375
+ const a = beforeOverrides[name];
376
+ const b = afterOverrides[name];
377
+ if (a && !b) lines.push(`${pc.red('-')} overrides.${name} ${pc.dim(a)}`);
378
+ else if (!a && b) lines.push(`${pc.green('+')} overrides.${name} ${pc.dim(b)}`);
379
+ else if (a !== b) lines.push(`${pc.yellow('~')} overrides.${name} ${pc.dim(a + ' → ' + b)}`);
380
+ }
381
+
266
382
  const beforeScripts = before.scripts ?? {};
267
383
  const afterScripts = after.scripts ?? {};
268
384
  const allScripts = new Set([...Object.keys(beforeScripts), ...Object.keys(afterScripts)]);
@@ -276,9 +392,9 @@ function diffPackageJson(before, after) {
276
392
  }
277
393
  }
278
394
 
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.
395
+ // Anything outside the canonical set is discarded by the rewrite — show it here
396
+ // too, or the operator confirms a change (e.g. losing devDependencies) they
397
+ // never saw.
282
398
  for (const key of Object.keys(before).sort()) {
283
399
  if (CANONICAL_FIELDS.has(key)) continue;
284
400
  lines.push(`${pc.red('-')} ${key} ${pc.dim(summarize(before[key]))}`);
@@ -307,6 +423,37 @@ export function detectDrift(pkgJson, dir) {
307
423
  if (!deps[name]) reasons.push(`${name} is missing`);
308
424
  }
309
425
 
426
+ // pm2 is the one dependency pinned exactly, so a version difference is drift
427
+ // rather than an operator preference. Presence alone was checked above and
428
+ // isn't enough: a deployment scaffolded against an older CLI keeps its old
429
+ // pm2 forever, which is how the pin reached a two-major gap unnoticed.
430
+ if (deps.pm2 && deps.pm2 !== DEPENDENCIES.pm2) {
431
+ reasons.push(`pm2 is pinned to ${deps.pm2} (current scaffold pins ${DEPENDENCIES.pm2})`);
432
+ }
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
+
448
+ // Overrides are security shims for vulnerable transitive deps — a
449
+ // deployment without them installs the vulnerable version on its next
450
+ // `npm install`, silently.
451
+ for (const [name, version] of Object.entries(OVERRIDES)) {
452
+ if (pkgJson?.overrides?.[name] !== version) {
453
+ reasons.push(`overrides.${name} is missing or outdated (security shim, expects ${version})`);
454
+ }
455
+ }
456
+
310
457
  if (dir) {
311
458
  const configPath = join(dir, 'selva.config.js');
312
459
  if (existsSync(configPath)) {
@@ -328,4 +475,26 @@ export function detectDrift(pkgJson, dir) {
328
475
  return reasons;
329
476
  }
330
477
 
478
+ // Null when the pin didn't change. Exported for its test — the wording is the
479
+ // operator's only pointer to finish the upgrade.
480
+ export function buildPm2UpgradeNotice(beforeVersion, afterVersion) {
481
+ if (!beforeVersion || beforeVersion === afterVersion) return null;
482
+ return [
483
+ `pm2 was upgraded ${beforeVersion} → ${afterVersion}, but the running pm2 daemon is`,
484
+ `still the old version. Finish the upgrade (brief restart of managed processes):`,
485
+ ``,
486
+ ` npx pm2 update`,
487
+ ` npx pm2 list # ${APP_NAME} must be listed and 'online' before the next step`,
488
+ ` npx pm2 save`,
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
+ ``,
495
+ `If doctor reports a pm2 outside this deployment (global or apt install), follow`,
496
+ `the full procedure in the docs instead: self-hosting → deployment → prerequisites.`
497
+ ].join('\n');
498
+ }
499
+
331
500
  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,