@selvajs/cli 4.7.4-beta.1 → 4.8.0-beta.11

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