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

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,44 @@
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 — and only those needing no root and
6
+ // no runtime restart. 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 {
27
+ checkDeprecatedEnv,
28
+ checkOrigin,
29
+ checkProviders,
30
+ checkSecret,
31
+ checkTenancy,
32
+ resolveProviders
33
+ } from '../checks/config.js';
34
+ import { detectDrift, NODE_MODULES_STASH } from './migrate.js';
35
+
36
+ export async function runDoctor(argv = []) {
37
+ const fix = argv.includes('--fix');
18
38
  const dir = resolveDeploymentDir();
19
39
  requireDeploymentDir(dir);
20
40
 
21
- p.intro(pc.bgCyan(pc.black(' selva doctor ')));
41
+ p.intro(pc.bgCyan(pc.black(fix ? ' selva doctor --fix ' : ' selva doctor ')));
22
42
 
23
43
  const checks = [];
24
44
  const env = readEnvFile(join(dir, '.env'));
@@ -26,28 +46,12 @@ export async function runDoctor() {
26
46
  checks.push(checkFile(join(dir, '.env'), '.env present'));
27
47
  checks.push(checkFile(join(dir, 'ecosystem.config.cjs'), 'ecosystem.config.cjs present'));
28
48
  checks.push(checkLayoutDrift(dir));
49
+ checks.push(checkMigrationLeftovers(dir));
29
50
  checks.push(checkSecret(env.SELVA_HMAC_KEY, 'SELVA_HMAC_KEY is a 32-byte hex string'));
30
51
  checks.push(checkSecret(env.SELVA_AT_REST_KEY, 'SELVA_AT_REST_KEY is a 32-byte hex string'));
31
52
 
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
- }
53
+ const providers = resolveProviders(env);
54
+ checks.push(...checkProviders(providers));
51
55
 
52
56
  const used = new Set(Object.values(providers));
53
57
 
@@ -64,34 +68,35 @@ export async function runDoctor() {
64
68
  checks.push(...checkHeaderAuth(dir, env, providers.data));
65
69
  }
66
70
 
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
- }
71
+ checks.push(checkTenancy(env));
73
72
 
74
73
  checks.push(checkPackage(dir, '@selvajs/selva'));
74
+ checks.push(checkNodeEngine(dir));
75
75
  checks.push(checkCliRuntimeAlignment(dir));
76
76
  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
- }
77
+ checks.push(checkOrigin(env));
78
+ checks.push(...checkDeprecatedEnv(env));
87
79
 
88
80
  // ── Render ─────────────────────────────────────────────────────────
81
+ const resolved = await Promise.all(checks);
89
82
  let failures = 0;
90
- for (const c of await Promise.all(checks)) {
83
+ for (const c of resolved) {
91
84
  console.log(' ' + c.line);
92
85
  if (c.severity === 'red') failures += 1;
93
86
  }
94
87
 
88
+ if (fix) {
89
+ failures = await applyFixes(resolved, failures);
90
+ } else {
91
+ const repairable = resolved.filter((c) => c.fix).length;
92
+ if (repairable > 0) {
93
+ p.log.info(
94
+ `${repairable} issue${repairable === 1 ? '' : 's'} can be repaired automatically — ` +
95
+ `re-run with \`selva doctor --fix\`.`
96
+ );
97
+ }
98
+ }
99
+
95
100
  if (failures === 0) {
96
101
  p.outro(pc.green('All checks passed.'));
97
102
  } else {
@@ -100,27 +105,50 @@ export async function runDoctor() {
100
105
  }
101
106
  }
102
107
 
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}` };
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
+ */
117
+ async function applyFixes(resolved, failures) {
118
+ const repairs = resolved.filter((c) => c.fix && c.severity !== 'green');
119
+ if (repairs.length === 0) {
120
+ p.log.info('Nothing to repair automatically.');
121
+ return failures;
122
+ }
123
+
124
+ console.log('');
125
+ p.log.step(`${repairs.length} repair${repairs.length === 1 ? '' : 's'} available`);
126
+
127
+ for (const check of repairs) {
128
+ const approved = await p.confirm({
129
+ message: check.fix.label,
130
+ initialValue: false
131
+ });
132
+ if (p.isCancel(approved) || !approved) {
133
+ p.log.info('Skipped.');
134
+ continue;
135
+ }
136
+ let result;
137
+ try {
138
+ result = await check.fix.run();
139
+ } catch (err) {
140
+ result = red(`repair threw: ${err instanceof Error ? err.message : String(err)}`);
141
+ }
142
+ console.log(' ' + result.line);
143
+ if (result.severity === 'green' && check.severity === 'red') failures -= 1;
144
+ }
145
+ return failures;
111
146
  }
112
147
 
113
148
  function checkFile(path, label) {
114
149
  return existsSync(path) ? green(label) : red(`${label} (missing: ${path})`);
115
150
  }
116
151
 
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
152
  function checkDataPath(dir, dataPath) {
125
153
  const absolute = resolve(dir, dataPath);
126
154
  try {
@@ -297,94 +325,65 @@ function checkCliRuntimeAlignment(dir) {
297
325
  }
298
326
 
299
327
  // 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
- )
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
+ */
337
+ function checkNodeEngine(dir) {
338
+ let required;
339
+ try {
340
+ const pkg = JSON.parse(
341
+ readFileSync(join(dir, 'node_modules', '@selvajs', 'selva', 'package.json'), 'utf8')
319
342
  );
343
+ required = pkg.engines?.node;
344
+ } catch {
345
+ return yellow('@selvajs/selva engines.node — package.json unreadable, skipped');
320
346
  }
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
- );
347
+ if (typeof required !== 'string') {
348
+ return yellow('@selvajs/selva declares no engines.node — cannot verify this host');
361
349
  }
362
350
 
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
- );
351
+ const running = process.versions.node;
352
+ const ok = satisfiesNodeRange(running, required);
353
+ if (ok === null) {
354
+ return yellow(`engines.node "${required}" not understood — verify Node v${running} manually`);
373
355
  }
356
+ if (ok) return green(`Node v${running} satisfies engines.node ${required}`);
374
357
 
375
- return out;
358
+ return red(
359
+ `Node v${running} does NOT satisfy @selvajs/selva's engines.node ${required}.\n ` +
360
+ `npm installs it anyway and the health check still passes, so this fails only\n ` +
361
+ `under real traffic. Upgrade Node on this host (nvm/fnm or your package manager),\n ` +
362
+ `then: npm rebuild && npm run restart`
363
+ );
376
364
  }
