@selvajs/cli 4.3.0 → 4.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/doctor.js +115 -1
package/package.json
CHANGED
package/src/commands/doctor.js
CHANGED
|
@@ -7,13 +7,17 @@
|
|
|
7
7
|
// • DATA_PATH writable (when local provider is in use)
|
|
8
8
|
// • Supabase URL reachable (when supabase provider is in use)
|
|
9
9
|
// • @selvajs/selva installed
|
|
10
|
+
// • Boot persistence (Linux): dump.pm2 saved, systemd unit installed and
|
|
11
|
+
// pointing at the deployment-local pm2, no stray global pm2 on PATH
|
|
10
12
|
// • Origin set when behind a reverse proxy looks set
|
|
11
13
|
//
|
|
12
14
|
// Exits 0 (green) or 1 (any red); yellow checks don't fail the run.
|
|
15
|
+
// All checks are read-only — doctor never starts or pings the pm2 daemon.
|
|
13
16
|
|
|
14
17
|
import { existsSync, readFileSync, accessSync, constants, statSync } from 'node:fs';
|
|
15
|
-
import { join, resolve, dirname } from 'node:path';
|
|
18
|
+
import { join, resolve, dirname, delimiter } from 'node:path';
|
|
16
19
|
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { homedir } from 'node:os';
|
|
17
21
|
import * as p from '@clack/prompts';
|
|
18
22
|
import pc from 'picocolors';
|
|
19
23
|
import { readEnvFile } from '../env.js';
|
|
@@ -103,6 +107,13 @@ export async function runDoctor() {
|
|
|
103
107
|
// makes `selva update` look like a no-op for the CLI. Surface it.
|
|
104
108
|
checks.push(checkCliRuntimeAlignment(dir));
|
|
105
109
|
|
|
110
|
+
// ── Boot persistence (read-only) ───────────────────────────────────
|
|
111
|
+
// Validates that a reboot will actually bring the app back. All checks
|
|
112
|
+
// here are pure reads — no `pm2 ping`/daemon interaction — so doctor keeps
|
|
113
|
+
// its "never starts anything" contract. Linux/systemd only; skipped
|
|
114
|
+
// elsewhere because pm2's boot integration is platform-specific.
|
|
115
|
+
checks.push(...checkBootPersistence(dir));
|
|
116
|
+
|
|
106
117
|
// ── Origin (best-effort) ───────────────────────────────────────────
|
|
107
118
|
if (env.ORIGIN) {
|
|
108
119
|
try {
|
|
@@ -251,6 +262,109 @@ function checkCliRuntimeAlignment(dir) {
|
|
|
251
262
|
);
|
|
252
263
|
}
|
|
253
264
|
|
|
265
|
+
// Validate that a VM reboot will resurrect the app. Three failure modes,
|
|
266
|
+
// all of which we hit in the field:
|
|
267
|
+
// 1. `pm2 save` never run → no dump.pm2 → resurrect restores nothing.
|
|
268
|
+
// 2. `pm2 startup` never run → no systemd unit → pm2 never launches at boot.
|
|
269
|
+
// 3. The systemd unit was generated from a *global* pm2 (the operator
|
|
270
|
+
// pasted pm2's auto-printed `sudo env ... pm2 startup` line verbatim
|
|
271
|
+
// while a global pm2 was on PATH). The unit then resurrects via a
|
|
272
|
+
// different pm2 than the deployment-local one the CLI manages with —
|
|
273
|
+
// silent version skew on every boot. This is the only RED here.
|
|
274
|
+
// All checks are read-only (existsSync + reading the unit file + scanning
|
|
275
|
+
// PATH); none touch the pm2 daemon, so doctor stays side-effect-free.
|
|
276
|
+
function checkBootPersistence(dir) {
|
|
277
|
+
// pm2's boot integration is Linux/systemd-specific. On macOS it's launchd
|
|
278
|
+
// (different unit path) and on Windows pm2 boot persistence isn't a thing —
|
|
279
|
+
// stay silent rather than emit misleading checks.
|
|
280
|
+
if (process.platform !== 'linux') return [];
|
|
281
|
+
|
|
282
|
+
const out = [];
|
|
283
|
+
|
|
284
|
+
// 1. dump.pm2 — what `pm2 startup` resurrects. PM2_HOME overrides location.
|
|
285
|
+
const pm2Home = process.env.PM2_HOME ?? join(homedir(), '.pm2');
|
|
286
|
+
const dumpPath = join(pm2Home, 'dump.pm2');
|
|
287
|
+
if (existsSync(dumpPath)) {
|
|
288
|
+
out.push(green('pm2 process list saved (dump.pm2 present)'));
|
|
289
|
+
} else {
|
|
290
|
+
out.push(
|
|
291
|
+
yellow(
|
|
292
|
+
'pm2 process list not saved — run `npx pm2 save` so a reboot can ' +
|
|
293
|
+
'resurrect the app (nothing to restore without it)'
|
|
294
|
+
)
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// 2 + 3. systemd unit: present, and pointing at the deployment-local pm2.
|
|
299
|
+
const user = process.env.USER ?? process.env.LOGNAME;
|
|
300
|
+
const unitPath = user
|
|
301
|
+
? `/etc/systemd/system/pm2-${user}.service`
|
|
302
|
+
: null;
|
|
303
|
+
|
|
304
|
+
if (unitPath && existsSync(unitPath)) {
|
|
305
|
+
const localPm2 = join(dir, 'node_modules', 'pm2', 'bin', 'pm2');
|
|
306
|
+
let unit = '';
|
|
307
|
+
try {
|
|
308
|
+
unit = readFileSync(unitPath, 'utf8');
|
|
309
|
+
} catch {
|
|
310
|
+
out.push(yellow(`pm2 systemd unit present but unreadable (${unitPath})`));
|
|
311
|
+
return out;
|
|
312
|
+
}
|
|
313
|
+
const execStart = /^ExecStart=(.+)$/m.exec(unit)?.[1] ?? '';
|
|
314
|
+
if (execStart.includes(localPm2)) {
|
|
315
|
+
out.push(green('pm2 systemd boot unit installed (uses deployment-local pm2)'));
|
|
316
|
+
} else {
|
|
317
|
+
out.push(
|
|
318
|
+
red(
|
|
319
|
+
`pm2 systemd boot unit points at a different pm2 than this deployment's.\n ` +
|
|
320
|
+
`ExecStart: ${execStart || '(not found)'}\n ` +
|
|
321
|
+
`expected: ${localPm2} resurrect\n ` +
|
|
322
|
+
`Reboots will resurrect via the wrong pm2 (version skew). Re-run startup ` +
|
|
323
|
+
`with the local binary:\n ` +
|
|
324
|
+
`sudo env PATH=$PATH:${join(dir, 'node_modules', '.bin')} ${localPm2} ` +
|
|
325
|
+
`startup systemd -u $USER --hp $HOME`
|
|
326
|
+
)
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
} else {
|
|
330
|
+
out.push(
|
|
331
|
+
yellow(
|
|
332
|
+
'pm2 systemd boot unit not installed — the app will NOT restart after a ' +
|
|
333
|
+
'reboot. Run `npx pm2 startup systemd -u $USER --hp $HOME` and paste the ' +
|
|
334
|
+
'printed command (point it at this deployment’s pm2).'
|
|
335
|
+
)
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Global pm2 on PATH — the root cause of mode 3 and of day-to-day skew
|
|
340
|
+
// warnings. Advisory only.
|
|
341
|
+
const globalPm2 = findGlobalPm2(dir);
|
|
342
|
+
if (globalPm2) {
|
|
343
|
+
out.push(
|
|
344
|
+
yellow(
|
|
345
|
+
`a pm2 outside this deployment is on PATH (${globalPm2}) — it can fork a ` +
|
|
346
|
+
`mismatched daemon and trigger skew. Prefer \`npm run\` wrappers / \`npx pm2\` ` +
|
|
347
|
+
`from this directory; consider \`npm uninstall -g pm2\`.`
|
|
348
|
+
)
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return out;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Scan PATH for a `pm2` binary that isn't this deployment's. Returns the first
|
|
356
|
+
// such path, or null. Pure filesystem reads — no execution.
|
|
357
|
+
function findGlobalPm2(dir) {
|
|
358
|
+
const localBin = resolve(dir, 'node_modules', '.bin');
|
|
359
|
+
const dirs = (process.env.PATH ?? '').split(delimiter).filter(Boolean);
|
|
360
|
+
for (const d of dirs) {
|
|
361
|
+
if (resolve(d) === localBin) continue;
|
|
362
|
+
const candidate = join(d, 'pm2');
|
|
363
|
+
if (existsSync(candidate)) return candidate;
|
|
364
|
+
}
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
|
|
254
368
|
function checkLayoutDrift(dir) {
|
|
255
369
|
const pkgPath = join(dir, 'package.json');
|
|
256
370
|
if (!existsSync(pkgPath)) return yellow('package.json missing — cannot check layout');
|