mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(cli): preserve quoted multiline .env values (#6124)
* fix(cli): preserve quoted multiline .env values
applyDotEnv parsed .env content line by line: a quoted value's closing
quote was only ever looked for on the same physical line, so a value
like HYPERFRAMES_VERTEX_SERVICE_ACCOUNT='{...}' spread across several
lines got truncated to the leftover text on its opening line, and the
continuation lines were silently dropped (no "=" to parse as entries).
Join physical lines into logical ones before parsing: when a line
opens a quote that isn't closed on that same line, keep appending
following lines until one contains the closing quote character. This
matches how Node's util.parseEnv treats quoted multiline values.
Fixes #6075
* fix(cli): do not let a commented or unclosed quote swallow later .env lines
logicalLines looked for an open quote on every physical line before
entryOf had a chance to classify it as blank or a comment, so a
commented-out example like `# KEY="sk-old` (an unclosed quote inside
a comment) opened a quote that never closed, joining every line below
it into one value and dropping the real keys that followed.
A genuinely stray unclosed quote with no later closing line had the
same failure mode: the join ran to EOF and swallowed the rest of the
file instead of leaving the malformed line on its own, so a later
file like A='x / B=2 lost B entirely.
Skip blank/comment lines before checking for an open quote (matching
entryOf's own skip), and if the closing-quote search reaches EOF
without finding one, fall back to the opening line on its own and
resume parsing from the next line. Also strip a trailing \r per line
so a CRLF multiline value doesn't carry it into the joined string.
* fix(cli): trim comments on dotEnv.ts to stay within the comment-ratchet budget
Dropped the comments on openQuoteOf and closingLineIndex (their names
already say what they do) and cut the logicalLines comment down to
two lines, bringing the file back to 6/93 comment lines against the
base's 4/57.
This commit is contained in:
@@ -45,3 +45,42 @@ describe("applyDotEnv", () => {
|
||||
expect(env).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyDotEnv quoted multiline values", () => {
|
||||
it("keeps a quoted multiline value whole instead of truncating it to its first line", () => {
|
||||
const env: NodeJS.ProcessEnv = {};
|
||||
applyDotEnv(
|
||||
"HYPERFRAMES_VERTEX_PROJECT_ID=synthetic-project\n" +
|
||||
"HYPERFRAMES_VERTEX_SERVICE_ACCOUNT='{\n" +
|
||||
' "type": "service_account",\n' +
|
||||
' "project_id": "synthetic-project"\n' +
|
||||
"}'\n" +
|
||||
"AFTER=still-parsed",
|
||||
env,
|
||||
);
|
||||
expect(env.HYPERFRAMES_VERTEX_SERVICE_ACCOUNT).toBe(
|
||||
'{\n "type": "service_account",\n "project_id": "synthetic-project"\n}',
|
||||
);
|
||||
expect(() => JSON.parse(env.HYPERFRAMES_VERTEX_SERVICE_ACCOUNT!)).not.toThrow();
|
||||
expect(env.AFTER).toBe("still-parsed");
|
||||
});
|
||||
|
||||
it("a commented-out example with an unclosed quote does not swallow the real keys below it", () => {
|
||||
const env: NodeJS.ProcessEnv = {};
|
||||
applyDotEnv('# OPENROUTER_API_KEY="sk-old\nGEMINI_API_KEY=real1\nGOOGLE_API_KEY=real2', env);
|
||||
expect(env).toEqual({ GEMINI_API_KEY: "real1", GOOGLE_API_KEY: "real2" });
|
||||
});
|
||||
|
||||
it("a stray unclosed quote with no later closing line does not swallow the rest of the file", () => {
|
||||
const env: NodeJS.ProcessEnv = {};
|
||||
applyDotEnv("A='x\nB=2", env);
|
||||
expect(env).toEqual({ A: "x", B: "2" });
|
||||
});
|
||||
|
||||
it("strips the trailing \\r from a CRLF multiline value", () => {
|
||||
const env: NodeJS.ProcessEnv = {};
|
||||
applyDotEnv("KEY='line1\r\nline2'\r\nAFTER=2", env);
|
||||
expect(env.KEY).toBe("line1\nline2");
|
||||
expect(env.AFTER).toBe("2");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,42 @@ function entryOf(rawLine: string): [string, string] | null {
|
||||
return [line.slice(0, eq).trim(), valueOf(line.slice(eq + 1).trim())];
|
||||
}
|
||||
|
||||
function openQuoteOf(trimmedLine: string): string | null {
|
||||
const eq = trimmedLine.indexOf("=");
|
||||
if (eq < 1) return null;
|
||||
const valueStart = trimmedLine.slice(eq + 1).trim();
|
||||
const quote = valueStart.charAt(0);
|
||||
if (quote !== '"' && quote !== "'") return null;
|
||||
return valueStart.indexOf(quote, 1) < 0 ? quote : null;
|
||||
}
|
||||
|
||||
function closingLineIndex(rawLines: string[], from: number, quote: string): number {
|
||||
for (let j = from; j < rawLines.length; j++) {
|
||||
if ((rawLines[j] ?? "").includes(quote)) return j;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// A quoted value left open at end of line continues on later lines until one closes it;
|
||||
// blank/comment lines and an EOF with no closing quote never open or complete a continuation.
|
||||
function logicalLines(content: string): string[] {
|
||||
const rawLines = content.split("\n").map((line) => line.replace(/\r$/, ""));
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < rawLines.length; i++) {
|
||||
const line = rawLines[i] ?? "";
|
||||
const trimmed = line.trim();
|
||||
const quote = trimmed && !trimmed.startsWith("#") ? openQuoteOf(trimmed) : null;
|
||||
const end = quote === null ? -1 : closingLineIndex(rawLines, i + 1, quote);
|
||||
if (end === -1) {
|
||||
lines.push(line);
|
||||
continue;
|
||||
}
|
||||
lines.push(rawLines.slice(i, end + 1).join("\n"));
|
||||
i = end;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Never over a key already set, and never the feedback email in any letter case (Windows env names are case-blind):
|
||||
// only the launching app may attach it, and an agent can write a project `.env`.
|
||||
// An optional exact-name allowlist narrows callers that load less-trusted project settings.
|
||||
@@ -42,7 +78,7 @@ export function applyDotEnv(
|
||||
allowedKeys?: readonly string[],
|
||||
): void {
|
||||
const allowed = allowedKeys === undefined ? undefined : new Set(allowedKeys);
|
||||
for (const entry of content.split("\n").map(entryOf)) {
|
||||
for (const entry of logicalLines(content).map(entryOf)) {
|
||||
if (!entry) continue;
|
||||
const [key, value] = entry;
|
||||
if (
|
||||
|
||||
Reference in New Issue
Block a user