| import fs from "node:fs"; |
| import path from "node:path"; |
| import { paperclipConfigSchema, type PaperclipConfig } from "./schema.js"; |
| import { |
| resolveDefaultConfigPath, |
| resolvePaperclipInstanceId, |
| } from "./home.js"; |
|
|
| const DEFAULT_CONFIG_BASENAME = "config.json"; |
|
|
| function findConfigFileFromAncestors(startDir: string): string | null { |
| const absoluteStartDir = path.resolve(startDir); |
| let currentDir = absoluteStartDir; |
|
|
| while (true) { |
| const candidate = path.resolve(currentDir, ".paperclip", DEFAULT_CONFIG_BASENAME); |
| if (fs.existsSync(candidate)) { |
| return candidate; |
| } |
|
|
| const nextDir = path.resolve(currentDir, ".."); |
| if (nextDir === currentDir) break; |
| currentDir = nextDir; |
| } |
|
|
| return null; |
| } |
|
|
| export function resolveConfigPath(overridePath?: string): string { |
| if (overridePath) return path.resolve(overridePath); |
| if (process.env.PAPERCLIP_CONFIG) return path.resolve(process.env.PAPERCLIP_CONFIG); |
| return findConfigFileFromAncestors(process.cwd()) ?? resolveDefaultConfigPath(resolvePaperclipInstanceId()); |
| } |
|
|
| function parseJson(filePath: string): unknown { |
| try { |
| return JSON.parse(fs.readFileSync(filePath, "utf-8")); |
| } catch (err) { |
| throw new Error(`Failed to parse JSON at ${filePath}: ${err instanceof Error ? err.message : String(err)}`); |
| } |
| } |
|
|
| function migrateLegacyConfig(raw: unknown): unknown { |
| if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return raw; |
| const config = { ...(raw as Record<string, unknown>) }; |
| const databaseRaw = config.database; |
| if (typeof databaseRaw !== "object" || databaseRaw === null || Array.isArray(databaseRaw)) { |
| return config; |
| } |
|
|
| const database = { ...(databaseRaw as Record<string, unknown>) }; |
| if (database.mode === "pglite") { |
| database.mode = "embedded-postgres"; |
|
|
| if (typeof database.embeddedPostgresDataDir !== "string" && typeof database.pgliteDataDir === "string") { |
| database.embeddedPostgresDataDir = database.pgliteDataDir; |
| } |
| if ( |
| typeof database.embeddedPostgresPort !== "number" && |
| typeof database.pglitePort === "number" && |
| Number.isFinite(database.pglitePort) |
| ) { |
| database.embeddedPostgresPort = database.pglitePort; |
| } |
| } |
|
|
| config.database = database; |
| return config; |
| } |
|
|
| function formatValidationError(err: unknown): string { |
| const issues = (err as { issues?: Array<{ path?: unknown; message?: unknown }> })?.issues; |
| if (Array.isArray(issues) && issues.length > 0) { |
| return issues |
| .map((issue) => { |
| const pathParts = Array.isArray(issue.path) ? issue.path.map(String) : []; |
| const issuePath = pathParts.length > 0 ? pathParts.join(".") : "config"; |
| const message = typeof issue.message === "string" ? issue.message : "Invalid value"; |
| return `${issuePath}: ${message}`; |
| }) |
| .join("; "); |
| } |
| return err instanceof Error ? err.message : String(err); |
| } |
|
|
| export function readConfig(configPath?: string): PaperclipConfig | null { |
| const filePath = resolveConfigPath(configPath); |
| if (!fs.existsSync(filePath)) return null; |
| const raw = parseJson(filePath); |
| const migrated = migrateLegacyConfig(raw); |
| const parsed = paperclipConfigSchema.safeParse(migrated); |
| if (!parsed.success) { |
| throw new Error(`Invalid config at ${filePath}: ${formatValidationError(parsed.error)}`); |
| } |
| return parsed.data; |
| } |
|
|
| export function writeConfig( |
| config: PaperclipConfig, |
| configPath?: string, |
| ): void { |
| const filePath = resolveConfigPath(configPath); |
| const dir = path.dirname(filePath); |
| fs.mkdirSync(dir, { recursive: true }); |
|
|
| |
| if (fs.existsSync(filePath)) { |
| const backupPath = filePath + ".backup"; |
| fs.copyFileSync(filePath, backupPath); |
| fs.chmodSync(backupPath, 0o600); |
| } |
|
|
| fs.writeFileSync(filePath, JSON.stringify(config, null, 2) + "\n", { |
| mode: 0o600, |
| }); |
| } |
|
|
| export function configExists(configPath?: string): boolean { |
| return fs.existsSync(resolveConfigPath(configPath)); |
| } |
|
|