@selvajs/cli 4.7.4-beta.0 → 4.8.0-beta.10

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.
@@ -2,47 +2,46 @@
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, renameSync } from 'node:fs';
12
6
  import { join } from 'node:path';
13
- import { spawnSync, execSync } from 'node:child_process';
7
+ import { 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';
17
-
18
- const APP_NAME = 'selva-compute';
19
-
20
- // Packages we own. Everything in this set is rewritten wholesale by migrate;
21
- // anything outside it (operator's own deps) gets dropped, which is the
22
- // explicit design choice — operators with custom deps should fork the
23
- // template rather than hand-patch the deployment package.json.
24
- const SELVA_DEPS = new Set([
25
- '@selvajs/cli',
26
- '@selvajs/selva',
27
- '@selvajs/runtime', // legacy — removed during migrate
28
- '@selvajs/platform', // legacy — bundled into @selvajs/selva
29
- '@selvajs/local-provider', // legacy — bundled into @selvajs/selva
30
- '@selvajs/supabase-provider', // legacy — bundled into @selvajs/selva
31
- '@selvajs/header-auth-provider', // legacy — bundled into @selvajs/selva
32
- '@selvajs/create' // legacy CLI — removed during migrate
33
- ]);
34
-
35
- const CANONICAL_SCRIPTS = {
36
- start: 'selva start',
37
- stop: 'selva stop',
38
- restart: 'selva restart',
39
- logs: 'selva logs',
40
- doctor: 'selva doctor',
41
- update: 'selva update'
42
- };
43
-
44
- export async function runMigrate() {
45
- const dir = resolveDeploymentDir();
11
+ import { renameEnvKeys, RENAMED_ENV_VARS } from '../env.js';
12
+ import {
13
+ buildDeploymentPackageJson,
14
+ CANONICAL_FIELDS,
15
+ DEPENDENCIES,
16
+ LEGACY_DEPENDENCIES
17
+ } from '../deployment-package.js';
18
+ import { APP_NAME, runPm2 } from './pm2.js';
19
+
20
+ /**
21
+ * Where `migrate` parks the old node_modules while npm installs the new one.
22
+ * Left behind only if the process is killed mid-migration — `detectDrift`
23
+ * reports it, because a stray copy of the dependency tree is both confusing and
24
+ * large enough to matter on a small VM.
25
+ */
26
+ export const NODE_MODULES_STASH = 'node_modules.selva-migrate-bak';
27
+
28
+ /**
29
+ * @param {object} [deps] Seams for tests. The rollback path destroys and
30
+ * restores a real dependency tree, so it has to be exercised against a real
31
+ * directory — but not against a real npm or a real pm2 daemon.
32
+ */
33
+ export async function runMigrate(
34
+ _argv,
35
+ {
36
+ dir: injectedDir,
37
+ // --prefer-online bypasses the stale-packument trap: npm can otherwise
38
+ // resolve a cached packument that predates the release being migrated to.
39
+ install = (cwd) => execSync('npm install --prefer-online', { cwd, stdio: 'pipe' }),
40
+ confirm = (message) => p.confirm({ message, initialValue: true }),
41
+ pm2 = runPm2
42
+ } = {}
43
+ ) {
44
+ const dir = injectedDir ?? resolveDeploymentDir();
46
45
  requireDeploymentDir(dir);
47
46
 
48
47
  p.intro(pc.bgCyan(pc.black(' selva migrate ')));
@@ -66,9 +65,31 @@ export async function runMigrate() {
66
65
  const ecoHasStaleRuntime =
67
66
  existsSync(ecoPath) && readFileSync(ecoPath, 'utf8').includes('@selvajs/runtime');
68
67
 
68
+ // Deprecated env keys. The server reads the old names for one more minor
69
+ // version, so this is a warning today and a silent fallback to defaults once
70
+ // that shim goes — rewriting now is what keeps a tuned value tuned.
71
+ const envPath = join(dir, '.env');
72
+ const envBefore = existsSync(envPath) ? readFileSync(envPath, 'utf8') : null;
73
+ const envRename = envBefore
74
+ ? renameEnvKeys(envBefore, RENAMED_ENV_VARS)
75
+ : { text: '', changes: [] };
76
+
69
77
  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)')}`);
78
+ if (hasStaleConfig)
79
+ sideFileChanges.push(
80
+ `${pc.red('-')} selva.config.js ${pc.dim('(no longer needed; providers are env-driven)')}`
81
+ );
82
+ if (ecoHasStaleRuntime)
83
+ sideFileChanges.push(
84
+ `${pc.yellow('~')} ecosystem.config.cjs ${pc.dim('(rewrite: @selvajs/runtime → @selvajs/selva)')}`
85
+ );
86
+ for (const [oldName, newName, action] of envRename.changes) {
87
+ sideFileChanges.push(
88
+ action === 'dropped'
89
+ ? `${pc.red('-')} .env ${pc.dim(`${oldName} (deprecated; ${newName} is already set)`)}`
90
+ : `${pc.yellow('~')} .env ${pc.dim(`${oldName} → ${newName}`)}`
91
+ );
92
+ }
72
93
 
73
94
  if (pkgDiff.length === 0 && sideFileChanges.length === 0) {
74
95
  p.outro(pc.green('Already on the current layout — nothing to migrate.'));
@@ -79,19 +100,22 @@ export async function runMigrate() {
79
100
  for (const line of pkgDiff) console.log(' ' + line);
80
101
  for (const line of sideFileChanges) console.log(' ' + line);
81
102
 
82
- const confirmed = await p.confirm({
83
- message: 'Apply these changes, reinstall, and restart?',
84
- initialValue: true
85
- });
103
+ const confirmed = await confirm('Apply these changes, reinstall, and restart?');
86
104
  if (p.isCancel(confirmed) || !confirmed) {
87
105
  p.cancel('Cancelled.');
88
106
  return;
89
107
  }
90
108
 
91
109
  // Stop pm2 before npm rewrites (SvelteKit lazy-loads chunks; see update command).
92
- const stopStatus = runPm2(dir, ['stop', APP_NAME], { inherit: false });
93
- if (stopStatus !== 0) {
94
- p.log.warn('pm2 stop did not succeed — selva-compute may not be running. Continuing.');
110
+ // A legacy deployment may predate the local-pm2 layout, so a missing binary
111
+ // is not fatal here — there is nothing running for us to stop.
112
+ try {
113
+ const stopStatus = pm2(dir, ['stop', APP_NAME], { inherit: false });
114
+ if (stopStatus !== 0) {
115
+ p.log.warn('pm2 stop did not succeed — selva-compute may not be running. Continuing.');
116
+ }
117
+ } catch {
118
+ p.log.warn('No deployment-local pm2 to stop — continuing.');
95
119
  }
96
120
 
97
121
  // Back up for rollback on npm-install failure.
@@ -114,22 +138,33 @@ export async function runMigrate() {
114
138
  writeFileSync(ecoPath, ecoContent, 'utf8');
115
139
  }
116
140
 
117
- // Clean install required (major version changes break legacy lockfile).
118
- rmSync(join(dir, 'node_modules'), { recursive: true, force: true });
119
- rmSync(join(dir, 'package-lock.json'), { force: true });
141
+ if (envRename.changes.length > 0) {
142
+ copyFileSync(envPath, envPath + '.bak');
143
+ writeFileSync(envPath, envRename.text, 'utf8');
144
+ }
145
+
146
+ // A clean install is required — a legacy lockfile pins the old package set
147
+ // across a major bump. But node_modules is also where pm2 lives, and the
148
+ // rollback below has to restart the app, so deleting it outright leaves a
149
+ // failed migration with no way back up. Rename instead: atomic, keeps the
150
+ // .bin symlinks intact, and restorable until the install succeeds.
151
+ const nodeModules = join(dir, 'node_modules');
152
+ const nodeModulesBak = join(dir, NODE_MODULES_STASH);
153
+ const lockPath = join(dir, 'package-lock.json');
154
+
155
+ rmSync(nodeModulesBak, { recursive: true, force: true });
156
+ if (existsSync(nodeModules)) renameSync(nodeModules, nodeModulesBak);
157
+ const lockBak = existsSync(lockPath) ? readFileSync(lockPath, 'utf8') : null;
158
+ rmSync(lockPath, { force: true });
120
159
 
121
160
  const s = p.spinner();
122
161
  s.start('Installing new dependencies (this can take a minute)');
123
162
  try {
124
- // --prefer-online to bypass the stale-packument trap documented in
125
- // docs/Hotfix-CLI-Runtime.md.
126
- execSync('npm install --prefer-online', {
127
- cwd: dir,
128
- stdio: 'pipe'
129
- });
163
+ install(dir);
130
164
  s.stop('Dependencies installed');
165
+ rmSync(nodeModulesBak, { recursive: true, force: true });
131
166
  } catch (err) {
132
- s.stop(pc.red('npm install failed — rolling back package.json'));
167
+ s.stop(pc.red('npm install failed — rolling back'));
133
168
  copyFileSync(bakPath, pkgPath);
134
169
  if (hasStaleConfig && existsSync(configPath + '.bak')) {
135
170
  copyFileSync(configPath + '.bak', configPath);
@@ -137,16 +172,32 @@ export async function runMigrate() {
137
172
  if (ecoHasStaleRuntime && existsSync(ecoPath + '.bak')) {
138
173
  copyFileSync(ecoPath + '.bak', ecoPath);
139
174
  }
140
- // Try to bring the process back up (best-effort; at least package.json is consistent).
141
- runPm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
175
+ if (envRename.changes.length > 0 && existsSync(envPath + '.bak')) {
176
+ copyFileSync(envPath + '.bak', envPath);
177
+ }
178
+
179
+ // Restore the dependency tree the app was running on. A half-installed
180
+ // node_modules from the failed attempt is worse than the old one.
181
+ rmSync(nodeModules, { recursive: true, force: true });
182
+ if (existsSync(nodeModulesBak)) renameSync(nodeModulesBak, nodeModules);
183
+ if (lockBak !== null) writeFileSync(lockPath, lockBak, 'utf8');
184
+
185
+ restartAfterRollback(dir, pm2);
142
186
  p.outro(pc.red(`Migration aborted: ${err.message ?? err}`));
143
187
  process.exit(1);
144
188
  }
145
189
 
146
- const status = runPm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
190
+ let status;
191
+ try {
192
+ status = pm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
193
+ } catch (err) {
194
+ status = 1;
195
+ p.log.error(`Could not invoke pm2: ${err instanceof Error ? err.message : err}`);
196
+ }
147
197
  const backupHints = ['package.json.bak'];
148
198
  if (hasStaleConfig) backupHints.push('selva.config.js.bak');
149
199
  if (ecoHasStaleRuntime) backupHints.push('ecosystem.config.cjs.bak');
200
+ if (envRename.changes.length > 0) backupHints.push('.env.bak');
150
201
 
151
202
  if (status === 0) {
152
203
  p.outro(
@@ -161,23 +212,39 @@ export async function runMigrate() {
161
212
  }
162
213
  }
163
214
 
164
- // Build target package.json with canonical dependencies (wholesale replace).
165
- // Drops non-canonical @selvajs/* and operator's own deps (by design).
166
- function buildTargetPackageJson(current) {
167
- const deps = {
168
- '@selvajs/cli': 'latest',
169
- '@selvajs/selva': 'latest',
170
- pm2: '^5.4.0'
171
- };
215
+ /**
216
+ * Bring the app back up after a failed migration.
217
+ *
218
+ * Never throws: the migration is already aborting, and an exception here would
219
+ * replace the rollback's diagnosis with a stack trace. A failure to restart is
220
+ * the operator's problem to act on, so it must be stated, not swallowed — the
221
+ * old code returned a bare 1 and printed nothing.
222
+ */
223
+ function restartAfterRollback(dir, pm2 = runPm2) {
224
+ try {
225
+ const status = pm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
226
+ if (status === 0) {
227
+ p.log.success(`Rolled back and restarted ${APP_NAME}.`);
228
+ return;
229
+ }
230
+ p.log.error(
231
+ `Rolled back, but \`pm2 start\` exited ${status}. The app may be down — ` +
232
+ `check \`pm2 logs ${APP_NAME}\` and start it with \`selva start\`.`
233
+ );
234
+ } catch (err) {
235
+ p.log.error(
236
+ `Rolled back, but pm2 could not be invoked (${err instanceof Error ? err.message : err}). ` +
237
+ `The app is likely down — run \`npm install\` then \`selva start\`.`
238
+ );
239
+ }
240
+ }
172
241
 
173
- return {
242
+ function buildTargetPackageJson(current) {
243
+ return buildDeploymentPackageJson({
174
244
  name: current.name ?? 'selva-deployment',
175
245
  version: current.version ?? '0.1.0',
176
- private: true,
177
- type: 'module',
178
- scripts: { ...CANONICAL_SCRIPTS },
179
- dependencies: deps
180
- };
246
+ engines: current.engines
247
+ });
181
248
  }
182
249
 
183
250
  // Format package.json diff for confirmation prompt (concise, not full diff).
@@ -209,38 +276,36 @@ function diffPackageJson(before, after) {
209
276
  }
210
277
  }
211
278
 
279
+ // Anything outside the canonical set is discarded by the rewrite.
280
+ // devDependencies and description used to vanish without appearing here, so
281
+ // the operator confirmed a change they were never shown.
282
+ for (const key of Object.keys(before).sort()) {
283
+ if (CANONICAL_FIELDS.has(key)) continue;
284
+ lines.push(`${pc.red('-')} ${key} ${pc.dim(summarize(before[key]))}`);
285
+ }
286
+
212
287
  return lines;
213
288
  }
214
289
 
215
- // Local pm2 runner (avoid circular dep; duplication is small).
216
- function runPm2(dir, args, { inherit = true } = {}) {
217
- const local = join(dir, 'node_modules', '.bin', process.platform === 'win32' ? 'pm2.cmd' : 'pm2');
218
- const bin = existsSync(local) ? local : 'pm2';
219
- const result = spawnSync(bin, args, {
220
- cwd: dir,
221
- stdio: inherit ? 'inherit' : 'pipe',
222
- shell: process.platform === 'win32'
223
- });
224
- if (result.error) return 1;
225
- return result.status ?? 0;
290
+ // One-line preview of a dropped field, enough to recognise what is being lost.
291
+ function summarize(value) {
292
+ if (value === null || typeof value !== 'object') return String(value);
293
+ const keys = Object.keys(value);
294
+ if (Array.isArray(value)) return `[${keys.length} item${keys.length === 1 ? '' : 's'}]`;
295
+ return keys.length ? `{${keys.join(', ')}}` : '{}';
226
296
  }
227
297
 
228
298
  // Exported for `selva doctor` to check layout drift without duplication.
229
299
  export function detectDrift(pkgJson, dir) {
230
300
  const deps = pkgJson?.dependencies ?? {};
231
301
  const reasons = [];
232
- if (deps['@selvajs/runtime']) reasons.push('@selvajs/runtime is the old runtime package');
233
- 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');
235
- if (deps['@selvajs/local-provider'])
236
- reasons.push('@selvajs/local-provider is now bundled into @selvajs/selva');
237
- if (deps['@selvajs/supabase-provider'])
238
- reasons.push('@selvajs/supabase-provider is now bundled into @selvajs/selva');
239
- if (deps['@selvajs/header-auth-provider'])
240
- reasons.push('@selvajs/header-auth-provider is now bundled into @selvajs/selva');
241
- if (!deps['@selvajs/selva']) reasons.push('@selvajs/selva is missing');
242
- if (!deps['@selvajs/cli']) reasons.push('@selvajs/cli is missing');
243
- if (!deps['pm2']) reasons.push('pm2 is not in dependencies');
302
+
303
+ for (const [name, why] of Object.entries(LEGACY_DEPENDENCIES)) {
304
+ if (deps[name]) reasons.push(`${name} is ${why}`);
305
+ }
306
+ for (const name of Object.keys(DEPENDENCIES)) {
307
+ if (!deps[name]) reasons.push(`${name} is missing`);
308
+ }
244
309
 
245
310
  if (dir) {
246
311
  const configPath = join(dir, 'selva.config.js');
@@ -251,10 +316,16 @@ export function detectDrift(pkgJson, dir) {
251
316
  if (existsSync(ecoPath) && readFileSync(ecoPath, 'utf8').includes('@selvajs/runtime')) {
252
317
  reasons.push('ecosystem.config.cjs still references @selvajs/runtime');
253
318
  }
319
+ const envPath = join(dir, '.env');
320
+ if (existsSync(envPath)) {
321
+ const { changes } = renameEnvKeys(readFileSync(envPath, 'utf8'), RENAMED_ENV_VARS);
322
+ for (const [oldName, newName] of changes) {
323
+ reasons.push(`.env still uses ${oldName} (renamed to ${newName})`);
324
+ }
325
+ }
254
326
  }
255
327
 
256
328
  return reasons;
257
329
  }
258
330
 
259
- // Re-exported for tests to verify without running migrate.
260
- export { buildTargetPackageJson, SELVA_DEPS };
331
+ export { buildTargetPackageJson, diffPackageJson };
@@ -9,10 +9,10 @@ import * as p from '@clack/prompts';
9
9
  import pc from 'picocolors';
10
10
  import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
11
11
 
12
- const APP_NAME = 'selva-compute';
12
+ export const APP_NAME = 'selva-compute';
13
13
 
14
14
  // Resolve to deployment-local pm2 (no global fallback; prevents version skew).
15
- function pm2Bin(dir) {
15
+ export function pm2Bin(dir) {
16
16
  const local = join(dir, 'node_modules', '.bin', process.platform === 'win32' ? 'pm2.cmd' : 'pm2');
17
17
  if (!existsSync(local)) {
18
18
  throw new Error(
@@ -25,7 +25,36 @@ function pm2Bin(dir) {
25
25
  return local;
26
26
  }
27
27
 
28
- // Check for daemon/CLI version mismatch; run `pm2 update` if stale.
28
+ // Parse the two versions pm2 prints alongside its out-of-date warning:
29
+ // In memory PM2 version: 6.0.14
30
+ // Local PM2 version: 5.4.3
31
+ // Returns nulls when the shape changes — callers then fall back to resyncing,
32
+ // which is the pre-existing behaviour.
33
+ export function parsePm2Skew(output) {
34
+ // eslint-disable-next-line no-control-regex -- pm2 colourises via chalk on a TTY
35
+ const clean = output.replace(/\[[0-9;]*m/g, '');
36
+ const daemon = /In memory PM2 version:\s*v?(\d+\.\d+\.\d+)/i.exec(clean);
37
+ const local = /Local PM2 version:\s*v?(\d+\.\d+\.\d+)/i.exec(clean);
38
+ return { daemon: daemon?.[1] ?? null, local: local?.[1] ?? null };
39
+ }
40
+
41
+ /**
42
+ * True when the running daemon is newer than the deployment-local pm2 — i.e. a
43
+ * foreign global pm2 owns it. `pm2 update` cannot fix that direction: it
44
+ * downgrades the daemon and hands it a dump from a newer version, which drops
45
+ * the process table so selva-compute is never re-registered (issue #118).
46
+ */
47
+ export function daemonOutranksCli(daemon, local) {
48
+ if (!daemon || !local) return false;
49
+ const [a, b] = [daemon.split('.').map(Number), local.split('.').map(Number)];
50
+ for (let i = 0; i < 3; i++) {
51
+ if (a[i] > b[i]) return true;
52
+ if (a[i] < b[i]) return false;
53
+ }
54
+ return false;
55
+ }
56
+
57
+ // Check for daemon/CLI version mismatch; run `pm2 update` only when it can help.
29
58
  function ensurePm2InSync(dir) {
30
59
  const bin = pm2Bin(dir);
31
60
  const probe = spawnSync(bin, ['ping'], {
@@ -36,6 +65,16 @@ function ensurePm2InSync(dir) {
36
65
  const output = (probe.stdout ?? '') + (probe.stderr ?? '');
37
66
  if (!/out-of-date/i.test(output)) return;
38
67
 
68
+ const { daemon, local } = parsePm2Skew(output);
69
+ if (daemonOutranksCli(daemon, local)) {
70
+ throw new Error(
71
+ `The running PM2 daemon (v${daemon}) is NEWER than this deployment's pm2 (v${local}), ` +
72
+ `so a global pm2 owns it. Running \`pm2 update\` would downgrade the daemon and drop ` +
73
+ `its process table, leaving ${APP_NAME} unregistered. Resolve the conflict first: ` +
74
+ `\`which -a pm2\`, \`pm2 -v\`, \`pm2 ping\`.`
75
+ );
76
+ }
77
+
39
78
  p.log.warn(
40
79
  'PM2 in-memory daemon is a different version than the deployment-local pm2 — ' +
41
80
  'running `pm2 update` to resync (this briefly restarts managed processes).'
@@ -53,8 +92,14 @@ function ensurePm2InSync(dir) {
53
92
  }
54
93
  }
55
94
 
56
- function runPm2(dir, args, { inherit = true } = {}) {
57
- const bin = pm2Bin(dir);
95
+ /**
96
+ * Run the deployment-local pm2.
97
+ *
98
+ * `bin` overrides where pm2 is resolved from — `selva migrate` wipes
99
+ * node_modules mid-flight and passes the binary it stashed beforehand, because
100
+ * resolving from the deployment at that moment would find nothing.
101
+ */
102
+ export function runPm2(dir, args, { inherit = true, bin = pm2Bin(dir) } = {}) {
58
103
  const result = spawnSync(bin, args, {
59
104
  cwd: dir,
60
105
  stdio: inherit ? 'inherit' : 'pipe',
@@ -0,0 +1,102 @@
1
+ // The package.json a deployment directory gets. `create` writes it at scaffold
2
+ // time and `migrate` rewrites an existing one onto it, so it lives here rather
3
+ // than in either command — two copies drifted once already, and the pm2 pin is
4
+ // exactly the field where a silent difference costs the most.
5
+
6
+ /**
7
+ * pm2 is pinned exactly, not caret-ranged.
8
+ *
9
+ * pm2's CLI and its long-running daemon must be the same version. A range lets
10
+ * two deployments installed a week apart resolve differently, and the newer one
11
+ * silently adopts the daemon the older one started — the skew `ensurePm2InSync`
12
+ * has to detect and, in the daemon-is-newer direction, cannot repair (#118).
13
+ */
14
+ const PM2_VERSION = '5.4.3';
15
+
16
+ const SCRIPTS = {
17
+ start: 'selva start',
18
+ stop: 'selva stop',
19
+ restart: 'selva restart',
20
+ logs: 'selva logs',
21
+ doctor: 'selva doctor',
22
+ update: 'selva update'
23
+ };
24
+
25
+ /**
26
+ * Packages a deployment carries. Providers are bundled into `@selvajs/selva`;
27
+ * the CLI is a dependency so `npm run start` resolves the `selva` bin locally
28
+ * instead of relying on a global install.
29
+ */
30
+ const DEPENDENCIES = {
31
+ '@selvajs/cli': 'latest',
32
+ '@selvajs/selva': 'latest',
33
+ pm2: PM2_VERSION
34
+ };
35
+
36
+ /**
37
+ * Legacy `@selvajs/*` packages that a deployment may still list. Each was
38
+ * either renamed or folded into `@selvajs/selva`; `migrate` drops them and
39
+ * `doctor` reports them as layout drift.
40
+ */
41
+ export const LEGACY_DEPENDENCIES = {
42
+ '@selvajs/runtime': 'the old runtime package',
43
+ '@selvajs/create': 'the old CLI package',
44
+ '@selvajs/platform': 'now bundled into @selvajs/selva',
45
+ '@selvajs/local-provider': 'now bundled into @selvajs/selva',
46
+ '@selvajs/supabase-provider': 'now bundled into @selvajs/selva',
47
+ '@selvajs/header-auth-provider': 'now bundled into @selvajs/selva'
48
+ };
49
+
50
+ /**
51
+ * Build the canonical deployment package.json.
52
+ *
53
+ * Dependencies are replaced wholesale rather than merged, so an operator's own
54
+ * additions are dropped. That is deliberate — a deployment directory is
55
+ * generated output, and anyone needing custom dependencies should depend on
56
+ * `@selvajs/selva` from their own project instead of editing this file.
57
+ *
58
+ * `engines` is the one exception, carried over when present: npm only enforces
59
+ * it under `engine-strict`, so an operator who pinned a Node floor did it
60
+ * deliberately, and dropping it removes a guard whose absence shows up only
61
+ * under real traffic (issue #176). Everything else a migration discards is
62
+ * listed by `diffPackageJson` before the operator confirms.
63
+ */
64
+ export function buildDeploymentPackageJson({ name, version = '0.1.0', engines }) {
65
+ const pkg = {
66
+ name: sanitizePackageName(name),
67
+ version,
68
+ private: true,
69
+ type: 'module',
70
+ scripts: { ...SCRIPTS },
71
+ dependencies: { ...DEPENDENCIES }
72
+ };
73
+ if (engines && typeof engines === 'object') pkg.engines = { ...engines };
74
+ return pkg;
75
+ }
76
+
77
+ /**
78
+ * Top-level keys the canonical package.json defines. Anything outside this set
79
+ * that an operator added is dropped by `migrate` — `diffPackageJson` reports
80
+ * them so the loss is declared rather than discovered later.
81
+ */
82
+ export const CANONICAL_FIELDS = new Set([
83
+ 'name',
84
+ 'version',
85
+ 'private',
86
+ 'type',
87
+ 'scripts',
88
+ 'dependencies',
89
+ 'engines'
90
+ ]);
91
+
92
+ export function sanitizePackageName(name) {
93
+ // npm package names: lowercase, no spaces, limited punctuation.
94
+ return (
95
+ String(name ?? '')
96
+ .toLowerCase()
97
+ .replace(/[^a-z0-9._-]+/g, '-')
98
+ .replace(/^[-._]+|[-._]+$/g, '') || 'selva-deployment'
99
+ );
100
+ }
101
+
102
+ export { DEPENDENCIES, SCRIPTS, PM2_VERSION };
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 '""';