@selvajs/cli 4.20.0-beta.3 → 4.20.0-beta.30

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.20.0-beta.3",
3
+ "version": "4.20.0-beta.30",
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
  "author": "VektorNode",
@@ -41,11 +41,11 @@
41
41
  "picocolors": "^1.1.1"
42
42
  },
43
43
  "devDependencies": {
44
- "pm2": "7.0.3"
44
+ "pm2": "7.1.2"
45
45
  },
46
46
  "scripts": {
47
47
  "start": "node ./bin/cli.js",
48
48
  "selva": "node ./bin/selva.js",
49
- "test": "node --check ./bin/cli.js && node --check ./bin/selva.js && node --input-type=module -e \"for (const m of ['./src/cli.js','./src/prompts.js','./src/env.js','./src/paths.js','./src/secrets.js','./src/node-range.js','./src/deployment-package.js','./src/commands/create.js','./src/commands/init.js','./src/commands/doctor.js','./src/commands/pm2.js','./src/commands/migrate.js','./src/commands/keys.js']) { await import(m); }\" && node --test \"./src/__tests__/*.test.js\""
49
+ "test": "node --check ./bin/cli.js && node --check ./bin/selva.js && node --input-type=module -e \"for (const m of ['./src/cli.js','./src/prompts.js','./src/env.js','./src/paths.js','./src/secrets.js','./src/node-range.js','./src/deployment-package.js','./src/commands/create.js','./src/commands/init.js','./src/commands/doctor.js','./src/commands/pm2.js','./src/commands/migrate.js','./src/commands/keys.js','./src/commands/config.js','./src/env-migration.js']) { await import(m); }\" && node --test \"./src/__tests__/*.test.js\""
50
50
  }
51
51
  }
