@selvajs/cli 4.20.0-beta.2 → 4.20.0-beta.21
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/caddyfile.js +3 -3
- package/src/checks/config.js +68 -1
- package/src/cli.js +23 -7
- package/src/commands/config.js +196 -0
- package/src/commands/doctor.js +13 -5
- package/src/commands/init.js +15 -7
- package/src/commands/keys.js +112 -33
- package/src/commands/migrate.js +132 -37
- package/src/commands/proxy.js +12 -0
- package/src/env-migration.js +148 -0
- package/src/env.js +77 -36
- package/src/generated/config.js +1099 -0
- package/src/prompts.js +9 -0
package/src/commands/migrate.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
135
|
-
//
|
|
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
|
|
140
|
-
?
|
|
141
|
-
: { text: '', changes: [] };
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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 (
|
|
265
|
+
if (envChanged) {
|
|
207
266
|
copyFileSync(envPath, envBak);
|
|
208
|
-
|
|
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 (
|
|
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/
|
|
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 (
|
|
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
|
+
}
|
package/src/commands/proxy.js
CHANGED
|
@@ -144,6 +144,18 @@ export async function runSetupProxy(argv = []) {
|
|
|
144
144
|
);
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
const host = env.HOST ?? '0.0.0.0';
|
|
148
|
+
if (host !== '127.0.0.1' && host !== 'localhost' && host !== '::1') {
|
|
149
|
+
p.log.warn(
|
|
150
|
+
`Add this to .env, then \`selva restart\`:\n` +
|
|
151
|
+
pc.cyan(' HOST=127.0.0.1\n') +
|
|
152
|
+
pc.dim(
|
|
153
|
+
'Without it the app listens on every interface, so port 3000 is reachable\n' +
|
|
154
|
+
'without Caddy and a direct caller can set its own X-Forwarded-For.'
|
|
155
|
+
)
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
147
159
|
p.outro(
|
|
148
160
|
[
|
|
149
161
|
pc.green(`Caddy is serving ${domain}.`),
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Plan the .env rewrite `selva migrate` applies: old names renamed, values a
|
|
2
|
+
// looser reader used to accept rewritten to what they meant, no-op lines
|
|
3
|
+
// removed, shipped template prose stripped. Unknown keys are kept.
|
|
4
|
+
//
|
|
5
|
+
// `mismatches` is the proof: every declared key resolved from the old file
|
|
6
|
+
// (with the old readers' semantics) and from the new one. Anything but empty
|
|
7
|
+
// means the rewrite would change behaviour, and migrate refuses to write it.
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
countEnvCommentLines,
|
|
11
|
+
parseEnv,
|
|
12
|
+
RENAMED_ENV_VARS as RENAMES,
|
|
13
|
+
renameEnvKeys,
|
|
14
|
+
setEnvValues,
|
|
15
|
+
stripEnvComments
|
|
16
|
+
} from './env.js';
|
|
17
|
+
import { ALL_SETTINGS, legacyValue, resolveConfig } from './generated/config.js';
|
|
18
|
+
import { reportFor } from './commands/config.js';
|
|
19
|
+
|
|
20
|
+
const SPECS = new Map(ALL_SETTINGS.map((s) => [s.key, s]));
|
|
21
|
+
|
|
22
|
+
// How the old reader took SELVA_FLAG_COMPUTE_DEBUG_VERBOSE (engine.server.ts).
|
|
23
|
+
const OLD_VERBOSE_TRUE = new Set(['true', '1', 'yes']);
|
|
24
|
+
|
|
25
|
+
/** The debug mode the app runs in, from either generation of the keys. */
|
|
26
|
+
function computeDebugMode(env) {
|
|
27
|
+
const raw = (env.SELVA_FLAG_COMPUTE_DEBUG ?? '').toLowerCase();
|
|
28
|
+
if (raw === 'verbose') return 'verbose';
|
|
29
|
+
if (OLD_VERBOSE_TRUE.has((env.SELVA_FLAG_COMPUTE_DEBUG_VERBOSE ?? '').toLowerCase()))
|
|
30
|
+
return 'verbose';
|
|
31
|
+
return ['true', '1', 'yes', 'on'].includes(raw) ? 'on' : 'off';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Keys whose effective value depends on another key. */
|
|
35
|
+
const EFFECTIVE = {
|
|
36
|
+
HEADER_AUTH_DATA_DIR: (env) => env.HEADER_AUTH_DATA_DIR || env.DATA_PATH,
|
|
37
|
+
SELVA_FLAG_COMPUTE_DEBUG: computeDebugMode,
|
|
38
|
+
SELVA_FLAG_COMPUTE_DEBUG_VERBOSE: () => undefined
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function canonical(value) {
|
|
42
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
43
|
+
if (Array.isArray(value)) return value.join(',');
|
|
44
|
+
return String(value);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** What `env[key]` means to the reader of that generation. */
|
|
48
|
+
function meaning(spec, env, { legacy }) {
|
|
49
|
+
if (EFFECTIVE[spec.key]) return EFFECTIVE[spec.key](env);
|
|
50
|
+
const name = [spec.key, ...(spec.aliases ?? [])].find(
|
|
51
|
+
(k) => env[k] !== undefined && env[k] !== ''
|
|
52
|
+
);
|
|
53
|
+
if (name === undefined) return spec.default;
|
|
54
|
+
const raw = env[name];
|
|
55
|
+
if (legacy && spec.legacy) return legacyValue(spec, raw);
|
|
56
|
+
return resolveConfig([spec], { [spec.key]: raw }).values[spec.key];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function planEnvMigration(input) {
|
|
60
|
+
const changes = [];
|
|
61
|
+
let text = input;
|
|
62
|
+
|
|
63
|
+
if (countEnvCommentLines(text) > 0) {
|
|
64
|
+
text = stripEnvComments(text).text;
|
|
65
|
+
changes.push({ key: '(comments)', action: 'stripped' });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const renamed = renameEnvKeys(text, RENAMES);
|
|
69
|
+
text = renamed.text;
|
|
70
|
+
for (const [from, to, how] of renamed.changes) {
|
|
71
|
+
changes.push(
|
|
72
|
+
how === 'renamed'
|
|
73
|
+
? { key: from, action: 'renamed', to }
|
|
74
|
+
: { key: from, action: 'dropped', reason: `${to} is set` }
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const env = parseEnv(text);
|
|
79
|
+
const updates = {};
|
|
80
|
+
|
|
81
|
+
if (env.SELVA_FLAG_COMPUTE_DEBUG_VERBOSE !== undefined) {
|
|
82
|
+
updates.SELVA_FLAG_COMPUTE_DEBUG_VERBOSE = undefined;
|
|
83
|
+
if (computeDebugMode(env) === 'verbose') updates.SELVA_FLAG_COMPUTE_DEBUG = 'verbose';
|
|
84
|
+
changes.push({
|
|
85
|
+
key: 'SELVA_FLAG_COMPUTE_DEBUG_VERBOSE',
|
|
86
|
+
action: 'replaced',
|
|
87
|
+
to: 'SELVA_FLAG_COMPUTE_DEBUG'
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const [key, raw] of Object.entries(env)) {
|
|
92
|
+
const spec = SPECS.get(key);
|
|
93
|
+
if (!spec?.legacy) continue;
|
|
94
|
+
const old = legacyValue(spec, raw);
|
|
95
|
+
const now = resolveConfig([spec], { [key]: raw }).keys[key];
|
|
96
|
+
if (now.status !== 'ok' || now.value !== old) {
|
|
97
|
+
updates[key] = canonical(old);
|
|
98
|
+
changes.push({ key, action: 'rewritten', to: canonical(old) });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (const key of [
|
|
103
|
+
'HEADER_AUTH_UPN_HEADER',
|
|
104
|
+
'HEADER_AUTH_EMAIL_HEADER',
|
|
105
|
+
'HEADER_AUTH_DISPLAY_NAME_HEADER'
|
|
106
|
+
]) {
|
|
107
|
+
if (env[key] !== undefined && env[key] === SPECS.get(key)?.default) {
|
|
108
|
+
updates[key] = undefined;
|
|
109
|
+
changes.push({ key, action: 'removed', reason: 'same as the default' });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (env.HEADER_AUTH_DATA_DIR !== undefined && env.HEADER_AUTH_DATA_DIR === env.DATA_PATH) {
|
|
113
|
+
updates.HEADER_AUTH_DATA_DIR = undefined;
|
|
114
|
+
changes.push({ key: 'HEADER_AUTH_DATA_DIR', action: 'removed', reason: 'same as DATA_PATH' });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (Object.keys(updates).length > 0) text = setEnvValues(text, updates);
|
|
118
|
+
|
|
119
|
+
// Read back with Node's parser, the one the app boots with, so a writer bug
|
|
120
|
+
// shows up here as a mismatch instead of on the server.
|
|
121
|
+
const before = parseEnv(input);
|
|
122
|
+
const after = parseEnv(text);
|
|
123
|
+
const mismatches = [];
|
|
124
|
+
for (const spec of ALL_SETTINGS) {
|
|
125
|
+
const was = meaning(spec, before, { legacy: true });
|
|
126
|
+
const is = meaning(spec, after, { legacy: false });
|
|
127
|
+
if (JSON.stringify(was) !== JSON.stringify(is)) mismatches.push({ key: spec.key, was, is });
|
|
128
|
+
}
|
|
129
|
+
for (const key of Object.keys(before)) {
|
|
130
|
+
if (!SPECS.has(key) && !(key in RENAMES) && after[key] !== before[key]) {
|
|
131
|
+
mismatches.push({ key, was: before[key], is: after[key] });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// A value both generations reject compares equal above, yet the old reader
|
|
136
|
+
// shrugged it off while the new one may stop boot on it. Name those apart:
|
|
137
|
+
// `blockers` stop the app, `fallbacks` run on the default.
|
|
138
|
+
const report = reportFor(after);
|
|
139
|
+
return {
|
|
140
|
+
text,
|
|
141
|
+
changes,
|
|
142
|
+
mismatches,
|
|
143
|
+
// A missing required key is a broken .env either way: doctor's job, not a migration's.
|
|
144
|
+
blockers: report.problems.filter((p) => p.status === 'error' && p.source !== 'unset'),
|
|
145
|
+
fallbacks: report.problems.filter((p) => p.status === 'invalid'),
|
|
146
|
+
unknown: report.unknown
|
|
147
|
+
};
|
|
148
|
+
}
|
package/src/env.js
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
|
-
//
|
|
1
|
+
// .env reading and writing. Reads go through Node's own parser, the one
|
|
2
|
+
// `node --env-file` boots the app with, so the CLI never sees a value the app
|
|
3
|
+
// doesn't.
|
|
2
4
|
|
|
3
|
-
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
5
|
+
import { readFileSync, writeFileSync, existsSync, chmodSync } from 'node:fs';
|
|
6
|
+
import { parseEnv as nodeParseEnv } from 'node:util';
|
|
7
|
+
import { ALL_SETTINGS } from './generated/config.js';
|
|
4
8
|
|
|
5
9
|
// Old env name → its replacement, for keys renamed without changing meaning.
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
COMPUTE_DEFINITION_BYTE_CACHE_MB: 'COMPUTE_DEFINITION_CACHE_MB',
|
|
11
|
-
COMPUTE_RESPONSE_CACHE_MB: 'COMPUTE_SOLVE_CACHE_MB',
|
|
12
|
-
DEFINITION_CACHE_TTL_MS: 'REMOTE_DEFINITION_CACHE_TTL_MS',
|
|
13
|
-
MAX_SOLVE_DURATION_MS: 'COMPUTE_SOLVE_DEADLINE_MS'
|
|
14
|
-
};
|
|
10
|
+
// From the manifest's `aliases`, the same list the server reads old names by.
|
|
11
|
+
export const RENAMED_ENV_VARS = Object.fromEntries(
|
|
12
|
+
ALL_SETTINGS.flatMap((s) => (s.aliases ?? []).map((alias) => [alias, s.key]))
|
|
13
|
+
);
|
|
15
14
|
|
|
16
15
|
// Deprecated vars whose replacement changes the VALUE too, so a simple key
|
|
17
16
|
// rename can't migrate them — `selva migrate` leaves these alone rather than guessing.
|
|
@@ -156,23 +155,7 @@ export function countEnvCommentLines(text) {
|
|
|
156
155
|
}
|
|
157
156
|
|
|
158
157
|
export function parseEnv(text) {
|
|
159
|
-
|
|
160
|
-
for (const rawLine of text.split(/\r?\n/)) {
|
|
161
|
-
const line = rawLine.trim();
|
|
162
|
-
if (!line || line.startsWith('#')) continue;
|
|
163
|
-
const eq = line.indexOf('=');
|
|
164
|
-
if (eq === -1) continue;
|
|
165
|
-
const key = line.slice(0, eq).trim();
|
|
166
|
-
let value = line.slice(eq + 1).trim();
|
|
167
|
-
if (
|
|
168
|
-
(value.startsWith('"') && value.endsWith('"')) ||
|
|
169
|
-
(value.startsWith("'") && value.endsWith("'"))
|
|
170
|
-
) {
|
|
171
|
-
value = value.slice(1, -1);
|
|
172
|
-
}
|
|
173
|
-
out[key] = value;
|
|
174
|
-
}
|
|
175
|
-
return out;
|
|
158
|
+
return nodeParseEnv(text);
|
|
176
159
|
}
|
|
177
160
|
|
|
178
161
|
export function readEnvFile(path) {
|
|
@@ -196,7 +179,7 @@ export function readEnvFile(path) {
|
|
|
196
179
|
const SEEDED_TEMPLATE_DEFAULTS = ['BODY_SIZE_LIMIT'];
|
|
197
180
|
|
|
198
181
|
/** The subset of the template's live `KEY=value` lines that are safe to seed. */
|
|
199
|
-
function seededTemplateDefaults(template) {
|
|
182
|
+
export function seededTemplateDefaults(template) {
|
|
200
183
|
const parsed = parseEnv(template);
|
|
201
184
|
const out = {};
|
|
202
185
|
for (const key of SEEDED_TEMPLATE_DEFAULTS) {
|
|
@@ -253,15 +236,65 @@ export function renderEnvValues(template, values, { header = ENV_HEADER } = {})
|
|
|
253
236
|
// operator has deliberately blanked one out.
|
|
254
237
|
const value = Object.prototype.hasOwnProperty.call(values, key) ? values[key] : defaults[key];
|
|
255
238
|
if (value === undefined || value === null || value === '') continue;
|
|
256
|
-
lines.push(`${key}=${
|
|
239
|
+
lines.push(`${key}=${encodeEnvValue(value)}`);
|
|
257
240
|
}
|
|
258
241
|
|
|
259
242
|
return header ? `${header}\n\n${lines.join('\n')}\n` : `${lines.join('\n')}\n`;
|
|
260
243
|
}
|
|
261
244
|
|
|
262
|
-
/** Writes
|
|
245
|
+
/** Writes a fresh deployment `.env`, values only, in the template's key order. */
|
|
263
246
|
export function writeEnvFile(path, template, values) {
|
|
264
|
-
|
|
247
|
+
writeEnvText(path, renderEnvValues(template, values));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** `.env` holds both root keys, so it is the owner's alone. */
|
|
251
|
+
export function writeEnvText(path, text) {
|
|
252
|
+
writeFileSync(path, text, { encoding: 'utf8', mode: 0o600 });
|
|
253
|
+
// `mode` applies only when the file is created; an existing file keeps its own.
|
|
254
|
+
if (process.platform !== 'win32') chmodSync(path, 0o600);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Set or remove keys in an existing `.env`, touching only their lines.
|
|
259
|
+
*
|
|
260
|
+
* Re-rendering the whole file drops the operator's comments and re-encodes
|
|
261
|
+
* every value. `undefined` or `null` removes the key; a key not yet present is
|
|
262
|
+
* appended.
|
|
263
|
+
*/
|
|
264
|
+
export function setEnvValues(text, updates) {
|
|
265
|
+
const pending = new Map(Object.entries(updates));
|
|
266
|
+
const lines = text.split(/\r?\n/);
|
|
267
|
+
// Node reads the last line for a key that appears twice, so that is the one
|
|
268
|
+
// to edit; earlier duplicates would only mislead the next reader of the file.
|
|
269
|
+
const last = new Map();
|
|
270
|
+
lines.forEach((line, i) => {
|
|
271
|
+
const key = keyOfLine(line);
|
|
272
|
+
if (key !== null && pending.has(key)) last.set(key, i);
|
|
273
|
+
});
|
|
274
|
+
const out = [];
|
|
275
|
+
lines.forEach((line, i) => {
|
|
276
|
+
const key = keyOfLine(line);
|
|
277
|
+
if (key === null || !pending.has(key)) {
|
|
278
|
+
out.push(line);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (last.get(key) !== i) return;
|
|
282
|
+
const value = pending.get(key);
|
|
283
|
+
if (value !== undefined && value !== null) out.push(`${key}=${encodeEnvValue(value)}`);
|
|
284
|
+
});
|
|
285
|
+
for (const key of last.keys()) pending.delete(key);
|
|
286
|
+
// The split leaves '' for the final newline; appended keys go above it.
|
|
287
|
+
while (out.length > 0 && out[out.length - 1] === '') out.pop();
|
|
288
|
+
for (const [key, value] of pending) {
|
|
289
|
+
if (value === undefined || value === null) continue;
|
|
290
|
+
out.push(`${key}=${encodeEnvValue(value)}`);
|
|
291
|
+
}
|
|
292
|
+
return out.length === 0 ? '' : `${out.join('\n')}\n`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function keyOfLine(line) {
|
|
296
|
+
const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
|
|
297
|
+
return match ? match[1] : null;
|
|
265
298
|
}
|
|
266
299
|
|
|
267
300
|
/**
|
|
@@ -304,9 +337,17 @@ export function renameEnvKeys(text, renames) {
|
|
|
304
337
|
return { text: out.join('\n'), changes };
|
|
305
338
|
}
|
|
306
339
|
|
|
307
|
-
|
|
340
|
+
/**
|
|
341
|
+
* Quote a value so Node's parser reads it back unchanged. Node does not
|
|
342
|
+
* unescape `\"` inside double quotes, so `JSON.stringify` corrupts JSON values,
|
|
343
|
+
* and an unquoted `#` starts a comment. Single quotes are literal, so they come
|
|
344
|
+
* first; the other two quote kinds cover values that contain them.
|
|
345
|
+
*/
|
|
346
|
+
export function encodeEnvValue(value) {
|
|
308
347
|
const s = String(value);
|
|
309
|
-
if (s === '') return '"
|
|
310
|
-
if (
|
|
311
|
-
|
|
348
|
+
if (s === '') return "''";
|
|
349
|
+
if (!/[\s"'`#\\]/.test(s)) return s;
|
|
350
|
+
if (s.includes('\n')) throw new Error('Multi-line .env values are not supported');
|
|
351
|
+
for (const q of ["'", '"', '`']) if (!s.includes(q)) return `${q}${s}${q}`;
|
|
352
|
+
throw new Error('Value contains every quote character and cannot be written to .env');
|
|
312
353
|
}
|