@selvajs/cli 4.7.4-beta.1 → 4.8.0-beta.11
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 +7 -12
- package/package.json +10 -4
- package/src/checks/boot.js +151 -0
- package/src/checks/config.js +89 -0
- package/src/checks/result.js +27 -0
- package/src/checks/runtime.js +289 -0
- package/src/cli.js +2 -2
- package/src/commands/create.js +43 -69
- package/src/commands/doctor.js +151 -170
- package/src/commands/init.js +9 -32
- package/src/commands/keys.js +3 -17
- package/src/commands/migrate.js +225 -105
- package/src/commands/pm2.js +45 -9
- package/src/deployment-package.js +143 -0
- package/src/env.js +57 -3
- package/src/node-range.js +66 -0
- package/src/paths.js +68 -5
- package/src/prompts.js +4 -16
- package/bin/create.js +0 -7
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
|
-
|
|
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
|
-
|
|
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
|
|
19
|
-
selva doctor
|
|
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
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
## Idempotency rules
|
|
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 future CLI versions can migrate config schema 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.
|
|
3
|
+
"version": "4.8.0-beta.11",
|
|
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,18 +28,24 @@
|
|
|
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": ">=
|
|
37
|
+
"node": ">=24.0.0"
|
|
35
38
|
},
|
|
36
39
|
"dependencies": {
|
|
37
40
|
"@clack/prompts": "^1.7.0",
|
|
38
41
|
"picocolors": "^1.1.1"
|
|
39
42
|
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"pm2": "7.0.3"
|
|
45
|
+
},
|
|
40
46
|
"scripts": {
|
|
41
47
|
"start": "node ./bin/cli.js",
|
|
42
48
|
"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); }\""
|
|
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\""
|
|
44
50
|
}
|
|
45
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,89 @@
|
|
|
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
|
+
// A rename is auto-fixable (`selva migrate` rewrites the key). A replacement
|
|
69
|
+
// encodes a value in the new name, so migrate leaves it alone rather than
|
|
70
|
+
// guessing — it's reported here and nowhere else.
|
|
71
|
+
export function checkDeprecatedEnv(env) {
|
|
72
|
+
const checks = [];
|
|
73
|
+
|
|
74
|
+
for (const [oldName, newName] of Object.entries(RENAMED_ENV_VARS)) {
|
|
75
|
+
if (env[oldName] === undefined) continue;
|
|
76
|
+
checks.push(
|
|
77
|
+
env[newName] === undefined
|
|
78
|
+
? yellow(`${oldName} is deprecated — \`selva migrate\` renames it to ${newName}`)
|
|
79
|
+
: yellow(`${oldName} is deprecated and ignored — ${newName} is set and wins`)
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const [oldName, replacement] of Object.entries(REPLACED_ENV_VARS)) {
|
|
84
|
+
if (env[oldName] === undefined) continue;
|
|
85
|
+
checks.push(yellow(`${oldName} is deprecated — replace it with ${replacement}`));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return checks;
|
|
89
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// What the host provides versus what the deployment brings itself.
|
|
2
|
+
//
|
|
3
|
+
// The split trips operators up: Node and npm come from the host (distro
|
|
4
|
+
// packages, nvm, whatever), pm2 comes from the deployment. Installing pm2 from
|
|
5
|
+
// apt or `npm i -g` looks like the tidy thing to do and produces a daemon that
|
|
6
|
+
// silently fights the local one. These checks name which side of the line each
|
|
7
|
+
// tool is on, so the answer is on screen instead of in a support thread.
|
|
8
|
+
//
|
|
9
|
+
// checkNodeEngine (doctor.js) already validates the running Node against
|
|
10
|
+
// engines.node. Nothing here repeats that — this covers what it can't see: npm,
|
|
11
|
+
// the pm2 install itself, and whether the Node running doctor is the same one
|
|
12
|
+
// pm2 launched the app under.
|
|
13
|
+
|
|
14
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
15
|
+
import { spawnSync } from 'node:child_process';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
import { green, red, yellow } from './result.js';
|
|
19
|
+
import { PM2_VERSION } from '../deployment-package.js';
|
|
20
|
+
|
|
21
|
+
/** The real OS. Tests pass a stand-in with the same shape. */
|
|
22
|
+
export const runtimeEnv = {
|
|
23
|
+
platform: () => process.platform,
|
|
24
|
+
env: () => process.env,
|
|
25
|
+
homedir,
|
|
26
|
+
exists: existsSync,
|
|
27
|
+
readFile: (path) => readFileSync(path, 'utf8'),
|
|
28
|
+
realpath: (path) => {
|
|
29
|
+
try {
|
|
30
|
+
return realpathSync(path);
|
|
31
|
+
} catch {
|
|
32
|
+
return path;
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
execPath: () => process.execPath,
|
|
36
|
+
// Signal 0 probes liveness without touching the process. EPERM means it
|
|
37
|
+
// exists but belongs to another user — alive for our purposes.
|
|
38
|
+
processAlive: (pid) => {
|
|
39
|
+
try {
|
|
40
|
+
process.kill(pid, 0);
|
|
41
|
+
return true;
|
|
42
|
+
} catch (err) {
|
|
43
|
+
return err?.code === 'EPERM';
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
run: (cmd, args, opts) =>
|
|
47
|
+
spawnSync(cmd, args, {
|
|
48
|
+
...opts,
|
|
49
|
+
encoding: 'utf8',
|
|
50
|
+
shell: process.platform === 'win32'
|
|
51
|
+
})
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export function checkRuntimeEnvironment(dir, env = runtimeEnv) {
|
|
55
|
+
return [
|
|
56
|
+
checkNpm(env),
|
|
57
|
+
checkNodeProvenance(env),
|
|
58
|
+
...checkPm2Install(dir, env),
|
|
59
|
+
...checkPm2Daemon(dir, env)
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── npm ─────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
// Debian's `nodejs` package doesn't always pull in `npm`, and some distro
|
|
66
|
+
// splits ship an npm several majors behind the Node beside it. Both only
|
|
67
|
+
// surface at `selva update`, which is the worst moment to discover it.
|
|
68
|
+
const MIN_NPM_MAJOR = 10;
|
|
69
|
+
|
|
70
|
+
function checkNpm(env) {
|
|
71
|
+
const probe = env.run('npm', ['--version'], {});
|
|
72
|
+
const version = String(probe.stdout ?? '')
|
|
73
|
+
.trim()
|
|
74
|
+
.split('\n')
|
|
75
|
+
.pop();
|
|
76
|
+
|
|
77
|
+
if ((probe.status ?? 1) !== 0 || !/^\d+\.\d+\.\d+/.test(version ?? '')) {
|
|
78
|
+
return red(
|
|
79
|
+
'npm not found on PATH — `selva update` and `npm install` cannot run.\n ' +
|
|
80
|
+
'On Debian/Ubuntu the `nodejs` package does not always include it. Install\n ' +
|
|
81
|
+
'Node 24 from NodeSource, which bundles a matching npm:\n ' +
|
|
82
|
+
'curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash - && sudo apt-get install -y nodejs'
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const major = Number(/^(\d+)\./.exec(version)[1]);
|
|
87
|
+
if (major < MIN_NPM_MAJOR) {
|
|
88
|
+
return yellow(
|
|
89
|
+
`npm ${version} is older than the Node beside it usually ships (expected >= ${MIN_NPM_MAJOR}).\n ` +
|
|
90
|
+
'A distro-split npm can resolve dependencies the runtime does not expect.\n ' +
|
|
91
|
+
'Install Node from NodeSource so npm and Node come from one source.'
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return green(`npm ${version} on PATH`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Node provenance ─────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
// Which Node runs the app is decided by pm2's environment, not by the shell
|
|
100
|
+
// running doctor. When those differ — a distro Node on PATH for the login
|
|
101
|
+
// shell, an nvm Node for the daemon, or the reverse — doctor validates a
|
|
102
|
+
// version the app never uses, and engines.node passes while production runs
|
|
103
|
+
// something else entirely.
|
|
104
|
+
function checkNodeProvenance(env) {
|
|
105
|
+
const running = env.realpath(env.execPath());
|
|
106
|
+
const source = describeNodeSource(running);
|
|
107
|
+
|
|
108
|
+
const which = env.run(env.platform() === 'win32' ? 'where' : 'which', ['node'], {});
|
|
109
|
+
const onPath = String(which.stdout ?? '')
|
|
110
|
+
.trim()
|
|
111
|
+
.split('\n')[0]
|
|
112
|
+
?.trim();
|
|
113
|
+
|
|
114
|
+
if (!onPath) {
|
|
115
|
+
return yellow(`Node v${process.versions.node} at ${running} (${source}) — not resolvable on PATH`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (env.realpath(onPath) === running) {
|
|
119
|
+
return green(`Node v${process.versions.node} — ${source} (${running})`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return yellow(
|
|
123
|
+
`Two Node installations are in play: this shell resolves \`node\` to ${env.realpath(onPath)},\n ` +
|
|
124
|
+
`but doctor is running under ${running} (${source}). pm2 launches the app with the\n ` +
|
|
125
|
+
`Node it inherited when the daemon started, which may be neither. Confirm with:\n ` +
|
|
126
|
+
`npx pm2 describe selva-compute | grep -i "node.js version"`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// nvm/fnm-managed Nodes disappear for a systemd unit that starts before the
|
|
131
|
+
// user's shell profile runs — worth naming the manager rather than the path.
|
|
132
|
+
function describeNodeSource(path) {
|
|
133
|
+
const p = path.replace(/\\/g, '/');
|
|
134
|
+
if (/\/\.nvm\//.test(p)) return 'nvm-managed';
|
|
135
|
+
if (/\/\.fnm\/|\/fnm_multishells\//.test(p)) return 'fnm-managed';
|
|
136
|
+
if (/\/\.volta\//.test(p)) return 'volta-managed';
|
|
137
|
+
if (/^\/usr\/bin\/|^\/bin\//.test(p)) return 'distro package';
|
|
138
|
+
if (/^\/usr\/local\//.test(p)) return 'manual /usr/local install';
|
|
139
|
+
if (/\/snap\//.test(p)) return 'snap package';
|
|
140
|
+
return 'unknown source';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── pm2 install ─────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
// The deployment pins pm2 exactly (see deployment-package.js). This verifies
|
|
146
|
+
// the pin actually landed — a hand-edited package.json, a partial install, or
|
|
147
|
+
// an operator who ran `npm i -g pm2` and deleted the local one all end here.
|
|
148
|
+
function checkPm2Install(dir, env) {
|
|
149
|
+
const out = [];
|
|
150
|
+
const pkgPath = join(dir, 'node_modules', 'pm2', 'package.json');
|
|
151
|
+
|
|
152
|
+
if (!env.exists(pkgPath)) {
|
|
153
|
+
out.push(
|
|
154
|
+
red(
|
|
155
|
+
`pm2 is not installed in this deployment (expected ${join(dir, 'node_modules', 'pm2')}).\n ` +
|
|
156
|
+
'Selva ships its own pinned pm2 on purpose — the `selva` commands refuse to fall\n ' +
|
|
157
|
+
'back to a global one, because two pm2 versions sharing a daemon hang restarts.\n ' +
|
|
158
|
+
'Fix: npm install'
|
|
159
|
+
)
|
|
160
|
+
);
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let installed = null;
|
|
165
|
+
try {
|
|
166
|
+
installed = JSON.parse(env.readFile(pkgPath)).version ?? null;
|
|
167
|
+
} catch {
|
|
168
|
+
// Unreadable package.json — reported below as an unverifiable version.
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!installed) {
|
|
172
|
+
out.push(yellow('pm2 installed but its version is unreadable — cannot verify the pin'));
|
|
173
|
+
} else if (installed === PM2_VERSION) {
|
|
174
|
+
out.push(green(`pm2 ${installed} installed locally (matches the pinned version)`));
|
|
175
|
+
} else {
|
|
176
|
+
out.push(
|
|
177
|
+
yellow(
|
|
178
|
+
`pm2 ${installed} is installed but this scaffold pins ${PM2_VERSION}.\n ` +
|
|
179
|
+
'The pin is exact because pm2 CLI and daemon must match. Realign it:\n ' +
|
|
180
|
+
`npm install pm2@${PM2_VERSION} && npx pm2 update`
|
|
181
|
+
)
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// A pm2 listed in dependencies with a range instead of the exact pin means
|
|
186
|
+
// package.json was hand-edited; the next `npm install` silently drifts again.
|
|
187
|
+
const declared = readDeclaredPm2(dir, env);
|
|
188
|
+
if (declared && declared !== PM2_VERSION) {
|
|
189
|
+
out.push(
|
|
190
|
+
yellow(
|
|
191
|
+
`package.json declares pm2 "${declared}" rather than the exact pin ${PM2_VERSION}.\n ` +
|
|
192
|
+
'A range lets a later `npm install` adopt a version the running daemon is not.\n ' +
|
|
193
|
+
'Run `selva migrate` to restore the canonical package.json.'
|
|
194
|
+
)
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function readDeclaredPm2(dir, env) {
|
|
202
|
+
try {
|
|
203
|
+
const pkg = JSON.parse(env.readFile(join(dir, 'package.json')));
|
|
204
|
+
return pkg.dependencies?.pm2 ?? null;
|
|
205
|
+
} catch {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── pm2 daemon ──────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
// Reports three distinct states rather than pass/fail: no daemon (fine,
|
|
213
|
+
// nothing started yet), a matching daemon, or a daemon a foreign pm2 owns.
|
|
214
|
+
//
|
|
215
|
+
// `pm2 ping` SPAWNS a daemon when none is running, and doctor must stay
|
|
216
|
+
// read-only — so liveness is checked via pm2.pid + signal 0 first, and pm2
|
|
217
|
+
// itself is only invoked once a live daemon is confirmed.
|
|
218
|
+
//
|
|
219
|
+
// The daemon-is-newer direction is red and deliberately offers no fix: `pm2
|
|
220
|
+
// update` would downgrade the daemon and drop its process table, leaving
|
|
221
|
+
// selva-compute unregistered. Same reasoning as ensurePm2InSync in commands/pm2.js.
|
|
222
|
+
function checkPm2Daemon(dir, env) {
|
|
223
|
+
const bin = join(dir, 'node_modules', '.bin', env.platform() === 'win32' ? 'pm2.cmd' : 'pm2');
|
|
224
|
+
if (!env.exists(bin)) return [];
|
|
225
|
+
|
|
226
|
+
const pm2Home = env.env().PM2_HOME ?? join(env.homedir(), '.pm2');
|
|
227
|
+
const pidPath = join(pm2Home, 'pm2.pid');
|
|
228
|
+
if (!env.exists(pidPath)) {
|
|
229
|
+
return [yellow('pm2 daemon is not running — it starts on `selva start`')];
|
|
230
|
+
}
|
|
231
|
+
let pid = NaN;
|
|
232
|
+
try {
|
|
233
|
+
pid = Number(String(env.readFile(pidPath)).trim());
|
|
234
|
+
} catch {
|
|
235
|
+
// Unreadable pid file — fall through to the stale case.
|
|
236
|
+
}
|
|
237
|
+
if (!Number.isInteger(pid) || pid <= 0 || !env.processAlive(pid)) {
|
|
238
|
+
return [yellow('pm2 daemon is not running (stale pm2.pid) — it starts on `selva start`')];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const probe = env.run(bin, ['ping'], { cwd: dir });
|
|
242
|
+
const output = String(probe.stdout ?? '') + String(probe.stderr ?? '');
|
|
243
|
+
|
|
244
|
+
if (!/out-of-date/i.test(output)) {
|
|
245
|
+
if (/pong/i.test(output)) return [green('pm2 daemon responds and matches the local pm2')];
|
|
246
|
+
return [yellow('pm2 daemon did not answer `pm2 ping` — check `npx pm2 ping` by hand')];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const { daemon, local } = parseSkew(output);
|
|
250
|
+
if (daemon && local && compareVersions(daemon, local) > 0) {
|
|
251
|
+
return [
|
|
252
|
+
red(
|
|
253
|
+
`The running pm2 daemon (v${daemon}) is NEWER than this deployment's pm2 (v${local}),\n ` +
|
|
254
|
+
'so a pm2 from outside the deployment owns it. `pm2 update` would downgrade the\n ' +
|
|
255
|
+
'daemon and drop its process table, so `selva start` refuses outright.\n ' +
|
|
256
|
+
'Resolve it (the app goes down briefly):\n ' +
|
|
257
|
+
`npx pm2 kill && sudo npm uninstall -g pm2 && npm install && npm start\n ` +
|
|
258
|
+
'Then re-run: npx pm2 save && npx pm2 startup systemd -u $USER --hp $HOME'
|
|
259
|
+
)
|
|
260
|
+
];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return [
|
|
264
|
+
yellow(
|
|
265
|
+
`pm2 daemon (v${daemon ?? 'unknown'}) differs from the local pm2 (v${local ?? PM2_VERSION}).\n ` +
|
|
266
|
+
'Resync it — this briefly restarts managed processes: npx pm2 update'
|
|
267
|
+
)
|
|
268
|
+
];
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function parseSkew(output) {
|
|
272
|
+
// eslint-disable-next-line no-control-regex -- pm2 colourises via chalk on a TTY
|
|
273
|
+
const clean = output.replace(/\[[0-9;]*m/g, '');
|
|
274
|
+
return {
|
|
275
|
+
daemon: /In memory PM2 version:\s*v?(\d+\.\d+\.\d+)/i.exec(clean)?.[1] ?? null,
|
|
276
|
+
local: /Local PM2 version:\s*v?(\d+\.\d+\.\d+)/i.exec(clean)?.[1] ?? null
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function compareVersions(a, b) {
|
|
281
|
+
const [x, y] = [a.split('.').map(Number), b.split('.').map(Number)];
|
|
282
|
+
for (let i = 0; i < 3; i++) {
|
|
283
|
+
if (x[i] > y[i]) return 1;
|
|
284
|
+
if (x[i] < y[i]) return -1;
|
|
285
|
+
}
|
|
286
|
+
return 0;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export { describeNodeSource, compareVersions, MIN_NPM_MAJOR };
|
package/src/cli.js
CHANGED
|
@@ -62,12 +62,12 @@ function printHelp() {
|
|
|
62
62
|
'',
|
|
63
63
|
pc.bold('Commands:'),
|
|
64
64
|
' init Reconfigure this deployment (prompts again)',
|
|
65
|
-
' doctor
|
|
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',
|
|
69
69
|
' logs pm2 logs selva-compute',
|
|
70
|
-
' update npm update @selvajs/
|
|
70
|
+
' update npm update @selvajs/cli + @selvajs/selva, then restart',
|
|
71
71
|
' migrate Bring package.json onto the current layout',
|
|
72
72
|
' keys rotate <hmac|at-rest> Rotate a secret in .env (destructive)',
|
|
73
73
|
'',
|