Spaces:
Build error
Build error
| import { randomInt } from "node:crypto"; | |
| import path from "node:path"; | |
| import type { PaperclipConfig } from "../config/schema.js"; | |
| import { expandHomePrefix } from "../config/home.js"; | |
| export const DEFAULT_WORKTREE_HOME = "~/.paperclip-worktrees"; | |
| export const WORKTREE_SEED_MODES = ["minimal", "full"] as const; | |
| export type WorktreeSeedMode = (typeof WORKTREE_SEED_MODES)[number]; | |
| export type WorktreeSeedPlan = { | |
| mode: WorktreeSeedMode; | |
| excludedTables: string[]; | |
| nullifyColumns: Record<string, string[]>; | |
| }; | |
| const MINIMAL_WORKTREE_EXCLUDED_TABLES = [ | |
| "activity_log", | |
| "agent_runtime_state", | |
| "agent_task_sessions", | |
| "agent_wakeup_requests", | |
| "cost_events", | |
| "heartbeat_run_events", | |
| "heartbeat_runs", | |
| "workspace_runtime_services", | |
| ]; | |
| const MINIMAL_WORKTREE_NULLIFIED_COLUMNS: Record<string, string[]> = { | |
| issues: ["checkout_run_id", "execution_run_id"], | |
| }; | |
| export type WorktreeLocalPaths = { | |
| cwd: string; | |
| repoConfigDir: string; | |
| configPath: string; | |
| envPath: string; | |
| homeDir: string; | |
| instanceId: string; | |
| instanceRoot: string; | |
| contextPath: string; | |
| embeddedPostgresDataDir: string; | |
| backupDir: string; | |
| logDir: string; | |
| secretsKeyFilePath: string; | |
| storageDir: string; | |
| }; | |
| export type WorktreeUiBranding = { | |
| name: string; | |
| color: string; | |
| }; | |
| export function isWorktreeSeedMode(value: string): value is WorktreeSeedMode { | |
| return (WORKTREE_SEED_MODES as readonly string[]).includes(value); | |
| } | |
| export function resolveWorktreeSeedPlan(mode: WorktreeSeedMode): WorktreeSeedPlan { | |
| if (mode === "full") { | |
| return { | |
| mode, | |
| excludedTables: [], | |
| nullifyColumns: {}, | |
| }; | |
| } | |
| return { | |
| mode, | |
| excludedTables: [...MINIMAL_WORKTREE_EXCLUDED_TABLES], | |
| nullifyColumns: { | |
| ...MINIMAL_WORKTREE_NULLIFIED_COLUMNS, | |
| }, | |
| }; | |
| } | |
| function nonEmpty(value: string | null | undefined): string | null { | |
| return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; | |
| } | |
| function isLoopbackHost(hostname: string): boolean { | |
| const value = hostname.trim().toLowerCase(); | |
| return value === "127.0.0.1" || value === "localhost" || value === "::1"; | |
| } | |
| export function sanitizeWorktreeInstanceId(rawValue: string): string { | |
| const trimmed = rawValue.trim().toLowerCase(); | |
| const normalized = trimmed | |
| .replace(/[^a-z0-9_-]+/g, "-") | |
| .replace(/-+/g, "-") | |
| .replace(/^[-_]+|[-_]+$/g, ""); | |
| return normalized || "worktree"; | |
| } | |
| export function resolveSuggestedWorktreeName(cwd: string, explicitName?: string): string { | |
| return nonEmpty(explicitName) ?? path.basename(path.resolve(cwd)); | |
| } | |
| function hslComponentToHex(n: number): string { | |
| return Math.round(Math.max(0, Math.min(255, n))) | |
| .toString(16) | |
| .padStart(2, "0"); | |
| } | |
| function hslToHex(hue: number, saturation: number, lightness: number): string { | |
| const s = Math.max(0, Math.min(100, saturation)) / 100; | |
| const l = Math.max(0, Math.min(100, lightness)) / 100; | |
| const c = (1 - Math.abs((2 * l) - 1)) * s; | |
| const h = ((hue % 360) + 360) % 360; | |
| const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); | |
| const m = l - (c / 2); | |
| let r = 0; | |
| let g = 0; | |
| let b = 0; | |
| if (h < 60) { | |
| r = c; | |
| g = x; | |
| } else if (h < 120) { | |
| r = x; | |
| g = c; | |
| } else if (h < 180) { | |
| g = c; | |
| b = x; | |
| } else if (h < 240) { | |
| g = x; | |
| b = c; | |
| } else if (h < 300) { | |
| r = x; | |
| b = c; | |
| } else { | |
| r = c; | |
| b = x; | |
| } | |
| return `#${hslComponentToHex((r + m) * 255)}${hslComponentToHex((g + m) * 255)}${hslComponentToHex((b + m) * 255)}`; | |
| } | |
| export function generateWorktreeColor(): string { | |
| return hslToHex(randomInt(0, 360), 68, 56); | |
| } | |
| export function resolveWorktreeLocalPaths(opts: { | |
| cwd: string; | |
| homeDir?: string; | |
| instanceId: string; | |
| }): WorktreeLocalPaths { | |
| const cwd = path.resolve(opts.cwd); | |
| const homeDir = path.resolve(expandHomePrefix(opts.homeDir ?? DEFAULT_WORKTREE_HOME)); | |
| const instanceRoot = path.resolve(homeDir, "instances", opts.instanceId); | |
| const repoConfigDir = path.resolve(cwd, ".paperclip"); | |
| return { | |
| cwd, | |
| repoConfigDir, | |
| configPath: path.resolve(repoConfigDir, "config.json"), | |
| envPath: path.resolve(repoConfigDir, ".env"), | |
| homeDir, | |
| instanceId: opts.instanceId, | |
| instanceRoot, | |
| contextPath: path.resolve(homeDir, "context.json"), | |
| embeddedPostgresDataDir: path.resolve(instanceRoot, "db"), | |
| backupDir: path.resolve(instanceRoot, "data", "backups"), | |
| logDir: path.resolve(instanceRoot, "logs"), | |
| secretsKeyFilePath: path.resolve(instanceRoot, "secrets", "master.key"), | |
| storageDir: path.resolve(instanceRoot, "data", "storage"), | |
| }; | |
| } | |
| export function rewriteLocalUrlPort(rawUrl: string | undefined, port: number): string | undefined { | |
| if (!rawUrl) return undefined; | |
| try { | |
| const parsed = new URL(rawUrl); | |
| if (!isLoopbackHost(parsed.hostname)) return rawUrl; | |
| parsed.port = String(port); | |
| return parsed.toString(); | |
| } catch { | |
| return rawUrl; | |
| } | |
| } | |
| export function buildWorktreeConfig(input: { | |
| sourceConfig: PaperclipConfig | null; | |
| paths: WorktreeLocalPaths; | |
| serverPort: number; | |
| databasePort: number; | |
| now?: Date; | |
| }): PaperclipConfig { | |
| const { sourceConfig, paths, serverPort, databasePort } = input; | |
| const nowIso = (input.now ?? new Date()).toISOString(); | |
| const source = sourceConfig; | |
| const authPublicBaseUrl = rewriteLocalUrlPort(source?.auth.publicBaseUrl, serverPort); | |
| return { | |
| $meta: { | |
| version: 1, | |
| updatedAt: nowIso, | |
| source: "configure", | |
| }, | |
| ...(source?.llm ? { llm: source.llm } : {}), | |
| database: { | |
| mode: "embedded-postgres", | |
| embeddedPostgresDataDir: paths.embeddedPostgresDataDir, | |
| embeddedPostgresPort: databasePort, | |
| backup: { | |
| enabled: source?.database.backup.enabled ?? true, | |
| intervalMinutes: source?.database.backup.intervalMinutes ?? 60, | |
| retentionDays: source?.database.backup.retentionDays ?? 30, | |
| dir: paths.backupDir, | |
| }, | |
| }, | |
| logging: { | |
| mode: source?.logging.mode ?? "file", | |
| logDir: paths.logDir, | |
| }, | |
| server: { | |
| deploymentMode: source?.server.deploymentMode ?? "local_trusted", | |
| exposure: source?.server.exposure ?? "private", | |
| host: source?.server.host ?? "127.0.0.1", | |
| port: serverPort, | |
| allowedHostnames: source?.server.allowedHostnames ?? [], | |
| serveUi: source?.server.serveUi ?? true, | |
| }, | |
| auth: { | |
| baseUrlMode: source?.auth.baseUrlMode ?? "auto", | |
| ...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}), | |
| disableSignUp: source?.auth.disableSignUp ?? false, | |
| }, | |
| storage: { | |
| provider: source?.storage.provider ?? "local_disk", | |
| localDisk: { | |
| baseDir: paths.storageDir, | |
| }, | |
| s3: { | |
| bucket: source?.storage.s3.bucket ?? "paperclip", | |
| region: source?.storage.s3.region ?? "us-east-1", | |
| endpoint: source?.storage.s3.endpoint, | |
| prefix: source?.storage.s3.prefix ?? "", | |
| forcePathStyle: source?.storage.s3.forcePathStyle ?? false, | |
| }, | |
| }, | |
| secrets: { | |
| provider: source?.secrets.provider ?? "local_encrypted", | |
| strictMode: source?.secrets.strictMode ?? false, | |
| localEncrypted: { | |
| keyFilePath: paths.secretsKeyFilePath, | |
| }, | |
| }, | |
| }; | |
| } | |
| export function buildWorktreeEnvEntries( | |
| paths: WorktreeLocalPaths, | |
| branding?: WorktreeUiBranding, | |
| ): Record<string, string> { | |
| return { | |
| PAPERCLIP_HOME: paths.homeDir, | |
| PAPERCLIP_INSTANCE_ID: paths.instanceId, | |
| PAPERCLIP_CONFIG: paths.configPath, | |
| PAPERCLIP_CONTEXT: paths.contextPath, | |
| PAPERCLIP_IN_WORKTREE: "true", | |
| ...(branding?.name ? { PAPERCLIP_WORKTREE_NAME: branding.name } : {}), | |
| ...(branding?.color ? { PAPERCLIP_WORKTREE_COLOR: branding.color } : {}), | |
| }; | |
| } | |
| function shellEscape(value: string): string { | |
| return `'${value.replaceAll("'", `'\"'\"'`)}'`; | |
| } | |
| export function formatShellExports(entries: Record<string, string>): string { | |
| return Object.entries(entries) | |
| .filter(([, value]) => typeof value === "string" && value.trim().length > 0) | |
| .map(([key, value]) => `export ${key}=${shellEscape(value)}`) | |
| .join("\n"); | |
| } | |