@selvajs/cli 4.8.0-beta.9 → 4.8.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/README.md +7 -12
- package/package.json +5 -2
- package/src/checks/boot.js +18 -16
- package/src/checks/config.js +101 -9
- package/src/checks/runtime.js +372 -0
- package/src/cli.js +70 -2
- package/src/commands/create.js +53 -22
- package/src/commands/doctor.js +130 -83
- package/src/commands/init.js +12 -13
- package/src/commands/migrate.js +228 -59
- package/src/commands/pm2.js +12 -21
- package/src/deployment-package.js +156 -4
- package/src/env.js +197 -25
- package/src/paths.js +2 -3
- package/src/prompts.js +4 -14
package/src/commands/init.js
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
|
-
// `selva init` —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// • Reads current .env values; uses them as prompt defaults.
|
|
5
|
-
// • Never regenerates SELVA_HMAC_KEY / SELVA_AT_REST_KEY if they're set.
|
|
6
|
-
// Rotating those invalidates sessions and at-rest encryption — that's
|
|
7
|
-
// `selva keys rotate`'s job, not init's.
|
|
8
|
-
// • Doesn't touch package.json or run npm install.
|
|
1
|
+
// `selva init` — reconfigures an existing deployment. Unlike `create`, it reads
|
|
2
|
+
// the current .env as prompt defaults and never touches package.json or runs
|
|
3
|
+
// npm install.
|
|
9
4
|
|
|
10
5
|
import { join } from 'node:path';
|
|
11
6
|
import * as p from '@clack/prompts';
|
|
@@ -23,10 +18,10 @@ export async function runInit() {
|
|
|
23
18
|
const current = readEnvFile(envPath);
|
|
24
19
|
const values = await collectConfig({ defaults: current, mode: 'init' });
|
|
25
20
|
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
21
|
+
// Never regenerate an existing key: rotating SELVA_HMAC_KEY or
|
|
22
|
+
// SELVA_AT_REST_KEY invalidates sessions and at-rest encryption — that's
|
|
23
|
+
// `selva keys rotate`'s job, not init's. Missing keys (a scaffold that
|
|
24
|
+
// bailed mid-flight) are the one case it's safe to generate here.
|
|
30
25
|
if (
|
|
31
26
|
current.SELVA_HMAC_KEY &&
|
|
32
27
|
current.SELVA_HMAC_KEY !== 'replace-this-with-a-random-32-byte-hex-key'
|
|
@@ -47,7 +42,11 @@ export async function runInit() {
|
|
|
47
42
|
p.log.warn('SELVA_AT_REST_KEY was missing — generated a fresh one.');
|
|
48
43
|
}
|
|
49
44
|
|
|
50
|
-
|
|
45
|
+
// A values-only rewrite drops anything it isn't handed, and the prompts cover
|
|
46
|
+
// only the vars they ask about — every tuned knob the operator set by hand
|
|
47
|
+
// (BODY_SIZE_LIMIT, COMPUTE_*, LOG_LEVEL, …) lives outside that set. Carry
|
|
48
|
+
// the existing file forward and let the prompt answers win on top.
|
|
49
|
+
writeEnvFile(envPath, readEnvTemplate(dir), { ...current, ...values });
|
|
51
50
|
|
|
52
51
|
p.outro(
|
|
53
52
|
[
|
package/src/commands/migrate.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
// provider packages),
|
|
1
|
+
// Migrates a deployment to the current layout: rewrites package.json (drops legacy
|
|
2
|
+
// provider packages), removes stale selva.config.js, updates ecosystem.config.cjs.
|
|
3
3
|
// Idempotent; mirrors update's lifecycle (stop/mutate/start with rollback on failure).
|
|
4
4
|
|
|
5
5
|
import { existsSync, readFileSync, writeFileSync, copyFileSync, rmSync, renameSync } from 'node:fs';
|
|
@@ -13,32 +13,101 @@ import {
|
|
|
13
13
|
buildDeploymentPackageJson,
|
|
14
14
|
CANONICAL_FIELDS,
|
|
15
15
|
DEPENDENCIES,
|
|
16
|
-
|
|
16
|
+
isFloatingPin,
|
|
17
|
+
LEGACY_DEPENDENCIES,
|
|
18
|
+
npmDistTagVersion,
|
|
19
|
+
OVERRIDES,
|
|
20
|
+
resolveSelvaPins,
|
|
21
|
+
SELVA_PACKAGES
|
|
17
22
|
} from '../deployment-package.js';
|
|
18
23
|
import { APP_NAME, runPm2 } from './pm2.js';
|
|
19
24
|
|
|
25
|
+
// Where migrate parks the old node_modules while npm installs the new one.
|
|
26
|
+
// Left behind only if the process is killed mid-migration; `selva doctor`
|
|
27
|
+
// flags a surviving stash as an interrupted migration.
|
|
28
|
+
export const NODE_MODULES_STASH = 'node_modules.selva-migrate-bak';
|
|
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
|
+
|
|
20
34
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* reports it, because a stray copy of the dependency tree is both confusing and
|
|
24
|
-
* large enough to matter on a small VM.
|
|
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.
|
|
25
37
|
*/
|
|
26
|
-
export
|
|
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;
|
|
27
62
|
|
|
28
63
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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.
|
|
32
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
|
+
|
|
98
|
+
// `deps` are seams for tests: the rollback path destroys and restores a real
|
|
99
|
+
// dependency tree, so it has to run against a real directory — but not against
|
|
100
|
+
// a real npm or pm2 daemon.
|
|
33
101
|
export async function runMigrate(
|
|
34
102
|
_argv,
|
|
35
103
|
{
|
|
36
104
|
dir: injectedDir,
|
|
37
|
-
// --prefer-online
|
|
38
|
-
//
|
|
105
|
+
// --prefer-online: npm can otherwise resolve a cached packument that
|
|
106
|
+
// predates the release being migrated to.
|
|
39
107
|
install = (cwd) => execSync('npm install --prefer-online', { cwd, stdio: 'pipe' }),
|
|
40
108
|
confirm = (message) => p.confirm({ message, initialValue: true }),
|
|
41
|
-
pm2 = runPm2
|
|
109
|
+
pm2 = runPm2,
|
|
110
|
+
resolveVersion = npmDistTagVersion
|
|
42
111
|
} = {}
|
|
43
112
|
) {
|
|
44
113
|
const dir = injectedDir ?? resolveDeploymentDir();
|
|
@@ -53,11 +122,9 @@ export async function runMigrate(
|
|
|
53
122
|
}
|
|
54
123
|
|
|
55
124
|
const before = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
56
|
-
const target = buildTargetPackageJson(before);
|
|
125
|
+
const { pkg: target, notes: pinNotes } = buildTargetPackageJson(before, resolveVersion);
|
|
57
126
|
const pkgDiff = diffPackageJson(before, target);
|
|
58
127
|
|
|
59
|
-
// Side-files: selva.config.js (now unused) and ecosystem.config.cjs
|
|
60
|
-
// (must point at @selvajs/selva, not @selvajs/runtime).
|
|
61
128
|
const configPath = join(dir, 'selva.config.js');
|
|
62
129
|
const hasStaleConfig = existsSync(configPath);
|
|
63
130
|
|
|
@@ -65,9 +132,9 @@ export async function runMigrate(
|
|
|
65
132
|
const ecoHasStaleRuntime =
|
|
66
133
|
existsSync(ecoPath) && readFileSync(ecoPath, 'utf8').includes('@selvajs/runtime');
|
|
67
134
|
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
135
|
+
// The server drops its read-old-name shim after one minor version, so
|
|
136
|
+
// rewriting now is what keeps a tuned value tuned instead of silently
|
|
137
|
+
// reverting to defaults later.
|
|
71
138
|
const envPath = join(dir, '.env');
|
|
72
139
|
const envBefore = existsSync(envPath) ? readFileSync(envPath, 'utf8') : null;
|
|
73
140
|
const envRename = envBefore
|
|
@@ -100,15 +167,33 @@ export async function runMigrate(
|
|
|
100
167
|
for (const line of pkgDiff) console.log(' ' + line);
|
|
101
168
|
for (const line of sideFileChanges) console.log(' ' + line);
|
|
102
169
|
|
|
170
|
+
// The diff shows the pin that will be written; these explain why it is that
|
|
171
|
+
// pin. A preserved beta and a pin that couldn't be resolved both look like
|
|
172
|
+
// "no change" in the diff, and the second one is a stale pin, not a stable one.
|
|
173
|
+
for (const note of pinNotes) {
|
|
174
|
+
if (note.kind === 'prerelease') {
|
|
175
|
+
p.log.info(
|
|
176
|
+
`${note.name} stays on ${note.pin} — a prerelease pin is kept, since \`latest\` ` +
|
|
177
|
+
`means newest stable and would move this deployment backwards.`
|
|
178
|
+
);
|
|
179
|
+
} else {
|
|
180
|
+
p.log.warn(
|
|
181
|
+
`Could not resolve the current version of ${note.name} (${note.reason}). ` +
|
|
182
|
+
`Keeping ${JSON.stringify(note.pin ?? null)}; re-run migrate when the registry is reachable.`
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
103
187
|
const confirmed = await confirm('Apply these changes, reinstall, and restart?');
|
|
104
188
|
if (p.isCancel(confirmed) || !confirmed) {
|
|
105
189
|
p.cancel('Cancelled.');
|
|
106
190
|
return;
|
|
107
191
|
}
|
|
108
192
|
|
|
109
|
-
// Stop pm2 before npm rewrites
|
|
193
|
+
// Stop pm2 before npm rewrites — SvelteKit lazy-loads chunks, so a rewrite
|
|
194
|
+
// under a running server serves a broken page (same reason `update` stops first).
|
|
110
195
|
// A legacy deployment may predate the local-pm2 layout, so a missing binary
|
|
111
|
-
//
|
|
196
|
+
// isn't fatal here: there's nothing running for us to stop.
|
|
112
197
|
try {
|
|
113
198
|
const stopStatus = pm2(dir, ['stop', APP_NAME], { inherit: false });
|
|
114
199
|
if (stopStatus !== 0) {
|
|
@@ -118,19 +203,25 @@ export async function runMigrate(
|
|
|
118
203
|
p.log.warn('No deployment-local pm2 to stop — continuing.');
|
|
119
204
|
}
|
|
120
205
|
|
|
121
|
-
//
|
|
122
|
-
|
|
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
|
+
|
|
123
214
|
copyFileSync(pkgPath, bakPath);
|
|
124
215
|
writeFileSync(pkgPath, JSON.stringify(target, null, 2) + '\n', 'utf8');
|
|
125
216
|
|
|
126
217
|
if (hasStaleConfig) {
|
|
127
|
-
copyFileSync(configPath,
|
|
218
|
+
copyFileSync(configPath, configBak);
|
|
128
219
|
rmSync(configPath, { force: true });
|
|
129
220
|
}
|
|
130
221
|
|
|
131
222
|
if (ecoHasStaleRuntime) {
|
|
132
|
-
copyFileSync(ecoPath,
|
|
133
|
-
//
|
|
223
|
+
copyFileSync(ecoPath, ecoBak);
|
|
224
|
+
// Text substitution, not regeneration, so any operator customizations to the file survive.
|
|
134
225
|
const ecoContent = readFileSync(ecoPath, 'utf8').replace(
|
|
135
226
|
/@selvajs\/runtime/g,
|
|
136
227
|
'@selvajs/selva'
|
|
@@ -139,15 +230,15 @@ export async function runMigrate(
|
|
|
139
230
|
}
|
|
140
231
|
|
|
141
232
|
if (envRename.changes.length > 0) {
|
|
142
|
-
copyFileSync(envPath,
|
|
233
|
+
copyFileSync(envPath, envBak);
|
|
143
234
|
writeFileSync(envPath, envRename.text, 'utf8');
|
|
144
235
|
}
|
|
145
236
|
|
|
146
|
-
// A
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
237
|
+
// A legacy lockfile pins the old package set across a major bump, so this needs
|
|
238
|
+
// a clean install. But node_modules is also where pm2 lives, and rollback has to
|
|
239
|
+
// restart the app — deleting it outright would leave a failed migration with no
|
|
240
|
+
// way back up. Rename instead: atomic, keeps .bin symlinks intact, restorable
|
|
241
|
+
// until the install succeeds.
|
|
151
242
|
const nodeModules = join(dir, 'node_modules');
|
|
152
243
|
const nodeModulesBak = join(dir, NODE_MODULES_STASH);
|
|
153
244
|
const lockPath = join(dir, 'package-lock.json');
|
|
@@ -166,18 +257,18 @@ export async function runMigrate(
|
|
|
166
257
|
} catch (err) {
|
|
167
258
|
s.stop(pc.red('npm install failed — rolling back'));
|
|
168
259
|
copyFileSync(bakPath, pkgPath);
|
|
169
|
-
if (hasStaleConfig && existsSync(
|
|
170
|
-
copyFileSync(
|
|
260
|
+
if (hasStaleConfig && existsSync(configBak)) {
|
|
261
|
+
copyFileSync(configBak, configPath);
|
|
171
262
|
}
|
|
172
|
-
if (ecoHasStaleRuntime && existsSync(
|
|
173
|
-
copyFileSync(
|
|
263
|
+
if (ecoHasStaleRuntime && existsSync(ecoBak)) {
|
|
264
|
+
copyFileSync(ecoBak, ecoPath);
|
|
174
265
|
}
|
|
175
|
-
if (envRename.changes.length > 0 && existsSync(
|
|
176
|
-
copyFileSync(
|
|
266
|
+
if (envRename.changes.length > 0 && existsSync(envBak)) {
|
|
267
|
+
copyFileSync(envBak, envPath);
|
|
177
268
|
}
|
|
178
269
|
|
|
179
|
-
//
|
|
180
|
-
//
|
|
270
|
+
// A half-installed node_modules from the failed attempt is worse than the old
|
|
271
|
+
// one, so clear it before restoring the stash.
|
|
181
272
|
rmSync(nodeModules, { recursive: true, force: true });
|
|
182
273
|
if (existsSync(nodeModulesBak)) renameSync(nodeModulesBak, nodeModules);
|
|
183
274
|
if (lockBak !== null) writeFileSync(lockPath, lockBak, 'utf8');
|
|
@@ -187,6 +278,10 @@ export async function runMigrate(
|
|
|
187
278
|
process.exit(1);
|
|
188
279
|
}
|
|
189
280
|
|
|
281
|
+
// Migration itself already succeeded at this point, so a failed pm2 start is reported
|
|
282
|
+
// but doesn't exit non-zero or trigger rollback — the on-disk state is fine, only the
|
|
283
|
+
// running process isn't. Contrast restartAfterRollback, which does exit 1: that path
|
|
284
|
+
// runs after a rollback, where a failed restart leaves nothing serving traffic at all.
|
|
190
285
|
let status;
|
|
191
286
|
try {
|
|
192
287
|
status = pm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
|
|
@@ -194,10 +289,20 @@ export async function runMigrate(
|
|
|
194
289
|
status = 1;
|
|
195
290
|
p.log.error(`Could not invoke pm2: ${err instanceof Error ? err.message : err}`);
|
|
196
291
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
292
|
+
// A pin change means the just-installed pm2 CLI and the still-running
|
|
293
|
+
// daemon are now different versions. That's the repairable skew direction
|
|
294
|
+
// (daemon older), but it stays skewed until the operator acts — say so
|
|
295
|
+
// here, once, instead of letting doctor nag forever. Deliberately NOT
|
|
296
|
+
// automated: `pm2 update` recycles the daemon, and doing that unattended
|
|
297
|
+
// inside a migration converts a mid-flight failure into an outage (see
|
|
298
|
+
// plans/fixes/host-prerequisites-and-pm2-audit.md).
|
|
299
|
+
const pm2Notice = buildPm2UpgradeNotice(before.dependencies?.pm2, target.dependencies.pm2);
|
|
300
|
+
if (pm2Notice) p.log.warn(pm2Notice);
|
|
301
|
+
|
|
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`);
|
|
201
306
|
|
|
202
307
|
if (status === 0) {
|
|
203
308
|
p.outro(
|
|
@@ -212,14 +317,9 @@ export async function runMigrate(
|
|
|
212
317
|
}
|
|
213
318
|
}
|
|
214
319
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
* Never throws: the migration is already aborting, and an exception here would
|
|
219
|
-
* replace the rollback's diagnosis with a stack trace. A failure to restart is
|
|
220
|
-
* the operator's problem to act on, so it must be stated, not swallowed — the
|
|
221
|
-
* old code returned a bare 1 and printed nothing.
|
|
222
|
-
*/
|
|
320
|
+
// Never throws: the migration is already aborting, and an exception here would
|
|
321
|
+
// replace the rollback's diagnosis with a stack trace. A failure to restart is
|
|
322
|
+
// the operator's problem to act on, so it gets logged, not swallowed.
|
|
223
323
|
function restartAfterRollback(dir, pm2 = runPm2) {
|
|
224
324
|
try {
|
|
225
325
|
const status = pm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
|
|
@@ -239,12 +339,15 @@ function restartAfterRollback(dir, pm2 = runPm2) {
|
|
|
239
339
|
}
|
|
240
340
|
}
|
|
241
341
|
|
|
242
|
-
function buildTargetPackageJson(current) {
|
|
243
|
-
|
|
342
|
+
function buildTargetPackageJson(current, resolveVersion = npmDistTagVersion) {
|
|
343
|
+
const { pins, notes } = resolveSelvaPins(current.dependencies, resolveVersion);
|
|
344
|
+
const pkg = buildDeploymentPackageJson({
|
|
244
345
|
name: current.name ?? 'selva-deployment',
|
|
245
346
|
version: current.version ?? '0.1.0',
|
|
246
|
-
engines: current.engines
|
|
347
|
+
engines: current.engines,
|
|
348
|
+
dependencies: pins
|
|
247
349
|
});
|
|
350
|
+
return { pkg, notes };
|
|
248
351
|
}
|
|
249
352
|
|
|
250
353
|
// Format package.json diff for confirmation prompt (concise, not full diff).
|
|
@@ -263,6 +366,19 @@ function diffPackageJson(before, after) {
|
|
|
263
366
|
else if (a !== b) lines.push(`${pc.yellow('~')} ${name} ${pc.dim(a + ' → ' + b)}`);
|
|
264
367
|
}
|
|
265
368
|
|
|
369
|
+
// Without this section, an overrides-only change reads as "already on the
|
|
370
|
+
// current layout" and the security shim never reaches existing deployments.
|
|
371
|
+
const beforeOverrides = before.overrides ?? {};
|
|
372
|
+
const afterOverrides = after.overrides ?? {};
|
|
373
|
+
const allOverrides = new Set([...Object.keys(beforeOverrides), ...Object.keys(afterOverrides)]);
|
|
374
|
+
for (const name of [...allOverrides].sort()) {
|
|
375
|
+
const a = beforeOverrides[name];
|
|
376
|
+
const b = afterOverrides[name];
|
|
377
|
+
if (a && !b) lines.push(`${pc.red('-')} overrides.${name} ${pc.dim(a)}`);
|
|
378
|
+
else if (!a && b) lines.push(`${pc.green('+')} overrides.${name} ${pc.dim(b)}`);
|
|
379
|
+
else if (a !== b) lines.push(`${pc.yellow('~')} overrides.${name} ${pc.dim(a + ' → ' + b)}`);
|
|
380
|
+
}
|
|
381
|
+
|
|
266
382
|
const beforeScripts = before.scripts ?? {};
|
|
267
383
|
const afterScripts = after.scripts ?? {};
|
|
268
384
|
const allScripts = new Set([...Object.keys(beforeScripts), ...Object.keys(afterScripts)]);
|
|
@@ -276,9 +392,9 @@ function diffPackageJson(before, after) {
|
|
|
276
392
|
}
|
|
277
393
|
}
|
|
278
394
|
|
|
279
|
-
// Anything outside the canonical set is discarded by the rewrite
|
|
280
|
-
//
|
|
281
|
-
//
|
|
395
|
+
// Anything outside the canonical set is discarded by the rewrite — show it here
|
|
396
|
+
// too, or the operator confirms a change (e.g. losing devDependencies) they
|
|
397
|
+
// never saw.
|
|
282
398
|
for (const key of Object.keys(before).sort()) {
|
|
283
399
|
if (CANONICAL_FIELDS.has(key)) continue;
|
|
284
400
|
lines.push(`${pc.red('-')} ${key} ${pc.dim(summarize(before[key]))}`);
|
|
@@ -307,6 +423,37 @@ export function detectDrift(pkgJson, dir) {
|
|
|
307
423
|
if (!deps[name]) reasons.push(`${name} is missing`);
|
|
308
424
|
}
|
|
309
425
|
|
|
426
|
+
// pm2 is the one dependency pinned exactly, so a version difference is drift
|
|
427
|
+
// rather than an operator preference. Presence alone was checked above and
|
|
428
|
+
// isn't enough: a deployment scaffolded against an older CLI keeps its old
|
|
429
|
+
// pm2 forever, which is how the pin reached a two-major gap unnoticed.
|
|
430
|
+
if (deps.pm2 && deps.pm2 !== DEPENDENCIES.pm2) {
|
|
431
|
+
reasons.push(`pm2 is pinned to ${deps.pm2} (current scaffold pins ${DEPENDENCIES.pm2})`);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// A stored dist-tag re-resolves on every `npm install`, so the deployment
|
|
435
|
+
// follows the tag instead of a version an operator chose. Worse on a
|
|
436
|
+
// prerelease line, where `latest` points at the older stable release and the
|
|
437
|
+
// "upgrade" is a downgrade. Migrate wrote these before it learned to resolve
|
|
438
|
+
// tags, so existing deployments carry one and nothing else reports it.
|
|
439
|
+
for (const name of SELVA_PACKAGES) {
|
|
440
|
+
if (deps[name] && isFloatingPin(deps[name])) {
|
|
441
|
+
reasons.push(
|
|
442
|
+
`${name} is pinned to the floating tag ${JSON.stringify(deps[name])} ` +
|
|
443
|
+
`(re-resolves on every install; migrate writes a concrete version)`
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Overrides are security shims for vulnerable transitive deps — a
|
|
449
|
+
// deployment without them installs the vulnerable version on its next
|
|
450
|
+
// `npm install`, silently.
|
|
451
|
+
for (const [name, version] of Object.entries(OVERRIDES)) {
|
|
452
|
+
if (pkgJson?.overrides?.[name] !== version) {
|
|
453
|
+
reasons.push(`overrides.${name} is missing or outdated (security shim, expects ${version})`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
310
457
|
if (dir) {
|
|
311
458
|
const configPath = join(dir, 'selva.config.js');
|
|
312
459
|
if (existsSync(configPath)) {
|
|
@@ -328,4 +475,26 @@ export function detectDrift(pkgJson, dir) {
|
|
|
328
475
|
return reasons;
|
|
329
476
|
}
|
|
330
477
|
|
|
478
|
+
// Null when the pin didn't change. Exported for its test — the wording is the
|
|
479
|
+
// operator's only pointer to finish the upgrade.
|
|
480
|
+
export function buildPm2UpgradeNotice(beforeVersion, afterVersion) {
|
|
481
|
+
if (!beforeVersion || beforeVersion === afterVersion) return null;
|
|
482
|
+
return [
|
|
483
|
+
`pm2 was upgraded ${beforeVersion} → ${afterVersion}, but the running pm2 daemon is`,
|
|
484
|
+
`still the old version. Finish the upgrade (brief restart of managed processes):`,
|
|
485
|
+
``,
|
|
486
|
+
` npx pm2 update`,
|
|
487
|
+
` npx pm2 list # ${APP_NAME} must be listed and 'online' before the next step`,
|
|
488
|
+
` npx pm2 save`,
|
|
489
|
+
``,
|
|
490
|
+
`Do not run \`pm2 save\` until the list looks right. \`pm2 update\` empties the`,
|
|
491
|
+
`process table before restoring it, so saving a failed restore overwrites`,
|
|
492
|
+
`~/.pm2/dump.pm2 — the only record of what to bring back. If the list is empty,`,
|
|
493
|
+
`recover with \`npx pm2 resurrect\`, or \`npx pm2 start ecosystem.config.cjs\`.`,
|
|
494
|
+
``,
|
|
495
|
+
`If doctor reports a pm2 outside this deployment (global or apt install), follow`,
|
|
496
|
+
`the full procedure in the docs instead: self-hosting → deployment → prerequisites.`
|
|
497
|
+
].join('\n');
|
|
498
|
+
}
|
|
499
|
+
|
|
331
500
|
export { buildTargetPackageJson, diffPackageJson };
|
package/src/commands/pm2.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// Thin wrappers around PM2 commands:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Thin wrappers around PM2 commands: resolve to the deployment-local pm2, resync
|
|
2
|
+
// the daemon before state changes, and pass --update-env on restart so edits to
|
|
3
|
+
// .env actually take effect.
|
|
4
4
|
|
|
5
5
|
import { existsSync, readFileSync } from 'node:fs';
|
|
6
6
|
import { join } from 'node:path';
|
|
@@ -11,7 +11,6 @@ import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
|
|
|
11
11
|
|
|
12
12
|
export const APP_NAME = 'selva-compute';
|
|
13
13
|
|
|
14
|
-
// Resolve to deployment-local pm2 (no global fallback; prevents version skew).
|
|
15
14
|
export function pm2Bin(dir) {
|
|
16
15
|
const local = join(dir, 'node_modules', '.bin', process.platform === 'win32' ? 'pm2.cmd' : 'pm2');
|
|
17
16
|
if (!existsSync(local)) {
|
|
@@ -25,11 +24,10 @@ export function pm2Bin(dir) {
|
|
|
25
24
|
return local;
|
|
26
25
|
}
|
|
27
26
|
|
|
28
|
-
//
|
|
27
|
+
// Parses the two versions pm2 prints alongside its out-of-date warning:
|
|
29
28
|
// In memory PM2 version: 6.0.14
|
|
30
29
|
// Local PM2 version: 5.4.3
|
|
31
|
-
// Returns nulls when the shape
|
|
32
|
-
// which is the pre-existing behaviour.
|
|
30
|
+
// Returns nulls when the shape doesn't match, so callers fall back to resyncing.
|
|
33
31
|
export function parsePm2Skew(output) {
|
|
34
32
|
// eslint-disable-next-line no-control-regex -- pm2 colourises via chalk on a TTY
|
|
35
33
|
const clean = output.replace(/\[[0-9;]*m/g, '');
|
|
@@ -38,12 +36,10 @@ export function parsePm2Skew(output) {
|
|
|
38
36
|
return { daemon: daemon?.[1] ?? null, local: local?.[1] ?? null };
|
|
39
37
|
}
|
|
40
38
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
* the process table so selva-compute is never re-registered (issue #118).
|
|
46
|
-
*/
|
|
39
|
+
// True when the running daemon is newer than the deployment-local pm2 — i.e. a
|
|
40
|
+
// foreign global pm2 owns it. `pm2 update` can't fix that direction: it would
|
|
41
|
+
// downgrade the daemon and hand it a dump from a newer version, dropping the
|
|
42
|
+
// process table so selva-compute never gets re-registered.
|
|
47
43
|
export function daemonOutranksCli(daemon, local) {
|
|
48
44
|
if (!daemon || !local) return false;
|
|
49
45
|
const [a, b] = [daemon.split('.').map(Number), local.split('.').map(Number)];
|
|
@@ -54,7 +50,6 @@ export function daemonOutranksCli(daemon, local) {
|
|
|
54
50
|
return false;
|
|
55
51
|
}
|
|
56
52
|
|
|
57
|
-
// Check for daemon/CLI version mismatch; run `pm2 update` only when it can help.
|
|
58
53
|
function ensurePm2InSync(dir) {
|
|
59
54
|
const bin = pm2Bin(dir);
|
|
60
55
|
const probe = spawnSync(bin, ['ping'], {
|
|
@@ -92,13 +87,9 @@ function ensurePm2InSync(dir) {
|
|
|
92
87
|
}
|
|
93
88
|
}
|
|
94
89
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
* `bin` overrides where pm2 is resolved from — `selva migrate` wipes
|
|
99
|
-
* node_modules mid-flight and passes the binary it stashed beforehand, because
|
|
100
|
-
* resolving from the deployment at that moment would find nothing.
|
|
101
|
-
*/
|
|
90
|
+
// `bin` lets a caller pass an already-resolved pm2 path instead of resolving
|
|
91
|
+
// fresh from `dir` — needed when node_modules won't exist at call time (e.g.
|
|
92
|
+
// `selva migrate` wipes it mid-flight and passes the binary it stashed first).
|
|
102
93
|
export function runPm2(dir, args, { inherit = true, bin = pm2Bin(dir) } = {}) {
|
|
103
94
|
const result = spawnSync(bin, args, {
|
|
104
95
|
cwd: dir,
|