Spaces:
Paused
Paused
| import fs from "node:fs"; | |
| import path from "node:path"; | |
| import { fileURLToPath, pathToFileURL } from "node:url"; | |
| import * as p from "@clack/prompts"; | |
| import pc from "picocolors"; | |
| import { bootstrapCeoInvite } from "./auth-bootstrap-ceo.js"; | |
| import { onboard } from "./onboard.js"; | |
| import { doctor } from "./doctor.js"; | |
| import { loadPaperclipEnvFile } from "../config/env.js"; | |
| import { configExists, resolveConfigPath } from "../config/store.js"; | |
| import type { PaperclipConfig } from "../config/schema.js"; | |
| import { readConfig } from "../config/store.js"; | |
| import { | |
| describeLocalInstancePaths, | |
| resolvePaperclipHomeDir, | |
| resolvePaperclipInstanceId, | |
| } from "../config/home.js"; | |
| interface RunOptions { | |
| config?: string; | |
| instance?: string; | |
| repair?: boolean; | |
| yes?: boolean; | |
| } | |
| interface StartedServer { | |
| apiUrl: string; | |
| databaseUrl: string; | |
| host: string; | |
| listenPort: number; | |
| } | |
| export async function runCommand(opts: RunOptions): Promise<void> { | |
| const instanceId = resolvePaperclipInstanceId(opts.instance); | |
| process.env.PAPERCLIP_INSTANCE_ID = instanceId; | |
| const homeDir = resolvePaperclipHomeDir(); | |
| fs.mkdirSync(homeDir, { recursive: true }); | |
| const paths = describeLocalInstancePaths(instanceId); | |
| fs.mkdirSync(paths.instanceRoot, { recursive: true }); | |
| const configPath = resolveConfigPath(opts.config); | |
| process.env.PAPERCLIP_CONFIG = configPath; | |
| loadPaperclipEnvFile(configPath); | |
| p.intro(pc.bgCyan(pc.black(" paperclipai run "))); | |
| p.log.message(pc.dim(`Home: ${paths.homeDir}`)); | |
| p.log.message(pc.dim(`Instance: ${paths.instanceId}`)); | |
| p.log.message(pc.dim(`Config: ${configPath}`)); | |
| if (!configExists(configPath)) { | |
| if (!process.stdin.isTTY || !process.stdout.isTTY) { | |
| p.log.error("No config found and terminal is non-interactive."); | |
| p.log.message(`Run ${pc.cyan("paperclipai onboard")} once, then retry ${pc.cyan("paperclipai run")}.`); | |
| process.exit(1); | |
| } | |
| p.log.step("No config found. Starting onboarding..."); | |
| await onboard({ config: configPath, invokedByRun: true }); | |
| } | |
| p.log.step("Running doctor checks..."); | |
| const summary = await doctor({ | |
| config: configPath, | |
| repair: opts.repair ?? true, | |
| yes: opts.yes ?? true, | |
| }); | |
| if (summary.failed > 0) { | |
| p.log.error("Doctor found blocking issues. Not starting server."); | |
| process.exit(1); | |
| } | |
| const config = readConfig(configPath); | |
| if (!config) { | |
| p.log.error(`No config found at ${configPath}.`); | |
| process.exit(1); | |
| } | |
| p.log.step("Starting Paperclip server..."); | |
| const startedServer = await importServerEntry(); | |
| if (shouldGenerateBootstrapInviteAfterStart(config)) { | |
| p.log.step("Generating bootstrap CEO invite"); | |
| await bootstrapCeoInvite({ | |
| config: configPath, | |
| dbUrl: startedServer.databaseUrl, | |
| baseUrl: resolveBootstrapInviteBaseUrl(config, startedServer), | |
| }); | |
| } | |
| } | |
| function resolveBootstrapInviteBaseUrl( | |
| config: PaperclipConfig, | |
| startedServer: StartedServer, | |
| ): string { | |
| const explicitBaseUrl = | |
| process.env.PAPERCLIP_PUBLIC_URL ?? | |
| process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ?? | |
| process.env.BETTER_AUTH_URL ?? | |
| process.env.BETTER_AUTH_BASE_URL ?? | |
| (config.auth.baseUrlMode === "explicit" ? config.auth.publicBaseUrl : undefined); | |
| if (typeof explicitBaseUrl === "string" && explicitBaseUrl.trim().length > 0) { | |
| return explicitBaseUrl.trim().replace(/\/+$/, ""); | |
| } | |
| return startedServer.apiUrl.replace(/\/api$/, ""); | |
| } | |
| function formatError(err: unknown): string { | |
| if (err instanceof Error) { | |
| if (err.message && err.message.trim().length > 0) return err.message; | |
| return err.name; | |
| } | |
| if (typeof err === "string") return err; | |
| try { | |
| return JSON.stringify(err); | |
| } catch { | |
| return String(err); | |
| } | |
| } | |
| function isModuleNotFoundError(err: unknown): boolean { | |
| if (!(err instanceof Error)) return false; | |
| const code = (err as { code?: unknown }).code; | |
| if (code === "ERR_MODULE_NOT_FOUND") return true; | |
| return err.message.includes("Cannot find module"); | |
| } | |
| function getMissingModuleSpecifier(err: unknown): string | null { | |
| if (!(err instanceof Error)) return null; | |
| const packageMatch = err.message.match(/Cannot find package '([^']+)' imported from/); | |
| if (packageMatch?.[1]) return packageMatch[1]; | |
| const moduleMatch = err.message.match(/Cannot find module '([^']+)'/); | |
| if (moduleMatch?.[1]) return moduleMatch[1]; | |
| return null; | |
| } | |
| function maybeEnableUiDevMiddleware(entrypoint: string): void { | |
| if (process.env.PAPERCLIP_UI_DEV_MIDDLEWARE !== undefined) return; | |
| const normalized = entrypoint.replaceAll("\\", "/"); | |
| if (normalized.endsWith("/server/src/index.ts") || normalized.endsWith("@paperclipai/server/src/index.ts")) { | |
| process.env.PAPERCLIP_UI_DEV_MIDDLEWARE = "true"; | |
| } | |
| } | |
| async function importServerEntry(): Promise<StartedServer> { | |
| // Dev mode: try local workspace path (monorepo with tsx) | |
| const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); | |
| const devEntry = path.resolve(projectRoot, "server/src/index.ts"); | |
| if (fs.existsSync(devEntry)) { | |
| maybeEnableUiDevMiddleware(devEntry); | |
| const mod = await import(pathToFileURL(devEntry).href); | |
| return await startServerFromModule(mod, devEntry); | |
| } | |
| // Production mode: import the published @paperclipai/server package | |
| try { | |
| const mod = await import("@paperclipai/server"); | |
| return await startServerFromModule(mod, "@paperclipai/server"); | |
| } catch (err) { | |
| const missingSpecifier = getMissingModuleSpecifier(err); | |
| const missingServerEntrypoint = !missingSpecifier || missingSpecifier === "@paperclipai/server"; | |
| if (isModuleNotFoundError(err) && missingServerEntrypoint) { | |
| throw new Error( | |
| `Could not locate a Paperclip server entrypoint.\n` + | |
| `Tried: ${devEntry}, @paperclipai/server\n` + | |
| `${formatError(err)}`, | |
| ); | |
| } | |
| throw new Error( | |
| `Paperclip server failed to start.\n` + | |
| `${formatError(err)}`, | |
| ); | |
| } | |
| } | |
| function shouldGenerateBootstrapInviteAfterStart(config: PaperclipConfig): boolean { | |
| return config.server.deploymentMode === "authenticated" && config.database.mode === "embedded-postgres"; | |
| } | |
| async function startServerFromModule(mod: unknown, label: string): Promise<StartedServer> { | |
| const startServer = (mod as { startServer?: () => Promise<StartedServer> }).startServer; | |
| if (typeof startServer !== "function") { | |
| throw new Error(`Paperclip server entrypoint did not export startServer(): ${label}`); | |
| } | |
| return await startServer(); | |
| } | |