@selvajs/cli 4.8.0-beta.10 → 4.8.0-beta.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -12
- package/package.json +4 -1
- package/src/checks/boot.js +18 -16
- package/src/checks/config.js +101 -9
- package/src/checks/runtime.js +291 -0
- package/src/cli.js +1 -1
- package/src/commands/create.js +27 -22
- package/src/commands/doctor.js +91 -81
- package/src/commands/init.js +12 -13
- package/src/commands/migrate.js +142 -48
- package/src/commands/pm2.js +12 -21
- package/src/deployment-package.js +156 -4
- package/src/env.js +197 -25
- package/src/paths.js +2 -3
- package/src/prompts.js +4 -14
package/src/commands/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);
|
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,13 +20,16 @@ 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,
|
|
@@ -73,9 +78,11 @@ export async function runDoctor(argv = []) {
|
|
|
73
78
|
checks.push(checkPackage(dir, '@selvajs/selva'));
|
|
74
79
|
checks.push(checkNodeEngine(dir));
|
|
75
80
|
checks.push(checkCliRuntimeAlignment(dir));
|
|
81
|
+
checks.push(...checkRuntimeEnvironment(dir));
|
|
76
82
|
checks.push(...checkBootPersistence(dir));
|
|
77
83
|
checks.push(checkOrigin(env));
|
|
78
84
|
checks.push(...checkDeprecatedEnv(env));
|
|
85
|
+
checks.push(checkEnvDocs(dir));
|
|
79
86
|
|
|
80
87
|
// ── Render ─────────────────────────────────────────────────────────
|
|
81
88
|
const resolved = await Promise.all(checks);
|
|
@@ -105,15 +112,9 @@ export async function runDoctor(argv = []) {
|
|
|
105
112
|
}
|
|
106
113
|
}
|
|
107
114
|
|
|
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
|
-
*/
|
|
115
|
+
// Anything privileged (the systemd unit) or that would restart this process
|
|
116
|
+
// (a Node upgrade) stays a printed instruction instead of a fixer — one that
|
|
117
|
+
// dies halfway through its own runtime leaves the operator with no way back.
|
|
117
118
|
async function applyFixes(resolved, failures) {
|
|
118
119
|
const repairs = resolved.filter((c) => c.fix && c.severity !== 'green');
|
|
119
120
|
if (repairs.length === 0) {
|
|
@@ -183,8 +184,7 @@ async function checkSupabase(env) {
|
|
|
183
184
|
return red(`SUPABASE_URL="${env.SUPABASE_URL}" is not a valid URL`);
|
|
184
185
|
}
|
|
185
186
|
|
|
186
|
-
//
|
|
187
|
-
// errors — operators may be offline at install time.
|
|
187
|
+
// Network errors go yellow, not red — operators may be offline at install time.
|
|
188
188
|
try {
|
|
189
189
|
const controller = new AbortController();
|
|
190
190
|
const timer = setTimeout(() => controller.abort(), 4000);
|
|
@@ -199,11 +199,10 @@ async function checkSupabase(env) {
|
|
|
199
199
|
}
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
// confusing deploy.
|
|
202
|
+
// Compares the newest migration shipped by @selvajs/supabase-provider against
|
|
203
|
+
// what the database reports via selva.migration_head(). Red on skew — the app
|
|
204
|
+
// degrades /api/health to 503 at boot in the same state, so catching it here
|
|
205
|
+
// saves a confusing deploy.
|
|
207
206
|
async function checkSupabaseMigrations(dir, env) {
|
|
208
207
|
if (!env.SUPABASE_URL || !env.SUPABASE_SERVICE_ROLE_KEY) {
|
|
209
208
|
return yellow('Migration-head check skipped — Supabase env incomplete');
|
|
@@ -264,6 +263,7 @@ async function checkSupabaseMigrations(dir, env) {
|
|
|
264
263
|
`Database reports no applied migrations (expected head ${expected}). Sync + run: npx supabase db push`
|
|
265
264
|
);
|
|
266
265
|
}
|
|
266
|
+
// String comparison — relies on both heads being fixed-width, zero-padded timestamps.
|
|
267
267
|
if (actual < expected) {
|
|
268
268
|
return red(
|
|
269
269
|
`Database migration head ${actual} is behind the installed provider (${expected}). Sync + run: npx supabase db push`
|
|
@@ -271,7 +271,6 @@ async function checkSupabaseMigrations(dir, env) {
|
|
|
271
271
|
}
|
|
272
272
|
return green(`Database migration head ${actual} matches the installed provider`);
|
|
273
273
|
} catch (err) {
|
|
274
|
-
// Soft-fail on network errors, same as checkSupabase.
|
|
275
274
|
return yellow(`Migration-head check skipped (${err.message ?? err})`);
|
|
276
275
|
}
|
|
277
276
|
}
|
|
@@ -295,7 +294,7 @@ function majorOf(version) {
|
|
|
295
294
|
return m ? Number(m[1]) : null;
|
|
296
295
|
}
|
|
297
296
|
|
|
298
|
-
// CLI and runtime release
|
|
297
|
+
// CLI and runtime release as a fixed group; major-version skew means a stale CLI pin.
|
|
299
298
|
function checkCliRuntimeAlignment(dir) {
|
|
300
299
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
301
300
|
const cliVersion = readPackageVersion(join(here, '..', '..', 'package.json'));
|
|
@@ -303,7 +302,7 @@ function checkCliRuntimeAlignment(dir) {
|
|
|
303
302
|
join(dir, 'node_modules', '@selvajs', 'selva', 'package.json')
|
|
304
303
|
);
|
|
305
304
|
|
|
306
|
-
//
|
|
305
|
+
// checkPackage already reports missing runtime as red; stay quiet here.
|
|
307
306
|
if (!runtimeVersion) return yellow('CLI/runtime version check skipped (runtime not installed)');
|
|
308
307
|
if (!cliVersion) return yellow('CLI/runtime version check skipped (could not read CLI version)');
|
|
309
308
|
|
|
@@ -324,16 +323,10 @@ function checkCliRuntimeAlignment(dir) {
|
|
|
324
323
|
);
|
|
325
324
|
}
|
|
326
325
|
|
|
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
|
-
*/
|
|
326
|
+
// npm only enforces engines.node under engine-strict=true, which no deployment
|
|
327
|
+
// sets, so a mismatched install succeeds silently and /api/health still returns
|
|
328
|
+
// 200 — routes using newer APIs throw only under real traffic. Not auto-fixable:
|
|
329
|
+
// upgrading Node is distro-specific and would restart the process running this check.
|
|
337
330
|
function checkNodeEngine(dir) {
|
|
338
331
|
let required;
|
|
339
332
|
try {
|
|
@@ -363,12 +356,10 @@ function checkNodeEngine(dir) {
|
|
|
363
356
|
);
|
|
364
357
|
}
|
|
365
358
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
* the dependency tree and the deployment may be running on the wrong one.
|
|
371
|
-
*/
|
|
359
|
+
// `selva migrate` parks the old node_modules aside so a failed install can be
|
|
360
|
+
// rolled back, and removes it on either outcome. One surviving here means the
|
|
361
|
+
// migration was killed mid-flight — worth flagging since it's a full copy of
|
|
362
|
+
// the dependency tree and the deployment may be running on the wrong one.
|
|
372
363
|
function checkMigrationLeftovers(dir) {
|
|
373
364
|
const stash = join(dir, NODE_MODULES_STASH);
|
|
374
365
|
if (!existsSync(stash)) return green('no interrupted migration left behind');
|
|
@@ -386,6 +377,58 @@ function checkMigrationLeftovers(dir) {
|
|
|
386
377
|
);
|
|
387
378
|
}
|
|
388
379
|
|
|
380
|
+
// Reads the raw file rather than the parsed env: the finding IS the comments,
|
|
381
|
+
// which parseEnv discards. The repair is attached here (not in checks/config.js)
|
|
382
|
+
// to keep that module filesystem-free.
|
|
383
|
+
function checkEnvDocs(dir) {
|
|
384
|
+
const envPath = join(dir, '.env');
|
|
385
|
+
if (!existsSync(envPath)) return green('.env documentation check skipped (no .env)');
|
|
386
|
+
|
|
387
|
+
let text;
|
|
388
|
+
try {
|
|
389
|
+
text = readFileSync(envPath, 'utf8');
|
|
390
|
+
} catch (err) {
|
|
391
|
+
return yellow(`.env unreadable (${err instanceof Error ? err.message : err})`);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return checkEnvDocumentation(
|
|
395
|
+
countEnvCommentLines(text),
|
|
396
|
+
fixable('strip the shipped documentation from .env (backup: .env.bak)', () => {
|
|
397
|
+
try {
|
|
398
|
+
// Re-read at repair time: doctor may have run minutes ago in --fix's
|
|
399
|
+
// confirmation loop, and rewriting from a stale snapshot would silently
|
|
400
|
+
// revert an edit made in between.
|
|
401
|
+
const current = readFileSync(envPath, 'utf8');
|
|
402
|
+
const { text: strippedText, removed } = stripEnvComments(current);
|
|
403
|
+
// A rewrite that changes which settings are live is a bug, not a
|
|
404
|
+
// cleanup — refuse rather than hand back a subtly different deployment.
|
|
405
|
+
const before = parseEnv(current);
|
|
406
|
+
const after = parseEnv(strippedText);
|
|
407
|
+
const drift = diffEnvKeys(before, after);
|
|
408
|
+
if (drift) return red(`refused to strip .env — ${drift}`);
|
|
409
|
+
|
|
410
|
+
copyFileSync(envPath, envPath + '.bak');
|
|
411
|
+
writeFileSync(envPath, strippedText, 'utf8');
|
|
412
|
+
return green(`stripped ${removed} comment lines from .env (backup: .env.bak)`);
|
|
413
|
+
} catch (err) {
|
|
414
|
+
return red(`could not rewrite .env: ${err instanceof Error ? err.message : err}`);
|
|
415
|
+
}
|
|
416
|
+
})
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Names the first discrepancy between two parsed envs, or null when identical.
|
|
421
|
+
function diffEnvKeys(before, after) {
|
|
422
|
+
for (const key of Object.keys(before)) {
|
|
423
|
+
if (!(key in after)) return `${key} would be lost`;
|
|
424
|
+
if (before[key] !== after[key]) return `${key} would change value`;
|
|
425
|
+
}
|
|
426
|
+
for (const key of Object.keys(after)) {
|
|
427
|
+
if (!(key in before)) return `${key} would be added`;
|
|
428
|
+
}
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
|
|
389
432
|
function checkLayoutDrift(dir) {
|
|
390
433
|
const pkgPath = join(dir, 'package.json');
|
|
391
434
|
if (!existsSync(pkgPath)) return yellow('package.json missing — cannot check layout');
|
|
@@ -403,19 +446,10 @@ function checkLayoutDrift(dir) {
|
|
|
403
446
|
);
|
|
404
447
|
}
|
|
405
448
|
|
|
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).
|
|
449
|
+
// Static config only — runtime invariants like spoofing protection can't be checked from here.
|
|
415
450
|
function checkHeaderAuth(dir, env, dataProvider) {
|
|
416
451
|
const out = [];
|
|
417
452
|
|
|
418
|
-
// Allowlist file location.
|
|
419
453
|
const allowlistDir = env.HEADER_AUTH_DATA_DIR ?? env.DATA_PATH;
|
|
420
454
|
if (!allowlistDir) {
|
|
421
455
|
out.push(red('HEADER_AUTH_DATA_DIR (or DATA_PATH) unset — provider will fail to start'));
|
|
@@ -425,21 +459,21 @@ function checkHeaderAuth(dir, env, dataProvider) {
|
|
|
425
459
|
if (existsSync(allowlistPath)) {
|
|
426
460
|
out.push(green(`header-allowlist.json present (${allowlistDir}/header-allowlist.json)`));
|
|
427
461
|
} else {
|
|
428
|
-
//
|
|
462
|
+
// The provider creates this file lazily, but a missing file locks everyone out
|
|
463
|
+
// until one exists — worth a warning even though nothing is technically broken.
|
|
429
464
|
out.push(
|
|
430
465
|
yellow(
|
|
431
466
|
`header-allowlist.json not found at ${allowlistDir}/ — no users will be allowed in until one is added`
|
|
432
467
|
)
|
|
433
468
|
);
|
|
434
469
|
|
|
435
|
-
//
|
|
470
|
+
// DATA_PATH's own writability is checked separately (checkDataPath).
|
|
436
471
|
if (env.HEADER_AUTH_DATA_DIR) {
|
|
437
472
|
out.push(checkDirWritable(allowlistAbsDir, `HEADER_AUTH_DATA_DIR=${allowlistDir}`));
|
|
438
473
|
}
|
|
439
474
|
}
|
|
440
475
|
}
|
|
441
476
|
|
|
442
|
-
// HOST binding (loopback recommended for header-auth).
|
|
443
477
|
const host = env.HOST ?? '0.0.0.0';
|
|
444
478
|
if (host === '127.0.0.1' || host === 'localhost') {
|
|
445
479
|
out.push(green(`HOST=${host} (loopback-only)`));
|
|
@@ -452,12 +486,11 @@ function checkHeaderAuth(dir, env, dataProvider) {
|
|
|
452
486
|
);
|
|
453
487
|
}
|
|
454
488
|
|
|
455
|
-
//
|
|
489
|
+
// header-auth always sits behind a reverse proxy, so ORIGIN is required.
|
|
456
490
|
if (!env.ORIGIN) {
|
|
457
491
|
out.push(red('ORIGIN unset — required for header-auth (always behind a proxy)'));
|
|
458
492
|
}
|
|
459
493
|
|
|
460
|
-
// Non-local data provider: HEADER_AUTH_DATA_DIR must be explicit (no DATA_PATH fallback).
|
|
461
494
|
if (dataProvider !== 'local' && !env.HEADER_AUTH_DATA_DIR) {
|
|
462
495
|
out.push(
|
|
463
496
|
red(
|
|
@@ -467,10 +500,10 @@ function checkHeaderAuth(dir, env, dataProvider) {
|
|
|
467
500
|
);
|
|
468
501
|
}
|
|
469
502
|
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
503
|
+
// Without this, the first proxy-authenticated visitor's UPN is rejected
|
|
504
|
+
// (not in the allowlist) and the operator has to hand-write JSON to claim
|
|
505
|
+
// admin. With it, the first matching visit auto-allowlists and grants
|
|
506
|
+
// instance_admin in one step.
|
|
474
507
|
if (!env.BOOTSTRAP_INSTANCE_ADMIN_EMAIL) {
|
|
475
508
|
out.push(
|
|
476
509
|
red(
|
|
@@ -482,30 +515,7 @@ function checkHeaderAuth(dir, env, dataProvider) {
|
|
|
482
515
|
out.push(green(`BOOTSTRAP_INSTANCE_ADMIN_EMAIL=${env.BOOTSTRAP_INSTANCE_ADMIN_EMAIL}`));
|
|
483
516
|
}
|
|
484
517
|
|
|
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
|
-
}
|
|
518
|
+
out.push(checkHeaderNames(env));
|
|
509
519
|
|
|
510
520
|
return out;
|
|
511
521
|
}
|
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'
|
|
@@ -47,7 +42,11 @@ export async function runInit() {
|
|
|
47
42
|
p.log.warn('SELVA_AT_REST_KEY was missing — generated a fresh one.');
|
|
48
43
|
}
|
|
49
44
|
|
|
50
|
-
|
|
45
|
+
// A values-only rewrite drops anything it isn't handed, and the prompts cover
|
|
46
|
+
// only the vars they ask about — every tuned knob the operator set by hand
|
|
47
|
+
// (BODY_SIZE_LIMIT, COMPUTE_*, LOG_LEVEL, …) lives outside that set. Carry
|
|
48
|
+
// the existing file forward and let the prompt answers win on top.
|
|
49
|
+
writeEnvFile(envPath, readEnvTemplate(dir), { ...current, ...values });
|
|
51
50
|
|
|
52
51
|
p.outro(
|
|
53
52
|
[
|