mirror of
https://github.com/oven-sh/bun
synced 2026-02-10 02:48:50 +00:00
## Summary
Fixes environment variable expansion in quoted `.npmrc` values and adds
support for the `?` optional modifier.
### Changes
**Simplified quoted value handling:**
- Removed unnecessary `isProperlyQuoted` check that added complexity
without benefit
- When JSON.parse succeeds for quoted strings, expand env vars in the
result
- When JSON.parse fails for single-quoted strings like `'${VAR}'`, still
expand env vars
**Added `?` modifier support (matching npm behavior):**
- `${VAR}` - if VAR is undefined, leaves as `${VAR}` (no expansion)
- `${VAR?}` - if VAR is undefined, expands to empty string
This applies consistently to both quoted and unquoted values.
### Examples
```ini
# Env var found - all expand to the value
token = ${NPM_TOKEN}
token = "${NPM_TOKEN}"
token = '${NPM_TOKEN}'
# Env var NOT found - left as-is
token = ${NPM_TOKEN} # → ${NPM_TOKEN}
token = "${NPM_TOKEN}" # → ${NPM_TOKEN}
token = '${NPM_TOKEN}' # → ${NPM_TOKEN}
# Optional modifier (?) - expands to empty if not found
token = ${NPM_TOKEN?} # → (empty)
token = "${NPM_TOKEN?}" # → (empty)
auth = "Bearer ${TOKEN?}" # → Bearer
```
### Test Plan
- Added 8 new tests for the `?` modifier covering quoted and unquoted
values
- Verified all expected values match `npm config get` behavior
- All 30 ini tests pass
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
533 lines
12 KiB
TypeScript
533 lines
12 KiB
TypeScript
const { iniInternals } = require("bun:internal-for-testing");
|
|
const { parse } = iniInternals;
|
|
import { describe, expect, it, test } from "bun:test";
|
|
import { bunEnv, bunExe, tempDirWithFiles } from "harness";
|
|
|
|
describe("parse ini", () => {
|
|
test("weird section", () => {
|
|
const ini = /* ini */ `
|
|
[foo\\]]
|
|
lol = true
|
|
`;
|
|
|
|
expect(parse(ini)).toEqual({ "[foo\\]]": true, "lol": true });
|
|
});
|
|
|
|
test("really long input", () => {
|
|
const ini = /* ini */ `
|
|
[${Array(1024).fill("a").join("")}.lol.this.be.long]
|
|
wow = 'hi'
|
|
`;
|
|
|
|
expect(parse(ini)).toEqual({
|
|
[`${Array(1024).fill("a").join("")}`]: {
|
|
lol: {
|
|
this: {
|
|
be: {
|
|
long: {
|
|
wow: "hi",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
describe("env vars", () => {
|
|
// Tests translated from npm's workspaces/config/test/env-replace.js
|
|
envVarTest({
|
|
name: "replaces multiple defined variables",
|
|
ini: "hi = ${FOO}${BAR}",
|
|
env: { FOO: "bar", BAR: "baz" },
|
|
expected: { hi: "barbaz" },
|
|
});
|
|
|
|
envVarTest({
|
|
name: "replaces mixed defined/undefined variables with ? modifier",
|
|
ini: "hi = ${FOO?}${BAZ?}",
|
|
env: { FOO: "bar" },
|
|
expected: { hi: "bar" },
|
|
});
|
|
|
|
envVarTest({
|
|
name: "escapes normal variable",
|
|
ini: "hi = \\${FOO}",
|
|
env: { FOO: "bar" },
|
|
expected: { hi: "${FOO}" },
|
|
});
|
|
|
|
envVarTest({
|
|
name: "double escape allows replacement",
|
|
ini: "hi = \\\\${FOO}",
|
|
env: { FOO: "bar" },
|
|
expected: { hi: "\\bar" },
|
|
});
|
|
|
|
envVarTest({
|
|
name: "triple escape prevents replacement",
|
|
ini: "hi = \\\\\\${FOO}",
|
|
env: { FOO: "bar" },
|
|
expected: { hi: "\\${FOO}" },
|
|
});
|
|
|
|
envVarTest({
|
|
name: "leaves undefined variable unreplaced",
|
|
ini: "hi = ${BAZ}",
|
|
env: { FOO: "bar" },
|
|
expected: { hi: "${BAZ}" },
|
|
});
|
|
|
|
envVarTest({
|
|
name: "escapes undefined variable",
|
|
ini: "hi = \\${BAZ}",
|
|
env: { FOO: "bar" },
|
|
expected: { hi: "${BAZ}" },
|
|
});
|
|
|
|
envVarTest({
|
|
name: "double escape with undefined variable",
|
|
ini: "hi = \\\\${BAZ}",
|
|
env: { FOO: "bar" },
|
|
expected: { hi: "\\${BAZ}" },
|
|
});
|
|
|
|
envVarTest({
|
|
name: "escapes optional variable",
|
|
ini: "hi = \\${FOO?}",
|
|
env: { FOO: "bar" },
|
|
expected: { hi: "${FOO?}" },
|
|
});
|
|
|
|
envVarTest({
|
|
name: "double escape allows optional replacement",
|
|
ini: "hi = \\\\${FOO?}",
|
|
env: { FOO: "bar" },
|
|
expected: { hi: "\\bar" },
|
|
});
|
|
|
|
envVarTest({
|
|
name: "replaces undefined variable with empty string when using ? modifier",
|
|
ini: "hi = ${BAZ?}",
|
|
env: { FOO: "bar" },
|
|
expected: { hi: "" },
|
|
});
|
|
|
|
envVarTest({
|
|
name: "escapes undefined optional variable",
|
|
ini: "hi = \\${BAZ?}",
|
|
env: { FOO: "bar" },
|
|
expected: { hi: "${BAZ?}" },
|
|
});
|
|
|
|
envVarTest({
|
|
name: "double escape with undefined optional variable results in empty replacement",
|
|
ini: "hi = \\\\${BAZ?}",
|
|
env: { FOO: "bar" },
|
|
expected: { hi: "\\" },
|
|
});
|
|
|
|
// Original bun tests
|
|
envVarTest({
|
|
name: "escaped",
|
|
ini: "hi = \\${NODE_ENV}",
|
|
env: { NODE_ENV: "production" },
|
|
expected: { hi: "${NODE_ENV}" },
|
|
});
|
|
|
|
envVarTest({
|
|
name: "escaped2",
|
|
ini: "hi = \\\\${NODE_ENV}",
|
|
env: { NODE_ENV: "production" },
|
|
expected: { hi: "\\production" },
|
|
});
|
|
|
|
envVarTest({
|
|
name: "backslashes",
|
|
ini: "filepath=C:\\Home\\someuser\\My Documents\nfilepath2=\\\\\\\\TwoBackslashes",
|
|
env: {},
|
|
expected: { filepath: "C:\\Home\\someuser\\My Documents", filepath2: "\\\\TwoBackslashes" },
|
|
});
|
|
|
|
envVarTest({
|
|
name: "basic",
|
|
ini: /* ini */ `
|
|
hello = \${LOL}
|
|
`,
|
|
env: { LOL: "hi" },
|
|
expected: { hello: "hi" },
|
|
});
|
|
|
|
envVarTest({
|
|
name: "no val",
|
|
ini: /* ini */ `
|
|
hello = \${oooooooooooooooogaboga}
|
|
`,
|
|
env: {},
|
|
expected: { hello: "${oooooooooooooooogaboga}" },
|
|
});
|
|
|
|
envVarTest({
|
|
name: "concat",
|
|
ini: /* ini */ `
|
|
hello = greeting: \${LOL}
|
|
`,
|
|
env: { LOL: "hi" },
|
|
expected: { hello: "greeting: hi" },
|
|
});
|
|
|
|
envVarTest({
|
|
name: "nesting selects the inner most",
|
|
ini: /* ini */ `
|
|
hello = greeting: \${what\${LOL}lol}
|
|
`,
|
|
env: { LOL: "hi" },
|
|
expected: { hello: "greeting: ${whathilol}" },
|
|
});
|
|
|
|
envVarTest({
|
|
name: "nesting 2 selects the inner most",
|
|
ini: /* ini */ `
|
|
hello = greeting: \${what\${omg\${LOL}why}lol}
|
|
`,
|
|
env: { LOL: "hi" },
|
|
expected: { hello: "greeting: ${what${omghiwhy}lol}" },
|
|
});
|
|
|
|
envVarTest({
|
|
name: "unclosed",
|
|
ini: /* ini */ `
|
|
hello = greeting: \${LOL
|
|
`,
|
|
env: { LOL: "hi" },
|
|
expected: { hello: "greeting: ${LOL" },
|
|
});
|
|
|
|
envVarTest({
|
|
name: "double quoted env var",
|
|
ini: /* ini */ `
|
|
hello = "\${LOL}"
|
|
`,
|
|
env: { LOL: "hi" },
|
|
expected: { hello: "hi" },
|
|
});
|
|
|
|
envVarTest({
|
|
name: "single quoted env var",
|
|
ini: /* ini */ `
|
|
hello = '\${LOL}'
|
|
`,
|
|
env: { LOL: "hi" },
|
|
expected: { hello: "hi" },
|
|
});
|
|
|
|
envVarTest({
|
|
name: "double quoted env var with prefix",
|
|
ini: /* ini */ `
|
|
hello = "Bearer \${TOKEN}"
|
|
`,
|
|
env: { TOKEN: "secret123" },
|
|
expected: { hello: "Bearer secret123" },
|
|
});
|
|
|
|
envVarTest({
|
|
name: "double quoted env var not found leaves as-is",
|
|
ini: /* ini */ `
|
|
hello = "\${NOTFOUND}"
|
|
`,
|
|
env: {},
|
|
expected: { hello: "${NOTFOUND}" },
|
|
});
|
|
|
|
envVarTest({
|
|
name: "unquoted optional env var expands to empty when not found",
|
|
ini: /* ini */ `
|
|
hello = \${NOTFOUND?}
|
|
`,
|
|
env: {},
|
|
expected: { hello: "" },
|
|
});
|
|
|
|
envVarTest({
|
|
name: "unquoted optional env var expands to value when found",
|
|
ini: /* ini */ `
|
|
hello = \${TOKEN?}
|
|
`,
|
|
env: { TOKEN: "secret" },
|
|
expected: { hello: "secret" },
|
|
});
|
|
|
|
envVarTest({
|
|
name: "double quoted optional env var expands to empty when not found",
|
|
ini: /* ini */ `
|
|
hello = "\${NOTFOUND?}"
|
|
`,
|
|
env: {},
|
|
expected: { hello: "" },
|
|
});
|
|
|
|
envVarTest({
|
|
name: "double quoted optional env var expands to value when found",
|
|
ini: /* ini */ `
|
|
hello = "\${TOKEN?}"
|
|
`,
|
|
env: { TOKEN: "secret" },
|
|
expected: { hello: "secret" },
|
|
});
|
|
|
|
envVarTest({
|
|
name: "single quoted optional env var expands to empty when not found",
|
|
ini: /* ini */ `
|
|
hello = '\${NOTFOUND?}'
|
|
`,
|
|
env: {},
|
|
expected: { hello: "" },
|
|
});
|
|
|
|
envVarTest({
|
|
name: "unquoted optional env var with prefix",
|
|
ini: /* ini */ `
|
|
hello = Bearer \${TOKEN?}
|
|
`,
|
|
env: {},
|
|
expected: { hello: "Bearer " },
|
|
});
|
|
|
|
envVarTest({
|
|
name: "double quoted optional env var with prefix",
|
|
ini: /* ini */ `
|
|
hello = "Bearer \${TOKEN?}"
|
|
`,
|
|
env: {},
|
|
expected: { hello: "Bearer " },
|
|
});
|
|
|
|
// Note: In JSON strings, \$ is just $ (backslash doesn't escape $)
|
|
// So "\\${LOL}" in .npmrc becomes "\${LOL}" after JSON parsing, which expands to "\hi"
|
|
// This matches npm behavior where escaping env vars in quoted strings requires \\$
|
|
envVarTest({
|
|
name: "double quoted with backslash before env var",
|
|
ini: /* ini */ `
|
|
hello = "\\\\$\{LOL}"
|
|
`,
|
|
env: { LOL: "hi" },
|
|
expected: { hello: "\\hi" },
|
|
});
|
|
|
|
function envVarTest(args: { name: string; ini: string; env: Record<string, string>; expected: any }) {
|
|
const { name, ini, env, expected } = args;
|
|
test(name, async () => {
|
|
const tempdir = tempDirWithFiles("hi", { "foo.ini": ini });
|
|
const inipath = `${tempdir}/foo.ini`.replaceAll("\\", "/");
|
|
const code = /* ts */ `
|
|
const { iniInternals } = require("bun:internal-for-testing");
|
|
const { parse } = iniInternals;
|
|
|
|
const ini = await Bun.$\`cat ${inipath}\`.text()
|
|
|
|
console.log(JSON.stringify(parse(ini)))
|
|
`;
|
|
|
|
const result = await Bun.$`${bunExe()} -e ${code}`.env({ ...bunEnv, ...env }).json();
|
|
expect(result).toEqual(expected);
|
|
});
|
|
}
|
|
});
|
|
|
|
it("works with unicode in the .ini file", () => {
|
|
let ini /* ini */ = `
|
|
hi👋lol = 'lol hi 👋'
|
|
`;
|
|
|
|
expect(parse(ini)).toEqual({
|
|
"hi👋lol": "lol hi 👋",
|
|
});
|
|
|
|
ini = /* ini */ `
|
|
[😎.🫒.🤦♀️]
|
|
lol = 'wtf'
|
|
`;
|
|
|
|
expect(parse(ini)).toEqual({
|
|
"😎": {
|
|
"🫒": {
|
|
"🤦♀️": {
|
|
lol: "wtf",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("matches stupid npm/ini behavior", () => {
|
|
let ini /* ini */ = `
|
|
'{ "what": "is this" }' = seriously?
|
|
`;
|
|
|
|
let result = parse(ini);
|
|
expect(result).toEqual({
|
|
"[Object object]": "seriously?",
|
|
});
|
|
|
|
ini = /* ini */ `
|
|
'[1, 2, 3]' = cmon man
|
|
`;
|
|
|
|
result = parse(ini);
|
|
expect(result).toEqual({
|
|
"1,2,3": "cmon man",
|
|
});
|
|
});
|
|
|
|
test("basic", () => {
|
|
const ini = /* ini */ `
|
|
hello = 'friends'
|
|
`;
|
|
|
|
expect(parse(ini)).toEqual({
|
|
hello: "friends",
|
|
});
|
|
});
|
|
|
|
test("basic sections", () => {
|
|
const ini = /* ini */ `
|
|
hello = 'friends'
|
|
|
|
[foo]
|
|
bar = 'baz'
|
|
`;
|
|
|
|
expect(parse(ini)).toEqual({
|
|
hello: "friends",
|
|
foo: {
|
|
bar: "baz",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("key and then section edgecase", () => {
|
|
const ini = /* ini */ `
|
|
foo = 'hihihi'
|
|
|
|
[foo]
|
|
isbar = 'lol'
|
|
`;
|
|
|
|
expect(parse(ini)).toEqual({
|
|
foo: "hihihi",
|
|
});
|
|
});
|
|
|
|
describe("duplicate properties", () => {
|
|
test("decode with duplicate properties", () => {
|
|
const ini = /* ini */ `
|
|
zr[] = deedee
|
|
zr=123
|
|
ar[] = one
|
|
ar[] = three
|
|
str = 3
|
|
brr = 1
|
|
brr = 2
|
|
brr = 3
|
|
brr = 3
|
|
`;
|
|
|
|
expect(parse(ini)).toEqual({
|
|
zr: ["deedee", "123"],
|
|
ar: ["one", "three"],
|
|
str: "3",
|
|
brr: "3",
|
|
});
|
|
});
|
|
});
|
|
|
|
test("bigboi", async () => {
|
|
const foo = await Bun.$`cat ${__dirname}/foo.ini`.text();
|
|
const result = parse(foo);
|
|
console.log(JSON.stringify(result));
|
|
expect(result).toEqual({
|
|
" xa n p ": '"\r\nyoyoyo\r\r\n',
|
|
"[disturbing]": "hey you never know",
|
|
"a": {
|
|
"[]": "a square?",
|
|
"av": "a val",
|
|
"b": {
|
|
"c": {
|
|
"e": "1",
|
|
"j": "2",
|
|
},
|
|
},
|
|
"cr": ["four", "eight"],
|
|
"e": '{ o: p, a: { av: a val, b: { c: { e: "this [value]" } } } }',
|
|
"j": '"{ o: "p", a: { av: "a val", b: { c: { e: "this [value]" } } } }"',
|
|
},
|
|
"a with spaces": "b c",
|
|
"ar": ["one", "three", "this is included"],
|
|
"b": {},
|
|
"br": "warm",
|
|
"eq": "eq=eq",
|
|
"false": false,
|
|
"null": null,
|
|
"o": "p",
|
|
"s": "something",
|
|
"s1": "\"something'",
|
|
"s2": "something else",
|
|
"s3": "",
|
|
"s4": "",
|
|
"s5": " ",
|
|
"s6": " a ",
|
|
"s7": true,
|
|
"true": true,
|
|
"undefined": "undefined",
|
|
"x.y.z": {
|
|
"a.b.c": {
|
|
"a.b.c": "abc",
|
|
"nocomment": "this; this is not a comment",
|
|
"noHashComment": "this# this is not a comment",
|
|
},
|
|
"x.y.z": "xyz",
|
|
},
|
|
"zr": ["deedee"],
|
|
});
|
|
});
|
|
});
|
|
|
|
const wtf = {
|
|
"o": "p",
|
|
"a with spaces": "b c",
|
|
" xa n p ": '"\r\nyoyoyo\r\r\n',
|
|
"[disturbing]": "hey you never know",
|
|
"s": "something",
|
|
"s1": "\"something'",
|
|
"s2": "something else",
|
|
"s3": true,
|
|
"s4": true,
|
|
"s5": " ",
|
|
"s6": " a ",
|
|
"s7": true,
|
|
"true": true,
|
|
"false": false,
|
|
"null": null,
|
|
"undefined": "undefined",
|
|
"zr": ["deedee"],
|
|
"ar": [["one"], "three", "this is included"],
|
|
"br": "warm",
|
|
"eq": "eq=eq",
|
|
"a": {
|
|
"av": "a val",
|
|
"e": '{ o: p, a: { av: a val, b: { c: { e: "this [value]" } } } }',
|
|
"j": '"{ o: "p", a: { av: "a val", b: { c: { e: "this [value]" } } } }"',
|
|
"[]": "a square?",
|
|
"cr": [["four"], "eight"],
|
|
"b": { "c": { "e": "1", "j": "2" } },
|
|
},
|
|
"b": {},
|
|
"x.y.z": {
|
|
"x.y.z": "xyz",
|
|
"a.b.c": {
|
|
"a.b.c": "abc",
|
|
"nocomment": "this; this is not a comment",
|
|
"noHashComment": "this# this is not a comment",
|
|
},
|
|
},
|
|
};
|