@selvajs/cli 4.5.0 → 4.6.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selvajs/cli",
3
- "version": "4.5.0",
3
+ "version": "4.6.0-beta.0",
4
4
  "description": "Scaffold and operate a Selva white-label deployment. `npx @selvajs/cli <dir>` to bootstrap, `selva <cmd>` to manage.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -1,16 +1,6 @@
1
- // `npx @selvajs/cli <dir>` scaffold a fresh deployment.
2
- //
3
- // What this writes into <dir>:
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
- // 1. Collect config. --yes (or CI=1) takes everything from the
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
- // 2. Always generate fresh secrets for a new install. They MUST be stable
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
- // 3. Write package.json before installing so npm has something to read.
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
- // 5. Now copy templates from the installed runtime and fill them in.
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
- // Run `npm install` with live progress and a real error report.
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
- // Ring buffer — keep the last 80 lines so we can show them on failure.
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 > maxTail) tail.shift();
101
+ if (tail.length > 80) tail.shift();
129
102
  };
130
103
 
131
- // Visible progress milestones. npm doesn't emit a clean progress
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
- // "npm WARN ..." / "npm error ..." — pass through prefixed.
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 npm with --loglevel=info so we get reify: progress lines.
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
- // The deployment's package.json depends on @selvajs/selva (prebuilt
259
- // SvelteKit app with all providers bundled in) plus @selvajs/cli (the
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 lives in the deployment's own node_modules so `selva start` can
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',
@@ -1,18 +1,5 @@
1
- // `selva doctor` validate a deployment without starting it.
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
- // ── Provider wiring ────────────────────────────────────────────────
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
- // Compare the running CLI's major against the installed @selvajs/selva
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
- // Validate that a VM reboot will resurrect the app. Three failure modes,
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
- // 1. dump.pm2 what `pm2 startup` resurrects. PM2_HOME overrides location.
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
- // 2 + 3. systemd unit: present, and pointing at the deployment-local pm2.
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
- // Global pm2 on PATH — the root cause of mode 3 and of day-to-day skew
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 a `pm2` binary that isn't this deployment's. Returns the first
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
- // Defaults must match HeaderAuthProvider.DEFAULT_HEADERS. Duplicated here so
386
- // doctor doesn't have to load the runtime. If the provider's defaults ever
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-specific sanity checks. None of these catch the truly dangerous
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
- // 1. Where does header-allowlist.json live?
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
- // The provider creates it lazily, but a fresh deployment with no
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
- // If we expect lazy creation, the dir (or its parent) needs to be
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
- // 2. HOST binding. Loopback is strongly recommended for header-auth.
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
- // 4. If data provider isn't local, the allowlist file is the ONLY local
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
- // 6. Resolved header names. The most common header-auth boot symptom is
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,
@@ -1,16 +1,4 @@
1
- // `selva keys rotate <hmac|at-rest>` generate a fresh secret and write it
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';
@@ -1,27 +1,6 @@
1
- // `selva migrate` bring an existing deployment onto the current layout.
2
- //
3
- // Three historical migrations exist for Selva deployments:
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 mutating node_modules. Same reasoning as `selva update`:
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 everything we're about to mutate. Restored on npm-install failure
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
- // Single-line rewrite: only @selvajs/runtime → @selvajs/selva. We don't
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
- // Nuke node_modules + lockfile. A simple `npm install` won't always
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 old process back up so we don't leave the operator
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
- // Compute what package.json should look like given the current contents.
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
- // Produce human-readable diff lines for the confirmation prompt. Keep it
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 copy of pm2.js's runner. Importing it would create a circular
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 use by `selva doctor` so it can warn about layout drift
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 so tests/imports can verify what migrate would write without
303
- // actually running it.
259
+ // Re-exported for tests to verify without running migrate.
304
260
  export { buildTargetPackageJson, SELVA_DEPS };
@@ -1,16 +1,6 @@
1
- // Thin wrappers around PM2 commands. The point isn't to abstract pm2 —
2
- // it's to hide footguns the docs already warn about:
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 pm2 to the deployment's own copy. NO global fallback having two
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 whether the in-memory PM2 daemon was forked by a different pm2 than
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 the daemon BEFORE we touch the running app — if the daemon is a
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 the running process BEFORE npm rewrites node_modules/@selvajs/selva/build/.
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 forces npm to revalidate cached packuments against
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
- // Bring the old process back up so the operator isn't left with downtime.
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 explicitly. --prefer-online closes most cache
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 / serializer.
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
- // Re-serialize a .env file. Comments + section structure from `template` are
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
- // Always end with a single trailing newline. Without it, a later
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 used by both `create` (fresh scaffold) and
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 `collectConfig`. Reads everything from `env`
16
- // (defaults to process.env) so unattended bootstraps Terraform startup
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
- // ── Feature flags ────────────────────────────────────────────────────
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
- // Runs the full interactive prompt sequence and returns a flat object of
152
- // env-var-name value (string). The caller decides whether to merge with an
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 is auth-only it has no data/storage to share. The user
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
- // In practice operators almost always want data + storage on the same
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 is where header-allowlist.json lives. When the
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 deployments MUST bind to loopback. We don't force it
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
- // ── Bootstrap admin ────────────────────────────────────────────────
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 + NODE_ENV=production drops the session cookie (Secure
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
- // `selva init` should default to "yes, same provider for all three" only when
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
- // Shown once when the operator picks header-auth. The provider's README is
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',