@selvajs/cli 4.7.4-beta.1 → 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 +10 -4
- package/src/checks/boot.js +151 -0
- package/src/checks/config.js +89 -0
- package/src/checks/result.js +27 -0
- package/src/checks/runtime.js +289 -0
- package/src/cli.js +2 -2
- package/src/commands/create.js +43 -69
- package/src/commands/doctor.js +151 -170
- package/src/commands/init.js +9 -32
- package/src/commands/keys.js +3 -17
- package/src/commands/migrate.js +225 -105
- package/src/commands/pm2.js +45 -9
- package/src/deployment-package.js +143 -0
- package/src/env.js +57 -3
- package/src/node-range.js +66 -0
- package/src/paths.js +68 -5
- package/src/prompts.js +4 -16
- package/bin/create.js +0 -7
package/src/commands/doctor.js
CHANGED
|
@@ -1,24 +1,45 @@
|
|
|
1
1
|
// Validate deployment without starting it: files, secrets, layout, providers,
|
|
2
|
-
// boot persistence.
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
// Node engine, boot persistence. Yellow warnings don't fail, red failures exit 1.
|
|
3
|
+
//
|
|
4
|
+
// Read-only by default. `--fix` applies the repairs attached to individual
|
|
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
|
+
readdirSync,
|
|
12
|
+
accessSync,
|
|
13
|
+
constants,
|
|
14
|
+
statSync,
|
|
15
|
+
rmSync
|
|
16
|
+
} from 'node:fs';
|
|
17
|
+
import { join, resolve, dirname } from 'node:path';
|
|
6
18
|
import { fileURLToPath } from 'node:url';
|
|
7
|
-
import { homedir } from 'node:os';
|
|
8
19
|
import * as p from '@clack/prompts';
|
|
9
20
|
import pc from 'picocolors';
|
|
10
21
|
import { readEnvFile } from '../env.js';
|
|
11
22
|
import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
23
|
+
import { satisfiesNodeRange } from '../node-range.js';
|
|
24
|
+
import { green, yellow, red, fixable } from '../checks/result.js';
|
|
25
|
+
import { checkBootPersistence } from '../checks/boot.js';
|
|
26
|
+
import { checkRuntimeEnvironment } from '../checks/runtime.js';
|
|
27
|
+
import {
|
|
28
|
+
checkDeprecatedEnv,
|
|
29
|
+
checkOrigin,
|
|
30
|
+
checkProviders,
|
|
31
|
+
checkSecret,
|
|
32
|
+
checkTenancy,
|
|
33
|
+
resolveProviders
|
|
34
|
+
} from '../checks/config.js';
|
|
35
|
+
import { detectDrift, NODE_MODULES_STASH } from './migrate.js';
|
|
36
|
+
|
|
37
|
+
export async function runDoctor(argv = []) {
|
|
38
|
+
const fix = argv.includes('--fix');
|
|
18
39
|
const dir = resolveDeploymentDir();
|
|
19
40
|
requireDeploymentDir(dir);
|
|
20
41
|
|
|
21
|
-
p.intro(pc.bgCyan(pc.black(' selva doctor ')));
|
|
42
|
+
p.intro(pc.bgCyan(pc.black(fix ? ' selva doctor --fix ' : ' selva doctor ')));
|
|
22
43
|
|
|
23
44
|
const checks = [];
|
|
24
45
|
const env = readEnvFile(join(dir, '.env'));
|
|
@@ -26,28 +47,12 @@ export async function runDoctor() {
|
|
|
26
47
|
checks.push(checkFile(join(dir, '.env'), '.env present'));
|
|
27
48
|
checks.push(checkFile(join(dir, 'ecosystem.config.cjs'), 'ecosystem.config.cjs present'));
|
|
28
49
|
checks.push(checkLayoutDrift(dir));
|
|
50
|
+
checks.push(checkMigrationLeftovers(dir));
|
|
29
51
|
checks.push(checkSecret(env.SELVA_HMAC_KEY, 'SELVA_HMAC_KEY is a 32-byte hex string'));
|
|
30
52
|
checks.push(checkSecret(env.SELVA_AT_REST_KEY, 'SELVA_AT_REST_KEY is a 32-byte hex string'));
|
|
31
53
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
auth: (env.SELVA_AUTH_PROVIDER ?? 'local').toLowerCase(),
|
|
35
|
-
data: (env.SELVA_DATA_PROVIDER ?? 'local').toLowerCase(),
|
|
36
|
-
storage: (env.SELVA_STORAGE_PROVIDER ?? 'local').toLowerCase()
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
const validForAuth = new Set(['local', 'supabase', 'header']);
|
|
40
|
-
const validForData = new Set(['local', 'supabase']);
|
|
41
|
-
|
|
42
|
-
if (!validForAuth.has(providers.auth)) {
|
|
43
|
-
checks.push(red(`SELVA_AUTH_PROVIDER="${providers.auth}" — expected local|supabase|header`));
|
|
44
|
-
}
|
|
45
|
-
if (!validForData.has(providers.data)) {
|
|
46
|
-
checks.push(red(`SELVA_DATA_PROVIDER="${providers.data}" — expected local|supabase`));
|
|
47
|
-
}
|
|
48
|
-
if (!validForData.has(providers.storage)) {
|
|
49
|
-
checks.push(red(`SELVA_STORAGE_PROVIDER="${providers.storage}" — expected local|supabase`));
|
|
50
|
-
}
|
|
54
|
+
const providers = resolveProviders(env);
|
|
55
|
+
checks.push(...checkProviders(providers));
|
|
51
56
|
|
|
52
57
|
const used = new Set(Object.values(providers));
|
|
53
58
|
|
|
@@ -64,34 +69,36 @@ export async function runDoctor() {
|
|
|
64
69
|
checks.push(...checkHeaderAuth(dir, env, providers.data));
|
|
65
70
|
}
|
|
66
71
|
|
|
67
|
-
|
|
68
|
-
if (tenancy !== 'single' && tenancy !== 'multi') {
|
|
69
|
-
checks.push(red(`SELVA_TENANCY="${tenancy}" — expected single|multi`));
|
|
70
|
-
} else {
|
|
71
|
-
checks.push(green(`SELVA_TENANCY=${tenancy}`));
|
|
72
|
-
}
|
|
72
|
+
checks.push(checkTenancy(env));
|
|
73
73
|
|
|
74
74
|
checks.push(checkPackage(dir, '@selvajs/selva'));
|
|
75
|
+
checks.push(checkNodeEngine(dir));
|
|
75
76
|
checks.push(checkCliRuntimeAlignment(dir));
|
|
77
|
+
checks.push(...checkRuntimeEnvironment(dir));
|
|
76
78
|
checks.push(...checkBootPersistence(dir));
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
new URL(env.ORIGIN);
|
|
80
|
-
checks.push(green(`ORIGIN=${env.ORIGIN}`));
|
|
81
|
-
} catch {
|
|
82
|
-
checks.push(red(`ORIGIN="${env.ORIGIN}" is not a valid URL`));
|
|
83
|
-
}
|
|
84
|
-
} else {
|
|
85
|
-
checks.push(yellow('ORIGIN unset — required behind a reverse proxy'));
|
|
86
|
-
}
|
|
79
|
+
checks.push(checkOrigin(env));
|
|
80
|
+
checks.push(...checkDeprecatedEnv(env));
|
|
87
81
|
|
|
88
82
|
// ── Render ─────────────────────────────────────────────────────────
|
|
83
|
+
const resolved = await Promise.all(checks);
|
|
89
84
|
let failures = 0;
|
|
90
|
-
for (const c of
|
|
85
|
+
for (const c of resolved) {
|
|
91
86
|
console.log(' ' + c.line);
|
|
92
87
|
if (c.severity === 'red') failures += 1;
|
|
93
88
|
}
|
|
94
89
|
|
|
90
|
+
if (fix) {
|
|
91
|
+
failures = await applyFixes(resolved, failures);
|
|
92
|
+
} else {
|
|
93
|
+
const repairable = resolved.filter((c) => c.fix).length;
|
|
94
|
+
if (repairable > 0) {
|
|
95
|
+
p.log.info(
|
|
96
|
+
`${repairable} issue${repairable === 1 ? '' : 's'} can be repaired automatically — ` +
|
|
97
|
+
`re-run with \`selva doctor --fix\`.`
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
95
102
|
if (failures === 0) {
|
|
96
103
|
p.outro(pc.green('All checks passed.'));
|
|
97
104
|
} else {
|
|
@@ -100,27 +107,44 @@ export async function runDoctor() {
|
|
|
100
107
|
}
|
|
101
108
|
}
|
|
102
109
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
function
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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.
|
|
113
|
+
async function applyFixes(resolved, failures) {
|
|
114
|
+
const repairs = resolved.filter((c) => c.fix && c.severity !== 'green');
|
|
115
|
+
if (repairs.length === 0) {
|
|
116
|
+
p.log.info('Nothing to repair automatically.');
|
|
117
|
+
return failures;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
console.log('');
|
|
121
|
+
p.log.step(`${repairs.length} repair${repairs.length === 1 ? '' : 's'} available`);
|
|
122
|
+
|
|
123
|
+
for (const check of repairs) {
|
|
124
|
+
const approved = await p.confirm({
|
|
125
|
+
message: check.fix.label,
|
|
126
|
+
initialValue: false
|
|
127
|
+
});
|
|
128
|
+
if (p.isCancel(approved) || !approved) {
|
|
129
|
+
p.log.info('Skipped.');
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
let result;
|
|
133
|
+
try {
|
|
134
|
+
result = await check.fix.run();
|
|
135
|
+
} catch (err) {
|
|
136
|
+
result = red(`repair threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
137
|
+
}
|
|
138
|
+
console.log(' ' + result.line);
|
|
139
|
+
if (result.severity === 'green' && check.severity === 'red') failures -= 1;
|
|
140
|
+
}
|
|
141
|
+
return failures;
|
|
111
142
|
}
|
|
112
143
|
|
|
113
144
|
function checkFile(path, label) {
|
|
114
145
|
return existsSync(path) ? green(label) : red(`${label} (missing: ${path})`);
|
|
115
146
|
}
|
|
116
147
|
|
|
117
|
-
function checkSecret(value, label) {
|
|
118
|
-
if (!value) return red(`${label} — unset`);
|
|
119
|
-
if (value === PLACEHOLDER) return red(`${label} — still the placeholder`);
|
|
120
|
-
if (!HEX_64.test(value)) return red(`${label} — not 64 hex chars`);
|
|
121
|
-
return green(label);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
148
|
function checkDataPath(dir, dataPath) {
|
|
125
149
|
const absolute = resolve(dir, dataPath);
|
|
126
150
|
try {
|
|
@@ -155,8 +179,7 @@ async function checkSupabase(env) {
|
|
|
155
179
|
return red(`SUPABASE_URL="${env.SUPABASE_URL}" is not a valid URL`);
|
|
156
180
|
}
|
|
157
181
|
|
|
158
|
-
//
|
|
159
|
-
// errors — operators may be offline at install time.
|
|
182
|
+
// Network errors go yellow, not red — operators may be offline at install time.
|
|
160
183
|
try {
|
|
161
184
|
const controller = new AbortController();
|
|
162
185
|
const timer = setTimeout(() => controller.abort(), 4000);
|
|
@@ -171,11 +194,10 @@ async function checkSupabase(env) {
|
|
|
171
194
|
}
|
|
172
195
|
}
|
|
173
196
|
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
// 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.
|
|
179
201
|
async function checkSupabaseMigrations(dir, env) {
|
|
180
202
|
if (!env.SUPABASE_URL || !env.SUPABASE_SERVICE_ROLE_KEY) {
|
|
181
203
|
return yellow('Migration-head check skipped — Supabase env incomplete');
|
|
@@ -236,6 +258,7 @@ async function checkSupabaseMigrations(dir, env) {
|
|
|
236
258
|
`Database reports no applied migrations (expected head ${expected}). Sync + run: npx supabase db push`
|
|
237
259
|
);
|
|
238
260
|
}
|
|
261
|
+
// String comparison — relies on both heads being fixed-width, zero-padded timestamps.
|
|
239
262
|
if (actual < expected) {
|
|
240
263
|
return red(
|
|
241
264
|
`Database migration head ${actual} is behind the installed provider (${expected}). Sync + run: npx supabase db push`
|
|
@@ -243,7 +266,6 @@ async function checkSupabaseMigrations(dir, env) {
|
|
|
243
266
|
}
|
|
244
267
|
return green(`Database migration head ${actual} matches the installed provider`);
|
|
245
268
|
} catch (err) {
|
|
246
|
-
// Soft-fail on network errors, same as checkSupabase.
|
|
247
269
|
return yellow(`Migration-head check skipped (${err.message ?? err})`);
|
|
248
270
|
}
|
|
249
271
|
}
|
|
@@ -267,7 +289,7 @@ function majorOf(version) {
|
|
|
267
289
|
return m ? Number(m[1]) : null;
|
|
268
290
|
}
|
|
269
291
|
|
|
270
|
-
// CLI and runtime release
|
|
292
|
+
// CLI and runtime release as a fixed group; major-version skew means a stale CLI pin.
|
|
271
293
|
function checkCliRuntimeAlignment(dir) {
|
|
272
294
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
273
295
|
const cliVersion = readPackageVersion(join(here, '..', '..', 'package.json'));
|
|
@@ -275,7 +297,7 @@ function checkCliRuntimeAlignment(dir) {
|
|
|
275
297
|
join(dir, 'node_modules', '@selvajs', 'selva', 'package.json')
|
|
276
298
|
);
|
|
277
299
|
|
|
278
|
-
//
|
|
300
|
+
// checkPackage already reports missing runtime as red; stay quiet here.
|
|
279
301
|
if (!runtimeVersion) return yellow('CLI/runtime version check skipped (runtime not installed)');
|
|
280
302
|
if (!cliVersion) return yellow('CLI/runtime version check skipped (could not read CLI version)');
|
|
281
303
|
|
|
@@ -296,95 +318,58 @@ function checkCliRuntimeAlignment(dir) {
|
|
|
296
318
|
);
|
|
297
319
|
}
|
|
298
320
|
|
|
299
|
-
//
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
// dump.pm2 (what pm2 startup resurrects).
|
|
309
|
-
const pm2Home = process.env.PM2_HOME ?? join(homedir(), '.pm2');
|
|
310
|
-
const dumpPath = join(pm2Home, 'dump.pm2');
|
|
311
|
-
if (existsSync(dumpPath)) {
|
|
312
|
-
out.push(green('pm2 process list saved (dump.pm2 present)'));
|
|
313
|
-
} else {
|
|
314
|
-
out.push(
|
|
315
|
-
yellow(
|
|
316
|
-
'pm2 process list not saved — run `npx pm2 save` so a reboot can ' +
|
|
317
|
-
'resurrect the app (nothing to restore without it)'
|
|
318
|
-
)
|
|
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.
|
|
325
|
+
function checkNodeEngine(dir) {
|
|
326
|
+
let required;
|
|
327
|
+
try {
|
|
328
|
+
const pkg = JSON.parse(
|
|
329
|
+
readFileSync(join(dir, 'node_modules', '@selvajs', 'selva', 'package.json'), 'utf8')
|
|
319
330
|
);
|
|
331
|
+
required = pkg.engines?.node;
|
|
332
|
+
} catch {
|
|
333
|
+
return yellow('@selvajs/selva engines.node — package.json unreadable, skipped');
|
|
320
334
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
const user = process.env.USER ?? process.env.LOGNAME;
|
|
324
|
-
const unitPath = user
|
|
325
|
-
? `/etc/systemd/system/pm2-${user}.service`
|
|
326
|
-
: null;
|
|
327
|
-
|
|
328
|
-
if (unitPath && existsSync(unitPath)) {
|
|
329
|
-
const localPm2 = join(dir, 'node_modules', 'pm2', 'bin', 'pm2');
|
|
330
|
-
let unit = '';
|
|
331
|
-
try {
|
|
332
|
-
unit = readFileSync(unitPath, 'utf8');
|
|
333
|
-
} catch {
|
|
334
|
-
out.push(yellow(`pm2 systemd unit present but unreadable (${unitPath})`));
|
|
335
|
-
return out;
|
|
336
|
-
}
|
|
337
|
-
const execStart = /^ExecStart=(.+)$/m.exec(unit)?.[1] ?? '';
|
|
338
|
-
if (execStart.includes(localPm2)) {
|
|
339
|
-
out.push(green('pm2 systemd boot unit installed (uses deployment-local pm2)'));
|
|
340
|
-
} else {
|
|
341
|
-
out.push(
|
|
342
|
-
red(
|
|
343
|
-
`pm2 systemd boot unit points at a different pm2 than this deployment's.\n ` +
|
|
344
|
-
`ExecStart: ${execStart || '(not found)'}\n ` +
|
|
345
|
-
`expected: ${localPm2} resurrect\n ` +
|
|
346
|
-
`Reboots will resurrect via the wrong pm2 (version skew). Re-run startup ` +
|
|
347
|
-
`with the local binary:\n ` +
|
|
348
|
-
`sudo env PATH=$PATH:${join(dir, 'node_modules', '.bin')} ${localPm2} ` +
|
|
349
|
-
`startup systemd -u $USER --hp $HOME`
|
|
350
|
-
)
|
|
351
|
-
);
|
|
352
|
-
}
|
|
353
|
-
} else {
|
|
354
|
-
out.push(
|
|
355
|
-
yellow(
|
|
356
|
-
'pm2 systemd boot unit not installed — the app will NOT restart after a ' +
|
|
357
|
-
'reboot. Run `npx pm2 startup systemd -u $USER --hp $HOME` and paste the ' +
|
|
358
|
-
'printed command (point it at this deployment’s pm2).'
|
|
359
|
-
)
|
|
360
|
-
);
|
|
335
|
+
if (typeof required !== 'string') {
|
|
336
|
+
return yellow('@selvajs/selva declares no engines.node — cannot verify this host');
|
|
361
337
|
}
|
|
362
338
|
|
|
363
|
-
|
|
364
|
-
const
|
|
365
|
-
if (
|
|
366
|
-
|
|
367
|
-
yellow(
|
|
368
|
-
`a pm2 outside this deployment is on PATH (${globalPm2}) — it can fork a ` +
|
|
369
|
-
`mismatched daemon and trigger skew. Prefer \`npm run\` wrappers / \`npx pm2\` ` +
|
|
370
|
-
`from this directory; consider \`npm uninstall -g pm2\`.`
|
|
371
|
-
)
|
|
372
|
-
);
|
|
339
|
+
const running = process.versions.node;
|
|
340
|
+
const ok = satisfiesNodeRange(running, required);
|
|
341
|
+
if (ok === null) {
|
|
342
|
+
return yellow(`engines.node "${required}" not understood — verify Node v${running} manually`);
|
|
373
343
|
}
|
|
344
|
+
if (ok) return green(`Node v${running} satisfies engines.node ${required}`);
|
|
374
345
|
|
|
375
|
-
return
|
|
346
|
+
return red(
|
|
347
|
+
`Node v${running} does NOT satisfy @selvajs/selva's engines.node ${required}.\n ` +
|
|
348
|
+
`npm installs it anyway and the health check still passes, so this fails only\n ` +
|
|
349
|
+
`under real traffic. Upgrade Node on this host (nvm/fnm or your package manager),\n ` +
|
|
350
|
+
`then: npm rebuild && npm run restart`
|
|
351
|
+
);
|
|
376
352
|
}
|
|
377
353
|
|
|
378
|
-
//
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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.
|
|
358
|
+
function checkMigrationLeftovers(dir) {
|
|
359
|
+
const stash = join(dir, NODE_MODULES_STASH);
|
|
360
|
+
if (!existsSync(stash)) return green('no interrupted migration left behind');
|
|
361
|
+
return yellow(
|
|
362
|
+
`${NODE_MODULES_STASH} exists — a \`selva migrate\` was interrupted. If the app ` +
|
|
363
|
+
`is healthy, delete it: rm -rf ${stash}`,
|
|
364
|
+
fixable(`delete the leftover ${NODE_MODULES_STASH}`, () => {
|
|
365
|
+
try {
|
|
366
|
+
rmSync(stash, { recursive: true, force: true });
|
|
367
|
+
return green(`removed ${NODE_MODULES_STASH}`);
|
|
368
|
+
} catch (err) {
|
|
369
|
+
return red(`could not remove ${stash}: ${err instanceof Error ? err.message : err}`);
|
|
370
|
+
}
|
|
371
|
+
})
|
|
372
|
+
);
|
|
388
373
|
}
|
|
389
374
|
|
|
390
375
|
function checkLayoutDrift(dir) {
|
|
@@ -404,19 +389,18 @@ function checkLayoutDrift(dir) {
|
|
|
404
389
|
);
|
|
405
390
|
}
|
|
406
391
|
|
|
407
|
-
//
|
|
408
|
-
//
|
|
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.
|
|
409
394
|
const DEFAULT_HEADER_NAMES = {
|
|
410
395
|
upn: 'SELVA-UserPrincipalName',
|
|
411
396
|
email: 'SELVA-Email',
|
|
412
397
|
displayName: 'SELVA-DisplayName'
|
|
413
398
|
};
|
|
414
399
|
|
|
415
|
-
//
|
|
400
|
+
// Static config only — runtime invariants like spoofing protection can't be checked from here.
|
|
416
401
|
function checkHeaderAuth(dir, env, dataProvider) {
|
|
417
402
|
const out = [];
|
|
418
403
|
|
|
419
|
-
// Allowlist file location.
|
|
420
404
|
const allowlistDir = env.HEADER_AUTH_DATA_DIR ?? env.DATA_PATH;
|
|
421
405
|
if (!allowlistDir) {
|
|
422
406
|
out.push(red('HEADER_AUTH_DATA_DIR (or DATA_PATH) unset — provider will fail to start'));
|
|
@@ -426,21 +410,21 @@ function checkHeaderAuth(dir, env, dataProvider) {
|
|
|
426
410
|
if (existsSync(allowlistPath)) {
|
|
427
411
|
out.push(green(`header-allowlist.json present (${allowlistDir}/header-allowlist.json)`));
|
|
428
412
|
} else {
|
|
429
|
-
//
|
|
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.
|
|
430
415
|
out.push(
|
|
431
416
|
yellow(
|
|
432
417
|
`header-allowlist.json not found at ${allowlistDir}/ — no users will be allowed in until one is added`
|
|
433
418
|
)
|
|
434
419
|
);
|
|
435
420
|
|
|
436
|
-
//
|
|
421
|
+
// DATA_PATH's own writability is checked separately (checkDataPath).
|
|
437
422
|
if (env.HEADER_AUTH_DATA_DIR) {
|
|
438
423
|
out.push(checkDirWritable(allowlistAbsDir, `HEADER_AUTH_DATA_DIR=${allowlistDir}`));
|
|
439
424
|
}
|
|
440
425
|
}
|
|
441
426
|
}
|
|
442
427
|
|
|
443
|
-
// HOST binding (loopback recommended for header-auth).
|
|
444
428
|
const host = env.HOST ?? '0.0.0.0';
|
|
445
429
|
if (host === '127.0.0.1' || host === 'localhost') {
|
|
446
430
|
out.push(green(`HOST=${host} (loopback-only)`));
|
|
@@ -453,12 +437,11 @@ function checkHeaderAuth(dir, env, dataProvider) {
|
|
|
453
437
|
);
|
|
454
438
|
}
|
|
455
439
|
|
|
456
|
-
//
|
|
440
|
+
// header-auth always sits behind a reverse proxy, so ORIGIN is required.
|
|
457
441
|
if (!env.ORIGIN) {
|
|
458
442
|
out.push(red('ORIGIN unset — required for header-auth (always behind a proxy)'));
|
|
459
443
|
}
|
|
460
444
|
|
|
461
|
-
// Non-local data provider: HEADER_AUTH_DATA_DIR must be explicit (no DATA_PATH fallback).
|
|
462
445
|
if (dataProvider !== 'local' && !env.HEADER_AUTH_DATA_DIR) {
|
|
463
446
|
out.push(
|
|
464
447
|
red(
|
|
@@ -468,10 +451,10 @@ function checkHeaderAuth(dir, env, dataProvider) {
|
|
|
468
451
|
);
|
|
469
452
|
}
|
|
470
453
|
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
//
|
|
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.
|
|
475
458
|
if (!env.BOOTSTRAP_INSTANCE_ADMIN_EMAIL) {
|
|
476
459
|
out.push(
|
|
477
460
|
red(
|
|
@@ -483,7 +466,6 @@ function checkHeaderAuth(dir, env, dataProvider) {
|
|
|
483
466
|
out.push(green(`BOOTSTRAP_INSTANCE_ADMIN_EMAIL=${env.BOOTSTRAP_INSTANCE_ADMIN_EMAIL}`));
|
|
484
467
|
}
|
|
485
468
|
|
|
486
|
-
// Resolved header names (print for diffing against proxy config; partial override = typo risk).
|
|
487
469
|
const resolved = {
|
|
488
470
|
upn: env.HEADER_AUTH_UPN_HEADER || DEFAULT_HEADER_NAMES.upn,
|
|
489
471
|
email: env.HEADER_AUTH_EMAIL_HEADER || DEFAULT_HEADER_NAMES.email,
|
|
@@ -494,8 +476,7 @@ function checkHeaderAuth(dir, env, dataProvider) {
|
|
|
494
476
|
Boolean(env.HEADER_AUTH_EMAIL_HEADER),
|
|
495
477
|
Boolean(env.HEADER_AUTH_DISPLAY_NAME_HEADER)
|
|
496
478
|
].filter(Boolean).length;
|
|
497
|
-
const headerList =
|
|
498
|
-
`UPN=${resolved.upn}, Email=${resolved.email}, DisplayName=${resolved.displayName}`;
|
|
479
|
+
const headerList = `UPN=${resolved.upn}, Email=${resolved.email}, DisplayName=${resolved.displayName}`;
|
|
499
480
|
if (overrides === 0) {
|
|
500
481
|
out.push(green(`header names (bundled defaults): ${headerList}`));
|
|
501
482
|
} else if (overrides === 3) {
|
package/src/commands/init.js
CHANGED
|
@@ -1,20 +1,14 @@
|
|
|
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
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
11
5
|
import { join } from 'node:path';
|
|
12
6
|
import * as p from '@clack/prompts';
|
|
13
7
|
import pc from 'picocolors';
|
|
14
8
|
import { collectConfig } from '../prompts.js';
|
|
15
9
|
import { readEnvFile, writeEnvFile } from '../env.js';
|
|
16
10
|
import { generateKey } from '../secrets.js';
|
|
17
|
-
import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
|
|
11
|
+
import { readEnvTemplate, requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
|
|
18
12
|
|
|
19
13
|
export async function runInit() {
|
|
20
14
|
const dir = resolveDeploymentDir();
|
|
@@ -24,10 +18,10 @@ export async function runInit() {
|
|
|
24
18
|
const current = readEnvFile(envPath);
|
|
25
19
|
const values = await collectConfig({ defaults: current, mode: 'init' });
|
|
26
20
|
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
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.
|
|
31
25
|
if (
|
|
32
26
|
current.SELVA_HMAC_KEY &&
|
|
33
27
|
current.SELVA_HMAC_KEY !== 'replace-this-with-a-random-32-byte-hex-key'
|
|
@@ -48,24 +42,7 @@ export async function runInit() {
|
|
|
48
42
|
p.log.warn('SELVA_AT_REST_KEY was missing — generated a fresh one.');
|
|
49
43
|
}
|
|
50
44
|
|
|
51
|
-
|
|
52
|
-
// canonical template. Falls back to the current .env if the runtime
|
|
53
|
-
// templates aren't present (shouldn't happen post-install).
|
|
54
|
-
const templatePath = join(
|
|
55
|
-
dir,
|
|
56
|
-
'node_modules',
|
|
57
|
-
'@selvajs',
|
|
58
|
-
'selva',
|
|
59
|
-
'templates',
|
|
60
|
-
'.env.example'
|
|
61
|
-
);
|
|
62
|
-
const template = existsSync(templatePath)
|
|
63
|
-
? readFileSync(templatePath, 'utf8')
|
|
64
|
-
: existsSync(envPath)
|
|
65
|
-
? readFileSync(envPath, 'utf8')
|
|
66
|
-
: '';
|
|
67
|
-
|
|
68
|
-
writeEnvFile(envPath, template, values);
|
|
45
|
+
writeEnvFile(envPath, readEnvTemplate(dir), values);
|
|
69
46
|
|
|
70
47
|
p.outro(
|
|
71
48
|
[
|
package/src/commands/keys.js
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
// Rotate SELVA_HMAC_KEY or SELVA_AT_REST_KEY; requires explicit confirm (blast radius in TARGETS).
|
|
2
2
|
|
|
3
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
4
3
|
import { join } from 'node:path';
|
|
5
4
|
import * as p from '@clack/prompts';
|
|
6
5
|
import pc from 'picocolors';
|
|
7
6
|
import { readEnvFile, writeEnvFile } from '../env.js';
|
|
8
7
|
import { generateKey } from '../secrets.js';
|
|
9
|
-
import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
|
|
8
|
+
import { readEnvTemplate, requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
|
|
10
9
|
|
|
11
10
|
const TARGETS = {
|
|
12
11
|
hmac: {
|
|
@@ -14,8 +13,7 @@ const TARGETS = {
|
|
|
14
13
|
warning: [
|
|
15
14
|
'This will:',
|
|
16
15
|
pc.red(' • log every signed-in user out (existing session cookies stop verifying)'),
|
|
17
|
-
pc.red(' • invalidate share
|
|
18
|
-
pc.dim(' (only relevant when SHARE_LINK_SECRET / INVITE_TOKEN_SECRET are unset)')
|
|
16
|
+
pc.red(' • invalidate every share link and pending invite')
|
|
19
17
|
].join('\n')
|
|
20
18
|
},
|
|
21
19
|
'at-rest': {
|
|
@@ -58,19 +56,7 @@ export async function runKeysRotate(argv) {
|
|
|
58
56
|
const fresh = generateKey();
|
|
59
57
|
current[envVar] = fresh;
|
|
60
58
|
|
|
61
|
-
|
|
62
|
-
dir,
|
|
63
|
-
'node_modules',
|
|
64
|
-
'@selvajs',
|
|
65
|
-
'selva',
|
|
66
|
-
'templates',
|
|
67
|
-
'.env.example'
|
|
68
|
-
);
|
|
69
|
-
const template = existsSync(templatePath)
|
|
70
|
-
? readFileSync(templatePath, 'utf8')
|
|
71
|
-
: readFileSync(envPath, 'utf8');
|
|
72
|
-
|
|
73
|
-
writeEnvFile(envPath, template, current);
|
|
59
|
+
writeEnvFile(envPath, readEnvTemplate(dir), current);
|
|
74
60
|
|
|
75
61
|
p.outro(
|
|
76
62
|
[
|