@selvajs/cli 4.8.0-beta.8 → 4.8.0

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
@@ -8,28 +8,23 @@ CLI for white-label Selva deployments.
8
8
  npx @selvajs/cli my-deployment
9
9
  ```
10
10
 
11
- Interactive scaffolder. Prompts for provider, tenancy, flags, brand name, admin email. Generates `SELVA_HMAC_KEY` + `SELVA_AT_REST_KEY`. Writes `.env`, `ecosystem.config.cjs`, `package.json`. Runs `npm install`.
11
+ Prompts for provider, tenancy, flags, brand name, admin email; generates `SELVA_HMAC_KEY` and `SELVA_AT_REST_KEY`; writes `.env`, `ecosystem.config.cjs`, `package.json`; runs `npm install`. Refuses to overwrite a non-empty directory unless you pass `--force`.
12
12
 
13
13
  ## Operate an existing deployment
14
14
 
15
- After install, the package exposes a `selva` bin:
15
+ Installing adds a `selva` bin, scoped to the deployment directory (needs `.env` or `ecosystem.config.cjs` in the cwd):
16
16
 
17
17
  ```bash
18
- selva init # reconfigure prompts; preserves existing secrets
19
- selva doctor # validate env + providers + paths
18
+ selva init # reconfigure prompts; keeps existing secrets
19
+ selva doctor [--fix] # validate env, providers, Node engine, boot persistence
20
+ selva migrate # bring package.json onto the current layout
20
21
  selva start | stop | restart | logs
21
- selva update # npm update @selvajs/selva + pm2 restart
22
+ selva update # update @selvajs/cli + @selvajs/selva, then restart
22
23
  selva keys rotate hmac # rotate SELVA_HMAC_KEY (logs everyone out)
23
24
  selva keys rotate at-rest # rotate SELVA_AT_REST_KEY (compute API key needs re-entry)
