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

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.12",
3
+ "version": "4.8.0-beta.13",
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",
@@ -160,7 +160,7 @@ export function checkEnvDocumentation(commentLines, fix) {
160
160
  `never updated by \`selva migrate\`, so they still describe the release this ` +
161
161
  `deployment was installed at — including variables the code no longer reads. ` +
162
162
  `Strip them (values and your own inline notes are kept, .env.bak written):\n ` +
163
- `selva doctor --fix`,
163
+ `npx selva doctor --fix`,
164
164
  fix
165
165
  );
166
166
  }
@@ -15,7 +15,7 @@ import { existsSync, readFileSync, realpathSync } from 'node:fs';
15
15
  import { spawnSync } from 'node:child_process';
16
16
  import { join } from 'node:path';
17
17
  import { homedir } from 'node:os';
18
- import { green, red, yellow } from './result.js';
18
+ import { fixable, green, red, yellow } from './result.js';
19
19
  import { PM2_VERSION } from '../deployment-package.js';
20
20
 
21
21
  /** The real OS. Tests pass a stand-in with the same shape. */
@@ -56,7 +56,8 @@ export function checkRuntimeEnvironment(dir, env = runtimeEnv) {
56
56
  checkNpm(env),
57
57
  checkNodeProvenance(env),
58
58
  ...checkPm2Install(dir, env),
59
- ...checkPm2Daemon(dir, env)
59
+ ...checkPm2Daemon(dir, env),
60
+ ...checkPm2LogRotation(dir, env)
60
61
  ];
61
62
  }
62
63
 
@@ -270,6 +271,81 @@ function checkPm2Daemon(dir, env) {
270
271
  ];
271
272
  }
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). Installed modules live in `$PM2_HOME/node_modules`.
284
+ function checkPm2LogRotation(dir, env) {
285
+ const bin = join(dir, 'node_modules', '.bin', env.platform() === 'win32' ? 'pm2.cmd' : 'pm2');
286
+ if (!env.exists(bin)) return [];
287
+
288
+ const pm2Home = env.env().PM2_HOME ?? join(env.homedir(), '.pm2');
289
+ if (env.exists(join(pm2Home, 'node_modules', 'pm2-logrotate'))) {
290
+ return [green('pm2-logrotate installed — pm2 logs are rotated')];
291
+ }
292
+
293
+ return [
294
+ yellow(
295
+ 'pm2-logrotate is not installed — pm2 logs grow without bound and are only\n ' +
296
+ 'capped by free disk. Install it (takes effect immediately, no restart):\n ' +
297
+ 'npx pm2 install pm2-logrotate\n ' +
298
+ `npx pm2 set pm2-logrotate:rotateInterval "${LOGROTATE_SETTINGS.rotateInterval}"\n ` +
299
+ `npx pm2 set pm2-logrotate:retain ${LOGROTATE_SETTINGS.retain}\n ` +
300
+ `npx pm2 set pm2-logrotate:compress ${LOGROTATE_SETTINGS.compress}`,
301
+ fixable('install pm2-logrotate (weekly rotation, 8 kept, compressed)', () =>
302
+ installLogRotate(bin, dir, env)
303
+ )
304
+ )
305
+ ];
306
+ }
307
+
308
+ /**
309
+ * Weekly on Sunday 00:00, keeping 8 — about two months of history, compressed.
310
+ *
311
+ * Weekly rather than pm2's daily default: a Selva deployment writes a line per
312
+ * solve, so daily files are mostly idle days, and two months of them is a lot
313
+ * of near-empty archives to page through when tracing an incident.
314
+ */
315
+ const LOGROTATE_SETTINGS = {
316
+ rotateInterval: '0 0 * * 0',
317
+ retain: 8,
318
+ compress: true
319
+ };
320
+
321
+ // `pm2 install` needs no root and doesn't restart managed processes, which is
322
+ // what makes it eligible as a doctor fixer at all. The settings are applied
323
+ // after: failing to set one leaves rotation on at pm2's defaults, which still
324
+ // beats unbounded growth, so a failed `set` is reported, not fatal.
325
+ function installLogRotate(bin, dir, env) {
326
+ const install = env.run(bin, ['install', 'pm2-logrotate'], { cwd: dir });
327
+ if ((install.status ?? 1) !== 0) {
328
+ const detail = String(install.stderr ?? install.stdout ?? '')
329
+ .trim()
330
+ .split('\n')
331
+ .pop();
332
+ return red(`\`pm2 install pm2-logrotate\` failed${detail ? `: ${detail}` : ''}`);
333
+ }
334
+
335
+ const failed = [];
336
+ for (const [key, value] of Object.entries(LOGROTATE_SETTINGS)) {
337
+ const set = env.run(bin, ['set', `pm2-logrotate:${key}`, String(value)], { cwd: dir });
338
+ if ((set.status ?? 1) !== 0) failed.push(key);
339
+ }
340
+ if (failed.length > 0) {
341
+ return yellow(
342
+ `pm2-logrotate installed, but could not set: ${failed.join(', ')}. ` +
343
+ `Rotation is active at pm2's defaults; set them by hand with \`npx pm2 set\`.`
344
+ );
345
+ }
346
+ return green('pm2-logrotate installed — weekly rotation, 8 kept, compressed');
347
+ }
348
+
273
349
  function parseSkew(output) {
274
350
  // eslint-disable-next-line no-control-regex -- pm2 colourises via chalk on a TTY
275
351
  const clean = output.replace(/\[[0-9;]*m/g, '');
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',
@@ -71,7 +131,15 @@ function printHelp() {
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
  }
@@ -215,11 +215,37 @@ function runNpmInstall(cwd) {
215
215
  });
216
216
  }
