@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 +1 -1
- package/src/checks/config.js +1 -1
- package/src/checks/runtime.js +78 -2
- package/src/cli.js +69 -1
- package/src/commands/create.js +26 -0
- package/src/commands/doctor.js +39 -2
- package/src/commands/migrate.js +89 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@selvajs/cli",
|
|
3
|
-
"version": "4.8.0-beta.
|
|
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",
|
package/src/checks/config.js
CHANGED
|
@@ -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
|
}
|
package/src/checks/runtime.js
CHANGED
|
@@ -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('
|
|
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
|
}
|
package/src/commands/create.js
CHANGED
|
@@ -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;
|
package/src/commands/doctor.js
CHANGED
|
@@ -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.
|
package/src/commands/migrate.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
218
|
+
copyFileSync(configPath, configBak);
|
|
144
219
|
rmSync(configPath, { force: true });
|
|
145
220
|
}
|
|
146
221
|
|
|
147
222
|
if (ecoHasStaleRuntime) {
|
|
148
|
-
copyFileSync(ecoPath,
|
|
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,
|
|
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(
|
|
186
|
-
copyFileSync(
|
|
260
|
+
if (hasStaleConfig && existsSync(configBak)) {
|
|
261
|
+
copyFileSync(configBak, configPath);
|
|
187
262
|
}
|
|
188
|
-
if (ecoHasStaleRuntime && existsSync(
|
|
189
|
-
copyFileSync(
|
|
263
|
+
if (ecoHasStaleRuntime && existsSync(ecoBak)) {
|
|
264
|
+
copyFileSync(ecoBak, ecoPath);
|
|
190
265
|
}
|
|
191
|
-
if (envRename.changes.length > 0 && existsSync(
|
|
192
|
-
copyFileSync(
|
|
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 = [
|
|
228
|
-
if (hasStaleConfig) backupHints.push(
|
|
229
|
-
if (ecoHasStaleRuntime) backupHints.push(
|
|
230
|
-
if (envRename.changes.length > 0) backupHints.push(
|
|
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(
|