@selvajs/cli 4.8.0-beta.10 → 4.8.0-beta.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,5 @@
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.
1
+ // Scaffolds a fresh deployment: package.json, node_modules, .env (merged from
2
+ // template + prompts), and ecosystem.config.cjs.
4
3
 
5
4
  import { writeFileSync, existsSync, mkdirSync, readFileSync, cpSync } from 'node:fs';
6
5
  import { resolve, join, basename, dirname } from 'node:path';
@@ -16,18 +15,20 @@ import {
16
15
  runtimeTemplatePath,
17
16
  scaffoldVersion
18
17
  } from '../paths.js';
19
- import { buildDeploymentPackageJson } from '../deployment-package.js';
18
+ import {
19
+ buildDeploymentPackageJson,
20
+ npmDistTagVersion,
21
+ resolveSelvaPins
22
+ } from '../deployment-package.js';
20
23
  import { satisfiesNodeRange } from '../node-range.js';
21
24
 
22
25
  export async function runCreate(argv) {
23
26
  const { dir: rawDir, force, skipInstall, yes } = parseArgs(argv);
24
27
 
25
- // Scaffolding under too old a Node produces a deployment that installs
26
- // cleanly and passes its health check, then fails only under real traffic —
27
- // npm treats an engine mismatch as a warning unless engine-strict is set,
28
- // which no deployment sets (issue #176). Refuse at the one moment the
29
- // operator is still choosing their environment. The floor comes from this
30
- // CLI's own engines.node, so a bump can't leave a stale copy behind.
28
+ // Scaffolding under too old a Node installs and passes the health check,
29
+ // then fails only under real traffic — npm treats an engine mismatch as a
30
+ // warning, not an error, unless engine-strict is set, which no deployment
31
+ // sets. Refuse now, while the operator is still choosing their environment.
31
32
  const required = requiredNodeRange();
32
33
  if (required && satisfiesNodeRange(process.versions.node, required) === false) {
33
34
  console.error(
@@ -50,20 +51,29 @@ export async function runCreate(argv) {
50
51
  }
51
52
  mkdirSync(targetDir, { recursive: true });
52
53
 
53
- // --yes or CI=1 skips prompts (unattended bootstrap).
54
54
  const nonInteractive = yes || envBool(process.env.CI);
55
55
  const values = nonInteractive
56
56
  ? collectConfigFromEnv(process.env)
57
57
  : await collectConfig({ defaults: {}, mode: 'create' });
58
58
 
59
- // Generate fresh secrets (stable across restarts; init refuses to regen).
60
59
  values.SELVA_HMAC_KEY = generateKey();
61
60
  values.SELVA_AT_REST_KEY = generateKey();
62
61
 
63
62
  const deployName = basename(targetDir);
64
63
 
65
- // Write package.json, then install (need templates from @selvajs/selva).
66
- const pkgJson = JSON.stringify(buildDeploymentPackageJson({ name: deployName }), null, 2);
64
+ // Resolve the dist-tags to concrete versions before writing. A stored
65
+ // `"latest"` re-resolves on every later `npm install`, so the deployment
66
+ // would follow the tag instead of the version it was scaffolded against —
67
+ // and `selva doctor` reports a floating pin as drift.
68
+ const { pins } = resolveSelvaPins({}, npmDistTagVersion);
69
+
70
+ // package.json first: npm install needs it, and the runtime templates below
71
+ // only exist once @selvajs/selva is installed into node_modules.
72
+ const pkgJson = JSON.stringify(
73
+ buildDeploymentPackageJson({ name: deployName, dependencies: pins }),
74
+ null,
75
+ 2
76
+ );
67
77
  writeFileSync(join(targetDir, 'package.json'), pkgJson + '\n', 'utf8');
68
78
 
69
79
  if (!skipInstall) {
@@ -74,7 +84,6 @@ export async function runCreate(argv) {
74
84
  );
75
85
  }
76
86
 
77
- // Copy templates from installed runtime.
78
87
  const envTemplatePath = runtimeTemplatePath(targetDir, '.env.example');
79
88
  const ecosystemTemplatePath = runtimeTemplatePath(targetDir, 'ecosystem.config.cjs');
80
89
  if (skipInstall || !existsSync(envTemplatePath)) {
@@ -110,21 +119,17 @@ export async function runCreate(argv) {
110
119
  );
111
120
  }
112
121
 
113
- // Stream npm output with live progress; on failure, show tail for debugging.
114
- // Cache-bust hint: surface broken versions like @selvajs/selva@0.10.2.
115
122
  function runNpmInstall(cwd) {
116
123
  return new Promise((resolveP, rejectP) => {
117
124
  const s = p.spinner();
118
125
  s.start('Installing dependencies (this can take a minute)');
119
126
 
120
- // Keep last 80 lines for failure output.
121
127
  const tail = [];
122
128
  const remember = (line) => {
123
129
  tail.push(line);
124
130
  if (tail.length > 80) tail.shift();
125
131
  };
126
132
 
127
- // Extract progress milestones (reify: lines, package count).
128
133
  const updateProgress = (line) => {
129
134
  const trimmed = line.trim();
130
135
  if (!trimmed) return;
@@ -140,7 +145,7 @@ function runNpmInstall(cwd) {
140
145
  if (/^npm (WARN|error|notice)/.test(trimmed)) return;
141
146
  };
142
147
 
143
- // Spawn with --loglevel=info for reify: progress; pipe stdout/stderr for live updates.
148
+ // --loglevel=info is what makes npm print the reify: lines updateProgress matches on.
144
149
  const child = spawn('npm', ['install', '--loglevel=info'], {
145
150
  cwd,
146
151
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -184,8 +189,8 @@ function runNpmInstall(cwd) {
184
189
  }
185
190
  s.stop(pc.red(`npm install failed (exit ${code})`));
186
191
 
187
- // Show what npm actually said. Without this the operator has to
188
- // dig through ~/.npm/_logs/*-debug-0.log to find a single line.
192
+ // Without this the operator has to dig through ~/.npm/_logs/*-debug-0.log
193
+ // to find out what actually went wrong.
189
194
  console.error('');
190
195
  console.error(pc.dim('── last lines of npm output ──'));
191
196
  for (const line of tail) console.error(line);
@@ -2,12 +2,14 @@
2
2
  // Node engine, boot persistence. Yellow warnings don't fail, red failures exit 1.
3
3
  //
4
4
  // Read-only by default. `--fix` applies the repairs attached to individual
5
- // checks, each behind its own confirmation — and only those needing no root and
6
- // no runtime restart. See `applyFixes`.
5
+ // checks, each behind its own confirmation — only fixes needing no root and
6
+ // no runtime restart exist. See applyFixes.
7
7
 
8
8
  import {
9
9
  existsSync,
10
10
  readFileSync,
11
+ writeFileSync,
12
+ copyFileSync,
11
13
  readdirSync,
12
14
  accessSync,
13
15
  constants,
@@ -18,13 +20,16 @@ import { join, resolve, dirname } from 'node:path';
18
20
  import { fileURLToPath } from 'node:url';
19
21
  import * as p from '@clack/prompts';
20
22
  import pc from 'picocolors';
21
- import { readEnvFile } from '../env.js';
23
+ import { readEnvFile, parseEnv, countEnvCommentLines, stripEnvComments } from '../env.js';
22
24
  import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
23
25
  import { satisfiesNodeRange } from '../node-range.js';
24
26
  import { green, yellow, red, fixable } from '../checks/result.js';
25
27
  import { checkBootPersistence } from '../checks/boot.js';
28
+ import { checkRuntimeEnvironment } from '../checks/runtime.js';
26
29
  import {
27
30
  checkDeprecatedEnv,
31
+ checkEnvDocumentation,
32
+ checkHeaderNames,
28
33
  checkOrigin,
29
34
  checkProviders,
30
35
  checkSecret,
@@ -73,9 +78,11 @@ export async function runDoctor(argv = []) {
73
78
  checks.push(checkPackage(dir, '@selvajs/selva'));
74
79
  checks.push(checkNodeEngine(dir));
75
80
  checks.push(checkCliRuntimeAlignment(dir));
81
+ checks.push(...checkRuntimeEnvironment(dir));
76
82
  checks.push(...checkBootPersistence(dir));
77
83
  checks.push(checkOrigin(env));
78
84
  checks.push(...checkDeprecatedEnv(env));
85
+ checks.push(checkEnvDocs(dir));
79
86
 
80
87
  // ── Render ─────────────────────────────────────────────────────────
81
88
  const resolved = await Promise.all(checks);
@@ -105,15 +112,9 @@ export async function runDoctor(argv = []) {
105
112
  }
106
113
  }
107
114
 
108
- /**
109
- * Apply the repairs attached to failing checks, each behind its own
110
- * confirmation. Returns the updated failure count.
111
- *
112
- * Nothing here needs root: anything privileged (the systemd unit) or that would
113
- * restart this process (a Node upgrade) stays a printed instruction, because a
114
- * fixer that dies halfway through its own runtime leaves the operator with no
115
- * obvious path back.
116
- */
115
+ // Anything privileged (the systemd unit) or that would restart this process
116
+ // (a Node upgrade) stays a printed instruction instead of a fixer — one that
117
+ // dies halfway through its own runtime leaves the operator with no way back.
117
118
  async function applyFixes(resolved, failures) {
118
119
  const repairs = resolved.filter((c) => c.fix && c.severity !== 'green');
119
120
  if (repairs.length === 0) {
@@ -183,8 +184,7 @@ async function checkSupabase(env) {
183
184
  return red(`SUPABASE_URL="${env.SUPABASE_URL}" is not a valid URL`);
184
185
  }
185
186
 
186
- // Ping the Supabase health endpoint. Soft-fail to yellow on network
187
- // errors — operators may be offline at install time.
187
+ // Network errors go yellow, not red — operators may be offline at install time.
188
188
  try {
189
189
  const controller = new AbortController();
190
190
  const timer = setTimeout(() => controller.abort(), 4000);
@@ -199,11 +199,10 @@ async function checkSupabase(env) {
199
199
  }
200
200
  }
201
201
 
202
- // App↔DB schema handshake (audit O3). Expected head = newest migration shipped
203
- // by the installed @selvajs/supabase-provider; actual = what the database
204
- // reports via the selva.migration_head() RPC. Red on skew — the app degrades
205
- // /api/health to 503 at boot in the same state, so catching it here saves a
206
- // confusing deploy.
202
+ // Compares the newest migration shipped by @selvajs/supabase-provider against
203
+ // what the database reports via selva.migration_head(). Red on skew — the app
204
+ // degrades /api/health to 503 at boot in the same state, so catching it here
205
+ // saves a confusing deploy.
207
206
  async function checkSupabaseMigrations(dir, env) {
208
207
  if (!env.SUPABASE_URL || !env.SUPABASE_SERVICE_ROLE_KEY) {
209
208
  return yellow('Migration-head check skipped — Supabase env incomplete');
@@ -264,6 +263,7 @@ async function checkSupabaseMigrations(dir, env) {
264
263
  `Database reports no applied migrations (expected head ${expected}). Sync + run: npx supabase db push`
265
264
  );
266
265
  }
266
+ // String comparison — relies on both heads being fixed-width, zero-padded timestamps.
267
267
  if (actual < expected) {
268
268
  return red(
269
269
  `Database migration head ${actual} is behind the installed provider (${expected}). Sync + run: npx supabase db push`
@@ -271,7 +271,6 @@ async function checkSupabaseMigrations(dir, env) {
271
271
  }
272
272
  return green(`Database migration head ${actual} matches the installed provider`);
273
273
  } catch (err) {
274
- // Soft-fail on network errors, same as checkSupabase.
275
274
  return yellow(`Migration-head check skipped (${err.message ?? err})`);
276
275
  }
277
276
  }
@@ -295,7 +294,7 @@ function majorOf(version) {
295
294
  return m ? Number(m[1]) : null;
296
295
  }
297
296
 
298
- // CLI and runtime release together (fixed group); major skew = stale CLI pin.
297
+ // CLI and runtime release as a fixed group; major-version skew means a stale CLI pin.
299
298
  function checkCliRuntimeAlignment(dir) {
300
299
  const here = dirname(fileURLToPath(import.meta.url));
301
300
  const cliVersion = readPackageVersion(join(here, '..', '..', 'package.json'));
@@ -303,7 +302,7 @@ function checkCliRuntimeAlignment(dir) {
303
302
  join(dir, 'node_modules', '@selvajs', 'selva', 'package.json')
304
303
  );
305
304
 
306
- // Runtime missing is already reported red by checkPackage; stay quiet here.
305
+ // checkPackage already reports missing runtime as red; stay quiet here.
307
306
  if (!runtimeVersion) return yellow('CLI/runtime version check skipped (runtime not installed)');
308
307
  if (!cliVersion) return yellow('CLI/runtime version check skipped (could not read CLI version)');
309
308
 
@@ -324,16 +323,10 @@ function checkCliRuntimeAlignment(dir) {
324
323
  );
325
324
  }
326
325
 
327
- // Verify VM reboot will resurrect app: dump.pm2 saved, systemd unit installed, no stray global pm2.
328
- /**
329
- * Does this host's Node satisfy the installed runtime's `engines.node`?
330
- *
331
- * npm only enforces engines under `engine-strict=true`, which no deployment
332
- * sets, so a mismatched install succeeds silently and `/api/health` still
333
- * returns 200 — routes using newer APIs throw only under real traffic
334
- * (issue #176). Deliberately NOT auto-fixable: upgrading Node is
335
- * distro-specific and would restart the process running this check.
336
- */
326
+ // npm only enforces engines.node under engine-strict=true, which no deployment
327
+ // sets, so a mismatched install succeeds silently and /api/health still returns
328
+ // 200 — routes using newer APIs throw only under real traffic. Not auto-fixable:
329
+ // upgrading Node is distro-specific and would restart the process running this check.
337
330
  function checkNodeEngine(dir) {
338
331
  let required;
339
332
  try {
@@ -363,12 +356,10 @@ function checkNodeEngine(dir) {
363
356
  );
364
357
  }
365
358
 
366
- /**
367
- * `selva migrate` parks the old node_modules aside so a failed install can be
368
- * rolled back, and removes it on either outcome. One surviving here means the
369
- * migration was killed mid-flight — worth saying, because it is a full copy of
370
- * the dependency tree and the deployment may be running on the wrong one.
371
- */
359
+ // `selva migrate` parks the old node_modules aside so a failed install can be
360
+ // rolled back, and removes it on either outcome. One surviving here means the
361
+ // migration was killed mid-flight — worth flagging since it's a full copy of
362
+ // the dependency tree and the deployment may be running on the wrong one.
372
363
  function checkMigrationLeftovers(dir) {
373
364
  const stash = join(dir, NODE_MODULES_STASH);
374
365
  if (!existsSync(stash)) return green('no interrupted migration left behind');
@@ -386,6 +377,58 @@ function checkMigrationLeftovers(dir) {
386
377
  );
387
378
  }
388
379
 
380
+ // Reads the raw file rather than the parsed env: the finding IS the comments,
381
+ // which parseEnv discards. The repair is attached here (not in checks/config.js)
382
+ // to keep that module filesystem-free.
383
+ function checkEnvDocs(dir) {
384
+ const envPath = join(dir, '.env');
385
+ if (!existsSync(envPath)) return green('.env documentation check skipped (no .env)');
386
+
387
+ let text;
388
+ try {
389
+ text = readFileSync(envPath, 'utf8');
390
+ } catch (err) {
391
+ return yellow(`.env unreadable (${err instanceof Error ? err.message : err})`);
392
+ }
393
+
394
+ return checkEnvDocumentation(
395
+ countEnvCommentLines(text),
396
+ fixable('strip the shipped documentation from .env (backup: .env.bak)', () => {
397
+ try {
398
+ // Re-read at repair time: doctor may have run minutes ago in --fix's
399
+ // confirmation loop, and rewriting from a stale snapshot would silently
400
+ // revert an edit made in between.
401
+ const current = readFileSync(envPath, 'utf8');
402
+ const { text: strippedText, removed } = stripEnvComments(current);
403
+ // A rewrite that changes which settings are live is a bug, not a
404
+ // cleanup — refuse rather than hand back a subtly different deployment.
405
+ const before = parseEnv(current);
406
+ const after = parseEnv(strippedText);
407
+ const drift = diffEnvKeys(before, after);
408
+ if (drift) return red(`refused to strip .env — ${drift}`);
409
+
410
+ copyFileSync(envPath, envPath + '.bak');
411
+ writeFileSync(envPath, strippedText, 'utf8');
412
+ return green(`stripped ${removed} comment lines from .env (backup: .env.bak)`);
413
+ } catch (err) {
414
+ return red(`could not rewrite .env: ${err instanceof Error ? err.message : err}`);
415
+ }
416
+ })
417
+ );
418
+ }
419
+
420
+ // Names the first discrepancy between two parsed envs, or null when identical.
421
+ function diffEnvKeys(before, after) {
422
+ for (const key of Object.keys(before)) {
423
+ if (!(key in after)) return `${key} would be lost`;
424
+ if (before[key] !== after[key]) return `${key} would change value`;
425
+ }
426
+ for (const key of Object.keys(after)) {
427
+ if (!(key in before)) return `${key} would be added`;
428
+ }
429
+ return null;
430
+ }
431
+
389
432
  function checkLayoutDrift(dir) {
390
433
  const pkgPath = join(dir, 'package.json');
391
434
  if (!existsSync(pkgPath)) return yellow('package.json missing — cannot check layout');
@@ -403,19 +446,10 @@ function checkLayoutDrift(dir) {
403
446
  );
404
447
  }
405
448
 
406
- // Must match HeaderAuthProvider.DEFAULT_HEADERS (duplicated to avoid loading runtime).
407
- // Smoke test in providers/header-auth pins them; divergence surfaces in CI.
408
- const DEFAULT_HEADER_NAMES = {
409
- upn: 'SELVA-UserPrincipalName',
410
- email: 'SELVA-Email',
411
- displayName: 'SELVA-DisplayName'
412
- };
413
-
414
- // Header-auth checks (read-only; runtime invariants like spoofing can't be verified).
449
+ // Static config only — runtime invariants like spoofing protection can't be checked from here.
415
450
  function checkHeaderAuth(dir, env, dataProvider) {
416
451
  const out = [];
417
452
 
418
- // Allowlist file location.
419
453
  const allowlistDir = env.HEADER_AUTH_DATA_DIR ?? env.DATA_PATH;
420
454
  if (!allowlistDir) {
421
455
  out.push(red('HEADER_AUTH_DATA_DIR (or DATA_PATH) unset — provider will fail to start'));
@@ -425,21 +459,21 @@ function checkHeaderAuth(dir, env, dataProvider) {
425
459
  if (existsSync(allowlistPath)) {
426
460
  out.push(green(`header-allowlist.json present (${allowlistDir}/header-allowlist.json)`));
427
461
  } else {
428
- // Provider creates lazily, but missing file locks everyone out.
462
+ // The provider creates this file lazily, but a missing file locks everyone out
463
+ // until one exists — worth a warning even though nothing is technically broken.
429
464
  out.push(
430
465
  yellow(
431
466
  `header-allowlist.json not found at ${allowlistDir}/ — no users will be allowed in until one is added`
432
467
  )
433
468
  );
434
469
 
435
- // Check writability only if explicitly set (DATA_PATH checked separately).
470
+ // DATA_PATH's own writability is checked separately (checkDataPath).
436
471
  if (env.HEADER_AUTH_DATA_DIR) {
437
472
  out.push(checkDirWritable(allowlistAbsDir, `HEADER_AUTH_DATA_DIR=${allowlistDir}`));
438
473
  }
439
474
  }
440
475
  }
441
476
 
442
- // HOST binding (loopback recommended for header-auth).
443
477
  const host = env.HOST ?? '0.0.0.0';
444
478
  if (host === '127.0.0.1' || host === 'localhost') {
445
479
  out.push(green(`HOST=${host} (loopback-only)`));
@@ -452,12 +486,11 @@ function checkHeaderAuth(dir, env, dataProvider) {
452
486
  );
453
487
  }
454
488
 
455
- // 3. ORIGIN — header-auth implies a reverse proxy, so ORIGIN is required.
489
+ // header-auth always sits behind a reverse proxy, so ORIGIN is required.
456
490
  if (!env.ORIGIN) {
457
491
  out.push(red('ORIGIN unset — required for header-auth (always behind a proxy)'));
458
492
  }
459
493
 
460
- // Non-local data provider: HEADER_AUTH_DATA_DIR must be explicit (no DATA_PATH fallback).
461
494
  if (dataProvider !== 'local' && !env.HEADER_AUTH_DATA_DIR) {
462
495
  out.push(
463
496
  red(
@@ -467,10 +500,10 @@ function checkHeaderAuth(dir, env, dataProvider) {
467
500
  );
468
501
  }
469
502
 
470
- // 5. Bootstrap admin email. Without it, the first proxy-authenticated
471
- // visitor's UPN is rejected (not in allowlist) and the operator has to
472
- // hand-write JSON to claim admin. With it, the first matching visit is
473
- // auto-allowlisted and granted instance_admin in one step.
503
+ // Without this, the first proxy-authenticated visitor's UPN is rejected
504
+ // (not in the allowlist) and the operator has to hand-write JSON to claim
505
+ // admin. With it, the first matching visit auto-allowlists and grants
506
+ // instance_admin in one step.
474
507
  if (!env.BOOTSTRAP_INSTANCE_ADMIN_EMAIL) {
475
508
  out.push(
476
509
  red(
@@ -482,30 +515,7 @@ function checkHeaderAuth(dir, env, dataProvider) {
482
515
  out.push(green(`BOOTSTRAP_INSTANCE_ADMIN_EMAIL=${env.BOOTSTRAP_INSTANCE_ADMIN_EMAIL}`));
483
516
  }
484
517
 
485
- // Resolved header names (print for diffing against proxy config; partial override = typo risk).
486
- const resolved = {
487
- upn: env.HEADER_AUTH_UPN_HEADER || DEFAULT_HEADER_NAMES.upn,
488
- email: env.HEADER_AUTH_EMAIL_HEADER || DEFAULT_HEADER_NAMES.email,
489
- displayName: env.HEADER_AUTH_DISPLAY_NAME_HEADER || DEFAULT_HEADER_NAMES.displayName
490
- };
491
- const overrides = [
492
- Boolean(env.HEADER_AUTH_UPN_HEADER),
493
- Boolean(env.HEADER_AUTH_EMAIL_HEADER),
494
- Boolean(env.HEADER_AUTH_DISPLAY_NAME_HEADER)
495
- ].filter(Boolean).length;
496
- const headerList = `UPN=${resolved.upn}, Email=${resolved.email}, DisplayName=${resolved.displayName}`;
497
- if (overrides === 0) {
498
- out.push(green(`header names (bundled defaults): ${headerList}`));
499
- } else if (overrides === 3) {
500
- out.push(green(`header names (all overridden): ${headerList}`));
501
- } else {
502
- out.push(
503
- yellow(
504
- `header names partially overridden (${overrides}/3 set): ${headerList} — ` +
505
- `a partial override is usually a typo. Set all three or none.`
506
- )
507
- );
508
- }
518
+ out.push(checkHeaderNames(env));
509
519
 
510
520
  return out;
511
521
  }
@@ -1,11 +1,6 @@
1
- // `selva init` — reconfigure an existing deployment.
2
- //
3
- // Differences from `create`:
4
- // • Reads current .env values; uses them as prompt defaults.
5
- // • Never regenerates SELVA_HMAC_KEY / SELVA_AT_REST_KEY if they're set.
6
- // Rotating those invalidates sessions and at-rest encryption — that's
7
- // `selva keys rotate`'s job, not init's.
8
- // • Doesn't touch package.json or run npm install.
1
+ // `selva init` — reconfigures an existing deployment. Unlike `create`, it reads
2
+ // the current .env as prompt defaults and never touches package.json or runs
3
+ // npm install.
9
4
 
10
5
  import { join } from 'node:path';
11
6
  import * as p from '@clack/prompts';
@@ -23,10 +18,10 @@ export async function runInit() {
23
18
  const current = readEnvFile(envPath);
24
19
  const values = await collectConfig({ defaults: current, mode: 'init' });
25
20
 
26
- // Preserve secrets. The only safe time to generate them is at install
27
- // time (no sessions to invalidate, no encrypted data to lose). If the
28
- // existing .env doesn't have them — which can happen if a previous
29
- // scaffold bailed mid-flight — generate now and warn.
21
+ // Never regenerate an existing key: rotating SELVA_HMAC_KEY or
22
+ // SELVA_AT_REST_KEY invalidates sessions and at-rest encryption — that's
23
+ // `selva keys rotate`'s job, not init's. Missing keys (a scaffold that
24
+ // bailed mid-flight) are the one case it's safe to generate here.
30
25
  if (
31
26
  current.SELVA_HMAC_KEY &&
32
27
  current.SELVA_HMAC_KEY !== 'replace-this-with-a-random-32-byte-hex-key'
@@ -47,7 +42,11 @@ export async function runInit() {
47
42
  p.log.warn('SELVA_AT_REST_KEY was missing — generated a fresh one.');
48
43
  }
49
44
 
50
- writeEnvFile(envPath, readEnvTemplate(dir), values);
45
+ // A values-only rewrite drops anything it isn't handed, and the prompts cover
46
+ // only the vars they ask about — every tuned knob the operator set by hand
47
+ // (BODY_SIZE_LIMIT, COMPUTE_*, LOG_LEVEL, …) lives outside that set. Carry
48
+ // the existing file forward and let the prompt answers win on top.
49
+ writeEnvFile(envPath, readEnvTemplate(dir), { ...current, ...values });
51
50
 
52
51
  p.outro(
53
52
  [