@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.
@@ -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
- // Providers are bundled into @selvajs/selva — the only @selvajs/* packages
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 === 0) {
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) {
@@ -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
- * Drop when pm2's own js-yaml dependency reaches >= 4.3.1.
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
- // Minimal .env parser/serializer (no dotenv; runtime uses node --env-file).
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
- // The server still reads each old name for one minor version and warns at
7
- // boot; keep this in sync with the server's list and drop an entry once the
8
- // server stops reading it.
9
- export const RENAMED_ENV_VARS = {
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
- };
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
- const out = {};
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}=${quoteIfNeeded(value)}`);
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 the deployment `.env` — values only, in the template's key order. */
242
+ /** Writes a fresh deployment `.env`, values only, in the template's key order. */
263
243
  export function writeEnvFile(path, template, values) {
264
- writeFileSync(path, renderEnvValues(template, values), 'utf8');
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
- function quoteIfNeeded(value) {
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 (/[\s"'#=]/.test(s)) return JSON.stringify(s);
311
- return s;
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
  }