@selvajs/cli 4.8.0-beta.4 → 4.8.0-beta.6

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selvajs/cli",
3
- "version": "4.8.0-beta.4",
3
+ "version": "4.8.0-beta.6",
4
4
  "description": "Scaffold and operate a Selva white-label deployment. `npx @selvajs/cli <dir>` to bootstrap, `selva <cmd>` to manage.",
5
5
  "license": "MIT",
6
6
  "author": "VektorNode",
@@ -0,0 +1,97 @@
1
+ // Node engine detection in `selva doctor` (issue #176).
2
+ //
3
+ // The CLI carries its own range check rather than importing @selvajs/server:
4
+ // it scaffolds the deployment that installs the runtime, so it cannot depend on
5
+ // it. These pin the two behaviours that matter — the incident direction is
6
+ // caught, and an unparseable range never produces a false failure that would
7
+ // tell an operator to upgrade a Node that is already fine.
8
+ //
9
+ // Run by `pnpm test` via node's built-in runner.
10
+
11
+ import { test } from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+ import { satisfiesNodeRange } from '../commands/doctor.js';
14
+
15
+ test('catches the reported incident — Node 20 host, runtime requiring >=22', () => {
16
+ assert.equal(satisfiesNodeRange('20.20.2', '>=22.0.0'), false);
17
+ });
18
+
19
+ test('accepts a host that satisfies the floor', () => {
20
+ assert.equal(satisfiesNodeRange('22.0.0', '>=22.0.0'), true);
21
+ assert.equal(satisfiesNodeRange('24.16.0', '>=22.0.0'), true);
22
+ });
23
+
24
+ test('accepts a v-prefixed version, as process.version reports it', () => {
25
+ assert.equal(satisfiesNodeRange('v22.4.0', '>=22.0.0'), true);
26
+ });
27
+
28
+ test('is exact at the boundary', () => {
29
+ assert.equal(satisfiesNodeRange('21.9.9', '>=22.0.0'), false);
30
+ assert.equal(satisfiesNodeRange('22.0.0', '>22.0.0'), false);
31
+ });
32
+
33
+ test('handles caret, tilde, alternatives and conjunctions', () => {
34
+ assert.equal(satisfiesNodeRange('22.5.0', '^22.0.0'), true);
35
+ assert.equal(satisfiesNodeRange('23.0.0', '^22.0.0'), false);
36
+ assert.equal(satisfiesNodeRange('22.1.5', '~22.1.0'), true);
37
+ assert.equal(satisfiesNodeRange('20.0.0', '^18.0.0 || ^20.0.0'), true);
38
+ assert.equal(satisfiesNodeRange('19.0.0', '^18.0.0 || ^20.0.0'), false);
39
+ assert.equal(satisfiesNodeRange('20.0.0', '>=18 <21'), true);
40
+ assert.equal(satisfiesNodeRange('21.0.0', '>=18 <21'), false);
41
+ });
42
+
43
+ test('treats a bare major or x-range as the whole line', () => {
44
+ assert.equal(satisfiesNodeRange('22.7.1', '22'), true);
45
+ assert.equal(satisfiesNodeRange('22.7.1', '22.x'), true);
46
+ assert.equal(satisfiesNodeRange('23.0.0', '22.x'), false);
47
+ });
48
+
49
+ test('returns null — never a false failure — when it cannot parse', () => {
50
+ // A wrong `false` would tell an operator to upgrade a Node that is fine.
51
+ assert.equal(satisfiesNodeRange('22.0.0', 'lts/*'), null);
52
+ assert.equal(satisfiesNodeRange('not-a-version', '>=22.0.0'), null);
53
+ assert.equal(satisfiesNodeRange('22.0.0', ''), null);
54
+ });
55
+
56
+ test('agrees with @selvajs/server satisfiesRange on the shared cases', () => {
57
+ // The two implementations are duplicated by necessity; if they diverge, the
58
+ // admin UI and the CLI would disagree about the same deployment.
59
+ const cases = [
60
+ ['20.20.2', '>=22.0.0', false],
61
+ ['22.0.0', '>=22.0.0', true],
62
+ ['22.5.0', '^22.0.0', true],
63
+ ['23.0.0', '^22.0.0', false],
64
+ ['20.0.0', '>=18 <21', true],
65
+ ['22.0.0', 'lts/*', null]
66
+ ];
67
+ for (const [version, range, expected] of cases) {
68
+ assert.equal(satisfiesNodeRange(version, range), expected, `${version} vs ${range}`);
69
+ }
70
+ });
71
+
72
+ // ── The floor itself must not be hardcoded ──────────────────────────────
73
+
74
+ test('requiredNodeRange reads the CLI package.json, not a copy in code', async () => {
75
+ const { requiredNodeRange } = await import('../paths.js');
76
+ const { readFileSync } = await import('node:fs');
77
+ const { join, dirname } = await import('node:path');
78
+ const { fileURLToPath } = await import('node:url');
79
+
80
+ const here = dirname(fileURLToPath(import.meta.url));
81
+ const pkg = JSON.parse(readFileSync(join(here, '..', '..', 'package.json'), 'utf8'));
82
+
83
+ // If someone bumps engines.node, this follows automatically. A hardcoded
84
+ // literal anywhere in the scaffolder would fail here.
85
+ assert.equal(requiredNodeRange(), pkg.engines.node);
86
+ });
87
+
88
+ test('the create-time guard tracks a bumped floor', async () => {
89
+ const { requiredNodeRange } = await import('../paths.js');
90
+ const range = requiredNodeRange();
91
+ assert.ok(range, 'engines.node must be declared for the scaffolder to enforce it');
92
+
93
+ // The floor is whatever package.json says; a host below it is refused.
94
+ const floorMajor = Number(/(\d+)/.exec(range)[1]);
95
+ assert.equal(satisfiesNodeRange(`${floorMajor - 1}.0.0`, range), false);
96
+ assert.equal(satisfiesNodeRange(`${floorMajor}.0.0`, range), true);
97
+ });
@@ -0,0 +1,60 @@
1
+ // PM2 daemon/CLI skew direction (issue #118, defect 2).
2
+ //
3
+ // The old check treated any `out-of-date` report as "run `pm2 update`". That is
4
+ // only correct when the daemon is OLDER than the deployment-local CLI. In the
5
+ // reported incident a global pm2 v6 owned the daemon while the deployment pinned
6
+ // v5.4.3 — `pm2 update` ran the v5 CLI against it, downgrading the daemon and
7
+ // feeding it a v6 dump, so the process table came back empty and selva-compute
8
+ // was never re-registered.
9
+ //
10
+ // Run by `pnpm test` via node's built-in runner.
11
+
12
+ import { test } from 'node:test';
13
+ import assert from 'node:assert/strict';
14
+ import { parsePm2Skew, daemonOutranksCli } from '../commands/pm2.js';
15
+
16
+ // pm2 colourises through chalk on a TTY; build the codes rather than embedding
17
+ // raw control characters in the source.
18
+ const ESC = String.fromCharCode(27);
19
+ const colour = (s) => `${ESC}[34m${ESC}[1m${s}${ESC}[22m${ESC}[39m`;
20
+
21
+ const REAL_OUTPUT =
22
+ `${ESC}[31m${ESC}[1m>>>> In-memory PM2 is out-of-date, do:\n>>>> $ pm2 update${ESC}[22m${ESC}[39m\n` +
23
+ `In memory PM2 version: ${colour('6.0.14')}\n` +
24
+ `Local PM2 version: ${colour('5.4.3')}\n`;
25
+
26
+ test('parses both versions out of real colourised pm2 output', () => {
27
+ assert.deepEqual(parsePm2Skew(REAL_OUTPUT), { daemon: '6.0.14', local: '5.4.3' });
28
+ });
29
+
30
+ test('parses plain uncoloured output (non-TTY)', () => {
31
+ const plain = 'In memory PM2 version: 5.4.3\nLocal PM2 version: 5.4.3\n';
32
+ assert.deepEqual(parsePm2Skew(plain), { daemon: '5.4.3', local: '5.4.3' });
33
+ });
34
+
35
+ test('returns nulls when the message shape changes, so callers fall back to resync', () => {
36
+ assert.deepEqual(parsePm2Skew('something else entirely'), { daemon: null, local: null });
37
+ });
38
+
39
+ test('flags the incident direction — global daemon newer than the pinned CLI', () => {
40
+ assert.equal(daemonOutranksCli('6.0.14', '5.4.3'), true);
41
+ });
42
+
43
+ test('allows the repairable direction — daemon older than the CLI', () => {
44
+ assert.equal(daemonOutranksCli('5.4.3', '6.0.14'), false);
45
+ });
46
+
47
+ test('is false when the versions match', () => {
48
+ assert.equal(daemonOutranksCli('5.4.3', '5.4.3'), false);
49
+ });
50
+
51
+ test('compares minor and patch, not just major', () => {
52
+ assert.equal(daemonOutranksCli('5.5.0', '5.4.3'), true);
53
+ assert.equal(daemonOutranksCli('5.4.4', '5.4.3'), true);
54
+ assert.equal(daemonOutranksCli('5.4.3', '5.4.4'), false);
55
+ });
56
+
57
+ test('never blocks on an unparseable version — resync stays the default', () => {
58
+ assert.equal(daemonOutranksCli(null, '5.4.3'), false);
59
+ assert.equal(daemonOutranksCli('6.0.14', null), false);
60
+ });
package/src/cli.js CHANGED
@@ -62,7 +62,7 @@ function printHelp() {
62
62
  '',
63
63
  pc.bold('Commands:'),
64
64
  ' init Reconfigure this deployment (prompts again)',
65
- ' doctor Validate env, providers, and installed packages',
65
+ ' doctor [--fix] Validate env, providers, Node engine, and packages',
66
66
  ' start pm2 start ecosystem.config.cjs',
67
67
  ' stop pm2 stop selva-compute',
68
68
  ' restart pm2 restart selva-compute --update-env',
@@ -10,13 +10,32 @@ import pc from 'picocolors';
10
10
  import { collectConfig, collectConfigFromEnv } from '../prompts.js';
11
11
  import { generateKey } from '../secrets.js';
12
12
  import { writeEnvFile } from '../env.js';
13
- import { isEmptyOrMissing } from '../paths.js';
13
+ import { isEmptyOrMissing, requiredNodeRange } from '../paths.js';
14
+ import { satisfiesNodeRange } from './doctor.js';
14
15
 
15
16
  const CLI_VERSION = '0.1.0';
16
17
 
17
18
  export async function runCreate(argv) {
18
19
  const { dir: rawDir, force, skipInstall, yes } = parseArgs(argv);
19
20
 
21
+ // Scaffolding under too old a Node produces a deployment that installs
22
+ // cleanly and passes its health check, then fails only under real traffic —
23
+ // npm treats an engine mismatch as a warning unless engine-strict is set,
24
+ // which no deployment sets (issue #176). Refuse at the one moment the
25
+ // operator is still choosing their environment. The floor comes from this
26
+ // CLI's own engines.node, so a bump can't leave a stale copy behind.
27
+ const required = requiredNodeRange();
28
+ if (required && satisfiesNodeRange(process.versions.node, required) === false) {
29
+ console.error(
30
+ `${pc.red('✗')} Selva requires Node ${required}, but this shell runs ` +
31
+ `v${process.versions.node}.\n` +
32
+ ` A deployment scaffolded here would install and start, then fail once real\n` +
33
+ ` requests hit newer Node APIs. Upgrade Node first (nvm, fnm, or your package\n` +
34
+ ` manager), then re-run this command.`
35
+ );
36
+ process.exit(1);
37
+ }
38
+
20
39
  const targetDir = resolve(rawDir);
21
40
  if (!isEmptyOrMissing(targetDir) && !force) {
22
41
  console.error(
@@ -1,7 +1,12 @@
1
1
  // Validate deployment without starting it: files, secrets, layout, providers,
2
- // boot persistence. Read-only; yellow warnings don't fail, red failures exit 1.
2
+ // Node engine, boot persistence. Yellow warnings don't fail, red failures exit 1.
3
+ //
4
+ // Read-only by default. `--fix` applies the repairs attached to individual
5
+ // checks, each behind its own confirmation — and only those needing no root and
6
+ // no runtime restart. See `applyFixes`.
3
7
 
4
8
  import { existsSync, readFileSync, readdirSync, accessSync, constants, statSync } from 'node:fs';
9
+ import { spawnSync } from 'node:child_process';
5
10
  import { join, resolve, dirname, delimiter } from 'node:path';
6
11
  import { fileURLToPath } from 'node:url';
7
12
  import { homedir } from 'node:os';
@@ -14,11 +19,12 @@ import { detectDrift } from './migrate.js';
14
19
  const HEX_64 = /^[0-9a-f]{64}$/i;
15
20
  const PLACEHOLDER = 'replace-this-with-a-random-32-byte-hex-key';
16
21
 
17
- export async function runDoctor() {
22
+ export async function runDoctor(argv = []) {
23
+ const fix = argv.includes('--fix');
18
24
  const dir = resolveDeploymentDir();
19
25
  requireDeploymentDir(dir);
20
26
 
21
- p.intro(pc.bgCyan(pc.black(' selva doctor ')));
27
+ p.intro(pc.bgCyan(pc.black(fix ? ' selva doctor --fix ' : ' selva doctor ')));
22
28
 
23
29
  const checks = [];
24
30
  const env = readEnvFile(join(dir, '.env'));
@@ -72,6 +78,7 @@ export async function runDoctor() {
72
78
  }
73
79
 
74
80
  checks.push(checkPackage(dir, '@selvajs/selva'));
81
+ checks.push(checkNodeEngine(dir));
75
82
  checks.push(checkCliRuntimeAlignment(dir));
76
83
  checks.push(...checkBootPersistence(dir));
77
84
  if (env.ORIGIN) {
@@ -101,12 +108,25 @@ export async function runDoctor() {
101
108
  }
102
109
 
103
110
  // ── Render ─────────────────────────────────────────────────────────
111
+ const resolved = await Promise.all(checks);
104
112
  let failures = 0;
105
- for (const c of await Promise.all(checks)) {
113
+ for (const c of resolved) {
106
114
  console.log(' ' + c.line);
107
115
  if (c.severity === 'red') failures += 1;
108
116
  }
109
117
 
118
+ if (fix) {
119
+ failures = await applyFixes(resolved, failures);
120
+ } else {
121
+ const repairable = resolved.filter((c) => c.fix).length;
122
+ if (repairable > 0) {
123
+ p.log.info(
124
+ `${repairable} issue${repairable === 1 ? '' : 's'} can be repaired automatically — ` +
125
+ `re-run with \`selva doctor --fix\`.`
126
+ );
127
+ }
128
+ }
129
+
110
130
  if (failures === 0) {
111
131
  p.outro(pc.green('All checks passed.'));
112
132
  } else {
@@ -115,14 +135,66 @@ export async function runDoctor() {
115
135
  }
116
136
  }
117
137
 
138
+ /**
139
+ * Apply the repairs attached to failing checks, each behind its own
140
+ * confirmation. Returns the updated failure count.
141
+ *
142
+ * Nothing here needs root: anything privileged (the systemd unit) or that would
143
+ * restart this process (a Node upgrade) stays a printed instruction, because a
144
+ * fixer that dies halfway through its own runtime leaves the operator with no
145
+ * obvious path back.
146
+ */
147
+ async function applyFixes(resolved, failures) {
148
+ const repairs = resolved.filter((c) => c.fix && c.severity !== 'green');
149
+ if (repairs.length === 0) {
150
+ p.log.info('Nothing to repair automatically.');
151
+ return failures;
152
+ }
153
+
154
+ console.log('');
155
+ p.log.step(`${repairs.length} repair${repairs.length === 1 ? '' : 's'} available`);
156
+
157
+ for (const check of repairs) {
158
+ const approved = await p.confirm({
159
+ message: check.fix.label,
160
+ initialValue: false
161
+ });
162
+ if (p.isCancel(approved) || !approved) {
163
+ p.log.info('Skipped.');
164
+ continue;
165
+ }
166
+ let result;
167
+ try {
168
+ result = await check.fix.run();
169
+ } catch (err) {
170
+ result = red(`repair threw: ${err instanceof Error ? err.message : String(err)}`);
171
+ }
172
+ console.log(' ' + result.line);
173
+ if (result.severity === 'green' && check.severity === 'red') failures -= 1;
174
+ }
175
+ return failures;
176
+ }
177
+
118
178
  function green(text) {
119
179
  return { severity: 'green', line: `${pc.green('✓')} ${text}` };
120
180
  }
121
- function yellow(text) {
122
- return { severity: 'yellow', line: `${pc.yellow('!')} ${text}` };
181
+ function yellow(text, fix) {
182
+ return { severity: 'yellow', line: `${pc.yellow('!')} ${text}`, fix };
123
183
  }
124
- function red(text) {
125
- return { severity: 'red', line: `${pc.red('✗')} ${text}` };
184
+ function red(text, fix) {
185
+ return { severity: 'red', line: `${pc.red('✗')} ${text}`, fix };
186
+ }
187
+
188
+ /**
189
+ * A repair `--fix` may run. `label` is what the operator is asked to approve;
190
+ * `run()` performs it and returns a result line.
191
+ *
192
+ * Only attach one where the repair is unambiguous and reversible-ish. Anything
193
+ * needing root, or that restarts the runtime running this process, stays a
194
+ * printed instruction — a half-applied privileged fix is worse than none.
195
+ */
196
+ function fixable(label, run) {
197
+ return { label, run };
126
198
  }
127
199
 
128
200
  function checkFile(path, label) {
@@ -312,6 +384,108 @@ function checkCliRuntimeAlignment(dir) {
312
384
  }
313
385
 
314
386
  // Verify VM reboot will resurrect app: dump.pm2 saved, systemd unit installed, no stray global pm2.
387
+ /**
388
+ * Does this host's Node satisfy the installed runtime's `engines.node`?
389
+ *
390
+ * npm only enforces engines under `engine-strict=true`, which no deployment
391
+ * sets, so a mismatched install succeeds silently and `/api/health` still
392
+ * returns 200 — routes using newer APIs throw only under real traffic
393
+ * (issue #176). Deliberately NOT auto-fixable: upgrading Node is
394
+ * distro-specific and would restart the process running this check.
395
+ */
396
+ function checkNodeEngine(dir) {
397
+ let required;
398
+ try {
399
+ const pkg = JSON.parse(
400
+ readFileSync(join(dir, 'node_modules', '@selvajs', 'selva', 'package.json'), 'utf8')
401
+ );
402
+ required = pkg.engines?.node;
403
+ } catch {
404
+ return yellow('@selvajs/selva engines.node — package.json unreadable, skipped');
405
+ }
406
+ if (typeof required !== 'string') {
407
+ return yellow('@selvajs/selva declares no engines.node — cannot verify this host');
408
+ }
409
+
410
+ const running = process.versions.node;
411
+ const ok = satisfiesNodeRange(running, required);
412
+ if (ok === null) {
413
+ return yellow(`engines.node "${required}" not understood — verify Node v${running} manually`);
414
+ }
415
+ if (ok) return green(`Node v${running} satisfies engines.node ${required}`);
416
+
417
+ return red(
418
+ `Node v${running} does NOT satisfy @selvajs/selva's engines.node ${required}.\n ` +
419
+ `npm installs it anyway and the health check still passes, so this fails only\n ` +
420
+ `under real traffic. Upgrade Node on this host (nvm/fnm or your package manager),\n ` +
421
+ `then: npm rebuild && npm run restart`
422
+ );
423
+ }
424
+
425
+ /**
426
+ * Narrow `engines.node` range check — `>=X`, `^`, `~`, `||`, and bare/x-ranges.
427
+ * Returns null when unparseable so a misread never reports a false failure.
428
+ *
429
+ * Mirrors `satisfiesRange` in @selvajs/server/ops. Duplicated because the CLI is
430
+ * dependency-free by design (it scaffolds the deployment that installs the
431
+ * runtime, so it cannot import from it).
432
+ */
433
+ export function satisfiesNodeRange(version, range) {
434
+ const core = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(version).trim());
435
+ if (!core) return null;
436
+ const v = [Number(core[1]), Number(core[2]), Number(core[3])];
437
+
438
+ let anyParsed = false;
439
+ for (const alt of String(range).split('||')) {
440
+ const clauses = alt.trim().split(/\s+/).filter(Boolean);
441
+ if (!clauses.length) continue;
442
+
443
+ let all = true;
444
+ let parsed = true;
445
+ for (const clause of clauses) {
446
+ const m = /^(>=|<=|>|<|\^|~|=)?\s*v?(\d+)(?:\.(\d+|x|\*))?(?:\.(\d+|x|\*))?/.exec(clause);
447
+ if (!m) {
448
+ parsed = false;
449
+ break;
450
+ }
451
+ const op = m[1] ?? '=';
452
+ const num = (s) => (s == null || s === 'x' || s === '*' ? 0 : Number(s));
453
+ const t = [Number(m[2]), num(m[3]), num(m[4])];
454
+ let cmp = 0;
455
+ for (let i = 0; i < 3; i++) {
456
+ if (v[i] > t[i]) {
457
+ cmp = 1;
458
+ break;
459
+ }
460
+ if (v[i] < t[i]) {
461
+ cmp = -1;
462
+ break;
463
+ }
464
+ }
465
+ let ok;
466
+ if (op === '>=') ok = cmp >= 0;
467
+ else if (op === '>') ok = cmp > 0;
468
+ else if (op === '<=') ok = cmp <= 0;
469
+ else if (op === '<') ok = cmp < 0;
470
+ else if (op === '^') ok = cmp >= 0 && v[0] === t[0];
471
+ else if (op === '~') ok = cmp >= 0 && v[0] === t[0] && v[1] === t[1];
472
+ else
473
+ ok =
474
+ v[0] === t[0] &&
475
+ (m[3] == null || m[3] === 'x' || m[3] === '*' || v[1] === t[1]) &&
476
+ (m[4] == null || m[4] === 'x' || m[4] === '*' || v[2] === t[2]);
477
+ if (!ok) {
478
+ all = false;
479
+ break;
480
+ }
481
+ }
482
+ if (!parsed) continue;
483
+ anyParsed = true;
484
+ if (all) return true;
485
+ }
486
+ return anyParsed ? false : null;
487
+ }
488
+
315
489
  function checkBootPersistence(dir) {
316
490
  // pm2's boot integration is Linux/systemd-specific. On macOS it's launchd
317
491
  // (different unit path) and on Windows pm2 boot persistence isn't a thing —
@@ -329,7 +503,15 @@ function checkBootPersistence(dir) {
329
503
  out.push(
330
504
  yellow(
331
505
  'pm2 process list not saved — run `npx pm2 save` so a reboot can ' +
332
- 'resurrect the app (nothing to restore without it)'
506
+ 'resurrect the app (nothing to restore without it)',
507
+ fixable('run `pm2 save` to persist the current process list', () => {
508
+ const bin = join(dir, 'node_modules', '.bin', 'pm2');
509
+ if (!existsSync(bin)) return red('pm2 not installed in this deployment');
510
+ const r = spawnSync(bin, ['save'], { cwd: dir, encoding: 'utf8' });
511
+ return (r.status ?? 1) === 0
512
+ ? green('pm2 process list saved')
513
+ : red(`pm2 save failed: ${(r.stderr || r.stdout || '').trim()}`);
514
+ })
333
515
  )
334
516
  );
335
517
  }
@@ -376,11 +558,31 @@ function checkBootPersistence(dir) {
376
558
  // Warn if global pm2 is on PATH (root cause of version skew).
377
559
  const globalPm2 = findGlobalPm2(dir);
378
560
  if (globalPm2) {
561
+ // Only offer the removal when we could actually perform it. A global
562
+ // install under /usr is root-owned; attempting it would half-fail and
563
+ // leave the operator worse informed than a printed instruction.
564
+ let writable = false;
565
+ try {
566
+ accessSync(dirname(globalPm2), constants.W_OK);
567
+ writable = true;
568
+ } catch {
569
+ writable = false;
570
+ }
571
+
379
572
  out.push(
380
573
  yellow(
381
574
  `a pm2 outside this deployment is on PATH (${globalPm2}) — it can fork a ` +
382
575
  `mismatched daemon and trigger skew. Prefer \`npm run\` wrappers / \`npx pm2\` ` +
383
- `from this directory; consider \`npm uninstall -g pm2\`.`
576
+ `from this directory; consider \`npm uninstall -g pm2\`.` +
577
+ (writable ? '' : `\n (${dirname(globalPm2)} is not writable — needs sudo)`),
578
+ writable
579
+ ? fixable(`uninstall the global pm2 at ${globalPm2}`, () => {
580
+ const r = spawnSync('npm', ['uninstall', '-g', 'pm2'], { encoding: 'utf8' });
581
+ return (r.status ?? 1) === 0
582
+ ? green('global pm2 uninstalled')
583
+ : red(`npm uninstall -g pm2 failed: ${(r.stderr || r.stdout || '').trim()}`);
584
+ })
585
+ : undefined
384
586
  )
385
587
  );
386
588
  }
@@ -25,7 +25,36 @@ function pm2Bin(dir) {
25
25
  return local;
26
26
  }
27
27
 
28
- // Check for daemon/CLI version mismatch; run `pm2 update` if stale.
28
+ // Parse the two versions pm2 prints alongside its out-of-date warning:
29
+ // In memory PM2 version: 6.0.14
30
+ // 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.
33
+ export function parsePm2Skew(output) {
34
+ // eslint-disable-next-line no-control-regex -- pm2 colourises via chalk on a TTY
35
+ const clean = output.replace(/\[[0-9;]*m/g, '');
36
+ const daemon = /In memory PM2 version:\s*v?(\d+\.\d+\.\d+)/i.exec(clean);
37
+ const local = /Local PM2 version:\s*v?(\d+\.\d+\.\d+)/i.exec(clean);
38
+ return { daemon: daemon?.[1] ?? null, local: local?.[1] ?? null };
39
+ }
40
+
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
+ */
47
+ export function daemonOutranksCli(daemon, local) {
48
+ if (!daemon || !local) return false;
49
+ const [a, b] = [daemon.split('.').map(Number), local.split('.').map(Number)];
50
+ for (let i = 0; i < 3; i++) {
51
+ if (a[i] > b[i]) return true;
52
+ if (a[i] < b[i]) return false;
53
+ }
54
+ return false;
55
+ }
56
+
57
+ // Check for daemon/CLI version mismatch; run `pm2 update` only when it can help.
29
58
  function ensurePm2InSync(dir) {
30
59
  const bin = pm2Bin(dir);
31
60
  const probe = spawnSync(bin, ['ping'], {
@@ -36,6 +65,16 @@ function ensurePm2InSync(dir) {
36
65
  const output = (probe.stdout ?? '') + (probe.stderr ?? '');
37
66
  if (!/out-of-date/i.test(output)) return;
38
67
 
68
+ const { daemon, local } = parsePm2Skew(output);
69
+ if (daemonOutranksCli(daemon, local)) {
70
+ throw new Error(
71
+ `The running PM2 daemon (v${daemon}) is NEWER than this deployment's pm2 (v${local}), ` +
72
+ `so a global pm2 owns it. Running \`pm2 update\` would downgrade the daemon and drop ` +
73
+ `its process table, leaving ${APP_NAME} unregistered. Resolve the conflict first: ` +
74
+ `\`which -a pm2\`, \`pm2 -v\`, \`pm2 ping\`.`
75
+ );
76
+ }
77
+
39
78
  p.log.warn(
40
79
  'PM2 in-memory daemon is a different version than the deployment-local pm2 — ' +
41
80
  'running `pm2 update` to resync (this briefly restarts managed processes).'
package/src/paths.js CHANGED
@@ -1,5 +1,25 @@
1
- import { existsSync, readdirSync } from 'node:fs';
2
- import { resolve, join } from 'node:path';
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { resolve, join, dirname } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ /**
6
+ * The Node floor Selva requires, read from this CLI's own `engines.node`.
7
+ *
8
+ * Never hardcode it: the CLI, runtime, and server all declare the same range in
9
+ * package.json, and a copy in code silently keeps enforcing the old floor after
10
+ * a bump. Falls back to null (skip the check) rather than guessing a number —
11
+ * refusing to scaffold on a wrong guess is worse than not checking.
12
+ */
13
+ export function requiredNodeRange() {
14
+ try {
15
+ const here = dirname(fileURLToPath(import.meta.url));
16
+ const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8'));
17
+ const range = pkg.engines?.node;
18
+ return typeof range === 'string' ? range : null;
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
3
23
 
4
24
  // A deployment directory must contain an .env and an ecosystem.config.cjs.
5
25
  // Anything else (selva.config.js, node_modules) is "should be there" but the