@selvajs/cli 4.7.4-beta.0 → 4.8.0-beta.2
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/package.json +2 -2
- package/src/__tests__/env-rename.test.js +65 -0
- package/src/commands/doctor.js +18 -6
- package/src/commands/migrate.js +44 -10
- package/src/env.js +65 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@selvajs/cli",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.8.0-beta.2",
|
|
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",
|
|
@@ -40,6 +40,6 @@
|
|
|
40
40
|
"scripts": {
|
|
41
41
|
"start": "node ./bin/cli.js",
|
|
42
42
|
"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); }\""
|
|
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); }\" && node --test \"./src/__tests__/*.test.js\""
|
|
44
44
|
}
|
|
45
45
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
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
|
+
});
|
package/src/commands/doctor.js
CHANGED
|
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
7
|
import { homedir } from 'node:os';
|
|
8
8
|
import * as p from '@clack/prompts';
|
|
9
9
|
import pc from 'picocolors';
|
|
10
|
-
import { readEnvFile } from '../env.js';
|
|
10
|
+
import { readEnvFile, RENAMED_ENV_VARS, REPLACED_ENV_VARS } from '../env.js';
|
|
11
11
|
import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
|
|
12
12
|
import { detectDrift } from './migrate.js';
|
|
13
13
|
|
|
@@ -85,6 +85,21 @@ export async function runDoctor() {
|
|
|
85
85
|
checks.push(yellow('ORIGIN unset — required behind a reverse proxy'));
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
for (const [oldName, newName] of Object.entries(RENAMED_ENV_VARS)) {
|
|
89
|
+
if (env[oldName] === undefined) continue;
|
|
90
|
+
checks.push(
|
|
91
|
+
env[newName] === undefined
|
|
92
|
+
? yellow(`${oldName} is deprecated — \`selva migrate\` renames it to ${newName}`)
|
|
93
|
+
: yellow(`${oldName} is deprecated and ignored — ${newName} is set and wins`)
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Not auto-fixable: the replacement encodes a value, so migrate won't guess.
|
|
98
|
+
for (const [oldName, replacement] of Object.entries(REPLACED_ENV_VARS)) {
|
|
99
|
+
if (env[oldName] === undefined) continue;
|
|
100
|
+
checks.push(yellow(`${oldName} is deprecated — replace it with ${replacement}`));
|
|
101
|
+
}
|
|
102
|
+
|
|
88
103
|
// ── Render ─────────────────────────────────────────────────────────
|
|
89
104
|
let failures = 0;
|
|
90
105
|
for (const c of await Promise.all(checks)) {
|
|
@@ -321,9 +336,7 @@ function checkBootPersistence(dir) {
|
|
|
321
336
|
|
|
322
337
|
// systemd unit: present and pointing at deployment-local pm2.
|
|
323
338
|
const user = process.env.USER ?? process.env.LOGNAME;
|
|
324
|
-
const unitPath = user
|
|
325
|
-
? `/etc/systemd/system/pm2-${user}.service`
|
|
326
|
-
: null;
|
|
339
|
+
const unitPath = user ? `/etc/systemd/system/pm2-${user}.service` : null;
|
|
327
340
|
|
|
328
341
|
if (unitPath && existsSync(unitPath)) {
|
|
329
342
|
const localPm2 = join(dir, 'node_modules', 'pm2', 'bin', 'pm2');
|
|
@@ -494,8 +507,7 @@ function checkHeaderAuth(dir, env, dataProvider) {
|
|
|
494
507
|
Boolean(env.HEADER_AUTH_EMAIL_HEADER),
|
|
495
508
|
Boolean(env.HEADER_AUTH_DISPLAY_NAME_HEADER)
|
|
496
509
|
].filter(Boolean).length;
|
|
497
|
-
const headerList =
|
|
498
|
-
`UPN=${resolved.upn}, Email=${resolved.email}, DisplayName=${resolved.displayName}`;
|
|
510
|
+
const headerList = `UPN=${resolved.upn}, Email=${resolved.email}, DisplayName=${resolved.displayName}`;
|
|
499
511
|
if (overrides === 0) {
|
|
500
512
|
out.push(green(`header names (bundled defaults): ${headerList}`));
|
|
501
513
|
} else if (overrides === 3) {
|
package/src/commands/migrate.js
CHANGED
|
@@ -2,18 +2,13 @@
|
|
|
2
2
|
// provider packages), remove stale selva.config.js, update ecosystem.config.cjs.
|
|
3
3
|
// Idempotent; mirrors update's lifecycle (stop/mutate/start with rollback on failure).
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
existsSync,
|
|
7
|
-
readFileSync,
|
|
8
|
-
writeFileSync,
|
|
9
|
-
copyFileSync,
|
|
10
|
-
rmSync
|
|
11
|
-
} from 'node:fs';
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync, copyFileSync, rmSync } from 'node:fs';
|
|
12
6
|
import { join } from 'node:path';
|
|
13
7
|
import { spawnSync, execSync } from 'node:child_process';
|
|
14
8
|
import * as p from '@clack/prompts';
|
|
15
9
|
import pc from 'picocolors';
|
|
16
10
|
import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
|
|
11
|
+
import { renameEnvKeys, RENAMED_ENV_VARS } from '../env.js';
|
|
17
12
|
|
|
18
13
|
const APP_NAME = 'selva-compute';
|
|
19
14
|
|
|
@@ -66,9 +61,31 @@ export async function runMigrate() {
|
|
|
66
61
|
const ecoHasStaleRuntime =
|
|
67
62
|
existsSync(ecoPath) && readFileSync(ecoPath, 'utf8').includes('@selvajs/runtime');
|
|
68
63
|
|
|
64
|
+
// Deprecated env keys. The server reads the old names for one more minor
|
|
65
|
+
// version, so this is a warning today and a silent fallback to defaults once
|
|
66
|
+
// that shim goes — rewriting now is what keeps a tuned value tuned.
|
|
67
|
+
const envPath = join(dir, '.env');
|
|
68
|
+
const envBefore = existsSync(envPath) ? readFileSync(envPath, 'utf8') : null;
|
|
69
|
+
const envRename = envBefore
|
|
70
|
+
? renameEnvKeys(envBefore, RENAMED_ENV_VARS)
|
|
71
|
+
: { text: '', changes: [] };
|
|
72
|
+
|
|
69
73
|
const sideFileChanges = [];
|
|
70
|
-
if (hasStaleConfig)
|
|
71
|
-
|
|
74
|
+
if (hasStaleConfig)
|
|
75
|
+
sideFileChanges.push(
|
|
76
|
+
`${pc.red('-')} selva.config.js ${pc.dim('(no longer needed; providers are env-driven)')}`
|
|
77
|
+
);
|
|
78
|
+
if (ecoHasStaleRuntime)
|
|
79
|
+
sideFileChanges.push(
|
|
80
|
+
`${pc.yellow('~')} ecosystem.config.cjs ${pc.dim('(rewrite: @selvajs/runtime → @selvajs/selva)')}`
|
|
81
|
+
);
|
|
82
|
+
for (const [oldName, newName, action] of envRename.changes) {
|
|
83
|
+
sideFileChanges.push(
|
|
84
|
+
action === 'dropped'
|
|
85
|
+
? `${pc.red('-')} .env ${pc.dim(`${oldName} (deprecated; ${newName} is already set)`)}`
|
|
86
|
+
: `${pc.yellow('~')} .env ${pc.dim(`${oldName} → ${newName}`)}`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
72
89
|
|
|
73
90
|
if (pkgDiff.length === 0 && sideFileChanges.length === 0) {
|
|
74
91
|
p.outro(pc.green('Already on the current layout — nothing to migrate.'));
|
|
@@ -114,6 +131,11 @@ export async function runMigrate() {
|
|
|
114
131
|
writeFileSync(ecoPath, ecoContent, 'utf8');
|
|
115
132
|
}
|
|
116
133
|
|
|
134
|
+
if (envRename.changes.length > 0) {
|
|
135
|
+
copyFileSync(envPath, envPath + '.bak');
|
|
136
|
+
writeFileSync(envPath, envRename.text, 'utf8');
|
|
137
|
+
}
|
|
138
|
+
|
|
117
139
|
// Clean install required (major version changes break legacy lockfile).
|
|
118
140
|
rmSync(join(dir, 'node_modules'), { recursive: true, force: true });
|
|
119
141
|
rmSync(join(dir, 'package-lock.json'), { force: true });
|
|
@@ -137,6 +159,9 @@ export async function runMigrate() {
|
|
|
137
159
|
if (ecoHasStaleRuntime && existsSync(ecoPath + '.bak')) {
|
|
138
160
|
copyFileSync(ecoPath + '.bak', ecoPath);
|
|
139
161
|
}
|
|
162
|
+
if (envRename.changes.length > 0 && existsSync(envPath + '.bak')) {
|
|
163
|
+
copyFileSync(envPath + '.bak', envPath);
|
|
164
|
+
}
|
|
140
165
|
// Try to bring the process back up (best-effort; at least package.json is consistent).
|
|
141
166
|
runPm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
|
|
142
167
|
p.outro(pc.red(`Migration aborted: ${err.message ?? err}`));
|
|
@@ -147,6 +172,7 @@ export async function runMigrate() {
|
|
|
147
172
|
const backupHints = ['package.json.bak'];
|
|
148
173
|
if (hasStaleConfig) backupHints.push('selva.config.js.bak');
|
|
149
174
|
if (ecoHasStaleRuntime) backupHints.push('ecosystem.config.cjs.bak');
|
|
175
|
+
if (envRename.changes.length > 0) backupHints.push('.env.bak');
|
|
150
176
|
|
|
151
177
|
if (status === 0) {
|
|
152
178
|
p.outro(
|
|
@@ -231,7 +257,8 @@ export function detectDrift(pkgJson, dir) {
|
|
|
231
257
|
const reasons = [];
|
|
232
258
|
if (deps['@selvajs/runtime']) reasons.push('@selvajs/runtime is the old runtime package');
|
|
233
259
|
if (deps['@selvajs/create']) reasons.push('@selvajs/create is the old CLI package');
|
|
234
|
-
if (deps['@selvajs/platform'])
|
|
260
|
+
if (deps['@selvajs/platform'])
|
|
261
|
+
reasons.push('@selvajs/platform is now bundled into @selvajs/selva');
|
|
235
262
|
if (deps['@selvajs/local-provider'])
|
|
236
263
|
reasons.push('@selvajs/local-provider is now bundled into @selvajs/selva');
|
|
237
264
|
if (deps['@selvajs/supabase-provider'])
|
|
@@ -251,6 +278,13 @@ export function detectDrift(pkgJson, dir) {
|
|
|
251
278
|
if (existsSync(ecoPath) && readFileSync(ecoPath, 'utf8').includes('@selvajs/runtime')) {
|
|
252
279
|
reasons.push('ecosystem.config.cjs still references @selvajs/runtime');
|
|
253
280
|
}
|
|
281
|
+
const envPath = join(dir, '.env');
|
|
282
|
+
if (existsSync(envPath)) {
|
|
283
|
+
const { changes } = renameEnvKeys(readFileSync(envPath, 'utf8'), RENAMED_ENV_VARS);
|
|
284
|
+
for (const [oldName, newName] of changes) {
|
|
285
|
+
reasons.push(`.env still uses ${oldName} (renamed to ${newName})`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
254
288
|
}
|
|
255
289
|
|
|
256
290
|
return reasons;
|
package/src/env.js
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Old env name → its replacement, for keys renamed without changing meaning.
|
|
7
|
+
* The server still reads each old name for one minor version and warns at boot
|
|
8
|
+
* (`readRenamed` in `@selvajs/server` `resolveComputeLimits`); `selva doctor`
|
|
9
|
+
* reports them and `selva migrate` rewrites them. Keep in sync with that list,
|
|
10
|
+
* and drop an entry when the server stops reading it.
|
|
11
|
+
*/
|
|
12
|
+
export const RENAMED_ENV_VARS = {
|
|
13
|
+
COMPUTE_DEFINITION_BYTE_CACHE_MB: 'COMPUTE_DEFINITION_CACHE_MB',
|
|
14
|
+
COMPUTE_RESPONSE_CACHE_MB: 'COMPUTE_SOLVE_CACHE_MB',
|
|
15
|
+
DEFINITION_CACHE_TTL_MS: 'REMOTE_DEFINITION_CACHE_TTL_MS',
|
|
16
|
+
MAX_SOLVE_DURATION_MS: 'COMPUTE_SOLVE_DEADLINE_MS'
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Deprecated vars whose replacement changes the VALUE too, so they can't be
|
|
21
|
+
* rewritten by renaming a key. `selva doctor` reports these; migrate leaves
|
|
22
|
+
* them alone rather than guessing.
|
|
23
|
+
*/
|
|
24
|
+
export const REPLACED_ENV_VARS = {
|
|
25
|
+
SELVA_FLAG_COMPUTE_DEBUG_VERBOSE: 'SELVA_FLAG_COMPUTE_DEBUG=verbose'
|
|
26
|
+
};
|
|
27
|
+
|
|
5
28
|
// Parse a .env file into { key: value }. Preserves nothing else.
|
|
6
29
|
export function parseEnv(text) {
|
|
7
30
|
const out = {};
|
|
@@ -78,6 +101,48 @@ export function writeEnvFile(path, template, values) {
|
|
|
78
101
|
writeFileSync(path, mergeEnv(template, values), 'utf8');
|
|
79
102
|
}
|
|
80
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Rewrite deprecated keys to their current names, in place. `renames` maps old
|
|
106
|
+
* name → new name. Only the key is touched — the value, comments, ordering and
|
|
107
|
+
* spacing survive, so an operator's tuned file stays recognisably theirs.
|
|
108
|
+
*
|
|
109
|
+
* A key already present under its new name is left alone and its old line is
|
|
110
|
+
* dropped: the server resolves the same way (new name wins), so keeping both
|
|
111
|
+
* would preserve a line that does nothing.
|
|
112
|
+
*
|
|
113
|
+
* Returns `{ text, changes }` where `changes` is `[oldName, newName, 'renamed'
|
|
114
|
+
* | 'dropped']` per line acted on; an empty array means the file was already
|
|
115
|
+
* current and nothing should be written.
|
|
116
|
+
*/
|
|
117
|
+
export function renameEnvKeys(text, renames) {
|
|
118
|
+
const present = new Set(Object.keys(parseEnv(text)));
|
|
119
|
+
const changes = [];
|
|
120
|
+
const out = [];
|
|
121
|
+
|
|
122
|
+
for (const line of text.split(/\r?\n/)) {
|
|
123
|
+
const stripped = line.trim();
|
|
124
|
+
const eq = stripped.indexOf('=');
|
|
125
|
+
if (!stripped || stripped.startsWith('#') || eq === -1) {
|
|
126
|
+
out.push(line);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const key = stripped.slice(0, eq).trim();
|
|
130
|
+
const newName = renames[key];
|
|
131
|
+
if (!newName) {
|
|
132
|
+
out.push(line);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (present.has(newName)) {
|
|
136
|
+
changes.push([key, newName, 'dropped']);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
out.push(line.replace(key, newName));
|
|
140
|
+
changes.push([key, newName, 'renamed']);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { text: out.join('\n'), changes };
|
|
144
|
+
}
|
|
145
|
+
|
|
81
146
|
function quoteIfNeeded(value) {
|
|
82
147
|
const s = String(value);
|
|
83
148
|
if (s === '') return '""';
|