Spaces:
Paused
Paused
| import { existsSync, readFileSync } from "node:fs"; | |
| import os from "node:os"; | |
| import path from "node:path"; | |
| const DEFAULT_INSTANCE_ID = "default"; | |
| const CONFIG_BASENAME = "config.json"; | |
| const ENV_BASENAME = ".env"; | |
| const INSTANCE_ID_RE = /^[a-zA-Z0-9_-]+$/; | |
| type PartialConfig = { | |
| database?: { | |
| mode?: "embedded-postgres" | "postgres"; | |
| connectionString?: string; | |
| embeddedPostgresDataDir?: string; | |
| embeddedPostgresPort?: number; | |
| pgliteDataDir?: string; | |
| pglitePort?: number; | |
| }; | |
| }; | |
| export type ResolvedDatabaseTarget = | |
| | { | |
| mode: "postgres"; | |
| connectionString: string; | |
| source: "DATABASE_URL" | "paperclip-env" | "config.database.connectionString"; | |
| configPath: string; | |
| envPath: string; | |
| } | |
| | { | |
| mode: "embedded-postgres"; | |
| dataDir: string; | |
| port: number; | |
| source: `embedded-postgres@${number}`; | |
| configPath: string; | |
| envPath: string; | |
| }; | |
| function expandHomePrefix(value: string): string { | |
| if (value === "~") return os.homedir(); | |
| if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2)); | |
| return value; | |
| } | |
| function resolvePaperclipHomeDir(): string { | |
| const envHome = process.env.PAPERCLIP_HOME?.trim(); | |
| if (envHome) return path.resolve(expandHomePrefix(envHome)); | |
| return path.resolve(os.homedir(), ".paperclip"); | |
| } | |
| function resolvePaperclipInstanceId(): string { | |
| const raw = process.env.PAPERCLIP_INSTANCE_ID?.trim() || DEFAULT_INSTANCE_ID; | |
| if (!INSTANCE_ID_RE.test(raw)) { | |
| throw new Error(`Invalid PAPERCLIP_INSTANCE_ID '${raw}'.`); | |
| } | |
| return raw; | |
| } | |
| function resolveDefaultConfigPath(): string { | |
| return path.resolve( | |
| resolvePaperclipHomeDir(), | |
| "instances", | |
| resolvePaperclipInstanceId(), | |
| CONFIG_BASENAME, | |
| ); | |
| } | |
| function resolveDefaultEmbeddedPostgresDir(): string { | |
| return path.resolve(resolvePaperclipHomeDir(), "instances", resolvePaperclipInstanceId(), "db"); | |
| } | |
| function resolveHomeAwarePath(value: string): string { | |
| return path.resolve(expandHomePrefix(value)); | |
| } | |
| function findConfigFileFromAncestors(startDir: string): string | null { | |
| let currentDir = path.resolve(startDir); | |
| while (true) { | |
| const candidate = path.resolve(currentDir, ".paperclip", CONFIG_BASENAME); | |
| if (existsSync(candidate)) return candidate; | |
| const nextDir = path.resolve(currentDir, ".."); | |
| if (nextDir === currentDir) return null; | |
| currentDir = nextDir; | |
| } | |
| } | |
| function resolvePaperclipConfigPath(): string { | |
| if (process.env.PAPERCLIP_CONFIG?.trim()) { | |
| return path.resolve(process.env.PAPERCLIP_CONFIG.trim()); | |
| } | |
| return findConfigFileFromAncestors(process.cwd()) ?? resolveDefaultConfigPath(); | |
| } | |
| function resolvePaperclipEnvPath(configPath: string): string { | |
| return path.resolve(path.dirname(configPath), ENV_BASENAME); | |
| } | |
| function parseEnvFile(contents: string): Record<string, string> { | |
| const entries: Record<string, string> = {}; | |
| for (const rawLine of contents.split(/\r?\n/)) { | |
| const line = rawLine.trim(); | |
| if (!line || line.startsWith("#")) continue; | |
| const match = rawLine.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/); | |
| if (!match) continue; | |
| const [, key, rawValue] = match; | |
| const value = rawValue.trim(); | |
| if (!value) { | |
| entries[key] = ""; | |
| continue; | |
| } | |
| if ( | |
| (value.startsWith("\"") && value.endsWith("\"")) || | |
| (value.startsWith("'") && value.endsWith("'")) | |
| ) { | |
| entries[key] = value.slice(1, -1); | |
| continue; | |
| } | |
| entries[key] = value.replace(/\s+#.*$/, "").trim(); | |
| } | |
| return entries; | |
| } | |
| function readEnvEntries(envPath: string): Record<string, string> { | |
| if (!existsSync(envPath)) return {}; | |
| return parseEnvFile(readFileSync(envPath, "utf8")); | |
| } | |
| function migrateLegacyConfig(raw: unknown): PartialConfig | null { | |
| if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null; | |
| 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 as PartialConfig; | |
| } | |
| function asPositiveInt(value: unknown): number | null { | |
| if (typeof value !== "number" || !Number.isFinite(value)) return null; | |
| const rounded = Math.trunc(value); | |
| return rounded > 0 ? rounded : null; | |
| } | |
| function readConfig(configPath: string): PartialConfig | null { | |
| if (!existsSync(configPath)) return null; | |
| let parsed: unknown; | |
| try { | |
| parsed = JSON.parse(readFileSync(configPath, "utf8")); | |
| } catch (err) { | |
| throw new Error( | |
| `Failed to parse config at ${configPath}: ${err instanceof Error ? err.message : String(err)}`, | |
| ); | |
| } | |
| const migrated = migrateLegacyConfig(parsed); | |
| if (migrated === null || typeof migrated !== "object" || Array.isArray(migrated)) { | |
| throw new Error(`Invalid config at ${configPath}: expected a JSON object`); | |
| } | |
| const database = | |
| typeof migrated.database === "object" && | |
| migrated.database !== null && | |
| !Array.isArray(migrated.database) | |
| ? migrated.database | |
| : undefined; | |
| return { | |
| database: database | |
| ? { | |
| mode: database.mode === "postgres" ? "postgres" : "embedded-postgres", | |
| connectionString: | |
| typeof database.connectionString === "string" ? database.connectionString : undefined, | |
| embeddedPostgresDataDir: | |
| typeof database.embeddedPostgresDataDir === "string" | |
| ? database.embeddedPostgresDataDir | |
| : undefined, | |
| embeddedPostgresPort: asPositiveInt(database.embeddedPostgresPort) ?? undefined, | |
| pgliteDataDir: typeof database.pgliteDataDir === "string" ? database.pgliteDataDir : undefined, | |
| pglitePort: asPositiveInt(database.pglitePort) ?? undefined, | |
| } | |
| : undefined, | |
| }; | |
| } | |
| export function resolveDatabaseTarget(): ResolvedDatabaseTarget { | |
| const configPath = resolvePaperclipConfigPath(); | |
| const envPath = resolvePaperclipEnvPath(configPath); | |
| const envEntries = readEnvEntries(envPath); | |
| const envUrl = process.env.DATABASE_URL?.trim(); | |
| if (envUrl) { | |
| return { | |
| mode: "postgres", | |
| connectionString: envUrl, | |
| source: "DATABASE_URL", | |
| configPath, | |
| envPath, | |
| }; | |
| } | |
| const fileEnvUrl = envEntries.DATABASE_URL?.trim(); | |
| if (fileEnvUrl) { | |
| return { | |
| mode: "postgres", | |
| connectionString: fileEnvUrl, | |
| source: "paperclip-env", | |
| configPath, | |
| envPath, | |
| }; | |
| } | |
| const config = readConfig(configPath); | |
| const connectionString = config?.database?.connectionString?.trim(); | |
| if (config?.database?.mode === "postgres" && connectionString) { | |
| return { | |
| mode: "postgres", | |
| connectionString, | |
| source: "config.database.connectionString", | |
| configPath, | |
| envPath, | |
| }; | |
| } | |
| const port = config?.database?.embeddedPostgresPort ?? 54329; | |
| const dataDir = resolveHomeAwarePath( | |
| config?.database?.embeddedPostgresDataDir ?? resolveDefaultEmbeddedPostgresDir(), | |
| ); | |
| return { | |
| mode: "embedded-postgres", | |
| dataDir, | |
| port, | |
| source: `embedded-postgres@${port}`, | |
| configPath, | |
| envPath, | |
| }; | |
| } | |