This commit is contained in:
2026-04-20 18:05:36 +00:00
parent d73422555d
commit 33b1f276c6
26 changed files with 598 additions and 183 deletions

View File

@@ -7,60 +7,153 @@ const ENV_PATH = process.env.ENV_FILE
? path.resolve(process.env.ENV_FILE)
: 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 (const line of lines) {
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();
const value = line.slice(idx + 1).trim();
map.set(key, value);
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.
* 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}=${v}`);
for (const [k, v] of updates) lines.push(`${k}=${encodeEnvValue(v)}`);
fs.writeFileSync(ENV_PATH, lines.join('\n') + '\n', 'utf8');
return;
}
} else {
const raw = fs.readFileSync(ENV_PATH, 'utf8');
const lines = raw.split('\n');
const written = new Set();
const result = [];
const raw = fs.readFileSync(ENV_PATH, 'utf8');
const lines = raw.split('\n');
const written = new Set();
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 result = lines.map(line => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) return line;
const idx = line.indexOf('=');
if (idx === -1) return line;
const key = line.slice(0, idx).trim();
if (updates.has(key)) {
written.add(key);
return `${key}=${updates.get(key)}`;
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]);
}
}
return line;
});
// Append any new keys not already in the file
for (const [k, v] of updates) {
if (!written.has(k)) result.push(`${k}=${v}`);
for (const [k, v] of updates) {
if (!written.has(k)) result.push(`${k}=${encodeEnvValue(v)}`);
}
fs.writeFileSync(ENV_PATH, result.join('\n'), 'utf8');
}
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})`);
}
}
/**