@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.
@@ -0,0 +1,372 @@
1
+ // What the host provides versus what the deployment brings itself.
2
+ //
3
+ // The split trips operators up: Node and npm come from the host (distro
4
+ // packages, nvm, whatever), pm2 comes from the deployment. Installing pm2 from
5
+ // apt or `npm i -g` looks like the tidy thing to do and produces a daemon that
6
+ // silently fights the local one. These checks name which side of the line each
7
+ // tool is on, so the answer is on screen instead of in a support thread.
8
+ //
9
+ // checkNodeEngine (doctor.js) already validates the running Node against
10
+ // engines.node. Nothing here repeats that — this covers what it can't see: npm,
11
+ // the pm2 install itself, and whether the Node running doctor is the same one
12
+ // pm2 launched the app under.
13
+
14
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
15
+ import { spawnSync } from 'node:child_process';
16
+ import { join } from 'node:path';
17
+ import { homedir } from 'node:os';
18
+ import { fixable, green, red, yellow } from './result.js';
19
+ import { PM2_VERSION } from '../deployment-package.js';
20
+
21
+ /** The real OS. Tests pass a stand-in with the same shape. */
22
+ export const runtimeEnv = {
23
+ platform: () => process.platform,
24
+ env: () => process.env,
25
+ homedir,
26
+ exists: existsSync,
27
+ readFile: (path) => readFileSync(path, 'utf8'),
28
+ realpath: (path) => {
29
+ try {
30
+ return realpathSync(path);
31
+ } catch {
32
+ return path;
33
+ }
34
+ },
35
+ execPath: () => process.execPath,
36
+ // Signal 0 probes liveness without touching the process. EPERM means it
37
+ // exists but belongs to another user — alive for our purposes.
38
+ processAlive: (pid) => {
39
+ try {
40
+ process.kill(pid, 0);
41
+ return true;
42
+ } catch (err) {
43
+ return err?.code === 'EPERM';
44
+ }
45
+ },
46
+ run: (cmd, args, opts) =>
47
+ spawnSync(cmd, args, {
48
+ ...opts,
49
+ encoding: 'utf8',
50
+ shell: process.platform === 'win32'
51
+ })
52
+ };
53
+
54
+ export function checkRuntimeEnvironment(dir, env = runtimeEnv) {
55
+ return [
56
+ checkNpm(env),
57
+ checkNodeProvenance(env),
58
+ ...checkPm2Install(dir, env),
59
+ ...checkPm2Daemon(dir, env),
60
+ ...checkPm2LogRotation(dir, env)
61
+ ];
62
+ }
63
+
64
+ // ── npm ─────────────────────────────────────────────────────────────────
65
+
66
+ // Debian's `nodejs` package doesn't always pull in `npm`, and some distro
67
+ // splits ship an npm several majors behind the Node beside it. Both only
68
+ // surface at `selva update`, which is the worst moment to discover it.
69
+ const MIN_NPM_MAJOR = 10;
70
+
71
+ function checkNpm(env) {
72
+ const probe = env.run('npm', ['--version'], {});
73
+ const version = String(probe.stdout ?? '')
74
+ .trim()
75
+ .split('\n')
76
+ .pop();
77
+
78
+ if ((probe.status ?? 1) !== 0 || !/^\d+\.\d+\.\d+/.test(version ?? '')) {
79
+ return red(
80
+ 'npm not found on PATH — `selva update` and `npm install` cannot run.\n ' +
81
+ 'On Debian/Ubuntu the `nodejs` package does not always include it. Install\n ' +
82
+ 'Node 24 from NodeSource, which bundles a matching npm:\n ' +
83
+ 'curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash - && sudo apt-get install -y nodejs'
84
+ );
85
+ }
86
+
87
+ const major = Number(/^(\d+)\./.exec(version)[1]);
88
+ if (major < MIN_NPM_MAJOR) {
89
+ return yellow(
90
+ `npm ${version} is older than the Node beside it usually ships (expected >= ${MIN_NPM_MAJOR}).\n ` +
91
+ 'A distro-split npm can resolve dependencies the runtime does not expect.\n ' +
92
+ 'Install Node from NodeSource so npm and Node come from one source.'
93
+ );
94
+ }
95
+ return green(`npm ${version} on PATH`);
96
+ }
97
+
98
+ // ── Node provenance ─────────────────────────────────────────────────────
99
+
100
+ // Which Node runs the app is decided by pm2's environment, not by the shell
101
+ // running doctor. When those differ — a distro Node on PATH for the login
102
+ // shell, an nvm Node for the daemon, or the reverse — doctor validates a
103
+ // version the app never uses, and engines.node passes while production runs
104
+ // something else entirely.
105
+ function checkNodeProvenance(env) {
106
+ const running = env.realpath(env.execPath());
107
+ const source = describeNodeSource(running);
108
+
109
+ const which = env.run(env.platform() === 'win32' ? 'where' : 'which', ['node'], {});
110
+ const onPath = String(which.stdout ?? '')
111
+ .trim()
112
+ .split('\n')[0]
113
+ ?.trim();
114
+
115
+ if (!onPath) {
116
+ return yellow(
117
+ `Node v${process.versions.node} at ${running} (${source}) — not resolvable on PATH`
118
+ );
119
+ }
120
+
121
+ if (env.realpath(onPath) === running) {
122
+ return green(`Node v${process.versions.node} — ${source} (${running})`);
123
+ }
124
+
125
+ return yellow(
126
+ `Two Node installations are in play: this shell resolves \`node\` to ${env.realpath(onPath)},\n ` +
127
+ `but doctor is running under ${running} (${source}). pm2 launches the app with the\n ` +
128
+ `Node it inherited when the daemon started, which may be neither. Confirm with:\n ` +
129
+ `npx pm2 describe selva-compute | grep -i "node.js version"`
130
+ );
131
+ }
132
+
133
+ // nvm/fnm-managed Nodes disappear for a systemd unit that starts before the
134
+ // user's shell profile runs — worth naming the manager rather than the path.
135
+ function describeNodeSource(path) {
136
+ const p = path.replace(/\\/g, '/');
137
+ if (/\/\.nvm\//.test(p)) return 'nvm-managed';
138
+ if (/\/\.fnm\/|\/fnm_multishells\//.test(p)) return 'fnm-managed';
139
+ if (/\/\.volta\//.test(p)) return 'volta-managed';
140
+ if (/^\/usr\/bin\/|^\/bin\//.test(p)) return 'distro package';
141
+ if (/^\/usr\/local\//.test(p)) return 'manual /usr/local install';
142
+ if (/\/snap\//.test(p)) return 'snap package';
143
+ return 'unknown source';
144
+ }
145
+
146
+ // ── pm2 install ─────────────────────────────────────────────────────────
147
+
148
+ // The deployment pins pm2 exactly (see deployment-package.js). This verifies
149
+ // the pin actually landed — a hand-edited package.json, a partial install, or
150
+ // an operator who ran `npm i -g pm2` and deleted the local one all end here.
151
+ function checkPm2Install(dir, env) {
152
+ const out = [];
153
+ const pkgPath = join(dir, 'node_modules', 'pm2', 'package.json');
154
+
155
+ if (!env.exists(pkgPath)) {
156
+ out.push(
157
+ red(
158
+ `pm2 is not installed in this deployment (expected ${join(dir, 'node_modules', 'pm2')}).\n ` +
159
+ 'Selva ships its own pinned pm2 on purpose — the `selva` commands refuse to fall\n ' +
160
+ 'back to a global one, because two pm2 versions sharing a daemon hang restarts.\n ' +
161
+ 'Fix: npm install'
162
+ )
163
+ );
164
+ return out;
165
+ }
166
+
167
+ let installed = null;
168
+ try {
169
+ installed = JSON.parse(env.readFile(pkgPath)).version ?? null;
170
+ } catch {
171
+ // Unreadable package.json — reported below as an unverifiable version.
172
+ }
173
+
174
+ if (!installed) {
175
+ out.push(yellow('pm2 installed but its version is unreadable — cannot verify the pin'));
176
+ } else if (installed === PM2_VERSION) {
177
+ out.push(green(`pm2 ${installed} installed locally (matches the pinned version)`));
178
+ } else {
179
+ out.push(
180
+ yellow(
181
+ `pm2 ${installed} is installed but this scaffold pins ${PM2_VERSION}.\n ` +
182
+ 'The pin is exact because pm2 CLI and daemon must match. Realign it:\n ' +
183
+ `npm install pm2@${PM2_VERSION} && npx pm2 update`
184
+ )
185
+ );
186
+ }
187
+
188
+ // A pm2 listed in dependencies with a range instead of the exact pin means
189
+ // package.json was hand-edited; the next `npm install` silently drifts again.
190
+ const declared = readDeclaredPm2(dir, env);
191
+ if (declared && declared !== PM2_VERSION) {
192
+ out.push(
193
+ yellow(
194
+ `package.json declares pm2 "${declared}" rather than the exact pin ${PM2_VERSION}.\n ` +
195
+ 'A range lets a later `npm install` adopt a version the running daemon is not.\n ' +
196
+ 'Run `selva migrate` to restore the canonical package.json.'
197
+ )
198
+ );
199
+ }
200
+
201
+ return out;
202
+ }
203
+
204
+ function readDeclaredPm2(dir, env) {
205
+ try {
206
+ const pkg = JSON.parse(env.readFile(join(dir, 'package.json')));
207
+ return pkg.dependencies?.pm2 ?? null;
208
+ } catch {
209
+ return null;
210
+ }
211
+ }
212
+
213
+ // ── pm2 daemon ──────────────────────────────────────────────────────────
214
+
215
+ // Reports three distinct states rather than pass/fail: no daemon (fine,
216
+ // nothing started yet), a matching daemon, or a daemon a foreign pm2 owns.
217
+ //
218
+ // `pm2 ping` SPAWNS a daemon when none is running, and doctor must stay
219
+ // read-only — so liveness is checked via pm2.pid + signal 0 first, and pm2
220
+ // itself is only invoked once a live daemon is confirmed.
221
+ //
222
+ // The daemon-is-newer direction is red and deliberately offers no fix: `pm2
223
+ // update` would downgrade the daemon and drop its process table, leaving
224
+ // selva-compute unregistered. Same reasoning as ensurePm2InSync in commands/pm2.js.
225
+ function checkPm2Daemon(dir, env) {
226
+ const bin = join(dir, 'node_modules', '.bin', env.platform() === 'win32' ? 'pm2.cmd' : 'pm2');
227
+ if (!env.exists(bin)) return [];
228
+
229
+ const pm2Home = env.env().PM2_HOME ?? join(env.homedir(), '.pm2');
230
+ const pidPath = join(pm2Home, 'pm2.pid');
231
+ if (!env.exists(pidPath)) {
232
+ return [yellow('pm2 daemon is not running — it starts on `selva start`')];
233
+ }
234
+ let pid = NaN;
235
+ try {
236
+ pid = Number(String(env.readFile(pidPath)).trim());
237
+ } catch {
238
+ // Unreadable pid file — fall through to the stale case.
239
+ }
240
+ if (!Number.isInteger(pid) || pid <= 0 || !env.processAlive(pid)) {
241
+ return [yellow('pm2 daemon is not running (stale pm2.pid) — it starts on `selva start`')];
242
+ }
243
+
244
+ const probe = env.run(bin, ['ping'], { cwd: dir });
245
+ const output = String(probe.stdout ?? '') + String(probe.stderr ?? '');
246
+
247
+ if (!/out-of-date/i.test(output)) {
248
+ if (/pong/i.test(output)) return [green('pm2 daemon responds and matches the local pm2')];
249
+ return [yellow('pm2 daemon did not answer `pm2 ping` — check `npx pm2 ping` by hand')];
250
+ }
251
+
252
+ const { daemon, local } = parseSkew(output);
253
+ if (daemon && local && compareVersions(daemon, local) > 0) {
254
+ return [
255
+ red(
256
+ `The running pm2 daemon (v${daemon}) is NEWER than this deployment's pm2 (v${local}),\n ` +
257
+ 'so a pm2 from outside the deployment owns it. `pm2 update` would downgrade the\n ' +
258
+ 'daemon and drop its process table, so `selva start` refuses outright.\n ' +
259
+ 'Resolve it (the app goes down briefly):\n ' +
260
+ `npx pm2 kill && sudo npm uninstall -g pm2 && npm install && npm start\n ` +
261
+ 'Then re-run: npx pm2 save && npx pm2 startup systemd -u $USER --hp $HOME'
262
+ )
263
+ ];
264
+ }
265
+
266
+ return [
267
+ yellow(
268
+ `pm2 daemon (v${daemon ?? 'unknown'}) differs from the local pm2 (v${local ?? PM2_VERSION}).\n ` +
269
+ 'Resync it — this briefly restarts managed processes: npx pm2 update'
270
+ )
271
+ ];
272
+ }
273
+
274
+ // ── pm2 log rotation ────────────────────────────────────────────────────
275
+
276
+ // pm2 appends to `$PM2_HOME/logs/*.log` forever. Nothing in a default install
277
+ // truncates them, and the app writes a line per solve — so the only bound is
278
+ // the disk. It fails late and all at once: the partition fills, and pm2 and the
279
+ // app go down together on a box that looked healthy the day before.
280
+ //
281
+ // Detected from the filesystem, never by running `pm2 ls`: an invocation with
282
+ // no live daemon spawns one as a side effect, which doctor must not do (see
283
+ // checkPm2Daemon).
284
+ //
285
+ // `$PM2_HOME/modules/<name>`, NOT `node_modules/` — pm2 keeps installed modules
286
+ // in their own tree. Checking the wrong path made this report "not installed"
287
+ // straight after a successful install, so `--fix` offered the same repair every
288
+ // run.
289
+ function checkPm2LogRotation(dir, env) {
290
+ const bin = join(dir, 'node_modules', '.bin', env.platform() === 'win32' ? 'pm2.cmd' : 'pm2');
291
+ if (!env.exists(bin)) return [];
292
+
293
+ const pm2Home = env.env().PM2_HOME ?? join(env.homedir(), '.pm2');
294
+ if (env.exists(join(pm2Home, 'modules', 'pm2-logrotate'))) {
295
+ return [green('pm2-logrotate installed — pm2 logs are rotated')];
296
+ }
297
+
298
+ return [
299
+ yellow(
300
+ 'pm2-logrotate is not installed — pm2 logs grow without bound and are only\n ' +
301
+ 'capped by free disk. Install it (takes effect immediately, no restart):\n ' +
302
+ 'npx pm2 install pm2-logrotate\n ' +
303
+ `npx pm2 set pm2-logrotate:rotateInterval "${LOGROTATE_SETTINGS.rotateInterval}"\n ` +
304
+ `npx pm2 set pm2-logrotate:retain ${LOGROTATE_SETTINGS.retain}\n ` +
305
+ `npx pm2 set pm2-logrotate:compress ${LOGROTATE_SETTINGS.compress}`,
306
+ fixable('install pm2-logrotate (weekly rotation, 8 kept, compressed)', () =>
307
+ installLogRotate(bin, dir, env)
308
+ )
309
+ )
310
+ ];
311
+ }
312
+
313
+ /**
314
+ * Weekly on Sunday 00:00, keeping 8 — about two months of history, compressed.
315
+ *
316
+ * Weekly rather than pm2's daily default: a Selva deployment writes a line per
317
+ * solve, so daily files are mostly idle days, and two months of them is a lot
318
+ * of near-empty archives to page through when tracing an incident.
319
+ */
320
+ const LOGROTATE_SETTINGS = {
321
+ rotateInterval: '0 0 * * 0',
322
+ retain: 8,
323
+ compress: true
324
+ };
325
+
326
+ // `pm2 install` needs no root and doesn't restart managed processes, which is
327
+ // what makes it eligible as a doctor fixer at all. The settings are applied
328
+ // after: failing to set one leaves rotation on at pm2's defaults, which still
329
+ // beats unbounded growth, so a failed `set` is reported, not fatal.
330
+ function installLogRotate(bin, dir, env) {
331
+ const install = env.run(bin, ['install', 'pm2-logrotate'], { cwd: dir });
332
+ if ((install.status ?? 1) !== 0) {
333
+ const detail = String(install.stderr ?? install.stdout ?? '')
334
+ .trim()
335
+ .split('\n')
336
+ .pop();
337
+ return red(`\`pm2 install pm2-logrotate\` failed${detail ? `: ${detail}` : ''}`);
338
+ }
339
+
340
+ const failed = [];
341
+ for (const [key, value] of Object.entries(LOGROTATE_SETTINGS)) {
342
+ const set = env.run(bin, ['set', `pm2-logrotate:${key}`, String(value)], { cwd: dir });
343
+ if ((set.status ?? 1) !== 0) failed.push(key);
344
+ }
345
+ if (failed.length > 0) {
346
+ return yellow(
347
+ `pm2-logrotate installed, but could not set: ${failed.join(', ')}. ` +
348
+ `Rotation is active at pm2's defaults; set them by hand with \`npx pm2 set\`.`
349
+ );
350
+ }
351
+ return green('pm2-logrotate installed — weekly rotation, 8 kept, compressed');
352
+ }
353
+
354
+ function parseSkew(output) {
355
+ // eslint-disable-next-line no-control-regex -- pm2 colourises via chalk on a TTY
356
+ const clean = output.replace(/\[[0-9;]*m/g, '');
357
+ return {
358
+ daemon: /In memory PM2 version:\s*v?(\d+\.\d+\.\d+)/i.exec(clean)?.[1] ?? null,
359
+ local: /Local PM2 version:\s*v?(\d+\.\d+\.\d+)/i.exec(clean)?.[1] ?? null
360
+ };
361
+ }
362
+
363
+ function compareVersions(a, b) {
364
+ const [x, y] = [a.split('.').map(Number), b.split('.').map(Number)];
365
+ for (let i = 0; i < 3; i++) {
366
+ if (x[i] > y[i]) return 1;
367
+ if (x[i] < y[i]) return -1;
368
+ }
369
+ return 0;
370
+ }
371
+
372
+ export { describeNodeSource, compareVersions, MIN_NPM_MAJOR };
package/src/cli.js CHANGED
@@ -18,6 +18,59 @@ const COMMANDS = {
18
18
  keys: () => import('./commands/keys.js').then((m) => keysDispatch(m))
19
19
  };
20
20
 
21
+ // `selva doctor --help` used to run the full doctor: commands own their argv,
22
+ // and only `doctor` reads a flag at all, so every other command silently
23
+ // ignored `--help` and did its work. Intercepting here keeps `--help` uniform
24
+ // across the whole surface without each command re-implementing it.
25
+ const USAGE = {
26
+ init: {
27
+ usage: 'selva init',
28
+ blurb:
29
+ 'Reconfigure this deployment. Prompts for the same values as scaffolding\nand rewrites .env; generated secrets are left alone.'
30
+ },
31
+ doctor: {
32
+ usage: 'selva doctor [--fix]',
33
+ blurb:
34
+ 'Validate .env, providers, Node engine, pm2 boot persistence, and package\npins. Read-only without --fix.',
35
+ flags: [['--fix', 'Apply the repairs doctor reports as automatic. Prompts before each.']]
36
+ },
37
+ start: { usage: 'selva start', blurb: 'pm2 start ecosystem.config.cjs' },
38
+ stop: { usage: 'selva stop', blurb: 'pm2 stop selva-compute' },
39
+ restart: {
40
+ usage: 'selva restart',
41
+ blurb:
42
+ 'pm2 restart selva-compute --update-env. The --update-env is why this\nexists: without it a pm2 restart keeps the old .env in memory.'
43
+ },
44
+ logs: { usage: 'selva logs', blurb: 'pm2 logs selva-compute' },
45
+ update: {
46
+ usage: 'selva update',
47
+ blurb:
48
+ 'npm update @selvajs/cli + @selvajs/selva, then restart. Rolls the\ndependency tree back if the install fails.'
49
+ },
50
+ migrate: {
51
+ usage: 'selva migrate',
52
+ blurb:
53
+ 'Bring package.json and the deployment layout onto the current release.\nRewrites keys, never comments.'
54
+ },
55
+ keys: {
56
+ usage: 'selva keys rotate <hmac|at-rest>',
57
+ blurb:
58
+ 'Rotate one secret in .env. Destructive and not reversible — the command\nspells out the blast radius and asks before writing.'
59
+ }
60
+ };
61
+
62
+ function printCommandHelp(command) {
63
+ const entry = USAGE[command];
64
+ const lines = [pc.bold(entry.usage), '', entry.blurb];
65
+ if (entry.flags) {
66
+ lines.push('', pc.bold('Flags:'));
67
+ for (const [flag, description] of entry.flags) {
68
+ lines.push(` ${flag.padEnd(22)}${description}`);
69
+ }
70
+ }
71
+ console.log(lines.join('\n'));
72
+ }
73
+
21
74
  function keysDispatch(m) {
22
75
  return async (argv) => {
23
76
  const sub = argv[0];
@@ -51,6 +104,11 @@ export async function runSelva(argv) {
51
104
  process.exit(1);
52
105
  }
53
106
 
107
+ if (rest.includes('--help') || rest.includes('-h')) {
108
+ printCommandHelp(command);
109
+ return;
110
+ }
111
+
54
112
  const run = await loader();
55
113
  await run(rest);
56
114
  }
@@ -60,6 +118,8 @@ function printHelp() {
60
118
  [
61
119
  pc.bold('selva') + ' — operate a Selva deployment',
62
120
  '',
121
+ pc.bold('Usage:') + ' npx selva <command>',
122
+ '',
63
123
  pc.bold('Commands:'),
64
124
  ' init Reconfigure this deployment (prompts again)',
65
125
  ' doctor [--fix] Validate env, providers, Node engine, and packages',
@@ -67,11 +127,19 @@ function printHelp() {
67
127
  ' stop pm2 stop selva-compute',
68
128
  ' restart pm2 restart selva-compute --update-env',
69
129
  ' logs pm2 logs selva-compute',
70
- ' update npm update @selvajs/selva + restart',
130
+ ' update npm update @selvajs/cli + @selvajs/selva, then restart',
71
131
  ' migrate Bring package.json onto the current layout',
72
132
  ' keys rotate <hmac|at-rest> Rotate a secret in .env (destructive)',
73
133
  '',
74
- pc.dim('To scaffold a new deployment: ') + pc.cyan('npx @selvajs/cli <dir>')
134
+ pc.dim('`npx selva <command> --help` explains one command.'),
135
+ '',
136
+ pc.bold('Two commands, two jobs:'),
137
+ ' ' + pc.cyan('npx @selvajs/cli <dir>') + ' scaffold a NEW deployment into <dir>',
138
+ ' ' + pc.cyan('npx selva <command>') + ' operate the deployment you are standing in',
139
+ '',
140
+ pc.dim('`npx selva` works in a deployment directory because @selvajs/cli is one of'),
141
+ pc.dim('its dependencies. The package.json scripts (`npm run doctor`, `npm start`)'),
142
+ pc.dim('are thin aliases for the same commands.')
75
143
  ].join('\n')
76
144
  );
77
145
  }