Both were saturated with references to removed features. Regenerate fresh post-MVP.
205 lines
6.8 KiB
JavaScript
205 lines
6.8 KiB
JavaScript
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) {
|
|
throw new Error(`writeEnvFile: key count mismatch after write (expected ${expected}, got ${roundtrip.size})`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 };
|