24
25
  ```
25
26
 
26
- All operator commands run inside the deployment directory (the one that contains `.env` and `ecosystem.config.cjs`).
27
-
28
- ## Re-running a command is safe
29
-
30
- - `npx @selvajs/cli` refuses to overwrite a non-empty directory without `--force`.
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 a future CLI version knows which config layout it is looking at and can upgrade it cleanly.
27
+ `selva init` never regenerates `SELVA_HMAC_KEY` or `SELVA_AT_REST_KEY` once they're set — rotating those is `keys rotate`'s job, since it invalidates sessions or encrypted data. A `.selva-version` marker records which CLI version scaffolded the directory, so a later `selva migrate` knows what layout it's upgrading from.
33
28
 
34
29
  ## Relationship to `@selvajs/selva`
35
30
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selvajs/cli",
3
- "version": "4.8.0-beta.8",
3
+ "version": "4.8.0",
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",
@@ -34,15 +34,18 @@
34
34
  "!**/*.spec.*"
35
35
  ],
36
36
  "engines": {
37
- "node": ">=22.0.0"
37
+ "node": ">=24.0.0"
38
38
  },
39
39
  "dependencies": {
40
40
  "@clack/prompts": "^1.7.0",
41
41
  "picocolors": "^1.1.1"
42
42
  },
43
+ "devDependencies": {
44
+ "pm2": "7.0.3"
45
+ },
43
46
  "scripts": {
44
47
  "start": "node ./bin/cli.js",
45
48
  "selva": "node ./bin/selva.js",
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/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']) { await import(m); }\" && node --test \"./src/__tests__/*.test.js\""
47
50
  }
48
51
  }
@@ -0,0 +1,151 @@
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
+ // These checks only ever run on a Linux host (see the platform gate below), so
16
+ // every path here is POSIX. Using the platform-default `node:path` would build
17
+ // backslash paths and split PATH on `;` when the tests run on Windows.
18
+ import { posix as path } from 'node:path';
19
+ import { homedir } from 'node:os';
20
+ import { green, red, yellow, fixable } from './result.js';
21
+
22
+ /** The real OS. Tests pass a stand-in with the same shape. */
23
+ export const systemEnv = {
24
+ platform: () => process.platform,
25
+ env: () => process.env,
26
+ homedir,
27
+ exists: existsSync,
28
+ readFile: (target) => readFileSync(target, 'utf8'),
29
+ isWritable: (target) => {
30
+ try {
31
+ accessSync(target, constants.W_OK);
32
+ return true;
33
+ } catch {
34
+ return false;
35
+ }
36
+ },
37
+ run: (cmd, args, opts) => spawnSync(cmd, args, { ...opts, encoding: 'utf8' })
38
+ };
39
+
40
+ export function checkBootPersistence(dir, env = systemEnv) {
41
+ // pm2's boot integration is Linux/systemd-specific. On macOS it's launchd
42
+ // (different unit path) and on Windows pm2 boot persistence isn't a thing —
43
+ // stay silent rather than emit misleading checks.
44
+ if (env.platform() !== 'linux') return [];
45
+
46
+ return [
47
+ checkSavedProcessList(dir, env),
48
+ ...checkSystemdUnit(dir, env),
49
+ ...checkStrayGlobalPm2(dir, env)
50
+ ];
51
+ }
52
+
53
+ // dump.pm2 is what `pm2 resurrect` replays. Without it the unit starts pm2 and
54
+ // pm2 starts nothing.
55
+ function checkSavedProcessList(dir, env) {
56
+ const pm2Home = env.env().PM2_HOME ?? path.join(env.homedir(), '.pm2');
57
+ if (env.exists(path.join(pm2Home, 'dump.pm2'))) {
58
+ return green('pm2 process list saved (dump.pm2 present)');
59
+ }
60
+ return yellow(
61
+ 'pm2 process list not saved — run `npx pm2 save` so a reboot can ' +
62
+ 'resurrect the app (nothing to restore without it)',
63
+ fixable('run `pm2 save` to persist the current process list', () => {
64
+ const bin = path.join(dir, 'node_modules', '.bin', 'pm2');
65
+ if (!env.exists(bin)) return red('pm2 not installed in this deployment');
66
+ const r = env.run(bin, ['save'], { cwd: dir });
67
+ return (r.status ?? 1) === 0
68
+ ? green('pm2 process list saved')
69
+ : red(`pm2 save failed: ${(r.stderr || r.stdout || '').trim()}`);
70
+ })
71
+ );
72
+ }
73
+
74
+ function checkSystemdUnit(dir, env) {
75
+ const user = env.env().USER ?? env.env().LOGNAME;
76
+ const unitPath = user ? `/etc/systemd/system/pm2-${user}.service` : null;
77
+
78
+ if (!unitPath || !env.exists(unitPath)) {
79
+ return [
80
+ yellow(
81
+ 'pm2 systemd boot unit not installed — the app will NOT restart after a ' +
82
+ 'reboot. Run `npx pm2 startup systemd -u $USER --hp $HOME` and paste the ' +
83
+ 'printed command (point it at this deployment’s pm2).'
84
+ )
85
+ ];
86
+ }
87
+
88
+ let unit;
89
+ try {
90
+ unit = env.readFile(unitPath);
91
+ } catch {
92
+ return [yellow(`pm2 systemd unit present but unreadable (${unitPath})`)];
93
+ }
94
+
95
+ const localPm2 = path.join(dir, 'node_modules', 'pm2', 'bin', 'pm2');
96
+ const execStart = /^ExecStart=(.+)$/m.exec(unit)?.[1] ?? '';
97
+ if (execStart.includes(localPm2)) {
98
+ return [green('pm2 systemd boot unit installed (uses deployment-local pm2)')];
99
+ }
100
+
101
+ return [
102
+ red(
103
+ `pm2 systemd boot unit points at a different pm2 than this deployment's.\n ` +
104
+ `ExecStart: ${execStart || '(not found)'}\n ` +
105
+ `expected: ${localPm2} resurrect\n ` +
106
+ `Reboots will resurrect via the wrong pm2 (version skew). Re-run startup ` +
107
+ `with the local binary:\n ` +
108
+ `sudo env PATH=$PATH:${path.join(dir, 'node_modules', '.bin')} ${localPm2} ` +
109
+ `startup systemd -u $USER --hp $HOME`
110
+ )
111
+ ];
112
+ }
113
+
114
+ // A pm2 outside the deployment is the root cause of daemon version skew.
115
+ function checkStrayGlobalPm2(dir, env) {
116
+ const globalPm2 = findGlobalPm2(dir, env);
117
+ if (!globalPm2) return [];
118
+
119
+ // Only offer the removal when we could actually perform it. A global
120
+ // install under /usr is root-owned; attempting it would half-fail and
121
+ // leave the operator worse informed than a printed instruction.
122
+ const writable = env.isWritable(path.dirname(globalPm2));
123
+
124
+ return [
125
+ yellow(
126
+ `a pm2 outside this deployment is on PATH (${globalPm2}) — it can fork a ` +
127
+ `mismatched daemon and trigger skew. Prefer \`npm run\` wrappers / \`npx pm2\` ` +
128
+ `from this directory; consider \`npm uninstall -g pm2\`.` +
129
+ (writable ? '' : `\n (${path.dirname(globalPm2)} is not writable — needs sudo)`),
130
+ writable
131
+ ? fixable(`uninstall the global pm2 at ${globalPm2}`, () => {
132
+ const r = env.run('npm', ['uninstall', '-g', 'pm2'], {});
133
+ return (r.status ?? 1) === 0
134
+ ? green('global pm2 uninstalled')
135
+ : red(`npm uninstall -g pm2 failed: ${(r.stderr || r.stdout || '').trim()}`);
136
+ })
137
+ : undefined
138
+ )
139
+ ];
140
+ }
141
+
142
+ export function findGlobalPm2(dir, env = systemEnv) {
143
+ const localBin = path.resolve(dir, 'node_modules', '.bin');
144
+ const dirs = (env.env().PATH ?? '').split(path.delimiter).filter(Boolean);
145
+ for (const d of dirs) {
146
+ if (path.resolve(d) === localBin) continue;
147
+ const candidate = path.join(d, 'pm2');
148
+ if (env.exists(candidate)) return candidate;
149
+ }
150
+ return null;
151
+ }
@@ -0,0 +1,189 @@
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
+ // Lowercased and defaulted the same way create-selva-providers.ts defaults them.
28
+ export function resolveProviders(env) {
29
+ return {
30
+ auth: (env.SELVA_AUTH_PROVIDER ?? 'local').toLowerCase(),
31
+ data: (env.SELVA_DATA_PROVIDER ?? 'local').toLowerCase(),
32
+ storage: (env.SELVA_STORAGE_PROVIDER ?? 'local').toLowerCase()
33
+ };
34
+ }
35
+
36
+ export function checkProviders(providers) {
37
+ const checks = [];
38
+ if (!VALID_AUTH.includes(providers.auth)) {
39
+ checks.push(red(`SELVA_AUTH_PROVIDER="${providers.auth}" — expected local|supabase|header`));
40
+ }
41
+ if (!VALID_DATA.includes(providers.data)) {
42
+ checks.push(red(`SELVA_DATA_PROVIDER="${providers.data}" — expected local|supabase`));
43
+ }
44
+ if (!VALID_DATA.includes(providers.storage)) {
45
+ checks.push(red(`SELVA_STORAGE_PROVIDER="${providers.storage}" — expected local|supabase`));
46
+ }
47
+ return checks;
48
+ }
49
+
50
+ export function checkTenancy(env) {
51
+ const tenancy = (env.SELVA_TENANCY ?? 'single').toLowerCase();
52
+ if (tenancy !== 'single' && tenancy !== 'multi') {
53
+ return red(`SELVA_TENANCY="${tenancy}" — expected single|multi`);
54
+ }
55
+ return green(`SELVA_TENANCY=${tenancy}`);
56
+ }
57
+
58
+ export function checkOrigin(env) {
59
+ if (!env.ORIGIN) return yellow('ORIGIN unset — required behind a reverse proxy');
60
+ try {
61
+ new URL(env.ORIGIN);
62
+ } catch {
63
+ return red(`ORIGIN="${env.ORIGIN}" is not a valid URL`);
64
+ }
65
+ return green(`ORIGIN=${env.ORIGIN}`);
66
+ }
67
+
68
+ // Duplicated from HeaderAuthProvider.DEFAULT_HEADERS to avoid loading the runtime here.
69
+ // A test in providers/header-auth pins both sides, so drift surfaces in CI.
70
+ export const DEFAULT_HEADER_NAMES = {
71
+ upn: 'SELVA-UserPrincipalName',
72
+ email: 'SELVA-Email',
73
+ displayName: 'SELVA-DisplayName'
74
+ };
75
+
76
+ // oauth2-proxy's `set_xauthrequest` names. Two proxy styles are documented and
77
+ // both are correct, but they need opposite env config:
78
+ //
79
+ // rewrite — Caddy maps X-Auth-Request-* onto SELVA-* before proxying.
80
+ // Selva sees SELVA-*, so the overrides must be UNSET.
81
+ // copy-through — Caddy forwards X-Auth-Request-* unchanged.
82
+ // Selva sees those names, so all three must be overridden.
83
+ //
84
+ // Mixing them is the failure this check exists to catch: a name Selva reads but
85
+ // the proxy never sends yields `null` from identifyFromHeaders (for the UPN, a
86
+ // total login failure) with nothing in the logs pointing at the env var.
87
+ const OAUTH2_PROXY_HEADERS = new Set([
88
+ 'x-auth-request-user',
89
+ 'x-auth-request-email',
90
+ 'x-auth-request-preferred-username'
91
+ ]);
92
+
93
+ /**
94
+ * Reports the header names the provider will actually read, and flags the
95
+ * combinations that can't match any documented proxy config.
96
+ *
97
+ * Deliberately not a "did you override all three" count: with the rewrite
98
+ * style, zero overrides is the correct answer, so counting alone would push
99
+ * operators toward breaking a working deployment.
100
+ */
101
+ export function checkHeaderNames(env) {
102
+ const resolved = {
103
+ upn: env.HEADER_AUTH_UPN_HEADER || DEFAULT_HEADER_NAMES.upn,
104
+ email: env.HEADER_AUTH_EMAIL_HEADER || DEFAULT_HEADER_NAMES.email,
105
+ displayName: env.HEADER_AUTH_DISPLAY_NAME_HEADER || DEFAULT_HEADER_NAMES.displayName
106
+ };
107
+ const list = `UPN=${resolved.upn}, Email=${resolved.email}, DisplayName=${resolved.displayName}`;
108
+
109
+ const oauth2 = Object.values(resolved).filter((h) =>
110
+ OAUTH2_PROXY_HEADERS.has(h.toLowerCase())
111
+ ).length;
112
+ const defaults = Object.entries(resolved).filter(
113
+ ([slot, name]) => name === DEFAULT_HEADER_NAMES[slot]
114
+ ).length;
115
+
116
+ if (oauth2 === 0) {
117
+ return defaults === 3
118
+ ? green(`header names (bundled defaults): ${list}`)
119
+ : green(`header names (custom): ${list}`);
120
+ }
121
+
122
+ // Some X-Auth-Request-*, some SELVA-* defaults — neither proxy style emits
123
+ // that mix, so whichever slots are still on a default are being read from a
124
+ // header nothing sets.
125
+ if (defaults > 0) {
126
+ const stale = Object.entries(resolved)
127
+ .filter(([slot, name]) => name === DEFAULT_HEADER_NAMES[slot])
128
+ .map(([slot, name]) => `${slot}=${name}`)
129
+ .join(', ');
130
+ return yellow(
131
+ `header names mix oauth2-proxy and bundled-default names: ${list} — ` +
132
+ `${stale} will never arrive if your proxy forwards X-Auth-Request-* unchanged. ` +
133
+ `Either override all three with the X-Auth-Request-* names, or map them to SELVA-* ` +
134
+ `in the proxy and unset all three overrides.`
135
+ );
136
+ }
137
+
138
+ return green(`header names (oauth2-proxy pass-through): ${list}`);
139
+ }
140
+
141
+ // Below this, an annotated .env is just a slightly chatty config file; above it
142
+ // the operator is scrolling past screens of prose that no command can correct.
143
+ const COMMENT_LINE_BUDGET = 40;
144
+
145
+ /**
146
+ * Flags a `.env` still carrying the shipped documentation block.
147
+ *
148
+ * The block is a snapshot of the release the deployment was installed at.
149
+ * `migrate` rewrites keys but never prose, so those comments keep describing
150
+ * vars the code has since renamed or retired — confidently wrong instructions
151
+ * sitting in the file an operator reaches for first. Nothing breaks, which is
152
+ * why this is yellow, and why it needs saying at all.
153
+ */
154
+ export function checkEnvDocumentation(commentLines, fix) {
155
+ if (commentLines <= COMMENT_LINE_BUDGET) {
156
+ return green('.env is values-only');
157
+ }
158
+ return yellow(
159
+ `.env carries ~${commentLines} lines of shipped documentation. Comments are ` +
160
+ `never updated by \`selva migrate\`, so they still describe the release this ` +
161
+ `deployment was installed at — including variables the code no longer reads. ` +
162
+ `Strip them (values and your own inline notes are kept, .env.bak written):\n ` +
163
+ `npx selva doctor --fix`,
164
+ fix
165
+ );
166
+ }
167
+
168
+ // A rename is auto-fixable (`selva migrate` rewrites the key). A replacement
169
+ // encodes a value in the new name, so migrate leaves it alone rather than
170
+ // guessing — it's reported here and nowhere else.
171
+ export function checkDeprecatedEnv(env) {
172
+ const checks = [];
173
+
174
+ for (const [oldName, newName] of Object.entries(RENAMED_ENV_VARS)) {
175
+ if (env[oldName] === undefined) continue;
176
+ checks.push(
177
+ env[newName] === undefined
178
+ ? yellow(`${oldName} is deprecated — \`selva migrate\` renames it to ${newName}`)
179
+ : yellow(`${oldName} is deprecated and ignored — ${newName} is set and wins`)
180
+ );
181
+ }
182
+
183
+ for (const [oldName, replacement] of Object.entries(REPLACED_ENV_VARS)) {
184
+ if (env[oldName] === undefined) continue;
185
+ checks.push(yellow(`${oldName} is deprecated — replace it with ${replacement}`));
186
+ }
187
+
188
+ return checks;
189
+ }
@@ -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
+ }