From 15d286b61745502075cad01a039518f6cd3905b3 Mon Sep 17 00:00:00 2001 From: sheeek Date: Sun, 11 Jan 2026 15:01:02 +0100 Subject: [PATCH 1/4] feat(config): add $include directive for modular configs Adds support for splitting clawdbot.json into multiple files using the $include directive. This enables: - Single file includes: { "$include": "./agents.json5" } - Multiple file merging: { "$include": ["./a.json5", "./b.json5"] } - Nested includes (up to 10 levels deep) - Sibling key merging with includes Features: - Relative paths resolved from including file - Absolute paths supported - Circular include detection - Clear error messages with resolved paths Use case: Per-client agent configs for isolated sandboxed environments (e.g., legal case management with strict data separation). --- docs/gateway/configuration.md | 125 ++++++++++++++ src/config/io-includes.test.ts | 304 +++++++++++++++++++++++++++++++++ src/config/io.ts | 272 ++++++++++++++++++++++++++++- 3 files changed, 696 insertions(+), 5 deletions(-) create mode 100644 src/config/io-includes.test.ts diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 162d1c43e..9a0da11d7 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -82,6 +82,131 @@ To prevent the bot from responding to WhatsApp @-mentions in groups (only respon } ``` +## Config Includes (`$include`) + +Split your config into multiple files using the `$include` directive. This is useful for: +- Organizing large configs (e.g., per-client agent definitions) +- Sharing common settings across environments +- Keeping sensitive configs separate + +### Basic usage + +```json5 +// ~/.clawdbot/clawdbot.json +{ + gateway: { port: 18789 }, + + // Include a single file (replaces the key's value) + agents: { "$include": "./agents.json5" }, + + // Include multiple files (deep-merged in order) + broadcast: { + "$include": [ + "./clients/mueller.json5", + "./clients/schmidt.json5" + ] + } +} +``` + +```json5 +// ~/.clawdbot/agents.json5 +{ + defaults: { sandbox: { mode: "all", scope: "session" } }, + list: [ + { id: "main", workspace: "~/clawd" } + ] +} +``` + +### Merge behavior + +- **Single file**: Replaces the object containing `$include` +- **Array of files**: Deep-merges files in order (later files override earlier ones) +- **With sibling keys**: Sibling keys are merged after includes (override included values) + +```json5 +// Sibling keys override included values +{ + "$include": "./base.json5", // { a: 1, b: 2 } + b: 99 // Result: { a: 1, b: 99 } +} +``` + +### Nested includes + +Included files can themselves contain `$include` directives (up to 10 levels deep): + +```json5 +// clients/mueller.json5 +{ + agents: { "$include": "./mueller/agents.json5" }, + broadcast: { "$include": "./mueller/broadcast.json5" } +} +``` + +### Path resolution + +- **Relative paths**: Resolved relative to the including file +- **Absolute paths**: Used as-is +- **Parent directories**: `../` references work as expected + +```json5 +{ "$include": "./sub/config.json5" } // relative +{ "$include": "/etc/clawdbot/base.json5" } // absolute +{ "$include": "../shared/common.json5" } // parent dir +``` + +### Error handling + +- **Missing file**: Clear error with resolved path +- **Parse error**: Shows which included file failed +- **Circular includes**: Detected and reported with include chain + +### Example: Multi-client legal setup + +```json5 +// ~/.clawdbot/clawdbot.json +{ + gateway: { port: 18789, auth: { token: "secret" } }, + + // Common agent defaults + agents: { + defaults: { + sandbox: { mode: "all", scope: "session" } + }, + // Merge agent lists from all clients + list: { "$include": [ + "./clients/mueller/agents.json5", + "./clients/schmidt/agents.json5" + ]} + }, + + // Merge broadcast configs + broadcast: { "$include": [ + "./clients/mueller/broadcast.json5", + "./clients/schmidt/broadcast.json5" + ]}, + + whatsapp: { groupPolicy: "allowlist" } +} +``` + +```json5 +// ~/.clawdbot/clients/mueller/agents.json5 +[ + { id: "mueller-transcribe", workspace: "~/clients/mueller/transcribe" }, + { id: "mueller-docs", workspace: "~/clients/mueller/docs" } +] +``` + +```json5 +// ~/.clawdbot/clients/mueller/broadcast.json5 +{ + "120363403215116621@g.us": ["mueller-transcribe", "mueller-docs"] +} +``` + ## Common options ### Env vars + `.env` diff --git a/src/config/io-includes.test.ts b/src/config/io-includes.test.ts new file mode 100644 index 000000000..ad77d2570 --- /dev/null +++ b/src/config/io-includes.test.ts @@ -0,0 +1,304 @@ +import { describe, expect, it } from "vitest"; + +import { + CircularIncludeError, + ConfigIncludeError, + resolveIncludes, +} from "./io.js"; + +function createMockContext( + files: Record, + basePath = "/config/clawdbot.json", +) { + const fsModule = { + readFileSync: (filePath: string) => { + if (filePath in files) { + return JSON.stringify(files[filePath]); + } + const err = new Error(`ENOENT: no such file: ${filePath}`); + (err as NodeJS.ErrnoException).code = "ENOENT"; + throw err; + }, + } as typeof import("node:fs"); + + const json5Module = { + parse: JSON.parse, + } as typeof import("json5"); + + return { + basePath, + visited: new Set([basePath]), + depth: 0, + fsModule, + json5Module, + logger: { error: () => {}, warn: () => {} }, + }; +} + +describe("resolveIncludes", () => { + it("passes through primitives unchanged", () => { + const ctx = createMockContext({}); + expect(resolveIncludes("hello", ctx)).toBe("hello"); + expect(resolveIncludes(42, ctx)).toBe(42); + expect(resolveIncludes(true, ctx)).toBe(true); + expect(resolveIncludes(null, ctx)).toBe(null); + }); + + it("passes through arrays with recursion", () => { + const ctx = createMockContext({}); + expect(resolveIncludes([1, 2, { a: 1 }], ctx)).toEqual([1, 2, { a: 1 }]); + }); + + it("passes through objects without $include", () => { + const ctx = createMockContext({}); + const obj = { foo: "bar", nested: { x: 1 } }; + expect(resolveIncludes(obj, ctx)).toEqual(obj); + }); + + it("resolves single file $include", () => { + const ctx = createMockContext({ + "/config/agents.json": { list: [{ id: "main" }] }, + }); + const obj = { agents: { $include: "./agents.json" } }; + expect(resolveIncludes(obj, ctx)).toEqual({ + agents: { list: [{ id: "main" }] }, + }); + }); + + it("resolves absolute path $include", () => { + const ctx = createMockContext({ + "/etc/clawdbot/agents.json": { list: [{ id: "main" }] }, + }); + const obj = { agents: { $include: "/etc/clawdbot/agents.json" } }; + expect(resolveIncludes(obj, ctx)).toEqual({ + agents: { list: [{ id: "main" }] }, + }); + }); + + it("resolves array $include with deep merge", () => { + const ctx = createMockContext({ + "/config/a.json": { "group-a": ["agent1"] }, + "/config/b.json": { "group-b": ["agent2"] }, + }); + const obj = { broadcast: { $include: ["./a.json", "./b.json"] } }; + expect(resolveIncludes(obj, ctx)).toEqual({ + broadcast: { + "group-a": ["agent1"], + "group-b": ["agent2"], + }, + }); + }); + + it("deep merges overlapping keys in array $include", () => { + const ctx = createMockContext({ + "/config/a.json": { agents: { defaults: { workspace: "~/a" } } }, + "/config/b.json": { agents: { list: [{ id: "main" }] } }, + }); + const obj = { $include: ["./a.json", "./b.json"] }; + expect(resolveIncludes(obj, ctx)).toEqual({ + agents: { + defaults: { workspace: "~/a" }, + list: [{ id: "main" }], + }, + }); + }); + + it("merges $include with sibling keys", () => { + const ctx = createMockContext({ + "/config/base.json": { a: 1, b: 2 }, + }); + const obj = { $include: "./base.json", c: 3 }; + expect(resolveIncludes(obj, ctx)).toEqual({ a: 1, b: 2, c: 3 }); + }); + + it("sibling keys override included values", () => { + const ctx = createMockContext({ + "/config/base.json": { a: 1, b: 2 }, + }); + const obj = { $include: "./base.json", b: 99 }; + expect(resolveIncludes(obj, ctx)).toEqual({ a: 1, b: 99 }); + }); + + it("resolves nested includes", () => { + const ctx = createMockContext({ + "/config/level1.json": { nested: { $include: "./level2.json" } }, + "/config/level2.json": { deep: "value" }, + }); + const obj = { $include: "./level1.json" }; + expect(resolveIncludes(obj, ctx)).toEqual({ + nested: { deep: "value" }, + }); + }); + + it("throws ConfigIncludeError for missing file", () => { + const ctx = createMockContext({}); + const obj = { $include: "./missing.json" }; + expect(() => resolveIncludes(obj, ctx)).toThrow(ConfigIncludeError); + expect(() => resolveIncludes(obj, ctx)).toThrow(/Failed to read include file/); + }); + + it("throws ConfigIncludeError for invalid JSON", () => { + const fsModule = { + readFileSync: () => "{ invalid json }", + } as typeof import("node:fs"); + const json5Module = { + parse: JSON.parse, + } as typeof import("json5"); + const ctx = { + basePath: "/config/clawdbot.json", + visited: new Set(["/config/clawdbot.json"]), + depth: 0, + fsModule, + json5Module, + logger: { error: () => {}, warn: () => {} }, + }; + const obj = { $include: "./bad.json" }; + expect(() => resolveIncludes(obj, ctx)).toThrow(ConfigIncludeError); + expect(() => resolveIncludes(obj, ctx)).toThrow(/Failed to parse include file/); + }); + + it("throws CircularIncludeError for circular includes", () => { + // Create a mock that simulates circular includes + const fsModule = { + readFileSync: (filePath: string) => { + if (filePath === "/config/a.json") { + return JSON.stringify({ $include: "./b.json" }); + } + if (filePath === "/config/b.json") { + return JSON.stringify({ $include: "./a.json" }); + } + throw new Error(`Unknown file: ${filePath}`); + }, + } as typeof import("node:fs"); + const json5Module = { parse: JSON.parse } as typeof import("json5"); + const ctx = { + basePath: "/config/clawdbot.json", + visited: new Set(["/config/clawdbot.json"]), + depth: 0, + fsModule, + json5Module, + logger: { error: () => {}, warn: () => {} }, + }; + const obj = { $include: "./a.json" }; + expect(() => resolveIncludes(obj, ctx)).toThrow(CircularIncludeError); + expect(() => resolveIncludes(obj, ctx)).toThrow(/Circular include detected/); + }); + + it("throws ConfigIncludeError for invalid $include value type", () => { + const ctx = createMockContext({}); + const obj = { $include: 123 }; + expect(() => resolveIncludes(obj, ctx)).toThrow(ConfigIncludeError); + expect(() => resolveIncludes(obj, ctx)).toThrow(/expected string or array/); + }); + + it("throws ConfigIncludeError for invalid array item type", () => { + const ctx = createMockContext({ + "/config/valid.json": { valid: true }, + }); + const obj = { $include: ["./valid.json", 123] }; + expect(() => resolveIncludes(obj, ctx)).toThrow(ConfigIncludeError); + expect(() => resolveIncludes(obj, ctx)).toThrow(/expected string, got number/); + }); + + it("respects max depth limit", () => { + // Create deeply nested includes + const files: Record = {}; + for (let i = 0; i < 15; i++) { + files[`/config/level${i}.json`] = { $include: `./level${i + 1}.json` }; + } + files["/config/level15.json"] = { done: true }; + + const ctx = createMockContext(files); + const obj = { $include: "./level0.json" }; + expect(() => resolveIncludes(obj, ctx)).toThrow(ConfigIncludeError); + expect(() => resolveIncludes(obj, ctx)).toThrow(/Maximum include depth/); + }); + + it("handles relative paths correctly", () => { + const ctx = createMockContext( + { + "/config/clients/mueller/agents.json": { id: "mueller" }, + }, + "/config/clawdbot.json", + ); + const obj = { agent: { $include: "./clients/mueller/agents.json" } }; + expect(resolveIncludes(obj, ctx)).toEqual({ + agent: { id: "mueller" }, + }); + }); + + it("resolves parent directory references", () => { + const ctx = createMockContext( + { + "/shared/common.json": { shared: true }, + }, + "/config/sub/clawdbot.json", + ); + const obj = { $include: "../../shared/common.json" }; + expect(resolveIncludes(obj, ctx)).toEqual({ shared: true }); + }); +}); + +describe("real-world config patterns", () => { + it("supports per-client agent includes", () => { + const ctx = createMockContext({ + "/config/clients/mueller.json": { + agents: [ + { id: "mueller-screenshot", workspace: "~/clients/mueller/screenshot" }, + { id: "mueller-transcribe", workspace: "~/clients/mueller/transcribe" }, + ], + broadcast: { "group-mueller": ["mueller-screenshot", "mueller-transcribe"] }, + }, + "/config/clients/schmidt.json": { + agents: [ + { id: "schmidt-screenshot", workspace: "~/clients/schmidt/screenshot" }, + ], + broadcast: { "group-schmidt": ["schmidt-screenshot"] }, + }, + }); + + const obj = { + gateway: { port: 18789 }, + $include: ["./clients/mueller.json", "./clients/schmidt.json"], + }; + + expect(resolveIncludes(obj, ctx)).toEqual({ + gateway: { port: 18789 }, + agents: [ + { id: "mueller-screenshot", workspace: "~/clients/mueller/screenshot" }, + { id: "mueller-transcribe", workspace: "~/clients/mueller/transcribe" }, + { id: "schmidt-screenshot", workspace: "~/clients/schmidt/screenshot" }, + ], + broadcast: { + "group-mueller": ["mueller-screenshot", "mueller-transcribe"], + "group-schmidt": ["schmidt-screenshot"], + }, + }); + }); + + it("supports modular config structure", () => { + const ctx = createMockContext({ + "/config/gateway.json": { gateway: { port: 18789, bind: "loopback" } }, + "/config/providers/whatsapp.json": { + whatsapp: { dmPolicy: "pairing", allowFrom: ["+49123"] }, + }, + "/config/agents/defaults.json": { + agents: { defaults: { sandbox: { mode: "all" } } }, + }, + }); + + const obj = { + $include: [ + "./gateway.json", + "./providers/whatsapp.json", + "./agents/defaults.json", + ], + }; + + expect(resolveIncludes(obj, ctx)).toEqual({ + gateway: { port: 18789, bind: "loopback" }, + whatsapp: { dmPolicy: "pairing", allowFrom: ["+49123"] }, + agents: { defaults: { sandbox: { mode: "all" } } }, + }); + }); +}); diff --git a/src/config/io.ts b/src/config/io.ts index 05fa98c2d..8a68c8ae8 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import JSON5 from "json5"; + import { loadShellEnvFallback, resolveShellEnvFallbackTimeoutMs, @@ -128,6 +129,240 @@ export function parseConfigJson5( } } +// ============================================================================ +// Config Includes ($include directive) +// ============================================================================ + +const INCLUDE_KEY = "$include"; +const MAX_INCLUDE_DEPTH = 10; + +export class ConfigIncludeError extends Error { + constructor( + message: string, + public readonly includePath: string, + public readonly cause?: Error, + ) { + super(message); + this.name = "ConfigIncludeError"; + } +} + +export class CircularIncludeError extends ConfigIncludeError { + constructor( + public readonly chain: string[], + ) { + super( + `Circular include detected: ${chain.join(" -> ")}`, + chain[chain.length - 1], + ); + this.name = "CircularIncludeError"; + } +} + +type IncludeContext = { + basePath: string; + visited: Set; + depth: number; + fsModule: typeof fs; + json5Module: typeof JSON5; + logger: Pick; +}; + +function isPlainObject(value: unknown): value is Record { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.prototype.toString.call(value) === "[object Object]" + ); +} + +function deepMerge(target: unknown, source: unknown): unknown { + if (Array.isArray(target) && Array.isArray(source)) { + return [...target, ...source]; + } + if (isPlainObject(target) && isPlainObject(source)) { + const result: Record = { ...target }; + for (const key of Object.keys(source)) { + if (key in result) { + result[key] = deepMerge(result[key], source[key]); + } else { + result[key] = source[key]; + } + } + return result; + } + return source; +} + +function resolveIncludePath(includePath: string, basePath: string): string { + if (path.isAbsolute(includePath)) { + return includePath; + } + const baseDir = path.dirname(basePath); + return path.resolve(baseDir, includePath); +} + +function loadIncludeFile( + includePath: string, + ctx: IncludeContext, +): unknown { + const resolvedPath = resolveIncludePath(includePath, ctx.basePath); + const normalizedPath = path.normalize(resolvedPath); + + // Check for circular includes + if (ctx.visited.has(normalizedPath)) { + throw new CircularIncludeError([...ctx.visited, normalizedPath]); + } + + // Check depth limit + if (ctx.depth >= MAX_INCLUDE_DEPTH) { + throw new ConfigIncludeError( + `Maximum include depth (${MAX_INCLUDE_DEPTH}) exceeded at: ${includePath}`, + includePath, + ); + } + + // Read and parse the file + let raw: string; + try { + raw = ctx.fsModule.readFileSync(normalizedPath, "utf-8"); + } catch (err) { + throw new ConfigIncludeError( + `Failed to read include file: ${includePath} (resolved: ${normalizedPath})`, + includePath, + err instanceof Error ? err : undefined, + ); + } + + let parsed: unknown; + try { + parsed = ctx.json5Module.parse(raw); + } catch (err) { + throw new ConfigIncludeError( + `Failed to parse include file: ${includePath} (resolved: ${normalizedPath})`, + includePath, + err instanceof Error ? err : undefined, + ); + } + + // Recursively resolve includes in the loaded file + const newCtx: IncludeContext = { + ...ctx, + basePath: normalizedPath, + visited: new Set([...ctx.visited, normalizedPath]), + depth: ctx.depth + 1, + }; + + return resolveIncludes(parsed, newCtx); +} + +function resolveIncludeDirective( + includeValue: unknown, + ctx: IncludeContext, +): unknown { + if (typeof includeValue === "string") { + // Single file include + return loadIncludeFile(includeValue, ctx); + } + + if (Array.isArray(includeValue)) { + // Multiple files - deep merge them + let result: unknown = {}; + for (const item of includeValue) { + if (typeof item !== "string") { + throw new ConfigIncludeError( + `Invalid $include array item: expected string, got ${typeof item}`, + String(item), + ); + } + const loaded = loadIncludeFile(item, ctx); + result = deepMerge(result, loaded); + } + return result; + } + + throw new ConfigIncludeError( + `Invalid $include value: expected string or array of strings, got ${typeof includeValue}`, + String(includeValue), + ); +} + +/** + * Recursively resolves $include directives in the config object. + * + * Supports: + * - `{ "$include": "./path/to/file.json5" }` - replaces object with file contents + * - `{ "$include": ["./a.json5", "./b.json5"] }` - deep merges multiple files + * - Nested includes up to MAX_INCLUDE_DEPTH levels + * + * @example + * ```json5 + * // clawdbot.json + * { + * gateway: { port: 18789 }, + * agents: { "$include": "./agents.json5" }, + * broadcast: { "$include": ["./clients/a.json5", "./clients/b.json5"] } + * } + * ``` + */ +export function resolveIncludes( + obj: unknown, + ctx: IncludeContext, +): unknown { + if (Array.isArray(obj)) { + return obj.map((item) => resolveIncludes(item, ctx)); + } + + if (isPlainObject(obj)) { + // Check if this object is an include directive + if (INCLUDE_KEY in obj) { + const includeValue = obj[INCLUDE_KEY]; + const otherKeys = Object.keys(obj).filter((k) => k !== INCLUDE_KEY); + + if (otherKeys.length > 0) { + // Has other keys besides $include - merge include result with them + const included = resolveIncludeDirective(includeValue, ctx); + const rest: Record = {}; + for (const key of otherKeys) { + rest[key] = resolveIncludes(obj[key], ctx); + } + return deepMerge(included, rest); + } + + // Pure include directive + return resolveIncludeDirective(includeValue, ctx); + } + + // Regular object - recurse into properties + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + result[key] = resolveIncludes(value, ctx); + } + return result; + } + + // Primitives pass through unchanged + return obj; +} + +/** + * Creates an include context for resolving $include directives. + */ +function createIncludeContext( + configPath: string, + deps: Required, +): IncludeContext { + return { + basePath: configPath, + visited: new Set([path.normalize(configPath)]), + depth: 0, + fsModule: deps.fs, + json5Module: deps.json5, + logger: deps.logger, + }; +} + export function createConfigIO(overrides: ConfigIoDeps = {}) { const deps = normalizeDeps(overrides); const configPath = resolveConfigPathForDeps(deps); @@ -148,9 +383,14 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { } const raw = deps.fs.readFileSync(configPath, "utf-8"); const parsed = deps.json5.parse(raw); - warnOnConfigMiskeys(parsed, deps.logger); - if (typeof parsed !== "object" || parsed === null) return {}; - const validated = ClawdbotSchema.safeParse(parsed); + + // Resolve $include directives before validation + const includeCtx = createIncludeContext(configPath, deps); + const resolved = resolveIncludes(parsed, includeCtx); + + warnOnConfigMiskeys(resolved, deps.logger); + if (typeof resolved !== "object" || resolved === null) return {}; + const validated = ClawdbotSchema.safeParse(resolved); if (!validated.success) { deps.logger.error("Invalid config:"); for (const iss of validated.error.issues) { @@ -245,9 +485,31 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { }; } - const legacyIssues = findLegacyConfigIssues(parsedRes.parsed); + // Resolve $include directives + let resolved: unknown; + try { + const includeCtx = createIncludeContext(configPath, deps); + resolved = resolveIncludes(parsedRes.parsed, includeCtx); + } catch (err) { + const message = + err instanceof ConfigIncludeError + ? err.message + : `Include resolution failed: ${String(err)}`; + return { + path: configPath, + exists: true, + raw, + parsed: parsedRes.parsed, + valid: false, + config: {}, + issues: [{ path: "", message }], + legacyIssues: [], + }; + } - const validated = validateConfigObject(parsedRes.parsed); + const legacyIssues = findLegacyConfigIssues(resolved); + + const validated = validateConfigObject(resolved); if (!validated.ok) { return { path: configPath, From e6400b0b0f7ec6995a75047b6b09d015b663ba48 Mon Sep 17 00:00:00 2001 From: sheeek Date: Sun, 11 Jan 2026 15:33:43 +0100 Subject: [PATCH 2/4] refactor(config): extract includes logic to separate module - Move $include resolution to src/config/includes.ts - Simplify io.ts by importing from includes module - Cleaner API: resolveConfigIncludes(obj, configPath, resolver?) - Re-export errors from io.ts for backwards compatibility - Rename test file to match module name --- .../{io-includes.test.ts => includes.test.ts} | 200 ++++++-------- src/config/includes.ts | 246 +++++++++++++++++ src/config/io.ts | 256 ++---------------- 3 files changed, 343 insertions(+), 359 deletions(-) rename src/config/{io-includes.test.ts => includes.test.ts} (54%) create mode 100644 src/config/includes.ts diff --git a/src/config/io-includes.test.ts b/src/config/includes.test.ts similarity index 54% rename from src/config/io-includes.test.ts rename to src/config/includes.test.ts index ad77d2570..44a16758d 100644 --- a/src/config/io-includes.test.ts +++ b/src/config/includes.test.ts @@ -3,13 +3,10 @@ import { describe, expect, it } from "vitest"; import { CircularIncludeError, ConfigIncludeError, - resolveIncludes, -} from "./io.js"; + resolveConfigIncludes, +} from "./includes.js"; -function createMockContext( - files: Record, - basePath = "/config/clawdbot.json", -) { +function createMockResolver(files: Record) { const fsModule = { readFileSync: (filePath: string) => { if (filePath in files) { @@ -25,63 +22,57 @@ function createMockContext( parse: JSON.parse, } as typeof import("json5"); - return { - basePath, - visited: new Set([basePath]), - depth: 0, - fsModule, - json5Module, - logger: { error: () => {}, warn: () => {} }, - }; + return { fsModule, json5Module }; } -describe("resolveIncludes", () => { +function resolve( + obj: unknown, + files: Record = {}, + basePath = "/config/clawdbot.json", +) { + return resolveConfigIncludes(obj, basePath, createMockResolver(files)); +} + +describe("resolveConfigIncludes", () => { it("passes through primitives unchanged", () => { - const ctx = createMockContext({}); - expect(resolveIncludes("hello", ctx)).toBe("hello"); - expect(resolveIncludes(42, ctx)).toBe(42); - expect(resolveIncludes(true, ctx)).toBe(true); - expect(resolveIncludes(null, ctx)).toBe(null); + expect(resolve("hello")).toBe("hello"); + expect(resolve(42)).toBe(42); + expect(resolve(true)).toBe(true); + expect(resolve(null)).toBe(null); }); it("passes through arrays with recursion", () => { - const ctx = createMockContext({}); - expect(resolveIncludes([1, 2, { a: 1 }], ctx)).toEqual([1, 2, { a: 1 }]); + expect(resolve([1, 2, { a: 1 }])).toEqual([1, 2, { a: 1 }]); }); it("passes through objects without $include", () => { - const ctx = createMockContext({}); const obj = { foo: "bar", nested: { x: 1 } }; - expect(resolveIncludes(obj, ctx)).toEqual(obj); + expect(resolve(obj)).toEqual(obj); }); it("resolves single file $include", () => { - const ctx = createMockContext({ - "/config/agents.json": { list: [{ id: "main" }] }, - }); + const files = { "/config/agents.json": { list: [{ id: "main" }] } }; const obj = { agents: { $include: "./agents.json" } }; - expect(resolveIncludes(obj, ctx)).toEqual({ + expect(resolve(obj, files)).toEqual({ agents: { list: [{ id: "main" }] }, }); }); it("resolves absolute path $include", () => { - const ctx = createMockContext({ - "/etc/clawdbot/agents.json": { list: [{ id: "main" }] }, - }); + const files = { "/etc/clawdbot/agents.json": { list: [{ id: "main" }] } }; const obj = { agents: { $include: "/etc/clawdbot/agents.json" } }; - expect(resolveIncludes(obj, ctx)).toEqual({ + expect(resolve(obj, files)).toEqual({ agents: { list: [{ id: "main" }] }, }); }); it("resolves array $include with deep merge", () => { - const ctx = createMockContext({ + const files = { "/config/a.json": { "group-a": ["agent1"] }, "/config/b.json": { "group-b": ["agent2"] }, - }); + }; const obj = { broadcast: { $include: ["./a.json", "./b.json"] } }; - expect(resolveIncludes(obj, ctx)).toEqual({ + expect(resolve(obj, files)).toEqual({ broadcast: { "group-a": ["agent1"], "group-b": ["agent2"], @@ -90,12 +81,12 @@ describe("resolveIncludes", () => { }); it("deep merges overlapping keys in array $include", () => { - const ctx = createMockContext({ + const files = { "/config/a.json": { agents: { defaults: { workspace: "~/a" } } }, "/config/b.json": { agents: { list: [{ id: "main" }] } }, - }); + }; const obj = { $include: ["./a.json", "./b.json"] }; - expect(resolveIncludes(obj, ctx)).toEqual({ + expect(resolve(obj, files)).toEqual({ agents: { defaults: { workspace: "~/a" }, list: [{ id: "main" }], @@ -104,144 +95,111 @@ describe("resolveIncludes", () => { }); it("merges $include with sibling keys", () => { - const ctx = createMockContext({ - "/config/base.json": { a: 1, b: 2 }, - }); + const files = { "/config/base.json": { a: 1, b: 2 } }; const obj = { $include: "./base.json", c: 3 }; - expect(resolveIncludes(obj, ctx)).toEqual({ a: 1, b: 2, c: 3 }); + expect(resolve(obj, files)).toEqual({ a: 1, b: 2, c: 3 }); }); it("sibling keys override included values", () => { - const ctx = createMockContext({ - "/config/base.json": { a: 1, b: 2 }, - }); + const files = { "/config/base.json": { a: 1, b: 2 } }; const obj = { $include: "./base.json", b: 99 }; - expect(resolveIncludes(obj, ctx)).toEqual({ a: 1, b: 99 }); + expect(resolve(obj, files)).toEqual({ a: 1, b: 99 }); }); it("resolves nested includes", () => { - const ctx = createMockContext({ + const files = { "/config/level1.json": { nested: { $include: "./level2.json" } }, "/config/level2.json": { deep: "value" }, - }); + }; const obj = { $include: "./level1.json" }; - expect(resolveIncludes(obj, ctx)).toEqual({ + expect(resolve(obj, files)).toEqual({ nested: { deep: "value" }, }); }); it("throws ConfigIncludeError for missing file", () => { - const ctx = createMockContext({}); const obj = { $include: "./missing.json" }; - expect(() => resolveIncludes(obj, ctx)).toThrow(ConfigIncludeError); - expect(() => resolveIncludes(obj, ctx)).toThrow(/Failed to read include file/); + expect(() => resolve(obj)).toThrow(ConfigIncludeError); + expect(() => resolve(obj)).toThrow(/Failed to read include file/); }); it("throws ConfigIncludeError for invalid JSON", () => { - const fsModule = { - readFileSync: () => "{ invalid json }", - } as typeof import("node:fs"); - const json5Module = { - parse: JSON.parse, - } as typeof import("json5"); - const ctx = { - basePath: "/config/clawdbot.json", - visited: new Set(["/config/clawdbot.json"]), - depth: 0, - fsModule, - json5Module, - logger: { error: () => {}, warn: () => {} }, + const resolver = { + fsModule: { readFileSync: () => "{ invalid json }" } as typeof import("node:fs"), + json5Module: { parse: JSON.parse } as typeof import("json5"), }; const obj = { $include: "./bad.json" }; - expect(() => resolveIncludes(obj, ctx)).toThrow(ConfigIncludeError); - expect(() => resolveIncludes(obj, ctx)).toThrow(/Failed to parse include file/); + expect(() => resolveConfigIncludes(obj, "/config/clawdbot.json", resolver)) + .toThrow(ConfigIncludeError); + expect(() => resolveConfigIncludes(obj, "/config/clawdbot.json", resolver)) + .toThrow(/Failed to parse include file/); }); it("throws CircularIncludeError for circular includes", () => { - // Create a mock that simulates circular includes - const fsModule = { - readFileSync: (filePath: string) => { - if (filePath === "/config/a.json") { - return JSON.stringify({ $include: "./b.json" }); - } - if (filePath === "/config/b.json") { - return JSON.stringify({ $include: "./a.json" }); - } - throw new Error(`Unknown file: ${filePath}`); - }, - } as typeof import("node:fs"); - const json5Module = { parse: JSON.parse } as typeof import("json5"); - const ctx = { - basePath: "/config/clawdbot.json", - visited: new Set(["/config/clawdbot.json"]), - depth: 0, - fsModule, - json5Module, - logger: { error: () => {}, warn: () => {} }, + const resolver = { + fsModule: { + readFileSync: (filePath: string) => { + if (filePath === "/config/a.json") { + return JSON.stringify({ $include: "./b.json" }); + } + if (filePath === "/config/b.json") { + return JSON.stringify({ $include: "./a.json" }); + } + throw new Error(`Unknown file: ${filePath}`); + }, + } as typeof import("node:fs"), + json5Module: { parse: JSON.parse } as typeof import("json5"), }; const obj = { $include: "./a.json" }; - expect(() => resolveIncludes(obj, ctx)).toThrow(CircularIncludeError); - expect(() => resolveIncludes(obj, ctx)).toThrow(/Circular include detected/); + expect(() => resolveConfigIncludes(obj, "/config/clawdbot.json", resolver)) + .toThrow(CircularIncludeError); + expect(() => resolveConfigIncludes(obj, "/config/clawdbot.json", resolver)) + .toThrow(/Circular include detected/); }); it("throws ConfigIncludeError for invalid $include value type", () => { - const ctx = createMockContext({}); const obj = { $include: 123 }; - expect(() => resolveIncludes(obj, ctx)).toThrow(ConfigIncludeError); - expect(() => resolveIncludes(obj, ctx)).toThrow(/expected string or array/); + expect(() => resolve(obj)).toThrow(ConfigIncludeError); + expect(() => resolve(obj)).toThrow(/expected string or array/); }); it("throws ConfigIncludeError for invalid array item type", () => { - const ctx = createMockContext({ - "/config/valid.json": { valid: true }, - }); + const files = { "/config/valid.json": { valid: true } }; const obj = { $include: ["./valid.json", 123] }; - expect(() => resolveIncludes(obj, ctx)).toThrow(ConfigIncludeError); - expect(() => resolveIncludes(obj, ctx)).toThrow(/expected string, got number/); + expect(() => resolve(obj, files)).toThrow(ConfigIncludeError); + expect(() => resolve(obj, files)).toThrow(/expected string, got number/); }); it("respects max depth limit", () => { - // Create deeply nested includes const files: Record = {}; for (let i = 0; i < 15; i++) { files[`/config/level${i}.json`] = { $include: `./level${i + 1}.json` }; } files["/config/level15.json"] = { done: true }; - const ctx = createMockContext(files); const obj = { $include: "./level0.json" }; - expect(() => resolveIncludes(obj, ctx)).toThrow(ConfigIncludeError); - expect(() => resolveIncludes(obj, ctx)).toThrow(/Maximum include depth/); + expect(() => resolve(obj, files)).toThrow(ConfigIncludeError); + expect(() => resolve(obj, files)).toThrow(/Maximum include depth/); }); it("handles relative paths correctly", () => { - const ctx = createMockContext( - { - "/config/clients/mueller/agents.json": { id: "mueller" }, - }, - "/config/clawdbot.json", - ); + const files = { "/config/clients/mueller/agents.json": { id: "mueller" } }; const obj = { agent: { $include: "./clients/mueller/agents.json" } }; - expect(resolveIncludes(obj, ctx)).toEqual({ + expect(resolve(obj, files)).toEqual({ agent: { id: "mueller" }, }); }); it("resolves parent directory references", () => { - const ctx = createMockContext( - { - "/shared/common.json": { shared: true }, - }, - "/config/sub/clawdbot.json", - ); + const files = { "/shared/common.json": { shared: true } }; const obj = { $include: "../../shared/common.json" }; - expect(resolveIncludes(obj, ctx)).toEqual({ shared: true }); + expect(resolve(obj, files, "/config/sub/clawdbot.json")).toEqual({ shared: true }); }); }); describe("real-world config patterns", () => { it("supports per-client agent includes", () => { - const ctx = createMockContext({ + const files = { "/config/clients/mueller.json": { agents: [ { id: "mueller-screenshot", workspace: "~/clients/mueller/screenshot" }, @@ -255,14 +213,14 @@ describe("real-world config patterns", () => { ], broadcast: { "group-schmidt": ["schmidt-screenshot"] }, }, - }); + }; const obj = { gateway: { port: 18789 }, $include: ["./clients/mueller.json", "./clients/schmidt.json"], }; - expect(resolveIncludes(obj, ctx)).toEqual({ + expect(resolve(obj, files)).toEqual({ gateway: { port: 18789 }, agents: [ { id: "mueller-screenshot", workspace: "~/clients/mueller/screenshot" }, @@ -277,7 +235,7 @@ describe("real-world config patterns", () => { }); it("supports modular config structure", () => { - const ctx = createMockContext({ + const files = { "/config/gateway.json": { gateway: { port: 18789, bind: "loopback" } }, "/config/providers/whatsapp.json": { whatsapp: { dmPolicy: "pairing", allowFrom: ["+49123"] }, @@ -285,7 +243,7 @@ describe("real-world config patterns", () => { "/config/agents/defaults.json": { agents: { defaults: { sandbox: { mode: "all" } } }, }, - }); + }; const obj = { $include: [ @@ -295,7 +253,7 @@ describe("real-world config patterns", () => { ], }; - expect(resolveIncludes(obj, ctx)).toEqual({ + expect(resolve(obj, files)).toEqual({ gateway: { port: 18789, bind: "loopback" }, whatsapp: { dmPolicy: "pairing", allowFrom: ["+49123"] }, agents: { defaults: { sandbox: { mode: "all" } } }, diff --git a/src/config/includes.ts b/src/config/includes.ts new file mode 100644 index 000000000..d7ade4c42 --- /dev/null +++ b/src/config/includes.ts @@ -0,0 +1,246 @@ +/** + * Config includes: $include directive for modular configs + * + * Supports: + * - `{ "$include": "./path/to/file.json5" }` - single file include + * - `{ "$include": ["./a.json5", "./b.json5"] }` - deep merge multiple files + * - Nested includes up to MAX_INCLUDE_DEPTH levels + * - Circular include detection + */ + +import fs from "node:fs"; +import path from "node:path"; + +import JSON5 from "json5"; + +// ============================================================================ +// Constants +// ============================================================================ + +export const INCLUDE_KEY = "$include"; +export const MAX_INCLUDE_DEPTH = 10; + +// ============================================================================ +// Types +// ============================================================================ + +export type IncludeResolver = { + fsModule: typeof fs; + json5Module: typeof JSON5; +}; + +type IncludeContext = { + basePath: string; + visited: Set; + depth: number; + resolver: IncludeResolver; +}; + +// ============================================================================ +// Errors +// ============================================================================ + +export class ConfigIncludeError extends Error { + constructor( + message: string, + public readonly includePath: string, + public readonly cause?: Error, + ) { + super(message); + this.name = "ConfigIncludeError"; + } +} + +export class CircularIncludeError extends ConfigIncludeError { + constructor(public readonly chain: string[]) { + super( + `Circular include detected: ${chain.join(" -> ")}`, + chain[chain.length - 1], + ); + this.name = "CircularIncludeError"; + } +} + +// ============================================================================ +// Utilities +// ============================================================================ + +function isPlainObject(value: unknown): value is Record { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.prototype.toString.call(value) === "[object Object]" + ); +} + +/** + * Deep merge two values. + * - Arrays: concatenate + * - Objects: recursive merge + * - Primitives: source wins + */ +export function deepMerge(target: unknown, source: unknown): unknown { + if (Array.isArray(target) && Array.isArray(source)) { + return [...target, ...source]; + } + if (isPlainObject(target) && isPlainObject(source)) { + const result: Record = { ...target }; + for (const key of Object.keys(source)) { + result[key] = + key in result ? deepMerge(result[key], source[key]) : source[key]; + } + return result; + } + return source; +} + +function resolveIncludePath(includePath: string, basePath: string): string { + if (path.isAbsolute(includePath)) { + return includePath; + } + return path.resolve(path.dirname(basePath), includePath); +} + +// ============================================================================ +// Core Logic +// ============================================================================ + +function loadIncludeFile(includePath: string, ctx: IncludeContext): unknown { + const resolvedPath = resolveIncludePath(includePath, ctx.basePath); + const normalizedPath = path.normalize(resolvedPath); + + if (ctx.visited.has(normalizedPath)) { + throw new CircularIncludeError([...ctx.visited, normalizedPath]); + } + + if (ctx.depth >= MAX_INCLUDE_DEPTH) { + throw new ConfigIncludeError( + `Maximum include depth (${MAX_INCLUDE_DEPTH}) exceeded at: ${includePath}`, + includePath, + ); + } + + let raw: string; + try { + raw = ctx.resolver.fsModule.readFileSync(normalizedPath, "utf-8"); + } catch (err) { + throw new ConfigIncludeError( + `Failed to read include file: ${includePath} (resolved: ${normalizedPath})`, + includePath, + err instanceof Error ? err : undefined, + ); + } + + let parsed: unknown; + try { + parsed = ctx.resolver.json5Module.parse(raw); + } catch (err) { + throw new ConfigIncludeError( + `Failed to parse include file: ${includePath} (resolved: ${normalizedPath})`, + includePath, + err instanceof Error ? err : undefined, + ); + } + + const newCtx: IncludeContext = { + ...ctx, + basePath: normalizedPath, + visited: new Set([...ctx.visited, normalizedPath]), + depth: ctx.depth + 1, + }; + + return resolveIncludesInternal(parsed, newCtx); +} + +function resolveIncludeDirective( + includeValue: unknown, + ctx: IncludeContext, +): unknown { + if (typeof includeValue === "string") { + return loadIncludeFile(includeValue, ctx); + } + + if (Array.isArray(includeValue)) { + let result: unknown = {}; + for (const item of includeValue) { + if (typeof item !== "string") { + throw new ConfigIncludeError( + `Invalid $include array item: expected string, got ${typeof item}`, + String(item), + ); + } + result = deepMerge(result, loadIncludeFile(item, ctx)); + } + return result; + } + + throw new ConfigIncludeError( + `Invalid $include value: expected string or array of strings, got ${typeof includeValue}`, + String(includeValue), + ); +} + +function resolveIncludesInternal(obj: unknown, ctx: IncludeContext): unknown { + if (Array.isArray(obj)) { + return obj.map((item) => resolveIncludesInternal(item, ctx)); + } + + if (isPlainObject(obj)) { + if (INCLUDE_KEY in obj) { + const includeValue = obj[INCLUDE_KEY]; + const otherKeys = Object.keys(obj).filter((k) => k !== INCLUDE_KEY); + + if (otherKeys.length > 0) { + const included = resolveIncludeDirective(includeValue, ctx); + const rest: Record = {}; + for (const key of otherKeys) { + rest[key] = resolveIncludesInternal(obj[key], ctx); + } + return deepMerge(included, rest); + } + + return resolveIncludeDirective(includeValue, ctx); + } + + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + result[key] = resolveIncludesInternal(value, ctx); + } + return result; + } + + return obj; +} + +// ============================================================================ +// Public API +// ============================================================================ + +/** + * Resolves all $include directives in a parsed config object. + * + * @param obj - Parsed config object (from JSON5.parse) + * @param configPath - Path to the main config file (for relative path resolution) + * @param resolver - Optional custom fs/json5 modules (for testing) + * @returns Config object with all includes resolved + * + * @example + * ```typescript + * const parsed = JSON5.parse(raw); + * const resolved = resolveConfigIncludes(parsed, "/path/to/config.json5"); + * ``` + */ +export function resolveConfigIncludes( + obj: unknown, + configPath: string, + resolver: IncludeResolver = { fsModule: fs, json5Module: JSON5 }, +): unknown { + const ctx: IncludeContext = { + basePath: configPath, + visited: new Set([path.normalize(configPath)]), + depth: 0, + resolver, + }; + return resolveIncludesInternal(obj, ctx); +} diff --git a/src/config/io.ts b/src/config/io.ts index 8a68c8ae8..3d7326f8f 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -22,6 +22,10 @@ import { applySessionDefaults, applyTalkApiKey, } from "./defaults.js"; +import { + ConfigIncludeError, + resolveConfigIncludes, +} from "./includes.js"; import { findLegacyConfigIssues } from "./legacy.js"; import { resolveConfigPath, resolveStateDir } from "./paths.js"; import { applyConfigOverrides } from "./runtime-overrides.js"; @@ -33,6 +37,12 @@ import type { import { validateConfigObject } from "./validation.js"; import { ClawdbotSchema } from "./zod-schema.js"; +// Re-export for backwards compatibility +export { + CircularIncludeError, + ConfigIncludeError, +} from "./includes.js"; + const SHELL_ENV_EXPECTED_KEYS = [ "OPENAI_API_KEY", "ANTHROPIC_API_KEY", @@ -129,240 +139,6 @@ export function parseConfigJson5( } } -// ============================================================================ -// Config Includes ($include directive) -// ============================================================================ - -const INCLUDE_KEY = "$include"; -const MAX_INCLUDE_DEPTH = 10; - -export class ConfigIncludeError extends Error { - constructor( - message: string, - public readonly includePath: string, - public readonly cause?: Error, - ) { - super(message); - this.name = "ConfigIncludeError"; - } -} - -export class CircularIncludeError extends ConfigIncludeError { - constructor( - public readonly chain: string[], - ) { - super( - `Circular include detected: ${chain.join(" -> ")}`, - chain[chain.length - 1], - ); - this.name = "CircularIncludeError"; - } -} - -type IncludeContext = { - basePath: string; - visited: Set; - depth: number; - fsModule: typeof fs; - json5Module: typeof JSON5; - logger: Pick; -}; - -function isPlainObject(value: unknown): value is Record { - return ( - typeof value === "object" && - value !== null && - !Array.isArray(value) && - Object.prototype.toString.call(value) === "[object Object]" - ); -} - -function deepMerge(target: unknown, source: unknown): unknown { - if (Array.isArray(target) && Array.isArray(source)) { - return [...target, ...source]; - } - if (isPlainObject(target) && isPlainObject(source)) { - const result: Record = { ...target }; - for (const key of Object.keys(source)) { - if (key in result) { - result[key] = deepMerge(result[key], source[key]); - } else { - result[key] = source[key]; - } - } - return result; - } - return source; -} - -function resolveIncludePath(includePath: string, basePath: string): string { - if (path.isAbsolute(includePath)) { - return includePath; - } - const baseDir = path.dirname(basePath); - return path.resolve(baseDir, includePath); -} - -function loadIncludeFile( - includePath: string, - ctx: IncludeContext, -): unknown { - const resolvedPath = resolveIncludePath(includePath, ctx.basePath); - const normalizedPath = path.normalize(resolvedPath); - - // Check for circular includes - if (ctx.visited.has(normalizedPath)) { - throw new CircularIncludeError([...ctx.visited, normalizedPath]); - } - - // Check depth limit - if (ctx.depth >= MAX_INCLUDE_DEPTH) { - throw new ConfigIncludeError( - `Maximum include depth (${MAX_INCLUDE_DEPTH}) exceeded at: ${includePath}`, - includePath, - ); - } - - // Read and parse the file - let raw: string; - try { - raw = ctx.fsModule.readFileSync(normalizedPath, "utf-8"); - } catch (err) { - throw new ConfigIncludeError( - `Failed to read include file: ${includePath} (resolved: ${normalizedPath})`, - includePath, - err instanceof Error ? err : undefined, - ); - } - - let parsed: unknown; - try { - parsed = ctx.json5Module.parse(raw); - } catch (err) { - throw new ConfigIncludeError( - `Failed to parse include file: ${includePath} (resolved: ${normalizedPath})`, - includePath, - err instanceof Error ? err : undefined, - ); - } - - // Recursively resolve includes in the loaded file - const newCtx: IncludeContext = { - ...ctx, - basePath: normalizedPath, - visited: new Set([...ctx.visited, normalizedPath]), - depth: ctx.depth + 1, - }; - - return resolveIncludes(parsed, newCtx); -} - -function resolveIncludeDirective( - includeValue: unknown, - ctx: IncludeContext, -): unknown { - if (typeof includeValue === "string") { - // Single file include - return loadIncludeFile(includeValue, ctx); - } - - if (Array.isArray(includeValue)) { - // Multiple files - deep merge them - let result: unknown = {}; - for (const item of includeValue) { - if (typeof item !== "string") { - throw new ConfigIncludeError( - `Invalid $include array item: expected string, got ${typeof item}`, - String(item), - ); - } - const loaded = loadIncludeFile(item, ctx); - result = deepMerge(result, loaded); - } - return result; - } - - throw new ConfigIncludeError( - `Invalid $include value: expected string or array of strings, got ${typeof includeValue}`, - String(includeValue), - ); -} - -/** - * Recursively resolves $include directives in the config object. - * - * Supports: - * - `{ "$include": "./path/to/file.json5" }` - replaces object with file contents - * - `{ "$include": ["./a.json5", "./b.json5"] }` - deep merges multiple files - * - Nested includes up to MAX_INCLUDE_DEPTH levels - * - * @example - * ```json5 - * // clawdbot.json - * { - * gateway: { port: 18789 }, - * agents: { "$include": "./agents.json5" }, - * broadcast: { "$include": ["./clients/a.json5", "./clients/b.json5"] } - * } - * ``` - */ -export function resolveIncludes( - obj: unknown, - ctx: IncludeContext, -): unknown { - if (Array.isArray(obj)) { - return obj.map((item) => resolveIncludes(item, ctx)); - } - - if (isPlainObject(obj)) { - // Check if this object is an include directive - if (INCLUDE_KEY in obj) { - const includeValue = obj[INCLUDE_KEY]; - const otherKeys = Object.keys(obj).filter((k) => k !== INCLUDE_KEY); - - if (otherKeys.length > 0) { - // Has other keys besides $include - merge include result with them - const included = resolveIncludeDirective(includeValue, ctx); - const rest: Record = {}; - for (const key of otherKeys) { - rest[key] = resolveIncludes(obj[key], ctx); - } - return deepMerge(included, rest); - } - - // Pure include directive - return resolveIncludeDirective(includeValue, ctx); - } - - // Regular object - recurse into properties - const result: Record = {}; - for (const [key, value] of Object.entries(obj)) { - result[key] = resolveIncludes(value, ctx); - } - return result; - } - - // Primitives pass through unchanged - return obj; -} - -/** - * Creates an include context for resolving $include directives. - */ -function createIncludeContext( - configPath: string, - deps: Required, -): IncludeContext { - return { - basePath: configPath, - visited: new Set([path.normalize(configPath)]), - depth: 0, - fsModule: deps.fs, - json5Module: deps.json5, - logger: deps.logger, - }; -} - export function createConfigIO(overrides: ConfigIoDeps = {}) { const deps = normalizeDeps(overrides); const configPath = resolveConfigPathForDeps(deps); @@ -385,8 +161,10 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { const parsed = deps.json5.parse(raw); // Resolve $include directives before validation - const includeCtx = createIncludeContext(configPath, deps); - const resolved = resolveIncludes(parsed, includeCtx); + const resolved = resolveConfigIncludes(parsed, configPath, { + fsModule: deps.fs, + json5Module: deps.json5, + }); warnOnConfigMiskeys(resolved, deps.logger); if (typeof resolved !== "object" || resolved === null) return {}; @@ -488,8 +266,10 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { // Resolve $include directives let resolved: unknown; try { - const includeCtx = createIncludeContext(configPath, deps); - resolved = resolveIncludes(parsedRes.parsed, includeCtx); + resolved = resolveConfigIncludes(parsedRes.parsed, configPath, { + fsModule: deps.fs, + json5Module: deps.json5, + }); } catch (err) { const message = err instanceof ConfigIncludeError From 53d3134fe89037074ca74bfbf2ba9309507cb275 Mon Sep 17 00:00:00 2001 From: sheeek Date: Sun, 11 Jan 2026 15:48:40 +0100 Subject: [PATCH 3/4] refactor(config): simplify includes with class-based processor - Replace free functions with IncludeProcessor class - Simplify IncludeResolver interface: { readFile, parseJson } - Break down loadFile into focused private methods - Use reduce() for array include merging - Cleaner separation of concerns --- src/config/includes.test.ts | 50 +++--- src/config/includes.ts | 296 ++++++++++++++++++------------------ src/config/io.ts | 8 +- 3 files changed, 174 insertions(+), 180 deletions(-) diff --git a/src/config/includes.test.ts b/src/config/includes.test.ts index 44a16758d..fb78d23ea 100644 --- a/src/config/includes.test.ts +++ b/src/config/includes.test.ts @@ -3,26 +3,20 @@ import { describe, expect, it } from "vitest"; import { CircularIncludeError, ConfigIncludeError, + type IncludeResolver, resolveConfigIncludes, } from "./includes.js"; -function createMockResolver(files: Record) { - const fsModule = { - readFileSync: (filePath: string) => { +function createMockResolver(files: Record): IncludeResolver { + return { + readFile: (filePath: string) => { if (filePath in files) { return JSON.stringify(files[filePath]); } - const err = new Error(`ENOENT: no such file: ${filePath}`); - (err as NodeJS.ErrnoException).code = "ENOENT"; - throw err; + throw new Error(`ENOENT: no such file: ${filePath}`); }, - } as typeof import("node:fs"); - - const json5Module = { - parse: JSON.parse, - } as typeof import("json5"); - - return { fsModule, json5Module }; + parseJson: JSON.parse, + }; } function resolve( @@ -124,9 +118,9 @@ describe("resolveConfigIncludes", () => { }); it("throws ConfigIncludeError for invalid JSON", () => { - const resolver = { - fsModule: { readFileSync: () => "{ invalid json }" } as typeof import("node:fs"), - json5Module: { parse: JSON.parse } as typeof import("json5"), + const resolver: IncludeResolver = { + readFile: () => "{ invalid json }", + parseJson: JSON.parse, }; const obj = { $include: "./bad.json" }; expect(() => resolveConfigIncludes(obj, "/config/clawdbot.json", resolver)) @@ -136,19 +130,17 @@ describe("resolveConfigIncludes", () => { }); it("throws CircularIncludeError for circular includes", () => { - const resolver = { - fsModule: { - readFileSync: (filePath: string) => { - if (filePath === "/config/a.json") { - return JSON.stringify({ $include: "./b.json" }); - } - if (filePath === "/config/b.json") { - return JSON.stringify({ $include: "./a.json" }); - } - throw new Error(`Unknown file: ${filePath}`); - }, - } as typeof import("node:fs"), - json5Module: { parse: JSON.parse } as typeof import("json5"), + const resolver: IncludeResolver = { + readFile: (filePath: string) => { + if (filePath === "/config/a.json") { + return JSON.stringify({ $include: "./b.json" }); + } + if (filePath === "/config/b.json") { + return JSON.stringify({ $include: "./a.json" }); + } + throw new Error(`Unknown file: ${filePath}`); + }, + parseJson: JSON.parse, }; const obj = { $include: "./a.json" }; expect(() => resolveConfigIncludes(obj, "/config/clawdbot.json", resolver)) diff --git a/src/config/includes.ts b/src/config/includes.ts index d7ade4c42..f3a74d332 100644 --- a/src/config/includes.ts +++ b/src/config/includes.ts @@ -1,11 +1,13 @@ /** * Config includes: $include directive for modular configs * - * Supports: - * - `{ "$include": "./path/to/file.json5" }` - single file include - * - `{ "$include": ["./a.json5", "./b.json5"] }` - deep merge multiple files - * - Nested includes up to MAX_INCLUDE_DEPTH levels - * - Circular include detection + * @example + * ```json5 + * { + * "$include": "./base.json5", // single file + * "$include": ["./a.json5", "./b.json5"] // merge multiple + * } + * ``` */ import fs from "node:fs"; @@ -13,10 +15,6 @@ import path from "node:path"; import JSON5 from "json5"; -// ============================================================================ -// Constants -// ============================================================================ - export const INCLUDE_KEY = "$include"; export const MAX_INCLUDE_DEPTH = 10; @@ -25,15 +23,8 @@ export const MAX_INCLUDE_DEPTH = 10; // ============================================================================ export type IncludeResolver = { - fsModule: typeof fs; - json5Module: typeof JSON5; -}; - -type IncludeContext = { - basePath: string; - visited: Set; - depth: number; - resolver: IncludeResolver; + readFile: (path: string) => string; + parseJson: (raw: string) => unknown; }; // ============================================================================ @@ -74,12 +65,7 @@ function isPlainObject(value: unknown): value is Record { ); } -/** - * Deep merge two values. - * - Arrays: concatenate - * - Objects: recursive merge - * - Primitives: source wins - */ +/** Deep merge: arrays concatenate, objects merge recursively, primitives: source wins */ export function deepMerge(target: unknown, source: unknown): unknown { if (Array.isArray(target) && Array.isArray(source)) { return [...target, ...source]; @@ -87,160 +73,176 @@ export function deepMerge(target: unknown, source: unknown): unknown { if (isPlainObject(target) && isPlainObject(source)) { const result: Record = { ...target }; for (const key of Object.keys(source)) { - result[key] = - key in result ? deepMerge(result[key], source[key]) : source[key]; + result[key] = key in result + ? deepMerge(result[key], source[key]) + : source[key]; } return result; } return source; } -function resolveIncludePath(includePath: string, basePath: string): string { - if (path.isAbsolute(includePath)) { - return includePath; - } - return path.resolve(path.dirname(basePath), includePath); -} - // ============================================================================ -// Core Logic +// Include Resolver Class // ============================================================================ -function loadIncludeFile(includePath: string, ctx: IncludeContext): unknown { - const resolvedPath = resolveIncludePath(includePath, ctx.basePath); - const normalizedPath = path.normalize(resolvedPath); +class IncludeProcessor { + private visited = new Set(); + private depth = 0; - if (ctx.visited.has(normalizedPath)) { - throw new CircularIncludeError([...ctx.visited, normalizedPath]); + constructor( + private basePath: string, + private resolver: IncludeResolver, + ) { + this.visited.add(path.normalize(basePath)); } - if (ctx.depth >= MAX_INCLUDE_DEPTH) { - throw new ConfigIncludeError( - `Maximum include depth (${MAX_INCLUDE_DEPTH}) exceeded at: ${includePath}`, - includePath, - ); - } - - let raw: string; - try { - raw = ctx.resolver.fsModule.readFileSync(normalizedPath, "utf-8"); - } catch (err) { - throw new ConfigIncludeError( - `Failed to read include file: ${includePath} (resolved: ${normalizedPath})`, - includePath, - err instanceof Error ? err : undefined, - ); - } - - let parsed: unknown; - try { - parsed = ctx.resolver.json5Module.parse(raw); - } catch (err) { - throw new ConfigIncludeError( - `Failed to parse include file: ${includePath} (resolved: ${normalizedPath})`, - includePath, - err instanceof Error ? err : undefined, - ); - } - - const newCtx: IncludeContext = { - ...ctx, - basePath: normalizedPath, - visited: new Set([...ctx.visited, normalizedPath]), - depth: ctx.depth + 1, - }; - - return resolveIncludesInternal(parsed, newCtx); -} - -function resolveIncludeDirective( - includeValue: unknown, - ctx: IncludeContext, -): unknown { - if (typeof includeValue === "string") { - return loadIncludeFile(includeValue, ctx); - } - - if (Array.isArray(includeValue)) { - let result: unknown = {}; - for (const item of includeValue) { - if (typeof item !== "string") { - throw new ConfigIncludeError( - `Invalid $include array item: expected string, got ${typeof item}`, - String(item), - ); - } - result = deepMerge(result, loadIncludeFile(item, ctx)); - } - return result; - } - - throw new ConfigIncludeError( - `Invalid $include value: expected string or array of strings, got ${typeof includeValue}`, - String(includeValue), - ); -} - -function resolveIncludesInternal(obj: unknown, ctx: IncludeContext): unknown { - if (Array.isArray(obj)) { - return obj.map((item) => resolveIncludesInternal(item, ctx)); - } - - if (isPlainObject(obj)) { - if (INCLUDE_KEY in obj) { - const includeValue = obj[INCLUDE_KEY]; - const otherKeys = Object.keys(obj).filter((k) => k !== INCLUDE_KEY); - - if (otherKeys.length > 0) { - const included = resolveIncludeDirective(includeValue, ctx); - const rest: Record = {}; - for (const key of otherKeys) { - rest[key] = resolveIncludesInternal(obj[key], ctx); - } - return deepMerge(included, rest); - } - - return resolveIncludeDirective(includeValue, ctx); + process(obj: unknown): unknown { + if (Array.isArray(obj)) { + return obj.map((item) => this.process(item)); } + if (!isPlainObject(obj)) { + return obj; + } + + if (!(INCLUDE_KEY in obj)) { + return this.processObject(obj); + } + + return this.processInclude(obj); + } + + private processObject(obj: Record): Record { const result: Record = {}; for (const [key, value] of Object.entries(obj)) { - result[key] = resolveIncludesInternal(value, ctx); + result[key] = this.process(value); } return result; } - return obj; + private processInclude(obj: Record): unknown { + const includeValue = obj[INCLUDE_KEY]; + const otherKeys = Object.keys(obj).filter((k) => k !== INCLUDE_KEY); + const included = this.resolveInclude(includeValue); + + if (otherKeys.length === 0) { + return included; + } + + // Merge included content with sibling keys + const rest: Record = {}; + for (const key of otherKeys) { + rest[key] = this.process(obj[key]); + } + return deepMerge(included, rest); + } + + private resolveInclude(value: unknown): unknown { + if (typeof value === "string") { + return this.loadFile(value); + } + + if (Array.isArray(value)) { + return value.reduce((merged, item) => { + if (typeof item !== "string") { + throw new ConfigIncludeError( + `Invalid $include array item: expected string, got ${typeof item}`, + String(item), + ); + } + return deepMerge(merged, this.loadFile(item)); + }, {}); + } + + throw new ConfigIncludeError( + `Invalid $include value: expected string or array of strings, got ${typeof value}`, + String(value), + ); + } + + private loadFile(includePath: string): unknown { + const resolvedPath = this.resolvePath(includePath); + + this.checkCircular(resolvedPath); + this.checkDepth(includePath); + + const raw = this.readFile(includePath, resolvedPath); + const parsed = this.parseFile(includePath, resolvedPath, raw); + + return this.processNested(resolvedPath, parsed); + } + + private resolvePath(includePath: string): string { + const resolved = path.isAbsolute(includePath) + ? includePath + : path.resolve(path.dirname(this.basePath), includePath); + return path.normalize(resolved); + } + + private checkCircular(resolvedPath: string): void { + if (this.visited.has(resolvedPath)) { + throw new CircularIncludeError([...this.visited, resolvedPath]); + } + } + + private checkDepth(includePath: string): void { + if (this.depth >= MAX_INCLUDE_DEPTH) { + throw new ConfigIncludeError( + `Maximum include depth (${MAX_INCLUDE_DEPTH}) exceeded at: ${includePath}`, + includePath, + ); + } + } + + private readFile(includePath: string, resolvedPath: string): string { + try { + return this.resolver.readFile(resolvedPath); + } catch (err) { + throw new ConfigIncludeError( + `Failed to read include file: ${includePath} (resolved: ${resolvedPath})`, + includePath, + err instanceof Error ? err : undefined, + ); + } + } + + private parseFile(includePath: string, resolvedPath: string, raw: string): unknown { + try { + return this.resolver.parseJson(raw); + } catch (err) { + throw new ConfigIncludeError( + `Failed to parse include file: ${includePath} (resolved: ${resolvedPath})`, + includePath, + err instanceof Error ? err : undefined, + ); + } + } + + private processNested(resolvedPath: string, parsed: unknown): unknown { + const nested = new IncludeProcessor(resolvedPath, this.resolver); + nested.visited = new Set([...this.visited, resolvedPath]); + nested.depth = this.depth + 1; + return nested.process(parsed); + } } // ============================================================================ // Public API // ============================================================================ +const defaultResolver: IncludeResolver = { + readFile: (p) => fs.readFileSync(p, "utf-8"), + parseJson: (raw) => JSON5.parse(raw), +}; + /** * Resolves all $include directives in a parsed config object. - * - * @param obj - Parsed config object (from JSON5.parse) - * @param configPath - Path to the main config file (for relative path resolution) - * @param resolver - Optional custom fs/json5 modules (for testing) - * @returns Config object with all includes resolved - * - * @example - * ```typescript - * const parsed = JSON5.parse(raw); - * const resolved = resolveConfigIncludes(parsed, "/path/to/config.json5"); - * ``` */ export function resolveConfigIncludes( obj: unknown, configPath: string, - resolver: IncludeResolver = { fsModule: fs, json5Module: JSON5 }, + resolver: IncludeResolver = defaultResolver, ): unknown { - const ctx: IncludeContext = { - basePath: configPath, - visited: new Set([path.normalize(configPath)]), - depth: 0, - resolver, - }; - return resolveIncludesInternal(obj, ctx); + return new IncludeProcessor(configPath, resolver).process(obj); } diff --git a/src/config/io.ts b/src/config/io.ts index 3d7326f8f..f05f9f19f 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -162,8 +162,8 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { // Resolve $include directives before validation const resolved = resolveConfigIncludes(parsed, configPath, { - fsModule: deps.fs, - json5Module: deps.json5, + readFile: (p) => deps.fs.readFileSync(p, "utf-8"), + parseJson: (raw) => deps.json5.parse(raw), }); warnOnConfigMiskeys(resolved, deps.logger); @@ -267,8 +267,8 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { let resolved: unknown; try { resolved = resolveConfigIncludes(parsedRes.parsed, configPath, { - fsModule: deps.fs, - json5Module: deps.json5, + readFile: (p) => deps.fs.readFileSync(p, "utf-8"), + parseJson: (raw) => deps.json5.parse(raw), }); } catch (err) { const message = From e3e3498a4b0cc02a803749880231a4ff1ba8736f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 12 Jan 2026 00:12:03 +0000 Subject: [PATCH 4/4] fix: guard config includes (#731) (thanks @pasogott) --- CHANGELOG.md | 1 + docs/gateway/configuration.md | 1 + src/config/includes.test.ts | 52 ++++++++++++++++++++++++--------- src/config/includes.ts | 20 +++++++++---- src/config/io.ts | 5 +--- src/postinstall-patcher.test.ts | 10 +++---- 6 files changed, 62 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4a7ac352..3ea1fd8c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Plugins: voice-call plugin now real (Twilio/log), adds start/status RPC/CLI/tool + tests. - Docs: add plugins doc + cross-links from tools/skills/gateway config. - Tests: add Docker plugin loader smoke test. +- Config: add `$include` directive for modular config files. (#731) — thanks @pasogott. - Build: set pnpm minimum release age to 2880 minutes (2 days). (#718) — thanks @dan-dr. - macOS: prompt to install the global `clawdbot` CLI when missing in local mode; install via `clawd.bot/install-cli.sh` (no onboarding) and use external launchd/CLI instead of the embedded gateway runtime. - Docs: add gog calendar event color IDs from `gog calendar colors`. (#715) — thanks @mjrussell. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 9a0da11d7..06ceec2f7 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -124,6 +124,7 @@ Split your config into multiple files using the `$include` directive. This is us - **Single file**: Replaces the object containing `$include` - **Array of files**: Deep-merges files in order (later files override earlier ones) - **With sibling keys**: Sibling keys are merged after includes (override included values) +- **Sibling keys + arrays/primitives**: Not supported (included content must be an object) ```json5 // Sibling keys override included values diff --git a/src/config/includes.test.ts b/src/config/includes.test.ts index fb78d23ea..f8f85a8c6 100644 --- a/src/config/includes.test.ts +++ b/src/config/includes.test.ts @@ -100,6 +100,15 @@ describe("resolveConfigIncludes", () => { expect(resolve(obj, files)).toEqual({ a: 1, b: 99 }); }); + it("throws when sibling keys are used with non-object includes", () => { + const files = { "/config/list.json": ["a", "b"] }; + const obj = { $include: "./list.json", extra: true }; + expect(() => resolve(obj, files)).toThrow(ConfigIncludeError); + expect(() => resolve(obj, files)).toThrow( + /Sibling keys require included content to be an object/, + ); + }); + it("resolves nested includes", () => { const files = { "/config/level1.json": { nested: { $include: "./level2.json" } }, @@ -123,10 +132,12 @@ describe("resolveConfigIncludes", () => { parseJson: JSON.parse, }; const obj = { $include: "./bad.json" }; - expect(() => resolveConfigIncludes(obj, "/config/clawdbot.json", resolver)) - .toThrow(ConfigIncludeError); - expect(() => resolveConfigIncludes(obj, "/config/clawdbot.json", resolver)) - .toThrow(/Failed to parse include file/); + expect(() => + resolveConfigIncludes(obj, "/config/clawdbot.json", resolver), + ).toThrow(ConfigIncludeError); + expect(() => + resolveConfigIncludes(obj, "/config/clawdbot.json", resolver), + ).toThrow(/Failed to parse include file/); }); it("throws CircularIncludeError for circular includes", () => { @@ -143,10 +154,12 @@ describe("resolveConfigIncludes", () => { parseJson: JSON.parse, }; const obj = { $include: "./a.json" }; - expect(() => resolveConfigIncludes(obj, "/config/clawdbot.json", resolver)) - .toThrow(CircularIncludeError); - expect(() => resolveConfigIncludes(obj, "/config/clawdbot.json", resolver)) - .toThrow(/Circular include detected/); + expect(() => + resolveConfigIncludes(obj, "/config/clawdbot.json", resolver), + ).toThrow(CircularIncludeError); + expect(() => + resolveConfigIncludes(obj, "/config/clawdbot.json", resolver), + ).toThrow(/Circular include detected/); }); it("throws ConfigIncludeError for invalid $include value type", () => { @@ -185,7 +198,9 @@ describe("resolveConfigIncludes", () => { it("resolves parent directory references", () => { const files = { "/shared/common.json": { shared: true } }; const obj = { $include: "../../shared/common.json" }; - expect(resolve(obj, files, "/config/sub/clawdbot.json")).toEqual({ shared: true }); + expect(resolve(obj, files, "/config/sub/clawdbot.json")).toEqual({ + shared: true, + }); }); }); @@ -194,14 +209,25 @@ describe("real-world config patterns", () => { const files = { "/config/clients/mueller.json": { agents: [ - { id: "mueller-screenshot", workspace: "~/clients/mueller/screenshot" }, - { id: "mueller-transcribe", workspace: "~/clients/mueller/transcribe" }, + { + id: "mueller-screenshot", + workspace: "~/clients/mueller/screenshot", + }, + { + id: "mueller-transcribe", + workspace: "~/clients/mueller/transcribe", + }, ], - broadcast: { "group-mueller": ["mueller-screenshot", "mueller-transcribe"] }, + broadcast: { + "group-mueller": ["mueller-screenshot", "mueller-transcribe"], + }, }, "/config/clients/schmidt.json": { agents: [ - { id: "schmidt-screenshot", workspace: "~/clients/schmidt/screenshot" }, + { + id: "schmidt-screenshot", + workspace: "~/clients/schmidt/screenshot", + }, ], broadcast: { "group-schmidt": ["schmidt-screenshot"] }, }, diff --git a/src/config/includes.ts b/src/config/includes.ts index f3a74d332..3fa4f0185 100644 --- a/src/config/includes.ts +++ b/src/config/includes.ts @@ -73,9 +73,8 @@ export function deepMerge(target: unknown, source: unknown): unknown { if (isPlainObject(target) && isPlainObject(source)) { const result: Record = { ...target }; for (const key of Object.keys(source)) { - result[key] = key in result - ? deepMerge(result[key], source[key]) - : source[key]; + result[key] = + key in result ? deepMerge(result[key], source[key]) : source[key]; } return result; } @@ -130,6 +129,13 @@ class IncludeProcessor { return included; } + if (!isPlainObject(included)) { + throw new ConfigIncludeError( + "Sibling keys require included content to be an object", + typeof includeValue === "string" ? includeValue : INCLUDE_KEY, + ); + } + // Merge included content with sibling keys const rest: Record = {}; for (const key of otherKeys) { @@ -163,7 +169,7 @@ class IncludeProcessor { private loadFile(includePath: string): unknown { const resolvedPath = this.resolvePath(includePath); - + this.checkCircular(resolvedPath); this.checkDepth(includePath); @@ -207,7 +213,11 @@ class IncludeProcessor { } } - private parseFile(includePath: string, resolvedPath: string, raw: string): unknown { + private parseFile( + includePath: string, + resolvedPath: string, + raw: string, + ): unknown { try { return this.resolver.parseJson(raw); } catch (err) { diff --git a/src/config/io.ts b/src/config/io.ts index f05f9f19f..7b1ac6fb0 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -22,10 +22,7 @@ import { applySessionDefaults, applyTalkApiKey, } from "./defaults.js"; -import { - ConfigIncludeError, - resolveConfigIncludes, -} from "./includes.js"; +import { ConfigIncludeError, resolveConfigIncludes } from "./includes.js"; import { findLegacyConfigIssues } from "./legacy.js"; import { resolveConfigPath, resolveStateDir } from "./paths.js"; import { applyConfigOverrides } from "./runtime-overrides.js"; diff --git a/src/postinstall-patcher.test.ts b/src/postinstall-patcher.test.ts index 44dfe2e01..855905eb5 100644 --- a/src/postinstall-patcher.test.ts +++ b/src/postinstall-patcher.test.ts @@ -16,13 +16,13 @@ describe("postinstall patcher", () => { fs.mkdirSync(target); const filePath = path.join(target, "main.js"); - const original = [ + const original = `${[ "var QRCode = require('./../vendor/QRCode'),", " QRErrorCorrectLevel = require('./../vendor/QRCode/QRErrorCorrectLevel'),", ' black = "\\033[40m \\033[0m",', ' white = "\\033[47m \\033[0m",', " toCell = function (isBlack) {", - ].join("\n") + "\n"; + ].join("\n")}\n`; fs.writeFileSync(filePath, original, "utf-8"); const patchText = `diff --git a/lib/main.js b/lib/main.js @@ -43,13 +43,13 @@ index 0000000..1111111 100644 const updated = fs.readFileSync(filePath, "utf-8"); expect(updated).toBe( - [ + `${[ "var QRCode = require('./../vendor/QRCode/index.js'),", " QRErrorCorrectLevel = require('./../vendor/QRCode/QRErrorCorrectLevel.js'),", ' black = "\\033[40m \\033[0m",', ' white = "\\033[47m \\033[0m",', " toCell = function (isBlack) {", - ].join("\n") + "\n", + ].join("\n")}\n`, ); fs.rmSync(dir, { recursive: true, force: true }); @@ -60,7 +60,7 @@ index 0000000..1111111 100644 const filePath = path.join(dir, "file.txt"); fs.writeFileSync( filePath, - ["alpha", "beta", "gamma", "delta", "epsilon"].join("\n") + "\n", + `${["alpha", "beta", "gamma", "delta", "epsilon"].join("\n")}\n`, "utf-8", );