@selvajs/cli 4.20.0-beta.8 → 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.
- package/package.json +3 -3
- package/src/caddyfile.js +4 -4
- package/src/checks/config.js +79 -14
- package/src/checks/migrations.js +156 -0
- package/src/checks/runtime.js +35 -3
- package/src/cli.js +23 -7
- package/src/commands/config.js +196 -0
- package/src/commands/doctor.js +21 -83
- package/src/commands/init.js +15 -7
- package/src/commands/keys.js +112 -33
- package/src/commands/migrate.js +132 -37
- package/src/commands/pm2.js +129 -6
- package/src/commands/proxy.js +12 -0
- package/src/deployment-package.js +3 -1
- package/src/env-migration.js +155 -0
- package/src/env.js +80 -42
- package/src/generated/config.js +1186 -0
- package/src/prompts.js +9 -0
package/src/commands/pm2.js
CHANGED
|
@@ -8,6 +8,9 @@ import { spawnSync, execSync } from 'node:child_process';
|
|
|
8
8
|
import * as p from '@clack/prompts';
|
|
9
9
|
import pc from 'picocolors';
|
|
10
10
|
import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
|
|
11
|
+
import { readEnvFile } from '../env.js';
|
|
12
|
+
import { needsSupabaseProvider } from '../deployment-package.js';
|
|
13
|
+
import { checkSupabaseMigrations, providerTagFor } from '../checks/migrations.js';
|
|
11
14
|
|
|
12
15
|
export const APP_NAME = 'selva-compute';
|
|
13
16
|
|
|
@@ -147,10 +150,10 @@ export async function runUpdate() {
|
|
|
147
150
|
const before = readRuntimeVersion(dir);
|
|
148
151
|
p.log.info(`Current @selvajs/selva: ${before ?? 'unknown'}`);
|
|
149
152
|
|
|
150
|
-
//
|
|
151
|
-
// an operator install carries are the runtime and the CLI. The admin-center
|
|
152
|
-
// "Run update" button runs the same list — keep them in sync if you edit.
|
|
153
|
+
// The admin-center "Run update" button runs the same list: keep them in sync.
|
|
153
154
|
const packages = ['@selvajs/cli', '@selvajs/selva'];
|
|
155
|
+
const env = readEnvFile(join(dir, '.env'));
|
|
156
|
+
const supabase = needsSupabaseProvider(env);
|
|
154
157
|
|
|
155
158
|
const confirmed = await p.confirm({
|
|
156
159
|
message: 'Refresh all @selvajs/* packages and restart the app?',
|
|
@@ -185,6 +188,23 @@ export async function runUpdate() {
|
|
|
185
188
|
const after = readRuntimeVersion(dir);
|
|
186
189
|
p.log.info(`New @selvajs/selva: ${after ?? 'unknown'}`);
|
|
187
190
|
|
|
191
|
+
realignPm2Pin(dir, s);
|
|
192
|
+
|
|
193
|
+
if (supabase) {
|
|
194
|
+
// Provider code is bundled into @selvajs/selva, but the migration SQL ships
|
|
195
|
+
// here. Left behind, `selva-supabase` copies nothing new. Installed by tag,
|
|
196
|
+
// not `npm update`d: its pin can sit on another channel than the runtime's.
|
|
197
|
+
const spec = `@selvajs/supabase-provider@${providerTagFor(after)}`;
|
|
198
|
+
s.start(`npm install ${spec}`);
|
|
199
|
+
try {
|
|
200
|
+
execSync(`npm install --save --prefer-online ${spec}`, { cwd: dir, stdio: 'pipe' });
|
|
201
|
+
s.stop(`${spec} installed`);
|
|
202
|
+
} catch (err) {
|
|
203
|
+
s.stop(`npm install ${spec} failed`);
|
|
204
|
+
p.log.warn(String(err.stderr ?? err.message ?? err).trim());
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
188
208
|
if (before && after && before === after) {
|
|
189
209
|
p.log.warn(
|
|
190
210
|
[
|
|
@@ -199,12 +219,115 @@ export async function runUpdate() {
|
|
|
199
219
|
);
|
|
200
220
|
}
|
|
201
221
|
|
|
222
|
+
// Migrations are not applied here: they aren't reversible, and the old build
|
|
223
|
+
// is already gone. The app serves on an old schema (degraded), so start it and
|
|
224
|
+
// say what is left to do.
|
|
225
|
+
const schema = supabase ? await checkSupabaseMigrations(dir, env, after) : null;
|
|
226
|
+
|
|
202
227
|
const status = runPm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
|
|
203
|
-
if (status
|
|
204
|
-
p.outro(pc.green('Started ' + APP_NAME));
|
|
205
|
-
} else {
|
|
228
|
+
if (status !== 0) {
|
|
206
229
|
p.outro(pc.yellow(`Start failed — investigate with \`pm2 logs ${APP_NAME}\`.`));
|
|
230
|
+
process.exitCode = 1;
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const health = await waitForHealth(env);
|
|
235
|
+
if (schema && schema.severity !== 'green') p.log.message(schema.line);
|
|
236
|
+
if (health.ok) {
|
|
237
|
+
p.outro(pc.green(`Started ${APP_NAME}`));
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
p.outro(
|
|
241
|
+
pc.red(
|
|
242
|
+
health.status
|
|
243
|
+
? `Started ${APP_NAME}, but /api/health returned ${health.status}: ${health.reason}`
|
|
244
|
+
: `Started ${APP_NAME}, but /api/health did not answer within 30s. Check \`pm2 logs ${APP_NAME}\`.`
|
|
245
|
+
)
|
|
246
|
+
);
|
|
247
|
+
process.exitCode = 1;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Moves the deployment onto the pm2 the just-installed CLI pins, then swaps the
|
|
251
|
+
// daemon. Without it a release that bumps pm2 needs `selva migrate` and
|
|
252
|
+
// `pm2 update` by hand on every host. Runs from the OLD CLI, so the pin is read
|
|
253
|
+
// from the new one on disk. The app is stopped here, so the swap costs no
|
|
254
|
+
// extra downtime. A failure leaves the old pm2 working: warn, don't abort.
|
|
255
|
+
function readPm2Pins(dir) {
|
|
256
|
+
const read = (path) => {
|
|
257
|
+
try {
|
|
258
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
259
|
+
} catch {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
return {
|
|
264
|
+
declared: read(join(dir, 'package.json'))?.dependencies?.pm2 ?? null,
|
|
265
|
+
pinned:
|
|
266
|
+
read(join(dir, 'node_modules', '@selvajs', 'cli', 'package.json'))?.devDependencies?.pm2 ??
|
|
267
|
+
null
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function realignPm2Pin(dir, s) {
|
|
272
|
+
const { declared, pinned } = readPm2Pins(dir);
|
|
273
|
+
if (!pinned || pinned === declared) return;
|
|
274
|
+
|
|
275
|
+
s.start(`npm install pm2@${pinned}`);
|
|
276
|
+
try {
|
|
277
|
+
execSync(`npm install --save --save-exact --prefer-online pm2@${pinned}`, {
|
|
278
|
+
cwd: dir,
|
|
279
|
+
stdio: 'pipe'
|
|
280
|
+
});
|
|
281
|
+
s.stop(`pm2 ${declared ?? '(none)'} → ${pinned}`);
|
|
282
|
+
} catch (err) {
|
|
283
|
+
s.stop(`npm install pm2@${pinned} failed`);
|
|
284
|
+
p.log.warn(
|
|
285
|
+
`${String(err.stderr ?? err.message ?? err).trim()}\n` +
|
|
286
|
+
'The previous pm2 still runs the app. Retry with `npx selva migrate`.'
|
|
287
|
+
);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
ensurePm2InSync(dir);
|
|
292
|
+
} catch (err) {
|
|
293
|
+
p.log.warn(`${err instanceof Error ? err.message : err}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// The app was stopped before the update, so the first answer is the new process.
|
|
298
|
+
async function waitForHealth(env, timeoutMs = 30_000) {
|
|
299
|
+
const host = !env.HOST || env.HOST === '0.0.0.0' || env.HOST === '::' ? '127.0.0.1' : env.HOST;
|
|
300
|
+
const url = `http://${host.includes(':') ? `[${host}]` : host}:${env.PORT || 3000}/api/health`;
|
|
301
|
+
const deadline = Date.now() + timeoutMs;
|
|
302
|
+
while (Date.now() < deadline) {
|
|
303
|
+
try {
|
|
304
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(3000) });
|
|
305
|
+
if (res.ok) return { ok: true };
|
|
306
|
+
return {
|
|
307
|
+
ok: false,
|
|
308
|
+
status: res.status,
|
|
309
|
+
reason: degradedReason(await res.json().catch(() => null))
|
|
310
|
+
};
|
|
311
|
+
} catch {
|
|
312
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return { ok: false };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Names the cause a 503 from /api/health reports in `boot`. */
|
|
319
|
+
export function degradedReason(body) {
|
|
320
|
+
const boot = body?.boot;
|
|
321
|
+
if (boot?.schemaVersion?.ok === false) {
|
|
322
|
+
return (
|
|
323
|
+
boot.schemaVersion.message ??
|
|
324
|
+
`database migration head ${boot.schemaVersion.actual} is behind ${boot.schemaVersion.expected}`
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
if (boot?.atRestSecrets?.ok === false) {
|
|
328
|
+
return 'stored compute API keys cannot be decrypted under the current SELVA_AT_REST_KEY';
|
|
207
329
|
}
|
|
330
|
+
return 'see `pm2 logs selva-compute`';
|
|
208
331
|
}
|
|
209
332
|
|
|
210
333
|
function readRuntimeVersion(dir) {
|
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}.`),
|
|
@@ -129,7 +129,9 @@ export function isPrereleasePin(pin) {
|
|
|
129
129
|
*
|
|
130
130
|
* js-yaml: pm2 <= 7.0.3 pins 4.3.0, which carries the quadratic-complexity
|
|
131
131
|
* DoS advisories (GHSA-5p4m-2wfm-xmqj and friends); the fix landed in 4.3.1.
|
|
132
|
-
*
|
|
132
|
+
* pm2 7.1.2 pins 4.3.2, so this is now redundant. Dropping it rewrites every
|
|
133
|
+
* deployment's package.json and `doctor` reports the change as drift, so it
|
|
134
|
+
* goes with the next release that migrates the layout anyway.
|
|
133
135
|
*/
|
|
134
136
|
export const OVERRIDES = {
|
|
135
137
|
'js-yaml': '^4.3.1'
|
|
@@ -0,0 +1,155 @@
|
|
|
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 and retired
|
|
3
|
+
// lines 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
|
+
RETIRED_ENV_VARS as RETIRED,
|
|
14
|
+
renameEnvKeys,
|
|
15
|
+
setEnvValues,
|
|
16
|
+
stripEnvComments
|
|
17
|
+
} from './env.js';
|
|
18
|
+
import { ALL_SETTINGS, legacyValue, resolveConfig } from './generated/config.js';
|
|
19
|
+
import { reportFor } from './commands/config.js';
|
|
20
|
+
|
|
21
|
+
const SPECS = new Map(ALL_SETTINGS.map((s) => [s.key, s]));
|
|
22
|
+
|
|
23
|
+
// How the old reader took SELVA_FLAG_COMPUTE_DEBUG_VERBOSE (engine.server.ts).
|
|
24
|
+
const OLD_VERBOSE_TRUE = new Set(['true', '1', 'yes']);
|
|
25
|
+
|
|
26
|
+
/** The debug mode the app runs in, from either generation of the keys. */
|
|
27
|
+
function computeDebugMode(env) {
|
|
28
|
+
const raw = (env.SELVA_FLAG_COMPUTE_DEBUG ?? '').toLowerCase();
|
|
29
|
+
if (raw === 'verbose') return 'verbose';
|
|
30
|
+
if (OLD_VERBOSE_TRUE.has((env.SELVA_FLAG_COMPUTE_DEBUG_VERBOSE ?? '').toLowerCase()))
|
|
31
|
+
return 'verbose';
|
|
32
|
+
return ['true', '1', 'yes', 'on'].includes(raw) ? 'on' : 'off';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Keys whose effective value depends on another key. */
|
|
36
|
+
const EFFECTIVE = {
|
|
37
|
+
HEADER_AUTH_DATA_DIR: (env) => env.HEADER_AUTH_DATA_DIR || env.DATA_PATH,
|
|
38
|
+
SELVA_FLAG_COMPUTE_DEBUG: computeDebugMode
|
|
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
|
+
// The app ignores these whatever their value, so removing the line changes nothing it does.
|
|
118
|
+
for (const [key, now] of Object.entries(RETIRED)) {
|
|
119
|
+
if (env[key] === undefined || key in updates) continue;
|
|
120
|
+
updates[key] = undefined;
|
|
121
|
+
changes.push({ key, action: 'removed', reason: `no longer read: ${now}` });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (Object.keys(updates).length > 0) text = setEnvValues(text, updates);
|
|
125
|
+
|
|
126
|
+
// Read back with Node's parser, the one the app boots with, so a writer bug
|
|
127
|
+
// shows up here as a mismatch instead of on the server.
|
|
128
|
+
const before = parseEnv(input);
|
|
129
|
+
const after = parseEnv(text);
|
|
130
|
+
const mismatches = [];
|
|
131
|
+
for (const spec of ALL_SETTINGS) {
|
|
132
|
+
const was = meaning(spec, before, { legacy: true });
|
|
133
|
+
const is = meaning(spec, after, { legacy: false });
|
|
134
|
+
if (JSON.stringify(was) !== JSON.stringify(is)) mismatches.push({ key: spec.key, was, is });
|
|
135
|
+
}
|
|
136
|
+
for (const key of Object.keys(before)) {
|
|
137
|
+
if (!SPECS.has(key) && !(key in RENAMES) && !(key in RETIRED) && after[key] !== before[key]) {
|
|
138
|
+
mismatches.push({ key, was: before[key], is: after[key] });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// A value both generations reject compares equal above, yet the old reader
|
|
143
|
+
// shrugged it off while the new one may stop boot on it. Name those apart:
|
|
144
|
+
// `blockers` stop the app, `fallbacks` run on the default.
|
|
145
|
+
const report = reportFor(after);
|
|
146
|
+
return {
|
|
147
|
+
text,
|
|
148
|
+
changes,
|
|
149
|
+
mismatches,
|
|
150
|
+
// A missing required key is a broken .env either way: doctor's job, not a migration's.
|
|
151
|
+
blockers: report.problems.filter((p) => p.status === 'error' && p.source !== 'unset'),
|
|
152
|
+
fallbacks: report.problems.filter((p) => p.status === 'invalid'),
|
|
153
|
+
unknown: report.unknown
|
|
154
|
+
};
|
|
155
|
+
}
|
package/src/env.js
CHANGED
|
@@ -1,23 +1,19 @@
|
|
|
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, RETIRED_KEYS } from './generated/config.js';
|
|
4
8
|
|
|
5
9
|
// Old env name → its replacement, for keys renamed without changing meaning.
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
MAX_SOLVE_DURATION_MS: 'COMPUTE_SOLVE_DEADLINE_MS'
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
// Deprecated vars whose replacement changes the VALUE too, so a simple key
|
|
17
|
-
// rename can't migrate them — `selva migrate` leaves these alone rather than guessing.
|
|
18
|
-
export const REPLACED_ENV_VARS = {
|
|
19
|
-
SELVA_FLAG_COMPUTE_DEBUG_VERBOSE: 'SELVA_FLAG_COMPUTE_DEBUG=verbose'
|
|
20
|
-
};
|
|
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
|
+
);
|
|
14
|
+
|
|
15
|
+
// Keys the app no longer reads → what took their place. `selva migrate` removes them.
|
|
16
|
+
export const RETIRED_ENV_VARS = RETIRED_KEYS;
|
|
21
17
|
|
|
22
18
|
// Header written above the settings in a stripped .env. The pointer matters:
|
|
23
19
|
// without it the operator has no way back to what a var means, and the reflex
|
|
@@ -156,23 +152,7 @@ export function countEnvCommentLines(text) {
|
|
|
156
152
|
}
|
|
157
153
|
|
|
158
154
|
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;
|
|
155
|
+
return nodeParseEnv(text);
|
|
176
156
|
}
|
|
177
157
|
|
|
178
158
|
export function readEnvFile(path) {
|
|
@@ -196,7 +176,7 @@ export function readEnvFile(path) {
|
|
|
196
176
|
const SEEDED_TEMPLATE_DEFAULTS = ['BODY_SIZE_LIMIT'];
|
|
197
177
|
|
|
198
178
|
/** The subset of the template's live `KEY=value` lines that are safe to seed. */
|
|
199
|
-
function seededTemplateDefaults(template) {
|
|
179
|
+
export function seededTemplateDefaults(template) {
|
|
200
180
|
const parsed = parseEnv(template);
|
|
201
181
|
const out = {};
|
|
202
182
|
for (const key of SEEDED_TEMPLATE_DEFAULTS) {
|
|
@@ -253,15 +233,65 @@ export function renderEnvValues(template, values, { header = ENV_HEADER } = {})
|
|
|
253
233
|
// operator has deliberately blanked one out.
|
|
254
234
|
const value = Object.prototype.hasOwnProperty.call(values, key) ? values[key] : defaults[key];
|
|
255
235
|
if (value === undefined || value === null || value === '') continue;
|
|
256
|
-
lines.push(`${key}=${
|
|
236
|
+
lines.push(`${key}=${encodeEnvValue(value)}`);
|
|
257
237
|
}
|
|
258
238
|
|
|
259
239
|
return header ? `${header}\n\n${lines.join('\n')}\n` : `${lines.join('\n')}\n`;
|
|
260
240
|
}
|
|
261
241
|
|
|
262
|
-
/** Writes
|
|
242
|
+
/** Writes a fresh deployment `.env`, values only, in the template's key order. */
|
|
263
243
|
export function writeEnvFile(path, template, values) {
|
|
264
|
-
|
|
244
|
+
writeEnvText(path, renderEnvValues(template, values));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** `.env` holds both root keys, so it is the owner's alone. */
|
|
248
|
+
export function writeEnvText(path, text) {
|
|
249
|
+
writeFileSync(path, text, { encoding: 'utf8', mode: 0o600 });
|
|
250
|
+
// `mode` applies only when the file is created; an existing file keeps its own.
|
|
251
|
+
if (process.platform !== 'win32') chmodSync(path, 0o600);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Set or remove keys in an existing `.env`, touching only their lines.
|
|
256
|
+
*
|
|
257
|
+
* Re-rendering the whole file drops the operator's comments and re-encodes
|
|
258
|
+
* every value. `undefined` or `null` removes the key; a key not yet present is
|
|
259
|
+
* appended.
|
|
260
|
+
*/
|
|
261
|
+
export function setEnvValues(text, updates) {
|
|
262
|
+
const pending = new Map(Object.entries(updates));
|
|
263
|
+
const lines = text.split(/\r?\n/);
|
|
264
|
+
// Node reads the last line for a key that appears twice, so that is the one
|
|
265
|
+
// to edit; earlier duplicates would only mislead the next reader of the file.
|
|
266
|
+
const last = new Map();
|
|
267
|
+
lines.forEach((line, i) => {
|
|
268
|
+
const key = keyOfLine(line);
|
|
269
|
+
if (key !== null && pending.has(key)) last.set(key, i);
|
|
270
|
+
});
|
|
271
|
+
const out = [];
|
|
272
|
+
lines.forEach((line, i) => {
|
|
273
|
+
const key = keyOfLine(line);
|
|
274
|
+
if (key === null || !pending.has(key)) {
|
|
275
|
+
out.push(line);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (last.get(key) !== i) return;
|
|
279
|
+
const value = pending.get(key);
|
|
280
|
+
if (value !== undefined && value !== null) out.push(`${key}=${encodeEnvValue(value)}`);
|
|
281
|
+
});
|
|
282
|
+
for (const key of last.keys()) pending.delete(key);
|
|
283
|
+
// The split leaves '' for the final newline; appended keys go above it.
|
|
284
|
+
while (out.length > 0 && out[out.length - 1] === '') out.pop();
|
|
285
|
+
for (const [key, value] of pending) {
|
|
286
|
+
if (value === undefined || value === null) continue;
|
|
287
|
+
out.push(`${key}=${encodeEnvValue(value)}`);
|
|
288
|
+
}
|
|
289
|
+
return out.length === 0 ? '' : `${out.join('\n')}\n`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function keyOfLine(line) {
|
|
293
|
+
const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
|
|
294
|
+
return match ? match[1] : null;
|
|
265
295
|
}
|
|
266
296
|
|
|
267
297
|
/**
|
|
@@ -304,9 +334,17 @@ export function renameEnvKeys(text, renames) {
|
|
|
304
334
|
return { text: out.join('\n'), changes };
|
|
305
335
|
}
|
|
306
336
|
|
|
307
|
-
|
|
337
|
+
/**
|
|
338
|
+
* Quote a value so Node's parser reads it back unchanged. Node does not
|
|
339
|
+
* unescape `\"` inside double quotes, so `JSON.stringify` corrupts JSON values,
|
|
340
|
+
* and an unquoted `#` starts a comment. Single quotes are literal, so they come
|
|
341
|
+
* first; the other two quote kinds cover values that contain them.
|
|
342
|
+
*/
|
|
343
|
+
export function encodeEnvValue(value) {
|
|
308
344
|
const s = String(value);
|
|
309
|
-
if (s === '') return '"
|
|
310
|
-
if (
|
|
311
|
-
|
|
345
|
+
if (s === '') return "''";
|
|
346
|
+
if (!/[\s"'`#\\]/.test(s)) return s;
|
|
347
|
+
if (s.includes('\n')) throw new Error('Multi-line .env values are not supported');
|
|
348
|
+
for (const q of ["'", '"', '`']) if (!s.includes(q)) return `${q}${s}${q}`;
|
|
349
|
+
throw new Error('Value contains every quote character and cannot be written to .env');
|
|
312
350
|
}
|