@selvajs/cli 4.7.4-beta.1 → 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.
@@ -1,48 +1,43 @@
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 {
6
- existsSync,
7
- readFileSync,
8
- writeFileSync,
9
- copyFileSync,
10
- rmSync
11
- } from 'node:fs';
5
+ import { existsSync, readFileSync, writeFileSync, copyFileSync, rmSync, renameSync } from 'node:fs';
12
6
  import { join } from 'node:path';
13
- import { spawnSync, execSync } from 'node:child_process';
7
+ import { execSync } from 'node:child_process';
14
8
  import * as p from '@clack/prompts';
15
9
  import pc from 'picocolors';
16
10
  import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
17
-
18
- const APP_NAME = 'selva-compute';
19
-
20
- // Packages we own. Everything in this set is rewritten wholesale by migrate;
21
- // anything outside it (operator's own deps) gets dropped, which is the
22
- // explicit design choice — operators with custom deps should fork the
23
- // template rather than hand-patch the deployment package.json.
24
- const SELVA_DEPS = new Set([
25
- '@selvajs/cli',
26
- '@selvajs/selva',
27
- '@selvajs/runtime', // legacy — removed during migrate
28
- '@selvajs/platform', // legacy — bundled into @selvajs/selva
29
- '@selvajs/local-provider', // legacy — bundled into @selvajs/selva
30
- '@selvajs/supabase-provider', // legacy — bundled into @selvajs/selva
31
- '@selvajs/header-auth-provider', // legacy — bundled into @selvajs/selva
32
- '@selvajs/create' // legacy CLI — removed during migrate
33
- ]);
34
-
35
- const CANONICAL_SCRIPTS = {
36
- start: 'selva start',
37
- stop: 'selva stop',
38
- restart: 'selva restart',
39
- logs: 'selva logs',
40
- doctor: 'selva doctor',
41
- update: 'selva update'
42
- };
43
-
44
- export async function runMigrate() {
45
- const dir = resolveDeploymentDir();
11
+ import { renameEnvKeys, RENAMED_ENV_VARS } from '../env.js';
12
+ import {
13
+ buildDeploymentPackageJson,
14
+ CANONICAL_FIELDS,
15
+ DEPENDENCIES,
16
+ LEGACY_DEPENDENCIES,
17
+ OVERRIDES
18
+ } from '../deployment-package.js';
19
+ import { APP_NAME, runPm2 } from './pm2.js';
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; `selva doctor`
23
+ // flags a surviving stash as an interrupted migration.
24
+ export const NODE_MODULES_STASH = 'node_modules.selva-migrate-bak';
25
+
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.
29
+ export async function runMigrate(
30
+ _argv,
31
+ {
32
+ dir: injectedDir,
33
+ // --prefer-online: npm can otherwise resolve a cached packument that
34
+ // predates the release being migrated to.
35
+ install = (cwd) => execSync('npm install --prefer-online', { cwd, stdio: 'pipe' }),
36
+ confirm = (message) => p.confirm({ message, initialValue: true }),
37
+ pm2 = runPm2
38
+ } = {}
39
+ ) {
40
+ const dir = injectedDir ?? resolveDeploymentDir();
46
41
  requireDeploymentDir(dir);
47
42
 
48
43
  p.intro(pc.bgCyan(pc.black(' selva migrate ')));
@@ -57,8 +52,6 @@ export async function runMigrate() {
57
52
  const target = buildTargetPackageJson(before);
58
53
  const pkgDiff = diffPackageJson(before, target);
59
54
 
60
- // Side-files: selva.config.js (now unused) and ecosystem.config.cjs
61
- // (must point at @selvajs/selva, not @selvajs/runtime).
62
55
  const configPath = join(dir, 'selva.config.js');
63
56
  const hasStaleConfig = existsSync(configPath);
64
57
 
@@ -66,9 +59,31 @@ export async function runMigrate() {
66
59
  const ecoHasStaleRuntime =
67
60
  existsSync(ecoPath) && readFileSync(ecoPath, 'utf8').includes('@selvajs/runtime');
68
61
 
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.
65
+ const envPath = join(dir, '.env');
66
+ const envBefore = existsSync(envPath) ? readFileSync(envPath, 'utf8') : null;
67
+ const envRename = envBefore
68
+ ? renameEnvKeys(envBefore, RENAMED_ENV_VARS)
69
+ : { text: '', changes: [] };
70
+
69
71
  const sideFileChanges = [];
70
- if (hasStaleConfig) sideFileChanges.push(`${pc.red('-')} selva.config.js ${pc.dim('(no longer needed; providers are env-driven)')}`);
71
- if (ecoHasStaleRuntime) sideFileChanges.push(`${pc.yellow('~')} ecosystem.config.cjs ${pc.dim('(rewrite: @selvajs/runtime → @selvajs/selva)')}`);
72
+ if (hasStaleConfig)
73
+ sideFileChanges.push(
74
+ `${pc.red('-')} selva.config.js ${pc.dim('(no longer needed; providers are env-driven)')}`
75
+ );
76
+ if (ecoHasStaleRuntime)
77
+ sideFileChanges.push(
78
+ `${pc.yellow('~')} ecosystem.config.cjs ${pc.dim('(rewrite: @selvajs/runtime → @selvajs/selva)')}`
79
+ );
80
+ for (const [oldName, newName, action] of envRename.changes) {
81
+ sideFileChanges.push(
82
+ action === 'dropped'
83
+ ? `${pc.red('-')} .env ${pc.dim(`${oldName} (deprecated; ${newName} is already set)`)}`
84
+ : `${pc.yellow('~')} .env ${pc.dim(`${oldName} → ${newName}`)}`
85
+ );
86
+ }
72
87
 
73
88
  if (pkgDiff.length === 0 && sideFileChanges.length === 0) {
74
89
  p.outro(pc.green('Already on the current layout — nothing to migrate.'));
@@ -79,22 +94,25 @@ export async function runMigrate() {
79
94
  for (const line of pkgDiff) console.log(' ' + line);
80
95
  for (const line of sideFileChanges) console.log(' ' + line);
81
96
 
82
- const confirmed = await p.confirm({
83
- message: 'Apply these changes, reinstall, and restart?',
84
- initialValue: true
85
- });
97
+ const confirmed = await confirm('Apply these changes, reinstall, and restart?');
86
98
  if (p.isCancel(confirmed) || !confirmed) {
87
99
  p.cancel('Cancelled.');
88
100
  return;
89
101
  }
90
102
 
91
- // Stop pm2 before npm rewrites (SvelteKit lazy-loads chunks; see update command).
92
- const stopStatus = runPm2(dir, ['stop', APP_NAME], { inherit: false });
93
- if (stopStatus !== 0) {
94
- p.log.warn('pm2 stop did not succeed — selva-compute may not be running. Continuing.');
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).
105
+ // A legacy deployment may predate the local-pm2 layout, so a missing binary
106
+ // isn't fatal here: there's nothing running for us to stop.
107
+ try {
108
+ const stopStatus = pm2(dir, ['stop', APP_NAME], { inherit: false });
109
+ if (stopStatus !== 0) {
110
+ p.log.warn('pm2 stop did not succeed — selva-compute may not be running. Continuing.');
111
+ }
112
+ } catch {
113
+ p.log.warn('No deployment-local pm2 to stop — continuing.');
95
114
  }
96
115
 
97
- // Back up for rollback on npm-install failure.
98
116
  const bakPath = pkgPath + '.bak';
99
117
  copyFileSync(pkgPath, bakPath);
100
118
  writeFileSync(pkgPath, JSON.stringify(target, null, 2) + '\n', 'utf8');
@@ -106,7 +124,7 @@ export async function runMigrate() {
106
124
 
107
125
  if (ecoHasStaleRuntime) {
108
126
  copyFileSync(ecoPath, ecoPath + '.bak');
109
- // Rewrite @selvajs/runtime → @selvajs/selva only (preserve customizations).
127
+ // Text substitution, not regeneration, so any operator customizations to the file survive.
110
128
  const ecoContent = readFileSync(ecoPath, 'utf8').replace(
111
129
  /@selvajs\/runtime/g,
112
130
  '@selvajs/selva'
@@ -114,22 +132,33 @@ export async function runMigrate() {
114
132
  writeFileSync(ecoPath, ecoContent, 'utf8');
115
133
  }
116
134
 
117
- // Clean install required (major version changes break legacy lockfile).
118
- rmSync(join(dir, 'node_modules'), { recursive: true, force: true });
119
- rmSync(join(dir, 'package-lock.json'), { force: true });
135
+ if (envRename.changes.length > 0) {
136
+ copyFileSync(envPath, envPath + '.bak');
137
+ writeFileSync(envPath, envRename.text, 'utf8');
138
+ }
139
+
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.
145
+ const nodeModules = join(dir, 'node_modules');
146
+ const nodeModulesBak = join(dir, NODE_MODULES_STASH);
147
+ const lockPath = join(dir, 'package-lock.json');
148
+
149
+ rmSync(nodeModulesBak, { recursive: true, force: true });
150
+ if (existsSync(nodeModules)) renameSync(nodeModules, nodeModulesBak);
151
+ const lockBak = existsSync(lockPath) ? readFileSync(lockPath, 'utf8') : null;
152
+ rmSync(lockPath, { force: true });
120
153
 
121
154
  const s = p.spinner();
122
155
  s.start('Installing new dependencies (this can take a minute)');
123
156
  try {
124
- // --prefer-online to bypass the stale-packument trap documented in
125
- // docs/Hotfix-CLI-Runtime.md.
126
- execSync('npm install --prefer-online', {
127
- cwd: dir,
128
- stdio: 'pipe'
129
- });
157
+ install(dir);
130
158
  s.stop('Dependencies installed');
159
+ rmSync(nodeModulesBak, { recursive: true, force: true });
131
160
  } catch (err) {
132
- s.stop(pc.red('npm install failed — rolling back package.json'));
161
+ s.stop(pc.red('npm install failed — rolling back'));
133
162
  copyFileSync(bakPath, pkgPath);
134
163
  if (hasStaleConfig && existsSync(configPath + '.bak')) {
135
164
  copyFileSync(configPath + '.bak', configPath);
@@ -137,16 +166,46 @@ export async function runMigrate() {
137
166
  if (ecoHasStaleRuntime && existsSync(ecoPath + '.bak')) {
138
167
  copyFileSync(ecoPath + '.bak', ecoPath);
139
168
  }
140
- // Try to bring the process back up (best-effort; at least package.json is consistent).
141
- runPm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
169
+ if (envRename.changes.length > 0 && existsSync(envPath + '.bak')) {
170
+ copyFileSync(envPath + '.bak', envPath);
171
+ }
172
+
173
+ // A half-installed node_modules from the failed attempt is worse than the old
174
+ // one, so clear it before restoring the stash.
175
+ rmSync(nodeModules, { recursive: true, force: true });
176
+ if (existsSync(nodeModulesBak)) renameSync(nodeModulesBak, nodeModules);
177
+ if (lockBak !== null) writeFileSync(lockPath, lockBak, 'utf8');
178
+
179
+ restartAfterRollback(dir, pm2);
142
180
  p.outro(pc.red(`Migration aborted: ${err.message ?? err}`));
143
181
  process.exit(1);
144
182
  }
145
183
 
146
- const status = runPm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
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.
188
+ let status;
189
+ try {
190
+ status = pm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
191
+ } catch (err) {
192
+ status = 1;
193
+ p.log.error(`Could not invoke pm2: ${err instanceof Error ? err.message : err}`);
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
+
147
205
  const backupHints = ['package.json.bak'];
148
206
  if (hasStaleConfig) backupHints.push('selva.config.js.bak');
149
207
  if (ecoHasStaleRuntime) backupHints.push('ecosystem.config.cjs.bak');
208
+ if (envRename.changes.length > 0) backupHints.push('.env.bak');
150
209
 
151
210
  if (status === 0) {
152
211
  p.outro(
@@ -161,23 +220,34 @@ export async function runMigrate() {
161
220
  }
162
221
  }
163
222
 
164
- // Build target package.json with canonical dependencies (wholesale replace).
165
- // Drops non-canonical @selvajs/* and operator's own deps (by design).
166
- function buildTargetPackageJson(current) {
167
- const deps = {
168
- '@selvajs/cli': 'latest',
169
- '@selvajs/selva': 'latest',
170
- pm2: '^5.4.0'
171
- };
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.
226
+ function restartAfterRollback(dir, pm2 = runPm2) {
227
+ try {
228
+ const status = pm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
229
+ if (status === 0) {
230
+ p.log.success(`Rolled back and restarted ${APP_NAME}.`);
231
+ return;
232
+ }
233
+ p.log.error(
234
+ `Rolled back, but \`pm2 start\` exited ${status}. The app may be down — ` +
235
+ `check \`pm2 logs ${APP_NAME}\` and start it with \`selva start\`.`
236
+ );
237
+ } catch (err) {
238
+ p.log.error(
239
+ `Rolled back, but pm2 could not be invoked (${err instanceof Error ? err.message : err}). ` +
240
+ `The app is likely down — run \`npm install\` then \`selva start\`.`
241
+ );
242
+ }
243
+ }
172
244
 
173
- return {
245
+ function buildTargetPackageJson(current) {
246
+ return buildDeploymentPackageJson({
174
247
  name: current.name ?? 'selva-deployment',
175
248
  version: current.version ?? '0.1.0',
176
- private: true,
177
- type: 'module',
178
- scripts: { ...CANONICAL_SCRIPTS },
179
- dependencies: deps
180
- };
249
+ engines: current.engines
250
+ });
181
251
  }
182
252
 
183
253
  // Format package.json diff for confirmation prompt (concise, not full diff).
@@ -196,6 +266,19 @@ function diffPackageJson(before, after) {
196
266
  else if (a !== b) lines.push(`${pc.yellow('~')} ${name} ${pc.dim(a + ' → ' + b)}`);
197
267
  }
198
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
+
199
282
  const beforeScripts = before.scripts ?? {};
200
283
  const afterScripts = after.scripts ?? {};
201
284
  const allScripts = new Set([...Object.keys(beforeScripts), ...Object.keys(afterScripts)]);
@@ -209,38 +292,53 @@ function diffPackageJson(before, after) {
209
292
  }
210
293
  }
211
294
 
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.
298
+ for (const key of Object.keys(before).sort()) {
299
+ if (CANONICAL_FIELDS.has(key)) continue;
300
+ lines.push(`${pc.red('-')} ${key} ${pc.dim(summarize(before[key]))}`);
301
+ }
302
+
212
303
  return lines;
213
304
  }
214
305
 
215
- // Local pm2 runner (avoid circular dep; duplication is small).
216
- function runPm2(dir, args, { inherit = true } = {}) {
217
- const local = join(dir, 'node_modules', '.bin', process.platform === 'win32' ? 'pm2.cmd' : 'pm2');
218
- const bin = existsSync(local) ? local : 'pm2';
219
- const result = spawnSync(bin, args, {
220
- cwd: dir,
221
- stdio: inherit ? 'inherit' : 'pipe',
222
- shell: process.platform === 'win32'
223
- });
224
- if (result.error) return 1;
225
- return result.status ?? 0;
306
+ // One-line preview of a dropped field, enough to recognise what is being lost.
307
+ function summarize(value) {
308
+ if (value === null || typeof value !== 'object') return String(value);
309
+ const keys = Object.keys(value);
310
+ if (Array.isArray(value)) return `[${keys.length} item${keys.length === 1 ? '' : 's'}]`;
311
+ return keys.length ? `{${keys.join(', ')}}` : '{}';
226
312
  }
227
313
 
228
314
  // Exported for `selva doctor` to check layout drift without duplication.
229
315
  export function detectDrift(pkgJson, dir) {
230
316
  const deps = pkgJson?.dependencies ?? {};
231
317
  const reasons = [];
232
- if (deps['@selvajs/runtime']) reasons.push('@selvajs/runtime is the old runtime package');
233
- if (deps['@selvajs/create']) reasons.push('@selvajs/create is the old CLI package');
234
- if (deps['@selvajs/platform']) reasons.push('@selvajs/platform is now bundled into @selvajs/selva');
235
- if (deps['@selvajs/local-provider'])
236
- reasons.push('@selvajs/local-provider is now bundled into @selvajs/selva');
237
- if (deps['@selvajs/supabase-provider'])
238
- reasons.push('@selvajs/supabase-provider is now bundled into @selvajs/selva');
239
- if (deps['@selvajs/header-auth-provider'])
240
- reasons.push('@selvajs/header-auth-provider is now bundled into @selvajs/selva');
241
- if (!deps['@selvajs/selva']) reasons.push('@selvajs/selva is missing');
242
- if (!deps['@selvajs/cli']) reasons.push('@selvajs/cli is missing');
243
- if (!deps['pm2']) reasons.push('pm2 is not in dependencies');
318
+
319
+ for (const [name, why] of Object.entries(LEGACY_DEPENDENCIES)) {
320
+ if (deps[name]) reasons.push(`${name} is ${why}`);
321
+ }
322
+ for (const name of Object.keys(DEPENDENCIES)) {
323
+ if (!deps[name]) reasons.push(`${name} is missing`);
324
+ }
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
+ }
244
342
 
245
343
  if (dir) {
246
344
  const configPath = join(dir, 'selva.config.js');
@@ -251,10 +349,32 @@ export function detectDrift(pkgJson, dir) {
251
349
  if (existsSync(ecoPath) && readFileSync(ecoPath, 'utf8').includes('@selvajs/runtime')) {
252
350
  reasons.push('ecosystem.config.cjs still references @selvajs/runtime');
253
351
  }
352
+ const envPath = join(dir, '.env');
353
+ if (existsSync(envPath)) {
354
+ const { changes } = renameEnvKeys(readFileSync(envPath, 'utf8'), RENAMED_ENV_VARS);
355
+ for (const [oldName, newName] of changes) {
356
+ reasons.push(`.env still uses ${oldName} (renamed to ${newName})`);
357
+ }
358
+ }
254
359
  }
255
360
 
256
361
  return reasons;
257
362
  }
258
363
 
259
- // Re-exported for tests to verify without running migrate.
260
- export { buildTargetPackageJson, SELVA_DEPS };
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
+
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';
@@ -9,10 +9,9 @@ import * as p from '@clack/prompts';
9
9
  import pc from 'picocolors';
10
10
  import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
11
11
 
12
- const APP_NAME = 'selva-compute';
12
+ export const APP_NAME = 'selva-compute';
13
13
 
14
- // Resolve to deployment-local pm2 (no global fallback; prevents version skew).
15
- function pm2Bin(dir) {
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)) {
18
17
  throw new Error(
@@ -25,7 +24,32 @@ function pm2Bin(dir) {
25
24
  return local;
26
25
  }
27
26
 
28
- // Check for daemon/CLI version mismatch; run `pm2 update` if stale.
27
+ // Parses the two versions pm2 prints alongside its out-of-date warning:
28
+ // In memory PM2 version: 6.0.14
29
+ // Local PM2 version: 5.4.3
30
+ // Returns nulls when the shape doesn't match, so callers fall back to resyncing.
31
+ export function parsePm2Skew(output) {
32
+ // eslint-disable-next-line no-control-regex -- pm2 colourises via chalk on a TTY
33
+ const clean = output.replace(/\[[0-9;]*m/g, '');
34
+ const daemon = /In memory PM2 version:\s*v?(\d+\.\d+\.\d+)/i.exec(clean);
35
+ const local = /Local PM2 version:\s*v?(\d+\.\d+\.\d+)/i.exec(clean);
36
+ return { daemon: daemon?.[1] ?? null, local: local?.[1] ?? null };
37
+ }
38
+
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.
43
+ export function daemonOutranksCli(daemon, local) {
44
+ if (!daemon || !local) return false;
45
+ const [a, b] = [daemon.split('.').map(Number), local.split('.').map(Number)];
46
+ for (let i = 0; i < 3; i++) {
47
+ if (a[i] > b[i]) return true;
48
+ if (a[i] < b[i]) return false;
49
+ }
50
+ return false;
51
+ }
52
+
29
53
  function ensurePm2InSync(dir) {
30
54
  const bin = pm2Bin(dir);
31
55
  const probe = spawnSync(bin, ['ping'], {
@@ -36,6 +60,16 @@ function ensurePm2InSync(dir) {
36
60
  const output = (probe.stdout ?? '') + (probe.stderr ?? '');
37
61
  if (!/out-of-date/i.test(output)) return;
38
62
 
63
+ const { daemon, local } = parsePm2Skew(output);
64
+ if (daemonOutranksCli(daemon, local)) {
65
+ throw new Error(
66
+ `The running PM2 daemon (v${daemon}) is NEWER than this deployment's pm2 (v${local}), ` +
67
+ `so a global pm2 owns it. Running \`pm2 update\` would downgrade the daemon and drop ` +
68
+ `its process table, leaving ${APP_NAME} unregistered. Resolve the conflict first: ` +
69
+ `\`which -a pm2\`, \`pm2 -v\`, \`pm2 ping\`.`
70
+ );
71
+ }
72
+
39
73
  p.log.warn(
40
74
  'PM2 in-memory daemon is a different version than the deployment-local pm2 — ' +
41
75
  'running `pm2 update` to resync (this briefly restarts managed processes).'
@@ -53,8 +87,10 @@ function ensurePm2InSync(dir) {
53
87
  }
54
88
  }
55
89
 
56
- function runPm2(dir, args, { inherit = true } = {}) {
57
- const bin = pm2Bin(dir);
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).
93
+ export function runPm2(dir, args, { inherit = true, bin = pm2Bin(dir) } = {}) {
58
94
  const result = spawnSync(bin, args, {
59
95
  cwd: dir,
60
96
  stdio: inherit ? 'inherit' : 'pipe',