@selvajs/cli 4.7.4-beta.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selvajs/cli",
3
- "version": "4.7.4-beta.1",
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
+ });
@@ -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) {
@@ -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) sideFileChanges.push(`${pc.red('-')} selva.config.js ${pc.dim('(no longer needed; providers are env-driven)')}`);
71
- if (ecoHasStaleRuntime) sideFileChanges.push(`${pc.yellow('~')} ecosystem.config.cjs ${pc.dim('(rewrite: @selvajs/runtime → @selvajs/selva)')}`);
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']) reasons.push('@selvajs/platform is now bundled into @selvajs/selva');
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 '""';