package/src/caddyfile.js CHANGED
@@ -45,11 +45,11 @@ export function renderCaddyfile({ domain, acmeEmail, port = 3000 }) {
45
45
  @api path /api/*
46
46
  header @api Cache-Control "no-cache, no-store, must-revalidate"
47
47
 
48
- # Must clear the app's own caps (COMPUTE_REQUEST_MAX_BYTES / BODY_SIZE_LIMIT,
49
- # both 256 MB) — Caddy rejects first, so a lower value here silently caps
50
- # every upload no matter what .env says.
48
+ # Must clear the app's own caps (the solve request cap and BODY_SIZE_LIMIT,
49
+ # both 256 MiB) — Caddy rejects first, so a lower value here silently caps
50
+ # every upload no matter what .env says. Write MiB: Caddy's "mb" is 10^6 bytes.
51
51
  request_body {
52
- max_size 256mb
52
+ max_size 256MiB
53
53
  }
54
54
 
55
55
  log {
@@ -10,7 +10,8 @@
10
10
  // it. `__tests__/doctor-config-parity.test.js` asserts both against one fixture
11
11
  // table, so a rule changed here and not there fails CI.
12
12
 
13
- import { RENAMED_ENV_VARS, REPLACED_ENV_VARS } from '../env.js';
13
+ import { RENAMED_ENV_VARS, RETIRED_ENV_VARS } from '../env.js';
14
+ import { keyRotationStatus } from '../generated/config.js';
14
15
  import { green, red, yellow } from './result.js';
15
16
 
16
17
  const HEX_64 = /^[0-9a-f]{64}$/i;
@@ -28,10 +29,33 @@ const VALID_DATA = ['local', 'supabase'];
28
29
  export function checkSecret(value, label) {
29
30
  if (!value) return red(`${label} — unset`);
30
31
  if (value === PLACEHOLDER) return red(`${label} — still the placeholder`);
31
- if (!HEX_64.test(value)) return red(`${label} — not 64 hex chars`);
32
+ if (!HEX_64.test(value) && !isBase64Of32Bytes(value)) {
33
+ return red(`${label} — not 32 bytes as 64 hex chars or base64`);
34
+ }
32
35
  return green(label);
33
36
  }
34
37
 
38
+ /**
39
+ * SELVA_HMAC_KEY takes any string of 32+ characters (the token codec's rule),
40
+ * not only hex: reding a valid key would send an operator rotating it.
41
+ */
42
+ export function checkHmacKey(value) {
43
+ const label = 'SELVA_HMAC_KEY is at least 32 characters';
44
+ if (!value) return red(`${label} — unset`);
45
+ if (value === PLACEHOLDER) return red(`${label} — still the placeholder`);
46
+ if (value.length < 32) return red(`${label} — only ${value.length}`);
47
+ return green(label);
48
+ }
49
+
50
+ // Mirrors decodeSecretKey in @selvajs/platform: only a round trip proves the
51
+ // whole string was base64, since Buffer.from skips what it can't decode.
52
+ function isBase64Of32Bytes(value) {
53
+ const buf = Buffer.from(value, 'base64');
54
+ return (
55
+ buf.length === 32 && buf.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
56
+ );
57
+ }
58
+
35
59
  // Lowercased and defaulted the same way create-selva-providers.ts defaults them.
36
60
  export function resolveProviders(env) {
37
61
  return {
@@ -197,10 +221,10 @@ export function parseBodySizeLimit(raw) {
197
221
  return bytes;
198
222
  }
199
223
 
200
- // The app's own default for COMPUTE_REQUEST_MAX_BYTES (packages/server
224
+ // The app's solve request cap (COMPUTE_REQUEST_MAX_BYTES in packages/server
201
225
  // compute/limits.ts). Duplicated rather than imported: the CLI installs the
202
226
  // runtime and cannot import from it.
203
- const COMPUTE_REQUEST_DEFAULT_BYTES = 256 * 1024 * 1024;
227
+ const COMPUTE_REQUEST_MAX_BYTES = 256 * 1024 * 1024;
204
228
 
205
229
  /**
206
230
  * BODY_SIZE_LIMIT has to clear the compute request cap, or adapter-node's global
@@ -210,7 +234,7 @@ const COMPUTE_REQUEST_DEFAULT_BYTES = 256 * 1024 * 1024;
210
234
  */
211
235
  export function checkBodySizeLimit(env) {
212
236
  const raw = env.BODY_SIZE_LIMIT;
213
- const requestCap = Number(env.COMPUTE_REQUEST_MAX_BYTES) || COMPUTE_REQUEST_DEFAULT_BYTES;
237
+ const requestCap = COMPUTE_REQUEST_MAX_BYTES;
214
238
  const asMb = (bytes) => `${Math.round(bytes / (1024 * 1024))} MB`;
215
239
 
216
240
  if (raw == null || raw === '') {
@@ -231,9 +255,9 @@ export function checkBodySizeLimit(env) {
231
255
 
232
256
  if (bytes < requestCap) {
233
257
  return yellow(
234
- `BODY_SIZE_LIMIT=${raw} (${asMb(bytes)}) is below COMPUTE_REQUEST_MAX_BYTES ` +
235
- `(${asMb(requestCap)}) — the global cap rejects first, making the compute cap ` +
236
- `dead config. Raise it to at least ${asMb(requestCap)}.`
258
+ `BODY_SIZE_LIMIT=${raw} (${asMb(bytes)}) is below the ` +
259
+ `${asMb(requestCap)} a solve may send — larger solves fail with 413 before Selva ` +
260
+ `sees them. Raise it to at least ${asMb(requestCap)}.`
237
261
  );
238
262
  }
239
263
 
@@ -291,12 +315,23 @@ export function checkClientAddress(env) {
291
315
  );
292
316
  }
293
317
 
318
+ // A trusted address header is only safe if every request passes through the
319
+ // proxy: on a public bind a direct caller sets the header itself and picks
320
+ // its own rate-limit bucket.
321
+ const host = env.HOST ?? '0.0.0.0';
322
+ if (!['127.0.0.1', 'localhost', '::1'].includes(host)) {
323
+ return yellow(
324
+ `ADDRESS_HEADER=${header} is trusted but HOST=${host} leaves the app reachable ` +
325
+ `without the proxy, so a direct caller can set the header and pick its own ` +
326
+ `rate-limit bucket. Set HOST=127.0.0.1 and keep port 3000 closed in the ` +
327
+ `firewall (ignore this only if the proxy runs on another host behind network isolation).`
328
+ );
329
+ }
330
+
294
331
  return green(`ADDRESS_HEADER=${header}${depth ? ` (XFF_DEPTH=${depth})` : ''}`);
295
332
  }
296
333
 
297
- // A rename is auto-fixable (`selva migrate` rewrites the key). A replacement
298
- // encodes a value in the new name, so migrate leaves it alone rather than
299
- // guessing — it's reported here and nowhere else.
334
+ // Both are auto-fixable: `selva migrate` renames old names and removes retired keys.
300
335
  export function checkDeprecatedEnv(env) {
301
336
  const checks = [];
302
337
 
@@ -309,10 +344,40 @@ export function checkDeprecatedEnv(env) {
309
344
  );
310
345
  }
311
346
 
312
- for (const [oldName, replacement] of Object.entries(REPLACED_ENV_VARS)) {
313
- if (env[oldName] === undefined) continue;
314
- checks.push(yellow(`${oldName} is deprecated — replace it with ${replacement}`));
347
+ for (const [key, now] of Object.entries(RETIRED_ENV_VARS)) {
348
+ if (env[key] === undefined) continue;
349
+ checks.push(yellow(`${key} is no longer read (${now}) — \`selva migrate\` removes it`));
315
350
  }
316
351
 
317
352
  return checks;
318
353
  }
354
+
355
+ /** Where each root key's rotation overlap stands. Nothing while no rotation is in progress. */
356
+ export function checkKeyRotation(env, now = Date.now()) {
357
+ return keyRotationStatus(env, now).flatMap(({ key, state, expires, daysLeft }) => {
358
+ const previous = `${key}_PREVIOUS`;
359
+ const target = key === 'SELVA_HMAC_KEY' ? 'hmac' : 'at-rest';
360
+ if (state === 'active') {
361
+ return [
362
+ green(`${previous} accepted for ${daysLeft} more days (until ${expires.slice(0, 10)})`)
363
+ ];
364
+ }
365
+ if (state === 'expired') {
366
+ return [
367
+ yellow(
368
+ `${previous} passed its deadline (${expires}) and is no longer accepted. ` +
369
+ `Remove it: npx selva keys rotate ${target} --finish`
370
+ )
371
+ ];
372
+ }
373
+ if (state === 'no-deadline') {
374
+ return [
375
+ yellow(
376
+ `${previous} has no deadline, so the old key is accepted for good. Set ` +
377
+ `${previous}_EXPIRES, or remove it: npx selva keys rotate ${target} --finish`
378
+ )
379
+ ];
380
+ }
381
+ return [];
382
+ });
383
+ }
@@ -0,0 +1,156 @@
1
+ // Supabase schema head: what the installed runtime expects, what the installed
2
+ // provider can migrate to, and what the database is at. These are three
3
+ // separate numbers. The runtime bundles the provider's code but not its SQL,
4
+ // so `selva update` can move the runtime past a provider install that stays
5
+ // pinned. Comparing the database against the provider then reports a match
6
+ // while the app degrades /api/health to 503.
7
+
8
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { green, yellow, red } from './result.js';
11
+
12
+ const HEAD_RE = /^\d{14}$/;
13
+
14
+ /** Head the installed @selvajs/selva was built against, or null for a runtime that predates runtime-meta.json. */
15
+ export function readRuntimeMigrationHead(dir) {
16
+ try {
17
+ const meta = JSON.parse(
18
+ readFileSync(
19
+ join(dir, 'node_modules', '@selvajs', 'selva', 'build', 'runtime-meta.json'),
20
+ 'utf8'
21
+ )
22
+ );
23
+ return HEAD_RE.test(meta?.supabaseMigrationHead) ? meta.supabaseMigrationHead : null;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ /** Newest migration the installed @selvajs/supabase-provider ships, or null when it isn't installed. */
30
+ export function readProviderMigrationHead(dir) {
31
+ const migrationsDir = join(
32
+ dir,
33
+ 'node_modules',
34
+ '@selvajs',
35
+ 'supabase-provider',
36
+ 'supabase',
37
+ 'migrations'
38
+ );
39
+ if (!existsSync(migrationsDir)) return null;
40
+ return (
41
+ readdirSync(migrationsDir)
42
+ .filter((f) => /^\d{14}_.+\.sql$/.test(f))
43
+ .map((f) => f.slice(0, 14))
44
+ .sort()
45
+ .pop() ?? null
46
+ );
47
+ }
48
+
49
+ /** The npm dist-tag matching the installed runtime: a pre-release runtime needs the provider's beta migrations. */
50
+ export function providerTagFor(runtimeVersion) {
51
+ return runtimeVersion?.includes('-') ? 'beta' : 'latest';
52
+ }
53
+
54
+ /**
55
+ * `{ head }` on success, `{ missing: true }` when selva.migration_head() doesn't
56
+ * exist yet, `{ error }` when the database couldn't be asked.
57
+ */
58
+ export async function fetchDatabaseMigrationHead(env) {
59
+ try {
60
+ const controller = new AbortController();
61
+ const timer = setTimeout(() => controller.abort(), 4000);
62
+ const res = await fetch(env.SUPABASE_URL + '/rest/v1/rpc/migration_head', {
63
+ method: 'POST',
64
+ headers: {
65
+ apikey: env.SUPABASE_SERVICE_ROLE_KEY,
66
+ Authorization: `Bearer ${env.SUPABASE_SERVICE_ROLE_KEY}`,
67
+ 'Content-Type': 'application/json',
68
+ // The function lives in the selva schema the app is pinned to.
69
+ 'Content-Profile': 'selva'
70
+ },
71
+ body: '{}',
72
+ signal: controller.signal
73
+ });
74
+ clearTimeout(timer);
75
+ if (res.status === 404) return { missing: true };
76
+ if (!res.ok) return { error: `migration_head RPC responded ${res.status}` };
77
+ return { head: String((await res.json()) ?? '') };
78
+ } catch (err) {
79
+ return { error: err.message ?? String(err) };
80
+ }
81
+ }
82
+
83
+ function migrateSteps(tag, providerBehind) {
84
+ return [
85
+ ...(providerBehind ? [`npm install --prefer-online @selvajs/supabase-provider@${tag}`] : []),
86
+ 'npx selva-supabase',
87
+ 'npx supabase db push'
88
+ ].join(' && ');
89
+ }
90
+
91
+ /**
92
+ * Pure verdict, so the three-way comparison is testable without disk or network.
93
+ * `runtime` is null for an old runtime; the provider head is then the best guess.
94
+ * String comparison is safe: every head is a fixed-width timestamp.
95
+ */
96
+ export function judgeMigrationHeads({ runtime, provider, database, tag }) {
97
+ const expected = runtime ?? provider;
98
+ if (!expected) {
99
+ return red(
100
+ '@selvajs/supabase-provider is not installed, so the migration SQL is missing. ' +
101
+ 'Run `selva migrate` to add it, then `npm install`.'
102
+ );
103
+ }
104
+ const providerBehind = !provider || provider < expected;
105
+ const steps = migrateSteps(tag, providerBehind);
106
+ const providerNote =
107
+ runtime && providerBehind
108
+ ? ` The installed @selvajs/supabase-provider only reaches ${provider ?? '(none)'}, so syncing it copies nothing new.`
109
+ : '';
110
+
111
+ if (database.error) return yellow(`Migration-head check skipped (${database.error})`);
112
+ if (database.missing) {
113
+ return red(
114
+ `Database is missing selva.migration_head(): migrations are pending (expected head ${expected}).${providerNote} Run: ${steps}`
115
+ );
116
+ }
117
+ if (!database.head) {
118
+ return red(
119
+ `Database reports no applied migrations (expected head ${expected}).${providerNote} Run: ${steps}`
120
+ );
121
+ }
122
+ if (database.head < expected) {
123
+ const against = runtime ? "the app's expected" : 'the installed provider';
124
+ return red(
125
+ `Database migration head ${database.head} is behind ${against} ${expected}. ` +
126
+ `/api/health returns 503 and writes that need the newer schema fail.${providerNote} Run: ${steps}`
127
+ );
128
+ }
129
+ if (runtime && providerBehind) {
130
+ // The database is fine today; the next upgrade's sync would silently copy nothing.
131
+ return yellow(
132
+ `Database migration head ${database.head} matches the app, but @selvajs/supabase-provider ` +
133
+ `is at ${provider ?? '(none)'}. Run: npm install --prefer-online @selvajs/supabase-provider@${tag}`
134
+ );
135
+ }
136
+ return green(
137
+ `Database migration head ${database.head} matches the ${runtime ? 'app' : 'installed provider'}`
138
+ );
139
+ }
140
+
141
+ export async function checkSupabaseMigrations(dir, env, runtimeVersion) {
142
+ if (!env.SUPABASE_URL || !env.SUPABASE_SERVICE_ROLE_KEY) {
143
+ return yellow('Migration-head check skipped: Supabase env incomplete');
144
+ }
145
+ const runtime = readRuntimeMigrationHead(dir);
146
+ const provider = readProviderMigrationHead(dir);
147
+ if (!runtime && !provider) {
148
+ return judgeMigrationHeads({ runtime, provider, database: {}, tag: 'latest' });
149
+ }
150
+ return judgeMigrationHeads({
151
+ runtime,
152
+ provider,
153
+ database: await fetchDatabaseMigrationHead(env),
154
+ tag: providerTagFor(runtimeVersion)
155
+ });
156
+ }
@@ -17,6 +17,7 @@ import { join } from 'node:path';
17
17
  import { homedir } from 'node:os';
18
18
  import { fixable, green, red, yellow } from './result.js';
19
19
  import { PM2_VERSION } from '../deployment-package.js';
20
+ import { APP_NAME } from '../commands/pm2.js';
20
21
 
21
22
  /** The real OS. Tests pass a stand-in with the same shape. */
22
23
  export const runtimeEnv = {
@@ -245,7 +246,12 @@ function checkPm2Daemon(dir, env) {
245
246
  const output = String(probe.stdout ?? '') + String(probe.stderr ?? '');
246
247
 
247
248
  if (!/out-of-date/i.test(output)) {
248
- if (/pong/i.test(output)) return [green('pm2 daemon responds and matches the local pm2')];
249
+ if (/pong/i.test(output)) {
250
+ return [
251
+ green('pm2 daemon responds and matches the local pm2'),
252
+ checkAppProcess(bin, dir, env)
253
+ ];
254
+ }
249
255
  return [yellow('pm2 daemon did not answer `pm2 ping` — check `npx pm2 ping` by hand')];
250
256
  }
251
257
 
@@ -267,10 +273,37 @@ function checkPm2Daemon(dir, env) {
267
273
  yellow(
268
274
  `pm2 daemon (v${daemon ?? 'unknown'}) differs from the local pm2 (v${local ?? PM2_VERSION}).\n ` +
269
275
  'Resync it — this briefly restarts managed processes: npx pm2 update'
270
- )
276
+ ),
277
+ checkAppProcess(bin, dir, env)
271
278
  ];
272
279
  }
273
280
 
281
+ // A live daemon says nothing about the app: an interrupted `pm2 update` leaves
282
+ // the daemon up with an empty process table, and every other check still
283
+ // passes. Only called once the daemon is confirmed alive, so `jlist` can't
284
+ // spawn one.
285
+ function checkAppProcess(bin, dir, env) {
286
+ const r = env.run(bin, ['jlist'], { cwd: dir });
287
+ // A skewed daemon prints its out-of-date banner ahead of the JSON.
288
+ const json = String(r.stdout ?? '')
289
+ .split('\n')
290
+ .find((line) => line.trimStart().startsWith('['));
291
+ let list;
292
+ try {
293
+ list = JSON.parse(json ?? '');
294
+ } catch {
295
+ return yellow(`could not read the pm2 process list — check \`npx pm2 list\` by hand`);
296
+ }
297
+ const app = list.find((proc) => proc.name === APP_NAME);
298
+ const status = app?.pm2_env?.status;
299
+ if (status === 'online') return green(`${APP_NAME} is online`);
300
+ return red(
301
+ `${APP_NAME} is ${app ? `'${status}'` : 'not registered with pm2'} — the app is down.\n ` +
302
+ 'Start it, then save the list so a reboot brings it back:\n ' +
303
+ 'npx selva start && npx pm2 save'
304
+ );
305
+ }
306
+
274
307
  // ── pm2 log rotation ────────────────────────────────────────────────────
275
308
 
276
309
  // pm2 appends to `$PM2_HOME/logs/*.log` forever. Nothing in a default install
@@ -352,7 +385,6 @@ function installLogRotate(bin, dir, env) {
352
385
  }
353
386
 
354
387
  function parseSkew(output) {
355
- // eslint-disable-next-line no-control-regex -- pm2 colourises via chalk on a TTY
356
388
  const clean = output.replace(/\[[0-9;]*m/g, '');
357
389
  return {
358
390
  daemon: /In memory PM2 version:\s*v?(\d+\.\d+\.\d+)/i.exec(clean)?.[1] ?? null,
package/src/cli.js CHANGED
@@ -16,7 +16,8 @@ const COMMANDS = {
16
16
  update: () => import('./commands/pm2.js').then((m) => m.runUpdate),
17
17
  migrate: () => import('./commands/migrate.js').then((m) => m.runMigrate),
18
18
  'setup-proxy': () => import('./commands/proxy.js').then((m) => m.runSetupProxy),
19
- keys: () => import('./commands/keys.js').then((m) => keysDispatch(m))
19
+ keys: () => import('./commands/keys.js').then((m) => keysDispatch(m)),
20
+ config: () => import('./commands/config.js').then((m) => m.runConfig)
20
21
  };
21
22
 
22
23
  // `selva doctor --help` used to run the full doctor: commands own their argv,
@@ -49,9 +50,13 @@ const USAGE = {
49
50
  'npm update @selvajs/cli + @selvajs/selva, then restart. Rolls the\ndependency tree back if the install fails.'
50
51
  },
51
52
  migrate: {
52
- usage: 'selva migrate',
53
+ usage: 'selva migrate [--dry-run | --undo]',
53
54
  blurb:
54
- 'Bring package.json and the deployment layout onto the current release.\nRewrites keys, never comments.'
55
+ 'Bring package.json, the deployment layout and .env onto the current release.\nRenames old keys and proves every setting keeps its meaning before writing.',
56
+ flags: [
57
+ ['--dry-run', 'Show what would change. Writes nothing.'],
58
+ ['--undo', 'Restore the newest .env backup.']
59
+ ]
55
60
  },
56
61
  'setup-proxy': {
57
62
  usage: 'selva setup-proxy [--domain <fqdn>] [--acme-email <addr>]',
@@ -62,10 +67,20 @@ const USAGE = {
62
67
  ['--acme-email', "Address Let's Encrypt sends expiry notices to."]
63
68
  ]
64
69
  },
70
+ config: {
71
+ usage: 'selva config <show [--all] | set KEY=VALUE | unset KEY>',
72
+ blurb:
73
+ 'Show what .env sets, what is default and what is wrong; set or unset one\nkey after checking it. Edits only that line and keeps a backup.',
74
+ flags: [['--all', 'show: list every setting, defaults included.']]
75
+ },
65
76
  keys: {
66
- usage: 'selva keys rotate <hmac|at-rest>',
77
+ usage: 'selva keys rotate <hmac|at-rest> [--finish | --leaked]',
67
78
  blurb:
68
- 'Rotate one secret in .env. Destructive and not reversible — the command\nspells out the blast radius and asks before writing.'
79
+ 'Rotate one secret in .env. The old key stays accepted as <KEY>_PREVIOUS, so\nnothing breaks; --finish drops it later. --leaked drops it at once.',
80
+ flags: [
81
+ ['--finish', 'Remove <KEY>_PREVIOUS once the overlap has passed.'],
82
+ ['--leaked', 'Replace the key and drop the old one now. Breaks what it signed.']
83
+ ]
69
84
  }
70
85
  };
71
86
 
@@ -85,7 +100,7 @@ function keysDispatch(m) {
85
100
  return async (argv) => {
86
101
  const sub = argv[0];
87
102
  if (sub === 'rotate') return m.runKeysRotate(argv.slice(1));
88
- console.error(`Usage: selva keys rotate <hmac|at-rest>`);
103
+ console.error(`Usage: selva keys rotate <hmac|at-rest> [--finish | --leaked]`);
89
104
  process.exit(1);
90
105
  };
91
106
  }
@@ -140,7 +155,8 @@ function printHelp() {
140
155
  ' update npm update @selvajs/cli + @selvajs/selva, then restart',
141
156
  ' migrate Bring package.json onto the current layout',
142
157
  ' setup-proxy Install + configure Caddy with TLS for this deployment',
143
- ' keys rotate <hmac|at-rest> Rotate a secret in .env (destructive)',
158
+ ' config show|set|unset What .env sets; change one key safely',
159
+ ' keys rotate <hmac|at-rest> Rotate a secret; the old one keeps working',
144
160
  '',
145
161
  pc.dim('`npx selva <command> --help` explains one command.'),
146
162
  '',
@@ -0,0 +1,196 @@
1
+ // `selva config show|set|unset`: what this deployment's .env sets, checked
2
+ // against the manifest the runtime declares.
3
+
4
+ import { copyFileSync, existsSync, readFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import pc from 'picocolors';
7
+ import { parseEnv, setEnvValues, writeEnvText } from '../env.js';
8
+ import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
9
+ import {
10
+ ALL_SETTINGS,
11
+ deploymentReport,
12
+ deploymentSnapshot,
13
+ diffSnapshots,
14
+ resolveConfig
15
+ } from '../generated/config.js';
16
+ import { backupPathFor, backupStamp } from './migrate.js';
17
+
18
+ export const SNAPSHOT_FILE = '.selva-config-snapshot.json';
19
+
20
+ /** The same report the app builds for /admin/system. */
21
+ export const reportFor = deploymentReport;
22
+
23
+ /**
24
+ * How `.env` differs from what the app last booted with. Non-empty means it
25
+ * was edited without a restart, or something (pm2's saved env, compose)
26
+ * overrides it.
27
+ */
28
+ export function driftFromLastBoot(dir, env) {
29
+ const path = join(dir, SNAPSHOT_FILE);
30
+ if (!existsSync(path)) return null;
31
+ let snapshot;
32
+ try {
33
+ snapshot = JSON.parse(readFileSync(path, 'utf8'));
34
+ } catch {
35
+ return null;
36
+ }
37
+ return diffSnapshots(snapshot, deploymentSnapshot(env, snapshot.salt));
38
+ }
39
+
40
+ /** Validate one KEY=VALUE against the manifest. Returns the spec, or an error message. */
41
+ export function checkSetting(key, value) {
42
+ const spec = ALL_SETTINGS.find((s) => s.key === key);
43
+ if (!spec) {
44
+ const alias = ALL_SETTINGS.find((s) => s.aliases?.includes(key));
45
+ return {
46
+ error: alias
47
+ ? `${key} was renamed to ${alias.key}.`
48
+ : `${key} is not a setting Selva reads. \`selva config show --all\` lists them.`
49
+ };
50
+ }
51
+ // Setting a key by hand skips _PREVIOUS and breaks what it protected.
52
+ if (spec.class === 'secret' && spec.generated) {
53
+ const target = key.startsWith('SELVA_HMAC_KEY') ? 'hmac' : 'at-rest';
54
+ return {
55
+ error: `${key} is replaced with \`selva keys rotate ${target}\`, which keeps the old key working.`
56
+ };
57
+ }
58
+ const report = resolveConfig([spec], { [key]: value }).keys[key];
59
+ if (report.status !== 'ok') return { error: `${key} ${report.problem}.` };
60
+ return { spec };
61
+ }
62
+
63
+ function printRow(row) {
64
+ const value = row.display === '' ? pc.dim('—') : row.display;
65
+ const source =
66
+ row.source === 'default'
67
+ ? pc.dim('default')
68
+ : row.source === 'unset'
69
+ ? pc.dim('not set')
70
+ : row.source === 'alias'
71
+ ? pc.yellow(`from ${row.alias}`)
72
+ : '.env';
73
+ const mark =
74
+ row.status === 'error' ? pc.red('✗') : row.status === 'invalid' ? pc.yellow('!') : ' ';
75
+ console.log(`${mark} ${row.key.padEnd(36)} ${String(value).padEnd(30)} ${source}`);
76
+ if (row.problem) console.log(` ${pc.red(`value ${row.problem}`)}`);
77
+ }
78
+
79
+ export async function runConfig(argv) {
80
+ const [sub, ...rest] = argv;
81
+ if (sub === 'show' || sub === undefined) return runShow(rest);
82
+ if (sub === 'set') return runSet(rest);
83
+ if (sub === 'unset') return runUnset(rest);
84
+ console.error('Usage: selva config <show [--all] | set KEY=VALUE | unset KEY>');
85
+ process.exit(1);
86
+ }
87
+
88
+ function readDeploymentEnv() {
89
+ const dir = resolveDeploymentDir();
90
+ requireDeploymentDir(dir);
91
+ const envPath = join(dir, '.env');
92
+ const text = existsSync(envPath) ? readFileSync(envPath, 'utf8') : '';
93
+ return { dir, envPath, text, env: parseEnv(text) };
94
+ }
95
+
96
+ function runShow(argv) {
97
+ const all = argv.includes('--all');
98
+ const { dir, env } = readDeploymentEnv();
99
+ const report = reportFor(env);
100
+
101
+ console.log(pc.bold('\nThe important ones'));
102
+ for (const row of report.rows.filter((r) => r.summary)) printRow(row);
103
+
104
+ const rest = report.rows.filter(
105
+ (r) => !r.summary && (all || r.source !== 'default') && r.source !== 'unset'
106
+ );
107
+ if (rest.length > 0) {
108
+ console.log(pc.bold(all ? '\nEverything else' : '\nAlso set in .env'));
109
+ for (const row of rest) printRow(row);
110
+ }
111
+
112
+ if (report.unknown.length > 0) {
113
+ console.log(pc.bold('\nNot settings Selva reads'));
114
+ for (const u of report.unknown) {
115
+ console.log(
116
+ `${pc.yellow('!')} ${u.key}${u.suggestion ? pc.dim(` did you mean ${u.suggestion}?`) : ''}`
117
+ );
118
+ }
119
+ }
120
+
121
+ const drift = driftFromLastBoot(dir, env);
122
+ if (drift && drift.length > 0) {
123
+ console.log(pc.bold('\nDiffers from what the app last started with'));
124
+ console.log(
125
+ pc.dim(' Restart to apply, or check whether pm2 or the environment overrides .env.')
126
+ );
127
+ for (const c of drift) {
128
+ const detail =
129
+ c.from !== undefined || c.to !== undefined ? ` ${c.from ?? '—'} → ${c.to ?? '—'}` : '';
130
+ console.log(` ${c.key} ${pc.dim(c.change)}${detail}`);
131
+ }
132
+ }
133
+
134
+ const errors = report.problems.filter((p) => p.status === 'error').length;
135
+ console.log(
136
+ '\n' +
137
+ (errors > 0
138
+ ? pc.red(`${errors} setting${errors === 1 ? '' : 's'} would stop the app from starting.`)
139
+ : pc.green('Nothing stops the app from starting.')) +
140
+ (all ? '' : pc.dim(' `selva config show --all` lists every setting.'))
141
+ );
142
+ if (errors > 0) process.exit(1);
143
+ }
144
+
145
+ function backUp(envPath) {
146
+ if (!existsSync(envPath)) return null;
147
+ const bak = backupPathFor(envPath, backupStamp());
148
+ copyFileSync(envPath, bak);
149
+ writeEnvText(bak, readFileSync(bak, 'utf8'));
150
+ return bak;
151
+ }
152
+
153
+ function runSet(argv) {
154
+ const arg = argv[0] ?? '';
155
+ const eq = arg.indexOf('=');
156
+ if (eq <= 0) {
157
+ console.error('Usage: selva config set KEY=VALUE');
158
+ process.exit(1);
159
+ }
160
+ const key = arg.slice(0, eq);
161
+ const value = arg.slice(eq + 1);
162
+ const { spec, error } = checkSetting(key, value);
163
+ if (error) {
164
+ console.error(pc.red(`✗ ${error}`));
165
+ process.exit(1);
166
+ }
167
+ const { envPath, text } = readDeploymentEnv();
168
+ if (spec.impact) console.log(pc.yellow(`! ${spec.impact}`));
169
+ const bak = backUp(envPath);
170
+ writeEnvText(envPath, setEnvValues(text, { [key]: value }));
171
+ console.log(pc.green(`✓ ${key} set.`) + (bak ? pc.dim(` Backup: ${bak}`) : ''));
172
+ console.log(pc.dim('Restart to apply: ') + pc.cyan('selva restart'));
173
+ }
174
+
175
+ function runUnset(argv) {
176
+ const key = argv[0];
177
+ if (!key) {
178
+ console.error('Usage: selva config unset KEY');
179
+ process.exit(1);
180
+ }
181
+ const { envPath, text, env } = readDeploymentEnv();
182
+ if (!(key in env)) {
183
+ console.log(pc.dim(`${key} is not set in .env.`));
184
+ return;
185
+ }
186
+ const spec = ALL_SETTINGS.find((s) => s.key === key);
187
+ if (spec?.required) {
188
+ console.error(pc.red(`✗ ${key} is required; the app would not start without it.`));
189
+ process.exit(1);
190
+ }
191
+ if (spec?.impact) console.log(pc.yellow(`! ${spec.impact}`));
192
+ const bak = backUp(envPath);
193
+ writeEnvText(envPath, setEnvValues(text, { [key]: undefined }));
194
+ console.log(pc.green(`✓ ${key} removed.`) + (bak ? pc.dim(` Backup: ${bak}`) : ''));
195
+ console.log(pc.dim('Restart to apply: ') + pc.cyan('selva restart'));
196
+ }