@selvajs/cli 4.7.4-beta.0 → 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.
package/README.md CHANGED
@@ -25,11 +25,11 @@ selva keys rotate at-rest # rotate SELVA_AT_REST_KEY (compute API key needs re
25
25
 
26
26
  All operator commands run inside the deployment directory (the one that contains `.env` and `ecosystem.config.cjs`).
27
27
 
28
- ## Idempotency rules
28
+ ## Re-running a command is safe
29
29
 
30
30
  - `npx @selvajs/cli` refuses to overwrite a non-empty directory without `--force`.
31
31
  - `selva init` reads the current `.env`, lets the user edit, and **never** regenerates `SELVA_HMAC_KEY` / `SELVA_AT_REST_KEY` if they're already set.
32
- - A `.selva-version` marker is written so future CLI versions can migrate config schema cleanly.
32
+ - A `.selva-version` marker is written so a future CLI version knows which config layout it is looking at and can upgrade it cleanly.
33
33
 
34
34
  ## Relationship to `@selvajs/selva`
35
35
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selvajs/cli",
3
- "version": "4.7.4-beta.0",
3
+ "version": "4.8.0-beta.10",
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",
@@ -28,10 +28,13 @@
28
28
  },
29
29
  "files": [
30
30
  "bin",
31
- "src"
31
+ "src",
32
+ "!**/__tests__/**",
33
+ "!**/*.test.*",
34
+ "!**/*.spec.*"
32
35
  ],
33
36
  "engines": {
34
- "node": ">=22.0.0"
37
+ "node": ">=24.0.0"
35
38
  },
36
39
  "dependencies": {
37
40
  "@clack/prompts": "^1.7.0",
@@ -40,6 +43,6 @@
40
43
  "scripts": {
41
44
  "start": "node ./bin/cli.js",
42
45
  "selva": "node ./bin/selva.js",
43
- "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/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); }\""
46
+ "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\""
44
47
  }
45
48
  }
