summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-03 16:08:40 +0900
committerAdam Malczewski <[email protected]>2026-06-03 16:08:40 +0900
commitebd68da7dfd6d4f2ef6c6b29a62ec848bbf15cef (patch)
tree28de3a1dcd5e85a8fa9edb978ce0bad665ec722c /packages
parent5af9bd021c206b9e4330ab6a549dc8d013d91537 (diff)
downloaddispatch-ebd68da7dfd6d4f2ef6c6b29a62ec848bbf15cef.tar.gz
dispatch-ebd68da7dfd6d4f2ef6c6b29a62ec848bbf15cef.zip
feat(config): merge home-directory global dispatch.toml under project config
Load an optional global config at ~/.config/dispatch/dispatch.toml (override via DISPATCH_GLOBAL_CONFIG) and deep-merge it underneath every project/working-directory dispatch.toml, so machine-wide settings — most notably globally available LSP servers — work in any repo without per-repo config. Local always wins on conflicts. - loader: add getGlobalConfigPath(), loadGlobalConfig(), mergeConfigs(); loadConfig(dir) now loads+merges global. [lsp] and [[keys]] merge by id; [permissions] merge per-group with global patterns emitted first so local rules win at evaluation time (findLast). A malformed global config is downgraded to empty rather than breaking every repo. - watcher: watch BOTH global and local dispatch.toml so hot-reload re-merges on either change (dedupes when paths coincide). - export new loader fns from config/index and core index. - types/agent-manager: doc updates reflecting merged LSP resolution. - dispatch.toml: document global-default merge behavior; activate biome and typescript-language-server LSP entries. - tests: merge precedence, lsp/keys merge-by-id, permissions merge, filesystem integration, malformed-global resilience; isolate global path in existing loader tests.
Diffstat (limited to 'packages')
-rw-r--r--packages/api/src/agent-manager.ts13
-rw-r--r--packages/core/src/config/index.ts8
-rw-r--r--packages/core/src/config/loader.ts154
-rw-r--r--packages/core/src/config/watcher.ts20
-rw-r--r--packages/core/src/index.ts3
-rw-r--r--packages/core/src/types/index.ts11
-rw-r--r--packages/core/tests/config/loader.test.ts8
-rw-r--r--packages/core/tests/config/merge.test.ts206
8 files changed, 402 insertions, 21 deletions
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts
index 7cfa03c..913fb15 100644
--- a/packages/api/src/agent-manager.ts
+++ b/packages/api/src/agent-manager.ts
@@ -390,11 +390,14 @@ export class AgentManager {
/**
* Resolve (and cache) the LSP servers configured for a working directory.
*
- * LSP config is project-scoped: it lives in the `dispatch.toml` of the
- * tab's effective working directory, NOT the global config. We read that
- * directory's config once and cache the resolved servers; the cache is
- * cleared on config hot-reload. Returns `[]` when the directory has no
- * `[lsp]` block (the common case).
+ * LSP config is resolved by `loadConfig`, which merges the HOME-directory
+ * global `dispatch.toml` (`~/.config/dispatch/dispatch.toml`) underneath the
+ * tab's effective working-directory `dispatch.toml` — local `[lsp.<id>]`
+ * entries override global ones sharing the same id, while global-only
+ * servers stay active in every repository. We read+merge that config once
+ * per directory and cache the resolved servers; the cache is cleared on
+ * config hot-reload. Returns `[]` when neither config declares an `[lsp]`
+ * block (the common case).
*/
private getLspServersForDir(dir: string): ResolvedLspServer[] {
const cached = this.lspServersByDir.get(dir);
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>;
}
diff --git a/packages/core/tests/config/loader.test.ts b/packages/core/tests/config/loader.test.ts
index de025de..0d84d0b 100644
--- a/packages/core/tests/config/loader.test.ts
+++ b/packages/core/tests/config/loader.test.ts
@@ -6,12 +6,20 @@ import { configToRuleset, loadConfig } from "../../src/config/loader.js";
const TMP = join("/tmp/opencode", "dispatch-config-test");
+// Point the global config at a path that does not exist so these tests are
+// hermetic — they must not pick up this machine's real
+// ~/.config/dispatch/dispatch.toml.
+const prevGlobal = process.env.DISPATCH_GLOBAL_CONFIG;
+
beforeEach(() => {
mkdirSync(TMP, { recursive: true });
+ process.env.DISPATCH_GLOBAL_CONFIG = join(TMP, "__no_such_global__.toml");
});
afterEach(() => {
rmSync(TMP, { recursive: true, force: true });
+ if (prevGlobal === undefined) delete process.env.DISPATCH_GLOBAL_CONFIG;
+ else process.env.DISPATCH_GLOBAL_CONFIG = prevGlobal;
});
function writeToml(content: string): void {
diff --git a/packages/core/tests/config/merge.test.ts b/packages/core/tests/config/merge.test.ts
new file mode 100644
index 0000000..b362628
--- /dev/null
+++ b/packages/core/tests/config/merge.test.ts
@@ -0,0 +1,206 @@
+import { mkdirSync, rmSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import {
+ configToRuleset,
+ getGlobalConfigPath,
+ loadConfig,
+ loadGlobalConfig,
+ mergeConfigs,
+} from "../../src/config/loader.js";
+import { evaluate } from "../../src/permission/evaluate.js";
+import type { DispatchConfig } from "../../src/types/index.js";
+
+const TMP = join("/tmp/opencode", "dispatch-config-merge-test");
+const LOCAL_DIR = join(TMP, "project");
+const GLOBAL_PATH = join(TMP, "global", "dispatch.toml");
+
+const prevGlobal = process.env.DISPATCH_GLOBAL_CONFIG;
+
+beforeEach(() => {
+ mkdirSync(LOCAL_DIR, { recursive: true });
+ mkdirSync(join(TMP, "global"), { recursive: true });
+ process.env.DISPATCH_GLOBAL_CONFIG = GLOBAL_PATH;
+});
+
+afterEach(() => {
+ rmSync(TMP, { recursive: true, force: true });
+ if (prevGlobal === undefined) delete process.env.DISPATCH_GLOBAL_CONFIG;
+ else process.env.DISPATCH_GLOBAL_CONFIG = prevGlobal;
+});
+
+function writeGlobal(content: string): void {
+ writeFileSync(GLOBAL_PATH, content, "utf-8");
+}
+
+function writeLocal(content: string): void {
+ writeFileSync(join(LOCAL_DIR, "dispatch.toml"), content, "utf-8");
+}
+
+// ─── mergeConfigs (pure) ─────────────────────────────────────────
+
+describe("mergeConfigs — lsp by id", () => {
+ it("keeps non-conflicting servers from both global and local", () => {
+ const global: DispatchConfig = {
+ permissions: {},
+ lsp: { biome: { command: ["biome"], extensions: [".ts"] } },
+ };
+ const local: DispatchConfig = {
+ permissions: {},
+ lsp: { luau: { command: ["luau-lsp"], extensions: [".luau"] } },
+ };
+ const merged = mergeConfigs(global, local);
+ expect(Object.keys(merged.lsp ?? {}).sort()).toEqual(["biome", "luau"]);
+ });
+
+ it("local overrides global for the same server id", () => {
+ const global: DispatchConfig = {
+ permissions: {},
+ lsp: { biome: { command: ["global-biome"], extensions: [".ts"] } },
+ };
+ const local: DispatchConfig = {
+ permissions: {},
+ lsp: { biome: { command: ["local-biome"], extensions: [".ts", ".tsx"] } },
+ };
+ const merged = mergeConfigs(global, local);
+ expect(merged.lsp?.biome.command).toEqual(["local-biome"]);
+ expect(merged.lsp?.biome.extensions).toEqual([".ts", ".tsx"]);
+ });
+
+ it("omits lsp entirely when neither side declares one", () => {
+ const merged = mergeConfigs({ permissions: {} }, { permissions: {} });
+ expect(merged.lsp).toBeUndefined();
+ });
+
+ it("does not mutate inputs", () => {
+ const global: DispatchConfig = {
+ permissions: {},
+ lsp: { biome: { command: ["g"], extensions: [".ts"] } },
+ };
+ const local: DispatchConfig = {
+ permissions: {},
+ lsp: { biome: { command: ["l"], extensions: [".ts"] } },
+ };
+ mergeConfigs(global, local);
+ expect(global.lsp?.biome.command).toEqual(["g"]);
+ expect(local.lsp?.biome.command).toEqual(["l"]);
+ });
+});
+
+describe("mergeConfigs — keys by id", () => {
+ it("merges keys by id, local overriding global", () => {
+ const global: DispatchConfig = {
+ permissions: {},
+ keys: [
+ { id: "a", provider: "x", base_url: "g-a" },
+ { id: "b", provider: "x", base_url: "g-b" },
+ ],
+ };
+ const local: DispatchConfig = {
+ permissions: {},
+ keys: [
+ { id: "b", provider: "x", base_url: "l-b" },
+ { id: "c", provider: "x", base_url: "l-c" },
+ ],
+ };
+ const merged = mergeConfigs(global, local);
+ const byId = Object.fromEntries((merged.keys ?? []).map((k) => [k.id, k.base_url]));
+ expect(byId).toEqual({ a: "g-a", b: "l-b", c: "l-c" });
+ });
+
+ it("carries global keys through when local has none", () => {
+ const global: DispatchConfig = {
+ permissions: {},
+ keys: [{ id: "a", provider: "x", base_url: "g-a" }],
+ };
+ const merged = mergeConfigs(global, { permissions: {} });
+ expect(merged.keys).toEqual([{ id: "a", provider: "x", base_url: "g-a" }]);
+ });
+});
+
+describe("mergeConfigs — permissions", () => {
+ it("merges nested groups pattern-by-pattern with local winning", () => {
+ const global: DispatchConfig = {
+ permissions: { bash: { "git status": "allow", "*": "ask" } },
+ };
+ const local: DispatchConfig = {
+ permissions: { bash: { "*": "allow" } },
+ };
+ const merged = mergeConfigs(global, local);
+ const bash = merged.permissions.bash as Record<string, string>;
+ expect(bash["git status"]).toBe("allow");
+ expect(bash["*"]).toBe("allow"); // local override
+ });
+
+ it("local string value replaces a global nested group", () => {
+ const global: DispatchConfig = {
+ permissions: { read: { "src/**": "allow" } },
+ };
+ const local: DispatchConfig = { permissions: { read: "deny" } };
+ const merged = mergeConfigs(global, local);
+ expect(merged.permissions.read).toBe("deny");
+ });
+
+ it("keeps global-only permission groups", () => {
+ const global: DispatchConfig = { permissions: { read: "allow" } };
+ const local: DispatchConfig = { permissions: { edit: "ask" } };
+ const merged = mergeConfigs(global, local);
+ expect(merged.permissions.read).toBe("allow");
+ expect(merged.permissions.edit).toBe("ask");
+ });
+
+ it("local wins at evaluation time (findLast ordering)", () => {
+ const merged = mergeConfigs(
+ { permissions: { bash: { "*": "ask" } } },
+ { permissions: { bash: { "*": "allow" } } },
+ );
+ const ruleset = configToRuleset(merged);
+ expect(evaluate("bash", "anything", ruleset).action).toBe("allow");
+ });
+});
+
+// ─── loadConfig (filesystem integration) ─────────────────────────
+
+describe("loadConfig — global + local integration", () => {
+ it("returns global config when local dispatch.toml is missing", () => {
+ writeGlobal(`[lsp.biome]\ncommand = ["biome"]\nextensions = [".ts"]\n`);
+ const config = loadConfig(LOCAL_DIR);
+ expect(config.lsp?.biome.command).toEqual(["biome"]);
+ });
+
+ it("returns local config when global is missing", () => {
+ writeLocal(`[permissions]\nread = "allow"\n`);
+ const config = loadConfig(LOCAL_DIR);
+ expect(config.permissions.read).toBe("allow");
+ expect(config.lsp).toBeUndefined();
+ });
+
+ it("merges global LSP servers with local ones (local wins on id)", () => {
+ writeGlobal(
+ `[lsp.biome]\ncommand = ["global-biome"]\nextensions = [".ts"]\n\n[lsp.luau]\ncommand = ["luau-lsp"]\nextensions = [".luau"]\n`,
+ );
+ writeLocal(`[lsp.biome]\ncommand = ["local-biome"]\nextensions = [".ts"]\n`);
+ const config = loadConfig(LOCAL_DIR);
+ expect(config.lsp?.biome.command).toEqual(["local-biome"]);
+ expect(config.lsp?.luau.command).toEqual(["luau-lsp"]);
+ });
+
+ it("a malformed global config is ignored, local still loads", () => {
+ writeGlobal("not valid toml [[[");
+ writeLocal(`[permissions]\nread = "allow"\n`);
+ const config = loadConfig(LOCAL_DIR);
+ expect(config.permissions.read).toBe("allow");
+ });
+});
+
+describe("loadGlobalConfig", () => {
+ it("returns empty default when the global file is missing", () => {
+ expect(loadGlobalConfig()).toEqual({ permissions: {} });
+ });
+
+ it("loads the file at getGlobalConfigPath()", () => {
+ writeGlobal(`[permissions]\nedit = "deny"\n`);
+ expect(getGlobalConfigPath()).toBe(GLOBAL_PATH);
+ expect(loadGlobalConfig().permissions.edit).toBe("deny");
+ });
+});