UNPKG

@blundergoat/goat-flow

Version:

AI coding agent harness and local dashboard for Claude Code, OpenAI Codex, Google Antigravity, and GitHub Copilot - setup audits, guardrails, structured skills, deny hooks, and persistent learning loops.

215 lines 9.42 kB
/** * Shell and infrastructure HTTP route handlers for the dashboard server. * * Backs the HTML shell (`/`, with the bootstrap injection), static asset serving (`/assets/`, with * ETag/304 handling), the directory picker (`/api/browse`), hook state read/toggle (`/api/hooks`), * and agent-availability detection (`/api/agents/installed`). Agent detection spawns `which`/`where` * and `--version` probes with short timeouts and caches the result per handler set, refreshing only * on an explicit `fresh=true`. Filesystem and probe failures are reported as JSON error bodies or * recorded as "not installed" rather than thrown. Asset loading lives in dashboard-assets.ts; hook * mutation in hook-registrar.ts. */ import { execFileSync } from "node:child_process"; import { readdirSync } from "node:fs"; import { dirname, join } from "node:path"; import { loadDashboardAssetCached } from "./dashboard-assets.js"; import { KNOWN_AGENT_IDS, SUPPORTED_AGENTS, normalizeAgentVersionOutput, } from "./dashboard-route-types.js"; import { HookRegistrarError, applyHookState, readAllHookStates, } from "./hook-registrar.js"; import { isProjectDirectory } from "./setup-detect.js"; /** Writes the dashboard shell response after injecting the default workspace path. */ function handleHtmlRequest(ctx, url, res) { if (url.pathname !== "/") return false; const injection = `<script>window.__GOAT_FLOW_DEFAULT_PATH__ = ${JSON.stringify(ctx.absDefault)}; window.__GOAT_FLOW_VERSION__ = ${JSON.stringify(ctx.packageVersion)}; window.__GOAT_FLOW_DASHBOARD_TOKEN__ = ${JSON.stringify(ctx.dashboardToken)}; window.__GOAT_FLOW_AGENTS__ = ${JSON.stringify(SUPPORTED_AGENTS)}; window.__GOAT_FLOW_RUNNER_IDS__ = ${JSON.stringify(KNOWN_AGENT_IDS)}; window.__GOAT_FLOW_PRESETS__ = ${JSON.stringify(ctx.dashboardPresets)};</script>`; const liveReloadScript = ctx.devMode ? `<script>(function(){var ws=new WebSocket('ws://'+location.host+'/ws/livereload');ws.onmessage=function(){location.reload()};ws.onclose=function(){setTimeout(function(){location.reload()},1000)}})()</script>` : ""; const html = ctx .getTemplate() .replace("</body>", `${injection}\n${liveReloadScript}\n</body>`); res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); res.end(html); return true; } /** Serve bundled dashboard assets from the compiled `dist/dashboard/` output. */ function handleAssetRequest(req, url, res) { if (!url.pathname.startsWith("/assets/")) return false; const filename = url.pathname.slice("/assets/".length); if (!/^[a-z0-9_-]+\.(js|css|json)$/i.test(filename)) return false; const contentType = filename.endsWith(".css") ? "text/css; charset=utf-8" : filename.endsWith(".json") ? "application/json; charset=utf-8" : "application/javascript; charset=utf-8"; try { const asset = loadDashboardAssetCached(filename); const headers = { "Cache-Control": "no-cache", "Content-Type": contentType, ETag: asset.etag, }; if (req.headers["if-none-match"] === asset.etag) { res.writeHead(304, headers); res.end(); return true; } res.writeHead(200, headers); res.end(asset.content); } catch { res.writeHead(404); res.end("Not found"); } return true; } /** * List child directories for the path picker with a stable `{ current, parent, dirs }` shape. * * Reports validation and filesystem read failures as JSON. */ function handleBrowseRequest(ctx, url, res) { if (url.pathname !== "/api/browse") return false; try { const dirPath = ctx.validatedPath(url.searchParams.get("path"), "browse"); const entries = readdirSync(dirPath, { withFileTypes: true }) .filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")) .map((entry) => entry.name) .sort(); const dirs = entries.map((name) => { const full = join(dirPath, name); return { name, path: full, isProject: isProjectDirectory(full) }; }); ctx.jsonResponse(res, 200, { current: dirPath, parent: dirname(dirPath), dirs, }); } catch (err) { ctx.jsonResponse(res, ctx.responseStatusForError(err, 500), { error: err instanceof Error ? err.message : String(err), }); } return true; } /** Extract a hook id from dashboard toggle route paths. */ function hookIdFromTogglePath(pathname) { const match = pathname.match(/^\/api\/hooks\/([^/]+)\/toggle$/u); return match?.[1] ? decodeURIComponent(match[1]) : null; } /** Map hook registrar errors to HTTP status codes while preserving generic error handling. */ function hookErrorStatus(ctx, err) { if (err instanceof HookRegistrarError) return err.statusCode; return ctx.responseStatusForError(err, 500); } /** Return hook state or mutate one hook toggle for the selected project. */ async function handleHooksRequest(ctx, req, url, res) { if (url.pathname === "/api/hooks") { if (req.method !== "GET") { ctx.jsonResponse(res, 405, { error: "Method not allowed" }); return true; } try { const projectPath = ctx.validatedPath(url.searchParams.get("path"), "project-read"); ctx.jsonResponse(res, 200, { hooks: readAllHookStates(projectPath) }); } catch (err) { ctx.jsonResponse(res, hookErrorStatus(ctx, err), { error: err instanceof Error ? err.message : String(err), }); } return true; } const hookId = hookIdFromTogglePath(url.pathname); if (hookId === null) return false; if (req.method !== "POST") { ctx.jsonResponse(res, 405, { error: "Method not allowed" }); return true; } try { const projectPath = ctx.validatedPath(url.searchParams.get("path"), "write-local-state"); const { decodeHookToggleBody } = await import("./decoders.js"); const decoded = decodeHookToggleBody(await ctx.readBody(req)); if (!decoded.ok) { ctx.jsonResponse(res, 400, { error: decoded.error, path: decoded.path }); return true; } const hook = applyHookState(hookId, decoded.value.enabled, projectPath); ctx.jsonResponse(res, 200, { hook }); } catch (err) { ctx.jsonResponse(res, hookErrorStatus(ctx, err), { error: err instanceof Error ? err.message : String(err), }); } return true; } /** * Spawns lightweight agent probes because the dashboard needs availability * without failing page load when a runner is missing. * * Swallows missing binaries and optional version failures. */ function detectInstalledAgents(includeVersions) { return SUPPORTED_AGENTS.map((agent) => { try { const whichCmd = process.platform === "win32" ? "where" : "which"; execFileSync(whichCmd, [agent.terminalBinary], { timeout: 3000, stdio: "pipe", }); let version = null; if (includeVersions) { try { version = normalizeAgentVersionOutput(execFileSync(agent.terminalBinary, ["--version"], { timeout: 5000, stdio: "pipe", }).toString()); } catch { /* version detection optional */ } } return { ...agent, installed: true, version }; } catch { return { ...agent, installed: false, version: null }; } }); } /** Return cached agent availability unless the dashboard explicitly requests a fresh probe. */ function handleAgentDetectRequest(state, url, res) { if (url.pathname !== "/api/agents/installed") return false; const fresh = url.searchParams.get("fresh") === "true"; if (fresh || state.cached === null) { state.cached = detectInstalledAgents(fresh); } res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); res.end(JSON.stringify({ agents: state.cached })); return true; } /** * Bind the shell/infrastructure handlers to one server's request context and allocate the per-handler * agent-detection cache so probe results persist across requests until a fresh probe is requested. * * @param ctx - per-server dashboard route context with path validation, template/version/token, and IO hooks * @returns the HTML, asset, browse, hooks, and agent-detect handlers; each resolves true once it has * answered a matching request, or false to let another handler claim the URL */ export function createShellRouteHandlers(ctx) { const agentDetection = { cached: null }; return { handleHtmlRequest: (url, res) => handleHtmlRequest(ctx, url, res), handleAssetRequest, handleBrowseRequest: (url, res) => handleBrowseRequest(ctx, url, res), handleHooksRequest: (req, url, res) => handleHooksRequest(ctx, req, url, res), handleAgentDetectRequest: (url, res) => handleAgentDetectRequest(agentDetection, url, res), }; } //# sourceMappingURL=dashboard-shell-routes.js.map