diff options
Diffstat (limited to 'packages/core/src')
| -rw-r--r-- | packages/core/src/config/index.ts | 8 | ||||
| -rw-r--r-- | packages/core/src/config/loader.ts | 154 | ||||
| -rw-r--r-- | packages/core/src/config/watcher.ts | 20 | ||||
| -rw-r--r-- | packages/core/src/index.ts | 3 | ||||
| -rw-r--r-- | packages/core/src/types/index.ts | 11 |
5 files changed, 180 insertions, 16 deletions
diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index 5b128c4..4806409 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -1,3 +1,9 @@ -export { configToRuleset, loadConfig } from "./loader.js"; +export { + configToRuleset, + getGlobalConfigPath, + loadConfig, + loadGlobalConfig, + mergeConfigs, +} from "./loader.js"; export { validateConfig } from "./schema.js"; export { createConfigWatcher } from "./watcher.js"; diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts index bccbb8f..1e5f151 100644 --- a/packages/core/src/config/loader.ts +++ b/packages/core/src/config/loader.ts @@ -3,7 +3,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { parse } from "smol-toml"; import type { PermissionRule, Ruleset } from "../permission/index.js"; -import type { DispatchConfig } from "../types/index.js"; +import type { DispatchConfig, KeyDefinition, LspServerConfig } from "../types/index.js"; import { validateConfig } from "./schema.js"; const DEFAULT_CONFIG: DispatchConfig = { permissions: {} }; @@ -16,20 +16,40 @@ function validateAction(raw: string): "allow" | "deny" | "ask" { return "ask"; } -// Load dispatch.toml from the given directory -export function loadConfig(dir: string): DispatchConfig { - const tomlPath = join(dir, "dispatch.toml"); +/** + * Absolute path to the HOME-directory (global) `dispatch.toml`. + * + * Follows the same `~/.config/dispatch/` convention as global agents + * (`~/.config/dispatch/agents`). This file is OPTIONAL; when present its + * contents are merged underneath every project/working-directory config so + * machine-wide settings (e.g. globally available LSP servers) work in any + * repository without per-repo configuration. + * + * The path can be overridden with the `DISPATCH_GLOBAL_CONFIG` environment + * variable, which is primarily useful for tests (point it at a temp file) but + * also lets a user relocate the global config. + */ +export function getGlobalConfigPath(): string { + return ( + process.env.DISPATCH_GLOBAL_CONFIG ?? join(homedir(), ".config", "dispatch", "dispatch.toml") + ); +} + +// Parse + validate a single dispatch.toml. Returns null when the file does not +// exist. Re-throws TOML parse errors so a corrupt LOCAL config surfaces loudly +// (callers that must stay resilient, e.g. the global loader, catch it). +function readConfigFile(tomlPath: string): DispatchConfig | null { let raw: unknown; try { const content = readFileSync(tomlPath, "utf-8"); raw = parse(content); } catch (err: unknown) { if (err instanceof Error && (err as NodeJS.ErrnoException).code === "ENOENT") { - // File doesn't exist — return empty default - return DEFAULT_CONFIG; + // File doesn't exist — signal "no config here". + return null; } console.warn( - `dispatch: failed to parse dispatch.toml: ${err instanceof Error ? err.message : String(err)}`, + `dispatch: failed to parse ${tomlPath}: ${err instanceof Error ? err.message : String(err)}`, ); throw err; } @@ -41,6 +61,126 @@ export function loadConfig(dir: string): DispatchConfig { return config; } +/** + * Load the HOME-directory global `dispatch.toml` (see {@link getGlobalConfigPath}). + * + * Always resilient: a missing file yields the empty default and a malformed + * file is logged but downgraded to the empty default rather than thrown. A + * broken global config must never break config loading for every repository on + * the machine. + */ +export function loadGlobalConfig(): DispatchConfig { + try { + return readConfigFile(getGlobalConfigPath()) ?? DEFAULT_CONFIG; + } catch (err) { + console.warn( + `dispatch: ignoring global config due to parse error: ${err instanceof Error ? err.message : String(err)}`, + ); + return DEFAULT_CONFIG; + } +} + +/** + * Load the effective config for `dir`: the global config MERGED with the + * project/working-directory `dispatch.toml`, where the LOCAL config takes + * precedence on conflicts (see {@link mergeConfigs}). A missing local file + * yields the global config as-is; a missing global file yields the local + * config as-is. + * + * Note: a malformed LOCAL config still throws (callers may surface it), while a + * malformed GLOBAL config is downgraded to empty by {@link loadGlobalConfig}. + */ +export function loadConfig(dir: string): DispatchConfig { + const global = loadGlobalConfig(); + const local = readConfigFile(join(dir, "dispatch.toml")); + if (local === null) return global; + return mergeConfigs(global, local); +} + +// ─── Merge ─────────────────────────────────────────────────────── + +/** + * Merge two permission blocks. Local takes precedence on conflicts. + * + * - A key present only in one side is carried over verbatim. + * - A key whose value is a string on either side: local replaces global. + * - A key that is a nested `{ pattern -> action }` object on BOTH sides is + * merged pattern-by-pattern (local patterns override global patterns of the + * same name; non-conflicting patterns from both survive). + * + * Global groups are emitted before local-only groups, and global patterns + * before local patterns within a shared group. This ordering matters because + * `configToRuleset` flattens in iteration order and `evaluate` uses `findLast` + * (last match wins) — so local rules naturally win. + */ +function mergePermissions( + global: DispatchConfig["permissions"], + local: DispatchConfig["permissions"], +): DispatchConfig["permissions"] { + const result: DispatchConfig["permissions"] = {}; + for (const [key, value] of Object.entries(global)) { + result[key] = value; + } + for (const [key, value] of Object.entries(local)) { + const existing = result[key]; + if (existing !== undefined && typeof existing !== "string" && typeof value !== "string") { + // Both nested objects — merge patterns, local wins on conflicts. + result[key] = { ...existing, ...value }; + } else { + // Local string, brand-new key, or a string/object type mismatch: + // local replaces global wholesale. + result[key] = value; + } + } + return result; +} + +/** + * Merge two key lists by `id`. Local keys override global keys sharing the same + * id; non-conflicting ids from both lists survive. Global keys keep their + * relative order (overridden in place) followed by local-only keys. + */ +function mergeKeys(global: KeyDefinition[], local: KeyDefinition[]): KeyDefinition[] { + const byId = new Map<string, KeyDefinition>(); + for (const key of global) byId.set(key.id, key); + for (const key of local) byId.set(key.id, key); + return Array.from(byId.values()); +} + +/** + * Merge two `[lsp]` blocks by server id. Local servers override global servers + * sharing the same id; non-conflicting ids from both sides remain active. This + * is what lets a global config provide LSP servers to every repository while a + * project can still override or add its own. + */ +function mergeLsp( + global: Record<string, LspServerConfig>, + local: Record<string, LspServerConfig>, +): Record<string, LspServerConfig> { + return { ...global, ...local }; +} + +/** + * Deep-merge a `global` config with a `local` (project/working-directory) + * config, with LOCAL taking precedence on every conflict. Pure function — does + * not touch the filesystem and never mutates its inputs. + */ +export function mergeConfigs(global: DispatchConfig, local: DispatchConfig): DispatchConfig { + const merged: DispatchConfig = { + permissions: mergePermissions(global.permissions, local.permissions), + }; + + if (global.keys !== undefined || local.keys !== undefined) { + merged.keys = mergeKeys(global.keys ?? [], local.keys ?? []); + } + + if (global.lsp !== undefined || local.lsp !== undefined) { + merged.lsp = mergeLsp(global.lsp ?? {}, local.lsp ?? {}); + } + + return merged; +} + // Convert the config's permission block to a Ruleset export function configToRuleset(config: DispatchConfig): Ruleset { const home = homedir(); diff --git a/packages/core/src/config/watcher.ts b/packages/core/src/config/watcher.ts index 70821ed..9414ef6 100644 --- a/packages/core/src/config/watcher.ts +++ b/packages/core/src/config/watcher.ts @@ -1,16 +1,28 @@ import { join } from "node:path"; import { watch } from "chokidar"; import type { DispatchConfig } from "../types/index.js"; -import { loadConfig } from "./loader.js"; +import { getGlobalConfigPath, loadConfig } from "./loader.js"; +/** + * Watch BOTH the HOME-directory global `dispatch.toml` and the project/working- + * directory `dispatch.toml`. Either file changing triggers a reload that + * re-merges global + local (via {@link loadConfig}), so hot-reload works for + * global defaults and per-project overrides alike. + * + * When the global and local paths coincide (e.g. the working directory IS + * `~/.config/dispatch`, or `DISPATCH_GLOBAL_CONFIG` points at the local file) + * the duplicate is collapsed so chokidar only watches it once. + */ export function createConfigWatcher( dir: string, onChange: (config: DispatchConfig) => void, ): { close(): void } { - const tomlPath = join(dir, "dispatch.toml"); + const localPath = join(dir, "dispatch.toml"); + const globalPath = getGlobalConfigPath(); + const paths = globalPath === localPath ? [localPath] : [globalPath, localPath]; let debounceTimer: ReturnType<typeof setTimeout> | null = null; - const watcher = watch(tomlPath, { + const watcher = watch(paths, { ignoreInitial: true, persistent: false, }); @@ -21,7 +33,7 @@ export function createConfigWatcher( } debounceTimer = setTimeout(() => { debounceTimer = null; - console.log(`dispatch: reloading config from ${tomlPath}`); + console.log(`dispatch: reloading config (global + ${localPath})`); try { const config = loadConfig(dir); onChange(config); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 25cc909..b636cde 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -39,7 +39,10 @@ export { export { configToRuleset, createConfigWatcher, + getGlobalConfigPath, loadConfig, + loadGlobalConfig, + mergeConfigs, validateConfig, } from "./config/index.js"; // Credentials diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 4e3fa0b..e878813 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -508,10 +508,13 @@ export interface DispatchConfig { permissions: Record<string, string | Record<string, string>>; /** * Language Server Protocol servers, keyed by an arbitrary server id (e.g. - * `"luau-lsp"`). Project-scoped: read from the `dispatch.toml` in a tab's - * effective working directory and re-consulted when that directory (or the - * config) changes. Config-driven only — there is no builtin server registry - * and no auto-download; the declared `command[0]` must be on PATH. + * `"luau-lsp"`). Resolved by merging the HOME-directory global + * `dispatch.toml` (`~/.config/dispatch/dispatch.toml`) underneath the + * `dispatch.toml` in a tab's effective working directory — local entries + * override global ones sharing the same id, and global-only servers stay + * active in every repository. Re-consulted when either config (or the + * directory) changes. Config-driven only — there is no builtin server + * registry and no auto-download; the declared `command[0]` must be on PATH. */ lsp?: Record<string, LspServerConfig>; } |
