@selvajs/cli 4.8.0-beta.6 → 4.8.0-beta.8
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
|
-
##
|
|
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
|
|
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.8.0-beta.
|
|
3
|
+
"version": "4.8.0-beta.8",
|
|
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,7 +28,10 @@
|
|
|
28
28
|
},
|
|
29
29
|
"files": [
|
|
30
30
|
"bin",
|
|
31
|
-
"src"
|
|
31
|
+
"src",
|
|
32
|
+
"!**/__tests__/**",
|
|
33
|
+
"!**/*.test.*",
|
|
34
|
+
"!**/*.spec.*"
|
|
32
35
|
],
|
|
33
36
|
"engines": {
|
|
34
37
|
"node": ">=22.0.0"
|
package/src/commands/keys.js
CHANGED
|
@@ -14,8 +14,7 @@ const TARGETS = {
|
|
|
14
14
|
warning: [
|
|
15
15
|
'This will:',
|
|
16
16
|
pc.red(' • log every signed-in user out (existing session cookies stop verifying)'),
|
|
17
|
-
pc.red(' • invalidate share
|
|
18
|
-
pc.dim(' (only relevant when SHARE_LINK_SECRET / INVITE_TOKEN_SECRET are unset)')
|
|
17
|
+
pc.red(' • invalidate every share link and pending invite')
|
|
19
18
|
].join('\n')
|
|
20
19
|
},
|
|
21
20
|
'at-rest': {
|
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
// Node engine detection in `selva doctor` (issue #176).
|
|
2
|
-
//
|
|
3
|
-
// The CLI carries its own range check rather than importing @selvajs/server:
|
|
4
|
-
// it scaffolds the deployment that installs the runtime, so it cannot depend on
|
|
5
|
-
// it. These pin the two behaviours that matter — the incident direction is
|
|
6
|
-
// caught, and an unparseable range never produces a false failure that would
|
|
7
|
-
// tell an operator to upgrade a Node that is already fine.
|
|
8
|
-
//
|
|
9
|
-
// Run by `pnpm test` via node's built-in runner.
|
|
10
|
-
|
|
11
|
-
import { test } from 'node:test';
|
|
12
|
-
import assert from 'node:assert/strict';
|
|
13
|
-
import { satisfiesNodeRange } from '../commands/doctor.js';
|
|
14
|
-
|
|
15
|
-
test('catches the reported incident — Node 20 host, runtime requiring >=22', () => {
|
|
16
|
-
assert.equal(satisfiesNodeRange('20.20.2', '>=22.0.0'), false);
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
test('accepts a host that satisfies the floor', () => {
|
|
20
|
-
assert.equal(satisfiesNodeRange('22.0.0', '>=22.0.0'), true);
|
|
21
|
-
assert.equal(satisfiesNodeRange('24.16.0', '>=22.0.0'), true);
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
test('accepts a v-prefixed version, as process.version reports it', () => {
|
|
25
|
-
assert.equal(satisfiesNodeRange('v22.4.0', '>=22.0.0'), true);
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
test('is exact at the boundary', () => {
|
|
29
|
-
assert.equal(satisfiesNodeRange('21.9.9', '>=22.0.0'), false);
|
|
30
|
-
assert.equal(satisfiesNodeRange('22.0.0', '>22.0.0'), false);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
test('handles caret, tilde, alternatives and conjunctions', () => {
|
|
34
|
-
assert.equal(satisfiesNodeRange('22.5.0', '^22.0.0'), true);
|
|
35
|
-
assert.equal(satisfiesNodeRange('23.0.0', '^22.0.0'), false);
|
|
36
|
-
assert.equal(satisfiesNodeRange('22.1.5', '~22.1.0'), true);
|
|
37
|
-
assert.equal(satisfiesNodeRange('20.0.0', '^18.0.0 || ^20.0.0'), true);
|
|
38
|
-
assert.equal(satisfiesNodeRange('19.0.0', '^18.0.0 || ^20.0.0'), false);
|
|
39
|
-
assert.equal(satisfiesNodeRange('20.0.0', '>=18 <21'), true);
|
|
40
|
-
assert.equal(satisfiesNodeRange('21.0.0', '>=18 <21'), false);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
test('treats a bare major or x-range as the whole line', () => {
|
|
44
|
-
assert.equal(satisfiesNodeRange('22.7.1', '22'), true);
|
|
45
|
-
assert.equal(satisfiesNodeRange('22.7.1', '22.x'), true);
|
|
46
|
-
assert.equal(satisfiesNodeRange('23.0.0', '22.x'), false);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test('returns null — never a false failure — when it cannot parse', () => {
|
|
50
|
-
// A wrong `false` would tell an operator to upgrade a Node that is fine.
|
|
51
|
-
assert.equal(satisfiesNodeRange('22.0.0', 'lts/*'), null);
|
|
52
|
-
assert.equal(satisfiesNodeRange('not-a-version', '>=22.0.0'), null);
|
|
53
|
-
assert.equal(satisfiesNodeRange('22.0.0', ''), null);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test('agrees with @selvajs/server satisfiesRange on the shared cases', () => {
|
|
57
|
-
// The two implementations are duplicated by necessity; if they diverge, the
|
|
58
|
-
// admin UI and the CLI would disagree about the same deployment.
|
|
59
|
-
const cases = [
|
|
60
|
-
['20.20.2', '>=22.0.0', false],
|
|
61
|
-
['22.0.0', '>=22.0.0', true],
|
|
62
|
-
['22.5.0', '^22.0.0', true],
|
|
63
|
-
['23.0.0', '^22.0.0', false],
|
|
64
|
-
['20.0.0', '>=18 <21', true],
|
|
65
|
-
['22.0.0', 'lts/*', null]
|
|
66
|
-
];
|
|
67
|
-
for (const [version, range, expected] of cases) {
|
|
68
|
-
assert.equal(satisfiesNodeRange(version, range), expected, `${version} vs ${range}`);
|
|
69
|
-
}
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
// ── The floor itself must not be hardcoded ──────────────────────────────
|
|
73
|
-
|
|
74
|
-
test('requiredNodeRange reads the CLI package.json, not a copy in code', async () => {
|
|
75
|
-
const { requiredNodeRange } = await import('../paths.js');
|
|
76
|
-
const { readFileSync } = await import('node:fs');
|
|
77
|
-
const { join, dirname } = await import('node:path');
|
|
78
|
-
const { fileURLToPath } = await import('node:url');
|
|
79
|
-
|
|
80
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
81
|
-
const pkg = JSON.parse(readFileSync(join(here, '..', '..', 'package.json'), 'utf8'));
|
|
82
|
-
|
|
83
|
-
// If someone bumps engines.node, this follows automatically. A hardcoded
|
|
84
|
-
// literal anywhere in the scaffolder would fail here.
|
|
85
|
-
assert.equal(requiredNodeRange(), pkg.engines.node);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
test('the create-time guard tracks a bumped floor', async () => {
|
|
89
|
-
const { requiredNodeRange } = await import('../paths.js');
|
|
90
|
-
const range = requiredNodeRange();
|
|
91
|
-
assert.ok(range, 'engines.node must be declared for the scaffolder to enforce it');
|
|
92
|
-
|
|
93
|
-
// The floor is whatever package.json says; a host below it is refused.
|
|
94
|
-
const floorMajor = Number(/(\d+)/.exec(range)[1]);
|
|
95
|
-
assert.equal(satisfiesNodeRange(`${floorMajor - 1}.0.0`, range), false);
|
|
96
|
-
assert.equal(satisfiesNodeRange(`${floorMajor}.0.0`, range), true);
|
|
97
|
-
});
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
// `selva migrate` rewrites the operator's .env in place, so these pin the
|
|
2
|
-
// blast radius: only the key changes, and only on lines that are live config.
|
|
3
|
-
// Run by `pnpm test` via node's built-in runner.
|
|
4
|
-
|
|
5
|
-
import { test } from 'node:test';
|
|
6
|
-
import assert from 'node:assert/strict';
|
|
7
|
-
import { renameEnvKeys, RENAMED_ENV_VARS, REPLACED_ENV_VARS } from '../env.js';
|
|
8
|
-
|
|
9
|
-
test('renames a deprecated key and leaves everything else byte-identical', () => {
|
|
10
|
-
const before = [
|
|
11
|
-
'# Selva deployment',
|
|
12
|
-
'ORIGIN=https://example.com',
|
|
13
|
-
'',
|
|
14
|
-
'# Longest a solve may run.',
|
|
15
|
-
'MAX_SOLVE_DURATION_MS=45000',
|
|
16
|
-
'COMPUTE_RATE_LIMIT_MAX=120',
|
|
17
|
-
''
|
|
18
|
-
].join('\n');
|
|
19
|
-
|
|
20
|
-
const { text, changes } = renameEnvKeys(before, RENAMED_ENV_VARS);
|
|
21
|
-
|
|
22
|
-
assert.deepEqual(changes, [['MAX_SOLVE_DURATION_MS', 'COMPUTE_SOLVE_DEADLINE_MS', 'renamed']]);
|
|
23
|
-
assert.equal(text, before.replace('MAX_SOLVE_DURATION_MS', 'COMPUTE_SOLVE_DEADLINE_MS'));
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
test('preserves the value verbatim, including quotes and spacing', () => {
|
|
27
|
-
const { text } = renameEnvKeys(
|
|
28
|
-
'COMPUTE_RESPONSE_CACHE_MB="512"\n DEFINITION_CACHE_TTL_MS = 60000\n',
|
|
29
|
-
RENAMED_ENV_VARS
|
|
30
|
-
);
|
|
31
|
-
assert.match(text, /^COMPUTE_SOLVE_CACHE_MB="512"$/m);
|
|
32
|
-
assert.match(text, /^ {3}REMOTE_DEFINITION_CACHE_TTL_MS = 60000$/m);
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
test('drops the old line when the new name is already set', () => {
|
|
36
|
-
const { text, changes } = renameEnvKeys(
|
|
37
|
-
'MAX_SOLVE_DURATION_MS=45000\nCOMPUTE_SOLVE_DEADLINE_MS=30000\n',
|
|
38
|
-
RENAMED_ENV_VARS
|
|
39
|
-
);
|
|
40
|
-
// The server resolves the same way (new name wins), so keeping both would
|
|
41
|
-
// preserve a line that does nothing.
|
|
42
|
-
assert.deepEqual(changes, [['MAX_SOLVE_DURATION_MS', 'COMPUTE_SOLVE_DEADLINE_MS', 'dropped']]);
|
|
43
|
-
assert.equal(text, 'COMPUTE_SOLVE_DEADLINE_MS=30000\n');
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
test('reports no changes for an already-current file', () => {
|
|
47
|
-
const { changes } = renameEnvKeys('COMPUTE_SOLVE_DEADLINE_MS=30000\n', RENAMED_ENV_VARS);
|
|
48
|
-
assert.deepEqual(changes, []);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
test('ignores a commented-out old name — it is not active config', () => {
|
|
52
|
-
const before = '# MAX_SOLVE_DURATION_MS=100000\n';
|
|
53
|
-
const { text, changes } = renameEnvKeys(before, RENAMED_ENV_VARS);
|
|
54
|
-
assert.deepEqual(changes, []);
|
|
55
|
-
assert.equal(text, before);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
test('leaves value-changing replacements alone — migrate must not guess', () => {
|
|
59
|
-
const before = 'SELVA_FLAG_COMPUTE_DEBUG_VERBOSE=true\n';
|
|
60
|
-
const { text, changes } = renameEnvKeys(before, RENAMED_ENV_VARS);
|
|
61
|
-
assert.deepEqual(changes, []);
|
|
62
|
-
assert.equal(text, before);
|
|
63
|
-
// It is still reported by `selva doctor`, just not auto-fixed.
|
|
64
|
-
assert.ok(REPLACED_ENV_VARS.SELVA_FLAG_COMPUTE_DEBUG_VERBOSE);
|
|
65
|
-
});
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
// PM2 daemon/CLI skew direction (issue #118, defect 2).
|
|
2
|
-
//
|
|
3
|
-
// The old check treated any `out-of-date` report as "run `pm2 update`". That is
|
|
4
|
-
// only correct when the daemon is OLDER than the deployment-local CLI. In the
|
|
5
|
-
// reported incident a global pm2 v6 owned the daemon while the deployment pinned
|
|
6
|
-
// v5.4.3 — `pm2 update` ran the v5 CLI against it, downgrading the daemon and
|
|
7
|
-
// feeding it a v6 dump, so the process table came back empty and selva-compute
|
|
8
|
-
// was never re-registered.
|
|
9
|
-
//
|
|
10
|
-
// Run by `pnpm test` via node's built-in runner.
|
|
11
|
-
|
|
12
|
-
import { test } from 'node:test';
|
|
13
|
-
import assert from 'node:assert/strict';
|
|
14
|
-
import { parsePm2Skew, daemonOutranksCli } from '../commands/pm2.js';
|
|
15
|
-
|
|
16
|
-
// pm2 colourises through chalk on a TTY; build the codes rather than embedding
|
|
17
|
-
// raw control characters in the source.
|
|
18
|
-
const ESC = String.fromCharCode(27);
|
|
19
|
-
const colour = (s) => `${ESC}[34m${ESC}[1m${s}${ESC}[22m${ESC}[39m`;
|
|
20
|
-
|
|
21
|
-
const REAL_OUTPUT =
|
|
22
|
-
`${ESC}[31m${ESC}[1m>>>> In-memory PM2 is out-of-date, do:\n>>>> $ pm2 update${ESC}[22m${ESC}[39m\n` +
|
|
23
|
-
`In memory PM2 version: ${colour('6.0.14')}\n` +
|
|
24
|
-
`Local PM2 version: ${colour('5.4.3')}\n`;
|
|
25
|
-
|
|
26
|
-
test('parses both versions out of real colourised pm2 output', () => {
|
|
27
|
-
assert.deepEqual(parsePm2Skew(REAL_OUTPUT), { daemon: '6.0.14', local: '5.4.3' });
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
test('parses plain uncoloured output (non-TTY)', () => {
|
|
31
|
-
const plain = 'In memory PM2 version: 5.4.3\nLocal PM2 version: 5.4.3\n';
|
|
32
|
-
assert.deepEqual(parsePm2Skew(plain), { daemon: '5.4.3', local: '5.4.3' });
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
test('returns nulls when the message shape changes, so callers fall back to resync', () => {
|
|
36
|
-
assert.deepEqual(parsePm2Skew('something else entirely'), { daemon: null, local: null });
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test('flags the incident direction — global daemon newer than the pinned CLI', () => {
|
|
40
|
-
assert.equal(daemonOutranksCli('6.0.14', '5.4.3'), true);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
test('allows the repairable direction — daemon older than the CLI', () => {
|
|
44
|
-
assert.equal(daemonOutranksCli('5.4.3', '6.0.14'), false);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
test('is false when the versions match', () => {
|
|
48
|
-
assert.equal(daemonOutranksCli('5.4.3', '5.4.3'), false);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
test('compares minor and patch, not just major', () => {
|
|
52
|
-
assert.equal(daemonOutranksCli('5.5.0', '5.4.3'), true);
|
|
53
|
-
assert.equal(daemonOutranksCli('5.4.4', '5.4.3'), true);
|
|
54
|
-
assert.equal(daemonOutranksCli('5.4.3', '5.4.4'), false);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
test('never blocks on an unparseable version — resync stays the default', () => {
|
|
58
|
-
assert.equal(daemonOutranksCli(null, '5.4.3'), false);
|
|
59
|
-
assert.equal(daemonOutranksCli('6.0.14', null), false);
|
|
60
|
-
});
|