const fs = require('fs'); const path = require('path'); const { CONFIG } = require('../config'); const { getValidator } = require('./configSchema'); const ENV_PATH = path.resolve(process.cwd(), '.env'); /** * Serialize a runtime value for .env storage. * * Default container: backticks. Under dotenv v17, backtick-wrapped values are * preserved verbatim (literal newlines and inner quotes/backslashes all round-trip * without escape processing), which is the only container that survives quotes * AND newlines cleanly. Double-quoted values only decode `\n` / `\r`; `\"` and * `\\` stay literal, so quoted-with-escapes doesn't round-trip. * * Fallback for values that themselves contain a backtick (vanishingly rare in * env-style config): double-quote with escape-encoded `\\`, `\"`, `\n`, `\r`, * `\t`. Caveat — CONFIG will receive the still-escaped `\"` / `\\` after boot * because dotenv v17 won't reverse those. Flagged in the key-count verification * at the bottom of writeEnvFile; the combination is unreachable via the UI. */ function encodeEnvValue(v) { const s = String(v == null ? '' : v); if (!s.includes('`')) return '`' + s + '`'; const escaped = s .replace(/\\/g, '\\\\') .replace(/"/g, '\\"') .replace(/\n/g, '\\n') .replace(/\r/g, '\\r') .replace(/\t/g, '\\t'); return `"${escaped}"`; } /** * Decode a raw .env value. Backtick and double-quote containers supported; * unquoted values pass through (hand-edited .env back-compat). */ function decodeEnvValue(raw) { if (raw.length >= 2 && raw.startsWith('`') && raw.endsWith('`')) { return raw.slice(1, -1); } if (raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"')) { const inner = raw.slice(1, -1); let out = ''; for (let i = 0; i < inner.length; i++) { if (inner[i] === '\\' && i + 1 < inner.length) { const next = inner[i + 1]; if (next === 'n') out += '\n'; else if (next === 'r') out += '\r'; else if (next === 't') out += '\t'; else if (next === '"') out += '"'; else if (next === '\\') out += '\\'; else out += inner[i] + next; i++; } else { out += inner[i]; } } return out; } return raw; } /** * Read the current .env file and parse into a key->value Map. * Backtick-wrapped values may span multiple physical lines (dotenv behavior); * this reader joins continuation lines until the closing backtick is found. * Double-quoted values are decoded (`\n`/`\r` escapes processed); * unquoted values pass through. */ function readEnvFile() { if (!fs.existsSync(ENV_PATH)) return new Map(); const lines = fs.readFileSync(ENV_PATH, 'utf8').split('\n'); const map = new Map(); for (let i = 0; i < lines.length; i++) { const line = lines[i]; const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const idx = line.indexOf('='); if (idx === -1) continue; const key = line.slice(0, idx).trim(); let value = line.slice(idx + 1); if (value.trimStart().startsWith('`')) { let btCount = (value.match(/`/g) || []).length; while (btCount < 2 && i + 1 < lines.length) { i++; value += '\n' + lines[i]; btCount = (value.match(/`/g) || []).length; } } map.set(key, decodeEnvValue(value.trim())); } return map; } /** * Write a Map of key->value back to the .env file, * preserving comments and blank lines. Values are encoded via encodeEnvValue. * * After write, re-reads the file and throws if the key count doesn't match the * expected count — catches truncation or corrupted-quote escaping. */ function writeEnvFile(updates) { const expected = updates.size; if (!fs.existsSync(ENV_PATH)) { const lines = []; for (const [k, v] of updates) lines.push(`${k}=${encodeEnvValue(v)}`); fs.writeFileSync(ENV_PATH, lines.join('\n') + '\n', 'utf8'); } else { const raw = fs.readFileSync(ENV_PATH, 'utf8'); const lines = raw.split('\n'); const written = new Set(); const result = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) { result.push(line); continue; } const idx = line.indexOf('='); if (idx === -1) { result.push(line); continue; } const key = line.slice(0, idx).trim(); const spanStart = i; let valueSoFar = line.slice(idx + 1); if (valueSoFar.trimStart().startsWith('`')) { let btCount = (valueSoFar.match(/`/g) || []).length; while (btCount < 2 && i + 1 < lines.length) { i++; valueSoFar += '\n' + lines[i]; btCount = (valueSoFar.match(/`/g) || []).length; } } if (updates.has(key)) { written.add(key); result.push(`${key}=${encodeEnvValue(updates.get(key))}`); } else { for (let j = spanStart; j <= i; j++) result.push(lines[j]); } } for (const [k, v] of updates) { if (!written.has(k)) result.push(`${k}=${encodeEnvValue(v)}`); } fs.writeFileSync(ENV_PATH, result.join('\n'), 'utf8'); } const roundtrip = readEnvFile(); if (roundtrip.size !== expected) { const expectedKeys = new Set(updates.keys()); const actualKeys = new Set(roundtrip.keys()); const missing = [...expectedKeys].filter(k => !actualKeys.has(k)); const extra = [...actualKeys].filter(k => !expectedKeys.has(k)); throw new Error( `writeEnvFile: key count mismatch after write ` + `(expected ${expected}, got ${roundtrip.size})` + (missing.length ? `. Missing: [${missing.join(', ')}]` : '') + (extra.length ? `. Extra: [${extra.join(', ')}]` : '') ); } } /** * Apply a flat object of { KEY: value } to both CONFIG and .env. * Returns { applied: string[], errors: Array<{ key, error }> }. * * Every value is routed through services/configSchema so invalid inputs are * rejected before being persisted. Valid values are typed via the validator's * coerced return (boolean/number/string) into CONFIG; .env always stores * String(coerced). Invalid values are reported and NEITHER written to .env NOR * applied to CONFIG — callers see them in the `errors` array. */ function applyConfigUpdates(updates) { const applied = []; const errors = []; const coercedForEnv = new Map(); for (const [key, rawValue] of Object.entries(updates)) { const validator = getValidator(key); const result = validator.validate(rawValue); if (!result.ok) { errors.push({ key, error: result.error }); continue; } CONFIG[key] = result.coerced; coercedForEnv.set(key, String(result.coerced)); applied.push(key); } // Write only the valid entries to .env. Skip the disk write entirely when // nothing validated, so a fully-rejected save doesn't rewrite the file. if (applied.length > 0) { const envMap = readEnvFile(); for (const [key, value] of coercedForEnv) { envMap.set(key, value); } writeEnvFile(envMap); } return { applied, errors }; } /** * Read all current env values for the settings UI. */ function readAllConfig() { return readEnvFile(); } module.exports = { applyConfigUpdates, readAllConfig, readEnvFile, writeEnvFile };