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), ];