@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.
@@ -1,20 +1,14 @@
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
- import { existsSync, readFileSync } from 'node:fs';
11
5
  import { join } from 'node:path';
12
6
  import * as p from '@clack/prompts';
13
7
  import pc from 'picocolors';
14
8
  import { collectConfig } from '../prompts.js';
15
9
  import { readEnvFile, writeEnvFile } from '../env.js';
16
10
  import { generateKey } from '../secrets.js';
17
- import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
11
+ import { readEnvTemplate, requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
18
12
 
19
13
  export async function runInit() {
20
14
  const dir = resolveDeploymentDir();
@@ -24,10 +18,10 @@ export async function runInit() {
24
18
  const current = readEnvFile(envPath);
25
19
  const values = await collectConfig({ defaults: current, mode: 'init' });
26
20
 
27
- // Preserve secrets. The only safe time to generate them is at install
28
- // time (no sessions to invalidate, no encrypted data to lose). If the
29
- // existing .env doesn't have them — which can happen if a previous
30
- // 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.
31
25
  if (
32
26
  current.SELVA_HMAC_KEY &&
33
27
  current.SELVA_HMAC_KEY !== 'replace-this-with-a-random-32-byte-hex-key'
@@ -48,24 +42,11 @@ export async function runInit() {
48
42
  p.log.warn('SELVA_AT_REST_KEY was missing — generated a fresh one.');
49
43
  }
50
44
 
51
- // Use whatever .env.example shipped with the installed runtime as the
52
- // canonical template. Falls back to the current .env if the runtime
53
- // templates aren't present (shouldn't happen post-install).
54
- const templatePath = join(
55
- dir,
56
- 'node_modules',
57
- '@selvajs',
58
- 'selva',
59
- 'templates',
60
- '.env.example'
61
- );
62
- const template = existsSync(templatePath)
63
- ? readFileSync(templatePath, 'utf8')
64
- : existsSync(envPath)
65
- ? readFileSync(envPath, 'utf8')
66
- : '';
67
-
68
- writeEnvFile(envPath, template, 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 });
69
50
 
70
51
  p.outro(
71
52
  [
@@ -1,12 +1,11 @@
1
1
  // Rotate SELVA_HMAC_KEY or SELVA_AT_REST_KEY; requires explicit confirm (blast radius in TARGETS).
2
2
 
3
- import { readFileSync, existsSync } from 'node:fs';
4
3
  import { join } from 'node:path';
5
4
  import * as p from '@clack/prompts';
6
5
  import pc from 'picocolors';
7
6
  import { readEnvFile, writeEnvFile } from '../env.js';
8
7
  import { generateKey } from '../secrets.js';
9
- import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
8
+ import { readEnvTemplate, requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
10
9
 
11
10
  const TARGETS = {
12
11
  hmac: {
@@ -57,19 +56,7 @@ export async function runKeysRotate(argv) {
57
56
  const fresh = generateKey();
58
57
  current[envVar] = fresh;
59
58
 
60
- const templatePath = join(
61
- dir,
62
- 'node_modules',
63
- '@selvajs',
64
- 'selva',
65
- 'templates',
66
- '.env.example'
67
- );
68
- const template = existsSync(templatePath)
69
- ? readFileSync(templatePath, 'utf8')
70
- : readFileSync(envPath, 'utf8');
71
-
72
- writeEnvFile(envPath, template, current);
59
+ writeEnvFile(envPath, readEnvTemplate(dir), current);
73
60
 
74
61
  p.outro(
75
62
  [
@@ -1,43 +1,116 @@
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
- import { existsSync, readFileSync, writeFileSync, copyFileSync, rmSync } from 'node:fs';
5
+ import { existsSync, readFileSync, writeFileSync, copyFileSync, rmSync, renameSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
- import { spawnSync, execSync } from 'node:child_process';
7
+ import { execSync } from 'node:child_process';
8
8
  import * as p from '@clack/prompts';
9
9
  import pc from 'picocolors';
10
10
  import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
11
11
  import { renameEnvKeys, RENAMED_ENV_VARS } from '../env.js';
12
+ import {
13
+ buildDeploymentPackageJson,
14
+ CANONICAL_FIELDS,
15
+ DEPENDENCIES,
16
+ isFloatingPin,
17
+ LEGACY_DEPENDENCIES,
18
+ npmDistTagVersion,
19
+ OVERRIDES,
20
+ resolveSelvaPins,
21
+ SELVA_PACKAGES
22
+ } from '../deployment-package.js';
23
+ import { APP_NAME, runPm2 } from './pm2.js';
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
+
34
+ /**
35
+ * Colons are illegal in Windows filenames, so this is ISO with `:` swapped for
36
+ * `-` and the fractional seconds dropped: 2026-08-10T05-43-12.
37
+ */
38
+ export function backupStamp(now = new Date()) {
39
+ return now
40
+ .toISOString()
41
+ .replace(/\.\d+Z$/, '')
42
+ .replace(/:/g, '-');
43
+ }
44
+
45
+ /**
46
+ * A migration used to write `package.json.bak` at a fixed path, so the second
47
+ * substantive migration overwrote the first's copy — and since each run backs
48
+ * up the *current* file, that left the operator's original config recoverable
49
+ * from nowhere (#184). One stamp per run keeps a whole migration's backups
50
+ * together and makes them sortable.
51
+ *
52
+ * Not auto-pruned here: deleting an operator's only copy of their old config is
53
+ * worse than a few small files accumulating. `selva doctor` reports aged ones
54
+ * with a `--fix`, which keeps the decision theirs.
55
+ */
56
+ export function backupPathFor(filePath, stamp) {
57
+ return `${filePath}.${stamp}.bak`;
58
+ }
59
+
60
+ // Old enough that the migration which wrote it is long settled.
61
+ export const BACKUP_AGE_DAYS = 30;
62
+
63
+ /**
64
+ * Split migration backups into what to keep and what `doctor --fix` may delete.
65
+ *
66
+ * The newest run's backups are always kept, however old they are — on a
67
+ * deployment that migrates once a year, age alone would delete the only copy of
68
+ * the pre-migration config, which is the whole point of keeping them. So the
69
+ * rule is: keep the newest stamp, offer to delete stamps older than
70
+ * `BACKUP_AGE_DAYS`.
71
+ *
72
+ * `names` are basenames; the stamp is read from the filename rather than mtime,
73
+ * which a copy or restore would reset.
74
+ */
75
+ export function classifyBackups(names, now = new Date(), maxAgeDays = BACKUP_AGE_DAYS) {
76
+ const found = [];
77
+ for (const name of names) {
78
+ const m = BACKUP_PATTERN.exec(name);
79
+ if (m) found.push({ name, stamp: m[1] });
80
+ }
81
+ if (found.length === 0) return { keep: [], aged: [], newest: null };
82
+
83
+ // Stamps are fixed-width and zero-padded, so lexical order is chronological.
84
+ const newest = found.reduce((a, b) => (b.stamp > a.stamp ? b : a)).stamp;
85
+ const cutoff = now.getTime() - maxAgeDays * 24 * 60 * 60 * 1000;
86
+
87
+ const keep = [];
88
+ const aged = [];
89
+ for (const entry of found) {
90
+ // `2026-08-10T05-43-12` → parseable by restoring the colons.
91
+ const at = Date.parse(entry.stamp.replace(/T(\d{2})-(\d{2})-(\d{2})$/, 'T$1:$2:$3') + 'Z');
92
+ if (entry.stamp === newest || !Number.isFinite(at) || at >= cutoff) keep.push(entry.name);
93
+ else aged.push(entry.name);
94
+ }
95
+ return { keep, aged, newest };
96
+ }
12
97
 
13
- const APP_NAME = 'selva-compute';
14
-
15
- // Packages we own. Everything in this set is rewritten wholesale by migrate;
16
- // anything outside it (operator's own deps) gets dropped, which is the
17
- // explicit design choice — operators with custom deps should fork the
18
- // template rather than hand-patch the deployment package.json.
19
- const SELVA_DEPS = new Set([
20
- '@selvajs/cli',
21
- '@selvajs/selva',
22
- '@selvajs/runtime', // legacy — removed during migrate
23
- '@selvajs/platform', // legacy — bundled into @selvajs/selva
24
- '@selvajs/local-provider', // legacy — bundled into @selvajs/selva
25
- '@selvajs/supabase-provider', // legacy — bundled into @selvajs/selva
26
- '@selvajs/header-auth-provider', // legacy — bundled into @selvajs/selva
27
- '@selvajs/create' // legacy CLI — removed during migrate
28
- ]);
29
-
30
- const CANONICAL_SCRIPTS = {
31
- start: 'selva start',
32
- stop: 'selva stop',
33
- restart: 'selva restart',
34
- logs: 'selva logs',
35
- doctor: 'selva doctor',
36
- update: 'selva update'
37
- };
38
-
39
- export async function runMigrate() {
40
- const dir = resolveDeploymentDir();
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.
101
+ export async function runMigrate(
102
+ _argv,
103
+ {
104
+ dir: injectedDir,
105
+ // --prefer-online: npm can otherwise resolve a cached packument that
106
+ // predates the release being migrated to.
107
+ install = (cwd) => execSync('npm install --prefer-online', { cwd, stdio: 'pipe' }),
108
+ confirm = (message) => p.confirm({ message, initialValue: true }),
109
+ pm2 = runPm2,
110
+ resolveVersion = npmDistTagVersion
111
+ } = {}
112
+ ) {
113
+ const dir = injectedDir ?? resolveDeploymentDir();
41
114
  requireDeploymentDir(dir);
42
115
 
43
116
  p.intro(pc.bgCyan(pc.black(' selva migrate ')));
@@ -49,11 +122,9 @@ export async function runMigrate() {
49
122
  }
50
123
 
51
124
  const before = JSON.parse(readFileSync(pkgPath, 'utf8'));
52
- const target = buildTargetPackageJson(before);
125
+ const { pkg: target, notes: pinNotes } = buildTargetPackageJson(before, resolveVersion);
53
126
  const pkgDiff = diffPackageJson(before, target);
54
127
 
55
- // Side-files: selva.config.js (now unused) and ecosystem.config.cjs
56
- // (must point at @selvajs/selva, not @selvajs/runtime).
57
128
  const configPath = join(dir, 'selva.config.js');
58
129
  const hasStaleConfig = existsSync(configPath);
59
130
 
@@ -61,9 +132,9 @@ export async function runMigrate() {
61
132
  const ecoHasStaleRuntime =
62
133
  existsSync(ecoPath) && readFileSync(ecoPath, 'utf8').includes('@selvajs/runtime');
63
134
 
64
- // Deprecated env keys. The server reads the old names for one more minor
65
- // version, so this is a warning today and a silent fallback to defaults once
66
- // 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.
67
138
  const envPath = join(dir, '.env');
68
139
  const envBefore = existsSync(envPath) ? readFileSync(envPath, 'utf8') : null;
69
140
  const envRename = envBefore
@@ -96,34 +167,61 @@ export async function runMigrate() {
96
167
  for (const line of pkgDiff) console.log(' ' + line);
97
168
  for (const line of sideFileChanges) console.log(' ' + line);
98
169
 
99
- const confirmed = await p.confirm({
100
- message: 'Apply these changes, reinstall, and restart?',
101
- initialValue: true
102
- });
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
+
187
+ const confirmed = await confirm('Apply these changes, reinstall, and restart?');
103
188
  if (p.isCancel(confirmed) || !confirmed) {
104
189
  p.cancel('Cancelled.');
105
190
  return;
106
191
  }
107
192
 
108
- // Stop pm2 before npm rewrites (SvelteKit lazy-loads chunks; see update command).
109
- const stopStatus = runPm2(dir, ['stop', APP_NAME], { inherit: false });
110
- if (stopStatus !== 0) {
111
- p.log.warn('pm2 stop did not succeed — selva-compute may not be running. Continuing.');
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).
195
+ // A legacy deployment may predate the local-pm2 layout, so a missing binary
196
+ // isn't fatal here: there's nothing running for us to stop.
197
+ try {
198
+ const stopStatus = pm2(dir, ['stop', APP_NAME], { inherit: false });
199
+ if (stopStatus !== 0) {
200
+ p.log.warn('pm2 stop did not succeed — selva-compute may not be running. Continuing.');
201
+ }
202
+ } catch {
203
+ p.log.warn('No deployment-local pm2 to stop — continuing.');
112
204
  }
113
205
 
114
- // Back up for rollback on npm-install failure.
115
- 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
+
116
214
  copyFileSync(pkgPath, bakPath);
117
215
  writeFileSync(pkgPath, JSON.stringify(target, null, 2) + '\n', 'utf8');
118
216
 
119
217
  if (hasStaleConfig) {
120
- copyFileSync(configPath, configPath + '.bak');
218
+ copyFileSync(configPath, configBak);
121
219
  rmSync(configPath, { force: true });
122
220
  }
123
221
 
124
222
  if (ecoHasStaleRuntime) {
125
- copyFileSync(ecoPath, ecoPath + '.bak');
126
- // 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.
127
225
  const ecoContent = readFileSync(ecoPath, 'utf8').replace(
128
226
  /@selvajs\/runtime/g,
129
227
  '@selvajs/selva'
@@ -132,47 +230,79 @@ export async function runMigrate() {
132
230
  }
133
231
 
134
232
  if (envRename.changes.length > 0) {
135
- copyFileSync(envPath, envPath + '.bak');
233
+ copyFileSync(envPath, envBak);
136
234
  writeFileSync(envPath, envRename.text, 'utf8');
137
235
  }
138
236
 
139
- // Clean install required (major version changes break legacy lockfile).
140
- rmSync(join(dir, 'node_modules'), { recursive: true, force: true });
141
- rmSync(join(dir, 'package-lock.json'), { force: true });
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.
242
+ const nodeModules = join(dir, 'node_modules');
243
+ const nodeModulesBak = join(dir, NODE_MODULES_STASH);
244
+ const lockPath = join(dir, 'package-lock.json');
245
+
246
+ rmSync(nodeModulesBak, { recursive: true, force: true });
247
+ if (existsSync(nodeModules)) renameSync(nodeModules, nodeModulesBak);
248
+ const lockBak = existsSync(lockPath) ? readFileSync(lockPath, 'utf8') : null;
249
+ rmSync(lockPath, { force: true });
142
250
 
143
251
  const s = p.spinner();
144
252
  s.start('Installing new dependencies (this can take a minute)');
145
253
  try {
146
- // --prefer-online to bypass the stale-packument trap documented in
147
- // docs/Hotfix-CLI-Runtime.md.
148
- execSync('npm install --prefer-online', {
149
- cwd: dir,
150
- stdio: 'pipe'
151
- });
254
+ install(dir);
152
255
  s.stop('Dependencies installed');
256
+ rmSync(nodeModulesBak, { recursive: true, force: true });
153
257
  } catch (err) {
154
- s.stop(pc.red('npm install failed — rolling back package.json'));
258
+ s.stop(pc.red('npm install failed — rolling back'));
155
259
  copyFileSync(bakPath, pkgPath);
156
- if (hasStaleConfig && existsSync(configPath + '.bak')) {
157
- copyFileSync(configPath + '.bak', configPath);
260
+ if (hasStaleConfig && existsSync(configBak)) {
261
+ copyFileSync(configBak, configPath);
158
262
  }
159
- if (ecoHasStaleRuntime && existsSync(ecoPath + '.bak')) {
160
- copyFileSync(ecoPath + '.bak', ecoPath);
263
+ if (ecoHasStaleRuntime && existsSync(ecoBak)) {
264
+ copyFileSync(ecoBak, ecoPath);
161
265
  }
162
- if (envRename.changes.length > 0 && existsSync(envPath + '.bak')) {
163
- copyFileSync(envPath + '.bak', envPath);
266
+ if (envRename.changes.length > 0 && existsSync(envBak)) {
267
+ copyFileSync(envBak, envPath);
164
268
  }
165
- // Try to bring the process back up (best-effort; at least package.json is consistent).
166
- runPm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
269
+
270
+ // A half-installed node_modules from the failed attempt is worse than the old
271
+ // one, so clear it before restoring the stash.
272
+ rmSync(nodeModules, { recursive: true, force: true });
273
+ if (existsSync(nodeModulesBak)) renameSync(nodeModulesBak, nodeModules);
274
+ if (lockBak !== null) writeFileSync(lockPath, lockBak, 'utf8');
275
+
276
+ restartAfterRollback(dir, pm2);
167
277
  p.outro(pc.red(`Migration aborted: ${err.message ?? err}`));
168
278
  process.exit(1);
169
279
  }
170
280
 
171
- const status = runPm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
172
- const backupHints = ['package.json.bak'];
173
- if (hasStaleConfig) backupHints.push('selva.config.js.bak');
174
- if (ecoHasStaleRuntime) backupHints.push('ecosystem.config.cjs.bak');
175
- if (envRename.changes.length > 0) backupHints.push('.env.bak');
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.
285
+ let status;
286
+ try {
287
+ status = pm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
288
+ } catch (err) {
289
+ status = 1;
290
+ p.log.error(`Could not invoke pm2: ${err instanceof Error ? err.message : err}`);
291
+ }
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`);
176
306
 
177
307
  if (status === 0) {
178
308
  p.outro(
@@ -187,23 +317,37 @@ export async function runMigrate() {
187
317
  }
188
318
  }
189
319
 
190
- // Build target package.json with canonical dependencies (wholesale replace).
191
- // Drops non-canonical @selvajs/* and operator's own deps (by design).
192
- function buildTargetPackageJson(current) {
193
- const deps = {
194
- '@selvajs/cli': 'latest',
195
- '@selvajs/selva': 'latest',
196
- pm2: '^5.4.0'
197
- };
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.
323
+ function restartAfterRollback(dir, pm2 = runPm2) {
324
+ try {
325
+ const status = pm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
326
+ if (status === 0) {
327
+ p.log.success(`Rolled back and restarted ${APP_NAME}.`);
328
+ return;
329
+ }
330
+ p.log.error(
331
+ `Rolled back, but \`pm2 start\` exited ${status}. The app may be down — ` +
332
+ `check \`pm2 logs ${APP_NAME}\` and start it with \`selva start\`.`
333
+ );
334
+ } catch (err) {
335
+ p.log.error(
336
+ `Rolled back, but pm2 could not be invoked (${err instanceof Error ? err.message : err}). ` +
337
+ `The app is likely down — run \`npm install\` then \`selva start\`.`
338
+ );
339
+ }
340
+ }
198
341
 
199
- return {
342
+ function buildTargetPackageJson(current, resolveVersion = npmDistTagVersion) {
343
+ const { pins, notes } = resolveSelvaPins(current.dependencies, resolveVersion);
344
+ const pkg = buildDeploymentPackageJson({
200
345
  name: current.name ?? 'selva-deployment',
201
346
  version: current.version ?? '0.1.0',
202
- private: true,
203
- type: 'module',
204
- scripts: { ...CANONICAL_SCRIPTS },
205
- dependencies: deps
206
- };
347
+ engines: current.engines,
348
+ dependencies: pins
349
+ });
350
+ return { pkg, notes };
207
351
  }
208
352
 
209
353
  // Format package.json diff for confirmation prompt (concise, not full diff).
@@ -222,6 +366,19 @@ function diffPackageJson(before, after) {
222
366
  else if (a !== b) lines.push(`${pc.yellow('~')} ${name} ${pc.dim(a + ' → ' + b)}`);
223
367
  }
224
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
+
225
382
  const beforeScripts = before.scripts ?? {};
226
383
  const afterScripts = after.scripts ?? {};
227
384
  const allScripts = new Set([...Object.keys(beforeScripts), ...Object.keys(afterScripts)]);
@@ -235,39 +392,67 @@ function diffPackageJson(before, after) {
235
392
  }
236
393
  }
237
394
 
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.
398
+ for (const key of Object.keys(before).sort()) {
399
+ if (CANONICAL_FIELDS.has(key)) continue;
400
+ lines.push(`${pc.red('-')} ${key} ${pc.dim(summarize(before[key]))}`);
401
+ }
402
+
238
403
  return lines;
239
404
  }
240
405
 
241
- // Local pm2 runner (avoid circular dep; duplication is small).
242
- function runPm2(dir, args, { inherit = true } = {}) {
243
- const local = join(dir, 'node_modules', '.bin', process.platform === 'win32' ? 'pm2.cmd' : 'pm2');
244
- const bin = existsSync(local) ? local : 'pm2';
245
- const result = spawnSync(bin, args, {
246
- cwd: dir,
247
- stdio: inherit ? 'inherit' : 'pipe',
248
- shell: process.platform === 'win32'
249
- });
250
- if (result.error) return 1;
251
- return result.status ?? 0;
406
+ // One-line preview of a dropped field, enough to recognise what is being lost.
407
+ function summarize(value) {
408
+ if (value === null || typeof value !== 'object') return String(value);
409
+ const keys = Object.keys(value);
410
+ if (Array.isArray(value)) return `[${keys.length} item${keys.length === 1 ? '' : 's'}]`;
411
+ return keys.length ? `{${keys.join(', ')}}` : '{}';
252
412
  }
253
413
 
254
414
  // Exported for `selva doctor` to check layout drift without duplication.
255
415
  export function detectDrift(pkgJson, dir) {
256
416
  const deps = pkgJson?.dependencies ?? {};
257
417
  const reasons = [];
258
- if (deps['@selvajs/runtime']) reasons.push('@selvajs/runtime is the old runtime package');
259
- if (deps['@selvajs/create']) reasons.push('@selvajs/create is the old CLI package');
260
- if (deps['@selvajs/platform'])
261
- reasons.push('@selvajs/platform is now bundled into @selvajs/selva');
262
- if (deps['@selvajs/local-provider'])
263
- reasons.push('@selvajs/local-provider is now bundled into @selvajs/selva');
264
- if (deps['@selvajs/supabase-provider'])
265
- reasons.push('@selvajs/supabase-provider is now bundled into @selvajs/selva');
266
- if (deps['@selvajs/header-auth-provider'])
267
- reasons.push('@selvajs/header-auth-provider is now bundled into @selvajs/selva');
268
- if (!deps['@selvajs/selva']) reasons.push('@selvajs/selva is missing');
269
- if (!deps['@selvajs/cli']) reasons.push('@selvajs/cli is missing');
270
- if (!deps['pm2']) reasons.push('pm2 is not in dependencies');
418
+
419
+ for (const [name, why] of Object.entries(LEGACY_DEPENDENCIES)) {
420
+ if (deps[name]) reasons.push(`${name} is ${why}`);
421
+ }
422
+ for (const name of Object.keys(DEPENDENCIES)) {
423
+ if (!deps[name]) reasons.push(`${name} is missing`);
424
+ }
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
+ }
271
456
 
272
457
  if (dir) {
273
458
  const configPath = join(dir, 'selva.config.js');
@@ -290,5 +475,26 @@ export function detectDrift(pkgJson, dir) {
290
475
  return reasons;
291
476
  }
292
477
 
293
- // Re-exported for tests to verify without running migrate.
294
- export { buildTargetPackageJson, SELVA_DEPS };
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
+
500
+ export { buildTargetPackageJson, diffPackageJson };