@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 +7 -12
- package/package.json +6 -3
- package/src/checks/boot.js +151 -0
- package/src/checks/config.js +189 -0
- package/src/checks/result.js +27 -0
- package/src/checks/runtime.js +372 -0
- package/src/cli.js +70 -2
- package/src/commands/create.js +71 -76
- package/src/commands/doctor.js +163 -332
- package/src/commands/init.js +13 -32
- package/src/commands/keys.js +2 -15
- package/src/commands/migrate.js +321 -115
- package/src/commands/pm2.js +15 -18
- package/src/deployment-package.js +254 -0
- package/src/env.js +197 -25
- package/src/node-range.js +66 -0
- package/src/paths.js +46 -3
- 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
|
-
## 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
|
|
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": ">=
|
|
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
|
+
}
|