Skip to content
9 changes: 6 additions & 3 deletions docs/runtime/bunfig.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@ Bun relies on pre-existing configuration files like `package.json` and `tsconfig

Put `bunfig.toml` in your project root, alongside your `package.json`.

To configure Bun's package manager globally, you can also create a `.bunfig.toml` file at one of the following paths:
To configure Bun's package manager globally, you can also create a `bunfig.toml` file at one of the following paths. Bun first resolves the XDG config base (per the [XDG Base Directory Specification](https://specifications.freedesktop.org/basedir-spec/latest/)) — `$XDG_CONFIG_HOME` when set and non-empty, else the default `$HOME/.config` — then checks in order:

- `$HOME/.bunfig.toml`
- `$XDG_CONFIG_HOME/.bunfig.toml`
- `<xdg-base>/bun/bunfig.toml` — recommended
- `<xdg-base>/.bunfig.toml` — legacy, retained for back-compat
- `$HOME/.bunfig.toml` — original home dotfile

So with `$XDG_CONFIG_HOME` unset the XDG candidates resolve to `$HOME/.config/bun/bunfig.toml` and `$HOME/.config/.bunfig.toml`. With `$XDG_CONFIG_HOME` set to something other than `$HOME/.config`, the `$HOME/.config` locations are **not** searched — that path is only consulted as the spec default.

Only package manager commands (`bun install`, `bun add`, `bun remove`, `bun update`, `bun pm`, `bunx`, and so on) read the global file. If Bun finds both a global and a local `bunfig`, it shallow-merges them, with local overriding global. CLI flags override `bunfig` settings where applicable.

Expand Down
71 changes: 64 additions & 7 deletions src/bunfig/arguments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,75 @@

// ─── bunfig loading ──────────────────────────────────────────────────────────

/// Locate a global `bunfig.toml` using XDG Base Directory conventions with
/// back-compat fallbacks. Candidates are tried in order; the first existing
/// file wins:
///
/// 1. `$XDG_CONFIG_HOME/bun/bunfig.toml` — XDG-conventional (app subdir)
/// 2. `$XDG_CONFIG_HOME/.bunfig.toml` — legacy hidden-file path
/// 3. `$HOME/.config/bun/bunfig.toml` — XDG spec default when `XDG_CONFIG_HOME` unset
/// 4. `$HOME/.config/.bunfig.toml` — legacy hidden-file under spec default
/// 5. `$HOME/.bunfig.toml` — original home dotfile
///
/// (2) and (4) are retained because Bun previously documented them; new setups
/// should prefer (1)/(3), which follow the XDG Base Directory Specification
/// (<https://specifications.freedesktop.org/basedir-spec/latest/>). Candidates
/// under `$XDG_CONFIG_HOME` are existence-checked so we fall through to the
/// home dotfile; the final home-dotfile path is returned even if missing so
/// the downstream auto-load branch (which swallows "file not found" when
/// `auto_loaded=true`) can handle it uniformly.
Comment on lines +20 to +36

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

fn get_home_config_path(buf: &mut PathBuffer) -> Option<&ZStr> {
let paths: [&[u8]; 1] = [b".bunfig.toml"];
// Resolve the effective XDG base. When `$XDG_CONFIG_HOME` is unset **or
// empty** (per the XDG spec), apply the default `$HOME/.config`; own that
// in a small stack array so we can borrow it past the XDG-candidate loop.
// `$HOME` on every supported platform is well under a few hundred bytes,
// so 512 is ample — longer homes fall through to `$HOME/.bunfig.toml`.
// `get_not_empty()` mirrors the spec's "not set or empty" language — a
// bare `XDG_CONFIG_HOME=""` must be treated as unset, not as an
// empty-string base.
Comment on lines +38 to +45

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

let mut xdg_scratch = [0u8; 512];
let xdg_base: Option<&[u8]> = match (
env_var::XDG_CONFIG_HOME.get_not_empty(),
env_var::HOME.get_not_empty(),
) {
(Some(data_dir), _) => Some(data_dir),
(None, Some(home_dir)) => {
const SUFFIX: &[u8] = b"/.config";
let total = home_dir.len() + SUFFIX.len();
if total <= xdg_scratch.len() {
xdg_scratch[..home_dir.len()].copy_from_slice(home_dir);
xdg_scratch[home_dir.len()..total].copy_from_slice(SUFFIX);
Some(&xdg_scratch[..total])
} else {
None
}
}
(None, None) => None,
};

if let Some(data_dir) = env_var::XDG_CONFIG_HOME.get() {
return Some(resolve_path::join_abs_string_buf_z::<platform::Auto>(
data_dir, &mut **buf, &paths,
));
if let Some(base) = xdg_base {
// Probe each XDG-relative candidate. Existence is checked first, then
// the winning path is re-materialized in `buf` for the caller.
Comment on lines +67 to +68

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

for rel in [b"bun/bunfig.toml" as &[u8], b".bunfig.toml"] {
let parts: [&[u8]; 1] = [rel];
let path =
resolve_path::join_abs_string_buf_z::<platform::Auto>(base, &mut **buf, &parts);
if bun_sys::exists_z(path) {
// SAFETY: `buf` holds the NUL-terminated path; `path.len()`
// is the byte length excluding the trailing NUL.
let len = path.len();
return Some(unsafe { ZStr::from_raw(buf.as_ptr(), len) });

Check failure on line 77 in src/bunfig/arguments.rs

View workflow job for this annotation

GitHub Actions / cargo clippy

unsafe block missing a safety comment
}
}
}

if let Some(home_dir) = env_var::HOME.get() {
// Tail: `$HOME/.bunfig.toml`. Returned unconditionally (no existence
// check) to preserve prior behaviour — `load_bunfig(auto_loaded=true)`
// swallows "file not found" for this path.
Comment on lines +82 to +84

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

if let Some(home_dir) = env_var::HOME.get_not_empty() {
let parts: [&[u8]; 1] = [b".bunfig.toml"];
return Some(resolve_path::join_abs_string_buf_z::<platform::Auto>(
home_dir, &mut **buf, &paths,
home_dir, &mut **buf, &parts,
));
}

Expand Down
175 changes: 175 additions & 0 deletions test/config/bunfig/global-config-xdg.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// Regression coverage for oven-sh/bun#30842: global `bunfig.toml` lookup must
// follow the XDG Base Directory Specification — the XDG-conventional
// `$XDG_CONFIG_HOME/bun/bunfig.toml` path, the spec default of
// `$HOME/.config` when `$XDG_CONFIG_HOME` is unset, and back-compat for the
// previously documented `$XDG_CONFIG_HOME/.bunfig.toml`.
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";

// Observe global-bunfig loading via `[install.cache] dir = "<sentinel>"` and
// reading back the effective cache path with `bun pm cache`. Global config
// is read by install-related commands (`read_global_config()`) — `bun pm
// cache` qualifies, and it exits 0 without touching the network.

