Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions packages/cli/src/utils/dotEnv.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
38 changes: 37 additions & 1 deletion packages/cli/src/utils/dotEnv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 (
Expand Down
Loading