Files
broccolini-bot/services/configPersistence.js
indifferentketchup fa7d4af132 strip: delete stale docs/ and broccolini_bot_context.md
Both were saturated with references to removed features.
Regenerate fresh post-MVP.
2026-04-21 16:32:05 +00:00

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 };