@selvajs/cli 4.20.0-beta.9 → 4.20.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.
@@ -8,7 +8,6 @@
8
8
  import {
9
9
  existsSync,
10
10
  readFileSync,
11
- writeFileSync,
12
11
  copyFileSync,
13
12
  readdirSync,
14
13
  accessSync,
@@ -20,18 +19,27 @@ import { join, resolve, dirname } from 'node:path';
20
19
  import { fileURLToPath } from 'node:url';
21
20
  import * as p from '@clack/prompts';
22
21
  import pc from 'picocolors';
23
- import { readEnvFile, parseEnv, countEnvCommentLines, stripEnvComments } from '../env.js';
22
+ import {
23
+ readEnvFile,
24
+ parseEnv,
25
+ countEnvCommentLines,
26
+ stripEnvComments,
27
+ writeEnvText
28
+ } from '../env.js';
24
29
  import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
25
30
  import { satisfiesNodeRange } from '../node-range.js';
26
31
  import { green, yellow, red, fixable } from '../checks/result.js';
27
32
  import { checkBootPersistence } from '../checks/boot.js';
28
33
  import { checkRuntimeEnvironment } from '../checks/runtime.js';
34
+ import { checkSupabaseMigrations } from '../checks/migrations.js';
29
35
  import {
30
36
  checkBodySizeLimit,
31
37
  checkClientAddress,
32
38
  checkDeprecatedEnv,
33
39
  checkEnvDocumentation,
34
40
  checkHeaderNames,
41
+ checkHmacKey,
42
+ checkKeyRotation,
35
43
  checkOrigin,
36
44
  checkProviders,
37
45
  checkSecret,
@@ -55,8 +63,9 @@ export async function runDoctor(argv = []) {
55
63
  checks.push(checkLayoutDrift(dir));
56
64
  checks.push(checkMigrationLeftovers(dir));
57
65
  checks.push(checkAgedBackups(dir));
58
- checks.push(checkSecret(env.SELVA_HMAC_KEY, 'SELVA_HMAC_KEY is a 32-byte hex string'));
59
- checks.push(checkSecret(env.SELVA_AT_REST_KEY, 'SELVA_AT_REST_KEY is a 32-byte hex string'));
66
+ checks.push(checkHmacKey(env.SELVA_HMAC_KEY));
67
+ checks.push(checkSecret(env.SELVA_AT_REST_KEY, 'SELVA_AT_REST_KEY is a 32-byte key'));
68
+ checks.push(...checkKeyRotation(env));
60
69
 
61
70
  const providers = resolveProviders(env);
62
71
  checks.push(...checkProviders(providers));
@@ -69,7 +78,13 @@ export async function runDoctor(argv = []) {
69
78
 
70
79
  if (used.has('supabase')) {
71
80
  checks.push(checkSupabase(env));
72
- checks.push(checkSupabaseMigrations(dir, env));
81
+ checks.push(
82
+ checkSupabaseMigrations(
83
+ dir,
84
+ env,
85
+ readPackageVersion(join(dir, 'node_modules', '@selvajs', 'selva', 'package.json'))
86
+ )
87
+ );
73
88
  }
74
89
 
75
90
  if (providers.auth === 'header') {
@@ -214,83 +229,6 @@ export async function checkSupabase(env) {
214
229
  }
215
230
  }
216
231
 
217
- // Compares the newest migration shipped by @selvajs/supabase-provider against
218
- // what the database reports via selva.migration_head(). Red on skew — the app
219
- // degrades /api/health to 503 at boot in the same state, so catching it here
220
- // saves a confusing deploy.
221
- async function checkSupabaseMigrations(dir, env) {
222
- if (!env.SUPABASE_URL || !env.SUPABASE_SERVICE_ROLE_KEY) {
223
- return yellow('Migration-head check skipped — Supabase env incomplete');
224
- }
225
-
226
- const migrationsDir = join(
227
- dir,
228
- 'node_modules',
229
- '@selvajs',
230
- 'supabase-provider',
231
- 'supabase',
232
- 'migrations'
233
- );
234
- let expected = null;
235
- try {
236
- expected =
237
- readdirSync(migrationsDir)
238
- .filter((f) => /^\d{14}_.+\.sql$/.test(f))
239
- .map((f) => f.slice(0, 14))
240
- .sort()
241
- .pop() ?? null;
242
- } catch {
243
- // Provider package not installed in this deployment.
244
- }
245
- if (!expected) {
246
- return red(
247
- '@selvajs/supabase-provider is not installed, so the migration SQL is missing. ' +
248
- 'Run `selva migrate` to add it, then `npm install`.'
249
- );
250
- }
251
-
252
- try {
253
- const controller = new AbortController();
254
- const timer = setTimeout(() => controller.abort(), 4000);
255
- const res = await fetch(env.SUPABASE_URL + '/rest/v1/rpc/migration_head', {
256
- method: 'POST',
257
- headers: {
258
- apikey: env.SUPABASE_SERVICE_ROLE_KEY,
259
- Authorization: `Bearer ${env.SUPABASE_SERVICE_ROLE_KEY}`,
260
- 'Content-Type': 'application/json',
261
- // The function lives in the selva schema the app is pinned to.
262
- 'Content-Profile': 'selva'
263
- },
264
- body: '{}',
265
- signal: controller.signal
266
- });
267
- clearTimeout(timer);
268
- if (res.status === 404) {
269
- return red(
270
- `Database is missing selva.migration_head() — migrations are pending (expected head ${expected}). Run: npx selva-supabase && npx supabase db push`
271
- );
272
- }
273
- if (!res.ok) {
274
- return yellow(`migration_head RPC responded ${res.status} — cannot verify schema head`);
275
- }
276
- const actual = String((await res.json()) ?? '');
277
- if (!actual) {
278
- return red(
279
- `Database reports no applied migrations (expected head ${expected}). Run: npx selva-supabase && npx supabase db push`
280
- );
281
- }
282
- // String comparison — relies on both heads being fixed-width, zero-padded timestamps.
283
- if (actual < expected) {
284
- return red(
285
- `Database migration head ${actual} is behind the installed provider (${expected}). Run: npx selva-supabase && npx supabase db push`
286
- );
287
- }
288
- return green(`Database migration head ${actual} matches the installed provider`);
289
- } catch (err) {
290
- return yellow(`Migration-head check skipped (${err.message ?? err})`);
291
- }
292
- }
293
-
294
232
  function checkPackage(dir, name) {
295
233
  const path = join(dir, 'node_modules', ...name.split('/'), 'package.json');
296
234
  if (!existsSync(path)) return red(`${name} not installed (run npm install)`);
@@ -460,7 +398,7 @@ function checkEnvDocs(dir) {
460
398
  if (drift) return red(`refused to strip .env — ${drift}`);
461
399
 
462
400
  copyFileSync(envPath, envPath + '.bak');
463
- writeFileSync(envPath, strippedText, 'utf8');
401
+ writeEnvText(envPath, strippedText);
464
402
  return green(`stripped ${removed} comment lines from .env (backup: .env.bak)`);
465
403
  } catch (err) {
466
404
  return red(`could not rewrite .env: ${err instanceof Error ? err.message : err}`);
@@ -2,11 +2,12 @@
2
2
  // the current .env as prompt defaults and never touches package.json or runs
3
3
  // npm install.
4
4
 
5
+ import { existsSync, readFileSync } from 'node:fs';
5
6
  import { join } from 'node:path';
6
7
  import * as p from '@clack/prompts';
7
8
  import pc from 'picocolors';
8
9
  import { collectConfig } from '../prompts.js';
9
- import { readEnvFile, writeEnvFile } from '../env.js';
10
+ import { parseEnv, seededTemplateDefaults, setEnvValues, writeEnvText } from '../env.js';
10
11
  import { generateKey } from '../secrets.js';
11
12
  import { readEnvTemplate, requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
12
13
 
@@ -15,7 +16,8 @@ export async function runInit() {
15
16
  requireDeploymentDir(dir);
16
17
 
17
18
  const envPath = join(dir, '.env');
18
- const current = readEnvFile(envPath);
19
+ const text = existsSync(envPath) ? readFileSync(envPath, 'utf8') : '';
20
+ const current = parseEnv(text);
19
21
  const values = await collectConfig({ defaults: current, mode: 'init' });
20
22
 
21
23
  // Never regenerate an existing key: rotating SELVA_HMAC_KEY or
@@ -42,11 +44,17 @@ export async function runInit() {
42
44
  p.log.warn('SELVA_AT_REST_KEY was missing — generated a fresh one.');
43
45
  }
44
46
 
45
- // A values-only rewrite drops anything it isn't handed, and the prompts cover
46
- // only the vars they ask about — every tuned knob the operator set by hand
47
- // (BODY_SIZE_LIMIT, COMPUTE_*, LOG_LEVEL, …) lives outside that set. Carry
48
- // the existing file forward and let the prompt answers win on top.
49
- writeEnvFile(envPath, readEnvTemplate(dir), { ...current, ...values });
47
+ // Line-level: keys the prompts don't cover, and the operator's comments, stay as they are.
48
+ // A prompt answered blank means unset, as it did when the file was re-rendered.
49
+ const updates = Object.fromEntries(
50
+ Object.entries(values).map(([k, v]) => [k, v === '' ? undefined : v])
51
+ );
52
+ // A template default missing from .env (BODY_SIZE_LIMIT: unset, every upload
53
+ // 413s at adapter-node's 512 KB) is filled in; one the operator set is kept.
54
+ for (const [k, v] of Object.entries(seededTemplateDefaults(readEnvTemplate(dir)))) {
55
+ if (current[k] === undefined && !(k in updates)) updates[k] = v;
56
+ }
57
+ writeEnvText(envPath, setEnvValues(text, updates));
50
58
 
51
59
  p.outro(
52
60
  [
@@ -1,67 +1,146 @@
1
- // Rotate SELVA_HMAC_KEY or SELVA_AT_REST_KEY; requires explicit confirm (blast radius in TARGETS).
1
+ // `selva keys rotate <hmac|at-rest> [--finish | --leaked]`.
2
+ //
3
+ // A routine rotation keeps the old key as <KEY>_PREVIOUS, which the app still
4
+ // accepts, so nothing breaks. `--finish` drops the previous key once its
5
+ // overlap has passed. `--leaked` replaces the key and drops the old one at
6
+ // once: that is the destructive path, for a key that must stop working now.
2
7
 
8
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
3
9
  import { join } from 'node:path';
4
10
  import * as p from '@clack/prompts';
5
11
  import pc from 'picocolors';
6
- import { readEnvFile, writeEnvFile } from '../env.js';
12
+ import { parseEnv, setEnvValues, writeEnvText } from '../env.js';
13
+ import { resolveProviders } from '../checks/config.js';
7
14
  import { generateKey } from '../secrets.js';
8
- import { readEnvTemplate, requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
15
+ import { PREVIOUS_KEY_DAYS } from '../generated/config.js';
16
+ import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
9
17
 
10
18
  const TARGETS = {
11
19
  hmac: {
12
20
  envVar: 'SELVA_HMAC_KEY',
13
- warning: [
14
- 'This will:',
15
- pc.red(' • log every signed-in user out (existing session cookies stop verifying)'),
16
- pc.red(' • invalidate every share link and pending invite')
17
- ].join('\n')
21
+ leakedBlastRadius: hmacBlastRadius,
22
+ finishNote:
23
+ 'The app stops accepting the previous key on its own at SELVA_HMAC_KEY_PREVIOUS_EXPIRES; ' +
24
+ 'finishing ends it now. Either way this stops working: MCP clients registered before the ' +
25
+ 'rotation (they register again), and share links, invites and API tokens stored under the ' +
26
+ 'previous key before tokens were hashed without one. Sessions it signed last 8 hours at most.'
18
27
  },
19
28
  'at-rest': {
20
29
  envVar: 'SELVA_AT_REST_KEY',
21
- warning: [
22
- 'This will:',
23
- pc.red(' • make the encrypted Rhino.Compute API key undecryptable'),
24
- pc.red(' • require re-entering the API key at /admin/compute'),
25
- pc.dim(' (other data is plaintext on disk — only the compute API key is encrypted)')
26
- ].join('\n')
30
+ leakedBlastRadius: atRestBlastRadius,
31
+ finishNote:
32
+ 'The app re-seals stored API keys under the new key at boot, and stops accepting the previous ' +
33
+ 'key at SELVA_AT_REST_KEY_PREVIOUS_EXPIRES. Check that /admin/system shows none left on the ' +
34
+ 'previous key before finishing, or they become unreadable.'
27
35
  }
28
36
  };
29
37
 
38
+ /** What dropping the old SELVA_HMAC_KEY at once invalidates on this deployment's providers. */
39
+ export function hmacBlastRadius(env) {
40
+ const { auth } = resolveProviders(env);
41
+ const lines = [];
42
+ // Only the local provider signs sessions with it: Supabase sessions are
43
+ // Supabase JWTs, and under header auth the proxy owns the session.
44
+ if (auth === 'local') lines.push('log every signed-in user out');
45
+ lines.push(
46
+ 'invalidate share links, invites and API tokens created before stored tokens were hashed without a key',
47
+ 'disconnect every MCP client (each has to register and sign in again)',
48
+ 'fail live solve-event streams of solves already running'
49
+ );
50
+ return lines;
51
+ }
52
+
53
+ export function atRestBlastRadius() {
54
+ return [
55
+ 'make the stored Rhino.Compute API keys unreadable',
56
+ 'require re-entering each API key at /admin/compute'
57
+ ];
58
+ }
59
+
60
+ /** The .env change for each mode, as `setEnvValues` updates. */
61
+ export function rotationUpdates(env, envVar, mode, fresh = generateKey(), now = Date.now()) {
62
+ const previousVar = `${envVar}_PREVIOUS`;
63
+ const expiresVar = `${previousVar}_EXPIRES`;
64
+ if (mode === 'finish') return { [previousVar]: undefined, [expiresVar]: undefined };
65
+ if (mode === 'leaked')
66
+ return { [envVar]: fresh, [previousVar]: undefined, [expiresVar]: undefined };
67
+ const expires = new Date(now + PREVIOUS_KEY_DAYS * 86_400_000).toISOString();
68
+ return { [envVar]: fresh, [previousVar]: env[envVar], [expiresVar]: expires };
69
+ }
70
+
71
+ function backupsHolding(dir, value) {
72
+ if (!value) return [];
73
+ return readdirSync(dir)
74
+ .filter((f) => f.startsWith('.env.') && f.endsWith('.bak'))
75
+ .filter((f) => readFileSync(join(dir, f), 'utf8').includes(value));
76
+ }
77
+
30
78
  export async function runKeysRotate(argv) {
31
79
  const target = argv[0];
80
+ const mode = argv.includes('--finish')
81
+ ? 'finish'
82
+ : argv.includes('--leaked')
83
+ ? 'leaked'
84
+ : 'rotate';
32
85
  if (!target || !(target in TARGETS)) {
33
- console.error(`Usage: selva keys rotate <hmac|at-rest>`);
86
+ console.error('Usage: selva keys rotate <hmac|at-rest> [--finish | --leaked]');
34
87
  process.exit(1);
35
88
  }
36
89
 
37
90
  const dir = resolveDeploymentDir();
38
91
  requireDeploymentDir(dir);
39
92
 
40
- const { envVar, warning } = TARGETS[target];
93
+ const { envVar, leakedBlastRadius, finishNote } = TARGETS[target];
94
+ const previousVar = `${envVar}_PREVIOUS`;
41
95
  const envPath = join(dir, '.env');
42
- const current = readEnvFile(envPath);
96
+ const text = existsSync(envPath) ? readFileSync(envPath, 'utf8') : '';
97
+ const env = parseEnv(text);
43
98
 
44
- p.intro(pc.bgYellow(pc.black(` rotate ${envVar} `)));
45
- p.note(warning, 'Blast radius');
99
+ p.intro(pc.bgYellow(pc.black(` ${mode === 'rotate' ? 'rotate' : mode} ${envVar} `)));
100
+
101
+ if (mode === 'rotate') {
102
+ if (env[previousVar]) {
103
+ p.cancel(
104
+ `${previousVar} is still set from the last rotation. Run \`selva keys rotate ${target} --finish\` first.`
105
+ );
106
+ process.exit(1);
107
+ }
108
+ p.note(
109
+ `The current key moves to ${previousVar}, which the app keeps accepting for ${PREVIOUS_KEY_DAYS} days.\nNothing stops working now. After that it is refused on its own; \`selva keys rotate ${target} --finish\`\nremoves it from .env.`,
110
+ 'Safe rotation'
111
+ );
112
+ } else if (mode === 'finish') {
113
+ if (!env[previousVar]) {
114
+ p.outro(pc.dim(`${previousVar} is not set: nothing to finish.`));
115
+ return;
116
+ }
117
+ p.note(finishNote, 'Before you finish');
118
+ } else {
119
+ p.note(
120
+ ['This will:', ...leakedBlastRadius(env).map((l) => pc.red(` • ${l}`))].join('\n'),
121
+ 'Blast radius'
122
+ );
123
+ }
46
124
 
47
- const confirmed = await p.confirm({
48
- message: `Rotate ${envVar}?`,
49
- initialValue: false
50
- });
125
+ const confirmed = await p.confirm({ message: 'Continue?', initialValue: mode === 'rotate' });
51
126
  if (p.isCancel(confirmed) || !confirmed) {
52
127
  p.cancel('Cancelled.');
53
128
  return;
54
129
  }
55
130
 
56
- const fresh = generateKey();
57
- current[envVar] = fresh;
131
+ writeEnvText(envPath, setEnvValues(text, rotationUpdates(env, envVar, mode)));
58
132
 
59
- writeEnvFile(envPath, readEnvTemplate(dir), current);
60
-
61
- p.outro(
62
- [
63
- pc.green(`${envVar} rotated.`),
64
- pc.dim('Restart the app to apply: ') + pc.cyan('selva restart')
65
- ].join('\n')
66
- );
133
+ const lines = [pc.green(mode === 'finish' ? `${previousVar} removed.` : `${envVar} rotated.`)];
134
+ if (mode !== 'rotate') {
135
+ const stale = backupsHolding(dir, mode === 'finish' ? env[previousVar] : env[envVar]);
136
+ if (stale.length > 0) {
137
+ lines.push(
138
+ pc.yellow(
139
+ `These backups still hold the old key: ${stale.join(', ')}. Delete them if it leaked.`
140
+ )
141
+ );
142
+ }
143
+ }
144
+ lines.push(pc.dim('Restart the app to apply: ') + pc.cyan('selva restart'));
145
+ p.outro(lines.join('\n'));
67
146
  }
@@ -2,13 +2,22 @@
2
2
  // provider packages), removes stale selva.config.js, updates ecosystem.config.cjs.
3
3
  // Idempotent; mirrors update's lifecycle (stop/mutate/start with rollback on failure).
4
4
 
5
- import { existsSync, readFileSync, writeFileSync, copyFileSync, rmSync, renameSync } from 'node:fs';
5
+ import {
6
+ existsSync,
7
+ readFileSync,
8
+ readdirSync,
9
+ writeFileSync,
10
+ copyFileSync,
11
+ rmSync,
12
+ renameSync
13
+ } from 'node:fs';
6
14
  import { join } from 'node:path';
7
15
  import { execSync } from 'node:child_process';
8
16
  import * as p from '@clack/prompts';
9
17
  import pc from 'picocolors';
10
18
  import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
11
- import { renameEnvKeys, RENAMED_ENV_VARS } from '../env.js';
19
+ import { readEnvFile, renameEnvKeys, RENAMED_ENV_VARS, writeEnvText } from '../env.js';
20
+ import { planEnvMigration } from '../env-migration.js';
12
21
  import {
13
22
  buildDeploymentPackageJson,
14
23
  CANONICAL_FIELDS,
@@ -100,7 +109,7 @@ export function classifyBackups(names, now = new Date(), maxAgeDays = BACKUP_AGE
100
109
  // dependency tree, so it has to run against a real directory — but not against
101
110
  // a real npm or pm2 daemon.
102
111
  export async function runMigrate(
103
- _argv,
112
+ argv = [],
104
113
  {
105
114
  dir: injectedDir,
106
115
  // --prefer-online: npm can otherwise resolve a cached packument that
@@ -114,7 +123,13 @@ export async function runMigrate(
114
123
  const dir = injectedDir ?? resolveDeploymentDir();
115
124
  requireDeploymentDir(dir);
116
125
 
117
- p.intro(pc.bgCyan(pc.black(' selva migrate ')));
126
+ const dryRun = argv.includes('--dry-run');
127
+ p.intro(pc.bgCyan(pc.black(dryRun ? ' selva migrate --dry-run ' : ' selva migrate ')));
128
+
129
+ if (argv.includes('--undo')) {
130
+ undoEnvMigration(dir);
131
+ return;
132
+ }
118
133
 
119
134
  const pkgPath = join(dir, 'package.json');
120
135
  if (!existsSync(pkgPath)) {
@@ -123,7 +138,7 @@ export async function runMigrate(
123
138
  }
124
139
 
125
140
  const before = JSON.parse(readFileSync(pkgPath, 'utf8'));
126
- const deploymentEnv = readDeploymentEnv(join(dir, '.env'));
141
+ const deploymentEnv = readEnvFile(join(dir, '.env'));
127
142
  const { pkg: target, notes: pinNotes } = buildTargetPackageJson(
128
143
  before,
129
144
  resolveVersion,
@@ -131,32 +146,76 @@ export async function runMigrate(
131
146
  );
132
147
  const pkgDiff = diffPackageJson(before, target);
133
148
 
134
- // The server drops its read-old-name shim after one minor version, so
135
- // rewriting now is what keeps a tuned value tuned instead of silently
136
- // reverting to defaults later.
149
+ // The server stops reading old names in a later release, so rewriting now is
150
+ // what keeps a tuned value tuned instead of silently reverting to a default.
137
151
  const envPath = join(dir, '.env');
138
152
  const envBefore = existsSync(envPath) ? readFileSync(envPath, 'utf8') : null;
139
- const envRename = envBefore
140
- ? renameEnvKeys(envBefore, RENAMED_ENV_VARS)
141
- : { text: '', changes: [] };
142
-
143
- const sideFileChanges = [];
144
- for (const [oldName, newName, action] of envRename.changes) {
145
- sideFileChanges.push(
146
- action === 'dropped'
147
- ? `${pc.red('-')} .env ${pc.dim(`${oldName} (deprecated; ${newName} is already set)`)}`
148
- : `${pc.yellow('~')} .env ${pc.dim(`${oldName} → ${newName}`)}`
153
+ const envPlan = envBefore
154
+ ? planEnvMigration(envBefore)
155
+ : { text: '', changes: [], mismatches: [], blockers: [], fallbacks: [], unknown: [] };
156
+ const envChanged = envPlan.changes.length > 0;
157
+
158
+ // The rewrite would change what a setting means. Never write that.
159
+ if (envPlan.mismatches.length > 0) {
160
+ p.log.error('The .env rewrite would change these settings, so nothing was written:');
161
+ for (const m of envPlan.mismatches) {
162
+ console.log(` ${m.key}: ${JSON.stringify(m.was)} → ${JSON.stringify(m.is)}`);
163
+ }
164
+ p.outro(pc.red('Migration refused. Please report this: it is a bug in the migration.'));
165
+ process.exit(1);
166
+ }
167
+
168
+ // Values the old readers ignored that this release refuses to boot on.
169
+ if (envPlan.blockers.length > 0) {
170
+ p.log.error('This release would not start with these .env values:');
171
+ for (const b of envPlan.blockers) console.log(` ${b.key} ${b.problem}`);
172
+ p.outro(
173
+ pc.red(
174
+ 'Fix them first (`selva config set KEY=VALUE`), then run migrate again. Nothing was written.'
175
+ )
149
176
  );
177
+ process.exit(1);
150
178
  }
179
+ for (const f of envPlan.fallbacks) {
180
+ p.log.warn(`${f.key} ${f.problem}: the app runs on its default instead.`);
181
+ }
182
+
183
+ const sideFileChanges = envPlan.changes.map(describeEnvChange);
151
184
 
152
185
  if (pkgDiff.length === 0 && sideFileChanges.length === 0) {
186
+ reportUnknown(envPlan.unknown);
153
187
  p.outro(pc.green('Already on the current layout — nothing to migrate.'));
154
188
  return;
155
189
  }
156
190
 
157
- p.log.info('Changes to apply:');
191
+ p.log.info(dryRun ? 'Would apply (nothing is written in a dry run):' : 'Changes to apply:');
158
192
  for (const line of pkgDiff) console.log(' ' + line);
159
193
  for (const line of sideFileChanges) console.log(' ' + line);
194
+ reportUnknown(envPlan.unknown);
195
+
196
+ if (dryRun) {
197
+ p.outro(pc.green('Every setting keeps its meaning. Run without --dry-run to apply.'));
198
+ return;
199
+ }
200
+
201
+ // Only .env changes: no reinstall, no restart of anyone's running app.
202
+ if (pkgDiff.length === 0) {
203
+ const go = await confirm('Rewrite .env (a backup is kept)?');
204
+ if (p.isCancel(go) || !go) {
205
+ p.cancel('Cancelled.');
206
+ return;
207
+ }
208
+ const bak = backupPathFor(envPath, backupStamp());
209
+ copyFileSync(envPath, bak);
210
+ writeEnvText(bak, readFileSync(bak, 'utf8'));
211
+ writeEnvText(envPath, envPlan.text);
212
+ p.outro(
213
+ pc.green('.env migrated. ') +
214
+ pc.dim(`Backup: ${bak}. Undo: selva migrate --undo. Apply: `) +
215
+ pc.cyan('selva restart')
216
+ );
217
+ return;
218
+ }
160
219
 
161
220
  // The diff shows the pin that will be written; these explain why it is that
162
221
  // pin. A preserved beta and a pin that couldn't be resolved both look like
@@ -203,9 +262,10 @@ export async function runMigrate(
203
262
  copyFileSync(pkgPath, bakPath);
204
263
  writeFileSync(pkgPath, JSON.stringify(target, null, 2) + '\n', 'utf8');
205
264
 
206
- if (envRename.changes.length > 0) {
265
+ if (envChanged) {
207
266
  copyFileSync(envPath, envBak);
208
- writeFileSync(envPath, envRename.text, 'utf8');
267
+ writeEnvText(envBak, readFileSync(envBak, 'utf8'));
268
+ writeEnvText(envPath, envPlan.text);
209
269
  }
210
270
 
211
271
  // A legacy lockfile pins the old package set across a major bump, so this needs
@@ -231,7 +291,7 @@ export async function runMigrate(
231
291
  } catch (err) {
232
292
  s.stop(pc.red('npm install failed — rolling back'));
233
293
  copyFileSync(bakPath, pkgPath);
234
- if (envRename.changes.length > 0 && existsSync(envBak)) {
294
+ if (envChanged && existsSync(envBak)) {
235
295
  copyFileSync(envBak, envPath);
236
296
  }
237
297
 
@@ -263,12 +323,12 @@ export async function runMigrate(
263
323
  // here, once, instead of letting doctor nag forever. Deliberately NOT
264
324
  // automated: `pm2 update` recycles the daemon, and doing that unattended
265
325
  // inside a migration converts a mid-flight failure into an outage (see
266
- // plans/fixes/host-prerequisites-and-pm2-audit.md).
326
+ // plans/archive/host-prerequisites-and-pm2-audit.md).
267
327
  const pm2Notice = buildPm2UpgradeNotice(before.dependencies?.pm2, target.dependencies.pm2);
268
328
  if (pm2Notice) p.log.warn(pm2Notice);
269
329
 
270
330
  const backupHints = [`package.json.${stamp}.bak`];
271
- if (envRename.changes.length > 0) backupHints.push(`.env.${stamp}.bak`);
331
+ if (envChanged) backupHints.push(`.env.${stamp}.bak`);
272
332
 
273
333
  if (status === 0) {
274
334
  p.outro(
@@ -305,19 +365,6 @@ function restartAfterRollback(dir, pm2 = runPm2) {
305
365
  }
306
366
  }
307
367
 
308
- // Only the provider keys matter here, so this reads `KEY=value` lines rather
309
- // than pulling in a dotenv parser: no quoting, expansion, or multi-line values
310
- // to honour.
311
- function readDeploymentEnv(envPath) {
312
- if (!existsSync(envPath)) return {};
313
- const env = {};
314
- for (const line of readFileSync(envPath, 'utf8').split(/\r?\n/)) {
315
- const match = /^\s*([A-Z0-9_]+)\s*=\s*(.*)$/.exec(line);
316
- if (match) env[match[1]] = match[2].trim().replace(/^["']|["']$/g, '');
317
- }
318
- return env;
319
- }
320
-
321
368
  function buildTargetPackageJson(current, resolveVersion = npmDistTagVersion, env = {}) {
322
369
  const supabase = needsSupabaseProvider(env);
323
370
  const { pins, notes } = resolveSelvaPins(
@@ -475,3 +522,51 @@ export function buildPm2UpgradeNotice(beforeVersion, afterVersion) {
475
522
  }
476
523
 
477
524
  export { buildTargetPackageJson, diffPackageJson };
525
+
526
+ function describeEnvChange(c) {
527
+ const env = (text) => `${pc.yellow('~')} .env ${pc.dim(text)}`;
528
+ switch (c.action) {
529
+ case 'stripped':
530
+ return env('shipped template comments removed (your own notes above a setting are kept)');
531
+ case 'renamed':
532
+ return env(`${c.key} → ${c.to}`);
533
+ case 'dropped':
534
+ return `${pc.red('-')} .env ${pc.dim(`${c.key} (${c.reason})`)}`;
535
+ case 'replaced':
536
+ return env(`${c.key} → ${c.to}`);
537
+ case 'rewritten':
538
+ return env(`${c.key} written as ${c.to}, what it already meant`);
539
+ case 'removed':
540
+ return `${pc.red('-')} .env ${pc.dim(`${c.key} (${c.reason})`)}`;
541
+ default:
542
+ return env(c.key);
543
+ }
544
+ }
545
+
546
+ function reportUnknown(unknown) {
547
+ for (const u of unknown) {
548
+ p.log.warn(
549
+ `.env sets ${u.key}, which Selva does not read${u.suggestion ? ` (did you mean ${u.suggestion}?)` : ''}. Left as is.`
550
+ );
551
+ }
552
+ }
553
+
554
+ /** Restore the newest .env backup migrate (or config set) wrote. */
555
+ function undoEnvMigration(dir) {
556
+ const backups = readdirSync(dir)
557
+ .filter((f) => f.startsWith('.env.') && BACKUP_PATTERN.test(f))
558
+ .sort()
559
+ .reverse();
560
+ if (backups.length === 0) {
561
+ p.outro(pc.yellow('No .env backup to restore.'));
562
+ return;
563
+ }
564
+ const envPath = join(dir, '.env');
565
+ const current = existsSync(envPath) ? readFileSync(envPath, 'utf8') : '';
566
+ // Keep what is being replaced, so an undo can itself be undone.
567
+ if (current) writeEnvText(backupPathFor(envPath, backupStamp()), current);
568
+ writeEnvText(envPath, readFileSync(join(dir, backups[0]), 'utf8'));
569
+ p.outro(
570
+ pc.green(`Restored .env from ${backups[0]}. `) + pc.dim('Apply: ') + pc.cyan('selva restart')
571
+ );
572
+ }