@selvajs/cli 4.4.0 → 4.5.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/package.json +1 -1
- package/src/commands/create.js +17 -77
- package/src/commands/doctor.js +18 -93
- package/src/commands/keys.js +1 -13
- package/src/commands/migrate.js +14 -58
- package/src/commands/pm2.js +10 -43
- package/src/env.js +3 -16
- package/src/prompts.js +14 -66
package/package.json
CHANGED
package/src/commands/create.js
CHANGED
|
@@ -1,16 +1,6 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// .env merged from runtime's .env.example + prompt values
|
|
5
|
-
// ecosystem.config.cjs copied verbatim from runtime templates
|
|
6
|
-
// package.json depends on @selvajs/selva + @selvajs/cli + pm2
|
|
7
|
-
// .selva-version marker for future CLI migrations
|
|
8
|
-
// node_modules/ after `npm install`
|
|
9
|
-
//
|
|
10
|
-
// The runtime templates are the source of truth — we don't carry our own
|
|
11
|
-
// copies in @selvajs/cli. We install @selvajs/selva first, then copy
|
|
12
|
-
// from node_modules/@selvajs/selva/templates/. Provider selection is
|
|
13
|
-
// env-driven (SELVA_AUTH_PROVIDER etc.) — no selva.config.js is generated.
|
|
1
|
+
// Scaffold a fresh deployment: write .env (merged from template + prompts),
|
|
2
|
+
// ecosystem.config.cjs, package.json, and bootstrap node_modules. Runtime
|
|
3
|
+
// templates are the source of truth; providers are env-driven.
|
|
14
4
|
|
|
15
5
|
import { writeFileSync, existsSync, mkdirSync, readFileSync, cpSync } from 'node:fs';
|
|
16
6
|
import { resolve, join, basename } from 'node:path';
|
|
@@ -37,27 +27,22 @@ export async function runCreate(argv) {
|
|
|
37
27
|
}
|
|
38
28
|
mkdirSync(targetDir, { recursive: true });
|
|
39
29
|
|
|
40
|
-
//
|
|
41
|
-
// environment instead of prompting — for Terraform startup scripts,
|
|
42
|
-
// Dockerfiles, and any other unattended bootstrap.
|
|
30
|
+
// --yes or CI=1 skips prompts (unattended bootstrap).
|
|
43
31
|
const nonInteractive = yes || envBool(process.env.CI);
|
|
44
32
|
const values = nonInteractive
|
|
45
33
|
? collectConfigFromEnv(process.env)
|
|
46
34
|
: await collectConfig({ defaults: {}, mode: 'create' });
|
|
47
35
|
|
|
48
|
-
//
|
|
49
|
-
// across restarts; the env-merge logic below writes them once and
|
|
50
|
-
// `selva init` later refuses to regenerate them.
|
|
36
|
+
// Generate fresh secrets (stable across restarts; init refuses to regen).
|
|
51
37
|
values.SELVA_HMAC_KEY = generateKey();
|
|
52
38
|
values.SELVA_AT_REST_KEY = generateKey();
|
|
53
39
|
|
|
54
40
|
const deployName = basename(targetDir);
|
|
55
41
|
|
|
56
|
-
//
|
|
42
|
+
// Write package.json, then install (need templates from @selvajs/selva).
|
|
57
43
|
const pkgJson = buildPackageJson(deployName, values);
|
|
58
44
|
writeFileSync(join(targetDir, 'package.json'), pkgJson + '\n', 'utf8');
|
|
59
45
|
|
|
60
|
-
// 4. Install. We need @selvajs/selva on disk to copy templates from it.
|
|
61
46
|
if (!skipInstall) {
|
|
62
47
|
await runNpmInstall(targetDir);
|
|
63
48
|
} else {
|
|
@@ -66,7 +51,7 @@ export async function runCreate(argv) {
|
|
|
66
51
|
);
|
|
67
52
|
}
|
|
68
53
|
|
|
69
|
-
//
|
|
54
|
+
// Copy templates from installed runtime.
|
|
70
55
|
const runtimeTemplates = join(targetDir, 'node_modules', '@selvajs', 'selva', 'templates');
|
|
71
56
|
if (skipInstall || !existsSync(runtimeTemplates)) {
|
|
72
57
|
p.log.warn(
|
|
@@ -86,7 +71,6 @@ export async function runCreate(argv) {
|
|
|
86
71
|
writeFileSync(join(targetDir, '.selva-version'), CLI_VERSION + '\n', 'utf8');
|
|
87
72
|
writeGitignore(targetDir);
|
|
88
73
|
|
|
89
|
-
// 6. Outro with next steps.
|
|
90
74
|
p.outro(
|
|
91
75
|
[
|
|
92
76
|
pc.green('Scaffolded ' + pc.cyan(targetDir)),
|
|
@@ -103,59 +87,37 @@ export async function runCreate(argv) {
|
|
|
103
87
|
);
|
|
104
88
|
}
|
|
105
89
|
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
// The previous implementation used execSync + stdio:'pipe' which buffered
|
|
109
|
-
// everything in memory and discarded it on failure — operators saw "Command
|
|
110
|
-
// failed: npm install" with no clue what went wrong. We stream stdout/stderr
|
|
111
|
-
// into a ring buffer instead, and on failure dump the last lines so they can
|
|
112
|
-
// act on the actual error (sharp's libvips missing, registry timeout, etc.)
|
|
113
|
-
// without fishing through /home/user/.npm/_logs/.
|
|
114
|
-
//
|
|
115
|
-
// Cache-bust hint: if npm prints a placeDep for @selvajs/selva@0.10.2
|
|
116
|
-
// (which was published broken and unpublished), surface that so the operator
|
|
117
|
-
// knows to `npm cache clean --force`.
|
|
90
|
+
// Stream npm output with live progress; on failure, show tail for debugging.
|
|
91
|
+
// Cache-bust hint: surface broken versions like @selvajs/selva@0.10.2.
|
|
118
92
|
function runNpmInstall(cwd) {
|
|
119
93
|
return new Promise((resolveP, rejectP) => {
|
|
120
94
|
const s = p.spinner();
|
|
121
95
|
s.start('Installing dependencies (this can take a minute)');
|
|
122
96
|
|
|
123
|
-
//
|
|
97
|
+
// Keep last 80 lines for failure output.
|
|
124
98
|
const tail = [];
|
|
125
|
-
const maxTail = 80;
|
|
126
99
|
const remember = (line) => {
|
|
127
100
|
tail.push(line);
|
|
128
|
-
if (tail.length >
|
|
101
|
+
if (tail.length > 80) tail.shift();
|
|
129
102
|
};
|
|
130
103
|
|
|
131
|
-
//
|
|
132
|
-
// stream; we cherry-pick recognizable transitions and pass them to
|
|
133
|
-
// the spinner so the operator sees movement.
|
|
104
|
+
// Extract progress milestones (reify: lines, package count).
|
|
134
105
|
const updateProgress = (line) => {
|
|
135
106
|
const trimmed = line.trim();
|
|
136
107
|
if (!trimmed) return;
|
|
137
|
-
// "added 412 packages" — final summary
|
|
138
108
|
if (/^added \d+ packages/.test(trimmed)) {
|
|
139
109
|
s.message('Finalizing installation');
|
|
140
110
|
return;
|
|
141
111
|
}
|
|
142
|
-
// "reify:foo: timing reifyNode..." — npm 8/9/10 progress lines.
|
|
143
|
-
// Pull the package name out and show it.
|
|
144
112
|
const reify = trimmed.match(/^reify:([^:]+):/);
|
|
145
113
|
if (reify) {
|
|
146
114
|
s.message(`Installing ${pc.cyan(reify[1])}`);
|
|
147
115
|
return;
|
|
148
116
|
}
|
|
149
|
-
|
|
150
|
-
if (/^npm (WARN|error|notice)/.test(trimmed)) {
|
|
151
|
-
// Don't change the spinner message for these — they're noisy.
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
117
|
+
if (/^npm (WARN|error|notice)/.test(trimmed)) return;
|
|
154
118
|
};
|
|
155
119
|
|
|
156
|
-
// Spawn
|
|
157
|
-
// We DO NOT use stdio: 'inherit' because that would interleave with
|
|
158
|
-
// the spinner; we DO want a live read so we can update the message.
|
|
120
|
+
// Spawn with --loglevel=info for reify: progress; pipe stdout/stderr for live updates.
|
|
159
121
|
const child = spawn('npm', ['install', '--loglevel=info'], {
|
|
160
122
|
cwd,
|
|
161
123
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -255,38 +217,16 @@ function envBool(v) {
|
|
|
255
217
|
return ['1', 'true', 'yes'].includes(String(v).toLowerCase());
|
|
256
218
|
}
|
|
257
219
|
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
// operator-side tool). Provider selection is env-driven — no provider
|
|
261
|
-
// packages need to be listed here.
|
|
262
|
-
//
|
|
263
|
-
// We list @selvajs/cli itself as a dep so the `selva` bin gets linked
|
|
264
|
-
// into node_modules/.bin/. Without this the operator's only way to run
|
|
265
|
-
// `selva doctor` / `selva start` is a global install of the CLI.
|
|
220
|
+
// Depends on @selvajs/selva (prebuilt) + @selvajs/cli (operator tool).
|
|
221
|
+
// @selvajs/cli links the `selva` bin; without it, only global CLI works.
|
|
266
222
|
function buildPackageJson(name /*, values */) {
|
|
267
223
|
const deps = {
|
|
268
224
|
'@selvajs/cli': 'latest',
|
|
269
225
|
'@selvajs/selva': 'latest',
|
|
270
|
-
// pm2
|
|
271
|
-
// resolve it via node_modules/.bin/pm2 without a global install. The
|
|
272
|
-
// pm2.js wrapper requires this local binary (see pm2Bin()) — we never
|
|
273
|
-
// fall back to a global pm2 because two pm2 binaries managing the
|
|
274
|
-
// same daemon produces persistent CLI-vs-daemon version-skew
|
|
275
|
-
// warnings and stops/restarts that mysteriously hang.
|
|
276
|
-
//
|
|
277
|
-
// PINNED EXACT (no caret) on purpose. PM2's daemon and CLI must be
|
|
278
|
-
// the same version, and the daemon is sticky — once a daemon is
|
|
279
|
-
// running, only `pm2 update` swaps it. A caret range means two
|
|
280
|
-
// deployments scaffolded weeks apart can install different 5.x
|
|
281
|
-
// versions, and any host that briefly ran a different pm2 (e.g. a
|
|
282
|
-
// stray `npm i -g pm2`) is left with a daemon that won't match
|
|
283
|
-
// either. Bump this deliberately, in lockstep with documentation.
|
|
226
|
+
// Deployment-local pm2 (pinned exact to prevent daemon version skew).
|
|
284
227
|
pm2: '5.4.3'
|
|
285
228
|
};
|
|
286
229
|
|
|
287
|
-
// Use `selva` (resolved via node_modules/.bin) in the npm scripts. Running
|
|
288
|
-
// them as `npm run start` / `npm run doctor` works without remembering the
|
|
289
|
-
// ./node_modules/.bin/ prefix.
|
|
290
230
|
const pkg = {
|
|
291
231
|
name: sanitizePackageName(name),
|
|
292
232
|
version: '0.1.0',
|
package/src/commands/doctor.js
CHANGED
|
@@ -1,18 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// Checks:
|
|
4
|
-
// • .env and ecosystem.config.cjs exist
|
|
5
|
-
// • Layout drift (legacy provider packages, stale selva.config.js, etc.)
|
|
6
|
-
// • Secrets are present and look like 32-byte hex
|
|
7
|
-
// • DATA_PATH writable (when local provider is in use)
|
|
8
|
-
// • Supabase URL reachable (when supabase provider is in use)
|
|
9
|
-
// • @selvajs/selva installed
|
|
10
|
-
// • Boot persistence (Linux): dump.pm2 saved, systemd unit installed and
|
|
11
|
-
// pointing at the deployment-local pm2, no stray global pm2 on PATH
|
|
12
|
-
// • Origin set when behind a reverse proxy looks set
|
|
13
|
-
//
|
|
14
|
-
// Exits 0 (green) or 1 (any red); yellow checks don't fail the run.
|
|
15
|
-
// All checks are read-only — doctor never starts or pings the pm2 daemon.
|
|
1
|
+
// Validate deployment without starting it: files, secrets, layout, providers,
|
|
2
|
+
// boot persistence. Read-only; yellow warnings don't fail, red failures exit 1.
|
|
16
3
|
|
|
17
4
|
import { existsSync, readFileSync, accessSync, constants, statSync } from 'node:fs';
|
|
18
5
|
import { join, resolve, dirname, delimiter } from 'node:path';
|
|
@@ -36,23 +23,13 @@ export async function runDoctor() {
|
|
|
36
23
|
const checks = [];
|
|
37
24
|
const env = readEnvFile(join(dir, '.env'));
|
|
38
25
|
|
|
39
|
-
// ── Files ──────────────────────────────────────────────────────────
|
|
40
26
|
checks.push(checkFile(join(dir, '.env'), '.env present'));
|
|
41
27
|
checks.push(checkFile(join(dir, 'ecosystem.config.cjs'), 'ecosystem.config.cjs present'));
|
|
42
|
-
|
|
43
|
-
// ── Layout drift ───────────────────────────────────────────────────
|
|
44
|
-
// Catches deployments still on the @selvajs/runtime layout (or other
|
|
45
|
-
// historical states) and points the operator at `selva migrate` instead
|
|
46
|
-
// of just letting the missing-package checks below scream.
|
|
47
28
|
checks.push(checkLayoutDrift(dir));
|
|
48
|
-
|
|
49
|
-
// ── Secrets ────────────────────────────────────────────────────────
|
|
50
29
|
checks.push(checkSecret(env.SELVA_HMAC_KEY, 'SELVA_HMAC_KEY is a 32-byte hex string'));
|
|
51
30
|
checks.push(checkSecret(env.SELVA_AT_REST_KEY, 'SELVA_AT_REST_KEY is a 32-byte hex string'));
|
|
52
31
|
|
|
53
|
-
//
|
|
54
|
-
// `header` is only valid for the auth slot — data/storage stay
|
|
55
|
-
// local|supabase. Mirror what providers.server.ts enforces.
|
|
32
|
+
// Header-auth: auth only; data/storage must be local or supabase.
|
|
56
33
|
const providers = {
|
|
57
34
|
auth: (env.SELVA_AUTH_PROVIDER ?? 'local').toLowerCase(),
|
|
58
35
|
data: (env.SELVA_DATA_PROVIDER ?? 'local').toLowerCase(),
|
|
@@ -86,7 +63,6 @@ export async function runDoctor() {
|
|
|
86
63
|
checks.push(...checkHeaderAuth(dir, env, providers.data));
|
|
87
64
|
}
|
|
88
65
|
|
|
89
|
-
// ── Tenancy ────────────────────────────────────────────────────────
|
|
90
66
|
const tenancy = (env.SELVA_TENANCY ?? 'single').toLowerCase();
|
|
91
67
|
if (tenancy !== 'single' && tenancy !== 'multi') {
|
|
92
68
|
checks.push(red(`SELVA_TENANCY="${tenancy}" — expected single|multi`));
|
|
@@ -94,27 +70,9 @@ export async function runDoctor() {
|
|
|
94
70
|
checks.push(green(`SELVA_TENANCY=${tenancy}`));
|
|
95
71
|
}
|
|
96
72
|
|
|
97
|
-
// ── Installed packages ─────────────────────────────────────────────
|
|
98
|
-
// Provider implementations are bundled into @selvajs/selva — only the
|
|
99
|
-
// runtime package needs to be on disk.
|
|
100
73
|
checks.push(checkPackage(dir, '@selvajs/selva'));
|
|
101
|
-
|
|
102
|
-
// ── CLI / runtime version alignment ────────────────────────────────
|
|
103
|
-
// The CLI and runtime ship as a `fixed` changeset group, so they SHOULD
|
|
104
|
-
// always share a version. They can still drift on disk: a caret pin
|
|
105
|
-
// (`^4`) won't cross a major, so after the group jumps to 5.x a stale
|
|
106
|
-
// `^4` pin silently keeps the CLI on 4.x. That's the exact failure that
|
|
107
|
-
// makes `selva update` look like a no-op for the CLI. Surface it.
|
|
108
74
|
checks.push(checkCliRuntimeAlignment(dir));
|
|
109
|
-
|
|
110
|
-
// ── Boot persistence (read-only) ───────────────────────────────────
|
|
111
|
-
// Validates that a reboot will actually bring the app back. All checks
|
|
112
|
-
// here are pure reads — no `pm2 ping`/daemon interaction — so doctor keeps
|
|
113
|
-
// its "never starts anything" contract. Linux/systemd only; skipped
|
|
114
|
-
// elsewhere because pm2's boot integration is platform-specific.
|
|
115
75
|
checks.push(...checkBootPersistence(dir));
|
|
116
|
-
|
|
117
|
-
// ── Origin (best-effort) ───────────────────────────────────────────
|
|
118
76
|
if (env.ORIGIN) {
|
|
119
77
|
try {
|
|
120
78
|
new URL(env.ORIGIN);
|
|
@@ -231,9 +189,7 @@ function majorOf(version) {
|
|
|
231
189
|
return m ? Number(m[1]) : null;
|
|
232
190
|
}
|
|
233
191
|
|
|
234
|
-
//
|
|
235
|
-
// runtime's major. They release together (`fixed` group), so a major skew
|
|
236
|
-
// means the deployment's `@selvajs/cli` pin is stale — bump it and reinstall.
|
|
192
|
+
// CLI and runtime release together (fixed group); major skew = stale CLI pin.
|
|
237
193
|
function checkCliRuntimeAlignment(dir) {
|
|
238
194
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
239
195
|
const cliVersion = readPackageVersion(join(here, '..', '..', 'package.json'));
|
|
@@ -262,17 +218,7 @@ function checkCliRuntimeAlignment(dir) {
|
|
|
262
218
|
);
|
|
263
219
|
}
|
|
264
220
|
|
|
265
|
-
//
|
|
266
|
-
// all of which we hit in the field:
|
|
267
|
-
// 1. `pm2 save` never run → no dump.pm2 → resurrect restores nothing.
|
|
268
|
-
// 2. `pm2 startup` never run → no systemd unit → pm2 never launches at boot.
|
|
269
|
-
// 3. The systemd unit was generated from a *global* pm2 (the operator
|
|
270
|
-
// pasted pm2's auto-printed `sudo env ... pm2 startup` line verbatim
|
|
271
|
-
// while a global pm2 was on PATH). The unit then resurrects via a
|
|
272
|
-
// different pm2 than the deployment-local one the CLI manages with —
|
|
273
|
-
// silent version skew on every boot. This is the only RED here.
|
|
274
|
-
// All checks are read-only (existsSync + reading the unit file + scanning
|
|
275
|
-
// PATH); none touch the pm2 daemon, so doctor stays side-effect-free.
|
|
221
|
+
// Verify VM reboot will resurrect app: dump.pm2 saved, systemd unit installed, no stray global pm2.
|
|
276
222
|
function checkBootPersistence(dir) {
|
|
277
223
|
// pm2's boot integration is Linux/systemd-specific. On macOS it's launchd
|
|
278
224
|
// (different unit path) and on Windows pm2 boot persistence isn't a thing —
|
|
@@ -281,7 +227,7 @@ function checkBootPersistence(dir) {
|
|
|
281
227
|
|
|
282
228
|
const out = [];
|
|
283
229
|
|
|
284
|
-
//
|
|
230
|
+
// dump.pm2 (what pm2 startup resurrects).
|
|
285
231
|
const pm2Home = process.env.PM2_HOME ?? join(homedir(), '.pm2');
|
|
286
232
|
const dumpPath = join(pm2Home, 'dump.pm2');
|
|
287
233
|
if (existsSync(dumpPath)) {
|
|
@@ -295,7 +241,7 @@ function checkBootPersistence(dir) {
|
|
|
295
241
|
);
|
|
296
242
|
}
|
|
297
243
|
|
|
298
|
-
//
|
|
244
|
+
// systemd unit: present and pointing at deployment-local pm2.
|
|
299
245
|
const user = process.env.USER ?? process.env.LOGNAME;
|
|
300
246
|
const unitPath = user
|
|
301
247
|
? `/etc/systemd/system/pm2-${user}.service`
|
|
@@ -336,8 +282,7 @@ function checkBootPersistence(dir) {
|
|
|
336
282
|
);
|
|
337
283
|
}
|
|
338
284
|
|
|
339
|
-
//
|
|
340
|
-
// warnings. Advisory only.
|
|
285
|
+
// Warn if global pm2 is on PATH (root cause of version skew).
|
|
341
286
|
const globalPm2 = findGlobalPm2(dir);
|
|
342
287
|
if (globalPm2) {
|
|
343
288
|
out.push(
|
|
@@ -352,8 +297,7 @@ function checkBootPersistence(dir) {
|
|
|
352
297
|
return out;
|
|
353
298
|
}
|
|
354
299
|
|
|
355
|
-
// Scan PATH for
|
|
356
|
-
// such path, or null. Pure filesystem reads — no execution.
|
|
300
|
+
// Scan PATH for stray pm2 binary (read-only).
|
|
357
301
|
function findGlobalPm2(dir) {
|
|
358
302
|
const localBin = resolve(dir, 'node_modules', '.bin');
|
|
359
303
|
const dirs = (process.env.PATH ?? '').split(delimiter).filter(Boolean);
|
|
@@ -382,25 +326,19 @@ function checkLayoutDrift(dir) {
|
|
|
382
326
|
);
|
|
383
327
|
}
|
|
384
328
|
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
// change, update both places — there's a smoke test in providers/header-auth
|
|
388
|
-
// that pins them, so a divergence would surface in CI.
|
|
329
|
+
// Must match HeaderAuthProvider.DEFAULT_HEADERS (duplicated to avoid loading runtime).
|
|
330
|
+
// Smoke test in providers/header-auth pins them; divergence surfaces in CI.
|
|
389
331
|
const DEFAULT_HEADER_NAMES = {
|
|
390
332
|
upn: 'SELVA-UserPrincipalName',
|
|
391
333
|
email: 'SELVA-Email',
|
|
392
334
|
displayName: 'SELVA-DisplayName'
|
|
393
335
|
};
|
|
394
336
|
|
|
395
|
-
// Header-auth
|
|
396
|
-
// misconfigurations (header spoofing, missing proxy auth) — those are
|
|
397
|
-
// runtime invariants we can't verify from here. We DO check the things we can:
|
|
398
|
-
// allowlist file presence, HOST binding, ORIGIN, bootstrap admin, and the
|
|
399
|
-
// resolved header names so they can be diffed against the proxy config.
|
|
337
|
+
// Header-auth checks (read-only; runtime invariants like spoofing can't be verified).
|
|
400
338
|
function checkHeaderAuth(dir, env, dataProvider) {
|
|
401
339
|
const out = [];
|
|
402
340
|
|
|
403
|
-
//
|
|
341
|
+
// Allowlist file location.
|
|
404
342
|
const allowlistDir = env.HEADER_AUTH_DATA_DIR ?? env.DATA_PATH;
|
|
405
343
|
if (!allowlistDir) {
|
|
406
344
|
out.push(red('HEADER_AUTH_DATA_DIR (or DATA_PATH) unset — provider will fail to start'));
|
|
@@ -410,26 +348,21 @@ function checkHeaderAuth(dir, env, dataProvider) {
|
|
|
410
348
|
if (existsSync(allowlistPath)) {
|
|
411
349
|
out.push(green(`header-allowlist.json present (${allowlistDir}/header-allowlist.json)`));
|
|
412
350
|
} else {
|
|
413
|
-
//
|
|
414
|
-
// allowlisted UPNs locks everyone out — surface this so the
|
|
415
|
-
// operator knows to bootstrap.
|
|
351
|
+
// Provider creates lazily, but missing file locks everyone out.
|
|
416
352
|
out.push(
|
|
417
353
|
yellow(
|
|
418
354
|
`header-allowlist.json not found at ${allowlistDir}/ — no users will be allowed in until one is added`
|
|
419
355
|
)
|
|
420
356
|
);
|
|
421
357
|
|
|
422
|
-
//
|
|
423
|
-
// writable. Only check when HEADER_AUTH_DATA_DIR is set explicitly
|
|
424
|
-
// — when falling back to DATA_PATH the `local` provider's own
|
|
425
|
-
// checkDataPath has already covered the same ground.
|
|
358
|
+
// Check writability only if explicitly set (DATA_PATH checked separately).
|
|
426
359
|
if (env.HEADER_AUTH_DATA_DIR) {
|
|
427
360
|
out.push(checkDirWritable(allowlistAbsDir, `HEADER_AUTH_DATA_DIR=${allowlistDir}`));
|
|
428
361
|
}
|
|
429
362
|
}
|
|
430
363
|
}
|
|
431
364
|
|
|
432
|
-
//
|
|
365
|
+
// HOST binding (loopback recommended for header-auth).
|
|
433
366
|
const host = env.HOST ?? '0.0.0.0';
|
|
434
367
|
if (host === '127.0.0.1' || host === 'localhost') {
|
|
435
368
|
out.push(green(`HOST=${host} (loopback-only)`));
|
|
@@ -447,9 +380,7 @@ function checkHeaderAuth(dir, env, dataProvider) {
|
|
|
447
380
|
out.push(red('ORIGIN unset — required for header-auth (always behind a proxy)'));
|
|
448
381
|
}
|
|
449
382
|
|
|
450
|
-
//
|
|
451
|
-
// state — make sure the operator picked an explicit dir, not the
|
|
452
|
-
// fall-through DATA_PATH which may not be set.
|
|
383
|
+
// Non-local data provider: HEADER_AUTH_DATA_DIR must be explicit (no DATA_PATH fallback).
|
|
453
384
|
if (dataProvider !== 'local' && !env.HEADER_AUTH_DATA_DIR) {
|
|
454
385
|
out.push(
|
|
455
386
|
red(
|
|
@@ -474,13 +405,7 @@ function checkHeaderAuth(dir, env, dataProvider) {
|
|
|
474
405
|
out.push(green(`BOOTSTRAP_INSTANCE_ADMIN_EMAIL=${env.BOOTSTRAP_INSTANCE_ADMIN_EMAIL}`));
|
|
475
406
|
}
|
|
476
407
|
|
|
477
|
-
//
|
|
478
|
-
// `user:null` because the proxy sets one set of names and the provider
|
|
479
|
-
// reads another. Print what the provider WILL read so the operator can
|
|
480
|
-
// diff it against the Caddyfile / oauth2-proxy config. We don't fail on
|
|
481
|
-
// custom names — operators legitimately override these for non-Caddy
|
|
482
|
-
// proxies — but yellow-flag the case where one is overridden and the
|
|
483
|
-
// others aren't, since a partial override is almost always a typo.
|
|
408
|
+
// Resolved header names (print for diffing against proxy config; partial override = typo risk).
|
|
484
409
|
const resolved = {
|
|
485
410
|
upn: env.HEADER_AUTH_UPN_HEADER || DEFAULT_HEADER_NAMES.upn,
|
|
486
411
|
email: env.HEADER_AUTH_EMAIL_HEADER || DEFAULT_HEADER_NAMES.email,
|
package/src/commands/keys.js
CHANGED
|
@@ -1,16 +1,4 @@
|
|
|
1
|
-
//
|
|
2
|
-
// back to .env. Refuses without an explicit confirm; what gets invalidated is
|
|
3
|
-
// not subtle.
|
|
4
|
-
//
|
|
5
|
-
// hmac — SELVA_HMAC_KEY (HMAC-SHA256). Rotating logs every user out
|
|
6
|
-
// (cookie sessions stop verifying) and breaks any share-link /
|
|
7
|
-
// invite tokens that fell back to it (only relevant when
|
|
8
|
-
// SHARE_LINK_SECRET / INVITE_TOKEN_SECRET are unset — those have
|
|
9
|
-
// their own rotation cycle).
|
|
10
|
-
//
|
|
11
|
-
// at-rest — SELVA_AT_REST_KEY (AES-256-GCM). The encrypted Rhino.Compute
|
|
12
|
-
// API key in compute.config.json becomes undecryptable. The
|
|
13
|
-
// operator has to re-enter the key at /admin/compute.
|
|
1
|
+
// Rotate SELVA_HMAC_KEY or SELVA_AT_REST_KEY; requires explicit confirm (blast radius in TARGETS).
|
|
14
2
|
|
|
15
3
|
import { readFileSync, existsSync } from 'node:fs';
|
|
16
4
|
import { join } from 'node:path';
|
package/src/commands/migrate.js
CHANGED
|
@@ -1,27 +1,6 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// 1. `@selvajs/create` → `@selvajs/cli` (CLI bootstrap; can't be automated
|
|
5
|
-
// since the operator has no `selva` binary yet — they run two npm
|
|
6
|
-
// commands by hand from the Hotfix doc).
|
|
7
|
-
// 2. `@selvajs/runtime` → `@selvajs/selva` (runtime bundling: UI, schemas,
|
|
8
|
-
// ui-kit and the providers' workspace deps are now built into
|
|
9
|
-
// @selvajs/selva).
|
|
10
|
-
// 3. selva.config.js → env-driven providers (the picker logic moved into
|
|
11
|
-
// the runtime; deployments no longer need a config file. Provider
|
|
12
|
-
// packages are bundled into @selvajs/selva.)
|
|
13
|
-
//
|
|
14
|
-
// Migrate automates 2 and 3 together: it rewrites package.json, drops the
|
|
15
|
-
// now-bundled provider packages, removes any stale selva.config.js, and
|
|
16
|
-
// rewrites ecosystem.config.cjs if it still points at @selvajs/runtime.
|
|
17
|
-
//
|
|
18
|
-
// Future package-layout shifts go here too — the command should remain
|
|
19
|
-
// idempotent. On an already-current deployment it prints "nothing to
|
|
20
|
-
// migrate" and exits 0.
|
|
21
|
-
//
|
|
22
|
-
// Mirrors `selva update`'s lifecycle: stop pm2, mutate node_modules, start
|
|
23
|
-
// pm2 again with --update-env. Rollback restores package.json.bak on
|
|
24
|
-
// npm-install failure so the operator isn't left with a broken deployment.
|
|
1
|
+
// Migrate deployment to current layout: rewrite package.json (drop legacy
|
|
2
|
+
// provider packages), remove stale selva.config.js, update ecosystem.config.cjs.
|
|
3
|
+
// Idempotent; mirrors update's lifecycle (stop/mutate/start with rollback on failure).
|
|
25
4
|
|
|
26
5
|
import {
|
|
27
6
|
existsSync,
|
|
@@ -109,16 +88,13 @@ export async function runMigrate() {
|
|
|
109
88
|
return;
|
|
110
89
|
}
|
|
111
90
|
|
|
112
|
-
// Stop pm2 before
|
|
113
|
-
// SvelteKit's node adapter lazy-imports chunks, so swapping the build dir
|
|
114
|
-
// under a live process causes ERR_MODULE_NOT_FOUND on in-flight requests.
|
|
91
|
+
// Stop pm2 before npm rewrites (SvelteKit lazy-loads chunks; see update command).
|
|
115
92
|
const stopStatus = runPm2(dir, ['stop', APP_NAME], { inherit: false });
|
|
116
93
|
if (stopStatus !== 0) {
|
|
117
94
|
p.log.warn('pm2 stop did not succeed — selva-compute may not be running. Continuing.');
|
|
118
95
|
}
|
|
119
96
|
|
|
120
|
-
// Back up
|
|
121
|
-
// so the operator isn't left with a half-migrated deployment.
|
|
97
|
+
// Back up for rollback on npm-install failure.
|
|
122
98
|
const bakPath = pkgPath + '.bak';
|
|
123
99
|
copyFileSync(pkgPath, bakPath);
|
|
124
100
|
writeFileSync(pkgPath, JSON.stringify(target, null, 2) + '\n', 'utf8');
|
|
@@ -130,9 +106,7 @@ export async function runMigrate() {
|
|
|
130
106
|
|
|
131
107
|
if (ecoHasStaleRuntime) {
|
|
132
108
|
copyFileSync(ecoPath, ecoPath + '.bak');
|
|
133
|
-
//
|
|
134
|
-
// regenerate from the canonical template here because the operator may
|
|
135
|
-
// have customized port/memory/cluster settings; preserve their edits.
|
|
109
|
+
// Rewrite @selvajs/runtime → @selvajs/selva only (preserve customizations).
|
|
136
110
|
const ecoContent = readFileSync(ecoPath, 'utf8').replace(
|
|
137
111
|
/@selvajs\/runtime/g,
|
|
138
112
|
'@selvajs/selva'
|
|
@@ -140,10 +114,7 @@ export async function runMigrate() {
|
|
|
140
114
|
writeFileSync(ecoPath, ecoContent, 'utf8');
|
|
141
115
|
}
|
|
142
116
|
|
|
143
|
-
//
|
|
144
|
-
// resolve correctly when major version ranges change (e.g. runtime 0.10
|
|
145
|
-
// → selva 2.0) — the lockfile pins old transitive deps that no longer
|
|
146
|
-
// belong. Clean install is the only reliable path.
|
|
117
|
+
// Clean install required (major version changes break legacy lockfile).
|
|
147
118
|
rmSync(join(dir, 'node_modules'), { recursive: true, force: true });
|
|
148
119
|
rmSync(join(dir, 'package-lock.json'), { force: true });
|
|
149
120
|
|
|
@@ -166,9 +137,7 @@ export async function runMigrate() {
|
|
|
166
137
|
if (ecoHasStaleRuntime && existsSync(ecoPath + '.bak')) {
|
|
167
138
|
copyFileSync(ecoPath + '.bak', ecoPath);
|
|
168
139
|
}
|
|
169
|
-
// Try to bring the
|
|
170
|
-
// with downtime. If node_modules was wiped this won't help, but at
|
|
171
|
-
// least package.json matches what's on disk.
|
|
140
|
+
// Try to bring the process back up (best-effort; at least package.json is consistent).
|
|
172
141
|
runPm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
|
|
173
142
|
p.outro(pc.red(`Migration aborted: ${err.message ?? err}`));
|
|
174
143
|
process.exit(1);
|
|
@@ -192,16 +161,8 @@ export async function runMigrate() {
|
|
|
192
161
|
}
|
|
193
162
|
}
|
|
194
163
|
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
// Wholesale-replace semantics: any @selvajs/* or pm2 entry not in our
|
|
198
|
-
// canonical set is dropped. Non-selva deps the operator added are also
|
|
199
|
-
// dropped — that's the design choice we made up-front.
|
|
200
|
-
//
|
|
201
|
-
// Provider packages (@selvajs/local-provider etc.) are NOT preserved even
|
|
202
|
-
// when the operator's .env selects them: they're bundled into @selvajs/selva
|
|
203
|
-
// in v2.1+ and the standalone packages are legacy. Providers are picked at
|
|
204
|
-
// runtime from SELVA_*_PROVIDER env vars.
|
|
164
|
+
// Build target package.json with canonical dependencies (wholesale replace).
|
|
165
|
+
// Drops non-canonical @selvajs/* and operator's own deps (by design).
|
|
205
166
|
function buildTargetPackageJson(current) {
|
|
206
167
|
const deps = {
|
|
207
168
|
'@selvajs/cli': 'latest',
|
|
@@ -219,8 +180,7 @@ function buildTargetPackageJson(current) {
|
|
|
219
180
|
};
|
|
220
181
|
}
|
|
221
182
|
|
|
222
|
-
//
|
|
223
|
-
// short — the operator just needs to see what's moving, not a unified diff.
|
|
183
|
+
// Format package.json diff for confirmation prompt (concise, not full diff).
|
|
224
184
|
function diffPackageJson(before, after) {
|
|
225
185
|
const lines = [];
|
|
226
186
|
|
|
@@ -252,9 +212,7 @@ function diffPackageJson(before, after) {
|
|
|
252
212
|
return lines;
|
|
253
213
|
}
|
|
254
214
|
|
|
255
|
-
// Local
|
|
256
|
-
// dependency via the `pm2.js` exports; pm2 invocation is small enough to
|
|
257
|
-
// duplicate.
|
|
215
|
+
// Local pm2 runner (avoid circular dep; duplication is small).
|
|
258
216
|
function runPm2(dir, args, { inherit = true } = {}) {
|
|
259
217
|
const local = join(dir, 'node_modules', '.bin', process.platform === 'win32' ? 'pm2.cmd' : 'pm2');
|
|
260
218
|
const bin = existsSync(local) ? local : 'pm2';
|
|
@@ -267,8 +225,7 @@ function runPm2(dir, args, { inherit = true } = {}) {
|
|
|
267
225
|
return result.status ?? 0;
|
|
268
226
|
}
|
|
269
227
|
|
|
270
|
-
// Exported for
|
|
271
|
-
// without duplicating the detection logic.
|
|
228
|
+
// Exported for `selva doctor` to check layout drift without duplication.
|
|
272
229
|
export function detectDrift(pkgJson, dir) {
|
|
273
230
|
const deps = pkgJson?.dependencies ?? {};
|
|
274
231
|
const reasons = [];
|
|
@@ -299,6 +256,5 @@ export function detectDrift(pkgJson, dir) {
|
|
|
299
256
|
return reasons;
|
|
300
257
|
}
|
|
301
258
|
|
|
302
|
-
// Re-exported
|
|
303
|
-
// actually running it.
|
|
259
|
+
// Re-exported for tests to verify without running migrate.
|
|
304
260
|
export { buildTargetPackageJson, SELVA_DEPS };
|
package/src/commands/pm2.js
CHANGED
|
@@ -1,16 +1,6 @@
|
|
|
1
|
-
// Thin wrappers around PM2 commands
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// • `pm2 restart` without --update-env silently keeps the old env, even
|
|
5
|
-
// after the operator edited .env. We always pass --update-env.
|
|
6
|
-
//
|
|
7
|
-
// • PM2's daemon and CLI must be the same version. The daemon is sticky:
|
|
8
|
-
// once forked it runs that version forever, regardless of what the on-
|
|
9
|
-
// disk binary later becomes. We resolve pm2 to ONE binary (the project-
|
|
10
|
-
// local one) so every command — interactive, scripted, admin endpoint —
|
|
11
|
-
// hits the same code path. If a different pm2 ever started the daemon
|
|
12
|
-
// (e.g. a stray `npm i -g pm2`), `ensurePm2InSync` detects the skew
|
|
13
|
-
// and runs `pm2 update` to respawn the daemon under our binary.
|
|
1
|
+
// Thin wrappers around PM2 commands: always pass --update-env (ignore edits),
|
|
2
|
+
// resolve to deployment-local pm2 (avoid daemon version skew with global install),
|
|
3
|
+
// and resync daemon before state changes.
|
|
14
4
|
|
|
15
5
|
import { existsSync, readFileSync } from 'node:fs';
|
|
16
6
|
import { join } from 'node:path';
|
|
@@ -21,9 +11,7 @@ import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
|
|
|
21
11
|
|
|
22
12
|
const APP_NAME = 'selva-compute';
|
|
23
13
|
|
|
24
|
-
// Resolve
|
|
25
|
-
// pm2 binaries on the same host and letting them both manage the daemon
|
|
26
|
-
// produces the exact version-skew bug this wrapper exists to prevent.
|
|
14
|
+
// Resolve to deployment-local pm2 (no global fallback; prevents version skew).
|
|
27
15
|
function pm2Bin(dir) {
|
|
28
16
|
const local = join(dir, 'node_modules', '.bin', process.platform === 'win32' ? 'pm2.cmd' : 'pm2');
|
|
29
17
|
if (!existsSync(local)) {
|
|
@@ -37,12 +25,7 @@ function pm2Bin(dir) {
|
|
|
37
25
|
return local;
|
|
38
26
|
}
|
|
39
27
|
|
|
40
|
-
// Check
|
|
41
|
-
// the one we're about to invoke. PM2 prints "In-memory PM2 is out-of-date" on
|
|
42
|
-
// every command in that state and process operations may stall. `pm2 update`
|
|
43
|
-
// is the only fix: dump → kill daemon → respawn under the current binary →
|
|
44
|
-
// resurrect dump. We run it here before any state-changing command so the
|
|
45
|
-
// caller never gets a half-applied stop/restart against a stale daemon.
|
|
28
|
+
// Check for daemon/CLI version mismatch; run `pm2 update` if stale.
|
|
46
29
|
function ensurePm2InSync(dir) {
|
|
47
30
|
const bin = pm2Bin(dir);
|
|
48
31
|
const probe = spawnSync(bin, ['ping'], {
|
|
@@ -142,19 +125,10 @@ export async function runUpdate() {
|
|
|
142
125
|
return;
|
|
143
126
|
}
|
|
144
127
|
|
|
145
|
-
// Resync
|
|
146
|
-
// different version than the local CLI, `pm2 stop` may report success
|
|
147
|
-
// while leaving the process group in a half-state, and the subsequent
|
|
148
|
-
// `pm2 start` then hangs. Running `pm2 update` here puts everything on
|
|
149
|
-
// the same version (and survives the dump+resurrect cycle).
|
|
128
|
+
// Resync daemon before state changes (avoid half-state stop/start).
|
|
150
129
|
ensurePm2InSync(dir);
|
|
151
130
|
|
|
152
|
-
// Stop
|
|
153
|
-
// SvelteKit's node adapter lazy-imports chunks from build/server/chunks/ on every
|
|
154
|
-
// request; if we let npm replace them while the old process is still serving
|
|
155
|
-
// traffic, in-flight requests hit ERR_MODULE_NOT_FOUND for chunks whose hash
|
|
156
|
-
// just changed. Brief downtime (~1-2s longer than restart-in-place) but no
|
|
157
|
-
// chunk-mismatch errors.
|
|
131
|
+
// Stop before npm rewrites (SvelteKit lazy-loads chunks; see migrate command).
|
|
158
132
|
const stopStatus = runPm2(dir, ['stop', APP_NAME], { inherit: false });
|
|
159
133
|
if (stopStatus !== 0) {
|
|
160
134
|
p.log.warn('pm2 stop did not succeed — selva-compute may not be running. Continuing.');
|
|
@@ -163,11 +137,7 @@ export async function runUpdate() {
|
|
|
163
137
|
const s = p.spinner();
|
|
164
138
|
s.start(`npm update ${packages.join(' ')}`);
|
|
165
139
|
try {
|
|
166
|
-
// --prefer-online
|
|
167
|
-
// the registry before using them. Without this, npm's 5+ minute
|
|
168
|
-
// packument cache silently re-installs the same version even when a
|
|
169
|
-
// newer one was published in the meantime. See docs/Hotfix-CLI-Runtime.md
|
|
170
|
-
// "The stale-packument-cache trap".
|
|
140
|
+
// --prefer-online bypasses npm's packument cache (see docs/Hotfix-CLI-Runtime.md).
|
|
171
141
|
execSync(`npm update --save --prefer-online ${packages.join(' ')}`, {
|
|
172
142
|
cwd: dir,
|
|
173
143
|
stdio: 'pipe'
|
|
@@ -175,7 +145,7 @@ export async function runUpdate() {
|
|
|
175
145
|
s.stop('npm update finished');
|
|
176
146
|
} catch (err) {
|
|
177
147
|
s.stop('npm update failed');
|
|
178
|
-
//
|
|
148
|
+
// Best-effort: bring old process back up.
|
|
179
149
|
runPm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
|
|
180
150
|
throw err;
|
|
181
151
|
}
|
|
@@ -183,10 +153,7 @@ export async function runUpdate() {
|
|
|
183
153
|
const after = readRuntimeVersion(dir);
|
|
184
154
|
p.log.info(`New @selvajs/selva: ${after ?? 'unknown'}`);
|
|
185
155
|
|
|
186
|
-
// Surface no-op updates
|
|
187
|
-
// holes, but a freshly-published version can take a minute or two to
|
|
188
|
-
// propagate through npm's CDN — operators who run update too quickly
|
|
189
|
-
// after publish still see "Current = New". Tell them how to retry.
|
|
156
|
+
// Surface no-op updates (cache may be stale; propagation delay is real).
|
|
190
157
|
if (before && after && before === after) {
|
|
191
158
|
p.log.warn(
|
|
192
159
|
[
|
package/src/env.js
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
// Minimal .env parser
|
|
2
|
-
//
|
|
3
|
-
// We don't pull in dotenv: the runtime already loads .env via node --env-file,
|
|
4
|
-
// and the CLI's needs are simpler than dotenv supports (no shell expansion, no
|
|
5
|
-
// multiline values). Keeping it in-tree means one less dep to vet.
|
|
1
|
+
// Minimal .env parser/serializer (no dotenv; runtime uses node --env-file).
|
|
6
2
|
|
|
7
3
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
8
4
|
|
|
@@ -32,12 +28,7 @@ export function readEnvFile(path) {
|
|
|
32
28
|
return parseEnv(readFileSync(path, 'utf8'));
|
|
33
29
|
}
|
|
34
30
|
|
|
35
|
-
//
|
|
36
|
-
// preserved; lines whose key appears in `values` get rewritten in-place. Keys
|
|
37
|
-
// in `values` but absent from the template are appended at the end.
|
|
38
|
-
//
|
|
39
|
-
// Lines beginning with `# KEY=...` (commented examples) are treated as
|
|
40
|
-
// suggestions and left alone; the actual value (if any) goes uncommented.
|
|
31
|
+
// Merge values into template: preserve structure, rewrite in-place, append new keys.
|
|
41
32
|
export function mergeEnv(template, values) {
|
|
42
33
|
const seen = new Set();
|
|
43
34
|
const lines = template.split(/\r?\n/);
|
|
@@ -78,11 +69,7 @@ export function mergeEnv(template, values) {
|
|
|
78
69
|
out.push(...appended);
|
|
79
70
|
}
|
|
80
71
|
|
|
81
|
-
//
|
|
82
|
-
// `echo VAR=value >> .env` concatenates onto the last line, producing
|
|
83
|
-
// `HOST=127.0.0.1HEADER_AUTH_DATA_DIR=...` and a `getaddrinfo ENOTFOUND`
|
|
84
|
-
// boot crash. Strip any existing trailing blanks first so we don't grow
|
|
85
|
-
// the file by a line on each rewrite.
|
|
72
|
+
// Ensure single trailing newline (shell append mishap guard).
|
|
86
73
|
while (out.length > 0 && out[out.length - 1] === '') out.pop();
|
|
87
74
|
return out.join('\n') + '\n';
|
|
88
75
|
}
|
package/src/prompts.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
// Shared prompt flow
|
|
2
|
-
// `selva init` (reconfigure existing install). The two callers differ only in
|
|
3
|
-
// what defaults are passed in and what gets written afterwards.
|
|
1
|
+
// Shared prompt flow for create and init; differs only in defaults and output.
|
|
4
2
|
|
|
5
3
|
import * as p from '@clack/prompts';
|
|
6
4
|
import pc from 'picocolors';
|
|
@@ -12,17 +10,8 @@ function envBool(v) {
|
|
|
12
10
|
return TRUTHY.has(String(v).toLowerCase());
|
|
13
11
|
}
|
|
14
12
|
|
|
15
|
-
// Non-interactive sibling of
|
|
16
|
-
//
|
|
17
|
-
// scripts, CI, Docker entrypoints — never touch a prompt. Same output shape
|
|
18
|
-
// as `collectConfig` so the caller code in create.js doesn't branch further.
|
|
19
|
-
//
|
|
20
|
-
// Validation mirrors collectConfig's prompt-time checks. Anything missing or
|
|
21
|
-
// malformed throws with the offending var name so the boot log makes the
|
|
22
|
-
// fix obvious. We deliberately do NOT fall back to a "safe default" for
|
|
23
|
-
// security-relevant fields (BOOTSTRAP_INSTANCE_ADMIN_EMAIL for header-auth /
|
|
24
|
-
// multi-tenant, SUPABASE_SERVICE_ROLE_KEY when supabase is selected) — a
|
|
25
|
-
// silently-misconfigured deploy is worse than a loud failure.
|
|
13
|
+
// Non-interactive sibling of collectConfig: read from env, validate strictly,
|
|
14
|
+
// fail loud for security-relevant fields (no safe defaults).
|
|
26
15
|
export function collectConfigFromEnv(env = process.env) {
|
|
27
16
|
const tenancy = pick(env.SELVA_TENANCY, ['single', 'multi'], 'single', 'SELVA_TENANCY');
|
|
28
17
|
const auth = pick(
|
|
@@ -55,7 +44,6 @@ export function collectConfigFromEnv(env = process.env) {
|
|
|
55
44
|
SELVA_STORAGE_PROVIDER: storage
|
|
56
45
|
};
|
|
57
46
|
|
|
58
|
-
// ── Provider-specific config ──────────────────────────────────────────
|
|
59
47
|
if (auth === 'local' || data === 'local' || storage === 'local') {
|
|
60
48
|
values.DATA_PATH = env.DATA_PATH || './.selva-data';
|
|
61
49
|
}
|
|
@@ -101,7 +89,6 @@ export function collectConfigFromEnv(env = process.env) {
|
|
|
101
89
|
}
|
|
102
90
|
values.BOOTSTRAP_INSTANCE_ADMIN_EMAIL = adminEmail;
|
|
103
91
|
|
|
104
|
-
// ── Reverse proxy ────────────────────────────────────────────────────
|
|
105
92
|
const origin = env.ORIGIN || '';
|
|
106
93
|
if (origin) {
|
|
107
94
|
try {
|
|
@@ -115,10 +102,7 @@ export function collectConfigFromEnv(env = process.env) {
|
|
|
115
102
|
}
|
|
116
103
|
values.ORIGIN = origin;
|
|
117
104
|
|
|
118
|
-
//
|
|
119
|
-
// Pass-through: any SELVA_FLAG_* var set on the environment is written
|
|
120
|
-
// verbatim. Unset flags get an empty string so the .env file still has
|
|
121
|
-
// a row for them (operator can flip later without editing structure).
|
|
105
|
+
// Pass-through: SELVA_FLAG_* vars written verbatim; unset = empty string.
|
|
122
106
|
const flagNames = [
|
|
123
107
|
'ALLOW_ORG_CREATION',
|
|
124
108
|
'ALLOW_CROSS_ORG_PUBLIC',
|
|
@@ -148,12 +132,8 @@ function requireEnv(env, name) {
|
|
|
148
132
|
return v;
|
|
149
133
|
}
|
|
150
134
|
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
// existing .env or write fresh.
|
|
154
|
-
//
|
|
155
|
-
// `defaults` is an existing env map (from .env, or {}). Anything present there
|
|
156
|
-
// pre-populates the prompt so re-running is cheap.
|
|
135
|
+
// Run full interactive prompt sequence; return env vars. Caller decides merge vs fresh.
|
|
136
|
+
// `defaults` pre-populates prompts (cheap re-run).
|
|
157
137
|
export async function collectConfig({ defaults = {}, mode = 'create' } = {}) {
|
|
158
138
|
const isInit = mode === 'init';
|
|
159
139
|
|
|
@@ -164,7 +144,6 @@ export async function collectConfig({ defaults = {}, mode = 'create' } = {}) {
|
|
|
164
144
|
// these env vars are absent. To re-enable, add a brand prompt block here
|
|
165
145
|
// and write the values into `values` below.
|
|
166
146
|
|
|
167
|
-
// ── Tenancy ─────────────────────────────────────────────────────────
|
|
168
147
|
const tenancy = await p.select({
|
|
169
148
|
message: 'Tenancy mode',
|
|
170
149
|
initialValue: defaults.SELVA_TENANCY ?? 'single',
|
|
@@ -183,7 +162,6 @@ export async function collectConfig({ defaults = {}, mode = 'create' } = {}) {
|
|
|
183
162
|
});
|
|
184
163
|
cancelOn(tenancy);
|
|
185
164
|
|
|
186
|
-
// ── Auth provider ───────────────────────────────────────────────────
|
|
187
165
|
const auth = await p.select({
|
|
188
166
|
message: 'Auth backend',
|
|
189
167
|
initialValue: defaults.SELVA_AUTH_PROVIDER ?? 'local',
|
|
@@ -199,9 +177,7 @@ export async function collectConfig({ defaults = {}, mode = 'create' } = {}) {
|
|
|
199
177
|
});
|
|
200
178
|
cancelOn(auth);
|
|
201
179
|
|
|
202
|
-
// Header-auth
|
|
203
|
-
// MUST pick a separate backend for those. Local is the sensible default
|
|
204
|
-
// pairing (same filesystem as the allowlist).
|
|
180
|
+
// Header-auth: auth-only; user must pick separate data/storage backend.
|
|
205
181
|
let data;
|
|
206
182
|
let storage;
|
|
207
183
|
if (auth === 'header') {
|
|
@@ -229,9 +205,7 @@ export async function collectConfig({ defaults = {}, mode = 'create' } = {}) {
|
|
|
229
205
|
cancelOn(storageChoice);
|
|
230
206
|
storage = storageChoice;
|
|
231
207
|
} else {
|
|
232
|
-
//
|
|
233
|
-
// backend as auth. Ask once, default the others; let advanced users
|
|
234
|
-
// override.
|
|
208
|
+
// Operators usually want same backend for all three; ask once, default the others.
|
|
235
209
|
const mixProviders = await p.confirm({
|
|
236
210
|
message: `Use ${pc.cyan(auth)} for data and storage too?`,
|
|
237
211
|
initialValue: pickSameProviderDefault(defaults, auth)
|
|
@@ -265,7 +239,6 @@ export async function collectConfig({ defaults = {}, mode = 'create' } = {}) {
|
|
|
265
239
|
}
|
|
266
240
|
}
|
|
267
241
|
|
|
268
|
-
// ── Provider-specific config ───────────────────────────────────────
|
|
269
242
|
const providerValues = {};
|
|
270
243
|
if (auth === 'local' || data === 'local' || storage === 'local') {
|
|
271
244
|
const dataPath = await p.text({
|
|
@@ -316,10 +289,7 @@ export async function collectConfig({ defaults = {}, mode = 'create' } = {}) {
|
|
|
316
289
|
}
|
|
317
290
|
|
|
318
291
|
if (auth === 'header') {
|
|
319
|
-
// HEADER_AUTH_DATA_DIR
|
|
320
|
-
// data provider is local, DATA_PATH is the natural home and we let
|
|
321
|
-
// the provider fall back to it. Only ask explicitly if data isn't
|
|
322
|
-
// local — otherwise there's no obvious default.
|
|
292
|
+
// HEADER_AUTH_DATA_DIR: ask only if data provider isn't local (fallback available).
|
|
323
293
|
if (data !== 'local') {
|
|
324
294
|
const dataDir = await p.text({
|
|
325
295
|
message: 'HEADER_AUTH_DATA_DIR — directory for header-allowlist.json',
|
|
@@ -369,10 +339,7 @@ export async function collectConfig({ defaults = {}, mode = 'create' } = {}) {
|
|
|
369
339
|
providerValues.HEADER_AUTH_DISPLAY_NAME_HEADER = stringValue(display);
|
|
370
340
|
}
|
|
371
341
|
|
|
372
|
-
// Header-auth
|
|
373
|
-
// (the operator might run inside a Docker network where the proxy
|
|
374
|
-
// reaches the container by service name), but we set HOST=127.0.0.1
|
|
375
|
-
// as the default and let them override.
|
|
342
|
+
// Header-auth: default to loopback (not enforced; Docker networks may override).
|
|
376
343
|
const bindLoopback = await p.confirm({
|
|
377
344
|
message: 'Bind the app to 127.0.0.1 only? (recommended for header-auth)',
|
|
378
345
|
initialValue: !defaults.HOST || defaults.HOST === '127.0.0.1' || defaults.HOST === 'localhost'
|
|
@@ -381,14 +348,7 @@ export async function collectConfig({ defaults = {}, mode = 'create' } = {}) {
|
|
|
381
348
|
providerValues.HOST = bindLoopback ? '127.0.0.1' : stringValue(defaults.HOST);
|
|
382
349
|
}
|
|
383
350
|
|
|
384
|
-
//
|
|
385
|
-
// Two roles in one env var: (1) gate the "first-signup-becomes-admin"
|
|
386
|
-
// path to a specific email — required for multi-tenant so a random
|
|
387
|
-
// signup doesn't get Selva staff perms; (2) break-glass recovery if
|
|
388
|
-
// admin is lost to a backup restore / manual DB edit. The check ONLY
|
|
389
|
-
// runs while no admin exists yet, so leaving it set permanently is
|
|
390
|
-
// safe. Phrase the prompt differently per tenancy/auth-provider because
|
|
391
|
-
// the security implications differ.
|
|
351
|
+
// Bootstrap admin (checked only while no admin exists; safe to leave set).
|
|
392
352
|
if (auth === 'header') {
|
|
393
353
|
p.note(
|
|
394
354
|
[
|
|
@@ -449,7 +409,6 @@ export async function collectConfig({ defaults = {}, mode = 'create' } = {}) {
|
|
|
449
409
|
});
|
|
450
410
|
cancelOn(adminEmail);
|
|
451
411
|
|
|
452
|
-
// ── Reverse proxy ──────────────────────────────────────────────────
|
|
453
412
|
const behindProxy = await p.confirm({
|
|
454
413
|
message: 'Behind a reverse proxy (Caddy, nginx, etc.)?',
|
|
455
414
|
initialValue: Boolean(defaults.ORIGIN)
|
|
@@ -476,10 +435,7 @@ export async function collectConfig({ defaults = {}, mode = 'create' } = {}) {
|
|
|
476
435
|
cancelOn(value);
|
|
477
436
|
origin = String(value);
|
|
478
437
|
|
|
479
|
-
// Plain HTTP
|
|
480
|
-
// flag on, browser refuses to send over http://). Login appears to
|
|
481
|
-
// succeed but the next request is anonymous. Warn loudly here — the
|
|
482
|
-
// fix is either TLS or ALLOW_INSECURE_COOKIES=true in .env.
|
|
438
|
+
// Plain HTTP kills Secure cookies in production (login appears to succeed, then anon).
|
|
483
439
|
if (origin.startsWith('http://')) {
|
|
484
440
|
p.note(
|
|
485
441
|
'Sessions use Secure cookies in production; browsers will silently\n' +
|
|
@@ -494,7 +450,6 @@ export async function collectConfig({ defaults = {}, mode = 'create' } = {}) {
|
|
|
494
450
|
}
|
|
495
451
|
}
|
|
496
452
|
|
|
497
|
-
// ── Platform flags ─────────────────────────────────────────────────
|
|
498
453
|
const flagOptions = [
|
|
499
454
|
{
|
|
500
455
|
value: 'ALLOW_ORG_CREATION',
|
|
@@ -535,7 +490,6 @@ export async function collectConfig({ defaults = {}, mode = 'create' } = {}) {
|
|
|
535
490
|
});
|
|
536
491
|
cancelOn(flags);
|
|
537
492
|
|
|
538
|
-
// ── Done ───────────────────────────────────────────────────────────
|
|
539
493
|
const values = {
|
|
540
494
|
SELVA_TENANCY: tenancy,
|
|
541
495
|
SELVA_AUTH_PROVIDER: auth,
|
|
@@ -554,9 +508,7 @@ export async function collectConfig({ defaults = {}, mode = 'create' } = {}) {
|
|
|
554
508
|
return values;
|
|
555
509
|
}
|
|
556
510
|
|
|
557
|
-
//
|
|
558
|
-
// the current .env already reflects that. If the operator deliberately split
|
|
559
|
-
// auth and data, don't re-merge them on reconfigure.
|
|
511
|
+
// Init: default to "same provider" only if current .env already shows that.
|
|
560
512
|
function pickSameProviderDefault(defaults, auth) {
|
|
561
513
|
const data = defaults.SELVA_DATA_PROVIDER ?? auth;
|
|
562
514
|
const storage = defaults.SELVA_STORAGE_PROVIDER ?? auth;
|
|
@@ -565,8 +517,6 @@ function pickSameProviderDefault(defaults, auth) {
|
|
|
565
517
|
}
|
|
566
518
|
|
|
567
519
|
function cancelOn(v) {
|
|
568
|
-
// @clack/prompts returns Symbol(clack:cancel) when the user hits Ctrl+C.
|
|
569
|
-
// p.isCancel() is the official API for detecting it.
|
|
570
520
|
if (p.isCancel(v)) {
|
|
571
521
|
p.cancel('Cancelled.');
|
|
572
522
|
process.exit(0);
|
|
@@ -578,9 +528,7 @@ function stringValue(v) {
|
|
|
578
528
|
return String(v);
|
|
579
529
|
}
|
|
580
530
|
|
|
581
|
-
//
|
|
582
|
-
// emphatic that the deployment IS the security boundary — none of these
|
|
583
|
-
// invariants can be enforced at runtime, so we surface them up front.
|
|
531
|
+
// Header-auth security: deployment IS the boundary (runtime enforcement impossible).
|
|
584
532
|
function headerAuthSecurityWarning() {
|
|
585
533
|
return [
|
|
586
534
|
'Header-auth trusts identity headers from the upstream proxy. Anyone who',
|