diff --git a/packages/server/annotate.ts b/packages/server/annotate.ts index 6803c70..ed4770c 100644 --- a/packages/server/annotate.ts +++ b/packages/server/annotate.ts @@ -11,15 +11,14 @@ * PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote) */ -import { mkdirSync } from "fs"; import { isRemoteSession, getServerPort } from "./remote"; -import { openBrowser } from "./browser"; import { getRepoInfo } from "./repo"; -import { validateImagePath, validateUploadExtension, UPLOAD_DIR } from "./image"; +import { handleImage, handleUpload, handleServerReady } from "./shared-handlers"; // Re-export utilities export { isRemoteSession, getServerPort } from "./remote"; export { openBrowser } from "./browser"; +export { handleServerReady as handleAnnotateServerReady } from "./shared-handlers"; // --- Types --- @@ -126,48 +125,12 @@ export async function startAnnotateServer( // API: Serve images (local paths or temp uploads) if (url.pathname === "/api/image") { - const imagePath = url.searchParams.get("path"); - if (!imagePath) { - return new Response("Missing path parameter", { status: 400 }); - } - const validation = validateImagePath(imagePath); - if (!validation.valid) { - return new Response(validation.error!, { status: 403 }); - } - try { - const file = Bun.file(validation.resolved); - if (!(await file.exists())) { - return new Response("File not found", { status: 404 }); - } - return new Response(file); - } catch { - return new Response("Failed to read file", { status: 500 }); - } + return handleImage(req); } // API: Upload image -> save to temp -> return path if (url.pathname === "/api/upload" && req.method === "POST") { - try { - const formData = await req.formData(); - const file = formData.get("file") as File; - if (!file) { - return new Response("No file provided", { status: 400 }); - } - - const extResult = validateUploadExtension(file.name); - if (!extResult.valid) { - return Response.json({ error: extResult.error }, { status: 400 }); - } - mkdirSync(UPLOAD_DIR, { recursive: true }); - const tempPath = `${UPLOAD_DIR}/${crypto.randomUUID()}.${extResult.ext}`; - - await Bun.write(tempPath, file); - return Response.json({ path: tempPath, originalName: file.name }); - } catch (err) { - const message = - err instanceof Error ? err.message : "Upload failed"; - return Response.json({ error: message }, { status: 500 }); - } + return handleUpload(req); } // API: Submit annotation feedback @@ -242,16 +205,3 @@ export async function startAnnotateServer( stop: () => server.stop(), }; } - -/** - * Default behavior: open browser for local sessions - */ -export async function handleAnnotateServerReady( - url: string, - isRemote: boolean, - _port: number -): Promise { - if (!isRemote) { - await openBrowser(url); - } -} diff --git a/packages/server/index.ts b/packages/server/index.ts index e2aecbb..9fa54d8 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -9,14 +9,10 @@ * PLANNOTATOR_ORIGIN - Origin identifier ("claude-code" or "opencode") */ -import { mkdirSync, existsSync, statSync } from "fs"; import { resolve } from "path"; import { isRemoteSession, getServerPort } from "./remote"; -import { openBrowser } from "./browser"; -import { validateImagePath, validateUploadExtension, UPLOAD_DIR } from "./image"; import { openEditorDiff } from "./ide"; import { - detectObsidianVaults, saveToObsidian, saveToBear, type ObsidianConfig, @@ -37,12 +33,16 @@ import { } from "./storage"; import { getRepoInfo } from "./repo"; import { detectProjectName } from "./project"; +import { handleImage, handleUpload, handleAgents, handleServerReady, type OpencodeClient } from "./shared-handlers"; +import { handleDoc, handleObsidianVaults, handleObsidianFiles, handleObsidianDoc } from "./reference-handlers"; // Re-export utilities export { isRemoteSession, getServerPort } from "./remote"; export { openBrowser } from "./browser"; export * from "./integrations"; export * from "./storage"; +export { handleServerReady } from "./shared-handlers"; +export { type VaultNode, buildFileTree } from "./reference-handlers"; // --- Types --- @@ -64,11 +64,7 @@ export interface ServerOptions { /** Called when server starts with the URL, remote status, and port */ onReady?: (url: string, isRemote: boolean, port: number) => void; /** OpenCode client for querying available agents (OpenCode only) */ - opencodeClient?: { - app: { - agents: (options?: object) => Promise<{ data?: Array<{ name: string; description?: string; mode: string; hidden?: boolean }> }>; - }; - }; + opencodeClient?: OpencodeClient; } export interface ServerResult { @@ -203,120 +199,17 @@ export async function startPlannotatorServer( // API: Serve a linked markdown document if (url.pathname === "/api/doc" && req.method === "GET") { - const requestedPath = url.searchParams.get("path"); - if (!requestedPath) { - return Response.json({ error: "Missing path parameter" }, { status: 400 }); - } - - const projectRoot = process.cwd(); - - // Restrict to markdown files only - if (!/\.mdx?$/i.test(requestedPath)) { - return Response.json({ error: "Only .md and .mdx files are supported" }, { status: 400 }); - } - - // Path resolution: 3 strategies in order - let resolvedPath: string | null = null; - - if (requestedPath.startsWith("/")) { - // 1. Absolute path - resolvedPath = requestedPath; - } else { - // 2. Relative to project root - const fromRoot = resolve(projectRoot, requestedPath); - if (await Bun.file(fromRoot).exists()) { - resolvedPath = fromRoot; - } - - // 3. Bare filename — search entire project for unique match - if (!resolvedPath && !requestedPath.includes("/")) { - const glob = new Bun.Glob(`**/${requestedPath}`); - const matches: string[] = []; - for await (const match of glob.scan({ cwd: projectRoot, onlyFiles: true })) { - if (match.includes("node_modules/") || match.includes(".git/")) continue; - if (match.split("/").pop() === requestedPath) { - matches.push(resolve(projectRoot, match)); - } - } - if (matches.length === 1) { - resolvedPath = matches[0]; - } else if (matches.length > 1) { - const relativePaths = matches.map((m) => m.replace(projectRoot + "/", "")); - return Response.json( - { error: `Ambiguous filename '${requestedPath}': found ${matches.length} matches`, matches: relativePaths }, - { status: 400 } - ); - } - } - } - - if (!resolvedPath) { - return Response.json({ error: `File not found: ${requestedPath}` }, { status: 404 }); - } - - // Security: path must stay within projectRoot - const normalised = resolve(resolvedPath); - if (!normalised.startsWith(projectRoot + "/") && normalised !== projectRoot) { - return Response.json({ error: "Access denied: path is outside project root" }, { status: 403 }); - } - - const file = Bun.file(normalised); - if (!(await file.exists())) { - return Response.json({ error: `File not found: ${requestedPath}` }, { status: 404 }); - } - - try { - const markdown = await file.text(); - return Response.json({ markdown, filepath: normalised }); - } catch { - return Response.json({ error: "Failed to read file" }, { status: 500 }); - } + return handleDoc(req); } // API: Serve images (local paths or temp uploads) if (url.pathname === "/api/image") { - const imagePath = url.searchParams.get("path"); - if (!imagePath) { - return new Response("Missing path parameter", { status: 400 }); - } - const validation = validateImagePath(imagePath); - if (!validation.valid) { - return new Response(validation.error!, { status: 403 }); - } - try { - const file = Bun.file(validation.resolved); - if (!(await file.exists())) { - return new Response("File not found", { status: 404 }); - } - return new Response(file); - } catch { - return new Response("Failed to read file", { status: 500 }); - } + return handleImage(req); } // API: Upload image -> save to temp -> return path if (url.pathname === "/api/upload" && req.method === "POST") { - try { - const formData = await req.formData(); - const file = formData.get("file") as File; - if (!file) { - return new Response("No file provided", { status: 400 }); - } - - const extResult = validateUploadExtension(file.name); - if (!extResult.valid) { - return Response.json({ error: extResult.error }, { status: 400 }); - } - mkdirSync(UPLOAD_DIR, { recursive: true }); - const tempPath = `${UPLOAD_DIR}/${crypto.randomUUID()}.${extResult.ext}`; - - await Bun.write(tempPath, file); - return Response.json({ path: tempPath, originalName: file.name }); - } catch (err) { - const message = - err instanceof Error ? err.message : "Upload failed"; - return Response.json({ error: message }, { status: 500 }); - } + return handleUpload(req); } // API: Open plan diff in VS Code @@ -346,104 +239,22 @@ export async function startPlannotatorServer( // API: Detect Obsidian vaults if (url.pathname === "/api/obsidian/vaults") { - const vaults = detectObsidianVaults(); - return Response.json({ vaults }); + return handleObsidianVaults(); } // API: List Obsidian vault files as a tree if (url.pathname === "/api/reference/obsidian/files" && req.method === "GET") { - const vaultPath = url.searchParams.get("vaultPath"); - if (!vaultPath) { - return Response.json({ error: "Missing vaultPath parameter" }, { status: 400 }); - } - - const resolvedVault = resolve(vaultPath); - if (!existsSync(resolvedVault) || !statSync(resolvedVault).isDirectory()) { - return Response.json({ error: "Invalid vault path" }, { status: 400 }); - } - - try { - const glob = new Bun.Glob("**/*.md"); - const files: string[] = []; - for await (const match of glob.scan({ cwd: resolvedVault, onlyFiles: true })) { - if (match.includes(".obsidian/") || match.includes(".trash/")) continue; - files.push(match); - } - files.sort(); - - const tree = buildFileTree(files); - return Response.json({ tree }); - } catch { - return Response.json({ error: "Failed to list vault files" }, { status: 500 }); - } + return handleObsidianFiles(req); } // API: Read an Obsidian vault document if (url.pathname === "/api/reference/obsidian/doc" && req.method === "GET") { - const vaultPath = url.searchParams.get("vaultPath"); - const filePath = url.searchParams.get("path"); - if (!vaultPath || !filePath) { - return Response.json({ error: "Missing vaultPath or path parameter" }, { status: 400 }); - } - if (!/\.mdx?$/i.test(filePath)) { - return Response.json({ error: "Only markdown files are supported" }, { status: 400 }); - } - - const resolvedVault = resolve(vaultPath); - let resolvedFile = resolve(resolvedVault, filePath); - - // If direct path doesn't exist and it's a bare filename, search the vault - if (!existsSync(resolvedFile) && !filePath.includes("/")) { - const glob = new Bun.Glob(`**/${filePath}`); - const matches: string[] = []; - for await (const match of glob.scan({ cwd: resolvedVault, onlyFiles: true })) { - if (match.includes(".obsidian/") || match.includes(".trash/")) continue; - matches.push(resolve(resolvedVault, match)); - } - if (matches.length === 1) { - resolvedFile = matches[0]; - } else if (matches.length > 1) { - const relativePaths = matches.map((m) => m.replace(resolvedVault + "/", "")); - return Response.json( - { error: `Ambiguous filename '${filePath}': found ${matches.length} matches`, matches: relativePaths }, - { status: 400 } - ); - } - } - - // Security: must be within vault - if (!resolvedFile.startsWith(resolvedVault + "/")) { - return Response.json({ error: "Access denied: path is outside vault" }, { status: 403 }); - } - - try { - const file = Bun.file(resolvedFile); - if (!(await file.exists())) { - return Response.json({ error: `File not found: ${filePath}` }, { status: 404 }); - } - const markdown = await file.text(); - return Response.json({ markdown, filepath: resolvedFile }); - } catch { - return Response.json({ error: "Failed to read file" }, { status: 500 }); - } + return handleObsidianDoc(req); } // API: Get available agents (OpenCode only) if (url.pathname === "/api/agents") { - if (!options.opencodeClient) { - return Response.json({ agents: [] }); - } - - try { - const result = await options.opencodeClient.app.agents({}); - const agents = (result.data ?? []) - .filter((a) => a.mode === "primary" && !a.hidden) - .map((a) => ({ id: a.name, name: a.name, description: a.description })); - - return Response.json({ agents }); - } catch { - return Response.json({ agents: [], error: "Failed to fetch agents" }); - } + return handleAgents(options.opencodeClient); } // API: Save to notes (decoupled from approve/deny) @@ -637,69 +448,3 @@ export async function startPlannotatorServer( stop: () => server.stop(), }; } - -/** - * Default behavior: open browser for local sessions - */ -export async function handleServerReady( - url: string, - isRemote: boolean, - _port: number -): Promise { - if (!isRemote) { - await openBrowser(url); - } -} - -// --- Vault file tree helpers --- - -export interface VaultNode { - name: string; - path: string; // relative path within vault - type: "file" | "folder"; - children?: VaultNode[]; -} - -/** - * Build a nested file tree from a sorted list of relative paths. - * Folders are sorted before files at each level. - */ -function buildFileTree(relativePaths: string[]): VaultNode[] { - const root: VaultNode[] = []; - - for (const filePath of relativePaths) { - const parts = filePath.split("/"); - let current = root; - let pathSoFar = ""; - - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - pathSoFar = pathSoFar ? `${pathSoFar}/${part}` : part; - const isFile = i === parts.length - 1; - - let node = current.find((n) => n.name === part && n.type === (isFile ? "file" : "folder")); - if (!node) { - node = { name: part, path: pathSoFar, type: isFile ? "file" : "folder" }; - if (!isFile) node.children = []; - current.push(node); - } - if (!isFile) { - current = node.children!; - } - } - } - - // Sort: folders first (alphabetical), then files (alphabetical) - const sortNodes = (nodes: VaultNode[]) => { - nodes.sort((a, b) => { - if (a.type !== b.type) return a.type === "folder" ? -1 : 1; - return a.name.localeCompare(b.name); - }); - for (const node of nodes) { - if (node.children) sortNodes(node.children); - } - }; - sortNodes(root); - - return root; -} diff --git a/packages/server/reference-handlers.ts b/packages/server/reference-handlers.ts new file mode 100644 index 0000000..cf9e2a2 --- /dev/null +++ b/packages/server/reference-handlers.ts @@ -0,0 +1,224 @@ +/** + * Reference/document route handlers for the plan server. + * + * Handles /api/doc, /api/obsidian/vaults, /api/reference/obsidian/files, + * and /api/reference/obsidian/doc. Extracted from index.ts for modularity. + */ + +import { existsSync, statSync } from "fs"; +import { resolve } from "path"; +import { detectObsidianVaults } from "./integrations"; + +// --- Vault file tree helpers --- + +export interface VaultNode { + name: string; + path: string; // relative path within vault + type: "file" | "folder"; + children?: VaultNode[]; +} + +/** + * Build a nested file tree from a sorted list of relative paths. + * Folders are sorted before files at each level. + */ +export function buildFileTree(relativePaths: string[]): VaultNode[] { + const root: VaultNode[] = []; + + for (const filePath of relativePaths) { + const parts = filePath.split("/"); + let current = root; + let pathSoFar = ""; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + pathSoFar = pathSoFar ? `${pathSoFar}/${part}` : part; + const isFile = i === parts.length - 1; + + let node = current.find((n) => n.name === part && n.type === (isFile ? "file" : "folder")); + if (!node) { + node = { name: part, path: pathSoFar, type: isFile ? "file" : "folder" }; + if (!isFile) node.children = []; + current.push(node); + } + if (!isFile) { + current = node.children!; + } + } + } + + // Sort: folders first (alphabetical), then files (alphabetical) + const sortNodes = (nodes: VaultNode[]) => { + nodes.sort((a, b) => { + if (a.type !== b.type) return a.type === "folder" ? -1 : 1; + return a.name.localeCompare(b.name); + }); + for (const node of nodes) { + if (node.children) sortNodes(node.children); + } + }; + sortNodes(root); + + return root; +} + +// --- Route handlers --- + +/** Serve a linked markdown document. Resolves absolute, relative, or bare filename paths. */ +export async function handleDoc(req: Request): Promise { + const url = new URL(req.url); + const requestedPath = url.searchParams.get("path"); + if (!requestedPath) { + return Response.json({ error: "Missing path parameter" }, { status: 400 }); + } + + const projectRoot = process.cwd(); + + // Restrict to markdown files only + if (!/\.mdx?$/i.test(requestedPath)) { + return Response.json({ error: "Only .md and .mdx files are supported" }, { status: 400 }); + } + + // Path resolution: 3 strategies in order + let resolvedPath: string | null = null; + + if (requestedPath.startsWith("/")) { + // 1. Absolute path + resolvedPath = requestedPath; + } else { + // 2. Relative to project root + const fromRoot = resolve(projectRoot, requestedPath); + if (await Bun.file(fromRoot).exists()) { + resolvedPath = fromRoot; + } + + // 3. Bare filename — search entire project for unique match + if (!resolvedPath && !requestedPath.includes("/")) { + const glob = new Bun.Glob(`**/${requestedPath}`); + const matches: string[] = []; + for await (const match of glob.scan({ cwd: projectRoot, onlyFiles: true })) { + if (match.includes("node_modules/") || match.includes(".git/")) continue; + if (match.split("/").pop() === requestedPath) { + matches.push(resolve(projectRoot, match)); + } + } + if (matches.length === 1) { + resolvedPath = matches[0]; + } else if (matches.length > 1) { + const relativePaths = matches.map((m) => m.replace(projectRoot + "/", "")); + return Response.json( + { error: `Ambiguous filename '${requestedPath}': found ${matches.length} matches`, matches: relativePaths }, + { status: 400 }, + ); + } + } + } + + if (!resolvedPath) { + return Response.json({ error: `File not found: ${requestedPath}` }, { status: 404 }); + } + + // Security: path must stay within projectRoot + const normalised = resolve(resolvedPath); + if (!normalised.startsWith(projectRoot + "/") && normalised !== projectRoot) { + return Response.json({ error: "Access denied: path is outside project root" }, { status: 403 }); + } + + const file = Bun.file(normalised); + if (!(await file.exists())) { + return Response.json({ error: `File not found: ${requestedPath}` }, { status: 404 }); + } + + try { + const markdown = await file.text(); + return Response.json({ markdown, filepath: normalised }); + } catch { + return Response.json({ error: "Failed to read file" }, { status: 500 }); + } +} + +/** Detect available Obsidian vaults. */ +export function handleObsidianVaults(): Response { + const vaults = detectObsidianVaults(); + return Response.json({ vaults }); +} + +/** List Obsidian vault files as a nested tree. */ +export async function handleObsidianFiles(req: Request): Promise { + const url = new URL(req.url); + const vaultPath = url.searchParams.get("vaultPath"); + if (!vaultPath) { + return Response.json({ error: "Missing vaultPath parameter" }, { status: 400 }); + } + + const resolvedVault = resolve(vaultPath); + if (!existsSync(resolvedVault) || !statSync(resolvedVault).isDirectory()) { + return Response.json({ error: "Invalid vault path" }, { status: 400 }); + } + + try { + const glob = new Bun.Glob("**/*.md"); + const files: string[] = []; + for await (const match of glob.scan({ cwd: resolvedVault, onlyFiles: true })) { + if (match.includes(".obsidian/") || match.includes(".trash/")) continue; + files.push(match); + } + files.sort(); + + const tree = buildFileTree(files); + return Response.json({ tree }); + } catch { + return Response.json({ error: "Failed to list vault files" }, { status: 500 }); + } +} + +/** Read an Obsidian vault document. Supports direct path and bare filename search. */ +export async function handleObsidianDoc(req: Request): Promise { + const url = new URL(req.url); + const vaultPath = url.searchParams.get("vaultPath"); + const filePath = url.searchParams.get("path"); + if (!vaultPath || !filePath) { + return Response.json({ error: "Missing vaultPath or path parameter" }, { status: 400 }); + } + if (!/\.mdx?$/i.test(filePath)) { + return Response.json({ error: "Only markdown files are supported" }, { status: 400 }); + } + + const resolvedVault = resolve(vaultPath); + let resolvedFile = resolve(resolvedVault, filePath); + + // If direct path doesn't exist and it's a bare filename, search the vault + if (!existsSync(resolvedFile) && !filePath.includes("/")) { + const glob = new Bun.Glob(`**/${filePath}`); + const matches: string[] = []; + for await (const match of glob.scan({ cwd: resolvedVault, onlyFiles: true })) { + if (match.includes(".obsidian/") || match.includes(".trash/")) continue; + matches.push(resolve(resolvedVault, match)); + } + if (matches.length === 1) { + resolvedFile = matches[0]; + } else if (matches.length > 1) { + const relativePaths = matches.map((m) => m.replace(resolvedVault + "/", "")); + return Response.json( + { error: `Ambiguous filename '${filePath}': found ${matches.length} matches`, matches: relativePaths }, + { status: 400 }, + ); + } + } + + // Security: must be within vault + if (!resolvedFile.startsWith(resolvedVault + "/")) { + return Response.json({ error: "Access denied: path is outside vault" }, { status: 403 }); + } + + try { + const file = Bun.file(resolvedFile); + if (!(await file.exists())) { + return Response.json({ error: `File not found: ${filePath}` }, { status: 404 }); + } + const markdown = await file.text(); + return Response.json({ markdown, filepath: resolvedFile }); + } catch { + return Response.json({ error: "Failed to read file" }, { status: 500 }); + } +} diff --git a/packages/server/review.ts b/packages/server/review.ts index 019bc53..82c2c54 100644 --- a/packages/server/review.ts +++ b/packages/server/review.ts @@ -9,17 +9,16 @@ * PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote) */ -import { mkdirSync } from "fs"; import { isRemoteSession, getServerPort } from "./remote"; -import { openBrowser } from "./browser"; import { type DiffType, type GitContext, runGitDiff } from "./git"; import { getRepoInfo } from "./repo"; -import { validateImagePath, validateUploadExtension, UPLOAD_DIR } from "./image"; +import { handleImage, handleUpload, handleAgents, handleServerReady, type OpencodeClient } from "./shared-handlers"; // Re-export utilities export { isRemoteSession, getServerPort } from "./remote"; export { openBrowser } from "./browser"; export { type DiffType, type DiffOption, type GitContext } from "./git"; +export { handleServerReady as handleReviewServerReady } from "./shared-handlers"; // --- Types --- @@ -45,11 +44,7 @@ export interface ReviewServerOptions { /** Called when server starts with the URL, remote status, and port */ onReady?: (url: string, isRemote: boolean, port: number) => void; /** OpenCode client for querying available agents (OpenCode only) */ - opencodeClient?: { - app: { - agents: (options?: object) => Promise<{ data?: Array<{ name: string; description?: string; mode: string; hidden?: boolean }> }>; - }; - }; + opencodeClient?: OpencodeClient; } export interface ReviewServerResult { @@ -177,66 +172,17 @@ export async function startReviewServer( // API: Serve images (local paths or temp uploads) if (url.pathname === "/api/image") { - const imagePath = url.searchParams.get("path"); - if (!imagePath) { - return new Response("Missing path parameter", { status: 400 }); - } - const validation = validateImagePath(imagePath); - if (!validation.valid) { - return new Response(validation.error!, { status: 403 }); - } - try { - const file = Bun.file(validation.resolved); - if (!(await file.exists())) { - return new Response("File not found", { status: 404 }); - } - return new Response(file); - } catch { - return new Response("Failed to read file", { status: 500 }); - } + return handleImage(req); } // API: Upload image -> save to temp -> return path if (url.pathname === "/api/upload" && req.method === "POST") { - try { - const formData = await req.formData(); - const file = formData.get("file") as File; - if (!file) { - return new Response("No file provided", { status: 400 }); - } - - const extResult = validateUploadExtension(file.name); - if (!extResult.valid) { - return Response.json({ error: extResult.error }, { status: 400 }); - } - mkdirSync(UPLOAD_DIR, { recursive: true }); - const tempPath = `${UPLOAD_DIR}/${crypto.randomUUID()}.${extResult.ext}`; - - await Bun.write(tempPath, file); - return Response.json({ path: tempPath, originalName: file.name }); - } catch (err) { - const message = - err instanceof Error ? err.message : "Upload failed"; - return Response.json({ error: message }, { status: 500 }); - } + return handleUpload(req); } // API: Get available agents (OpenCode only) if (url.pathname === "/api/agents") { - if (!options.opencodeClient) { - return Response.json({ agents: [] }); - } - - try { - const result = await options.opencodeClient.app.agents({}); - const agents = (result.data ?? []) - .filter((a) => a.mode === "primary" && !a.hidden) - .map((a) => ({ id: a.name, name: a.name, description: a.description })); - - return Response.json({ agents }); - } catch { - return Response.json({ agents: [], error: "Failed to fetch agents" }); - } + return handleAgents(options.opencodeClient); } // API: Submit review feedback @@ -307,16 +253,3 @@ export async function startReviewServer( stop: () => server.stop(), }; } - -/** - * Default behavior: open browser for local sessions - */ -export async function handleReviewServerReady( - url: string, - isRemote: boolean, - _port: number -): Promise { - if (!isRemote) { - await openBrowser(url); - } -} diff --git a/packages/server/shared-handlers.ts b/packages/server/shared-handlers.ts new file mode 100644 index 0000000..8ce7ccd --- /dev/null +++ b/packages/server/shared-handlers.ts @@ -0,0 +1,94 @@ +/** + * Shared route handlers used by plan, review, and annotate servers. + * + * Eliminates duplication of /api/image, /api/upload, and the server-ready + * handler across all three server files. Also shares /api/agents for plan + review. + */ + +import { mkdirSync } from "fs"; +import { openBrowser } from "./browser"; +import { validateImagePath, validateUploadExtension, UPLOAD_DIR } from "./image"; + +/** Serve images from local paths or temp uploads. Used by all 3 servers. */ +export async function handleImage(req: Request): Promise { + const url = new URL(req.url); + const imagePath = url.searchParams.get("path"); + if (!imagePath) { + return new Response("Missing path parameter", { status: 400 }); + } + const validation = validateImagePath(imagePath); + if (!validation.valid) { + return new Response(validation.error!, { status: 403 }); + } + try { + const file = Bun.file(validation.resolved); + if (!(await file.exists())) { + return new Response("File not found", { status: 404 }); + } + return new Response(file); + } catch { + return new Response("Failed to read file", { status: 500 }); + } +} + +/** Upload image to temp dir, return path. Used by all 3 servers. */ +export async function handleUpload(req: Request): Promise { + try { + const formData = await req.formData(); + const file = formData.get("file") as File; + if (!file) { + return new Response("No file provided", { status: 400 }); + } + + const extResult = validateUploadExtension(file.name); + if (!extResult.valid) { + return Response.json({ error: extResult.error }, { status: 400 }); + } + mkdirSync(UPLOAD_DIR, { recursive: true }); + const tempPath = `${UPLOAD_DIR}/${crypto.randomUUID()}.${extResult.ext}`; + + await Bun.write(tempPath, file); + return Response.json({ path: tempPath, originalName: file.name }); + } catch (err) { + const message = err instanceof Error ? err.message : "Upload failed"; + return Response.json({ error: message }, { status: 500 }); + } +} + +/** OpenCode agent client interface (subset of OpenCode SDK) */ +export interface OpencodeClient { + app: { + agents: (options?: object) => Promise<{ + data?: Array<{ name: string; description?: string; mode: string; hidden?: boolean }>; + }>; + }; +} + +/** List available agents. Used by plan + review servers (OpenCode only). */ +export async function handleAgents(opencodeClient?: OpencodeClient): Promise { + if (!opencodeClient) { + return Response.json({ agents: [] }); + } + + try { + const result = await opencodeClient.app.agents({}); + const agents = (result.data ?? []) + .filter((a) => a.mode === "primary" && !a.hidden) + .map((a) => ({ id: a.name, name: a.name, description: a.description })); + + return Response.json({ agents }); + } catch { + return Response.json({ agents: [], error: "Failed to fetch agents" }); + } +} + +/** Open browser for local sessions. Used by all 3 servers. */ +export async function handleServerReady( + url: string, + isRemote: boolean, + _port: number, +): Promise { + if (!isRemote) { + await openBrowser(url); + } +}