217
217
 
218
+ // `npx @selvajs/cli` resolves to the `cli` bin — the scaffolder — while every
219
+ // operate command lives on the sibling `selva` bin. So `npx @selvajs/cli doctor
220
+ // --fix` lands here, where "doctor" is just a directory name and `--fix` an
221
+ // unknown flag. Name the bin split rather than letting the operator conclude
222
+ // their CLI is too old for a flag it has shipped for releases.
223
+ const OPERATE_COMMANDS = new Set([
224
+ 'doctor',
225
+ 'start',
226
+ 'stop',
227
+ 'restart',
228
+ 'logs',
229
+ 'update',
230
+ 'migrate',
231
+ 'keys',
232
+ 'init'
233
+ ]);
234
+
218
235
  function parseArgs(argv) {
219
236
  let dir;
220
237
  let force = false;
221
238
  let skipInstall = false;
222
239
  let yes = false;
240
+
241
+ if (OPERATE_COMMANDS.has(argv[0])) {
242
+ throw new Error(
243
+ `\`${argv[0]}\` is an operate command, but \`npx @selvajs/cli\` runs the scaffolder.\n` +
244
+ ` Run it through the deployment's own CLI instead:\n` +
245
+ ` npx selva ${argv.join(' ')}`
246
+ );
247
+ }
248
+
223
249
  for (const arg of argv) {
224
250
  if (arg === '--force') force = true;
225
251
  else if (arg === '--skip-install') skipInstall = true;
@@ -36,7 +36,7 @@ import {
36
36
  checkTenancy,
37
37
  resolveProviders
38
38
  } from '../checks/config.js';
39
- import { detectDrift, NODE_MODULES_STASH } from './migrate.js';
39
+ import { BACKUP_AGE_DAYS, classifyBackups, detectDrift, NODE_MODULES_STASH } from './migrate.js';
40
40
 
41
41
  export async function runDoctor(argv = []) {
42
42
  const fix = argv.includes('--fix');
@@ -52,6 +52,7 @@ export async function runDoctor(argv = []) {
52
52
  checks.push(checkFile(join(dir, 'ecosystem.config.cjs'), 'ecosystem.config.cjs present'));
53
53
  checks.push(checkLayoutDrift(dir));
54
54
  checks.push(checkMigrationLeftovers(dir));
55
+ checks.push(checkAgedBackups(dir));
55
56
  checks.push(checkSecret(env.SELVA_HMAC_KEY, 'SELVA_HMAC_KEY is a 32-byte hex string'));
56
57
  checks.push(checkSecret(env.SELVA_AT_REST_KEY, 'SELVA_AT_REST_KEY is a 32-byte hex string'));
57
58
 
@@ -99,7 +100,7 @@ export async function runDoctor(argv = []) {
99
100
  if (repairable > 0) {
100
101
  p.log.info(
101
102
  `${repairable} issue${repairable === 1 ? '' : 's'} can be repaired automatically — ` +
102
- `re-run with \`selva doctor --fix\`.`
103
+ `re-run with \`npx selva doctor --fix\`.`
103
104
  );
104
105
  }
105
106
  }
@@ -377,6 +378,42 @@ function checkMigrationLeftovers(dir) {
377
378
  );
378
379
  }
379
380
 
381
+ // Migration backups are timestamped so a later migration can't overwrite an
382
+ // earlier one's (#184), which means they accumulate. Deleting them silently
383
+ // would throw away the operator's only copy of a pre-migration config, so this
384
+ // reports and lets `--fix` opt in. `classifyBackups` always keeps the newest
385
+ // run, so the escape hatch is never emptied — only superseded generations go.
386
+ function checkAgedBackups(dir) {
387
+ let names;
388
+ try {
389
+ names = readdirSync(dir);
390
+ } catch {
391
+ return green('no aged migration backups');
392
+ }
393
+
394
+ const { aged } = classifyBackups(names);
395
+ if (aged.length === 0) return green('no aged migration backups');
396
+
397
+ const shown = aged.slice(0, 3).join(', ');
398
+ const more = aged.length > 3 ? `, +${aged.length - 3} more` : '';
399
+ return yellow(
400
+ `${aged.length} migration backup${aged.length === 1 ? '' : 's'} older than ` +
401
+ `${BACKUP_AGE_DAYS} days (${shown}${more}). The newest set is kept either way.`,
402
+ fixable(`delete ${aged.length} aged migration backup${aged.length === 1 ? '' : 's'}`, () => {
403
+ const failed = [];
404
+ for (const name of aged) {
405
+ try {
406
+ rmSync(join(dir, name), { force: true });
407
+ } catch (err) {
408
+ failed.push(`${name} (${err instanceof Error ? err.message : err})`);
409
+ }
410
+ }
411
+ if (failed.length > 0) return red(`could not remove: ${failed.join(', ')}`);
412
+ return green(`removed ${aged.length} aged backup${aged.length === 1 ? '' : 's'}`);
413
+ })
414
+ );
415
+ }
416
+
380
417
  // Reads the raw file rather than the parsed env: the finding IS the comments,
381
418
  // which parseEnv discards. The repair is attached here (not in checks/config.js)
382
419
  // to keep that module filesystem-free.
@@ -27,6 +27,74 @@ import { APP_NAME, runPm2 } from './pm2.js';
27
27
  // flags a surviving stash as an interrupted migration.
28
28
  export const NODE_MODULES_STASH = 'node_modules.selva-migrate-bak';
29
29
 
30
+ // Matches the backups migrate writes: `.env.2026-08-10T05-43-12.bak`. Anchored
31
+ // at both ends so an operator's own `notes.bak` is never picked up as ours.
32
+ export const BACKUP_PATTERN = /\.(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2})\.bak$/;
33
+
34
+ /**
35
+ * Colons are illegal in Windows filenames, so this is ISO with `:` swapped for
36
+ * `-` and the fractional seconds dropped: 2026-08-10T05-43-12.
37
+ */
38
+ export function backupStamp(now = new Date()) {
39
+ return now
40
+ .toISOString()
41
+ .replace(/\.\d+Z$/, '')
42
+ .replace(/:/g, '-');
43
+ }
44
+
45
+ /**
46
+ * A migration used to write `package.json.bak` at a fixed path, so the second
47
+ * substantive migration overwrote the first's copy — and since each run backs
48
+ * up the *current* file, that left the operator's original config recoverable
49
+ * from nowhere (#184). One stamp per run keeps a whole migration's backups
50
+ * together and makes them sortable.
51
+ *
52
+ * Not auto-pruned here: deleting an operator's only copy of their old config is
53
+ * worse than a few small files accumulating. `selva doctor` reports aged ones
54
+ * with a `--fix`, which keeps the decision theirs.
55
+ */
56
+ export function backupPathFor(filePath, stamp) {
57
+ return `${filePath}.${stamp}.bak`;
58
+ }
59
+
60
+ // Old enough that the migration which wrote it is long settled.
61
+ export const BACKUP_AGE_DAYS = 30;
62
+
63
+ /**
64
+ * Split migration backups into what to keep and what `doctor --fix` may delete.
65
+ *
66
+ * The newest run's backups are always kept, however old they are — on a
67
+ * deployment that migrates once a year, age alone would delete the only copy of
68
+ * the pre-migration config, which is the whole point of keeping them. So the
69
+ * rule is: keep the newest stamp, offer to delete stamps older than
70
+ * `BACKUP_AGE_DAYS`.
71
+ *
72
+ * `names` are basenames; the stamp is read from the filename rather than mtime,
73
+ * which a copy or restore would reset.
74
+ */
75
+ export function classifyBackups(names, now = new Date(), maxAgeDays = BACKUP_AGE_DAYS) {
76
+ const found = [];
77
+ for (const name of names) {
78
+ const m = BACKUP_PATTERN.exec(name);
79
+ if (m) found.push({ name, stamp: m[1] });
80
+ }
81
+ if (found.length === 0) return { keep: [], aged: [], newest: null };
82
+
83
+ // Stamps are fixed-width and zero-padded, so lexical order is chronological.
84
+ const newest = found.reduce((a, b) => (b.stamp > a.stamp ? b : a)).stamp;
85
+ const cutoff = now.getTime() - maxAgeDays * 24 * 60 * 60 * 1000;
86
+
87
+ const keep = [];
88
+ const aged = [];
89
+ for (const entry of found) {
90
+ // `2026-08-10T05-43-12` → parseable by restoring the colons.
91
+ const at = Date.parse(entry.stamp.replace(/T(\d{2})-(\d{2})-(\d{2})$/, 'T$1:$2:$3') + 'Z');
92
+ if (entry.stamp === newest || !Number.isFinite(at) || at >= cutoff) keep.push(entry.name);
93
+ else aged.push(entry.name);
94
+ }
95
+ return { keep, aged, newest };
96
+ }
97
+
30
98
  // `deps` are seams for tests: the rollback path destroys and restores a real
31
99
  // dependency tree, so it has to run against a real directory — but not against
32
100
  // a real npm or pm2 daemon.
@@ -135,17 +203,24 @@ export async function runMigrate(
135
203
  p.log.warn('No deployment-local pm2 to stop — continuing.');
136
204
  }
137
205
 
138
- const bakPath = pkgPath + '.bak';
206
+ // One stamp for the whole run, so a migration's backups sort together and a
207
+ // later migration cannot overwrite this one's (#184).
208
+ const stamp = backupStamp();
209
+ const bakPath = backupPathFor(pkgPath, stamp);
210
+ const configBak = backupPathFor(configPath, stamp);
211
+ const ecoBak = backupPathFor(ecoPath, stamp);
212
+ const envBak = backupPathFor(envPath, stamp);
213
+
139
214
  copyFileSync(pkgPath, bakPath);
140
215
  writeFileSync(pkgPath, JSON.stringify(target, null, 2) + '\n', 'utf8');
141
216
 
142
217
  if (hasStaleConfig) {
143
- copyFileSync(configPath, configPath + '.bak');
218
+ copyFileSync(configPath, configBak);
144
219
  rmSync(configPath, { force: true });
145
220
  }
146
221
 
147
222
  if (ecoHasStaleRuntime) {
148
- copyFileSync(ecoPath, ecoPath + '.bak');
223
+ copyFileSync(ecoPath, ecoBak);
149
224
  // Text substitution, not regeneration, so any operator customizations to the file survive.
150
225
  const ecoContent = readFileSync(ecoPath, 'utf8').replace(
151
226
  /@selvajs\/runtime/g,
@@ -155,7 +230,7 @@ export async function runMigrate(
155
230
  }
156
231
 
157
232
  if (envRename.changes.length > 0) {
158
- copyFileSync(envPath, envPath + '.bak');
233
+ copyFileSync(envPath, envBak);
159
234
  writeFileSync(envPath, envRename.text, 'utf8');
160
235
  }
161
236
 
@@ -182,14 +257,14 @@ export async function runMigrate(
182
257
  } catch (err) {
183
258
  s.stop(pc.red('npm install failed — rolling back'));
184
259
  copyFileSync(bakPath, pkgPath);
185
- if (hasStaleConfig && existsSync(configPath + '.bak')) {
186
- copyFileSync(configPath + '.bak', configPath);
260
+ if (hasStaleConfig && existsSync(configBak)) {
261
+ copyFileSync(configBak, configPath);
187
262
  }
188
- if (ecoHasStaleRuntime && existsSync(ecoPath + '.bak')) {
189
- copyFileSync(ecoPath + '.bak', ecoPath);
263
+ if (ecoHasStaleRuntime && existsSync(ecoBak)) {
264
+ copyFileSync(ecoBak, ecoPath);
190
265
  }
191
- if (envRename.changes.length > 0 && existsSync(envPath + '.bak')) {
192
- copyFileSync(envPath + '.bak', envPath);
266
+ if (envRename.changes.length > 0 && existsSync(envBak)) {
267
+ copyFileSync(envBak, envPath);
193
268
  }
194
269
 
195
270
  // A half-installed node_modules from the failed attempt is worse than the old
@@ -224,10 +299,10 @@ export async function runMigrate(
224
299
  const pm2Notice = buildPm2UpgradeNotice(before.dependencies?.pm2, target.dependencies.pm2);
225
300
  if (pm2Notice) p.log.warn(pm2Notice);
226
301
 
227
- const backupHints = ['package.json.bak'];
228
- if (hasStaleConfig) backupHints.push('selva.config.js.bak');
229
- if (ecoHasStaleRuntime) backupHints.push('ecosystem.config.cjs.bak');
230
- if (envRename.changes.length > 0) backupHints.push('.env.bak');
302
+ const backupHints = [`package.json.${stamp}.bak`];
303
+ if (hasStaleConfig) backupHints.push(`selva.config.js.${stamp}.bak`);
304
+ if (ecoHasStaleRuntime) backupHints.push(`ecosystem.config.cjs.${stamp}.bak`);
305
+ if (envRename.changes.length > 0) backupHints.push(`.env.${stamp}.bak`);
231
306
 
232
307
  if (status === 0) {
233
308
  p.outro(