@selvajs/cli 4.8.0-beta.8 → 4.8.0-beta.9

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.
@@ -5,19 +5,33 @@
5
5
  // checks, each behind its own confirmation — and only those needing no root and
6
6
  // no runtime restart. See `applyFixes`.
7
7
 
8
- import { existsSync, readFileSync, readdirSync, accessSync, constants, statSync } from 'node:fs';
9
- import { spawnSync } from 'node:child_process';
10
- import { join, resolve, dirname, delimiter } from 'node:path';
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';
11
18
  import { fileURLToPath } from 'node:url';
12
- import { homedir } from 'node:os';
13
19
  import * as p from '@clack/prompts';
14
20
  import pc from 'picocolors';
15
- import { readEnvFile, RENAMED_ENV_VARS, REPLACED_ENV_VARS } from '../env.js';
21
+ import { readEnvFile } from '../env.js';
16
22
  import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
17
- import { detectDrift } from './migrate.js';
18
-
19
- const HEX_64 = /^[0-9a-f]{64}$/i;
20
- const PLACEHOLDER = 'replace-this-with-a-random-32-byte-hex-key';
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';
21
35
 
22
36
  export async function runDoctor(argv = []) {
23
37
  const fix = argv.includes('--fix');
@@ -32,28 +46,12 @@ export async function runDoctor(argv = []) {
32
46
  checks.push(checkFile(join(dir, '.env'), '.env present'));
33
47
  checks.push(checkFile(join(dir, 'ecosystem.config.cjs'), 'ecosystem.config.cjs present'));
34
48
  checks.push(checkLayoutDrift(dir));
49
+ checks.push(checkMigrationLeftovers(dir));
35
50
  checks.push(checkSecret(env.SELVA_HMAC_KEY, 'SELVA_HMAC_KEY is a 32-byte hex string'));
36
51
  checks.push(checkSecret(env.SELVA_AT_REST_KEY, 'SELVA_AT_REST_KEY is a 32-byte hex string'));
37
52
 
38
- // Header-auth: auth only; data/storage must be local or supabase.
39
- const providers = {
40
- auth: (env.SELVA_AUTH_PROVIDER ?? 'local').toLowerCase(),
41
- data: (env.SELVA_DATA_PROVIDER ?? 'local').toLowerCase(),
42
- storage: (env.SELVA_STORAGE_PROVIDER ?? 'local').toLowerCase()
43
- };
44
-
45
- const validForAuth = new Set(['local', 'supabase', 'header']);
46
- const validForData = new Set(['local', 'supabase']);
47
-
48
- if (!validForAuth.has(providers.auth)) {
49
- checks.push(red(`SELVA_AUTH_PROVIDER="${providers.auth}" — expected local|supabase|header`));
50
- }
51
- if (!validForData.has(providers.data)) {
52
- checks.push(red(`SELVA_DATA_PROVIDER="${providers.data}" — expected local|supabase`));
53
- }
54
- if (!validForData.has(providers.storage)) {
55
- checks.push(red(`SELVA_STORAGE_PROVIDER="${providers.storage}" — expected local|supabase`));
56
- }
53
+ const providers = resolveProviders(env);
54
+ checks.push(...checkProviders(providers));
57
55
 
58
56
  const used = new Set(Object.values(providers));
59
57
 
@@ -70,42 +68,14 @@ export async function runDoctor(argv = []) {
70
68
  checks.push(...checkHeaderAuth(dir, env, providers.data));
71
69
  }
72
70
 
73
- const tenancy = (env.SELVA_TENANCY ?? 'single').toLowerCase();
74
- if (tenancy !== 'single' && tenancy !== 'multi') {
75
- checks.push(red(`SELVA_TENANCY="${tenancy}" — expected single|multi`));
76
- } else {
77
- checks.push(green(`SELVA_TENANCY=${tenancy}`));
78
- }
71
+ checks.push(checkTenancy(env));
79
72
 
80
73
  checks.push(checkPackage(dir, '@selvajs/selva'));
81
74
  checks.push(checkNodeEngine(dir));
82
75
  checks.push(checkCliRuntimeAlignment(dir));
83
76
  checks.push(...checkBootPersistence(dir));
84
- if (env.ORIGIN) {
85
- try {
86
- new URL(env.ORIGIN);
87
- checks.push(green(`ORIGIN=${env.ORIGIN}`));
88
- } catch {
89
- checks.push(red(`ORIGIN="${env.ORIGIN}" is not a valid URL`));
90
- }
91
- } else {
92
- checks.push(yellow('ORIGIN unset — required behind a reverse proxy'));
93
- }
94
-
95
- for (const [oldName, newName] of Object.entries(RENAMED_ENV_VARS)) {
96
- if (env[oldName] === undefined) continue;
97
- checks.push(
98
- env[newName] === undefined
99
- ? yellow(`${oldName} is deprecated — \`selva migrate\` renames it to ${newName}`)
100
- : yellow(`${oldName} is deprecated and ignored — ${newName} is set and wins`)
101
- );
102
- }
103
-
104
- // Not auto-fixable: the replacement encodes a value, so migrate won't guess.
105
- for (const [oldName, replacement] of Object.entries(REPLACED_ENV_VARS)) {
106
- if (env[oldName] === undefined) continue;
107
- checks.push(yellow(`${oldName} is deprecated — replace it with ${replacement}`));
108
- }
77
+ checks.push(checkOrigin(env));
78
+ checks.push(...checkDeprecatedEnv(env));
109
79
 
110
80
  // ── Render ─────────────────────────────────────────────────────────
111
81
  const resolved = await Promise.all(checks);
@@ -175,39 +145,10 @@ async function applyFixes(resolved, failures) {
175
145
  return failures;
176
146
  }
177
147
 
178
- function green(text) {
179
- return { severity: 'green', line: `${pc.green('✓')} ${text}` };
180
- }
181
- function yellow(text, fix) {
182
- return { severity: 'yellow', line: `${pc.yellow('!')} ${text}`, fix };
183
- }
184
- function red(text, fix) {
185
- return { severity: 'red', line: `${pc.red('✗')} ${text}`, fix };
186
- }
187
-
188
- /**
189
- * A repair `--fix` may run. `label` is what the operator is asked to approve;
190
- * `run()` performs it and returns a result line.
191
- *
192
- * Only attach one where the repair is unambiguous and reversible-ish. Anything
193
- * needing root, or that restarts the runtime running this process, stays a
194
- * printed instruction — a half-applied privileged fix is worse than none.
195
- */
196
- function fixable(label, run) {
197
- return { label, run };
198
- }
199
-
200
148
  function checkFile(path, label) {
201
149
  return existsSync(path) ? green(label) : red(`${label} (missing: ${path})`);
202
150
  }
203
151
 
204
- function checkSecret(value, label) {
205
- if (!value) return red(`${label} — unset`);
206
- if (value === PLACEHOLDER) return red(`${label} — still the placeholder`);
207
- if (!HEX_64.test(value)) return red(`${label} — not 64 hex chars`);
208
- return green(label);
209
- }
210
-
211
152
  function checkDataPath(dir, dataPath) {
212
153
  const absolute = resolve(dir, dataPath);
213
154
  try {
@@ -423,183 +364,26 @@ function checkNodeEngine(dir) {
423
364
  }
424
365
 
425
366
  /**
426
- * Narrow `engines.node` range check — `>=X`, `^`, `~`, `||`, and bare/x-ranges.
427
- * Returns null when unparseable so a misread never reports a false failure.
428
- *
429
- * Mirrors `satisfiesRange` in @selvajs/server/ops. Duplicated because the CLI is
430
- * dependency-free by design (it scaffolds the deployment that installs the
431
- * runtime, so it cannot import from it).
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.
432
371
  */
433
- export function satisfiesNodeRange(version, range) {
434
- const core = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(version).trim());
435
- if (!core) return null;
436
- const v = [Number(core[1]), Number(core[2]), Number(core[3])];
437
-
438
- let anyParsed = false;
439
- for (const alt of String(range).split('||')) {
440
- const clauses = alt.trim().split(/\s+/).filter(Boolean);
441
- if (!clauses.length) continue;
442
-
443
- let all = true;
444
- let parsed = true;
445
- for (const clause of clauses) {
446
- const m = /^(>=|<=|>|<|\^|~|=)?\s*v?(\d+)(?:\.(\d+|x|\*))?(?:\.(\d+|x|\*))?/.exec(clause);
447
- if (!m) {
448
- parsed = false;
449
- break;
450
- }
451
- const op = m[1] ?? '=';
452
- const num = (s) => (s == null || s === 'x' || s === '*' ? 0 : Number(s));
453
- const t = [Number(m[2]), num(m[3]), num(m[4])];
454
- let cmp = 0;
455
- for (let i = 0; i < 3; i++) {
456
- if (v[i] > t[i]) {
457
- cmp = 1;
458
- break;
459
- }
460
- if (v[i] < t[i]) {
461
- cmp = -1;
462
- break;
463
- }
464
- }
465
- let ok;
466
- if (op === '>=') ok = cmp >= 0;
467
- else if (op === '>') ok = cmp > 0;
468
- else if (op === '<=') ok = cmp <= 0;
469
- else if (op === '<') ok = cmp < 0;
470
- else if (op === '^') ok = cmp >= 0 && v[0] === t[0];
471
- else if (op === '~') ok = cmp >= 0 && v[0] === t[0] && v[1] === t[1];
472
- else
473
- ok =
474
- v[0] === t[0] &&
475
- (m[3] == null || m[3] === 'x' || m[3] === '*' || v[1] === t[1]) &&
476
- (m[4] == null || m[4] === 'x' || m[4] === '*' || v[2] === t[2]);
477
- if (!ok) {
478
- all = false;
479
- break;
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}`);
480
384
  }
481
- }
482
- if (!parsed) continue;
483
- anyParsed = true;
484
- if (all) return true;
485
- }
486
- return anyParsed ? false : null;
487
- }
488
-
489
- function checkBootPersistence(dir) {
490
- // pm2's boot integration is Linux/systemd-specific. On macOS it's launchd
491
- // (different unit path) and on Windows pm2 boot persistence isn't a thing —
492
- // stay silent rather than emit misleading checks.
493
- if (process.platform !== 'linux') return [];
494
-
495
- const out = [];
496
-
497
- // dump.pm2 (what pm2 startup resurrects).
498
- const pm2Home = process.env.PM2_HOME ?? join(homedir(), '.pm2');
499
- const dumpPath = join(pm2Home, 'dump.pm2');
500
- if (existsSync(dumpPath)) {
501
- out.push(green('pm2 process list saved (dump.pm2 present)'));
502
- } else {
503
- out.push(
504
- yellow(
505
- 'pm2 process list not saved — run `npx pm2 save` so a reboot can ' +
506
- 'resurrect the app (nothing to restore without it)',
507
- fixable('run `pm2 save` to persist the current process list', () => {
508
- const bin = join(dir, 'node_modules', '.bin', 'pm2');
509
- if (!existsSync(bin)) return red('pm2 not installed in this deployment');
510
- const r = spawnSync(bin, ['save'], { cwd: dir, encoding: 'utf8' });
511
- return (r.status ?? 1) === 0
512
- ? green('pm2 process list saved')
513
- : red(`pm2 save failed: ${(r.stderr || r.stdout || '').trim()}`);
514
- })
515
- )
516
- );
517
- }
518
-
519
- // systemd unit: present and pointing at deployment-local pm2.
520
- const user = process.env.USER ?? process.env.LOGNAME;
521
- const unitPath = user ? `/etc/systemd/system/pm2-${user}.service` : null;
522
-
523
- if (unitPath && existsSync(unitPath)) {
524
- const localPm2 = join(dir, 'node_modules', 'pm2', 'bin', 'pm2');
525
- let unit = '';
526
- try {
527
- unit = readFileSync(unitPath, 'utf8');
528
- } catch {
529
- out.push(yellow(`pm2 systemd unit present but unreadable (${unitPath})`));
530
- return out;
531
- }
532
- const execStart = /^ExecStart=(.+)$/m.exec(unit)?.[1] ?? '';
533
- if (execStart.includes(localPm2)) {
534
- out.push(green('pm2 systemd boot unit installed (uses deployment-local pm2)'));
535
- } else {
536
- out.push(
537
- red(
538
- `pm2 systemd boot unit points at a different pm2 than this deployment's.\n ` +
539
- `ExecStart: ${execStart || '(not found)'}\n ` +
540
- `expected: ${localPm2} resurrect\n ` +
541
- `Reboots will resurrect via the wrong pm2 (version skew). Re-run startup ` +
542
- `with the local binary:\n ` +
543
- `sudo env PATH=$PATH:${join(dir, 'node_modules', '.bin')} ${localPm2} ` +
544
- `startup systemd -u $USER --hp $HOME`
545
- )
546
- );
547
- }
548
- } else {
549
- out.push(
550
- yellow(
551
- 'pm2 systemd boot unit not installed — the app will NOT restart after a ' +
552
- 'reboot. Run `npx pm2 startup systemd -u $USER --hp $HOME` and paste the ' +
553
- 'printed command (point it at this deployment’s pm2).'
554
- )
555
- );
556
- }
557
-
558
- // Warn if global pm2 is on PATH (root cause of version skew).
559
- const globalPm2 = findGlobalPm2(dir);
560
- if (globalPm2) {
561
- // Only offer the removal when we could actually perform it. A global
562
- // install under /usr is root-owned; attempting it would half-fail and
563
- // leave the operator worse informed than a printed instruction.
564
- let writable = false;
565
- try {
566
- accessSync(dirname(globalPm2), constants.W_OK);
567
- writable = true;
568
- } catch {
569
- writable = false;
570
- }
571
-
572
- out.push(
573
- yellow(
574
- `a pm2 outside this deployment is on PATH (${globalPm2}) — it can fork a ` +
575
- `mismatched daemon and trigger skew. Prefer \`npm run\` wrappers / \`npx pm2\` ` +
576
- `from this directory; consider \`npm uninstall -g pm2\`.` +
577
- (writable ? '' : `\n (${dirname(globalPm2)} is not writable — needs sudo)`),
578
- writable
579
- ? fixable(`uninstall the global pm2 at ${globalPm2}`, () => {
580
- const r = spawnSync('npm', ['uninstall', '-g', 'pm2'], { encoding: 'utf8' });
581
- return (r.status ?? 1) === 0
582
- ? green('global pm2 uninstalled')
583
- : red(`npm uninstall -g pm2 failed: ${(r.stderr || r.stdout || '').trim()}`);
584
- })
585
- : undefined
586
- )
587
- );
588
- }
589
-
590
- return out;
591
- }
592
-
593
- // Scan PATH for stray pm2 binary (read-only).
594
- function findGlobalPm2(dir) {
595
- const localBin = resolve(dir, 'node_modules', '.bin');
596
- const dirs = (process.env.PATH ?? '').split(delimiter).filter(Boolean);
597
- for (const d of dirs) {
598
- if (resolve(d) === localBin) continue;
599
- const candidate = join(d, 'pm2');
600
- if (existsSync(candidate)) return candidate;
601
- }
602
- return null;
385
+ })
386
+ );
603
387
  }
604
388
 
605
389
  function checkLayoutDrift(dir) {
@@ -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: {
@@ -57,19 +56,7 @@ export async function runKeysRotate(argv) {
57
56
  const fresh = generateKey();
58
57
  current[envVar] = fresh;
59
58
 
60
- const templatePath = join(
61
- dir,
62
- 'node_modules',
63
- '@selvajs',
64
- 'selva',
65
- 'templates',
66
- '.env.example'
67
- );
68
- const template = existsSync(templatePath)
69
- ? readFileSync(templatePath, 'utf8')
70
- : readFileSync(envPath, 'utf8');
71
-
72
- writeEnvFile(envPath, template, current);
59
+ writeEnvFile(envPath, readEnvTemplate(dir), current);
73
60
 
74
61
  p.outro(
75
62
  [