summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src/config
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-04 21:21:20 +0900
committerAdam Malczewski <[email protected]>2026-06-04 21:21:20 +0900
commit394f1ed37ce860da6fdc385769bf29f9737105cd (patch)
tree4b825dc642cb6eb9a060e54bf8d69288fbee4904 /packages/core/src/config
parent81a9cdbadf8c9d940d4fe9a2a0de607dee1f5f1a (diff)
downloaddispatch-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.ts9
-rw-r--r--packages/core/src/config/loader.ts226
-rw-r--r--packages/core/src/config/schema.ts239
-rw-r--r--packages/core/src/config/watcher.ts129
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)}`,
- );
- });
- },
- };
-}