@selvajs/cli 4.8.0-beta.10 → 4.8.0-beta.11
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 +4 -12
- package/src/checks/runtime.js +289 -0
- package/src/cli.js +1 -1
- package/src/commands/create.js +11 -20
- package/src/commands/doctor.js +34 -51
- package/src/commands/init.js +7 -12
- package/src/commands/migrate.js +92 -43
- package/src/commands/pm2.js +12 -21
- package/src/deployment-package.js +44 -3
- package/src/env.js +12 -23
- package/src/paths.js +2 -3
- package/src/prompts.js +4 -14
package/src/commands/doctor.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// Node engine, boot persistence. Yellow warnings don't fail, red failures exit 1.
|
|
3
3
|
//
|
|
4
4
|
// Read-only by default. `--fix` applies the repairs attached to individual
|
|
5
|
-
// checks, each behind its own confirmation —
|
|
6
|
-
// no runtime restart. See
|
|
5
|
+
// checks, each behind its own confirmation — only fixes needing no root and
|
|
6
|
+
// no runtime restart exist. See applyFixes.
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
9
|
existsSync,
|
|
@@ -23,6 +23,7 @@ import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
|
|
|
23
23
|
import { satisfiesNodeRange } from '../node-range.js';
|
|
24
24
|
import { green, yellow, red, fixable } from '../checks/result.js';
|
|
25
25
|
import { checkBootPersistence } from '../checks/boot.js';
|
|
26
|
+
import { checkRuntimeEnvironment } from '../checks/runtime.js';
|
|
26
27
|
import {
|
|
27
28
|
checkDeprecatedEnv,
|
|
28
29
|
checkOrigin,
|
|
@@ -73,6 +74,7 @@ export async function runDoctor(argv = []) {
|
|
|
73
74
|
checks.push(checkPackage(dir, '@selvajs/selva'));
|
|
74
75
|
checks.push(checkNodeEngine(dir));
|
|
75
76
|
checks.push(checkCliRuntimeAlignment(dir));
|
|
77
|
+
checks.push(...checkRuntimeEnvironment(dir));
|
|
76
78
|
checks.push(...checkBootPersistence(dir));
|
|
77
79
|
checks.push(checkOrigin(env));
|
|
78
80
|
checks.push(...checkDeprecatedEnv(env));
|
|
@@ -105,15 +107,9 @@ export async function runDoctor(argv = []) {
|
|
|
105
107
|
}
|
|
106
108
|
}
|
|
107
109
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
*
|
|
112
|
-
* Nothing here needs root: anything privileged (the systemd unit) or that would
|
|
113
|
-
* restart this process (a Node upgrade) stays a printed instruction, because a
|
|
114
|
-
* fixer that dies halfway through its own runtime leaves the operator with no
|
|
115
|
-
* obvious path back.
|
|
116
|
-
*/
|
|
110
|
+
// Anything privileged (the systemd unit) or that would restart this process
|
|
111
|
+
// (a Node upgrade) stays a printed instruction instead of a fixer — one that
|
|
112
|
+
// dies halfway through its own runtime leaves the operator with no way back.
|
|
117
113
|
async function applyFixes(resolved, failures) {
|
|
118
114
|
const repairs = resolved.filter((c) => c.fix && c.severity !== 'green');
|
|
119
115
|
if (repairs.length === 0) {
|
|
@@ -183,8 +179,7 @@ async function checkSupabase(env) {
|
|
|
183
179
|
return red(`SUPABASE_URL="${env.SUPABASE_URL}" is not a valid URL`);
|
|
184
180
|
}
|
|
185
181
|
|
|
186
|
-
//
|
|
187
|
-
// errors — operators may be offline at install time.
|
|
182
|
+
// Network errors go yellow, not red — operators may be offline at install time.
|
|
188
183
|
try {
|
|
189
184
|
const controller = new AbortController();
|
|
190
185
|
const timer = setTimeout(() => controller.abort(), 4000);
|
|
@@ -199,11 +194,10 @@ async function checkSupabase(env) {
|
|
|
199
194
|
}
|
|
200
195
|
}
|
|
201
196
|
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
// confusing deploy.
|
|
197
|
+
// Compares the newest migration shipped by @selvajs/supabase-provider against
|
|
198
|
+
// what the database reports via selva.migration_head(). Red on skew — the app
|
|
199
|
+
// degrades /api/health to 503 at boot in the same state, so catching it here
|
|
200
|
+
// saves a confusing deploy.
|
|
207
201
|
async function checkSupabaseMigrations(dir, env) {
|
|
208
202
|
if (!env.SUPABASE_URL || !env.SUPABASE_SERVICE_ROLE_KEY) {
|
|
209
203
|
return yellow('Migration-head check skipped — Supabase env incomplete');
|
|
@@ -264,6 +258,7 @@ async function checkSupabaseMigrations(dir, env) {
|
|
|
264
258
|
`Database reports no applied migrations (expected head ${expected}). Sync + run: npx supabase db push`
|
|
265
259
|
);
|
|
266
260
|
}
|
|
261
|
+
// String comparison — relies on both heads being fixed-width, zero-padded timestamps.
|
|
267
262
|
if (actual < expected) {
|
|
268
263
|
return red(
|
|
269
264
|
`Database migration head ${actual} is behind the installed provider (${expected}). Sync + run: npx supabase db push`
|
|
@@ -271,7 +266,6 @@ async function checkSupabaseMigrations(dir, env) {
|
|
|
271
266
|
}
|
|
272
267
|
return green(`Database migration head ${actual} matches the installed provider`);
|
|
273
268
|
} catch (err) {
|
|
274
|
-
// Soft-fail on network errors, same as checkSupabase.
|
|
275
269
|
return yellow(`Migration-head check skipped (${err.message ?? err})`);
|
|
276
270
|
}
|
|
277
271
|
}
|
|
@@ -295,7 +289,7 @@ function majorOf(version) {
|
|
|
295
289
|
return m ? Number(m[1]) : null;
|
|
296
290
|
}
|
|
297
291
|
|
|
298
|
-
// CLI and runtime release
|
|
292
|
+
// CLI and runtime release as a fixed group; major-version skew means a stale CLI pin.
|
|
299
293
|
function checkCliRuntimeAlignment(dir) {
|
|
300
294
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
301
295
|
const cliVersion = readPackageVersion(join(here, '..', '..', 'package.json'));
|
|
@@ -303,7 +297,7 @@ function checkCliRuntimeAlignment(dir) {
|
|
|
303
297
|
join(dir, 'node_modules', '@selvajs', 'selva', 'package.json')
|
|
304
298
|
);
|
|
305
299
|
|
|
306
|
-
//
|
|
300
|
+
// checkPackage already reports missing runtime as red; stay quiet here.
|
|
307
301
|
if (!runtimeVersion) return yellow('CLI/runtime version check skipped (runtime not installed)');
|
|
308
302
|
if (!cliVersion) return yellow('CLI/runtime version check skipped (could not read CLI version)');
|
|
309
303
|
|
|
@@ -324,16 +318,10 @@ function checkCliRuntimeAlignment(dir) {
|
|
|
324
318
|
);
|
|
325
319
|
}
|
|
326
320
|
|
|
327
|
-
//
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
* npm only enforces engines under `engine-strict=true`, which no deployment
|
|
332
|
-
* sets, so a mismatched install succeeds silently and `/api/health` still
|
|
333
|
-
* returns 200 — routes using newer APIs throw only under real traffic
|
|
334
|
-
* (issue #176). Deliberately NOT auto-fixable: upgrading Node is
|
|
335
|
-
* distro-specific and would restart the process running this check.
|
|
336
|
-
*/
|
|
321
|
+
// npm only enforces engines.node under engine-strict=true, which no deployment
|
|
322
|
+
// sets, so a mismatched install succeeds silently and /api/health still returns
|
|
323
|
+
// 200 — routes using newer APIs throw only under real traffic. Not auto-fixable:
|
|
324
|
+
// upgrading Node is distro-specific and would restart the process running this check.
|
|
337
325
|
function checkNodeEngine(dir) {
|
|
338
326
|
let required;
|
|
339
327
|
try {
|
|
@@ -363,12 +351,10 @@ function checkNodeEngine(dir) {
|
|
|
363
351
|
);
|
|
364
352
|
}
|
|
365
353
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
* the dependency tree and the deployment may be running on the wrong one.
|
|
371
|
-
*/
|
|
354
|
+
// `selva migrate` parks the old node_modules aside so a failed install can be
|
|
355
|
+
// rolled back, and removes it on either outcome. One surviving here means the
|
|
356
|
+
// migration was killed mid-flight — worth flagging since it's a full copy of
|
|
357
|
+
// the dependency tree and the deployment may be running on the wrong one.
|
|
372
358
|
function checkMigrationLeftovers(dir) {
|
|
373
359
|
const stash = join(dir, NODE_MODULES_STASH);
|
|
374
360
|
if (!existsSync(stash)) return green('no interrupted migration left behind');
|
|
@@ -403,19 +389,18 @@ function checkLayoutDrift(dir) {
|
|
|
403
389
|
);
|
|
404
390
|
}
|
|
405
391
|
|
|
406
|
-
//
|
|
407
|
-
//
|
|
392
|
+
// Duplicated from HeaderAuthProvider.DEFAULT_HEADERS to avoid loading the runtime here.
|
|
393
|
+
// A test in providers/header-auth pins both sides, so drift surfaces in CI.
|
|
408
394
|
const DEFAULT_HEADER_NAMES = {
|
|
409
395
|
upn: 'SELVA-UserPrincipalName',
|
|
410
396
|
email: 'SELVA-Email',
|
|
411
397
|
displayName: 'SELVA-DisplayName'
|
|
412
398
|
};
|
|
413
399
|
|
|
414
|
-
//
|
|
400
|
+
// Static config only — runtime invariants like spoofing protection can't be checked from here.
|
|
415
401
|
function checkHeaderAuth(dir, env, dataProvider) {
|
|
416
402
|
const out = [];
|
|
417
403
|
|
|
418
|
-
// Allowlist file location.
|
|
419
404
|
const allowlistDir = env.HEADER_AUTH_DATA_DIR ?? env.DATA_PATH;
|
|
420
405
|
if (!allowlistDir) {
|
|
421
406
|
out.push(red('HEADER_AUTH_DATA_DIR (or DATA_PATH) unset — provider will fail to start'));
|
|
@@ -425,21 +410,21 @@ function checkHeaderAuth(dir, env, dataProvider) {
|
|
|
425
410
|
if (existsSync(allowlistPath)) {
|
|
426
411
|
out.push(green(`header-allowlist.json present (${allowlistDir}/header-allowlist.json)`));
|
|
427
412
|
} else {
|
|
428
|
-
//
|
|
413
|
+
// The provider creates this file lazily, but a missing file locks everyone out
|
|
414
|
+
// until one exists — worth a warning even though nothing is technically broken.
|
|
429
415
|
out.push(
|
|
430
416
|
yellow(
|
|
431
417
|
`header-allowlist.json not found at ${allowlistDir}/ — no users will be allowed in until one is added`
|
|
432
418
|
)
|
|
433
419
|
);
|
|
434
420
|
|
|
435
|
-
//
|
|
421
|
+
// DATA_PATH's own writability is checked separately (checkDataPath).
|
|
436
422
|
if (env.HEADER_AUTH_DATA_DIR) {
|
|
437
423
|
out.push(checkDirWritable(allowlistAbsDir, `HEADER_AUTH_DATA_DIR=${allowlistDir}`));
|
|
438
424
|
}
|
|
439
425
|
}
|
|
440
426
|
}
|
|
441
427
|
|
|
442
|
-
// HOST binding (loopback recommended for header-auth).
|
|
443
428
|
const host = env.HOST ?? '0.0.0.0';
|
|
444
429
|
if (host === '127.0.0.1' || host === 'localhost') {
|
|
445
430
|
out.push(green(`HOST=${host} (loopback-only)`));
|
|
@@ -452,12 +437,11 @@ function checkHeaderAuth(dir, env, dataProvider) {
|
|
|
452
437
|
);
|
|
453
438
|
}
|
|
454
439
|
|
|
455
|
-
//
|
|
440
|
+
// header-auth always sits behind a reverse proxy, so ORIGIN is required.
|
|
456
441
|
if (!env.ORIGIN) {
|
|
457
442
|
out.push(red('ORIGIN unset — required for header-auth (always behind a proxy)'));
|
|
458
443
|
}
|
|
459
444
|
|
|
460
|
-
// Non-local data provider: HEADER_AUTH_DATA_DIR must be explicit (no DATA_PATH fallback).
|
|
461
445
|
if (dataProvider !== 'local' && !env.HEADER_AUTH_DATA_DIR) {
|
|
462
446
|
out.push(
|
|
463
447
|
red(
|
|
@@ -467,10 +451,10 @@ function checkHeaderAuth(dir, env, dataProvider) {
|
|
|
467
451
|
);
|
|
468
452
|
}
|
|
469
453
|
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
454
|
+
// Without this, the first proxy-authenticated visitor's UPN is rejected
|
|
455
|
+
// (not in the allowlist) and the operator has to hand-write JSON to claim
|
|
456
|
+
// admin. With it, the first matching visit auto-allowlists and grants
|
|
457
|
+
// instance_admin in one step.
|
|
474
458
|
if (!env.BOOTSTRAP_INSTANCE_ADMIN_EMAIL) {
|
|
475
459
|
out.push(
|
|
476
460
|
red(
|
|
@@ -482,7 +466,6 @@ function checkHeaderAuth(dir, env, dataProvider) {
|
|
|
482
466
|
out.push(green(`BOOTSTRAP_INSTANCE_ADMIN_EMAIL=${env.BOOTSTRAP_INSTANCE_ADMIN_EMAIL}`));
|
|
483
467
|
}
|
|
484
468
|
|
|
485
|
-
// Resolved header names (print for diffing against proxy config; partial override = typo risk).
|
|
486
469
|
const resolved = {
|
|
487
470
|
upn: env.HEADER_AUTH_UPN_HEADER || DEFAULT_HEADER_NAMES.upn,
|
|
488
471
|
email: env.HEADER_AUTH_EMAIL_HEADER || DEFAULT_HEADER_NAMES.email,
|
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'
|
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,29 +13,25 @@ import {
|
|
|
13
13
|
buildDeploymentPackageJson,
|
|
14
14
|
CANONICAL_FIELDS,
|
|
15
15
|
DEPENDENCIES,
|
|
16
|
-
LEGACY_DEPENDENCIES
|
|
16
|
+
LEGACY_DEPENDENCIES,
|
|
17
|
+
OVERRIDES
|
|
17
18
|
} from '../deployment-package.js';
|
|
18
19
|
import { APP_NAME, runPm2 } from './pm2.js';
|
|
19
20
|
|
|
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
|
-
*/
|
|
21
|
+
// Where migrate parks the old node_modules while npm installs the new one.
|
|
22
|
+
// Left behind only if the process is killed mid-migration; `selva doctor`
|
|
23
|
+
// flags a surviving stash as an interrupted migration.
|
|
26
24
|
export const NODE_MODULES_STASH = 'node_modules.selva-migrate-bak';
|
|
27
25
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
* directory — but not against a real npm or a real pm2 daemon.
|
|
32
|
-
*/
|
|
26
|
+
// `deps` are seams for tests: the rollback path destroys and restores a real
|
|
27
|
+
// dependency tree, so it has to run against a real directory — but not against
|
|
28
|
+
// a real npm or pm2 daemon.
|
|
33
29
|
export async function runMigrate(
|
|
34
30
|
_argv,
|
|
35
31
|
{
|
|
36
32
|
dir: injectedDir,
|
|
37
|
-
// --prefer-online
|
|
38
|
-
//
|
|
33
|
+
// --prefer-online: npm can otherwise resolve a cached packument that
|
|
34
|
+
// predates the release being migrated to.
|
|
39
35
|
install = (cwd) => execSync('npm install --prefer-online', { cwd, stdio: 'pipe' }),
|
|
40
36
|
confirm = (message) => p.confirm({ message, initialValue: true }),
|
|
41
37
|
pm2 = runPm2
|
|
@@ -56,8 +52,6 @@ export async function runMigrate(
|
|
|
56
52
|
const target = buildTargetPackageJson(before);
|
|
57
53
|
const pkgDiff = diffPackageJson(before, target);
|
|
58
54
|
|
|
59
|
-
// Side-files: selva.config.js (now unused) and ecosystem.config.cjs
|
|
60
|
-
// (must point at @selvajs/selva, not @selvajs/runtime).
|
|
61
55
|
const configPath = join(dir, 'selva.config.js');
|
|
62
56
|
const hasStaleConfig = existsSync(configPath);
|
|
63
57
|
|
|
@@ -65,9 +59,9 @@ export async function runMigrate(
|
|
|
65
59
|
const ecoHasStaleRuntime =
|
|
66
60
|
existsSync(ecoPath) && readFileSync(ecoPath, 'utf8').includes('@selvajs/runtime');
|
|
67
61
|
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
62
|
+
// The server drops its read-old-name shim after one minor version, so
|
|
63
|
+
// rewriting now is what keeps a tuned value tuned instead of silently
|
|
64
|
+
// reverting to defaults later.
|
|
71
65
|
const envPath = join(dir, '.env');
|
|
72
66
|
const envBefore = existsSync(envPath) ? readFileSync(envPath, 'utf8') : null;
|
|
73
67
|
const envRename = envBefore
|
|
@@ -106,9 +100,10 @@ export async function runMigrate(
|
|
|
106
100
|
return;
|
|
107
101
|
}
|
|
108
102
|
|
|
109
|
-
// Stop pm2 before npm rewrites
|
|
103
|
+
// Stop pm2 before npm rewrites — SvelteKit lazy-loads chunks, so a rewrite
|
|
104
|
+
// under a running server serves a broken page (same reason `update` stops first).
|
|
110
105
|
// A legacy deployment may predate the local-pm2 layout, so a missing binary
|
|
111
|
-
//
|
|
106
|
+
// isn't fatal here: there's nothing running for us to stop.
|
|
112
107
|
try {
|
|
113
108
|
const stopStatus = pm2(dir, ['stop', APP_NAME], { inherit: false });
|
|
114
109
|
if (stopStatus !== 0) {
|
|
@@ -118,7 +113,6 @@ export async function runMigrate(
|
|
|
118
113
|
p.log.warn('No deployment-local pm2 to stop — continuing.');
|
|
119
114
|
}
|
|
120
115
|
|
|
121
|
-
// Back up for rollback on npm-install failure.
|
|
122
116
|
const bakPath = pkgPath + '.bak';
|
|
123
117
|
copyFileSync(pkgPath, bakPath);
|
|
124
118
|
writeFileSync(pkgPath, JSON.stringify(target, null, 2) + '\n', 'utf8');
|
|
@@ -130,7 +124,7 @@ export async function runMigrate(
|
|
|
130
124
|
|
|
131
125
|
if (ecoHasStaleRuntime) {
|
|
132
126
|
copyFileSync(ecoPath, ecoPath + '.bak');
|
|
133
|
-
//
|
|
127
|
+
// Text substitution, not regeneration, so any operator customizations to the file survive.
|
|
134
128
|
const ecoContent = readFileSync(ecoPath, 'utf8').replace(
|
|
135
129
|
/@selvajs\/runtime/g,
|
|
136
130
|
'@selvajs/selva'
|
|
@@ -143,11 +137,11 @@ export async function runMigrate(
|
|
|
143
137
|
writeFileSync(envPath, envRename.text, 'utf8');
|
|
144
138
|
}
|
|
145
139
|
|
|
146
|
-
// A
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
140
|
+
// A legacy lockfile pins the old package set across a major bump, so this needs
|
|
141
|
+
// a clean install. But node_modules is also where pm2 lives, and rollback has to
|
|
142
|
+
// restart the app — deleting it outright would leave a failed migration with no
|
|
143
|
+
// way back up. Rename instead: atomic, keeps .bin symlinks intact, restorable
|
|
144
|
+
// until the install succeeds.
|
|
151
145
|
const nodeModules = join(dir, 'node_modules');
|
|
152
146
|
const nodeModulesBak = join(dir, NODE_MODULES_STASH);
|
|
153
147
|
const lockPath = join(dir, 'package-lock.json');
|
|
@@ -176,8 +170,8 @@ export async function runMigrate(
|
|
|
176
170
|
copyFileSync(envPath + '.bak', envPath);
|
|
177
171
|
}
|
|
178
172
|
|
|
179
|
-
//
|
|
180
|
-
//
|
|
173
|
+
// A half-installed node_modules from the failed attempt is worse than the old
|
|
174
|
+
// one, so clear it before restoring the stash.
|
|
181
175
|
rmSync(nodeModules, { recursive: true, force: true });
|
|
182
176
|
if (existsSync(nodeModulesBak)) renameSync(nodeModulesBak, nodeModules);
|
|
183
177
|
if (lockBak !== null) writeFileSync(lockPath, lockBak, 'utf8');
|
|
@@ -187,6 +181,10 @@ export async function runMigrate(
|
|
|
187
181
|
process.exit(1);
|
|
188
182
|
}
|
|
189
183
|
|
|
184
|
+
// Migration itself already succeeded at this point, so a failed pm2 start is reported
|
|
185
|
+
// but doesn't exit non-zero or trigger rollback — the on-disk state is fine, only the
|
|
186
|
+
// running process isn't. Contrast restartAfterRollback, which does exit 1: that path
|
|
187
|
+
// runs after a rollback, where a failed restart leaves nothing serving traffic at all.
|
|
190
188
|
let status;
|
|
191
189
|
try {
|
|
192
190
|
status = pm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
|
|
@@ -194,6 +192,16 @@ export async function runMigrate(
|
|
|
194
192
|
status = 1;
|
|
195
193
|
p.log.error(`Could not invoke pm2: ${err instanceof Error ? err.message : err}`);
|
|
196
194
|
}
|
|
195
|
+
// A pin change means the just-installed pm2 CLI and the still-running
|
|
196
|
+
// daemon are now different versions. That's the repairable skew direction
|
|
197
|
+
// (daemon older), but it stays skewed until the operator acts — say so
|
|
198
|
+
// here, once, instead of letting doctor nag forever. Deliberately NOT
|
|
199
|
+
// automated: `pm2 update` recycles the daemon, and doing that unattended
|
|
200
|
+
// inside a migration converts a mid-flight failure into an outage (see
|
|
201
|
+
// plans/fixes/host-prerequisites-and-pm2-audit.md).
|
|
202
|
+
const pm2Notice = buildPm2UpgradeNotice(before.dependencies?.pm2, target.dependencies.pm2);
|
|
203
|
+
if (pm2Notice) p.log.warn(pm2Notice);
|
|
204
|
+
|
|
197
205
|
const backupHints = ['package.json.bak'];
|
|
198
206
|
if (hasStaleConfig) backupHints.push('selva.config.js.bak');
|
|
199
207
|
if (ecoHasStaleRuntime) backupHints.push('ecosystem.config.cjs.bak');
|
|
@@ -212,14 +220,9 @@ export async function runMigrate(
|
|
|
212
220
|
}
|
|
213
221
|
}
|
|
214
222
|
|
|
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
|
-
*/
|
|
223
|
+
// Never throws: the migration is already aborting, and an exception here would
|
|
224
|
+
// replace the rollback's diagnosis with a stack trace. A failure to restart is
|
|
225
|
+
// the operator's problem to act on, so it gets logged, not swallowed.
|
|
223
226
|
function restartAfterRollback(dir, pm2 = runPm2) {
|
|
224
227
|
try {
|
|
225
228
|
const status = pm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
|
|
@@ -263,6 +266,19 @@ function diffPackageJson(before, after) {
|
|
|
263
266
|
else if (a !== b) lines.push(`${pc.yellow('~')} ${name} ${pc.dim(a + ' → ' + b)}`);
|
|
264
267
|
}
|
|
265
268
|
|
|
269
|
+
// Without this section, an overrides-only change reads as "already on the
|
|
270
|
+
// current layout" and the security shim never reaches existing deployments.
|
|
271
|
+
const beforeOverrides = before.overrides ?? {};
|
|
272
|
+
const afterOverrides = after.overrides ?? {};
|
|
273
|
+
const allOverrides = new Set([...Object.keys(beforeOverrides), ...Object.keys(afterOverrides)]);
|
|
274
|
+
for (const name of [...allOverrides].sort()) {
|
|
275
|
+
const a = beforeOverrides[name];
|
|
276
|
+
const b = afterOverrides[name];
|
|
277
|
+
if (a && !b) lines.push(`${pc.red('-')} overrides.${name} ${pc.dim(a)}`);
|
|
278
|
+
else if (!a && b) lines.push(`${pc.green('+')} overrides.${name} ${pc.dim(b)}`);
|
|
279
|
+
else if (a !== b) lines.push(`${pc.yellow('~')} overrides.${name} ${pc.dim(a + ' → ' + b)}`);
|
|
280
|
+
}
|
|
281
|
+
|
|
266
282
|
const beforeScripts = before.scripts ?? {};
|
|
267
283
|
const afterScripts = after.scripts ?? {};
|
|
268
284
|
const allScripts = new Set([...Object.keys(beforeScripts), ...Object.keys(afterScripts)]);
|
|
@@ -276,9 +292,9 @@ function diffPackageJson(before, after) {
|
|
|
276
292
|
}
|
|
277
293
|
}
|
|
278
294
|
|
|
279
|
-
// Anything outside the canonical set is discarded by the rewrite
|
|
280
|
-
//
|
|
281
|
-
//
|
|
295
|
+
// Anything outside the canonical set is discarded by the rewrite — show it here
|
|
296
|
+
// too, or the operator confirms a change (e.g. losing devDependencies) they
|
|
297
|
+
// never saw.
|
|
282
298
|
for (const key of Object.keys(before).sort()) {
|
|
283
299
|
if (CANONICAL_FIELDS.has(key)) continue;
|
|
284
300
|
lines.push(`${pc.red('-')} ${key} ${pc.dim(summarize(before[key]))}`);
|
|
@@ -307,6 +323,23 @@ export function detectDrift(pkgJson, dir) {
|
|
|
307
323
|
if (!deps[name]) reasons.push(`${name} is missing`);
|
|
308
324
|
}
|
|
309
325
|
|
|
326
|
+
// pm2 is the one dependency pinned exactly, so a version difference is drift
|
|
327
|
+
// rather than an operator preference. Presence alone was checked above and
|
|
328
|
+
// isn't enough: a deployment scaffolded against an older CLI keeps its old
|
|
329
|
+
// pm2 forever, which is how the pin reached a two-major gap unnoticed.
|
|
330
|
+
if (deps.pm2 && deps.pm2 !== DEPENDENCIES.pm2) {
|
|
331
|
+
reasons.push(`pm2 is pinned to ${deps.pm2} (current scaffold pins ${DEPENDENCIES.pm2})`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Overrides are security shims for vulnerable transitive deps — a
|
|
335
|
+
// deployment without them installs the vulnerable version on its next
|
|
336
|
+
// `npm install`, silently.
|
|
337
|
+
for (const [name, version] of Object.entries(OVERRIDES)) {
|
|
338
|
+
if (pkgJson?.overrides?.[name] !== version) {
|
|
339
|
+
reasons.push(`overrides.${name} is missing or outdated (security shim, expects ${version})`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
310
343
|
if (dir) {
|
|
311
344
|
const configPath = join(dir, 'selva.config.js');
|
|
312
345
|
if (existsSync(configPath)) {
|
|
@@ -328,4 +361,20 @@ export function detectDrift(pkgJson, dir) {
|
|
|
328
361
|
return reasons;
|
|
329
362
|
}
|
|
330
363
|
|
|
364
|
+
// Null when the pin didn't change. Exported for its test — the wording is the
|
|
365
|
+
// operator's only pointer to finish the upgrade.
|
|
366
|
+
export function buildPm2UpgradeNotice(beforeVersion, afterVersion) {
|
|
367
|
+
if (!beforeVersion || beforeVersion === afterVersion) return null;
|
|
368
|
+
return [
|
|
369
|
+
`pm2 was upgraded ${beforeVersion} → ${afterVersion}, but the running pm2 daemon is`,
|
|
370
|
+
`still the old version. Finish the upgrade (brief restart of managed processes):`,
|
|
371
|
+
``,
|
|
372
|
+
` npx pm2 update`,
|
|
373
|
+
` npx pm2 save`,
|
|
374
|
+
``,
|
|
375
|
+
`If doctor reports a pm2 outside this deployment (global or apt install), follow`,
|
|
376
|
+
`the full procedure in the docs instead: self-hosting → deployment → prerequisites.`
|
|
377
|
+
].join('\n');
|
|
378
|
+
}
|
|
379
|
+
|
|
331
380
|
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,
|