diff options
| author | Adam Malczewski <[email protected]> | 2026-06-04 21:21:20 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-04 21:21:20 +0900 |
| commit | 394f1ed37ce860da6fdc385769bf29f9737105cd (patch) | |
| tree | 4b825dc642cb6eb9a060e54bf8d69288fbee4904 /packages/core/src/config | |
| parent | 81a9cdbadf8c9d940d4fe9a2a0de607dee1f5f1a (diff) | |
| download | dispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.tar.gz dispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.zip | |
chore: genesis — remove all files to rebuild from scratch (arch rewrite)
Diffstat (limited to 'packages/core/src/config')
| -rw-r--r-- | packages/core/src/config/index.ts | 9 | ||||
| -rw-r--r-- | packages/core/src/config/loader.ts | 226 | ||||
| -rw-r--r-- | packages/core/src/config/schema.ts | 239 | ||||
| -rw-r--r-- | packages/core/src/config/watcher.ts | 129 |
4 files changed, 0 insertions, 603 deletions
diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts deleted file mode 100644 index 7f76dd7..0000000 --- a/packages/core/src/config/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { - configToRuleset, - getGlobalConfigPath, - loadConfig, - loadGlobalConfig, - mergeConfigs, -} from "./loader.js"; -export { validateConfig } from "./schema.js"; -export { createConfigWatcher, watchDirConfig } from "./watcher.js"; diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts deleted file mode 100644 index 66f798b..0000000 --- a/packages/core/src/config/loader.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { readFileSync } from "node:fs"; -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, KeyDefinition, LspServerConfig } from "../types/index.js"; -import { validateConfig } from "./schema.js"; - -const DEFAULT_CONFIG: DispatchConfig = { permissions: {} }; - -const VALID_ACTIONS = new Set(["allow", "deny", "ask"]); - -function validateAction(raw: string): "allow" | "deny" | "ask" { - if (VALID_ACTIONS.has(raw)) return raw as "allow" | "deny" | "ask"; - console.warn(`dispatch: unrecognized action "${raw}", defaulting to "ask"`); - return "ask"; -} - -/** - * 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 — signal "no config here". - return null; - } - console.warn( - `dispatch: failed to parse ${tomlPath}: ${err instanceof Error ? err.message : String(err)}`, - ); - throw err; - } - - const { config, errors } = validateConfig(raw); - for (const e of errors) { - console.warn(`dispatch: config warning at ${e.path}: ${e.message}`); - } - 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: global patterns the local block does NOT also - * define come first (original order), then EVERY local pattern is appended - * last (overriding any same-named global pattern). - * - * Emitting all local patterns after the global ones is essential, not - * cosmetic: `configToRuleset` flattens patterns in iteration order and - * `evaluate` uses `findLast` (last match wins). If an overridden pattern were - * updated in place, a more-general global pattern (e.g. "*") could remain AFTER - * it and silently shadow the local override. Appending local patterns last - * reproduces a clean "global rules then local rules" concatenation so local - * always wins. - */ -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 so that ALL local patterns - // are emitted AFTER the global ones. This matters because - // `configToRuleset` flattens patterns in insertion order and - // `evaluate` uses `findLast` (last match wins): a naive - // `{ ...existing, ...value }` would update an overridden pattern - // IN PLACE, leaving a more-general global pattern (e.g. "*") sitting - // AFTER it and silently shadowing the local override. We therefore - // drop any global pattern that the local block also defines, keep the - // remaining global patterns in their original order, then append every - // local pattern last — reproducing a clean "global rules then local - // rules" concatenation where local always wins. - const merged: Record<string, string> = {}; - for (const [pattern, action] of Object.entries(existing)) { - if (!(pattern in value)) merged[pattern] = action; - } - for (const [pattern, action] of Object.entries(value)) { - merged[pattern] = action; - } - result[key] = merged; - } 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(); - const rules: PermissionRule[] = []; - - for (const [permission, value] of Object.entries(config.permissions)) { - if (typeof value === "string") { - const action = validateAction(value); - rules.push({ permission, pattern: "*", action }); - } else { - for (const [rawPattern, rawAction] of Object.entries(value)) { - const pattern = rawPattern - .replace(/^\$HOME(?=[/\\]|$)/, home) - .replace(/^~(?=[/\\]|$)/, home); - const action = validateAction(rawAction); - rules.push({ permission, pattern, action }); - } - } - } - - return rules; -} diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts deleted file mode 100644 index 304ee10..0000000 --- a/packages/core/src/config/schema.ts +++ /dev/null @@ -1,239 +0,0 @@ -import type { - ConfigError, - DispatchConfig, - KeyDefinition, - LspServerConfig, -} from "../types/index.js"; - -function isRecord(value: unknown): value is Record<string, unknown> { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function isStringRecord(value: unknown): value is Record<string, string> { - if (!isRecord(value)) return false; - return Object.values(value).every((v) => typeof v === "string"); -} - -function isValidAction(value: string): boolean { - return value === "allow" || value === "deny" || value === "ask"; -} - -function isPermissionsValue(value: unknown): value is string | Record<string, string> { - return typeof value === "string" || isStringRecord(value); -} - -function validatePermissions( - raw: unknown, - path: string, - errors: ConfigError[], -): Record<string, string | Record<string, string>> { - if (!isRecord(raw)) { - errors.push({ path, message: "must be an object" }); - return {}; - } - const result: Record<string, string | Record<string, string>> = {}; - for (const [key, value] of Object.entries(raw)) { - if (!isPermissionsValue(value)) { - errors.push({ - path: `${path}.${key}`, - message: "must be a string or a flat string-keyed object", - }); - continue; - } - if (typeof value === "string") { - if (!isValidAction(value)) { - errors.push({ - path: `${path}.${key}`, - message: `invalid action "${value}"; must be "allow", "deny", or "ask"`, - }); - continue; - } - } else { - let hasError = false; - for (const [pattern, action] of Object.entries(value)) { - if (!isValidAction(action)) { - errors.push({ - path: `${path}.${key}.${pattern}`, - message: `invalid action "${action}"; must be "allow", "deny", or "ask"`, - }); - hasError = true; - } - } - if (hasError) continue; - } - result[key] = value; - } - return result; -} - -function validateKey(raw: unknown, path: string, errors: ConfigError[]): KeyDefinition | null { - if (!isRecord(raw)) { - errors.push({ path, message: "must be an object" }); - return null; - } - if (typeof raw.id !== "string") { - errors.push({ path: `${path}.id`, message: "must be a string" }); - return null; - } - if (typeof raw.provider !== "string") { - errors.push({ path: `${path}.provider`, message: "must be a string" }); - return null; - } - if (typeof raw.base_url !== "string") { - errors.push({ path: `${path}.base_url`, message: "must be a string" }); - return null; - } - - // "anthropic" provider uses credentials_file instead of env - if (raw.provider === "anthropic") { - return { - id: raw.id as string, - provider: raw.provider as string, - base_url: raw.base_url as string, - ...(typeof raw.credentials_file === "string" - ? ({ credentials_file: raw.credentials_file } as Pick<KeyDefinition, "credentials_file">) - : {}), - }; - } - - // Other providers: env is optional (keys can be stored in DB) - return { - id: raw.id as string, - provider: raw.provider as string, - base_url: raw.base_url as string, - ...(typeof raw.env === "string" ? { env: raw.env } : {}), - }; -} - -function isStringArray(value: unknown): value is string[] { - return Array.isArray(value) && value.every((v) => typeof v === "string"); -} - -function validateLspServer( - raw: unknown, - path: string, - errors: ConfigError[], -): LspServerConfig | null { - if (!isRecord(raw)) { - errors.push({ path, message: "must be an object" }); - return null; - } - - const disabled = raw.disabled === true; - - // `command` is required and must be a non-empty string array unless the - // entry is explicitly disabled (a disabled entry is skipped wholesale). - if (!disabled) { - if (!isStringArray(raw.command) || raw.command.length === 0) { - errors.push({ - path: `${path}.command`, - message: "must be a non-empty array of strings", - }); - return null; - } - // `extensions` is required for custom servers — without it the client - // cannot know which files should activate the server. - if (!isStringArray(raw.extensions) || raw.extensions.length === 0) { - errors.push({ - path: `${path}.extensions`, - message: 'must be a non-empty array of strings (e.g. [".luau"])', - }); - return null; - } - } else { - // Disabled entries still must not carry a malformed command/extensions - // if present, but we do not require them. - if (raw.command !== undefined && !isStringArray(raw.command)) { - errors.push({ path: `${path}.command`, message: "must be an array of strings" }); - return null; - } - if (raw.extensions !== undefined && !isStringArray(raw.extensions)) { - errors.push({ path: `${path}.extensions`, message: "must be an array of strings" }); - return null; - } - } - - if (raw.env !== undefined && !isStringRecord(raw.env)) { - errors.push({ - path: `${path}.env`, - message: "must be a flat string-keyed object", - }); - return null; - } - - if (raw.initialization !== undefined && !isRecord(raw.initialization)) { - errors.push({ - path: `${path}.initialization`, - message: "must be an object", - }); - return null; - } - - const server: LspServerConfig = { - command: (raw.command as string[] | undefined) ?? [], - extensions: (raw.extensions as string[] | undefined) ?? [], - ...(isStringRecord(raw.env) ? { env: raw.env } : {}), - ...(isRecord(raw.initialization) - ? { initialization: raw.initialization as Record<string, unknown> } - : {}), - ...(disabled ? { disabled: true } : {}), - }; - return server; -} - -function validateLsp( - raw: unknown, - path: string, - errors: ConfigError[], -): Record<string, LspServerConfig> | undefined { - if (!isRecord(raw)) { - errors.push({ path, message: "must be an object" }); - return undefined; - } - const result: Record<string, LspServerConfig> = {}; - for (const [id, value] of Object.entries(raw)) { - const server = validateLspServer(value, `${path}.${id}`, errors); - if (server) result[id] = server; - } - return Object.keys(result).length > 0 ? result : undefined; -} - -export function validateConfig(raw: unknown): { config: DispatchConfig; errors: ConfigError[] } { - const errors: ConfigError[] = []; - - if (!isRecord(raw)) { - errors.push({ path: "", message: "config must be an object" }); - return { config: { permissions: {} }, errors }; - } - - // permissions (required, but can be empty) - const permissions = validatePermissions(raw.permissions ?? {}, "permissions", errors); - - // keys (optional) - let keys: KeyDefinition[] | undefined; - if (raw.keys !== undefined) { - if (!Array.isArray(raw.keys)) { - errors.push({ path: "keys", message: "must be an array" }); - } else { - keys = []; - for (let i = 0; i < raw.keys.length; i++) { - const key = validateKey(raw.keys[i], `keys[${i}]`, errors); - if (key) keys.push(key); - } - } - } - - // lsp (optional) - let lsp: Record<string, LspServerConfig> | undefined; - if (raw.lsp !== undefined) { - lsp = validateLsp(raw.lsp, "lsp", errors); - } - - const config: DispatchConfig = { - permissions, - ...(keys !== undefined && { keys }), - ...(lsp !== undefined && { lsp }), - }; - - return { config, errors }; -} diff --git a/packages/core/src/config/watcher.ts b/packages/core/src/config/watcher.ts deleted file mode 100644 index ad55804..0000000 --- a/packages/core/src/config/watcher.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { join } from "node:path"; -import { watch } from "chokidar"; -import type { DispatchConfig } from "../types/index.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 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(paths, { - ignoreInitial: true, - persistent: false, - }); - - const handleChange = () => { - if (debounceTimer !== null) { - clearTimeout(debounceTimer); - } - debounceTimer = setTimeout(() => { - debounceTimer = null; - console.log(`dispatch: reloading config (global + ${localPath})`); - try { - const config = loadConfig(dir); - onChange(config); - } catch (err) { - console.warn( - `dispatch: retaining last known config due to parse error: ${err instanceof Error ? err.message : String(err)}`, - ); - } - }, 300); - }; - - watcher.on("change", handleChange); - watcher.on("add", handleChange); - watcher.on("unlink", handleChange); - - watcher.on("error", (err) => { - console.warn( - `dispatch: config watcher error: ${err instanceof Error ? err.message : String(err)}`, - ); - }); - - return { - close() { - if (debounceTimer !== null) { - clearTimeout(debounceTimer); - debounceTimer = null; - } - watcher.close().catch((err) => { - console.warn( - `dispatch: error closing config watcher: ${err instanceof Error ? err.message : String(err)}`, - ); - }); - }, - }; -} - -/** - * Watch a SINGLE directory's `dispatch.toml` (no global merge, no reload — just - * a debounced change signal). Used by the agent manager to invalidate its - * per-directory LSP cache when a tab's effective working directory is a - * SUBDIRECTORY with its own `dispatch.toml`: the main `createConfigWatcher` - * only watches the root + global configs, so without this a nested config edit - * would never clear `lspServersByDir[subdir]` and agents there would keep using - * stale LSP servers until a root-config change or restart. - * - * `onChange` fires (debounced) on add/change/unlink of `<dir>/dispatch.toml`. - */ -export function watchDirConfig(dir: string, onChange: () => void): { close(): void } { - const tomlPath = join(dir, "dispatch.toml"); - let debounceTimer: ReturnType<typeof setTimeout> | null = null; - - const watcher = watch(tomlPath, { - ignoreInitial: true, - persistent: false, - }); - - const handleChange = () => { - if (debounceTimer !== null) clearTimeout(debounceTimer); - debounceTimer = setTimeout(() => { - debounceTimer = null; - try { - onChange(); - } catch (err) { - console.warn( - `dispatch: dir config watcher onChange error: ${err instanceof Error ? err.message : String(err)}`, - ); - } - }, 300); - }; - - watcher.on("change", handleChange); - watcher.on("add", handleChange); - watcher.on("unlink", handleChange); - watcher.on("error", (err) => { - console.warn( - `dispatch: dir config watcher error: ${err instanceof Error ? err.message : String(err)}`, - ); - }); - - return { - close() { - if (debounceTimer !== null) { - clearTimeout(debounceTimer); - debounceTimer = null; - } - watcher.close().catch((err) => { - console.warn( - `dispatch: error closing dir config watcher: ${err instanceof Error ? err.message : String(err)}`, - ); - }); - }, - }; -} |
