#!/usr/bin/env node import { spawn } from "node:child_process"; import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; import path from "node:path"; import { createInterface } from "node:readline/promises"; import { stdin, stdout } from "node:process"; import { fileURLToPath } from "node:url"; const mode = process.argv[2] === "watch" ? "watch" : "dev"; const cliArgs = process.argv.slice(3); const scanIntervalMs = 1500; const autoRestartPollIntervalMs = 2500; const gracefulShutdownTimeoutMs = 10_000; const changedPathSampleLimit = 5; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const devServerStatusFilePath = path.join(repoRoot, ".paperclip", "dev-server-status.json"); const watchedDirectories = [ ".paperclip", "cli", "scripts", "server", "packages/adapter-utils", "packages/adapters", "packages/db", "packages/plugins/sdk", "packages/shared", ].map((relativePath) => path.join(repoRoot, relativePath)); const watchedFiles = [ ".env", "package.json", "pnpm-workspace.yaml", "tsconfig.base.json", "tsconfig.json", "vitest.config.ts", ].map((relativePath) => path.join(repoRoot, relativePath)); const ignoredDirectoryNames = new Set([ ".git", ".turbo", ".vite", "coverage", "dist", "node_modules", "ui-dist", ]); const ignoredRelativePaths = new Set([ ".paperclip/dev-server-status.json", ]); const tailscaleAuthFlagNames = new Set([ "--tailscale-auth", "--authenticated-private", ]); let tailscaleAuth = false; const forwardedArgs = []; for (const arg of cliArgs) { if (tailscaleAuthFlagNames.has(arg)) { tailscaleAuth = true; continue; } forwardedArgs.push(arg); } if (process.env.npm_config_tailscale_auth === "true") { tailscaleAuth = true; } if (process.env.npm_config_authenticated_private === "true") { tailscaleAuth = true; } const env = { ...process.env, PAPERCLIP_UI_DEV_MIDDLEWARE: "true", }; if (mode === "dev") { env.PAPERCLIP_DEV_SERVER_STATUS_FILE = devServerStatusFilePath; } if (mode === "watch") { env.PAPERCLIP_MIGRATION_PROMPT ??= "never"; env.PAPERCLIP_MIGRATION_AUTO_APPLY ??= "true"; } if (tailscaleAuth) { env.PAPERCLIP_DEPLOYMENT_MODE = "authenticated"; env.PAPERCLIP_DEPLOYMENT_EXPOSURE = "private"; env.PAPERCLIP_AUTH_BASE_URL_MODE = "auto"; env.HOST = "0.0.0.0"; console.log("[paperclip] dev mode: authenticated/private (tailscale-friendly) on 0.0.0.0"); } else { console.log("[paperclip] dev mode: local_trusted (default)"); } const pnpmBin = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; let previousSnapshot = collectWatchedSnapshot(); let dirtyPaths = new Set(); let pendingMigrations = []; let lastChangedAt = null; let lastRestartAt = null; let scanInFlight = false; let restartInFlight = false; let shuttingDown = false; let childExitWasExpected = false; let child = null; let childExitPromise = null; let scanTimer = null; let autoRestartTimer = null; function toError(error, context = "Dev runner command failed") { if (error instanceof Error) return error; if (error === undefined) return new Error(context); if (typeof error === "string") return new Error(`${context}: ${error}`); try { return new Error(`${context}: ${JSON.stringify(error)}`); } catch { return new Error(`${context}: ${String(error)}`); } } process.on("uncaughtException", (error) => { const err = toError(error, "Uncaught exception in dev runner"); process.stderr.write(`${err.stack ?? err.message}\n`); process.exit(1); }); process.on("unhandledRejection", (reason) => { const err = toError(reason, "Unhandled promise rejection in dev runner"); process.stderr.write(`${err.stack ?? err.message}\n`); process.exit(1); }); function formatPendingMigrationSummary(migrations) { if (migrations.length === 0) return "none"; return migrations.length > 3 ? `${migrations.slice(0, 3).join(", ")} (+${migrations.length - 3} more)` : migrations.join(", "); } function exitForSignal(signal) { if (signal === "SIGINT") { process.exit(130); } if (signal === "SIGTERM") { process.exit(143); } process.exit(1); } function toRelativePath(absolutePath) { return path.relative(repoRoot, absolutePath).split(path.sep).join("/"); } function readSignature(absolutePath) { const stats = statSync(absolutePath); return `${Math.trunc(stats.mtimeMs)}:${stats.size}`; } function addFileToSnapshot(snapshot, absolutePath) { const relativePath = toRelativePath(absolutePath); if (ignoredRelativePaths.has(relativePath)) return; snapshot.set(relativePath, readSignature(absolutePath)); } function walkDirectory(snapshot, absoluteDirectory) { if (!existsSync(absoluteDirectory)) return; for (const entry of readdirSync(absoluteDirectory, { withFileTypes: true })) { if (ignoredDirectoryNames.has(entry.name)) continue; const absolutePath = path.join(absoluteDirectory, entry.name); if (entry.isDirectory()) { walkDirectory(snapshot, absolutePath); continue; } if (entry.isFile() || entry.isSymbolicLink()) { addFileToSnapshot(snapshot, absolutePath); } } } function collectWatchedSnapshot() { const snapshot = new Map(); for (const absoluteDirectory of watchedDirectories) { walkDirectory(snapshot, absoluteDirectory); } for (const absoluteFile of watchedFiles) { if (!existsSync(absoluteFile)) continue; addFileToSnapshot(snapshot, absoluteFile); } return snapshot; } function diffSnapshots(previous, next) { const changed = new Set(); for (const [relativePath, signature] of next) { if (previous.get(relativePath) !== signature) { changed.add(relativePath); } } for (const relativePath of previous.keys()) { if (!next.has(relativePath)) { changed.add(relativePath); } } return [...changed].sort(); } function ensureDevStatusDirectory() { mkdirSync(path.dirname(devServerStatusFilePath), { recursive: true }); } function writeDevServerStatus() { if (mode !== "dev") return; ensureDevStatusDirectory(); const changedPaths = [...dirtyPaths].sort(); writeFileSync( devServerStatusFilePath, `${JSON.stringify({ dirty: changedPaths.length > 0 || pendingMigrations.length > 0, lastChangedAt, changedPathCount: changedPaths.length, changedPathsSample: changedPaths.slice(0, changedPathSampleLimit), pendingMigrations, lastRestartAt, }, null, 2)}\n`, "utf8", ); } function clearDevServerStatus() { if (mode !== "dev") return; rmSync(devServerStatusFilePath, { force: true }); } async function runPnpm(args, options = {}) { return await new Promise((resolve, reject) => { const spawned = spawn(pnpmBin, args, { stdio: options.stdio ?? ["ignore", "pipe", "pipe"], env: options.env ?? process.env, shell: process.platform === "win32", }); let stdoutBuffer = ""; let stderrBuffer = ""; if (spawned.stdout) { spawned.stdout.on("data", (chunk) => { stdoutBuffer += String(chunk); }); } if (spawned.stderr) { spawned.stderr.on("data", (chunk) => { stderrBuffer += String(chunk); }); } spawned.on("error", reject); spawned.on("exit", (code, signal) => { resolve({ code: code ?? 0, signal, stdout: stdoutBuffer, stderr: stderrBuffer, }); }); }); } async function getMigrationStatusPayload() { const status = await runPnpm( ["--filter", "@paperclipai/db", "exec", "tsx", "src/migration-status.ts", "--json"], { env }, ); if (status.code !== 0) { process.stderr.write( status.stderr || status.stdout || `[paperclip] Command failed with code ${status.code}: pnpm --filter @paperclipai/db exec tsx src/migration-status.ts --json\n`, ); process.exit(status.code); } try { return JSON.parse(status.stdout.trim()); } catch (error) { process.stderr.write( status.stderr || status.stdout || "[paperclip] migration-status returned invalid JSON payload\n", ); throw toError(error, "Unable to parse migration-status JSON output"); } } async function refreshPendingMigrations() { const payload = await getMigrationStatusPayload(); pendingMigrations = payload.status === "needsMigrations" && Array.isArray(payload.pendingMigrations) ? payload.pendingMigrations.filter((entry) => typeof entry === "string" && entry.trim().length > 0) : []; writeDevServerStatus(); return payload; } async function maybePreflightMigrations(options = {}) { const interactive = options.interactive ?? mode === "watch"; const autoApply = options.autoApply ?? env.PAPERCLIP_MIGRATION_AUTO_APPLY === "true"; const exitOnDecline = options.exitOnDecline ?? mode === "watch"; const payload = await refreshPendingMigrations(); if (payload.status !== "needsMigrations" || pendingMigrations.length === 0) { return; } let shouldApply = autoApply; if (!autoApply && interactive) { if (!stdin.isTTY || !stdout.isTTY) { shouldApply = true; } else { const prompt = createInterface({ input: stdin, output: stdout }); try { const answer = ( await prompt.question( `Apply pending migrations (${formatPendingMigrationSummary(pendingMigrations)}) now? (y/N): `, ) ) .trim() .toLowerCase(); shouldApply = answer === "y" || answer === "yes"; } finally { prompt.close(); } } } if (!shouldApply) { if (exitOnDecline) { process.stderr.write( `[paperclip] Pending migrations detected (${formatPendingMigrationSummary(pendingMigrations)}). ` + "Refusing to start watch mode against a stale schema.\n", ); process.exit(1); } return; } const migrate = spawn(pnpmBin, ["db:migrate"], { stdio: "inherit", env, shell: process.platform === "win32", }); const exit = await new Promise((resolve) => { migrate.on("exit", (code, signal) => resolve({ code: code ?? 0, signal })); }); if (exit.signal) { exitForSignal(exit.signal); return; } if (exit.code !== 0) { process.exit(exit.code); } await refreshPendingMigrations(); } async function buildPluginSdk() { console.log("[paperclip] building plugin sdk..."); const result = await runPnpm( ["--filter", "@paperclipai/plugin-sdk", "build"], { stdio: "inherit" }, ); if (result.signal) { exitForSignal(result.signal); return; } if (result.code !== 0) { console.error("[paperclip] plugin sdk build failed"); process.exit(result.code); } } async function markChildAsCurrent() { previousSnapshot = collectWatchedSnapshot(); dirtyPaths = new Set(); lastChangedAt = null; lastRestartAt = new Date().toISOString(); await refreshPendingMigrations(); } async function scanForBackendChanges() { if (mode !== "dev" || scanInFlight || restartInFlight) return; scanInFlight = true; try { const nextSnapshot = collectWatchedSnapshot(); const changed = diffSnapshots(previousSnapshot, nextSnapshot); previousSnapshot = nextSnapshot; if (changed.length === 0) return; for (const relativePath of changed) { dirtyPaths.add(relativePath); } lastChangedAt = new Date().toISOString(); await refreshPendingMigrations(); } finally { scanInFlight = false; } } async function getDevHealthPayload() { const serverPort = env.PORT ?? process.env.PORT ?? "3100"; const response = await fetch(`http://127.0.0.1:${serverPort}/api/health`); if (!response.ok) { throw new Error(`Health request failed (${response.status})`); } return await response.json(); } async function waitForChildExit() { if (!childExitPromise) { return { code: 0, signal: null }; } return await childExitPromise; } async function stopChildForRestart() { if (!child) return { code: 0, signal: null }; childExitWasExpected = true; child.kill("SIGTERM"); const killTimer = setTimeout(() => { if (child) { child.kill("SIGKILL"); } }, gracefulShutdownTimeoutMs); try { return await waitForChildExit(); } finally { clearTimeout(killTimer); } } async function startServerChild() { await buildPluginSdk(); const serverScript = mode === "watch" ? "dev:watch" : "dev"; child = spawn( pnpmBin, ["--filter", "@paperclipai/server", serverScript, ...forwardedArgs], { stdio: "inherit", env, shell: process.platform === "win32" }, ); childExitPromise = new Promise((resolve, reject) => { child.on("error", reject); child.on("exit", (code, signal) => { const expected = childExitWasExpected; childExitWasExpected = false; child = null; childExitPromise = null; resolve({ code: code ?? 0, signal }); if (restartInFlight || expected || shuttingDown) { return; } if (signal) { exitForSignal(signal); return; } process.exit(code ?? 0); }); }); await markChildAsCurrent(); } async function maybeAutoRestartChild() { if (mode !== "dev" || restartInFlight || !child) return; if (dirtyPaths.size === 0 && pendingMigrations.length === 0) return; let health; try { health = await getDevHealthPayload(); } catch { return; } const devServer = health?.devServer; if (!devServer?.enabled || devServer.autoRestartEnabled !== true) return; if ((devServer.activeRunCount ?? 0) > 0) return; try { restartInFlight = true; await maybePreflightMigrations({ autoApply: true, interactive: false, exitOnDecline: false, }); await stopChildForRestart(); await startServerChild(); } catch (error) { const err = toError(error, "Auto-restart failed"); process.stderr.write(`${err.stack ?? err.message}\n`); process.exit(1); } finally { restartInFlight = false; } } function installDevIntervals() { if (mode !== "dev") return; scanTimer = setInterval(() => { void scanForBackendChanges(); }, scanIntervalMs); autoRestartTimer = setInterval(() => { void maybeAutoRestartChild(); }, autoRestartPollIntervalMs); } function clearDevIntervals() { if (scanTimer) { clearInterval(scanTimer); scanTimer = null; } if (autoRestartTimer) { clearInterval(autoRestartTimer); autoRestartTimer = null; } } async function shutdown(signal) { if (shuttingDown) return; shuttingDown = true; clearDevIntervals(); clearDevServerStatus(); if (!child) { if (signal) { exitForSignal(signal); return; } process.exit(0); } childExitWasExpected = true; child.kill(signal); const exit = await waitForChildExit(); if (exit.signal) { exitForSignal(exit.signal); return; } process.exit(exit.code ?? 0); } process.on("SIGINT", () => { void shutdown("SIGINT"); }); process.on("SIGTERM", () => { void shutdown("SIGTERM"); }); await maybePreflightMigrations(); await startServerChild(); installDevIntervals(); if (mode === "watch") { const exit = await waitForChildExit(); if (exit.signal) { exitForSignal(exit.signal); } process.exit(exit.code ?? 0); }