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

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,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,33 @@ 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
 
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
- */
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.
26
28
  export const NODE_MODULES_STASH = 'node_modules.selva-migrate-bak';
27
29
 
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
- */
30
+ // `deps` are seams for tests: the rollback path destroys and restores a real
31
+ // dependency tree, so it has to run against a real directory — but not against
32
+ // a real npm or pm2 daemon.
33
33
  export async function runMigrate(
34
34
  _argv,
35
35
  {
36
36
  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.
37
+ // --prefer-online: npm can otherwise resolve a cached packument that
38
+ // predates the release being migrated to.
39
39
  install = (cwd) => execSync('npm install --prefer-online', { cwd, stdio: 'pipe' }),
40
40
  confirm = (message) => p.confirm({ message, initialValue: true }),
41
- pm2 = runPm2
41
+ pm2 = runPm2,
42
+ resolveVersion = npmDistTagVersion
42
43
  } = {}
43
44
  ) {
44
45
  const dir = injectedDir ?? resolveDeploymentDir();
@@ -53,11 +54,9 @@ export async function runMigrate(
53
54
  }
54
55
 
55
56
  const before = JSON.parse(readFileSync(pkgPath, 'utf8'));
56
- const target = buildTargetPackageJson(before);
57
+ const { pkg: target, notes: pinNotes } = buildTargetPackageJson(before, resolveVersion);
57
58
  const pkgDiff = diffPackageJson(before, target);
58
59
 
59
- // Side-files: selva.config.js (now unused) and ecosystem.config.cjs
60
- // (must point at @selvajs/selva, not @selvajs/runtime).
61
60
  const configPath = join(dir, 'selva.config.js');
62
61
  const hasStaleConfig = existsSync(configPath);
63
62
 
@@ -65,9 +64,9 @@ export async function runMigrate(
65
64
  const ecoHasStaleRuntime =
66
65
  existsSync(ecoPath) && readFileSync(ecoPath, 'utf8').includes('@selvajs/runtime');
67
66
 
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.
67
+ // The server drops its read-old-name shim after one minor version, so
68
+ // rewriting now is what keeps a tuned value tuned instead of silently
69
+ // reverting to defaults later.
71
70
  const envPath = join(dir, '.env');
72
71
  const envBefore = existsSync(envPath) ? readFileSync(envPath, 'utf8') : null;
73
72
  const envRename = envBefore
@@ -100,15 +99,33 @@ export async function runMigrate(
100
99
  for (const line of pkgDiff) console.log(' ' + line);
101
100
  for (const line of sideFileChanges) console.log(' ' + line);
102
101
 
102
+ // The diff shows the pin that will be written; these explain why it is that
103
+ // pin. A preserved beta and a pin that couldn't be resolved both look like
104
+ // "no change" in the diff, and the second one is a stale pin, not a stable one.
105
+ for (const note of pinNotes) {
106
+ if (note.kind === 'prerelease') {
107
+ p.log.info(
108
+ `${note.name} stays on ${note.pin} — a prerelease pin is kept, since \`latest\` ` +
109
+ `means newest stable and would move this deployment backwards.`
110
+ );
111
+ } else {
112
+ p.log.warn(
113
+ `Could not resolve the current version of ${note.name} (${note.reason}). ` +
114
+ `Keeping ${JSON.stringify(note.pin ?? null)}; re-run migrate when the registry is reachable.`
115
+ );
116
+ }
117
+ }
118
+
103
119
  const confirmed = await confirm('Apply these changes, reinstall, and restart?');
104
120
  if (p.isCancel(confirmed) || !confirmed) {
105
121
  p.cancel('Cancelled.');
106
122
  return;
107
123
  }
108
124
 
109
- // Stop pm2 before npm rewrites (SvelteKit lazy-loads chunks; see update command).
125
+ // Stop pm2 before npm rewrites — SvelteKit lazy-loads chunks, so a rewrite
126
+ // under a running server serves a broken page (same reason `update` stops first).
110
127
  // 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.
128
+ // isn't fatal here: there's nothing running for us to stop.
112
129
  try {
113
130
  const stopStatus = pm2(dir, ['stop', APP_NAME], { inherit: false });
114
131
  if (stopStatus !== 0) {
@@ -118,7 +135,6 @@ export async function runMigrate(
118
135
  p.log.warn('No deployment-local pm2 to stop — continuing.');
119
136
  }
120
137
 
121
- // Back up for rollback on npm-install failure.
122
138
  const bakPath = pkgPath + '.bak';
123
139
  copyFileSync(pkgPath, bakPath);
124
140
  writeFileSync(pkgPath, JSON.stringify(target, null, 2) + '\n', 'utf8');
@@ -130,7 +146,7 @@ export async function runMigrate(
130
146
 
131
147
  if (ecoHasStaleRuntime) {
132
148
  copyFileSync(ecoPath, ecoPath + '.bak');
133
- // Rewrite @selvajs/runtime → @selvajs/selva only (preserve customizations).
149
+ // Text substitution, not regeneration, so any operator customizations to the file survive.
134
150
  const ecoContent = readFileSync(ecoPath, 'utf8').replace(
135
151
  /@selvajs\/runtime/g,
136
152
  '@selvajs/selva'
@@ -143,11 +159,11 @@ export async function runMigrate(
143
159
  writeFileSync(envPath, envRename.text, 'utf8');
144
160
  }
145
161
 
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.
162
+ // A legacy lockfile pins the old package set across a major bump, so this needs
163
+ // a clean install. But node_modules is also where pm2 lives, and rollback has to
164
+ // restart the app — deleting it outright would leave a failed migration with no
165
+ // way back up. Rename instead: atomic, keeps .bin symlinks intact, restorable
166
+ // until the install succeeds.
151
167
  const nodeModules = join(dir, 'node_modules');
152
168
  const nodeModulesBak = join(dir, NODE_MODULES_STASH);
153
169
  const lockPath = join(dir, 'package-lock.json');
@@ -176,8 +192,8 @@ export async function runMigrate(
176
192
  copyFileSync(envPath + '.bak', envPath);
177
193
  }
178
194
 
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.
195
+ // A half-installed node_modules from the failed attempt is worse than the old
196
+ // one, so clear it before restoring the stash.
181
197
  rmSync(nodeModules, { recursive: true, force: true });
182
198
  if (existsSync(nodeModulesBak)) renameSync(nodeModulesBak, nodeModules);
183
199
  if (lockBak !== null) writeFileSync(lockPath, lockBak, 'utf8');
@@ -187,6 +203,10 @@ export async function runMigrate(
187
203
  process.exit(1);
188
204
  }
189
205
 
206
+ // Migration itself already succeeded at this point, so a failed pm2 start is reported
207
+ // but doesn't exit non-zero or trigger rollback — the on-disk state is fine, only the
208
+ // running process isn't. Contrast restartAfterRollback, which does exit 1: that path
209
+ // runs after a rollback, where a failed restart leaves nothing serving traffic at all.
190
210
  let status;
191
211
  try {
192
212
  status = pm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
@@ -194,6 +214,16 @@ export async function runMigrate(
194
214
  status = 1;
195
215
  p.log.error(`Could not invoke pm2: ${err instanceof Error ? err.message : err}`);
196
216
  }
217
+ // A pin change means the just-installed pm2 CLI and the still-running
218
+ // daemon are now different versions. That's the repairable skew direction
219
+ // (daemon older), but it stays skewed until the operator acts — say so
220
+ // here, once, instead of letting doctor nag forever. Deliberately NOT
221
+ // automated: `pm2 update` recycles the daemon, and doing that unattended
222
+ // inside a migration converts a mid-flight failure into an outage (see
223
+ // plans/fixes/host-prerequisites-and-pm2-audit.md).
224
+ const pm2Notice = buildPm2UpgradeNotice(before.dependencies?.pm2, target.dependencies.pm2);
225
+ if (pm2Notice) p.log.warn(pm2Notice);
226
+
197
227
  const backupHints = ['package.json.bak'];
198
228
  if (hasStaleConfig) backupHints.push('selva.config.js.bak');
199
229
  if (ecoHasStaleRuntime) backupHints.push('ecosystem.config.cjs.bak');
@@ -212,14 +242,9 @@ export async function runMigrate(
212
242
  }
213
243
  }
214
244
 
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
- */
245
+ // Never throws: the migration is already aborting, and an exception here would
246
+ // replace the rollback's diagnosis with a stack trace. A failure to restart is
247
+ // the operator's problem to act on, so it gets logged, not swallowed.
223
248
  function restartAfterRollback(dir, pm2 = runPm2) {
224
249
  try {
225
250
  const status = pm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
@@ -239,12 +264,15 @@ function restartAfterRollback(dir, pm2 = runPm2) {
239
264
  }
240
265
  }
241
266
 
242
- function buildTargetPackageJson(current) {
243
- return buildDeploymentPackageJson({
267
+ function buildTargetPackageJson(current, resolveVersion = npmDistTagVersion) {
268
+ const { pins, notes } = resolveSelvaPins(current.dependencies, resolveVersion);
269
+ const pkg = buildDeploymentPackageJson({
244
270
  name: current.name ?? 'selva-deployment',
245
271
  version: current.version ?? '0.1.0',
246
- engines: current.engines
272
+ engines: current.engines,
273
+ dependencies: pins
247
274
  });
275
+ return { pkg, notes };
248
276
  }
249
277
 
250
278
  // Format package.json diff for confirmation prompt (concise, not full diff).
@@ -263,6 +291,19 @@ function diffPackageJson(before, after) {
263
291
  else if (a !== b) lines.push(`${pc.yellow('~')} ${name} ${pc.dim(a + ' → ' + b)}`);
264
292
  }
265
293
 
294
+ // Without this section, an overrides-only change reads as "already on the
295
+ // current layout" and the security shim never reaches existing deployments.
296
+ const beforeOverrides = before.overrides ?? {};
297
+ const afterOverrides = after.overrides ?? {};
298
+ const allOverrides = new Set([...Object.keys(beforeOverrides), ...Object.keys(afterOverrides)]);
299
+ for (const name of [...allOverrides].sort()) {
300
+ const a = beforeOverrides[name];
301
+ const b = afterOverrides[name];
302
+ if (a && !b) lines.push(`${pc.red('-')} overrides.${name} ${pc.dim(a)}`);
303
+ else if (!a && b) lines.push(`${pc.green('+')} overrides.${name} ${pc.dim(b)}`);
304
+ else if (a !== b) lines.push(`${pc.yellow('~')} overrides.${name} ${pc.dim(a + ' → ' + b)}`);
305
+ }
306
+
266
307
  const beforeScripts = before.scripts ?? {};
267
308
  const afterScripts = after.scripts ?? {};
268
309
  const allScripts = new Set([...Object.keys(beforeScripts), ...Object.keys(afterScripts)]);
@@ -276,9 +317,9 @@ function diffPackageJson(before, after) {
276
317
  }
277
318
  }
278
319
 
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.
320
+ // Anything outside the canonical set is discarded by the rewrite — show it here
321
+ // too, or the operator confirms a change (e.g. losing devDependencies) they
322
+ // never saw.
282
323
  for (const key of Object.keys(before).sort()) {
283
324
  if (CANONICAL_FIELDS.has(key)) continue;
284
325
  lines.push(`${pc.red('-')} ${key} ${pc.dim(summarize(before[key]))}`);
@@ -307,6 +348,37 @@ export function detectDrift(pkgJson, dir) {
307
348
  if (!deps[name]) reasons.push(`${name} is missing`);
308
349
  }
309
350
 
351
+ // pm2 is the one dependency pinned exactly, so a version difference is drift
352
+ // rather than an operator preference. Presence alone was checked above and
353
+ // isn't enough: a deployment scaffolded against an older CLI keeps its old
354
+ // pm2 forever, which is how the pin reached a two-major gap unnoticed.
355
+ if (deps.pm2 && deps.pm2 !== DEPENDENCIES.pm2) {
356
+ reasons.push(`pm2 is pinned to ${deps.pm2} (current scaffold pins ${DEPENDENCIES.pm2})`);
357
+ }
358
+
359
+ // A stored dist-tag re-resolves on every `npm install`, so the deployment
360
+ // follows the tag instead of a version an operator chose. Worse on a
361
+ // prerelease line, where `latest` points at the older stable release and the
362
+ // "upgrade" is a downgrade. Migrate wrote these before it learned to resolve
363
+ // tags, so existing deployments carry one and nothing else reports it.
364
+ for (const name of SELVA_PACKAGES) {
365
+ if (deps[name] && isFloatingPin(deps[name])) {
366
+ reasons.push(
367
+ `${name} is pinned to the floating tag ${JSON.stringify(deps[name])} ` +
368
+ `(re-resolves on every install; migrate writes a concrete version)`
369
+ );
370
+ }
371
+ }
372
+
373
+ // Overrides are security shims for vulnerable transitive deps — a
374
+ // deployment without them installs the vulnerable version on its next
375
+ // `npm install`, silently.
376
+ for (const [name, version] of Object.entries(OVERRIDES)) {
377
+ if (pkgJson?.overrides?.[name] !== version) {
378
+ reasons.push(`overrides.${name} is missing or outdated (security shim, expects ${version})`);
379
+ }
380
+ }
381
+
310
382
  if (dir) {
311
383
  const configPath = join(dir, 'selva.config.js');
312
384
  if (existsSync(configPath)) {
@@ -328,4 +400,26 @@ export function detectDrift(pkgJson, dir) {
328
400
  return reasons;
329
401
  }
330
402
 
403
+ // Null when the pin didn't change. Exported for its test — the wording is the
404
+ // operator's only pointer to finish the upgrade.
405
+ export function buildPm2UpgradeNotice(beforeVersion, afterVersion) {
406
+ if (!beforeVersion || beforeVersion === afterVersion) return null;
407
+ return [
408
+ `pm2 was upgraded ${beforeVersion} → ${afterVersion}, but the running pm2 daemon is`,
409
+ `still the old version. Finish the upgrade (brief restart of managed processes):`,
410
+ ``,
411
+ ` npx pm2 update`,
412
+ ` npx pm2 list # ${APP_NAME} must be listed and 'online' before the next step`,
413
+ ` npx pm2 save`,
414
+ ``,
415
+ `Do not run \`pm2 save\` until the list looks right. \`pm2 update\` empties the`,
416
+ `process table before restoring it, so saving a failed restore overwrites`,
417
+ `~/.pm2/dump.pm2 — the only record of what to bring back. If the list is empty,`,
418
+ `recover with \`npx pm2 resurrect\`, or \`npx pm2 start ecosystem.config.cjs\`.`,
419
+ ``,
420
+ `If doctor reports a pm2 outside this deployment (global or apt install), follow`,
421
+ `the full procedure in the docs instead: self-hosting → deployment → prerequisites.`
422
+ ].join('\n');
423
+ }
424
+
331
425
  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,
@@ -3,6 +3,11 @@
3
3
  // than in either command — two copies drifted once already, and the pm2 pin is
4
4
  // exactly the field where a silent difference costs the most.
5
5
 
6
+ import { readFileSync } from 'node:fs';
7
+ import { execSync } from 'node:child_process';
8
+ import { join, dirname } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
6
11
  /**
7
12
  * pm2 is pinned exactly, not caret-ranged.
8
13
  *
@@ -10,8 +15,29 @@
10
15
  * two deployments installed a week apart resolve differently, and the newer one
11
16
  * silently adopts the daemon the older one started — the skew `ensurePm2InSync`
12
17
  * has to detect and, in the daemon-is-newer direction, cannot repair (#118).
18
+ *
19
+ * Read from this package's own devDependencies rather than written here as a
20
+ * literal. Dependabot scans manifests, not source, so a literal is invisible to
21
+ * it — this pin sat on 5.4.3 while upstream reached 7.x, which is precisely the
22
+ * drift operators point at when they argue for distro packages instead.
23
+ * `checkPinIsExact` keeps the manifest entry from being loosened to a range.
13
24
  */
14
- const PM2_VERSION = '5.4.3';
25
+ const PM2_VERSION = readPinnedPm2Version();
26
+
27
+ function readPinnedPm2Version() {
28
+ const manifest = JSON.parse(
29
+ readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8')
30
+ );
31
+ const pinned = manifest.devDependencies?.pm2;
32
+ if (!pinned || !/^\d+\.\d+\.\d+$/.test(pinned)) {
33
+ throw new Error(
34
+ `@selvajs/cli devDependencies.pm2 must be an exact version, got ${JSON.stringify(pinned)}. ` +
35
+ `Every deployment this CLI scaffolds inherits it, and a range there lets two ` +
36
+ `installs resolve different pm2s that then fight over one daemon.`
37
+ );
38
+ }
39
+ return pinned;
40
+ }
15
41
 
16
42
  const SCRIPTS = {
17
43
  start: 'selva start',
@@ -26,6 +52,11 @@ const SCRIPTS = {
26
52
  * Packages a deployment carries. Providers are bundled into `@selvajs/selva`;
27
53
  * the CLI is a dependency so `npm run start` resolves the `selva` bin locally
28
54
  * instead of relying on a global install.
55
+ *
56
+ * The `latest` entries are what `create` resolves at scaffold time, never what
57
+ * gets written: `resolveSelvaPins` turns them into concrete versions. A stored
58
+ * `"latest"` re-resolves on every `npm install`, so the deployment follows the
59
+ * dist-tag forever and can change under the operator with no migrate at all.
29
60
  */
30
61
  const DEPENDENCIES = {
31
62
  '@selvajs/cli': 'latest',
@@ -33,6 +64,49 @@ const DEPENDENCIES = {
33
64
  pm2: PM2_VERSION
34
65
  };
35
66
 
67
+ /** The `@selvajs/*` packages whose pin belongs to the operator, not the scaffold. */
68
+ export const SELVA_PACKAGES = ['@selvajs/cli', '@selvajs/selva'];
69
+
70
+ /**
71
+ * True for a pin that names no version — a dist-tag (`latest`, `beta`) or the
72
+ * empty-ish values npm also accepts (`*`, `""`). These are the pins that move
73
+ * on their own, which is what makes them unfit to store.
74
+ */
75
+ export function isFloatingPin(pin) {
76
+ if (typeof pin !== 'string') return true;
77
+ const trimmed = pin.trim();
78
+ if (trimmed === '' || trimmed === '*' || trimmed === 'x') return true;
79
+ // A range still names versions; only a bare tag has no digits at all.
80
+ return !/\d/.test(trimmed);
81
+ }
82
+
83
+ /**
84
+ * True for a pin on a prerelease line (`^4.8.0-beta.11`).
85
+ *
86
+ * These are preserved across a migration. An operator on a beta channel chose
87
+ * it, and npm's `latest` tag points at the newest *stable* release — so
88
+ * resolving their pin through `latest` is a downgrade that reads as an upgrade
89
+ * in the confirmation diff.
90
+ */
91
+ export function isPrereleasePin(pin) {
92
+ return typeof pin === 'string' && /\d+\.\d+\.\d+-/.test(pin);
93
+ }
94
+
95
+ /**
96
+ * npm `overrides` forced onto every deployment — security patches for
97
+ * transitive deps whose direct parent hasn't picked up the fix yet. Each entry
98
+ * is a temporary shim: remove it once the parent ships the fixed version on
99
+ * its own (the override then matches what would install anyway, so removal is
100
+ * cleanup, never urgent).
101
+ *
102
+ * js-yaml: pm2 <= 7.0.3 pins 4.3.0, which carries the quadratic-complexity
103
+ * DoS advisories (GHSA-5p4m-2wfm-xmqj and friends); the fix landed in 4.3.1.
104
+ * Drop when pm2's own js-yaml dependency reaches >= 4.3.1.
105
+ */
106
+ export const OVERRIDES = {
107
+ 'js-yaml': '^4.3.1'
108
+ };
109
+
36
110
  /**
37
111
  * Legacy `@selvajs/*` packages that a deployment may still list. Each was
38
112
  * either renamed or folded into `@selvajs/selva`; `migrate` drops them and
@@ -60,20 +134,98 @@ export const LEGACY_DEPENDENCIES = {
60
134
  * deliberately, and dropping it removes a guard whose absence shows up only
61
135
  * under real traffic (issue #176). Everything else a migration discards is
62
136
  * listed by `diffPackageJson` before the operator confirms.
137
+ *
138
+ * `dependencies` is the second exception, for the same reason: a version pin is
139
+ * an operator choice too. `migrate` passes the resolved `@selvajs/*` pins in
140
+ * here; a fresh scaffold passes none and inherits the defaults.
63
141
  */
64
- export function buildDeploymentPackageJson({ name, version = '0.1.0', engines }) {
142
+ export function buildDeploymentPackageJson({ name, version = '0.1.0', engines, dependencies }) {
65
143
  const pkg = {
66
144
  name: sanitizePackageName(name),
67
145
  version,
68
146
  private: true,
69
147
  type: 'module',
70
148
  scripts: { ...SCRIPTS },
71
- dependencies: { ...DEPENDENCIES }
149
+ dependencies: { ...DEPENDENCIES, ...dependencies },
150
+ overrides: { ...OVERRIDES }
72
151
  };
73
152
  if (engines && typeof engines === 'object') pkg.engines = { ...engines };
74
153
  return pkg;
75
154
  }
76
155
 
156
+ /**
157
+ * Ask npm what a dist-tag currently points at. Kept beside the pin logic it
158
+ * serves, and injected rather than called directly so tests never hit the
159
+ * network.
160
+ */
161
+ export function npmDistTagVersion(name, tag) {
162
+ const out = execSync(`npm view ${name}@${tag} version`, {
163
+ encoding: 'utf8',
164
+ stdio: ['ignore', 'pipe', 'pipe']
165
+ });
166
+ const version = String(out).trim().split('\n').pop()?.trim();
167
+ if (!version || !/^\d+\.\d+\.\d+/.test(version)) {
168
+ throw new Error(`npm view returned no usable version for ${name}@${tag}`);
169
+ }
170
+ return version;
171
+ }
172
+
173
+ /**
174
+ * Decide the `@selvajs/*` pins a migration should write.
175
+ *
176
+ * Two rules, both aimed at the same failure: a migration silently moving a
177
+ * deployment to a version the operator didn't ask for.
178
+ *
179
+ * - A prerelease pin is kept as-is. `latest` means newest stable, so
180
+ * resolving a beta pin through it walks the deployment backwards.
181
+ * - Anything else resolves to a concrete version via `resolveVersion`, so
182
+ * `"latest"` never reaches disk and the diff can show what it resolved to.
183
+ *
184
+ * `resolveVersion` hits the network. When it fails — offline, registry down, a
185
+ * package with no such tag — the existing pin is kept and the reason returned,
186
+ * so migrate can report it rather than inventing a version or aborting a
187
+ * migration whose other half (layout, overrides, env renames) is still valid.
188
+ */
189
+ export function resolveSelvaPins(currentDeps, resolveVersion) {
190
+ const pins = {};
191
+ const notes = [];
192
+
193
+ for (const name of SELVA_PACKAGES) {
194
+ const current = currentDeps?.[name];
195
+
196
+ if (isPrereleasePin(current)) {
197
+ pins[name] = current;
198
+ notes.push({ name, kind: 'prerelease', pin: current });
199
+ continue;
200
+ }
201
+
202
+ let resolved = null;
203
+ try {
204
+ resolved = resolveVersion(name, 'latest');
205
+ } catch (err) {
206
+ notes.push({ name, kind: 'unresolved', pin: current, reason: err?.message ?? String(err) });
207
+ }
208
+
209
+ if (resolved) {
210
+ pins[name] = `^${resolved}`;
211
+ continue;
212
+ }
213
+
214
+ // Nothing resolvable and nothing worth keeping — fall back to the tag so
215
+ // the install still succeeds. Rare: offline *and* a floating pin.
216
+ if (isFloatingPin(current)) {
217
+ pins[name] = DEPENDENCIES[name];
218
+ if (!notes.some((n) => n.name === name)) {
219
+ notes.push({ name, kind: 'unresolved', pin: current, reason: 'no version returned' });
220
+ }
221
+ } else {
222
+ pins[name] = current;
223
+ }
224
+ }
225
+
226
+ return { pins, notes };
227
+ }
228
+
77
229
  /**
78
230
  * Top-level keys the canonical package.json defines. Anything outside this set
79
231
  * that an operator added is dropped by `migrate` — `diffPackageJson` reports
@@ -86,11 +238,11 @@ export const CANONICAL_FIELDS = new Set([
86
238
  'type',
87
239
  'scripts',
88
240
  'dependencies',
241
+ 'overrides',
89
242
  'engines'
90
243
  ]);
91
244
 
92
245
  export function sanitizePackageName(name) {
93
- // npm package names: lowercase, no spaces, limited punctuation.
94
246
  return (
95
247
  String(name ?? '')
96
248
  .toLowerCase()