diff --git a/README.md b/README.md index 6dc8819e..8266c527 100644 --- a/README.md +++ b/README.md @@ -75,10 +75,12 @@ c12 merged config sources with [unjs/defu](https://github.com/unjs/defu) by belo 1. Config overrides passed by options 2. Config file in CWD 3. RC file in CWD -4. Global RC file in the user's home directory -5. Config from `package.json` -6. Default config passed by options -7. Extended config layers +4. RC file in the workspace directory +5. RC file in the user's config directory (`$XDG_CONFIG_HOME` or `~/.config`) +6. Legacy RC file in the user's home directory +7. Config from `package.json` +8. Default config passed by options +9. Extended config layers ## Options @@ -102,7 +104,9 @@ Set to `false` to disable loading RC config. ### `globalRc` -Load RC config from the workspace directory and the user's home directory. Only enabled when `rcFile` is provided. Set to `false` to disable this functionality. +Load RC config from the workspace directory, the user's config directory (`$XDG_CONFIG_HOME` or `~/.config`) and the user's home directory. Only enabled when `rcFile` is provided. Set to `false` to disable this functionality. + +When the same key is set in more than one of these, the workspace wins over the user config directory, which wins over the home directory. ### `dotenv` diff --git a/src/loader.ts b/src/loader.ts index 3b7b5a17..65c96f3c 100644 --- a/src/loader.ts +++ b/src/loader.ts @@ -124,8 +124,10 @@ export async function loadConfig< if (workspaceDir) { rcSources.push(rc9.read({ name: options.rcFile, dir: workspaceDir })); } - // 3. user home - rcSources.push(rc9.readUser({ name: options.rcFile, dir: options.cwd })); + // 3. user config dir ($XDG_CONFIG_HOME or ~/.config) + rcSources.push(rc9.readUserConfig({ name: options.rcFile })); + // 4. user home (legacy) + rcSources.push(rc9.read({ name: options.rcFile, dir: homedir() })); } rawConfigs.rc = _merger({} as T, ...rcSources); } diff --git a/test/global-rc.test.ts b/test/global-rc.test.ts new file mode 100644 index 00000000..09024808 --- /dev/null +++ b/test/global-rc.test.ts @@ -0,0 +1,88 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { resolve } from "pathe"; +import { loadConfig } from "../src/index.ts"; + +describe("global rc", () => { + let dir: string; + let home: string; + let cwd: string; + const env = { + HOME: process.env.HOME, + USERPROFILE: process.env.USERPROFILE, + XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, + }; + + const restoreEnv = () => { + for (const [key, value] of Object.entries(env)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }; + + beforeEach(async () => { + dir = await mkdtemp(resolve(tmpdir(), "c12-global-rc-")); + home = resolve(dir, "home"); + cwd = resolve(dir, "project"); + await mkdir(resolve(home, ".config"), { recursive: true }); + await mkdir(cwd, { recursive: true }); + process.env.HOME = home; + process.env.USERPROFILE = home; + delete process.env.XDG_CONFIG_HOME; + }); + + afterEach(async () => { + restoreEnv(); + await rm(dir, { recursive: true, force: true }); + }); + + const load = () => loadConfig({ cwd, name: "test", globalRc: true, packageJson: false }); + + it("reads the legacy home rc file", async () => { + await writeFile(resolve(home, ".testrc"), "legacy=true\nshared=home\n"); + const { config } = await load(); + expect(config).toMatchObject({ legacy: true, shared: "home" }); + }); + + it("reads the rc file from ~/.config", async () => { + await writeFile(resolve(home, ".config/.testrc"), "xdg=true\nshared=config\n"); + const { config } = await load(); + expect(config).toMatchObject({ xdg: true, shared: "config" }); + }); + + it("prefers ~/.config over the legacy home rc file", async () => { + await writeFile(resolve(home, ".testrc"), "legacy=true\nshared=home\n"); + await writeFile(resolve(home, ".config/.testrc"), "xdg=true\nshared=config\n"); + const { config } = await load(); + expect(config).toMatchObject({ legacy: true, xdg: true, shared: "config" }); + }); + + it("still reads the legacy home rc file when ~/.config rc is empty", async () => { + await writeFile(resolve(home, ".config/.testrc"), ""); + await writeFile(resolve(home, ".testrc"), "legacy=true\nshared=home\n"); + const { config } = await load(); + expect(config).toMatchObject({ legacy: true, shared: "home" }); + }); + + it("prefers the project rc file over both", async () => { + await writeFile(resolve(home, ".testrc"), "shared=home\n"); + await writeFile(resolve(home, ".config/.testrc"), "shared=config\n"); + await writeFile(resolve(cwd, ".testrc"), "shared=project\n"); + const { config } = await load(); + expect(config).toMatchObject({ shared: "project" }); + }); + + it("still reads the legacy home rc file when XDG_CONFIG_HOME is set", async () => { + const xdg = resolve(dir, "xdg"); + await mkdir(xdg, { recursive: true }); + process.env.XDG_CONFIG_HOME = xdg; + await writeFile(resolve(xdg, ".testrc"), "xdg=true\nshared=config\n"); + await writeFile(resolve(home, ".testrc"), "legacy=true\nshared=home\n"); + const { config } = await load(); + expect(config).toMatchObject({ xdg: true, legacy: true, shared: "config" }); + }); +});