@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/cli.js
CHANGED
|
@@ -18,6 +18,59 @@ const COMMANDS = {
|
|
|
18
18
|
keys: () => import('./commands/keys.js').then((m) => keysDispatch(m))
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
+
// `selva doctor --help` used to run the full doctor: commands own their argv,
|
|
22
|
+
// and only `doctor` reads a flag at all, so every other command silently
|
|
23
|
+
// ignored `--help` and did its work. Intercepting here keeps `--help` uniform
|
|
24
|
+
// across the whole surface without each command re-implementing it.
|
|
25
|
+
const USAGE = {
|
|
26
|
+
init: {
|
|
27
|
+
usage: 'selva init',
|
|
28
|
+
blurb:
|
|
29
|
+
'Reconfigure this deployment. Prompts for the same values as scaffolding\nand rewrites .env; generated secrets are left alone.'
|
|
30
|
+
},
|
|
31
|
+
doctor: {
|
|
32
|
+
usage: 'selva doctor [--fix]',
|
|
33
|
+
blurb:
|
|
34
|
+
'Validate .env, providers, Node engine, pm2 boot persistence, and package\npins. Read-only without --fix.',
|
|
35
|
+
flags: [['--fix', 'Apply the repairs doctor reports as automatic. Prompts before each.']]
|
|
36
|
+
},
|
|
37
|
+
start: { usage: 'selva start', blurb: 'pm2 start ecosystem.config.cjs' },
|
|
38
|
+
stop: { usage: 'selva stop', blurb: 'pm2 stop selva-compute' },
|
|
39
|
+
restart: {
|
|
40
|
+
usage: 'selva restart',
|
|
41
|
+
blurb:
|
|
42
|
+
'pm2 restart selva-compute --update-env. The --update-env is why this\nexists: without it a pm2 restart keeps the old .env in memory.'
|
|
43
|
+
},
|
|
44
|
+
logs: { usage: 'selva logs', blurb: 'pm2 logs selva-compute' },
|
|
45
|
+
update: {
|
|
46
|
+
usage: 'selva update',
|
|
47
|
+
blurb:
|
|
48
|
+
'npm update @selvajs/cli + @selvajs/selva, then restart. Rolls the\ndependency tree back if the install fails.'
|
|
49
|
+
},
|
|
50
|
+
migrate: {
|
|
51
|
+
usage: 'selva migrate',
|
|
52
|
+
blurb:
|
|
53
|
+
'Bring package.json and the deployment layout onto the current release.\nRewrites keys, never comments.'
|
|
54
|
+
},
|
|
55
|
+
keys: {
|
|
56
|
+
usage: 'selva keys rotate <hmac|at-rest>',
|
|
57
|
+
blurb:
|
|
58
|
+
'Rotate one secret in .env. Destructive and not reversible — the command\nspells out the blast radius and asks before writing.'
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function printCommandHelp(command) {
|
|
63
|
+
const entry = USAGE[command];
|
|
64
|
+
const lines = [pc.bold(entry.usage), '', entry.blurb];
|
|
65
|
+
if (entry.flags) {
|
|
66
|
+
lines.push('', pc.bold('Flags:'));
|
|
67
|
+
for (const [flag, description] of entry.flags) {
|
|
68
|
+
lines.push(` ${flag.padEnd(22)}${description}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
console.log(lines.join('\n'));
|
|
72
|
+
}
|
|
73
|
+
|
|
21
74
|
function keysDispatch(m) {
|
|
22
75
|
return async (argv) => {
|
|
23
76
|
const sub = argv[0];
|
|
@@ -51,6 +104,11 @@ export async function runSelva(argv) {
|
|
|
51
104
|
process.exit(1);
|
|
52
105
|
}
|
|
53
106
|
|
|
107
|
+
if (rest.includes('--help') || rest.includes('-h')) {
|
|
108
|
+
printCommandHelp(command);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
54
112
|
const run = await loader();
|
|
55
113
|
await run(rest);
|
|
56
114
|
}
|
|
@@ -60,6 +118,8 @@ function printHelp() {
|
|
|
60
118
|
[
|
|
61
119
|
pc.bold('selva') + ' — operate a Selva deployment',
|
|
62
120
|
'',
|
|
121
|
+
pc.bold('Usage:') + ' npx selva <command>',
|
|
122
|
+
'',
|
|
63
123
|
pc.bold('Commands:'),
|
|
64
124
|
' init Reconfigure this deployment (prompts again)',
|
|
65
125
|
' doctor [--fix] Validate env, providers, Node engine, and packages',
|
|
@@ -67,11 +127,19 @@ function printHelp() {
|
|
|
67
127
|
' stop pm2 stop selva-compute',
|
|
68
128
|
' restart pm2 restart selva-compute --update-env',
|
|
69
129
|
' logs pm2 logs selva-compute',
|
|
70
|
-
' update npm update @selvajs/
|
|
130
|
+
' update npm update @selvajs/cli + @selvajs/selva, then restart',
|
|
71
131
|
' migrate Bring package.json onto the current layout',
|
|
72
132
|
' keys rotate <hmac|at-rest> Rotate a secret in .env (destructive)',
|
|
73
133
|
'',
|
|
74
|
-
pc.dim('
|
|
134
|
+
pc.dim('`npx selva <command> --help` explains one command.'),
|
|
135
|
+
'',
|
|
136
|
+
pc.bold('Two commands, two jobs:'),
|
|
137
|
+
' ' + pc.cyan('npx @selvajs/cli <dir>') + ' scaffold a NEW deployment into <dir>',
|
|
138
|
+
' ' + pc.cyan('npx selva <command>') + ' operate the deployment you are standing in',
|
|
139
|
+
'',
|
|
140
|
+
pc.dim('`npx selva` works in a deployment directory because @selvajs/cli is one of'),
|
|
141
|
+
pc.dim('its dependencies. The package.json scripts (`npm run doctor`, `npm start`)'),
|
|
142
|
+
pc.dim('are thin aliases for the same commands.')
|
|
75
143
|
].join('\n')
|
|
76
144
|
);
|
|
77
145
|
}
|
package/src/commands/create.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
// ecosystem.config.cjs
|
|
3
|
-
// templates are the source of truth; providers are env-driven.
|
|
1
|
+
// Scaffolds a fresh deployment: package.json, node_modules, .env (merged from
|
|
2
|
+
// template + prompts), and ecosystem.config.cjs.
|
|
4
3
|
|
|
5
4
|
import { writeFileSync, existsSync, mkdirSync, readFileSync, cpSync } from 'node:fs';
|
|
6
5
|
import { resolve, join, basename, dirname } from 'node:path';
|
|
@@ -16,18 +15,20 @@ import {
|
|
|
16
15
|
runtimeTemplatePath,
|
|
17
16
|
scaffoldVersion
|
|
18
17
|
} from '../paths.js';
|
|
19
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
buildDeploymentPackageJson,
|
|
20
|
+
npmDistTagVersion,
|
|
21
|
+
resolveSelvaPins
|
|
22
|
+
} from '../deployment-package.js';
|
|
20
23
|
import { satisfiesNodeRange } from '../node-range.js';
|
|
21
24
|
|
|
22
25
|
export async function runCreate(argv) {
|
|
23
26
|
const { dir: rawDir, force, skipInstall, yes } = parseArgs(argv);
|
|
24
27
|
|
|
25
|
-
// Scaffolding under too old a Node
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
// operator is still choosing their environment. The floor comes from this
|
|
30
|
-
// CLI's own engines.node, so a bump can't leave a stale copy behind.
|
|
28
|
+
// Scaffolding under too old a Node installs and passes the health check,
|
|
29
|
+
// then fails only under real traffic — npm treats an engine mismatch as a
|
|
30
|
+
// warning, not an error, unless engine-strict is set, which no deployment
|
|
31
|
+
// sets. Refuse now, while the operator is still choosing their environment.
|
|
31
32
|
const required = requiredNodeRange();
|
|
32
33
|
if (required && satisfiesNodeRange(process.versions.node, required) === false) {
|
|
33
34
|
console.error(
|
|
@@ -50,20 +51,29 @@ export async function runCreate(argv) {
|
|
|
50
51
|
}
|
|
51
52
|
mkdirSync(targetDir, { recursive: true });
|
|
52
53
|
|
|
53
|
-
// --yes or CI=1 skips prompts (unattended bootstrap).
|
|
54
54
|
const nonInteractive = yes || envBool(process.env.CI);
|
|
55
55
|
const values = nonInteractive
|
|
56
56
|
? collectConfigFromEnv(process.env)
|
|
57
57
|
: await collectConfig({ defaults: {}, mode: 'create' });
|
|
58
58
|
|
|
59
|
-
// Generate fresh secrets (stable across restarts; init refuses to regen).
|
|
60
59
|
values.SELVA_HMAC_KEY = generateKey();
|
|
61
60
|
values.SELVA_AT_REST_KEY = generateKey();
|
|
62
61
|
|
|
63
62
|
const deployName = basename(targetDir);
|
|
64
63
|
|
|
65
|
-
//
|
|
66
|
-
|
|
64
|
+
// Resolve the dist-tags to concrete versions before writing. A stored
|
|
65
|
+
// `"latest"` re-resolves on every later `npm install`, so the deployment
|
|
66
|
+
// would follow the tag instead of the version it was scaffolded against —
|
|
67
|
+
// and `selva doctor` reports a floating pin as drift.
|
|
68
|
+
const { pins } = resolveSelvaPins({}, npmDistTagVersion);
|
|
69
|
+
|
|
70
|
+
// package.json first: npm install needs it, and the runtime templates below
|
|
71
|
+
// only exist once @selvajs/selva is installed into node_modules.
|
|
72
|
+
const pkgJson = JSON.stringify(
|
|
73
|
+
buildDeploymentPackageJson({ name: deployName, dependencies: pins }),
|
|
74
|
+
null,
|
|
75
|
+
2
|
|
76
|
+
);
|
|
67
77
|
writeFileSync(join(targetDir, 'package.json'), pkgJson + '\n', 'utf8');
|
|
68
78
|
|
|
69
79
|
if (!skipInstall) {
|
|
@@ -74,7 +84,6 @@ export async function runCreate(argv) {
|
|
|
74
84
|
);
|
|
75
85
|
}
|
|
76
86
|
|
|
77
|
-
// Copy templates from installed runtime.
|
|
78
87
|
const envTemplatePath = runtimeTemplatePath(targetDir, '.env.example');
|
|
79
88
|
const ecosystemTemplatePath = runtimeTemplatePath(targetDir, 'ecosystem.config.cjs');
|
|
80
89
|
if (skipInstall || !existsSync(envTemplatePath)) {
|
|
@@ -110,21 +119,17 @@ export async function runCreate(argv) {
|
|
|
110
119
|
);
|
|
111
120
|
}
|
|
112
121
|
|
|
113
|
-
// Stream npm output with live progress; on failure, show tail for debugging.
|
|
114
|
-
// Cache-bust hint: surface broken versions like @selvajs/selva@0.10.2.
|
|
115
122
|
function runNpmInstall(cwd) {
|
|
116
123
|
return new Promise((resolveP, rejectP) => {
|
|
117
124
|
const s = p.spinner();
|
|
118
125
|
s.start('Installing dependencies (this can take a minute)');
|
|
119
126
|
|
|
120
|
-
// Keep last 80 lines for failure output.
|
|
121
127
|
const tail = [];
|
|
122
128
|
const remember = (line) => {
|
|
123
129
|
tail.push(line);
|
|
124
130
|
if (tail.length > 80) tail.shift();
|
|
125
131
|
};
|
|
126
132
|
|
|
127
|
-
// Extract progress milestones (reify: lines, package count).
|
|
128
133
|
const updateProgress = (line) => {
|
|
129
134
|
const trimmed = line.trim();
|
|
130
135
|
if (!trimmed) return;
|
|
@@ -140,7 +145,7 @@ function runNpmInstall(cwd) {
|
|
|
140
145
|
if (/^npm (WARN|error|notice)/.test(trimmed)) return;
|
|
141
146
|
};
|
|
142
147
|
|
|
143
|
-
//
|
|
148
|
+
// --loglevel=info is what makes npm print the reify: lines updateProgress matches on.
|
|
144
149
|
const child = spawn('npm', ['install', '--loglevel=info'], {
|
|
145
150
|
cwd,
|
|
146
151
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -184,8 +189,8 @@ function runNpmInstall(cwd) {
|
|
|
184
189
|
}
|
|
185
190
|
s.stop(pc.red(`npm install failed (exit ${code})`));
|
|
186
191
|
|
|
187
|
-
//
|
|
188
|
-
//
|
|
192
|
+
// Without this the operator has to dig through ~/.npm/_logs/*-debug-0.log
|
|
193
|
+
// to find out what actually went wrong.
|
|
189
194
|
console.error('');
|
|
190
195
|
console.error(pc.dim('── last lines of npm output ──'));
|
|
191
196
|
for (const line of tail) console.error(line);
|
|
@@ -210,11 +215,37 @@ function runNpmInstall(cwd) {
|
|
|
210
215
|
});
|
|
211
216
|
}
|
|
212
217
|
|
|
218
|
+
// `npx @selvajs/cli` resolves to the `cli` bin — the scaffolder — while every
|
|
219
|
+
// operate command lives on the sibling `selva` bin. So `npx @selvajs/cli doctor
|
|
220
|
+
// --fix` lands here, where "doctor" is just a directory name and `--fix` an
|
|
221
|
+
// unknown flag. Name the bin split rather than letting the operator conclude
|
|
222
|
+
// their CLI is too old for a flag it has shipped for releases.
|
|
223
|
+
const OPERATE_COMMANDS = new Set([
|
|
224
|
+
'doctor',
|
|
225
|
+
'start',
|
|
226
|
+
'stop',
|
|
227
|
+
'restart',
|
|
228
|
+
'logs',
|
|
229
|
+
'update',
|
|
230
|
+
'migrate',
|
|
231
|
+
'keys',
|
|
232
|
+
'init'
|
|
233
|
+
]);
|
|
234
|
+
|
|
213
235
|
function parseArgs(argv) {
|
|
214
236
|
let dir;
|
|
215
237
|
let force = false;
|
|
216
238
|
let skipInstall = false;
|
|
217
239
|
let yes = false;
|
|
240
|
+
|
|
241
|
+
if (OPERATE_COMMANDS.has(argv[0])) {
|
|
242
|
+
throw new Error(
|
|
243
|
+
`\`${argv[0]}\` is an operate command, but \`npx @selvajs/cli\` runs the scaffolder.\n` +
|
|
244
|
+
` Run it through the deployment's own CLI instead:\n` +
|
|
245
|
+
` npx selva ${argv.join(' ')}`
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
218
249
|
for (const arg of argv) {
|
|
219
250
|
if (arg === '--force') force = true;
|
|
220
251
|
else if (arg === '--skip-install') skipInstall = true;
|
package/src/commands/doctor.js
CHANGED
|
@@ -2,12 +2,14 @@
|
|
|
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,
|
|
10
10
|
readFileSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
copyFileSync,
|
|
11
13
|
readdirSync,
|
|
12
14
|
accessSync,
|
|
13
15
|
constants,
|
|
@@ -18,20 +20,23 @@ import { join, resolve, dirname } from 'node:path';
|
|
|
18
20
|
import { fileURLToPath } from 'node:url';
|
|
19
21
|
import * as p from '@clack/prompts';
|
|
20
22
|
import pc from 'picocolors';
|
|
21
|
-
import { readEnvFile } from '../env.js';
|
|
23
|
+
import { readEnvFile, parseEnv, countEnvCommentLines, stripEnvComments } from '../env.js';
|
|
22
24
|
import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
|
|
23
25
|
import { satisfiesNodeRange } from '../node-range.js';
|
|
24
26
|
import { green, yellow, red, fixable } from '../checks/result.js';
|
|
25
27
|
import { checkBootPersistence } from '../checks/boot.js';
|
|
28
|
+
import { checkRuntimeEnvironment } from '../checks/runtime.js';
|
|
26
29
|
import {
|
|
27
30
|
checkDeprecatedEnv,
|
|
31
|
+
checkEnvDocumentation,
|
|
32
|
+
checkHeaderNames,
|
|
28
33
|
checkOrigin,
|
|
29
34
|
checkProviders,
|
|
30
35
|
checkSecret,
|
|
31
36
|
checkTenancy,
|
|
32
37
|
resolveProviders
|
|
33
38
|
} from '../checks/config.js';
|
|
34
|
-
import { detectDrift, NODE_MODULES_STASH } from './migrate.js';
|
|
39
|
+
import { BACKUP_AGE_DAYS, classifyBackups, detectDrift, NODE_MODULES_STASH } from './migrate.js';
|
|
35
40
|
|
|
36
41
|
export async function runDoctor(argv = []) {
|
|
37
42
|
const fix = argv.includes('--fix');
|
|
@@ -47,6 +52,7 @@ export async function runDoctor(argv = []) {
|
|
|
47
52
|
checks.push(checkFile(join(dir, 'ecosystem.config.cjs'), 'ecosystem.config.cjs present'));
|
|
48
53
|
checks.push(checkLayoutDrift(dir));
|
|
49
54
|
checks.push(checkMigrationLeftovers(dir));
|
|
55
|
+
checks.push(checkAgedBackups(dir));
|
|
50
56
|
checks.push(checkSecret(env.SELVA_HMAC_KEY, 'SELVA_HMAC_KEY is a 32-byte hex string'));
|
|
51
57
|
checks.push(checkSecret(env.SELVA_AT_REST_KEY, 'SELVA_AT_REST_KEY is a 32-byte hex string'));
|
|
52
58
|
|
|
@@ -73,9 +79,11 @@ export async function runDoctor(argv = []) {
|
|
|
73
79
|
checks.push(checkPackage(dir, '@selvajs/selva'));
|
|
74
80
|
checks.push(checkNodeEngine(dir));
|
|
75
81
|
checks.push(checkCliRuntimeAlignment(dir));
|
|
82
|
+
checks.push(...checkRuntimeEnvironment(dir));
|
|
76
83
|
checks.push(...checkBootPersistence(dir));
|
|
77
84
|
checks.push(checkOrigin(env));
|
|
78
85
|
checks.push(...checkDeprecatedEnv(env));
|
|
86
|
+
checks.push(checkEnvDocs(dir));
|
|
79
87
|
|
|
80
88
|
// ── Render ─────────────────────────────────────────────────────────
|
|
81
89
|
const resolved = await Promise.all(checks);
|
|
@@ -92,7 +100,7 @@ export async function runDoctor(argv = []) {
|
|
|
92
100
|
if (repairable > 0) {
|
|
93
101
|
p.log.info(
|
|
94
102
|
`${repairable} issue${repairable === 1 ? '' : 's'} can be repaired automatically — ` +
|
|
95
|
-
`re-run with \`selva doctor --fix\`.`
|
|
103
|
+
`re-run with \`npx selva doctor --fix\`.`
|
|
96
104
|
);
|
|
97
105
|
}
|
|
98
106
|
}
|
|
@@ -105,15 +113,9 @@ export async function runDoctor(argv = []) {
|
|
|
105
113
|
}
|
|
106
114
|
}
|
|
107
115
|
|
|
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
|
-
*/
|
|
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.
|
|
117
119
|
async function applyFixes(resolved, failures) {
|
|
118
120
|
const repairs = resolved.filter((c) => c.fix && c.severity !== 'green');
|
|
119
121
|
if (repairs.length === 0) {
|
|
@@ -183,8 +185,7 @@ async function checkSupabase(env) {
|
|
|
183
185
|
return red(`SUPABASE_URL="${env.SUPABASE_URL}" is not a valid URL`);
|
|
184
186
|
}
|
|
185
187
|
|
|
186
|
-
//
|
|
187
|
-
// errors — operators may be offline at install time.
|
|
188
|
+
// Network errors go yellow, not red — operators may be offline at install time.
|
|
188
189
|
try {
|
|
189
190
|
const controller = new AbortController();
|
|
190
191
|
const timer = setTimeout(() => controller.abort(), 4000);
|
|
@@ -199,11 +200,10 @@ async function checkSupabase(env) {
|
|
|
199
200
|
}
|
|
200
201
|
}
|
|
201
202
|
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
// 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.
|
|
207
207
|
async function checkSupabaseMigrations(dir, env) {
|
|
208
208
|
if (!env.SUPABASE_URL || !env.SUPABASE_SERVICE_ROLE_KEY) {
|
|
209
209
|
return yellow('Migration-head check skipped — Supabase env incomplete');
|
|
@@ -264,6 +264,7 @@ async function checkSupabaseMigrations(dir, env) {
|
|
|
264
264
|
`Database reports no applied migrations (expected head ${expected}). Sync + run: npx supabase db push`
|
|
265
265
|
);
|
|
266
266
|
}
|
|
267
|
+
// String comparison — relies on both heads being fixed-width, zero-padded timestamps.
|
|
267
268
|
if (actual < expected) {
|
|
268
269
|
return red(
|
|
269
270
|
`Database migration head ${actual} is behind the installed provider (${expected}). Sync + run: npx supabase db push`
|
|
@@ -271,7 +272,6 @@ async function checkSupabaseMigrations(dir, env) {
|
|
|
271
272
|
}
|
|
272
273
|
return green(`Database migration head ${actual} matches the installed provider`);
|
|
273
274
|
} catch (err) {
|
|
274
|
-
// Soft-fail on network errors, same as checkSupabase.
|
|
275
275
|
return yellow(`Migration-head check skipped (${err.message ?? err})`);
|
|
276
276
|
}
|
|
277
277
|
}
|
|
@@ -295,7 +295,7 @@ function majorOf(version) {
|
|
|
295
295
|
return m ? Number(m[1]) : null;
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
-
// CLI and runtime release
|
|
298
|
+
// CLI and runtime release as a fixed group; major-version skew means a stale CLI pin.
|
|
299
299
|
function checkCliRuntimeAlignment(dir) {
|
|
300
300
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
301
301
|
const cliVersion = readPackageVersion(join(here, '..', '..', 'package.json'));
|
|
@@ -303,7 +303,7 @@ function checkCliRuntimeAlignment(dir) {
|
|
|
303
303
|
join(dir, 'node_modules', '@selvajs', 'selva', 'package.json')
|
|
304
304
|
);
|
|
305
305
|
|
|
306
|
-
//
|
|
306
|
+
// checkPackage already reports missing runtime as red; stay quiet here.
|
|
307
307
|
if (!runtimeVersion) return yellow('CLI/runtime version check skipped (runtime not installed)');
|
|
308
308
|
if (!cliVersion) return yellow('CLI/runtime version check skipped (could not read CLI version)');
|
|
309
309
|
|
|
@@ -324,16 +324,10 @@ function checkCliRuntimeAlignment(dir) {
|
|
|
324
324
|
);
|
|
325
325
|
}
|
|
326
326
|
|
|
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
|
-
*/
|
|
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.
|
|
337
331
|
function checkNodeEngine(dir) {
|
|
338
332
|
let required;
|
|
339
333
|
try {
|
|
@@ -363,12 +357,10 @@ function checkNodeEngine(dir) {
|
|
|
363
357
|
);
|
|
364
358
|
}
|
|
365
359
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
* the dependency tree and the deployment may be running on the wrong one.
|
|
371
|
-
*/
|
|
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.
|
|
372
364
|
function checkMigrationLeftovers(dir) {
|
|
373
365
|
const stash = join(dir, NODE_MODULES_STASH);
|
|
374
366
|
if (!existsSync(stash)) return green('no interrupted migration left behind');
|
|
@@ -386,6 +378,94 @@ function checkMigrationLeftovers(dir) {
|
|
|
386
378
|
);
|
|
387
379
|
}
|
|
388
380
|
|
|
381
|
+
// Migration backups are timestamped so a later migration can't overwrite an
|
|
382
|
+
// earlier one's (#184), which means they accumulate. Deleting them silently
|
|
383
|
+
// would throw away the operator's only copy of a pre-migration config, so this
|
|
384
|
+
// reports and lets `--fix` opt in. `classifyBackups` always keeps the newest
|
|
385
|
+
// run, so the escape hatch is never emptied — only superseded generations go.
|
|
386
|
+
function checkAgedBackups(dir) {
|
|
387
|
+
let names;
|
|
388
|
+
try {
|
|
389
|
+
names = readdirSync(dir);
|
|
390
|
+
} catch {
|
|
391
|
+
return green('no aged migration backups');
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const { aged } = classifyBackups(names);
|
|
395
|
+
if (aged.length === 0) return green('no aged migration backups');
|
|
396
|
+
|
|
397
|
+
const shown = aged.slice(0, 3).join(', ');
|
|
398
|
+
const more = aged.length > 3 ? `, +${aged.length - 3} more` : '';
|
|
399
|
+
return yellow(
|
|
400
|
+
`${aged.length} migration backup${aged.length === 1 ? '' : 's'} older than ` +
|
|
401
|
+
`${BACKUP_AGE_DAYS} days (${shown}${more}). The newest set is kept either way.`,
|
|
402
|
+
fixable(`delete ${aged.length} aged migration backup${aged.length === 1 ? '' : 's'}`, () => {
|
|
403
|
+
const failed = [];
|
|
404
|
+
for (const name of aged) {
|
|
405
|
+
try {
|
|
406
|
+
rmSync(join(dir, name), { force: true });
|
|
407
|
+
} catch (err) {
|
|
408
|
+
failed.push(`${name} (${err instanceof Error ? err.message : err})`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (failed.length > 0) return red(`could not remove: ${failed.join(', ')}`);
|
|
412
|
+
return green(`removed ${aged.length} aged backup${aged.length === 1 ? '' : 's'}`);
|
|
413
|
+
})
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
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)');
|
|
423
|
+
|
|
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
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
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`;
|
|
465
|
+
}
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
|
|
389
469
|
function checkLayoutDrift(dir) {
|
|
390
470
|
const pkgPath = join(dir, 'package.json');
|
|
391
471
|
if (!existsSync(pkgPath)) return yellow('package.json missing — cannot check layout');
|
|
@@ -403,19 +483,10 @@ function checkLayoutDrift(dir) {
|
|
|
403
483
|
);
|
|
404
484
|
}
|
|
405
485
|
|
|
406
|
-
//
|
|
407
|
-
// Smoke test in providers/header-auth pins them; divergence surfaces in CI.
|
|
408
|
-
const DEFAULT_HEADER_NAMES = {
|
|
409
|
-
upn: 'SELVA-UserPrincipalName',
|
|
410
|
-
email: 'SELVA-Email',
|
|
411
|
-
displayName: 'SELVA-DisplayName'
|
|
412
|
-
};
|
|
413
|
-
|
|
414
|
-
// 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.
|
|
415
487
|
function checkHeaderAuth(dir, env, dataProvider) {
|
|
416
488
|
const out = [];
|
|
417
489
|
|
|
418
|
-
// Allowlist file location.
|
|
419
490
|
const allowlistDir = env.HEADER_AUTH_DATA_DIR ?? env.DATA_PATH;
|
|
420
491
|
if (!allowlistDir) {
|
|
421
492
|
out.push(red('HEADER_AUTH_DATA_DIR (or DATA_PATH) unset — provider will fail to start'));
|
|
@@ -425,21 +496,21 @@ function checkHeaderAuth(dir, env, dataProvider) {
|
|
|
425
496
|
if (existsSync(allowlistPath)) {
|
|
426
497
|
out.push(green(`header-allowlist.json present (${allowlistDir}/header-allowlist.json)`));
|
|
427
498
|
} else {
|
|
428
|
-
//
|
|
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.
|
|
429
501
|
out.push(
|
|
430
502
|
yellow(
|
|
431
503
|
`header-allowlist.json not found at ${allowlistDir}/ — no users will be allowed in until one is added`
|
|
432
504
|
)
|
|
433
505
|
);
|
|
434
506
|
|
|
435
|
-
//
|
|
507
|
+
// DATA_PATH's own writability is checked separately (checkDataPath).
|
|
436
508
|
if (env.HEADER_AUTH_DATA_DIR) {
|
|
437
509
|
out.push(checkDirWritable(allowlistAbsDir, `HEADER_AUTH_DATA_DIR=${allowlistDir}`));
|
|
438
510
|
}
|
|
439
511
|
}
|
|
440
512
|
}
|
|
441
513
|
|
|
442
|
-
// HOST binding (loopback recommended for header-auth).
|
|
443
514
|
const host = env.HOST ?? '0.0.0.0';
|
|
444
515
|
if (host === '127.0.0.1' || host === 'localhost') {
|
|
445
516
|
out.push(green(`HOST=${host} (loopback-only)`));
|
|
@@ -452,12 +523,11 @@ function checkHeaderAuth(dir, env, dataProvider) {
|
|
|
452
523
|
);
|
|
453
524
|
}
|
|
454
525
|
|
|
455
|
-
//
|
|
526
|
+
// header-auth always sits behind a reverse proxy, so ORIGIN is required.
|
|
456
527
|
if (!env.ORIGIN) {
|
|
457
528
|
out.push(red('ORIGIN unset — required for header-auth (always behind a proxy)'));
|
|
458
529
|
}
|
|
459
530
|
|
|
460
|
-
// Non-local data provider: HEADER_AUTH_DATA_DIR must be explicit (no DATA_PATH fallback).
|
|
461
531
|
if (dataProvider !== 'local' && !env.HEADER_AUTH_DATA_DIR) {
|
|
462
532
|
out.push(
|
|
463
533
|
red(
|
|
@@ -467,10 +537,10 @@ function checkHeaderAuth(dir, env, dataProvider) {
|
|
|
467
537
|
);
|
|
468
538
|
}
|
|
469
539
|
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
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.
|
|
474
544
|
if (!env.BOOTSTRAP_INSTANCE_ADMIN_EMAIL) {
|
|
475
545
|
out.push(
|
|
476
546
|
red(
|
|
@@ -482,30 +552,7 @@ function checkHeaderAuth(dir, env, dataProvider) {
|
|
|
482
552
|
out.push(green(`BOOTSTRAP_INSTANCE_ADMIN_EMAIL=${env.BOOTSTRAP_INSTANCE_ADMIN_EMAIL}`));
|
|
483
553
|
}
|
|
484
554
|
|
|
485
|
-
|
|
486
|
-
const resolved = {
|
|
487
|
-
upn: env.HEADER_AUTH_UPN_HEADER || DEFAULT_HEADER_NAMES.upn,
|
|
488
|
-
email: env.HEADER_AUTH_EMAIL_HEADER || DEFAULT_HEADER_NAMES.email,
|
|
489
|
-
displayName: env.HEADER_AUTH_DISPLAY_NAME_HEADER || DEFAULT_HEADER_NAMES.displayName
|
|
490
|
-
};
|
|
491
|
-
const overrides = [
|
|
492
|
-
Boolean(env.HEADER_AUTH_UPN_HEADER),
|
|
493
|
-
Boolean(env.HEADER_AUTH_EMAIL_HEADER),
|
|
494
|
-
Boolean(env.HEADER_AUTH_DISPLAY_NAME_HEADER)
|
|
495
|
-
].filter(Boolean).length;
|
|
496
|
-
const headerList = `UPN=${resolved.upn}, Email=${resolved.email}, DisplayName=${resolved.displayName}`;
|
|
497
|
-
if (overrides === 0) {
|
|
498
|
-
out.push(green(`header names (bundled defaults): ${headerList}`));
|
|
499
|
-
} else if (overrides === 3) {
|
|
500
|
-
out.push(green(`header names (all overridden): ${headerList}`));
|
|
501
|
-
} else {
|
|
502
|
-
out.push(
|
|
503
|
-
yellow(
|
|
504
|
-
`header names partially overridden (${overrides}/3 set): ${headerList} — ` +
|
|
505
|
-
`a partial override is usually a typo. Set all three or none.`
|
|
506
|
-
)
|
|
507
|
-
);
|
|
508
|
-
}
|
|
555
|
+
out.push(checkHeaderNames(env));
|
|
509
556
|
|
|
510
557
|
return out;
|
|
511
558
|
}
|