From 4d5c96661545da8ae7445c3fbc7290f04a3b65dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81s=20Sainz=20de=20Aja?= Date: Fri, 1 May 2026 12:21:10 +0200 Subject: [PATCH] feat: add global config defaults # Current problem Workbox requires every repository to include its own config file before commands can run. This forces users to copy the same default config into new repositories even when their preferences are shared across projects. # Proposed solution Load a global config from the XDG config directory, falling back to ~/.workbox/config.toml when XDG_CONFIG_HOME is not set. Merge any project config over the global defaults, validate the merged result, and document the lookup order with tests for global-only, local-only, merged, and invalid config cases. --- README.md | 10 ++- global-config-resolution.md | 30 +++++++ src/core/config.test.ts | 128 +++++++++++++++++++++++++-- src/core/config.ts | 172 +++++++++++++++++++++++++++++------- src/core/paths.test.ts | 19 +++- src/core/paths.ts | 13 ++- 6 files changed, 329 insertions(+), 43 deletions(-) create mode 100644 global-config-resolution.md diff --git a/README.md b/README.md index 24b58f0..2665374 100644 --- a/README.md +++ b/README.md @@ -37,12 +37,18 @@ wkb exec -- # run a command in a sandbox ## Config -Looks for config in: +Looks for global config in: + +1. `$XDG_CONFIG_HOME/workbox/config.toml` +2. `~/.workbox/config.toml` when `$XDG_CONFIG_HOME` is not set + +Then looks for project config in: 1. `.workbox/config.toml` 2. `workbox.toml` -Config is required. Paths are resolved relative to the repo root. +Config is required from at least one global or project location. Global config provides defaults; +project config overrides only the settings it defines. Paths are resolved relative to the repo root. `worktrees.directory` must resolve within the repo root. Example: diff --git a/global-config-resolution.md b/global-config-resolution.md new file mode 100644 index 0000000..2eabf45 --- /dev/null +++ b/global-config-resolution.md @@ -0,0 +1,30 @@ +# Global Config Resolution + +## Problem +Workbox users must create or copy the same configuration into every repository before they +can use the CLI. This adds repetitive setup friction, especially when trying Workbox in a +new repository with otherwise standard preferences. + +## Desired Outcome +Users can define shared Workbox defaults once and have them apply across repositories, while +still being able to customize behavior for an individual project when needed. + +## Acceptance Criteria +1. A user can place shared configuration in the standard global Workbox config location. +2. If no platform-specific global config directory is configured, Workbox looks for shared + configuration at `~/.workbox/config.toml`. +3. A user with only a global Workbox configuration can run commands in a repository that has + no project configuration. +4. A user with only a project Workbox configuration continues to get the same behavior they + get today. +5. A user with both global and project configuration gets project-specific behavior where + the project configuration intentionally differs from the global defaults. +6. A user with both global and project configuration gets global defaults for settings the + project has not customized. +7. A user with neither global nor project configuration receives a clear error explaining + that Workbox configuration is missing. +8. Invalid global configuration is reported clearly when it is needed to run the command. +9. Invalid project configuration is reported clearly and is not hidden by the presence of a + valid global configuration. +10. Existing repositories with project configuration do not need to change their configuration + to keep working. diff --git a/src/core/config.test.ts b/src/core/config.test.ts index 0b5617b..bb57095 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -23,10 +23,13 @@ enabled = false steps = [] `; +const loadTestConfig = (cwd: string, homeDir = join(cwd, "home")) => + loadConfig(cwd, { env: {}, homeDir }); + describe("loadConfig", () => { it("rejects when no config exists", async () => { await withTempDir(async (cwd) => { - await expect(loadConfig(cwd)).rejects.toThrow(/No workbox config found/); + await expect(loadTestConfig(cwd)).rejects.toThrow(/No workbox config found/); }); }); @@ -42,16 +45,127 @@ describe("loadConfig", () => { minimalConfig.replace('.workbox/worktrees"', 'fallback"') ); - const result = await loadConfig(cwd); + const result = await loadTestConfig(cwd); expect(result.path).toBe(join(cwd, ".workbox", "config.toml")); expect(result.config.worktrees.directory).toBe(join(cwd, "sandbox")); }); }); + it("loads global config from the fallback home path when no project config exists", async () => { + await withTempDir(async (cwd) => { + const homeDir = join(cwd, "home"); + await mkdir(join(homeDir, ".workbox"), { recursive: true }); + await writeFile( + join(homeDir, ".workbox", "config.toml"), + minimalConfig.replace('.workbox/worktrees"', 'global-worktrees"') + ); + + const result = await loadTestConfig(cwd, homeDir); + expect(result.path).toBe(join(homeDir, ".workbox", "config.toml")); + expect(result.config.worktrees.directory).toBe(join(cwd, "global-worktrees")); + }); + }); + + it("loads global config from XDG_CONFIG_HOME when it is set", async () => { + await withTempDir(async (cwd) => { + const configHome = join(cwd, "xdg"); + await mkdir(join(configHome, "workbox"), { recursive: true }); + await writeFile( + join(configHome, "workbox", "config.toml"), + minimalConfig.replace('branch_prefix = "wkb/"', 'branch_prefix = "global/"') + ); + + const result = await loadConfig(cwd, { + env: { XDG_CONFIG_HOME: configHome }, + homeDir: join(cwd, "home"), + }); + expect(result.path).toBe(join(configHome, "workbox", "config.toml")); + expect(result.config.worktrees.branch_prefix).toBe("global/"); + }); + }); + + it("merges project config over global config", async () => { + await withTempDir(async (cwd) => { + const homeDir = join(cwd, "home"); + await mkdir(join(homeDir, ".workbox"), { recursive: true }); + await mkdir(join(cwd, ".workbox"), { recursive: true }); + await writeFile( + join(homeDir, ".workbox", "config.toml"), + minimalConfig + .replace('.workbox/worktrees"', 'global-worktrees"') + .replace('branch_prefix = "wkb/"', 'branch_prefix = "global/"') + ); + await writeFile( + join(cwd, ".workbox", "config.toml"), + `[worktrees] +branch_prefix = "local/" +` + ); + + const result = await loadTestConfig(cwd, homeDir); + expect(result.path).toBe(join(cwd, ".workbox", "config.toml")); + expect(result.config.worktrees.directory).toBe(join(cwd, "global-worktrees")); + expect(result.config.worktrees.branch_prefix).toBe("local/"); + }); + }); + + it("merges project dev config over global dev config", async () => { + await withTempDir(async (cwd) => { + const homeDir = join(cwd, "home"); + await mkdir(join(homeDir, ".workbox"), { recursive: true }); + await writeFile( + join(homeDir, ".workbox", "config.toml"), + `${minimalConfig} +[dev] +command = "bun run dev" +open = "open http://localhost:3000" +` + ); + await writeFile( + join(cwd, "workbox.toml"), + `[dev] +open = "open http://localhost:4000" +` + ); + + const result = await loadTestConfig(cwd, homeDir); + expect(result.config.dev).toEqual({ + command: "bun run dev", + open: "open http://localhost:4000", + }); + }); + }); + + it("rejects when merged global and project config is incomplete", async () => { + await withTempDir(async (cwd) => { + const homeDir = join(cwd, "home"); + await mkdir(join(homeDir, ".workbox"), { recursive: true }); + await writeFile( + join(homeDir, ".workbox", "config.toml"), + `[worktrees] +directory = ".workbox/worktrees" +` + ); + + await expect(loadTestConfig(cwd, homeDir)).rejects.toThrow(/worktrees.branch_prefix/); + }); + }); + + it("rejects invalid project config even when global config is valid", async () => { + await withTempDir(async (cwd) => { + const homeDir = join(cwd, "home"); + await mkdir(join(homeDir, ".workbox"), { recursive: true }); + await writeFile(join(homeDir, ".workbox", "config.toml"), minimalConfig); + await writeFile(join(cwd, "workbox.toml"), `[worktrees]\ndirectory = 123\n`); + + await expect(loadTestConfig(cwd, homeDir)).rejects.toThrow(/workbox\.toml/); + }); + }); + it("rejects invalid TOML", async () => { await withTempDir(async (cwd) => { await writeFile(join(cwd, "workbox.toml"), "=broken"); - await expect(loadConfig(cwd)).rejects.toThrow(/Invalid TOML/); + await expect(loadTestConfig(cwd)).rejects.toThrow(/Invalid TOML/); }); }); @@ -61,7 +175,7 @@ describe("loadConfig", () => { join(cwd, "workbox.toml"), minimalConfig.replace('directory = ".workbox/worktrees"', "directory = 123") ); - await expect(loadConfig(cwd)).rejects.toThrow(/worktrees.directory/); + await expect(loadTestConfig(cwd)).rejects.toThrow(/worktrees.directory/); }); }); @@ -71,7 +185,7 @@ describe("loadConfig", () => { join(cwd, "workbox.toml"), minimalConfig.replace('directory = ".workbox/worktrees"', 'directory = "../worktrees"') ); - await expect(loadConfig(cwd)).rejects.toThrow(/must be within repo root/); + await expect(loadTestConfig(cwd)).rejects.toThrow(/must be within repo root/); }); }); @@ -87,7 +201,7 @@ describe("loadConfig", () => { ) .replace("enabled = false", "enabled = true"); await writeFile(join(cwd, "workbox.toml"), duplicateConfig); - await expect(loadConfig(cwd)).rejects.toThrow(/Duplicate bootstrap step name/); + await expect(loadTestConfig(cwd)).rejects.toThrow(/Duplicate bootstrap step name/); }); }); @@ -97,7 +211,7 @@ describe("loadConfig", () => { try { await symlink(outside, join(cwd, ".workbox")); await writeFile(join(cwd, "workbox.toml"), minimalConfig); - await expect(loadConfig(cwd)).rejects.toThrow(/escapes repo root via symlink/); + await expect(loadTestConfig(cwd)).rejects.toThrow(/escapes repo root via symlink/); } finally { await rm(outside, { recursive: true, force: true }); } diff --git a/src/core/config.ts b/src/core/config.ts index 4919c70..877241e 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -5,7 +5,10 @@ import { checkPathWithinRoot } from "./path"; import { CONFIG_PRIMARY, CONFIG_SECONDARY, - getConfigCandidatePaths, + GLOBAL_CONFIG_FALLBACK, + GLOBAL_CONFIG_XDG, + getGlobalConfigPath, + getProjectConfigCandidatePaths, resolveWorktreesDir, } from "./paths"; @@ -18,25 +21,33 @@ const BootstrapStepSchema = z }) .strict(); -const BootstrapSchema = z +const BootstrapObjectSchema = z .object({ enabled: z.boolean(), steps: z.array(BootstrapStepSchema), }) - .strict() - .superRefine((value, ctx) => { - const seen = new Set(); - value.steps.forEach((step, index) => { - if (seen.has(step.name)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["steps", index, "name"], - message: `Duplicate bootstrap step name "${step.name}".`, - }); - } - seen.add(step.name); - }); + .strict(); + +const validateBootstrapSteps = ( + steps: Array>, + ctx: z.RefinementCtx +) => { + const seen = new Set(); + steps.forEach((step, index) => { + if (seen.has(step.name)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["steps", index, "name"], + message: `Duplicate bootstrap step name "${step.name}".`, + }); + } + seen.add(step.name); }); +}; + +const BootstrapSchema = BootstrapObjectSchema.superRefine((value, ctx) => { + validateBootstrapSteps(value.steps, ctx); +}); const WorktreesSchema = z .object({ @@ -62,6 +73,28 @@ const WorkboxConfigSchema = z export type WorkboxConfig = z.infer; +const PartialWorkboxConfigSchema = z + .object({ + worktrees: WorktreesSchema.partial().optional(), + bootstrap: BootstrapObjectSchema.partial() + .superRefine((value, ctx) => { + if (value.steps) { + validateBootstrapSteps(value.steps, ctx); + } + }) + .optional(), + dev: z + .object({ + command: z.string().min(1, "Dev command is required.").optional(), + open: z.string().min(1, "Dev open command must be non-empty.").optional(), + }) + .strict() + .optional(), + }) + .strict(); + +type PartialWorkboxConfig = z.infer; + export type ResolvedWorkboxConfig = WorkboxConfig & { worktrees: WorkboxConfig["worktrees"] & { directory: string }; }; @@ -71,6 +104,11 @@ type LoadedConfig = { path: string; }; +type LoadConfigOptions = { + env?: NodeJS.ProcessEnv; + homeDir?: string; +}; + const formatZodError = (error: z.ZodError, filePath: string): string => { const issues = error.issues.map((issue) => { const path = issue.path.length > 0 ? issue.path.join(".") : "(root)"; @@ -80,7 +118,7 @@ const formatZodError = (error: z.ZodError, filePath: string): string => { return `Invalid workbox config in ${filePath}:\n${issues.map((item) => `- ${item}`).join("\n")}`; }; -const parseConfig = (source: string, filePath: string): WorkboxConfig => { +const parseConfig = (source: string, filePath: string): PartialWorkboxConfig => { let parsed: unknown; try { parsed = Bun.TOML.parse(source); @@ -89,7 +127,7 @@ const parseConfig = (source: string, filePath: string): WorkboxConfig => { throw new ConfigError(`Invalid TOML in ${filePath}: ${message}`, { cause: error }); } - const result = WorkboxConfigSchema.safeParse(parsed); + const result = PartialWorkboxConfigSchema.safeParse(parsed); if (!result.success) { throw new ConfigError(formatZodError(result.error, filePath)); } @@ -97,6 +135,44 @@ const parseConfig = (source: string, filePath: string): WorkboxConfig => { return result.data; }; +const mergeConfig = (configs: PartialWorkboxConfig[]): PartialWorkboxConfig => + configs.reduce((merged, config) => { + if (config.worktrees) { + merged.worktrees = { + ...merged.worktrees, + ...config.worktrees, + }; + } + + if (config.bootstrap) { + merged.bootstrap = { + ...merged.bootstrap, + ...config.bootstrap, + }; + } + + if (config.dev) { + merged.dev = { + ...merged.dev, + ...config.dev, + }; + } + + return merged; + }, {}); + +const validateMergedConfig = ( + config: PartialWorkboxConfig, + sourceDescription: string +): WorkboxConfig => { + const result = WorkboxConfigSchema.safeParse(config); + if (!result.success) { + throw new ConfigError(formatZodError(result.error, sourceDescription)); + } + + return result.data; +}; + const resolveConfig = async ( config: WorkboxConfig, repoRoot: string @@ -120,22 +196,58 @@ const resolveConfig = async ( }; }; -export const loadConfig = async (repoRoot: string): Promise => { - const candidates = getConfigCandidatePaths(repoRoot); - - for (const configPath of candidates) { - const file = Bun.file(configPath); - if (await file.exists()) { - const contents = await file.text(); - const config = parseConfig(contents, configPath); - return { - config: await resolveConfig(config, repoRoot), - path: configPath, - }; +const describeConfigSources = (paths: string[]): string => + paths.length === 1 ? (paths[0] ?? "workbox config") : `merged config from ${paths.join(" and ")}`; + +const readConfigIfExists = async ( + configPath: string +): Promise<{ config: PartialWorkboxConfig; path: string } | undefined> => { + const file = Bun.file(configPath); + if (!(await file.exists())) { + return undefined; + } + + const contents = await file.text(); + return { + config: parseConfig(contents, configPath), + path: configPath, + }; +}; + +export const loadConfig = async ( + repoRoot: string, + options: LoadConfigOptions = {} +): Promise => { + const globalPath = getGlobalConfigPath(options.env, options.homeDir); + const projectCandidates = getProjectConfigCandidatePaths(repoRoot); + const loadedConfigs: Array<{ config: PartialWorkboxConfig; path: string }> = []; + + const globalConfig = await readConfigIfExists(globalPath); + if (globalConfig) { + loadedConfigs.push(globalConfig); + } + + for (const configPath of projectCandidates) { + const projectConfig = await readConfigIfExists(configPath); + if (projectConfig) { + loadedConfigs.push(projectConfig); + break; } } + if (loadedConfigs.length > 0) { + const paths = loadedConfigs.map((item) => item.path); + const merged = mergeConfig(loadedConfigs.map((item) => item.config)); + const config = validateMergedConfig(merged, describeConfigSources(paths)); + return { + config: await resolveConfig(config, repoRoot), + path: paths.at(-1) ?? globalPath, + }; + } + throw new ConfigError( - `No workbox config found. Expected ${CONFIG_PRIMARY} or ${CONFIG_SECONDARY} in ${repoRoot}.` + `No workbox config found. Expected ${GLOBAL_CONFIG_XDG} under $XDG_CONFIG_HOME, ` + + `${GLOBAL_CONFIG_FALLBACK} under your home directory, or ${CONFIG_PRIMARY} or ` + + `${CONFIG_SECONDARY} in ${repoRoot}.` ); }; diff --git a/src/core/paths.test.ts b/src/core/paths.test.ts index 85cf55b..0500d1e 100644 --- a/src/core/paths.test.ts +++ b/src/core/paths.test.ts @@ -4,19 +4,32 @@ import { join } from "node:path"; import { CONFIG_PRIMARY, CONFIG_SECONDARY, - getConfigCandidatePaths, + getGlobalConfigPath, + getProjectConfigCandidatePaths, resolveWorktreesDir, } from "./paths"; describe("paths", () => { - it("builds config candidate paths", () => { + it("builds project config candidate paths", () => { const cwd = "/repo"; - expect(getConfigCandidatePaths(cwd)).toEqual([ + expect(getProjectConfigCandidatePaths(cwd)).toEqual([ join(cwd, CONFIG_PRIMARY), join(cwd, CONFIG_SECONDARY), ]); }); + it("builds the XDG global config path when XDG_CONFIG_HOME is set", () => { + expect(getGlobalConfigPath({ XDG_CONFIG_HOME: "/config" }, "/home/user")).toBe( + join("/config", "workbox", "config.toml") + ); + }); + + it("falls back to the home workbox config path", () => { + expect(getGlobalConfigPath({}, "/home/user")).toBe( + join("/home/user", ".workbox", "config.toml") + ); + }); + it("resolves worktrees directory relative to repo root", () => { const cwd = "/repo"; expect(resolveWorktreesDir(".workbox/worktrees", cwd)).toBe(join(cwd, ".workbox", "worktrees")); diff --git a/src/core/paths.ts b/src/core/paths.ts index 946446f..b3ca4e0 100644 --- a/src/core/paths.ts +++ b/src/core/paths.ts @@ -1,9 +1,20 @@ +import { homedir } from "node:os"; import { join, resolve } from "node:path"; export const CONFIG_PRIMARY = join(".workbox", "config.toml"); export const CONFIG_SECONDARY = "workbox.toml"; +export const GLOBAL_CONFIG_XDG = join("workbox", "config.toml"); +export const GLOBAL_CONFIG_FALLBACK = join(".workbox", "config.toml"); -export const getConfigCandidatePaths = (repoRoot: string): string[] => [ +export const getGlobalConfigPath = ( + env: NodeJS.ProcessEnv = process.env, + homeDir = homedir() +): string => + env.XDG_CONFIG_HOME + ? join(env.XDG_CONFIG_HOME, GLOBAL_CONFIG_XDG) + : join(homeDir, GLOBAL_CONFIG_FALLBACK); + +export const getProjectConfigCandidatePaths = (repoRoot: string): string[] => [ join(repoRoot, CONFIG_PRIMARY), join(repoRoot, CONFIG_SECONDARY), ];