fix(utils): parsed dotenv with bun-compatible syntax

Handled export prefixes and quote-aware inline comments when parsing dotenv files, and filtered child shells by launcher provenance so injected values are dropped regardless of value formatting.

Covered export and inline-comment forms in unit and shell-filter tests.

Fixes #6813
This commit is contained in:
roboomp
2026-07-27 15:37:13 +00:00
parent 048b415670
commit b9f1c32f69
3 changed files with 72 additions and 29 deletions
@@ -66,7 +66,14 @@ it("filters expanded dotenv values while preserving matching launcher values", a
try {
await Bun.write(
path.join(tmp, ".env"),
"BASE=loaded-by-omp\nTEST_ENV_FROM_DOTENV=$BASE-suffix\nNODE_ENV=development\n",
[
"BASE=loaded-by-omp",
"TEST_ENV_FROM_DOTENV=$BASE-suffix",
"NODE_ENV=development",
"export EXPORTED_SECRET=exported",
"COMMENTED_SECRET=secret # trailing comment",
"",
].join("\n"),
);
await Bun.write(
path.join(tmp, ".env.local"),
@@ -82,6 +89,8 @@ it("filters expanded dotenv values while preserving matching launcher values", a
" url: env.CONVEX_URL ?? null,",
" inherited: env.OMP_TEST_INHERITED_MARKER ?? null,",
" matching: env.NODE_ENV ?? null,",
" exported: env.EXPORTED_SECRET ?? null,",
" commented: env.COMMENTED_SECRET ?? null,",
"}));",
].join("\n");
const bunArgSets = process.platform === "linux" ? [[], ["--no-env-file"]] : [["--no-env-file"]];
@@ -112,6 +121,8 @@ it("filters expanded dotenv values while preserving matching launcher values", a
url: string | null;
inherited: string | null;
matching: string | null;
exported: string | null;
commented: string | null;
} = JSON.parse(stdout);
expect(payload).toEqual({
project: null,
@@ -119,6 +130,8 @@ it("filters expanded dotenv values while preserving matching launcher values", a
url: null,
inherited: "keep-me",
matching: "development",
exported: null,
commented: null,
});
}
} finally {
+40 -28
View File
@@ -105,12 +105,16 @@ export function filterChildShellEnv(
...expandDotenvValues(localEnv, result),
};
for (const key in launchEnv) {
if (projectEnvNamesLoadedByOmp.has(key)) {
// Launcher-owned names always survive with the launcher's own value.
if (launchEnvNames?.has(key)) continue;
if (launchEnvNames || projectEnvNamesLoadedByOmp.has(key)) {
// Strong provenance: the launch environment is known and this name is
// absent from it, or OMP itself injected the value — either way it came
// from a project dotenv file, not the parent shell.
delete result[key];
} else if (
!launchEnvNames?.has(key) &&
(result[key] === launchEnv[key] || result[key] === expandedLaunchEnv[key])
) {
} else if (result[key] === launchEnv[key] || result[key] === expandedLaunchEnv[key]) {
// No launch-env snapshot (non-Linux source install without
// `--no-env-file`): best-effort value match against the Bun-parsed dotenv.
delete result[key];
}
}
@@ -118,35 +122,43 @@ export function filterChildShellEnv(
}
/**
* Parses a .env file synchronously and extracts key-value string pairs.
* Ignores lines that are empty or start with '#'. Trims whitespace.
* Allows values to be quoted with single or double quotes.
* Returns an object of key-value pairs.
* Parse one dotenv line with Bun-compatible semantics: an optional `export`
* prefix, full-line `#` comments, inline `#` comments after whitespace on
* unquoted values, and single/double/backtick quoting (a `#` inside quotes
* stays literal). Returns undefined for blank lines, comments, and malformed
* names.
*/
function parseEnvLine(line: string): { key: string; value: string } | undefined {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) return undefined;
const eqIndex = trimmed.indexOf("=");
if (eqIndex === -1) return undefined;
let key = trimmed.slice(0, eqIndex).trim();
const exported = key.match(/^export[ \t]+(.*)$/);
if (exported) key = exported[1].trim();
if (!isValidEnvName(key)) return undefined;
const raw = trimmed.slice(eqIndex + 1).replace(/^[ \t]+/, "");
const quote = raw[0];
if (quote === '"' || quote === "'" || quote === "`") {
const close = raw.indexOf(quote, 1);
return { key, value: close === -1 ? raw.slice(1) : raw.slice(1, close) };
}
const commentIndex = raw.search(/[ \t]#/);
return { key, value: (commentIndex === -1 ? raw : raw.slice(0, commentIndex)).trimEnd() };
}
/**
* Parses a .env file synchronously into key-value string pairs using
* {@link parseEnvLine} for Bun-compatible line semantics, then mirrors valid
* `OMP_` variables to their `PI_` aliases.
*/
export function parseEnvFile(filePath: string): Record<string, string> {
const result: Record<string, string> = {};
try {
const content = fs.readFileSync(filePath, "utf-8");
for (const line of content.split("\n")) {
const trimmed = line.trim();
// Skip comments and blank lines
if (!trimmed || trimmed.startsWith("#")) continue;
const eqIndex = trimmed.indexOf("=");
if (eqIndex === -1) continue;
const key = trimmed.slice(0, eqIndex).trim();
if (!isValidEnvName(key)) continue;
let value = trimmed.slice(eqIndex + 1).trim();
// Remove surrounding quotes (" or ')
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
if (!isSafeEnvValue(value)) continue;
result[key] = value;
const parsed = parseEnvLine(line);
if (parsed && isSafeEnvValue(parsed.value)) result[parsed.key] = parsed.value;
}
} catch {
// File doesn't exist or can't be read - return empty result
+18
View File
@@ -49,6 +49,24 @@ describe("parseEnvFile", () => {
PI_FEATURE: "enabled",
});
});
it("matches Bun dotenv syntax for export prefixes and inline comments", () => {
const filePath = writeTempEnv(
[
"export EXPORTED=value",
"COMMENTED=secret # trailing comment",
'QUOTED_HASH="keep # this"',
"NO_SPACE=http://host/path#frag",
].join("\n"),
);
expect(parseEnvFile(filePath)).toEqual({
EXPORTED: "value",
COMMENTED: "secret",
QUOTED_HASH: "keep # this",
NO_SPACE: "http://host/path#frag",
});
});
});
describe("filterProcessEnv", () => {