| #!/usr/bin/env node |
| import { spawn } from "node:child_process"; |
| import { randomUUID } from "node:crypto"; |
| 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"; |
| import { createCapturedOutputBuffer, parseJsonResponseWithLimit } from "./dev-runner-output.mjs"; |
| import { shouldTrackDevServerPath } from "./dev-runner-paths.mjs"; |
|
|
| 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 devServerStatusToken = mode === "dev" ? randomUUID() : null; |
| const devServerStatusTokenHeader = "x-paperclip-dev-server-status-token"; |
|
|
| const watchedDirectories = [ |
| "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; |
| env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN = devServerStatusToken ?? ""; |
| } |
|
|
| if (mode === "watch") { |
| delete env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN; |
| 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; |
| if (!shouldTrackDevServerPath(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, |
| cwd: options.cwd, |
| shell: process.platform === "win32", |
| }); |
|
|
| const stdoutBuffer = createCapturedOutputBuffer(); |
| const stderrBuffer = createCapturedOutputBuffer(); |
|
|
| if (spawned.stdout) { |
| spawned.stdout.on("data", (chunk) => { |
| stdoutBuffer.append(chunk); |
| }); |
| } |
| if (spawned.stderr) { |
| spawned.stderr.on("data", (chunk) => { |
| stderrBuffer.append(chunk); |
| }); |
| } |
|
|
| spawned.on("error", reject); |
| spawned.on("exit", (code, signal) => { |
| const stdout = stdoutBuffer.finish(); |
| const stderr = stderrBuffer.finish(); |
| resolve({ |
| code: code ?? 0, |
| signal, |
| stdout: stdout.text, |
| stderr: stderr.text, |
| }); |
| }); |
| }); |
| } |
|
|
| async function getMigrationStatusPayload() { |
| const status = await runPnpm( |
| ["--filter", "@penclipai/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 @penclipai/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", "@penclipai/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`, { |
| headers: devServerStatusToken ? { [devServerStatusTokenHeader]: devServerStatusToken } : undefined, |
| }); |
| if (!response.ok) { |
| throw new Error(`Health request failed (${response.status})`); |
| } |
| return await parseJsonResponseWithLimit(response); |
| } |
|
|
| 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", "@penclipai/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; |
|
|
| restartInFlight = true; |
| let health; |
| try { |
| health = await getDevHealthPayload(); |
| } catch { |
| restartInFlight = false; |
| return; |
| } |
|
|
| const devServer = health?.devServer; |
| if (!devServer?.enabled || devServer.autoRestartEnabled !== true) { |
| restartInFlight = false; |
| return; |
| } |
| if ((devServer.activeRunCount ?? 0) > 0) { |
| restartInFlight = false; |
| return; |
| } |
|
|
| try { |
| 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); |
| } |
|
|