@selvajs/cli 4.8.0-beta.10 → 4.8.0-beta.12
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 +4 -1
- package/src/checks/boot.js +18 -16
- package/src/checks/config.js +101 -9
- package/src/checks/runtime.js +291 -0
- package/src/cli.js +1 -1
- package/src/commands/create.js +27 -22
- package/src/commands/doctor.js +91 -81
- package/src/commands/init.js +12 -13
- package/src/commands/migrate.js +142 -48
- 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/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,33 @@ 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
|
|
|
20
|
-
|
|
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.
|
|
25
|
-
*/
|
|
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.
|
|
26
28
|
export const NODE_MODULES_STASH = 'node_modules.selva-migrate-bak';
|
|
27
29
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
* directory — but not against a real npm or a real pm2 daemon.
|
|
32
|
-
*/
|
|
30
|
+
// `deps` are seams for tests: the rollback path destroys and restores a real
|
|
31
|
+
// dependency tree, so it has to run against a real directory — but not against
|
|
32
|
+
// a real npm or pm2 daemon.
|
|
33
33
|
export async function runMigrate(
|
|
34
34
|
_argv,
|
|
35
35
|
{
|
|
36
36
|
dir: injectedDir,
|
|
37
|
-
// --prefer-online
|
|
38
|
-
//
|
|
37
|
+
// --prefer-online: npm can otherwise resolve a cached packument that
|
|
38
|
+
// predates the release being migrated to.
|
|
39
39
|
install = (cwd) => execSync('npm install --prefer-online', { cwd, stdio: 'pipe' }),
|
|
40
40
|
confirm = (message) => p.confirm({ message, initialValue: true }),
|
|
41
|
-
pm2 = runPm2
|
|
41
|
+
pm2 = runPm2,
|
|
42
|
+
resolveVersion = npmDistTagVersion
|
|
42
43
|
} = {}
|
|
43
44
|
) {
|
|
44
45
|
const dir = injectedDir ?? resolveDeploymentDir();
|
|
@@ -53,11 +54,9 @@ export async function runMigrate(
|
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
const before = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
56
|
-
const target = buildTargetPackageJson(before);
|
|
57
|
+
const { pkg: target, notes: pinNotes } = buildTargetPackageJson(before, resolveVersion);
|
|
57
58
|
const pkgDiff = diffPackageJson(before, target);
|
|
58
59
|
|
|
59
|
-
// Side-files: selva.config.js (now unused) and ecosystem.config.cjs
|
|
60
|
-
// (must point at @selvajs/selva, not @selvajs/runtime).
|
|
61
60
|
const configPath = join(dir, 'selva.config.js');
|
|
62
61
|
const hasStaleConfig = existsSync(configPath);
|
|
63
62
|
|
|
@@ -65,9 +64,9 @@ export async function runMigrate(
|
|
|
65
64
|
const ecoHasStaleRuntime =
|
|
66
65
|
existsSync(ecoPath) && readFileSync(ecoPath, 'utf8').includes('@selvajs/runtime');
|
|
67
66
|
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
67
|
+
// The server drops its read-old-name shim after one minor version, so
|
|
68
|
+
// rewriting now is what keeps a tuned value tuned instead of silently
|
|
69
|
+
// reverting to defaults later.
|
|
71
70
|
const envPath = join(dir, '.env');
|
|
72
71
|
const envBefore = existsSync(envPath) ? readFileSync(envPath, 'utf8') : null;
|
|
73
72
|
const envRename = envBefore
|
|
@@ -100,15 +99,33 @@ export async function runMigrate(
|
|
|
100
99
|
for (const line of pkgDiff) console.log(' ' + line);
|
|
101
100
|
for (const line of sideFileChanges) console.log(' ' + line);
|
|
102
101
|
|
|
102
|
+
// The diff shows the pin that will be written; these explain why it is that
|
|
103
|
+
// pin. A preserved beta and a pin that couldn't be resolved both look like
|
|
104
|
+
// "no change" in the diff, and the second one is a stale pin, not a stable one.
|
|
105
|
+
for (const note of pinNotes) {
|
|
106
|
+
if (note.kind === 'prerelease') {
|
|
107
|
+
p.log.info(
|
|
108
|
+
`${note.name} stays on ${note.pin} — a prerelease pin is kept, since \`latest\` ` +
|
|
109
|
+
`means newest stable and would move this deployment backwards.`
|
|
110
|
+
);
|
|
111
|
+
} else {
|
|
112
|
+
p.log.warn(
|
|
113
|
+
`Could not resolve the current version of ${note.name} (${note.reason}). ` +
|
|
114
|
+
`Keeping ${JSON.stringify(note.pin ?? null)}; re-run migrate when the registry is reachable.`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
103
119
|
const confirmed = await confirm('Apply these changes, reinstall, and restart?');
|
|
104
120
|
if (p.isCancel(confirmed) || !confirmed) {
|
|
105
121
|
p.cancel('Cancelled.');
|
|
106
122
|
return;
|
|
107
123
|
}
|
|
108
124
|
|
|
109
|
-
// Stop pm2 before npm rewrites
|
|
125
|
+
// Stop pm2 before npm rewrites — SvelteKit lazy-loads chunks, so a rewrite
|
|
126
|
+
// under a running server serves a broken page (same reason `update` stops first).
|
|
110
127
|
// A legacy deployment may predate the local-pm2 layout, so a missing binary
|
|
111
|
-
//
|
|
128
|
+
// isn't fatal here: there's nothing running for us to stop.
|
|
112
129
|
try {
|
|
113
130
|
const stopStatus = pm2(dir, ['stop', APP_NAME], { inherit: false });
|
|
114
131
|
if (stopStatus !== 0) {
|
|
@@ -118,7 +135,6 @@ export async function runMigrate(
|
|
|
118
135
|
p.log.warn('No deployment-local pm2 to stop — continuing.');
|
|
119
136
|
}
|
|
120
137
|
|
|
121
|
-
// Back up for rollback on npm-install failure.
|
|
122
138
|
const bakPath = pkgPath + '.bak';
|
|
123
139
|
copyFileSync(pkgPath, bakPath);
|
|
124
140
|
writeFileSync(pkgPath, JSON.stringify(target, null, 2) + '\n', 'utf8');
|
|
@@ -130,7 +146,7 @@ export async function runMigrate(
|
|
|
130
146
|
|
|
131
147
|
if (ecoHasStaleRuntime) {
|
|
132
148
|
copyFileSync(ecoPath, ecoPath + '.bak');
|
|
133
|
-
//
|
|
149
|
+
// Text substitution, not regeneration, so any operator customizations to the file survive.
|
|
134
150
|
const ecoContent = readFileSync(ecoPath, 'utf8').replace(
|
|
135
151
|
/@selvajs\/runtime/g,
|
|
136
152
|
'@selvajs/selva'
|
|
@@ -143,11 +159,11 @@ export async function runMigrate(
|
|
|
143
159
|
writeFileSync(envPath, envRename.text, 'utf8');
|
|
144
160
|
}
|
|
145
161
|
|
|
146
|
-
// A
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
162
|
+
// A legacy lockfile pins the old package set across a major bump, so this needs
|
|
163
|
+
// a clean install. But node_modules is also where pm2 lives, and rollback has to
|
|
164
|
+
// restart the app — deleting it outright would leave a failed migration with no
|
|
165
|
+
// way back up. Rename instead: atomic, keeps .bin symlinks intact, restorable
|
|
166
|
+
// until the install succeeds.
|
|
151
167
|
const nodeModules = join(dir, 'node_modules');
|
|
152
168
|
const nodeModulesBak = join(dir, NODE_MODULES_STASH);
|
|
153
169
|
const lockPath = join(dir, 'package-lock.json');
|
|
@@ -176,8 +192,8 @@ export async function runMigrate(
|
|
|
176
192
|
copyFileSync(envPath + '.bak', envPath);
|
|
177
193
|
}
|
|
178
194
|
|
|
179
|
-
//
|
|
180
|
-
//
|
|
195
|
+
// A half-installed node_modules from the failed attempt is worse than the old
|
|
196
|
+
// one, so clear it before restoring the stash.
|
|
181
197
|
rmSync(nodeModules, { recursive: true, force: true });
|
|
182
198
|
if (existsSync(nodeModulesBak)) renameSync(nodeModulesBak, nodeModules);
|
|
183
199
|
if (lockBak !== null) writeFileSync(lockPath, lockBak, 'utf8');
|
|
@@ -187,6 +203,10 @@ export async function runMigrate(
|
|
|
187
203
|
process.exit(1);
|
|
188
204
|
}
|
|
189
205
|
|
|
206
|
+
// Migration itself already succeeded at this point, so a failed pm2 start is reported
|
|
207
|
+
// but doesn't exit non-zero or trigger rollback — the on-disk state is fine, only the
|
|
208
|
+
// running process isn't. Contrast restartAfterRollback, which does exit 1: that path
|
|
209
|
+
// runs after a rollback, where a failed restart leaves nothing serving traffic at all.
|
|
190
210
|
let status;
|
|
191
211
|
try {
|
|
192
212
|
status = pm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
|
|
@@ -194,6 +214,16 @@ export async function runMigrate(
|
|
|
194
214
|
status = 1;
|
|
195
215
|
p.log.error(`Could not invoke pm2: ${err instanceof Error ? err.message : err}`);
|
|
196
216
|
}
|
|
217
|
+
// A pin change means the just-installed pm2 CLI and the still-running
|
|
218
|
+
// daemon are now different versions. That's the repairable skew direction
|
|
219
|
+
// (daemon older), but it stays skewed until the operator acts — say so
|
|
220
|
+
// here, once, instead of letting doctor nag forever. Deliberately NOT
|
|
221
|
+
// automated: `pm2 update` recycles the daemon, and doing that unattended
|
|
222
|
+
// inside a migration converts a mid-flight failure into an outage (see
|
|
223
|
+
// plans/fixes/host-prerequisites-and-pm2-audit.md).
|
|
224
|
+
const pm2Notice = buildPm2UpgradeNotice(before.dependencies?.pm2, target.dependencies.pm2);
|
|
225
|
+
if (pm2Notice) p.log.warn(pm2Notice);
|
|
226
|
+
|
|
197
227
|
const backupHints = ['package.json.bak'];
|
|
198
228
|
if (hasStaleConfig) backupHints.push('selva.config.js.bak');
|
|
199
229
|
if (ecoHasStaleRuntime) backupHints.push('ecosystem.config.cjs.bak');
|
|
@@ -212,14 +242,9 @@ export async function runMigrate(
|
|
|
212
242
|
}
|
|
213
243
|
}
|
|
214
244
|
|
|
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
|
-
*/
|
|
245
|
+
// Never throws: the migration is already aborting, and an exception here would
|
|
246
|
+
// replace the rollback's diagnosis with a stack trace. A failure to restart is
|
|
247
|
+
// the operator's problem to act on, so it gets logged, not swallowed.
|
|
223
248
|
function restartAfterRollback(dir, pm2 = runPm2) {
|
|
224
249
|
try {
|
|
225
250
|
const status = pm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
|
|
@@ -239,12 +264,15 @@ function restartAfterRollback(dir, pm2 = runPm2) {
|
|
|
239
264
|
}
|
|
240
265
|
}
|
|
241
266
|
|
|
242
|
-
function buildTargetPackageJson(current) {
|
|
243
|
-
|
|
267
|
+
function buildTargetPackageJson(current, resolveVersion = npmDistTagVersion) {
|
|
268
|
+
const { pins, notes } = resolveSelvaPins(current.dependencies, resolveVersion);
|
|
269
|
+
const pkg = buildDeploymentPackageJson({
|
|
244
270
|
name: current.name ?? 'selva-deployment',
|
|
245
271
|
version: current.version ?? '0.1.0',
|
|
246
|
-
engines: current.engines
|
|
272
|
+
engines: current.engines,
|
|
273
|
+
dependencies: pins
|
|
247
274
|
});
|
|
275
|
+
return { pkg, notes };
|
|
248
276
|
}
|
|
249
277
|
|
|
250
278
|
// Format package.json diff for confirmation prompt (concise, not full diff).
|
|
@@ -263,6 +291,19 @@ function diffPackageJson(before, after) {
|
|
|
263
291
|
else if (a !== b) lines.push(`${pc.yellow('~')} ${name} ${pc.dim(a + ' → ' + b)}`);
|
|
264
292
|
}
|
|
265
293
|
|
|
294
|
+
// Without this section, an overrides-only change reads as "already on the
|
|
295
|
+
// current layout" and the security shim never reaches existing deployments.
|
|
296
|
+
const beforeOverrides = before.overrides ?? {};
|
|
297
|
+
const afterOverrides = after.overrides ?? {};
|
|
298
|
+
const allOverrides = new Set([...Object.keys(beforeOverrides), ...Object.keys(afterOverrides)]);
|
|
299
|
+
for (const name of [...allOverrides].sort()) {
|
|
300
|
+
const a = beforeOverrides[name];
|
|
301
|
+
const b = afterOverrides[name];
|
|
302
|
+
if (a && !b) lines.push(`${pc.red('-')} overrides.${name} ${pc.dim(a)}`);
|
|
303
|
+
else if (!a && b) lines.push(`${pc.green('+')} overrides.${name} ${pc.dim(b)}`);
|
|
304
|
+
else if (a !== b) lines.push(`${pc.yellow('~')} overrides.${name} ${pc.dim(a + ' → ' + b)}`);
|
|
305
|
+
}
|
|
306
|
+
|
|
266
307
|
const beforeScripts = before.scripts ?? {};
|
|
267
308
|
const afterScripts = after.scripts ?? {};
|
|
268
309
|
const allScripts = new Set([...Object.keys(beforeScripts), ...Object.keys(afterScripts)]);
|
|
@@ -276,9 +317,9 @@ function diffPackageJson(before, after) {
|
|
|
276
317
|
}
|
|
277
318
|
}
|
|
278
319
|
|
|
279
|
-
// Anything outside the canonical set is discarded by the rewrite
|
|
280
|
-
//
|
|
281
|
-
//
|
|
320
|
+
// Anything outside the canonical set is discarded by the rewrite — show it here
|
|
321
|
+
// too, or the operator confirms a change (e.g. losing devDependencies) they
|
|
322
|
+
// never saw.
|
|
282
323
|
for (const key of Object.keys(before).sort()) {
|
|
283
324
|
if (CANONICAL_FIELDS.has(key)) continue;
|
|
284
325
|
lines.push(`${pc.red('-')} ${key} ${pc.dim(summarize(before[key]))}`);
|
|
@@ -307,6 +348,37 @@ export function detectDrift(pkgJson, dir) {
|
|
|
307
348
|
if (!deps[name]) reasons.push(`${name} is missing`);
|
|
308
349
|
}
|
|
309
350
|
|
|
351
|
+
// pm2 is the one dependency pinned exactly, so a version difference is drift
|
|
352
|
+
// rather than an operator preference. Presence alone was checked above and
|
|
353
|
+
// isn't enough: a deployment scaffolded against an older CLI keeps its old
|
|
354
|
+
// pm2 forever, which is how the pin reached a two-major gap unnoticed.
|
|
355
|
+
if (deps.pm2 && deps.pm2 !== DEPENDENCIES.pm2) {
|
|
356
|
+
reasons.push(`pm2 is pinned to ${deps.pm2} (current scaffold pins ${DEPENDENCIES.pm2})`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// A stored dist-tag re-resolves on every `npm install`, so the deployment
|
|
360
|
+
// follows the tag instead of a version an operator chose. Worse on a
|
|
361
|
+
// prerelease line, where `latest` points at the older stable release and the
|
|
362
|
+
// "upgrade" is a downgrade. Migrate wrote these before it learned to resolve
|
|
363
|
+
// tags, so existing deployments carry one and nothing else reports it.
|
|
364
|
+
for (const name of SELVA_PACKAGES) {
|
|
365
|
+
if (deps[name] && isFloatingPin(deps[name])) {
|
|
366
|
+
reasons.push(
|
|
367
|
+
`${name} is pinned to the floating tag ${JSON.stringify(deps[name])} ` +
|
|
368
|
+
`(re-resolves on every install; migrate writes a concrete version)`
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Overrides are security shims for vulnerable transitive deps — a
|
|
374
|
+
// deployment without them installs the vulnerable version on its next
|
|
375
|
+
// `npm install`, silently.
|
|
376
|
+
for (const [name, version] of Object.entries(OVERRIDES)) {
|
|
377
|
+
if (pkgJson?.overrides?.[name] !== version) {
|
|
378
|
+
reasons.push(`overrides.${name} is missing or outdated (security shim, expects ${version})`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
310
382
|
if (dir) {
|
|
311
383
|
const configPath = join(dir, 'selva.config.js');
|
|
312
384
|
if (existsSync(configPath)) {
|
|
@@ -328,4 +400,26 @@ export function detectDrift(pkgJson, dir) {
|
|
|
328
400
|
return reasons;
|
|
329
401
|
}
|
|
330
402
|
|
|
403
|
+
// Null when the pin didn't change. Exported for its test — the wording is the
|
|
404
|
+
// operator's only pointer to finish the upgrade.
|
|
405
|
+
export function buildPm2UpgradeNotice(beforeVersion, afterVersion) {
|
|
406
|
+
if (!beforeVersion || beforeVersion === afterVersion) return null;
|
|
407
|
+
return [
|
|
408
|
+
`pm2 was upgraded ${beforeVersion} → ${afterVersion}, but the running pm2 daemon is`,
|
|
409
|
+
`still the old version. Finish the upgrade (brief restart of managed processes):`,
|
|
410
|
+
``,
|
|
411
|
+
` npx pm2 update`,
|
|
412
|
+
` npx pm2 list # ${APP_NAME} must be listed and 'online' before the next step`,
|
|
413
|
+
` npx pm2 save`,
|
|
414
|
+
``,
|
|
415
|
+
`Do not run \`pm2 save\` until the list looks right. \`pm2 update\` empties the`,
|
|
416
|
+
`process table before restoring it, so saving a failed restore overwrites`,
|
|
417
|
+
`~/.pm2/dump.pm2 — the only record of what to bring back. If the list is empty,`,
|
|
418
|
+
`recover with \`npx pm2 resurrect\`, or \`npx pm2 start ecosystem.config.cjs\`.`,
|
|
419
|
+
``,
|
|
420
|
+
`If doctor reports a pm2 outside this deployment (global or apt install), follow`,
|
|
421
|
+
`the full procedure in the docs instead: self-hosting → deployment → prerequisites.`
|
|
422
|
+
].join('\n');
|
|
423
|
+
}
|
|
424
|
+
|
|
331
425
|
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,
|
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
// than in either command — two copies drifted once already, and the pm2 pin is
|
|
4
4
|
// exactly the field where a silent difference costs the most.
|
|
5
5
|
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { execSync } from 'node:child_process';
|
|
8
|
+
import { join, dirname } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
6
11
|
/**
|
|
7
12
|
* pm2 is pinned exactly, not caret-ranged.
|
|
8
13
|
*
|
|
@@ -10,8 +15,29 @@
|
|
|
10
15
|
* two deployments installed a week apart resolve differently, and the newer one
|
|
11
16
|
* silently adopts the daemon the older one started — the skew `ensurePm2InSync`
|
|
12
17
|
* has to detect and, in the daemon-is-newer direction, cannot repair (#118).
|
|
18
|
+
*
|
|
19
|
+
* Read from this package's own devDependencies rather than written here as a
|
|
20
|
+
* literal. Dependabot scans manifests, not source, so a literal is invisible to
|
|
21
|
+
* it — this pin sat on 5.4.3 while upstream reached 7.x, which is precisely the
|
|
22
|
+
* drift operators point at when they argue for distro packages instead.
|
|
23
|
+
* `checkPinIsExact` keeps the manifest entry from being loosened to a range.
|
|
13
24
|
*/
|
|
14
|
-
const PM2_VERSION =
|
|
25
|
+
const PM2_VERSION = readPinnedPm2Version();
|
|
26
|
+
|
|
27
|
+
function readPinnedPm2Version() {
|
|
28
|
+
const manifest = JSON.parse(
|
|
29
|
+
readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8')
|
|
30
|
+
);
|
|
31
|
+
const pinned = manifest.devDependencies?.pm2;
|
|
32
|
+
if (!pinned || !/^\d+\.\d+\.\d+$/.test(pinned)) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`@selvajs/cli devDependencies.pm2 must be an exact version, got ${JSON.stringify(pinned)}. ` +
|
|
35
|
+
`Every deployment this CLI scaffolds inherits it, and a range there lets two ` +
|
|
36
|
+
`installs resolve different pm2s that then fight over one daemon.`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return pinned;
|
|
40
|
+
}
|
|
15
41
|
|
|
16
42
|
const SCRIPTS = {
|
|
17
43
|
start: 'selva start',
|
|
@@ -26,6 +52,11 @@ const SCRIPTS = {
|
|
|
26
52
|
* Packages a deployment carries. Providers are bundled into `@selvajs/selva`;
|
|
27
53
|
* the CLI is a dependency so `npm run start` resolves the `selva` bin locally
|
|
28
54
|
* instead of relying on a global install.
|
|
55
|
+
*
|
|
56
|
+
* The `latest` entries are what `create` resolves at scaffold time, never what
|
|
57
|
+
* gets written: `resolveSelvaPins` turns them into concrete versions. A stored
|
|
58
|
+
* `"latest"` re-resolves on every `npm install`, so the deployment follows the
|
|
59
|
+
* dist-tag forever and can change under the operator with no migrate at all.
|
|
29
60
|
*/
|
|
30
61
|
const DEPENDENCIES = {
|
|
31
62
|
'@selvajs/cli': 'latest',
|
|
@@ -33,6 +64,49 @@ const DEPENDENCIES = {
|
|
|
33
64
|
pm2: PM2_VERSION
|
|
34
65
|
};
|
|
35
66
|
|
|
67
|
+
/** The `@selvajs/*` packages whose pin belongs to the operator, not the scaffold. */
|
|
68
|
+
export const SELVA_PACKAGES = ['@selvajs/cli', '@selvajs/selva'];
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* True for a pin that names no version — a dist-tag (`latest`, `beta`) or the
|
|
72
|
+
* empty-ish values npm also accepts (`*`, `""`). These are the pins that move
|
|
73
|
+
* on their own, which is what makes them unfit to store.
|
|
74
|
+
*/
|
|
75
|
+
export function isFloatingPin(pin) {
|
|
76
|
+
if (typeof pin !== 'string') return true;
|
|
77
|
+
const trimmed = pin.trim();
|
|
78
|
+
if (trimmed === '' || trimmed === '*' || trimmed === 'x') return true;
|
|
79
|
+
// A range still names versions; only a bare tag has no digits at all.
|
|
80
|
+
return !/\d/.test(trimmed);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* True for a pin on a prerelease line (`^4.8.0-beta.11`).
|
|
85
|
+
*
|
|
86
|
+
* These are preserved across a migration. An operator on a beta channel chose
|
|
87
|
+
* it, and npm's `latest` tag points at the newest *stable* release — so
|
|
88
|
+
* resolving their pin through `latest` is a downgrade that reads as an upgrade
|
|
89
|
+
* in the confirmation diff.
|
|
90
|
+
*/
|
|
91
|
+
export function isPrereleasePin(pin) {
|
|
92
|
+
return typeof pin === 'string' && /\d+\.\d+\.\d+-/.test(pin);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* npm `overrides` forced onto every deployment — security patches for
|
|
97
|
+
* transitive deps whose direct parent hasn't picked up the fix yet. Each entry
|
|
98
|
+
* is a temporary shim: remove it once the parent ships the fixed version on
|
|
99
|
+
* its own (the override then matches what would install anyway, so removal is
|
|
100
|
+
* cleanup, never urgent).
|
|
101
|
+
*
|
|
102
|
+
* js-yaml: pm2 <= 7.0.3 pins 4.3.0, which carries the quadratic-complexity
|
|
103
|
+
* DoS advisories (GHSA-5p4m-2wfm-xmqj and friends); the fix landed in 4.3.1.
|
|
104
|
+
* Drop when pm2's own js-yaml dependency reaches >= 4.3.1.
|
|
105
|
+
*/
|
|
106
|
+
export const OVERRIDES = {
|
|
107
|
+
'js-yaml': '^4.3.1'
|
|
108
|
+
};
|
|
109
|
+
|
|
36
110
|
/**
|
|
37
111
|
* Legacy `@selvajs/*` packages that a deployment may still list. Each was
|
|
38
112
|
* either renamed or folded into `@selvajs/selva`; `migrate` drops them and
|
|
@@ -60,20 +134,98 @@ export const LEGACY_DEPENDENCIES = {
|
|
|
60
134
|
* deliberately, and dropping it removes a guard whose absence shows up only
|
|
61
135
|
* under real traffic (issue #176). Everything else a migration discards is
|
|
62
136
|
* listed by `diffPackageJson` before the operator confirms.
|
|
137
|
+
*
|
|
138
|
+
* `dependencies` is the second exception, for the same reason: a version pin is
|
|
139
|
+
* an operator choice too. `migrate` passes the resolved `@selvajs/*` pins in
|
|
140
|
+
* here; a fresh scaffold passes none and inherits the defaults.
|
|
63
141
|
*/
|
|
64
|
-
export function buildDeploymentPackageJson({ name, version = '0.1.0', engines }) {
|
|
142
|
+
export function buildDeploymentPackageJson({ name, version = '0.1.0', engines, dependencies }) {
|
|
65
143
|
const pkg = {
|
|
66
144
|
name: sanitizePackageName(name),
|
|
67
145
|
version,
|
|
68
146
|
private: true,
|
|
69
147
|
type: 'module',
|
|
70
148
|
scripts: { ...SCRIPTS },
|
|
71
|
-
dependencies: { ...DEPENDENCIES }
|
|
149
|
+
dependencies: { ...DEPENDENCIES, ...dependencies },
|
|
150
|
+
overrides: { ...OVERRIDES }
|
|
72
151
|
};
|
|
73
152
|
if (engines && typeof engines === 'object') pkg.engines = { ...engines };
|
|
74
153
|
return pkg;
|
|
75
154
|
}
|
|
76
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Ask npm what a dist-tag currently points at. Kept beside the pin logic it
|
|
158
|
+
* serves, and injected rather than called directly so tests never hit the
|
|
159
|
+
* network.
|
|
160
|
+
*/
|
|
161
|
+
export function npmDistTagVersion(name, tag) {
|
|
162
|
+
const out = execSync(`npm view ${name}@${tag} version`, {
|
|
163
|
+
encoding: 'utf8',
|
|
164
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
165
|
+
});
|
|
166
|
+
const version = String(out).trim().split('\n').pop()?.trim();
|
|
167
|
+
if (!version || !/^\d+\.\d+\.\d+/.test(version)) {
|
|
168
|
+
throw new Error(`npm view returned no usable version for ${name}@${tag}`);
|
|
169
|
+
}
|
|
170
|
+
return version;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Decide the `@selvajs/*` pins a migration should write.
|
|
175
|
+
*
|
|
176
|
+
* Two rules, both aimed at the same failure: a migration silently moving a
|
|
177
|
+
* deployment to a version the operator didn't ask for.
|
|
178
|
+
*
|
|
179
|
+
* - A prerelease pin is kept as-is. `latest` means newest stable, so
|
|
180
|
+
* resolving a beta pin through it walks the deployment backwards.
|
|
181
|
+
* - Anything else resolves to a concrete version via `resolveVersion`, so
|
|
182
|
+
* `"latest"` never reaches disk and the diff can show what it resolved to.
|
|
183
|
+
*
|
|
184
|
+
* `resolveVersion` hits the network. When it fails — offline, registry down, a
|
|
185
|
+
* package with no such tag — the existing pin is kept and the reason returned,
|
|
186
|
+
* so migrate can report it rather than inventing a version or aborting a
|
|
187
|
+
* migration whose other half (layout, overrides, env renames) is still valid.
|
|
188
|
+
*/
|
|
189
|
+
export function resolveSelvaPins(currentDeps, resolveVersion) {
|
|
190
|
+
const pins = {};
|
|
191
|
+
const notes = [];
|
|
192
|
+
|
|
193
|
+
for (const name of SELVA_PACKAGES) {
|
|
194
|
+
const current = currentDeps?.[name];
|
|
195
|
+
|
|
196
|
+
if (isPrereleasePin(current)) {
|
|
197
|
+
pins[name] = current;
|
|
198
|
+
notes.push({ name, kind: 'prerelease', pin: current });
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
let resolved = null;
|
|
203
|
+
try {
|
|
204
|
+
resolved = resolveVersion(name, 'latest');
|
|
205
|
+
} catch (err) {
|
|
206
|
+
notes.push({ name, kind: 'unresolved', pin: current, reason: err?.message ?? String(err) });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (resolved) {
|
|
210
|
+
pins[name] = `^${resolved}`;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Nothing resolvable and nothing worth keeping — fall back to the tag so
|
|
215
|
+
// the install still succeeds. Rare: offline *and* a floating pin.
|
|
216
|
+
if (isFloatingPin(current)) {
|
|
217
|
+
pins[name] = DEPENDENCIES[name];
|
|
218
|
+
if (!notes.some((n) => n.name === name)) {
|
|
219
|
+
notes.push({ name, kind: 'unresolved', pin: current, reason: 'no version returned' });
|
|
220
|
+
}
|
|
221
|
+
} else {
|
|
222
|
+
pins[name] = current;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return { pins, notes };
|
|
227
|
+
}
|
|
228
|
+
|
|
77
229
|
/**
|
|
78
230
|
* Top-level keys the canonical package.json defines. Anything outside this set
|
|
79
231
|
* that an operator added is dropped by `migrate` — `diffPackageJson` reports
|
|
@@ -86,11 +238,11 @@ export const CANONICAL_FIELDS = new Set([
|
|
|
86
238
|
'type',
|
|
87
239
|
'scripts',
|
|
88
240
|
'dependencies',
|
|
241
|
+
'overrides',
|
|
89
242
|
'engines'
|
|
90
243
|
]);
|
|
91
244
|
|
|
92
245
|
export function sanitizePackageName(name) {
|
|
93
|
-
// npm package names: lowercase, no spaces, limited punctuation.
|
|
94
246
|
return (
|
|
95
247
|
String(name ?? '')
|
|
96
248
|
.toLowerCase()
|