diff --git a/server/src/__tests__/config-file.test.ts b/server/src/__tests__/config-file.test.ts new file mode 100644 index 0000000000..9d5b2701ca --- /dev/null +++ b/server/src/__tests__/config-file.test.ts @@ -0,0 +1,92 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { readConfigFile } from "../config-file.js"; + +const ORIGINAL_PAPERCLIP_CONFIG = process.env.PAPERCLIP_CONFIG; + +function writeConfig(configPath: string, value: unknown): void { + fs.writeFileSync(configPath, `${JSON.stringify(value, null, 2)}\n`); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function minimalConfig(): unknown { + return { + $meta: { + version: 1, + updatedAt: "2026-07-05T00:00:00.000Z", + source: "configure", + }, + database: { + mode: "embedded-postgres", + }, + logging: { + mode: "file", + }, + server: {}, + }; +} + +describe("readConfigFile", () => { + let tempDir: string; + let configPath: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-config-file-test-")); + configPath = path.join(tempDir, "config.json"); + process.env.PAPERCLIP_CONFIG = configPath; + }); + + afterEach(() => { + if (ORIGINAL_PAPERCLIP_CONFIG === undefined) { + delete process.env.PAPERCLIP_CONFIG; + } else { + process.env.PAPERCLIP_CONFIG = ORIGINAL_PAPERCLIP_CONFIG; + } + + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it("returns null when the config file does not exist", () => { + expect(readConfigFile()).toBeNull(); + }); + + it("throws a path-specific error when the config file is invalid JSON", () => { + fs.writeFileSync(configPath, "{"); + + expect(() => readConfigFile()).toThrow( + new RegExp(`Invalid Paperclip config at ${escapeRegExp(configPath)}: failed to read or parse JSON`), + ); + }); + + it("throws a field-specific error when the config file fails schema validation", () => { + const config = minimalConfig(); + if (typeof config === "object" && config !== null) { + (config as { $meta: { source: string } }).$meta.source = "edited-by-hand"; + } + + writeConfig(configPath, config); + + expect(() => readConfigFile()).toThrow(/Invalid Paperclip config .* \$meta\.source:/); + }); + + it("parses a valid config file", () => { + writeConfig(configPath, minimalConfig()); + + expect(readConfigFile()).toMatchObject({ + $meta: { + source: "configure", + }, + database: { + mode: "embedded-postgres", + }, + logging: { + mode: "file", + }, + }); + }); +}); diff --git a/server/src/config-file.ts b/server/src/config-file.ts index a25d4db58c..9c150f4a78 100644 --- a/server/src/config-file.ts +++ b/server/src/config-file.ts @@ -1,16 +1,37 @@ import fs from "node:fs"; import { paperclipConfigSchema, type PaperclipConfig } from "@paperclipai/shared"; +import { ZodError } from "zod"; import { resolvePaperclipConfigPath } from "./paths.js"; +function formatConfigValidationError(error: ZodError): string { + return error.issues + .map((issue) => { + const issuePath = issue.path.length > 0 ? issue.path.join(".") : ""; + return `${issuePath}: ${issue.message}`; + }) + .join("; "); +} + export function readConfigFile(): PaperclipConfig | null { const configPath = resolvePaperclipConfigPath(); if (!fs.existsSync(configPath)) return null; + let raw: unknown; + try { + raw = JSON.parse(fs.readFileSync(configPath, "utf-8")); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid Paperclip config at ${configPath}: failed to read or parse JSON: ${reason}`); + } + try { - const raw = JSON.parse(fs.readFileSync(configPath, "utf-8")); return paperclipConfigSchema.parse(raw); - } catch { - return null; + } catch (error) { + if (error instanceof ZodError) { + throw new Error(`Invalid Paperclip config at ${configPath}: ${formatConfigValidationError(error)}`); + } + + throw error; } }