377
365
 
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;
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
+ */
372
+ function checkMigrationLeftovers(dir) {
373
+ const stash = join(dir, NODE_MODULES_STASH);
374
+ if (!existsSync(stash)) return green('no interrupted migration left behind');
375
+ return yellow(
376
+ `${NODE_MODULES_STASH} exists — a \`selva migrate\` was interrupted. If the app ` +
377
+ `is healthy, delete it: rm -rf ${stash}`,
378
+ fixable(`delete the leftover ${NODE_MODULES_STASH}`, () => {
379
+ try {
380
+ rmSync(stash, { recursive: true, force: true });
381
+ return green(`removed ${NODE_MODULES_STASH}`);
382
+ } catch (err) {
383
+ return red(`could not remove ${stash}: ${err instanceof Error ? err.message : err}`);
384
+ }
385
+ })
386
+ );
388
387
  }
389
388
 
390
389
  function checkLayoutDrift(dir) {
@@ -494,8 +493,7 @@ function checkHeaderAuth(dir, env, dataProvider) {
494
493
  Boolean(env.HEADER_AUTH_EMAIL_HEADER),
495
494
  Boolean(env.HEADER_AUTH_DISPLAY_NAME_HEADER)
496
495
  ].filter(Boolean).length;
497
- const headerList =
498
- `UPN=${resolved.upn}, Email=${resolved.email}, DisplayName=${resolved.displayName}`;
496
+ const headerList = `UPN=${resolved.upn}, Email=${resolved.email}, DisplayName=${resolved.displayName}`;
499
497
  if (overrides === 0) {
500
498
  out.push(green(`header names (bundled defaults): ${headerList}`));
501
499
  } else if (overrides === 3) {
@@ -7,14 +7,13 @@
7
7
  // `selva keys rotate`'s job, not init's.
8
8
  // • Doesn't touch package.json or run npm install.
9
9
 
10
- import { existsSync, readFileSync } from 'node:fs';
11
10
  import { join } from 'node:path';
12
11
  import * as p from '@clack/prompts';
13
12
  import pc from 'picocolors';
14
13
  import { collectConfig } from '../prompts.js';
15
14
  import { readEnvFile, writeEnvFile } from '../env.js';
16
15
  import { generateKey } from '../secrets.js';
17
- import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
16
+ import { readEnvTemplate, requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
18
17
 
19
18
  export async function runInit() {
20
19
  const dir = resolveDeploymentDir();
@@ -48,24 +47,7 @@ export async function runInit() {
48
47
  p.log.warn('SELVA_AT_REST_KEY was missing — generated a fresh one.');
49
48
  }
50
49
 
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);
50
+ writeEnvFile(envPath, readEnvTemplate(dir), values);
69
51
 
70
52
  p.outro(
71
53
  [
@@ -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
  [