@selvajs/cli 4.8.0-beta.8 → 4.8.0
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 +6 -3
- package/src/checks/boot.js +151 -0
- package/src/checks/config.js +189 -0
- package/src/checks/result.js +27 -0
- package/src/checks/runtime.js +372 -0
- package/src/cli.js +70 -2
- package/src/commands/create.js +71 -76
- package/src/commands/doctor.js +163 -332
- package/src/commands/init.js +13 -32
- package/src/commands/keys.js +2 -15
- package/src/commands/migrate.js +321 -115
- package/src/commands/pm2.js +15 -18
- package/src/deployment-package.js +254 -0
- package/src/env.js +197 -25
- package/src/node-range.js +66 -0
- package/src/paths.js +46 -3
- package/src/prompts.js +4 -16
- package/bin/create.js +0 -7
package/src/commands/doctor.js
CHANGED
|
@@ -2,22 +2,41 @@
|
|
|
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
|
|
7
|
-
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
// checks, each behind its own confirmation — only fixes needing no root and
|
|
6
|
+
// no runtime restart exist. See applyFixes.
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
existsSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
copyFileSync,
|
|
13
|
+
readdirSync,
|
|
14
|
+
accessSync,
|
|
15
|
+
constants,
|
|
16
|
+
statSync,
|
|
17
|
+
rmSync
|
|
18
|
+
} from 'node:fs';
|
|
19
|
+
import { join, resolve, dirname } from 'node:path';
|
|
11
20
|
import { fileURLToPath } from 'node:url';
|
|
12
|
-
import { homedir } from 'node:os';
|
|
13
21
|
import * as p from '@clack/prompts';
|
|
14
22
|
import pc from 'picocolors';
|
|
15
|
-
import { readEnvFile,
|
|
23
|
+
import { readEnvFile, parseEnv, countEnvCommentLines, stripEnvComments } from '../env.js';
|
|
16
24
|
import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
25
|
+
import { satisfiesNodeRange } from '../node-range.js';
|
|
26
|
+
import { green, yellow, red, fixable } from '../checks/result.js';
|
|
27
|
+
import { checkBootPersistence } from '../checks/boot.js';
|
|
28
|
+
import { checkRuntimeEnvironment } from '../checks/runtime.js';
|
|
29
|
+
import {
|
|
30
|
+
checkDeprecatedEnv,
|
|
31
|
+
checkEnvDocumentation,
|
|
32
|
+
checkHeaderNames,
|
|
33
|
+
checkOrigin,
|
|
34
|
+
checkProviders,
|
|
35
|
+
checkSecret,
|
|
36
|
+
checkTenancy,
|
|
37
|
+
resolveProviders
|
|
38
|
+
} from '../checks/config.js';
|
|
39
|
+
import { BACKUP_AGE_DAYS, classifyBackups, detectDrift, NODE_MODULES_STASH } from './migrate.js';
|
|
21
40
|
|
|
22
41
|
export async function runDoctor(argv = []) {
|
|
23
42
|
const fix = argv.includes('--fix');
|
|
@@ -32,28 +51,13 @@ export async function runDoctor(argv = []) {
|
|
|
32
51
|
checks.push(checkFile(join(dir, '.env'), '.env present'));
|
|
33
52
|
checks.push(checkFile(join(dir, 'ecosystem.config.cjs'), 'ecosystem.config.cjs present'));
|
|
34
53
|
checks.push(checkLayoutDrift(dir));
|
|
54
|
+
checks.push(checkMigrationLeftovers(dir));
|
|
55
|
+
checks.push(checkAgedBackups(dir));
|
|
35
56
|
checks.push(checkSecret(env.SELVA_HMAC_KEY, 'SELVA_HMAC_KEY is a 32-byte hex string'));
|
|
36
57
|
checks.push(checkSecret(env.SELVA_AT_REST_KEY, 'SELVA_AT_REST_KEY is a 32-byte hex string'));
|
|
37
58
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
auth: (env.SELVA_AUTH_PROVIDER ?? 'local').toLowerCase(),
|
|
41
|
-
data: (env.SELVA_DATA_PROVIDER ?? 'local').toLowerCase(),
|
|
42
|
-
storage: (env.SELVA_STORAGE_PROVIDER ?? 'local').toLowerCase()
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
const validForAuth = new Set(['local', 'supabase', 'header']);
|
|
46
|
-
const validForData = new Set(['local', 'supabase']);
|
|
47
|
-
|
|
48
|
-
if (!validForAuth.has(providers.auth)) {
|
|
49
|
-
checks.push(red(`SELVA_AUTH_PROVIDER="${providers.auth}" — expected local|supabase|header`));
|
|
50
|
-
}
|
|
51
|
-
if (!validForData.has(providers.data)) {
|
|
52
|
-
checks.push(red(`SELVA_DATA_PROVIDER="${providers.data}" — expected local|supabase`));
|
|
53
|
-
}
|
|
54
|
-
if (!validForData.has(providers.storage)) {
|
|
55
|
-
checks.push(red(`SELVA_STORAGE_PROVIDER="${providers.storage}" — expected local|supabase`));
|
|
56
|
-
}
|
|
59
|
+
const providers = resolveProviders(env);
|
|
60
|
+
checks.push(...checkProviders(providers));
|
|
57
61
|
|
|
58
62
|
const used = new Set(Object.values(providers));
|
|
59
63
|
|
|
@@ -70,42 +74,16 @@ export async function runDoctor(argv = []) {
|
|
|
70
74
|
checks.push(...checkHeaderAuth(dir, env, providers.data));
|
|
71
75
|
}
|
|
72
76
|
|
|
73
|
-
|
|
74
|
-
if (tenancy !== 'single' && tenancy !== 'multi') {
|
|
75
|
-
checks.push(red(`SELVA_TENANCY="${tenancy}" — expected single|multi`));
|
|
76
|
-
} else {
|
|
77
|
-
checks.push(green(`SELVA_TENANCY=${tenancy}`));
|
|
78
|
-
}
|
|
77
|
+
checks.push(checkTenancy(env));
|
|
79
78
|
|
|
80
79
|
checks.push(checkPackage(dir, '@selvajs/selva'));
|
|
81
80
|
checks.push(checkNodeEngine(dir));
|
|
82
81
|
checks.push(checkCliRuntimeAlignment(dir));
|
|
82
|
+
checks.push(...checkRuntimeEnvironment(dir));
|
|
83
83
|
checks.push(...checkBootPersistence(dir));
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
checks.push(green(`ORIGIN=${env.ORIGIN}`));
|
|
88
|
-
} catch {
|
|
89
|
-
checks.push(red(`ORIGIN="${env.ORIGIN}" is not a valid URL`));
|
|
90
|
-
}
|
|
91
|
-
} else {
|
|
92
|
-
checks.push(yellow('ORIGIN unset — required behind a reverse proxy'));
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
for (const [oldName, newName] of Object.entries(RENAMED_ENV_VARS)) {
|
|
96
|
-
if (env[oldName] === undefined) continue;
|
|
97
|
-
checks.push(
|
|
98
|
-
env[newName] === undefined
|
|
99
|
-
? yellow(`${oldName} is deprecated — \`selva migrate\` renames it to ${newName}`)
|
|
100
|
-
: yellow(`${oldName} is deprecated and ignored — ${newName} is set and wins`)
|
|
101
|
-
);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// Not auto-fixable: the replacement encodes a value, so migrate won't guess.
|
|
105
|
-
for (const [oldName, replacement] of Object.entries(REPLACED_ENV_VARS)) {
|
|
106
|
-
if (env[oldName] === undefined) continue;
|
|
107
|
-
checks.push(yellow(`${oldName} is deprecated — replace it with ${replacement}`));
|
|
108
|
-
}
|
|
84
|
+
checks.push(checkOrigin(env));
|
|
85
|
+
checks.push(...checkDeprecatedEnv(env));
|
|
86
|
+
checks.push(checkEnvDocs(dir));
|
|
109
87
|
|
|
110
88
|
// ── Render ─────────────────────────────────────────────────────────
|
|
111
89
|
const resolved = await Promise.all(checks);
|
|
@@ -122,7 +100,7 @@ export async function runDoctor(argv = []) {
|
|
|
122
100
|
if (repairable > 0) {
|
|
123
101
|
p.log.info(
|
|
124
102
|
`${repairable} issue${repairable === 1 ? '' : 's'} can be repaired automatically — ` +
|
|
125
|
-
`re-run with \`selva doctor --fix\`.`
|
|
103
|
+
`re-run with \`npx selva doctor --fix\`.`
|
|
126
104
|
);
|
|
127
105
|
}
|
|
128
106
|
}
|
|
@@ -135,15 +113,9 @@ export async function runDoctor(argv = []) {
|
|
|
135
113
|
}
|
|
136
114
|
}
|
|
137
115
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
*
|
|
142
|
-
* Nothing here needs root: anything privileged (the systemd unit) or that would
|
|
143
|
-
* restart this process (a Node upgrade) stays a printed instruction, because a
|
|
144
|
-
* fixer that dies halfway through its own runtime leaves the operator with no
|
|
145
|
-
* obvious path back.
|
|
146
|
-
*/
|
|
116
|
+
// Anything privileged (the systemd unit) or that would restart this process
|
|
117
|
+
// (a Node upgrade) stays a printed instruction instead of a fixer — one that
|
|
118
|
+
// dies halfway through its own runtime leaves the operator with no way back.
|
|
147
119
|
async function applyFixes(resolved, failures) {
|
|
148
120
|
const repairs = resolved.filter((c) => c.fix && c.severity !== 'green');
|
|
149
121
|
if (repairs.length === 0) {
|
|
@@ -175,39 +147,10 @@ async function applyFixes(resolved, failures) {
|
|
|
175
147
|
return failures;
|
|
176
148
|
}
|
|
177
149
|
|
|
178
|
-
function green(text) {
|
|
179
|
-
return { severity: 'green', line: `${pc.green('✓')} ${text}` };
|
|
180
|
-
}
|
|
181
|
-
function yellow(text, fix) {
|
|
182
|
-
return { severity: 'yellow', line: `${pc.yellow('!')} ${text}`, fix };
|
|
183
|
-
}
|
|
184
|
-
function red(text, fix) {
|
|
185
|
-
return { severity: 'red', line: `${pc.red('✗')} ${text}`, fix };
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* A repair `--fix` may run. `label` is what the operator is asked to approve;
|
|
190
|
-
* `run()` performs it and returns a result line.
|
|
191
|
-
*
|
|
192
|
-
* Only attach one where the repair is unambiguous and reversible-ish. Anything
|
|
193
|
-
* needing root, or that restarts the runtime running this process, stays a
|
|
194
|
-
* printed instruction — a half-applied privileged fix is worse than none.
|
|
195
|
-
*/
|
|
196
|
-
function fixable(label, run) {
|
|
197
|
-
return { label, run };
|
|
198
|
-
}
|
|
199
|
-
|
|
200
150
|
function checkFile(path, label) {
|
|
201
151
|
return existsSync(path) ? green(label) : red(`${label} (missing: ${path})`);
|
|
202
152
|
}
|
|
203
153
|
|
|
204
|
-
function checkSecret(value, label) {
|
|
205
|
-
if (!value) return red(`${label} — unset`);
|
|
206
|
-
if (value === PLACEHOLDER) return red(`${label} — still the placeholder`);
|
|
207
|
-
if (!HEX_64.test(value)) return red(`${label} — not 64 hex chars`);
|
|
208
|
-
return green(label);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
154
|
function checkDataPath(dir, dataPath) {
|
|
212
155
|
const absolute = resolve(dir, dataPath);
|
|
213
156
|
try {
|
|
@@ -242,8 +185,7 @@ async function checkSupabase(env) {
|
|
|
242
185
|
return red(`SUPABASE_URL="${env.SUPABASE_URL}" is not a valid URL`);
|
|
243
186
|
}
|
|
244
187
|
|
|
245
|
-
//
|
|
246
|
-
// errors — operators may be offline at install time.
|
|
188
|
+
// Network errors go yellow, not red — operators may be offline at install time.
|
|
247
189
|
try {
|
|
248
190
|
const controller = new AbortController();
|
|
249
191
|
const timer = setTimeout(() => controller.abort(), 4000);
|
|
@@ -258,11 +200,10 @@ async function checkSupabase(env) {
|
|
|
258
200
|
}
|
|
259
201
|
}
|
|
260
202
|
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
// confusing deploy.
|
|
203
|
+
// Compares the newest migration shipped by @selvajs/supabase-provider against
|
|
204
|
+
// what the database reports via selva.migration_head(). Red on skew — the app
|
|
205
|
+
// degrades /api/health to 503 at boot in the same state, so catching it here
|
|
206
|
+
// saves a confusing deploy.
|
|
266
207
|
async function checkSupabaseMigrations(dir, env) {
|
|
267
208
|
if (!env.SUPABASE_URL || !env.SUPABASE_SERVICE_ROLE_KEY) {
|
|
268
209
|
return yellow('Migration-head check skipped — Supabase env incomplete');
|
|
@@ -323,6 +264,7 @@ async function checkSupabaseMigrations(dir, env) {
|
|
|
323
264
|
`Database reports no applied migrations (expected head ${expected}). Sync + run: npx supabase db push`
|
|
324
265
|
);
|
|
325
266
|
}
|
|
267
|
+
// String comparison — relies on both heads being fixed-width, zero-padded timestamps.
|
|
326
268
|
if (actual < expected) {
|
|
327
269
|
return red(
|
|
328
270
|
`Database migration head ${actual} is behind the installed provider (${expected}). Sync + run: npx supabase db push`
|
|
@@ -330,7 +272,6 @@ async function checkSupabaseMigrations(dir, env) {
|
|
|
330
272
|
}
|
|
331
273
|
return green(`Database migration head ${actual} matches the installed provider`);
|
|
332
274
|
} catch (err) {
|
|
333
|
-
// Soft-fail on network errors, same as checkSupabase.
|
|
334
275
|
return yellow(`Migration-head check skipped (${err.message ?? err})`);
|
|
335
276
|
}
|
|
336
277
|
}
|
|
@@ -354,7 +295,7 @@ function majorOf(version) {
|
|
|
354
295
|
return m ? Number(m[1]) : null;
|
|
355
296
|
}
|
|
356
297
|
|
|
357
|
-
// CLI and runtime release
|
|
298
|
+
// CLI and runtime release as a fixed group; major-version skew means a stale CLI pin.
|
|
358
299
|
function checkCliRuntimeAlignment(dir) {
|
|
359
300
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
360
301
|
const cliVersion = readPackageVersion(join(here, '..', '..', 'package.json'));
|
|
@@ -362,7 +303,7 @@ function checkCliRuntimeAlignment(dir) {
|
|
|
362
303
|
join(dir, 'node_modules', '@selvajs', 'selva', 'package.json')
|
|
363
304
|
);
|
|
364
305
|
|
|
365
|
-
//
|
|
306
|
+
// checkPackage already reports missing runtime as red; stay quiet here.
|
|
366
307
|
if (!runtimeVersion) return yellow('CLI/runtime version check skipped (runtime not installed)');
|
|
367
308
|
if (!cliVersion) return yellow('CLI/runtime version check skipped (could not read CLI version)');
|
|
368
309
|
|
|
@@ -383,16 +324,10 @@ function checkCliRuntimeAlignment(dir) {
|
|
|
383
324
|
);
|
|
384
325
|
}
|
|
385
326
|
|
|
386
|
-
//
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
* npm only enforces engines under `engine-strict=true`, which no deployment
|
|
391
|
-
* sets, so a mismatched install succeeds silently and `/api/health` still
|
|
392
|
-
* returns 200 — routes using newer APIs throw only under real traffic
|
|
393
|
-
* (issue #176). Deliberately NOT auto-fixable: upgrading Node is
|
|
394
|
-
* distro-specific and would restart the process running this check.
|
|
395
|
-
*/
|
|
327
|
+
// npm only enforces engines.node under engine-strict=true, which no deployment
|
|
328
|
+
// sets, so a mismatched install succeeds silently and /api/health still returns
|
|
329
|
+
// 200 — routes using newer APIs throw only under real traffic. Not auto-fixable:
|
|
330
|
+
// upgrading Node is distro-specific and would restart the process running this check.
|
|
396
331
|
function checkNodeEngine(dir) {
|
|
397
332
|
let required;
|
|
398
333
|
try {
|
|
@@ -422,182 +357,111 @@ function checkNodeEngine(dir) {
|
|
|
422
357
|
);
|
|
423
358
|
}
|
|
424
359
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
if (!clauses.length) continue;
|
|
442
|
-
|
|
443
|
-
let all = true;
|
|
444
|
-
let parsed = true;
|
|
445
|
-
for (const clause of clauses) {
|
|
446
|
-
const m = /^(>=|<=|>|<|\^|~|=)?\s*v?(\d+)(?:\.(\d+|x|\*))?(?:\.(\d+|x|\*))?/.exec(clause);
|
|
447
|
-
if (!m) {
|
|
448
|
-
parsed = false;
|
|
449
|
-
break;
|
|
360
|
+
// `selva migrate` parks the old node_modules aside so a failed install can be
|
|
361
|
+
// rolled back, and removes it on either outcome. One surviving here means the
|
|
362
|
+
// migration was killed mid-flight — worth flagging since it's a full copy of
|
|
363
|
+
// the dependency tree and the deployment may be running on the wrong one.
|
|
364
|
+
function checkMigrationLeftovers(dir) {
|
|
365
|
+
const stash = join(dir, NODE_MODULES_STASH);
|
|
366
|
+
if (!existsSync(stash)) return green('no interrupted migration left behind');
|
|
367
|
+
return yellow(
|
|
368
|
+
`${NODE_MODULES_STASH} exists — a \`selva migrate\` was interrupted. If the app ` +
|
|
369
|
+
`is healthy, delete it: rm -rf ${stash}`,
|
|
370
|
+
fixable(`delete the leftover ${NODE_MODULES_STASH}`, () => {
|
|
371
|
+
try {
|
|
372
|
+
rmSync(stash, { recursive: true, force: true });
|
|
373
|
+
return green(`removed ${NODE_MODULES_STASH}`);
|
|
374
|
+
} catch (err) {
|
|
375
|
+
return red(`could not remove ${stash}: ${err instanceof Error ? err.message : err}`);
|
|
450
376
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
377
|
+
})
|
|
378
|
+
);
|
|
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})`);
|
|
463
409
|
}
|
|
464
410
|
}
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
else if (op === '<') ok = cmp < 0;
|
|
470
|
-
else if (op === '^') ok = cmp >= 0 && v[0] === t[0];
|
|
471
|
-
else if (op === '~') ok = cmp >= 0 && v[0] === t[0] && v[1] === t[1];
|
|
472
|
-
else
|
|
473
|
-
ok =
|
|
474
|
-
v[0] === t[0] &&
|
|
475
|
-
(m[3] == null || m[3] === 'x' || m[3] === '*' || v[1] === t[1]) &&
|
|
476
|
-
(m[4] == null || m[4] === 'x' || m[4] === '*' || v[2] === t[2]);
|
|
477
|
-
if (!ok) {
|
|
478
|
-
all = false;
|
|
479
|
-
break;
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
if (!parsed) continue;
|
|
483
|
-
anyParsed = true;
|
|
484
|
-
if (all) return true;
|
|
485
|
-
}
|
|
486
|
-
return anyParsed ? false : null;
|
|
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
|
+
);
|
|
487
415
|
}
|
|
488
416
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
const out = [];
|
|
496
|
-
|
|
497
|
-
// dump.pm2 (what pm2 startup resurrects).
|
|
498
|
-
const pm2Home = process.env.PM2_HOME ?? join(homedir(), '.pm2');
|
|
499
|
-
const dumpPath = join(pm2Home, 'dump.pm2');
|
|
500
|
-
if (existsSync(dumpPath)) {
|
|
501
|
-
out.push(green('pm2 process list saved (dump.pm2 present)'));
|
|
502
|
-
} else {
|
|
503
|
-
out.push(
|
|
504
|
-
yellow(
|
|
505
|
-
'pm2 process list not saved — run `npx pm2 save` so a reboot can ' +
|
|
506
|
-
'resurrect the app (nothing to restore without it)',
|
|
507
|
-
fixable('run `pm2 save` to persist the current process list', () => {
|
|
508
|
-
const bin = join(dir, 'node_modules', '.bin', 'pm2');
|
|
509
|
-
if (!existsSync(bin)) return red('pm2 not installed in this deployment');
|
|
510
|
-
const r = spawnSync(bin, ['save'], { cwd: dir, encoding: 'utf8' });
|
|
511
|
-
return (r.status ?? 1) === 0
|
|
512
|
-
? green('pm2 process list saved')
|
|
513
|
-
: red(`pm2 save failed: ${(r.stderr || r.stdout || '').trim()}`);
|
|
514
|
-
})
|
|
515
|
-
)
|
|
516
|
-
);
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
// systemd unit: present and pointing at deployment-local pm2.
|
|
520
|
-
const user = process.env.USER ?? process.env.LOGNAME;
|
|
521
|
-
const unitPath = user ? `/etc/systemd/system/pm2-${user}.service` : null;
|
|
417
|
+
// Reads the raw file rather than the parsed env: the finding IS the comments,
|
|
418
|
+
// which parseEnv discards. The repair is attached here (not in checks/config.js)
|
|
419
|
+
// to keep that module filesystem-free.
|
|
420
|
+
function checkEnvDocs(dir) {
|
|
421
|
+
const envPath = join(dir, '.env');
|
|
422
|
+
if (!existsSync(envPath)) return green('.env documentation check skipped (no .env)');
|
|
522
423
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
)
|
|
555
|
-
);
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
// Warn if global pm2 is on PATH (root cause of version skew).
|
|
559
|
-
const globalPm2 = findGlobalPm2(dir);
|
|
560
|
-
if (globalPm2) {
|
|
561
|
-
// Only offer the removal when we could actually perform it. A global
|
|
562
|
-
// install under /usr is root-owned; attempting it would half-fail and
|
|
563
|
-
// leave the operator worse informed than a printed instruction.
|
|
564
|
-
let writable = false;
|
|
565
|
-
try {
|
|
566
|
-
accessSync(dirname(globalPm2), constants.W_OK);
|
|
567
|
-
writable = true;
|
|
568
|
-
} catch {
|
|
569
|
-
writable = false;
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
out.push(
|
|
573
|
-
yellow(
|
|
574
|
-
`a pm2 outside this deployment is on PATH (${globalPm2}) — it can fork a ` +
|
|
575
|
-
`mismatched daemon and trigger skew. Prefer \`npm run\` wrappers / \`npx pm2\` ` +
|
|
576
|
-
`from this directory; consider \`npm uninstall -g pm2\`.` +
|
|
577
|
-
(writable ? '' : `\n (${dirname(globalPm2)} is not writable — needs sudo)`),
|
|
578
|
-
writable
|
|
579
|
-
? fixable(`uninstall the global pm2 at ${globalPm2}`, () => {
|
|
580
|
-
const r = spawnSync('npm', ['uninstall', '-g', 'pm2'], { encoding: 'utf8' });
|
|
581
|
-
return (r.status ?? 1) === 0
|
|
582
|
-
? green('global pm2 uninstalled')
|
|
583
|
-
: red(`npm uninstall -g pm2 failed: ${(r.stderr || r.stdout || '').trim()}`);
|
|
584
|
-
})
|
|
585
|
-
: undefined
|
|
586
|
-
)
|
|
587
|
-
);
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
return out;
|
|
424
|
+
let text;
|
|
425
|
+
try {
|
|
426
|
+
text = readFileSync(envPath, 'utf8');
|
|
427
|
+
} catch (err) {
|
|
428
|
+
return yellow(`.env unreadable (${err instanceof Error ? err.message : err})`);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return checkEnvDocumentation(
|
|
432
|
+
countEnvCommentLines(text),
|
|
433
|
+
fixable('strip the shipped documentation from .env (backup: .env.bak)', () => {
|
|
434
|
+
try {
|
|
435
|
+
// Re-read at repair time: doctor may have run minutes ago in --fix's
|
|
436
|
+
// confirmation loop, and rewriting from a stale snapshot would silently
|
|
437
|
+
// revert an edit made in between.
|
|
438
|
+
const current = readFileSync(envPath, 'utf8');
|
|
439
|
+
const { text: strippedText, removed } = stripEnvComments(current);
|
|
440
|
+
// A rewrite that changes which settings are live is a bug, not a
|
|
441
|
+
// cleanup — refuse rather than hand back a subtly different deployment.
|
|
442
|
+
const before = parseEnv(current);
|
|
443
|
+
const after = parseEnv(strippedText);
|
|
444
|
+
const drift = diffEnvKeys(before, after);
|
|
445
|
+
if (drift) return red(`refused to strip .env — ${drift}`);
|
|
446
|
+
|
|
447
|
+
copyFileSync(envPath, envPath + '.bak');
|
|
448
|
+
writeFileSync(envPath, strippedText, 'utf8');
|
|
449
|
+
return green(`stripped ${removed} comment lines from .env (backup: .env.bak)`);
|
|
450
|
+
} catch (err) {
|
|
451
|
+
return red(`could not rewrite .env: ${err instanceof Error ? err.message : err}`);
|
|
452
|
+
}
|
|
453
|
+
})
|
|
454
|
+
);
|
|
591
455
|
}
|
|
592
456
|
|
|
593
|
-
//
|
|
594
|
-
function
|
|
595
|
-
const
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
if (
|
|
457
|
+
// Names the first discrepancy between two parsed envs, or null when identical.
|
|
458
|
+
function diffEnvKeys(before, after) {
|
|
459
|
+
for (const key of Object.keys(before)) {
|
|
460
|
+
if (!(key in after)) return `${key} would be lost`;
|
|
461
|
+
if (before[key] !== after[key]) return `${key} would change value`;
|
|
462
|
+
}
|
|
463
|
+
for (const key of Object.keys(after)) {
|
|
464
|
+
if (!(key in before)) return `${key} would be added`;
|
|
601
465
|
}
|
|
602
466
|
return null;
|
|
603
467
|
}
|
|
@@ -619,19 +483,10 @@ function checkLayoutDrift(dir) {
|
|
|
619
483
|
);
|
|
620
484
|
}
|
|
621
485
|
|
|
622
|
-
//
|
|
623
|
-
// Smoke test in providers/header-auth pins them; divergence surfaces in CI.
|
|
624
|
-
const DEFAULT_HEADER_NAMES = {
|
|
625
|
-
upn: 'SELVA-UserPrincipalName',
|
|
626
|
-
email: 'SELVA-Email',
|
|
627
|
-
displayName: 'SELVA-DisplayName'
|
|
628
|
-
};
|
|
629
|
-
|
|
630
|
-
// Header-auth checks (read-only; runtime invariants like spoofing can't be verified).
|
|
486
|
+
// Static config only — runtime invariants like spoofing protection can't be checked from here.
|
|
631
487
|
function checkHeaderAuth(dir, env, dataProvider) {
|
|
632
488
|
const out = [];
|
|
633
489
|
|
|
634
|
-
// Allowlist file location.
|
|
635
490
|
const allowlistDir = env.HEADER_AUTH_DATA_DIR ?? env.DATA_PATH;
|
|
636
491
|
if (!allowlistDir) {
|
|
637
492
|
out.push(red('HEADER_AUTH_DATA_DIR (or DATA_PATH) unset — provider will fail to start'));
|
|
@@ -641,21 +496,21 @@ function checkHeaderAuth(dir, env, dataProvider) {
|
|
|
641
496
|
if (existsSync(allowlistPath)) {
|
|
642
497
|
out.push(green(`header-allowlist.json present (${allowlistDir}/header-allowlist.json)`));
|
|
643
498
|
} else {
|
|
644
|
-
//
|
|
499
|
+
// The provider creates this file lazily, but a missing file locks everyone out
|
|
500
|
+
// until one exists — worth a warning even though nothing is technically broken.
|
|
645
501
|
out.push(
|
|
646
502
|
yellow(
|
|
647
503
|
`header-allowlist.json not found at ${allowlistDir}/ — no users will be allowed in until one is added`
|
|
648
504
|
)
|
|
649
505
|
);
|
|
650
506
|
|
|
651
|
-
//
|
|
507
|
+
// DATA_PATH's own writability is checked separately (checkDataPath).
|
|
652
508
|
if (env.HEADER_AUTH_DATA_DIR) {
|
|
653
509
|
out.push(checkDirWritable(allowlistAbsDir, `HEADER_AUTH_DATA_DIR=${allowlistDir}`));
|
|
654
510
|
}
|
|
655
511
|
}
|
|
656
512
|
}
|
|
657
513
|
|
|
658
|
-
// HOST binding (loopback recommended for header-auth).
|
|
659
514
|
const host = env.HOST ?? '0.0.0.0';
|
|
660
515
|
if (host === '127.0.0.1' || host === 'localhost') {
|
|
661
516
|
out.push(green(`HOST=${host} (loopback-only)`));
|
|
@@ -668,12 +523,11 @@ function checkHeaderAuth(dir, env, dataProvider) {
|
|
|
668
523
|
);
|
|
669
524
|
}
|
|
670
525
|
|
|
671
|
-
//
|
|
526
|
+
// header-auth always sits behind a reverse proxy, so ORIGIN is required.
|
|
672
527
|
if (!env.ORIGIN) {
|
|
673
528
|
out.push(red('ORIGIN unset — required for header-auth (always behind a proxy)'));
|
|
674
529
|
}
|
|
675
530
|
|
|
676
|
-
// Non-local data provider: HEADER_AUTH_DATA_DIR must be explicit (no DATA_PATH fallback).
|
|
677
531
|
if (dataProvider !== 'local' && !env.HEADER_AUTH_DATA_DIR) {
|
|
678
532
|
out.push(
|
|
679
533
|
red(
|
|
@@ -683,10 +537,10 @@ function checkHeaderAuth(dir, env, dataProvider) {
|
|
|
683
537
|
);
|
|
684
538
|
}
|
|
685
539
|
|
|
686
|
-
//
|
|
687
|
-
//
|
|
688
|
-
//
|
|
689
|
-
//
|
|
540
|
+
// Without this, the first proxy-authenticated visitor's UPN is rejected
|
|
541
|
+
// (not in the allowlist) and the operator has to hand-write JSON to claim
|
|
542
|
+
// admin. With it, the first matching visit auto-allowlists and grants
|
|
543
|
+
// instance_admin in one step.
|
|
690
544
|
if (!env.BOOTSTRAP_INSTANCE_ADMIN_EMAIL) {
|
|
691
545
|
out.push(
|
|
692
546
|
red(
|
|
@@ -698,30 +552,7 @@ function checkHeaderAuth(dir, env, dataProvider) {
|
|
|
698
552
|
out.push(green(`BOOTSTRAP_INSTANCE_ADMIN_EMAIL=${env.BOOTSTRAP_INSTANCE_ADMIN_EMAIL}`));
|
|
699
553
|
}
|
|
700
554
|
|
|
701
|
-
|
|
702
|
-
const resolved = {
|
|
703
|
-
upn: env.HEADER_AUTH_UPN_HEADER || DEFAULT_HEADER_NAMES.upn,
|
|
704
|
-
email: env.HEADER_AUTH_EMAIL_HEADER || DEFAULT_HEADER_NAMES.email,
|
|
705
|
-
displayName: env.HEADER_AUTH_DISPLAY_NAME_HEADER || DEFAULT_HEADER_NAMES.displayName
|
|
706
|
-
};
|
|
707
|
-
const overrides = [
|
|
708
|
-
Boolean(env.HEADER_AUTH_UPN_HEADER),
|
|
709
|
-
Boolean(env.HEADER_AUTH_EMAIL_HEADER),
|
|
710
|
-
Boolean(env.HEADER_AUTH_DISPLAY_NAME_HEADER)
|
|
711
|
-
].filter(Boolean).length;
|
|
712
|
-
const headerList = `UPN=${resolved.upn}, Email=${resolved.email}, DisplayName=${resolved.displayName}`;
|
|
713
|
-
if (overrides === 0) {
|
|
714
|
-
out.push(green(`header names (bundled defaults): ${headerList}`));
|
|
715
|
-
} else if (overrides === 3) {
|
|
716
|
-
out.push(green(`header names (all overridden): ${headerList}`));
|
|
717
|
-
} else {
|
|
718
|
-
out.push(
|
|
719
|
-
yellow(
|
|
720
|
-
`header names partially overridden (${overrides}/3 set): ${headerList} — ` +
|
|
721
|
-
`a partial override is usually a typo. Set all three or none.`
|
|
722
|
-
)
|
|
723
|
-
);
|
|
724
|
-
}
|
|
555
|
+
out.push(checkHeaderNames(env));
|
|
725
556
|
|
|
726
557
|
return out;
|
|
727
558
|
}
|