async function runPmCache(appDir: string, env: Record<string, string | undefined>) {
// Strip any inherited env that could mask the bunfig under test, then
// layer per-test values. An `undefined` value means "explicitly absent".
//
// - `BUN_INSTALL_CACHE_DIR` is set by the Buildkite runner
// (scripts/runner.node.mjs) and takes precedence over bunfig's
// `[install.cache].dir` in `fetch_cache_directory_path()`; drop it so
// our sentinel wins.
// - `BUN_INSTALL` / `XDG_CACHE_HOME` are checked after the bunfig option
// but we strip them defensively so the test signal is unambiguous.
// - `env_var::HOME` reads `USERPROFILE` on Windows (env_var.rs:138), so
// when a test passes `HOME`, mirror it into `USERPROFILE` so the
// spawned bun sees the same value on both platforms. `XDG_CONFIG_HOME`
// is honoured on Windows too (env_var.rs:177–180), so no special-casing
// is needed there.
const spawnEnv: Record<string, string> = { ...bunEnv };
delete spawnEnv.HOME;
delete spawnEnv.XDG_CONFIG_HOME;
delete spawnEnv.USERPROFILE;
delete spawnEnv.BUN_INSTALL_CACHE_DIR;
delete spawnEnv.BUN_INSTALL;
delete spawnEnv.XDG_CACHE_HOME;
for (const [k, v] of Object.entries(env)) {
if (v !== undefined) spawnEnv[k] = v;
}
Comment thread
robobun marked this conversation as resolved.
if (spawnEnv.HOME !== undefined && spawnEnv.USERPROFILE === undefined) {
spawnEnv.USERPROFILE = spawnEnv.HOME;
}

await using proc = Bun.spawn({
cmd: [bunExe(), "pm", "cache"],
cwd: appDir,
env: spawnEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout: stdout.trim(), stderr, exitCode };
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function writeBunfigCacheDir(path: string, cacheDir: string) {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `[install.cache]\ndir = ${JSON.stringify(cacheDir)}\n`);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
robobun marked this conversation as resolved.

describe.concurrent("global bunfig.toml XDG path lookup", () => {
test("loads $XDG_CONFIG_HOME/bun/bunfig.toml (XDG-conventional)", async () => {
using home = tempDir("bunfig-xdg-conventional", { "app/package.json": "{}" });
const homeStr = String(home);
const cacheDir = join(homeStr, "xdg-conventional-cache");
writeBunfigCacheDir(join(homeStr, ".config/bun/bunfig.toml"), cacheDir);

const { stdout, stderr, exitCode } = await runPmCache(join(homeStr, "app"), {
HOME: homeStr,
XDG_CONFIG_HOME: join(homeStr, ".config"),
});
expect(stdout).toBe(cacheDir);
if (exitCode !== 0) expect(stderr).toBe("");
expect(exitCode).toBe(0);
});

test("loads $HOME/.config/bun/bunfig.toml via spec default when XDG_CONFIG_HOME is unset", async () => {
using home = tempDir("bunfig-xdg-default", { "app/package.json": "{}" });
const homeStr = String(home);
const cacheDir = join(homeStr, "spec-default-cache");
writeBunfigCacheDir(join(homeStr, ".config/bun/bunfig.toml"), cacheDir);

const { stdout, stderr, exitCode } = await runPmCache(join(homeStr, "app"), {
HOME: homeStr,
// XDG_CONFIG_HOME explicitly omitted — spec default of `$HOME/.config`
// should apply.
});
expect(stdout).toBe(cacheDir);
if (exitCode !== 0) expect(stderr).toBe("");
expect(exitCode).toBe(0);
});

test("loads $XDG_CONFIG_HOME/.bunfig.toml (legacy back-compat)", async () => {
using home = tempDir("bunfig-xdg-legacy", { "app/package.json": "{}" });
const homeStr = String(home);
const cacheDir = join(homeStr, "xdg-legacy-cache");
writeBunfigCacheDir(join(homeStr, ".config/.bunfig.toml"), cacheDir);

const { stdout, stderr, exitCode } = await runPmCache(join(homeStr, "app"), {
HOME: homeStr,
XDG_CONFIG_HOME: join(homeStr, ".config"),
});
expect(stdout).toBe(cacheDir);
if (exitCode !== 0) expect(stderr).toBe("");
expect(exitCode).toBe(0);
});

test("loads $HOME/.bunfig.toml when no XDG-base candidate exists", async () => {
using home = tempDir("bunfig-home-dotfile", { "app/package.json": "{}" });
const homeStr = String(home);
const cacheDir = join(homeStr, "home-dotfile-cache");
writeBunfigCacheDir(join(homeStr, ".bunfig.toml"), cacheDir);

const { stdout, stderr, exitCode } = await runPmCache(join(homeStr, "app"), {
HOME: homeStr,
// `~/.config/bun/bunfig.toml` (spec default) does not exist here, so
// we fall through to `$HOME/.bunfig.toml`.
});
expect(stdout).toBe(cacheDir);
if (exitCode !== 0) expect(stderr).toBe("");
expect(exitCode).toBe(0);
});

test("$XDG_CONFIG_HOME/bun/bunfig.toml wins over $XDG_CONFIG_HOME/.bunfig.toml", async () => {
using home = tempDir("bunfig-xdg-priority", { "app/package.json": "{}" });
const homeStr = String(home);
const winnerCache = join(homeStr, "xdg-winner-cache");
const loserCache = join(homeStr, "xdg-loser-cache");
writeBunfigCacheDir(join(homeStr, ".config/bun/bunfig.toml"), winnerCache);
writeBunfigCacheDir(join(homeStr, ".config/.bunfig.toml"), loserCache);

const { stdout, stderr, exitCode } = await runPmCache(join(homeStr, "app"), {
HOME: homeStr,
XDG_CONFIG_HOME: join(homeStr, ".config"),
});
expect(stdout).toBe(winnerCache);
if (exitCode !== 0) expect(stderr).toBe("");
expect(exitCode).toBe(0);
});

test("empty XDG_CONFIG_HOME falls back to $HOME/.config (per XDG spec)", async () => {
// XDG spec: "If $XDG_CONFIG_HOME is either not set or empty, a default
// equal to $HOME/.config should be used." A bare `XDG_CONFIG_HOME=""`
// must be treated as unset, not as an empty-string base.
using home = tempDir("bunfig-xdg-empty", { "app/package.json": "{}" });
const homeStr = String(home);
const cacheDir = join(homeStr, "empty-xdg-default-cache");
writeBunfigCacheDir(join(homeStr, ".config/bun/bunfig.toml"), cacheDir);

const { stdout, stderr, exitCode } = await runPmCache(join(homeStr, "app"), {
HOME: homeStr,
XDG_CONFIG_HOME: "",
});
expect(stdout).toBe(cacheDir);
if (exitCode !== 0) expect(stderr).toBe("");
expect(exitCode).toBe(0);
});

test("explicit XDG_CONFIG_HOME beats the $HOME/.config spec default", async () => {
using home = tempDir("bunfig-xdg-override", { "app/package.json": "{}" });
const homeStr = String(home);
const customCache = join(homeStr, "custom-xdg-cache");
const defaultCache = join(homeStr, "spec-default-cache");
writeBunfigCacheDir(join(homeStr, "custom/bun/bunfig.toml"), customCache);
writeBunfigCacheDir(join(homeStr, ".config/bun/bunfig.toml"), defaultCache);

const { stdout, stderr, exitCode } = await runPmCache(join(homeStr, "app"), {
HOME: homeStr,
XDG_CONFIG_HOME: join(homeStr, "custom"),
});
expect(stdout).toBe(customCache);
if (exitCode !== 0) expect(stderr).toBe("");
expect(exitCode).toBe(0);
});
});
Loading