diff --git a/bun.lock b/bun.lock index 3267434..4eb00d7 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "prettier": "^3.8.3", "supermemory": "^4.24.12", "yaml": "^2.9.0", + "zod": "^4.4.3", }, "devDependencies": { "@types/node": "^25.9.1", diff --git a/package.json b/package.json index 0ef4471..817db30 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "commander": "^15.0.0", "prettier": "^3.8.3", "supermemory": "^4.24.12", - "yaml": "^2.9.0" + "yaml": "^2.9.0", + "zod": "^4.4.3" } } diff --git a/src/config.ts b/src/config.ts deleted file mode 100644 index bb703f5..0000000 --- a/src/config.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { mkdirSync, readFileSync, writeFileSync } from "fs"; -import { homedir } from "os"; -import { dirname, join, resolve } from "path"; -import { parse as parseYaml } from "yaml"; - -export interface Config { - openrouterApiKey: string; - supermemoryApiKey: string; - brainboxRoot: string; -} - -const brainboxRoot = process.env["BRAINBOX_ROOT_PATH"] - ? resolve(process.cwd(), process.env["BRAINBOX_ROOT_PATH"]) - : join(homedir(), ".brainbox"); - -interface BrainboxYaml { - openrouter?: { apiKey?: string }; - supermemory?: { apiKey?: string }; -} - -const yamlPath = join(brainboxRoot, "brainbox.yaml"); -let parsed: BrainboxYaml = {}; -try { - parsed = parseYaml(readFileSync(yamlPath, "utf8")) ?? {}; -} catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - mkdirSync(dirname(yamlPath), { recursive: true }); - writeFileSync( - yamlPath, - "# Fill in your API keys, then run brainbox again.\n" + - "openrouter:\n" + - " apiKey: \n" + - "supermemory:\n" + - " apiKey: \n", - ); - } else { - throw err; - } -} - -const openrouterApiKey = parsed.openrouter?.apiKey; -if (!openrouterApiKey) throw new Error(`openrouter.apiKey is missing in ${yamlPath}`); - -const supermemoryApiKey = parsed.supermemory?.apiKey; -if (!supermemoryApiKey) throw new Error(`supermemory.apiKey is missing in ${yamlPath}`); - -export const config: Config = { - openrouterApiKey, - supermemoryApiKey, - brainboxRoot, -}; \ No newline at end of file diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..b775e98 --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,14 @@ +import { brainboxRoot } from "./loader"; +import rootConfig from "./root"; + +export interface Config { + openrouterApiKey: string; + supermemoryApiKey: string; + brainboxRoot: string; +} + +export const config: Config = { + brainboxRoot, + openrouterApiKey: rootConfig.openrouter.apiKey, + supermemoryApiKey: rootConfig.supermemory.apiKey, +}; diff --git a/src/config/loader.ts b/src/config/loader.ts new file mode 100644 index 0000000..c177c8a --- /dev/null +++ b/src/config/loader.ts @@ -0,0 +1,45 @@ +import { dirname, join, resolve } from "path"; +import { z, ZodError } from "zod"; +import { homedir } from "os"; +import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; +import { mkdirSync, readFileSync, writeFileSync } from "fs"; + +export const brainboxRoot = process.env["BRAINBOX_ROOT_PATH"] + ? resolve(process.cwd(), process.env["BRAINBOX_ROOT_PATH"]) + : join(homedir(), ".brainbox"); + +// ponytail: add a file → define a zod schema + one parseConfigFile() call. +// ponytail: add a key → extend the schema, extend the template.body, add to Config + the mapping below. +// ponytail: keep template.body and the schema in sync by hand; replace with z.toJSONSchema → defaults when more than ~5 files. +export function parseConfigFile( + file: string, + template: { header?: string; body: Record }, + schema: z.ZodType, +): T { + const path = join(brainboxRoot, file); + const templateStr = + (template.header ? template.header + "\n" : "") + + stringifyYaml(template.body); + let raw: unknown; + try { + raw = parseYaml(readFileSync(path, "utf8")) ?? {}; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err; + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, templateStr); + raw = {}; + } + try { + return schema.parse(raw); + } catch (err) { + if (err instanceof ZodError) { + throw new Error( + `Invalid config in ${path}:\n` + + err.issues + .map((i) => ` ${i.path.join(".") || "(root)"}: ${i.message}`) + .join("\n"), + ); + } + throw err; + } +} diff --git a/src/config/root.ts b/src/config/root.ts new file mode 100644 index 0000000..0b005db --- /dev/null +++ b/src/config/root.ts @@ -0,0 +1,21 @@ +import z from "zod"; +import { parseConfigFile } from "./loader"; + +const RootConfigSchema = z.object({ + openrouter: z.object({ apiKey: z.string().min(1) }), + supermemory: z.object({ apiKey: z.string().min(1) }), +}); + +const rootConfig = parseConfigFile( + "brainbox.yaml", + { + header: "# Fill in your API keys, then run brainbox again.", + body: { + openrouter: { apiKey: "" }, + supermemory: { apiKey: "" }, + }, + }, + RootConfigSchema, +); + +export default rootConfig;