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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user