@@ -0,0 +1,149 @@
1
+ // Will a reboot bring the app back?
2
+ //
3
+ // Three things have to line up: pm2 has a saved process list to resurrect, a
4
+ // systemd unit exists to do the resurrecting, and that unit points at THIS
5
+ // deployment's pm2 rather than a global one. Each fails silently — the
6
+ // deployment runs fine until the VM restarts, which is exactly when nobody is
7
+ // watching.
8
+ //
9
+ // Every OS interaction arrives through `env` so the whole matrix (unit missing,
10
+ // unit pointing elsewhere, unit unreadable, stray global pm2) is reachable in a
11
+ // test without a Linux box, a systemd unit, or root.
12
+
13
+ import { existsSync, readFileSync, accessSync, constants } from 'node:fs';
14
+ import { spawnSync } from 'node:child_process';
15
+ import { join, resolve, dirname, delimiter } from 'node:path';
16
+ import { homedir } from 'node:os';
17
+ import { green, red, yellow, fixable } from './result.js';
18
+
19
+ /** The real OS. Tests pass a stand-in with the same shape. */
20
+ export const systemEnv = {
21
+ platform: () => process.platform,
22
+ env: () => process.env,
23
+ homedir,
24
+ exists: existsSync,
25
+ readFile: (path) => readFileSync(path, 'utf8'),
26
+ isWritable: (path) => {
27
+ try {
28
+ accessSync(path, constants.W_OK);
29
+ return true;
30
+ } catch {
31
+ return false;
32
+ }
33
+ },
34
+ run: (cmd, args, opts) => spawnSync(cmd, args, { ...opts, encoding: 'utf8' })
35
+ };
36
+
37
+ export function checkBootPersistence(dir, env = systemEnv) {
38
+ // pm2's boot integration is Linux/systemd-specific. On macOS it's launchd
39
+ // (different unit path) and on Windows pm2 boot persistence isn't a thing —
40
+ // stay silent rather than emit misleading checks.
41
+ if (env.platform() !== 'linux') return [];
42
+
43
+ return [
44
+ checkSavedProcessList(dir, env),
45
+ ...checkSystemdUnit(dir, env),
46
+ ...checkStrayGlobalPm2(dir, env)
47
+ ];
48
+ }
49
+
50
+ // dump.pm2 is what `pm2 resurrect` replays. Without it the unit starts pm2 and
51
+ // pm2 starts nothing.
52
+ function checkSavedProcessList(dir, env) {
53
+ const pm2Home = env.env().PM2_HOME ?? join(env.homedir(), '.pm2');
54
+ if (env.exists(join(pm2Home, 'dump.pm2'))) {
55
+ return green('pm2 process list saved (dump.pm2 present)');
56
+ }
57
+ return yellow(
58
+ 'pm2 process list not saved — run `npx pm2 save` so a reboot can ' +
59
+ 'resurrect the app (nothing to restore without it)',
60
+ fixable('run `pm2 save` to persist the current process list', () => {
61
+ const bin = join(dir, 'node_modules', '.bin', 'pm2');
62
+ if (!env.exists(bin)) return red('pm2 not installed in this deployment');
63
+ const r = env.run(bin, ['save'], { cwd: dir });
64
+ return (r.status ?? 1) === 0
65
+ ? green('pm2 process list saved')
66
+ : red(`pm2 save failed: ${(r.stderr || r.stdout || '').trim()}`);
67
+ })
68
+ );
69
+ }
70
+
71
+ function checkSystemdUnit(dir, env) {
72
+ const user = env.env().USER ?? env.env().LOGNAME;
73
+ const unitPath = user ? `/etc/systemd/system/pm2-${user}.service` : null;
74
+
75
+ if (!unitPath || !env.exists(unitPath)) {
76
+ return [
77
+ yellow(
78
+ 'pm2 systemd boot unit not installed — the app will NOT restart after a ' +
79
+ 'reboot. Run `npx pm2 startup systemd -u $USER --hp $HOME` and paste the ' +
80
+ 'printed command (point it at this deployment’s pm2).'
81
+ )
82
+ ];
83
+ }
84
+
85
+ let unit;
86
+ try {
87
+ unit = env.readFile(unitPath);
88
+ } catch {
89
+ return [yellow(`pm2 systemd unit present but unreadable (${unitPath})`)];
90
+ }
91
+
92
+ const localPm2 = join(dir, 'node_modules', 'pm2', 'bin', 'pm2');
93
+ const execStart = /^ExecStart=(.+)$/m.exec(unit)?.[1] ?? '';
94
+ if (execStart.includes(localPm2)) {
95
+ return [green('pm2 systemd boot unit installed (uses deployment-local pm2)')];
96
+ }
97
+
98
+ return [
99
+ red(
100
+ `pm2 systemd boot unit points at a different pm2 than this deployment's.\n ` +
101
+ `ExecStart: ${execStart || '(not found)'}\n ` +
102
+ `expected: ${localPm2} resurrect\n ` +
103
+ `Reboots will resurrect via the wrong pm2 (version skew). Re-run startup ` +
104
+ `with the local binary:\n ` +
105
+ `sudo env PATH=$PATH:${join(dir, 'node_modules', '.bin')} ${localPm2} ` +
106
+ `startup systemd -u $USER --hp $HOME`
107
+ )
108
+ ];
109
+ }
110
+
111
+ // A pm2 outside the deployment is the root cause of daemon version skew.
112
+ function checkStrayGlobalPm2(dir, env) {
113
+ const globalPm2 = findGlobalPm2(dir, env);
114
+ if (!globalPm2) return [];
115
+
116
+ // Only offer the removal when we could actually perform it. A global
117
+ // install under /usr is root-owned; attempting it would half-fail and
118
+ // leave the operator worse informed than a printed instruction.
119
+ const writable = env.isWritable(dirname(globalPm2));
120
+
121
+ return [
122
+ yellow(
123
+ `a pm2 outside this deployment is on PATH (${globalPm2}) — it can fork a ` +
124
+ `mismatched daemon and trigger skew. Prefer \`npm run\` wrappers / \`npx pm2\` ` +
125
+ `from this directory; consider \`npm uninstall -g pm2\`.` +
126
+ (writable ? '' : `\n (${dirname(globalPm2)} is not writable — needs sudo)`),
127
+ writable
128
+ ? fixable(`uninstall the global pm2 at ${globalPm2}`, () => {
129
+ const r = env.run('npm', ['uninstall', '-g', 'pm2'], {});
130
+ return (r.status ?? 1) === 0
131
+ ? green('global pm2 uninstalled')
132
+ : red(`npm uninstall -g pm2 failed: ${(r.stderr || r.stdout || '').trim()}`);
133
+ })
134
+ : undefined
135
+ )
136
+ ];
137
+ }
138
+
139
+ // Scan PATH for a stray pm2 binary (read-only).
140
+ export function findGlobalPm2(dir, env = systemEnv) {
141
+ const localBin = resolve(dir, 'node_modules', '.bin');
142
+ const dirs = (env.env().PATH ?? '').split(delimiter).filter(Boolean);
143
+ for (const d of dirs) {
144
+ if (resolve(d) === localBin) continue;
145
+ const candidate = join(d, 'pm2');
146
+ if (env.exists(candidate)) return candidate;
147
+ }
148
+ return null;
149
+ }
@@ -0,0 +1,97 @@
1
+ // Checks that read only `.env` values — no filesystem, no network, no
2
+ // subprocesses. These decide whether a deployment is validly configured, so
3
+ // they run on every `selva doctor` regardless of which providers are in play.
4
+
5
+ import { RENAMED_ENV_VARS, REPLACED_ENV_VARS } from '../env.js';
6
+ import { green, red, yellow } from './result.js';
7
+
8
+ const HEX_64 = /^[0-9a-f]{64}$/i;
9
+ const PLACEHOLDER = 'replace-this-with-a-random-32-byte-hex-key';
10
+
11
+ const VALID_AUTH = ['local', 'supabase', 'header'];
12
+ // Header-auth is an auth provider only — it has no data or storage layer.
13
+ const VALID_DATA = ['local', 'supabase'];
14
+
15
+ /**
16
+ * Guards SELVA_HMAC_KEY and SELVA_AT_REST_KEY. A placeholder or short key
17
+ * starts and serves traffic — the damage (forgeable sessions, weak at-rest
18
+ * encryption) is invisible until someone looks for it.
19
+ */
20
+ export function checkSecret(value, label) {
21
+ if (!value) return red(`${label} — unset`);
22
+ if (value === PLACEHOLDER) return red(`${label} — still the placeholder`);
23
+ if (!HEX_64.test(value)) return red(`${label} — not 64 hex chars`);
24
+ return green(label);
25
+ }
26
+
27
+ /**
28
+ * The three provider slots, lowercased and defaulted the same way the runtime
29
+ * defaults them. Returned alongside the checks because the caller decides which
30
+ * provider-specific checks to run from it.
31
+ */
32
+ export function resolveProviders(env) {
33
+ return {
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
+
40
+ export function checkProviders(providers) {
41
+ const checks = [];
42
+ if (!VALID_AUTH.includes(providers.auth)) {
43
+ checks.push(red(`SELVA_AUTH_PROVIDER="${providers.auth}" — expected local|supabase|header`));
44
+ }
45
+ if (!VALID_DATA.includes(providers.data)) {
46
+ checks.push(red(`SELVA_DATA_PROVIDER="${providers.data}" — expected local|supabase`));
47
+ }
48
+ if (!VALID_DATA.includes(providers.storage)) {
49
+ checks.push(red(`SELVA_STORAGE_PROVIDER="${providers.storage}" — expected local|supabase`));
50
+ }
51
+ return checks;
52
+ }
53
+
54
+ export function checkTenancy(env) {
55
+ const tenancy = (env.SELVA_TENANCY ?? 'single').toLowerCase();
56
+ if (tenancy !== 'single' && tenancy !== 'multi') {
57
+ return red(`SELVA_TENANCY="${tenancy}" — expected single|multi`);
58
+ }
59
+ return green(`SELVA_TENANCY=${tenancy}`);
60
+ }
61
+
62
+ export function checkOrigin(env) {
63
+ if (!env.ORIGIN) return yellow('ORIGIN unset — required behind a reverse proxy');
64
+ try {
65
+ new URL(env.ORIGIN);
66
+ } catch {
67
+ return red(`ORIGIN="${env.ORIGIN}" is not a valid URL`);
68
+ }
69
+ return green(`ORIGIN=${env.ORIGIN}`);
70
+ }
71
+
72
+ /**
73
+ * Report env vars that were renamed or replaced.
74
+ *
75
+ * A rename is auto-fixable (`selva migrate` rewrites the key). A replacement
76
+ * encodes a value in the new name, so migrate leaves it alone rather than
77
+ * guessing — it is reported here and nowhere else.
78
+ */
79
+ export function checkDeprecatedEnv(env) {
80
+ const checks = [];
81
+
82
+ for (const [oldName, newName] of Object.entries(RENAMED_ENV_VARS)) {
83
+ if (env[oldName] === undefined) continue;
84
+ checks.push(
85
+ env[newName] === undefined
86
+ ? yellow(`${oldName} is deprecated — \`selva migrate\` renames it to ${newName}`)
87
+ : yellow(`${oldName} is deprecated and ignored — ${newName} is set and wins`)
88
+ );
89
+ }
90
+
91
+ for (const [oldName, replacement] of Object.entries(REPLACED_ENV_VARS)) {
92
+ if (env[oldName] === undefined) continue;
93
+ checks.push(yellow(`${oldName} is deprecated — replace it with ${replacement}`));
94
+ }
95
+
96
+ return checks;
97
+ }
@@ -0,0 +1,27 @@
1
+ // The shape every `selva doctor` check returns, and the repair a check may
2
+ // carry. Kept apart from the checks themselves so a pure predicate can be
3
+ // tested without pulling in the filesystem and network ones.
4
+
5
+ import pc from 'picocolors';
6
+
7
+ export function green(text) {
8
+ return { severity: 'green', line: `${pc.green('✓')} ${text}` };
9
+ }
10
+ export function yellow(text, fix) {
11
+ return { severity: 'yellow', line: `${pc.yellow('!')} ${text}`, fix };
12
+ }
13
+ export function red(text, fix) {
14
+ return { severity: 'red', line: `${pc.red('✗')} ${text}`, fix };
15
+ }
16
+
17
+ /**
18
+ * A repair `--fix` may run. `label` is what the operator is asked to approve;
19
+ * `run()` performs it and returns a result line.
20
+ *
21
+ * Only attach one where the repair is unambiguous and reversible-ish. Anything
22
+ * needing root, or that restarts the runtime running this process, stays a
23
+ * printed instruction — a half-applied privileged fix is worse than none.
24
+ */
25
+ export function fixable(label, run) {
26
+ return { label, run };
27
+ }
package/src/cli.js CHANGED
@@ -62,7 +62,7 @@ function printHelp() {
62
62
  '',
63
63
  pc.bold('Commands:'),
64
64
  ' init Reconfigure this deployment (prompts again)',
65
- ' doctor Validate env, providers, and installed packages',
65
+ ' doctor [--fix] Validate env, providers, Node engine, and packages',
66
66
  ' start pm2 start ecosystem.config.cjs',
67
67
  ' stop pm2 stop selva-compute',
68
68
  ' restart pm2 restart selva-compute --update-env',
@@ -3,20 +3,43 @@
3
3
  // templates are the source of truth; providers are env-driven.
4
4
 
5
5
  import { writeFileSync, existsSync, mkdirSync, readFileSync, cpSync } from 'node:fs';
6
- import { resolve, join, basename } from 'node:path';
6
+ import { resolve, join, basename, dirname } from 'node:path';
7
7
  import { spawn } from 'node:child_process';
8
8
  import * as p from '@clack/prompts';
9
9
  import pc from 'picocolors';
10
10
  import { collectConfig, collectConfigFromEnv } from '../prompts.js';
11
11
  import { generateKey } from '../secrets.js';
12
12
  import { writeEnvFile } from '../env.js';
13
- import { isEmptyOrMissing } from '../paths.js';
14
-
15
- const CLI_VERSION = '0.1.0';
13
+ import {
14
+ isEmptyOrMissing,
15
+ requiredNodeRange,
16
+ runtimeTemplatePath,
17
+ scaffoldVersion
18
+ } from '../paths.js';
19
+ import { buildDeploymentPackageJson } from '../deployment-package.js';
20
+ import { satisfiesNodeRange } from '../node-range.js';
16
21
 
17
22
  export async function runCreate(argv) {
18
23
  const { dir: rawDir, force, skipInstall, yes } = parseArgs(argv);
19
24
 
25
+ // Scaffolding under too old a Node produces a deployment that installs
26
+ // cleanly and passes its health check, then fails only under real traffic —
27
+ // npm treats an engine mismatch as a warning unless engine-strict is set,
28
+ // which no deployment sets (issue #176). Refuse at the one moment the
29
+ // operator is still choosing their environment. The floor comes from this
30
+ // CLI's own engines.node, so a bump can't leave a stale copy behind.
31
+ const required = requiredNodeRange();
32
+ if (required && satisfiesNodeRange(process.versions.node, required) === false) {
33
+ console.error(
34
+ `${pc.red('✗')} Selva requires Node ${required}, but this shell runs ` +
35
+ `v${process.versions.node}.\n` +
36
+ ` A deployment scaffolded here would install and start, then fail once real\n` +
37
+ ` requests hit newer Node APIs. Upgrade Node first (nvm, fnm, or your package\n` +
38
+ ` manager), then re-run this command.`
39
+ );
40
+ process.exit(1);
41
+ }
42
+
20
43
  const targetDir = resolve(rawDir);
21
44
  if (!isEmptyOrMissing(targetDir) && !force) {
22
45
  console.error(
@@ -40,7 +63,7 @@ export async function runCreate(argv) {
40
63
  const deployName = basename(targetDir);
41
64
 
42
65
  // Write package.json, then install (need templates from @selvajs/selva).
43
- const pkgJson = buildPackageJson(deployName, values);
66
+ const pkgJson = JSON.stringify(buildDeploymentPackageJson({ name: deployName }), null, 2);
44
67
  writeFileSync(join(targetDir, 'package.json'), pkgJson + '\n', 'utf8');
45
68
 
46
69
  if (!skipInstall) {
@@ -52,23 +75,23 @@ export async function runCreate(argv) {
52
75
  }
53
76
 
54
77
  // Copy templates from installed runtime.
55
- const runtimeTemplates = join(targetDir, 'node_modules', '@selvajs', 'selva', 'templates');
56
- if (skipInstall || !existsSync(runtimeTemplates)) {
78
+ const envTemplatePath = runtimeTemplatePath(targetDir, '.env.example');
79
+ const ecosystemTemplatePath = runtimeTemplatePath(targetDir, 'ecosystem.config.cjs');
80
+ if (skipInstall || !existsSync(envTemplatePath)) {
57
81
  p.log.warn(
58
- `Couldn't read runtime templates from ${runtimeTemplates}. ` +
82
+ `Couldn't read runtime templates from ${dirname(envTemplatePath)}. ` +
59
83
  `Run \`npm install\`, then \`selva init\` to finish setup.`
60
84
  );
61
- writeFileSync(join(targetDir, '.selva-version'), CLI_VERSION + '\n', 'utf8');
85
+ writeFileSync(join(targetDir, '.selva-version'), scaffoldVersion() + '\n', 'utf8');
62
86
  p.outro(`Partial scaffold at ${pc.cyan(targetDir)}.`);
63
87
  return;
64
88
  }
65
89
 
66
- const envTemplate = readFileSync(join(runtimeTemplates, '.env.example'), 'utf8');
67
- writeEnvFile(join(targetDir, '.env'), envTemplate, values);
90
+ writeEnvFile(join(targetDir, '.env'), readFileSync(envTemplatePath, 'utf8'), values);
68
91
 
69
- cpSync(join(runtimeTemplates, 'ecosystem.config.cjs'), join(targetDir, 'ecosystem.config.cjs'));
92
+ cpSync(ecosystemTemplatePath, join(targetDir, 'ecosystem.config.cjs'));
70
93
 
71
- writeFileSync(join(targetDir, '.selva-version'), CLI_VERSION + '\n', 'utf8');
94
+ writeFileSync(join(targetDir, '.selva-version'), scaffoldVersion() + '\n', 'utf8');
72
95
  writeGitignore(targetDir);
73
96
 
74
97
  p.outro(
@@ -173,7 +196,7 @@ function runNpmInstall(cwd) {
173
196
  console.error(
174
197
  pc.yellow(
175
198
  'npm resolved @selvajs/selva@0.10.2 — that version is broken (unresolved\n' +
176
- "workspace:* / catalog: specs) and has been unpublished. Your local npm\n" +
199
+ 'workspace:* / catalog: specs) and has been unpublished. Your local npm\n' +
177
200
  'cache is stale. Clear it and retry:\n\n' +
178
201
  ' npm cache clean --force\n' +
179
202
  ' rm -rf node_modules package-lock.json\n' +
@@ -205,9 +228,7 @@ function parseArgs(argv) {
205
228
  }
206
229
  }
207
230
  if (!dir) {
208
- throw new Error(
209
- 'Usage: npx @selvajs/cli <directory> [--force] [--skip-install] [--yes]'
210
- );
231
+ throw new Error('Usage: npx @selvajs/cli <directory> [--force] [--skip-install] [--yes]');
211
232
  }
212
233
  return { dir, force, skipInstall, yes };
213
234
  }
@@ -217,44 +238,6 @@ function envBool(v) {
217
238
  return ['1', 'true', 'yes'].includes(String(v).toLowerCase());
218
239
  }
219
240
 
220
- // Depends on @selvajs/selva (prebuilt) + @selvajs/cli (operator tool).
221
- // @selvajs/cli links the `selva` bin; without it, only global CLI works.
222
- function buildPackageJson(name /*, values */) {
223
- const deps = {
224
- '@selvajs/cli': 'latest',
225
- '@selvajs/selva': 'latest',
226
- // Deployment-local pm2 (pinned exact to prevent daemon version skew).
227
- pm2: '5.4.3'
228
- };
229
-
230
- const pkg = {
231
- name: sanitizePackageName(name),
232
- version: '0.1.0',
233
- private: true,
234
- type: 'module',
235
- scripts: {
236
- start: 'selva start',
237
- stop: 'selva stop',
238
- restart: 'selva restart',
239
- logs: 'selva logs',
240
- doctor: 'selva doctor',
241
- update: 'selva update'
242
- },
243
- dependencies: deps
244
- };
245
- return JSON.stringify(pkg, null, 2);
246
- }
247
-
248
- function sanitizePackageName(name) {
249
- // npm package names: lowercase, no spaces, limited punctuation.
250
- return (
251
- name
252
- .toLowerCase()
253
- .replace(/[^a-z0-9._-]+/g, '-')
254
- .replace(/^[-._]+|[-._]+$/g, '') || 'selva-deployment'
255
- );
256
- }
257
-
258
241
  function writeGitignore(dir) {
259
242
  const path = join(dir, '.gitignore');
260
243
  if (existsSync(path)) return;