Files
NomadArch/packages/server/src/server/routes/workspaces.ts

488 lines
16 KiB
TypeScript

import { FastifyInstance, FastifyReply } from "fastify"
import { spawnSync } from "child_process"
import { z } from "zod"
import { existsSync, mkdirSync } from "fs"
import { cp, readFile, writeFile, stat as readFileStat } from "fs/promises"
import path from "path"
import { WorkspaceManager } from "../../workspaces/manager"
import { InstanceStore } from "../../storage/instance-store"
import { ConfigStore } from "../../config/store"
import { getWorkspaceOpencodeConfigDir } from "../../opencode-config"
interface RouteDeps {
workspaceManager: WorkspaceManager
instanceStore: InstanceStore
configStore: ConfigStore
}
const WorkspaceCreateSchema = z.object({
path: z.string(),
name: z.string().optional(),
})
const WorkspaceFilesQuerySchema = z.object({
path: z.string().optional(),
})
const WorkspaceFileContentQuerySchema = z.object({
path: z.string(),
})
const WorkspaceFileSearchQuerySchema = z.object({
q: z.string().trim().min(1, "Query is required"),
limit: z.coerce.number().int().positive().max(200).optional(),
type: z.enum(["all", "file", "directory"]).optional(),
refresh: z
.string()
.optional()
.transform((value) => (value === undefined ? undefined : value === "true")),
})
export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/workspaces", async () => {
return deps.workspaceManager.list()
})
app.post("/api/workspaces", async (request, reply) => {
try {
const body = WorkspaceCreateSchema.parse(request.body ?? {})
const workspace = await deps.workspaceManager.create(body.path, body.name)
reply.code(201)
return workspace
} catch (error) {
request.log.error({ err: error }, "Failed to create workspace")
const message = error instanceof Error ? error.message : "Failed to create workspace"
reply.code(400).type("text/plain").send(message)
}
})
app.get<{ Params: { id: string } }>("/api/workspaces/:id", async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
return workspace
})
app.delete<{ Params: { id: string } }>("/api/workspaces/:id", async (request, reply) => {
await deps.workspaceManager.delete(request.params.id)
reply.code(204)
})
app.get<{
Params: { id: string }
Querystring: { path?: string }
}>("/api/workspaces/:id/files", async (request, reply) => {
try {
const query = WorkspaceFilesQuerySchema.parse(request.query ?? {})
return deps.workspaceManager.listFiles(request.params.id, query.path ?? ".")
} catch (error) {
return handleWorkspaceError(error, reply)
}
})
app.get<{
Params: { id: string }
Querystring: { q?: string; limit?: string; type?: "all" | "file" | "directory"; refresh?: string }
}>("/api/workspaces/:id/files/search", async (request, reply) => {
try {
const query = WorkspaceFileSearchQuerySchema.parse(request.query ?? {})
return deps.workspaceManager.searchFiles(request.params.id, query.q, {
limit: query.limit,
type: query.type,
refresh: query.refresh,
})
} catch (error) {
return handleWorkspaceError(error, reply)
}
})
app.get<{
Params: { id: string }
Querystring: { path?: string }
}>("/api/workspaces/:id/files/content", async (request, reply) => {
try {
const query = WorkspaceFileContentQuerySchema.parse(request.query ?? {})
return deps.workspaceManager.readFile(request.params.id, query.path)
} catch (error) {
return handleWorkspaceError(error, reply)
}
})
app.get<{ Params: { id: string } }>("/api/workspaces/:id/git/status", async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
const result = spawnSync("git", ["-C", workspace.path, "status", "--porcelain=v1", "-b"], { encoding: "utf8" })
if (result.error) {
return {
isRepo: false,
branch: null,
ahead: 0,
behind: 0,
changes: [],
error: result.error.message,
}
}
if (result.status !== 0) {
const stderr = (result.stderr || "").toLowerCase()
if (stderr.includes("not a git repository")) {
return { isRepo: false, branch: null, ahead: 0, behind: 0, changes: [] }
}
reply.code(400)
return {
isRepo: false,
branch: null,
ahead: 0,
behind: 0,
changes: [],
error: result.stderr || "Unable to read git status",
}
}
const lines = (result.stdout || "").split(/\r?\n/).filter((line) => line.trim().length > 0)
let branch: string | null = null
let ahead = 0
let behind = 0
const changes: Array<{ path: string; status: string }> = []
for (const line of lines) {
if (line.startsWith("##")) {
const header = line.replace(/^##\s*/, "")
const [branchPart, trackingPart] = header.split("...")
branch = branchPart?.trim() || null
const tracking = trackingPart || ""
const aheadMatch = tracking.match(/ahead\s+(\d+)/)
const behindMatch = tracking.match(/behind\s+(\d+)/)
ahead = aheadMatch ? Number(aheadMatch[1]) : 0
behind = behindMatch ? Number(behindMatch[1]) : 0
continue
}
const status = line.slice(0, 2).trim() || line.slice(0, 2)
const path = line.slice(3).trim()
changes.push({ path, status })
}
return { isRepo: true, branch, ahead, behind, changes }
})
app.post<{
Params: { id: string }
Body: { destination: string; includeConfig?: boolean }
}>("/api/workspaces/:id/export", async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
const payload = request.body ?? { destination: "" }
const destination = payload.destination?.trim()
if (!destination) {
reply.code(400)
return { error: "Destination is required" }
}
const exportRoot = path.join(destination, `nomadarch-export-${path.basename(workspace.path)}-${Date.now()}`)
mkdirSync(exportRoot, { recursive: true })
const workspaceTarget = path.join(exportRoot, "workspace")
await cp(workspace.path, workspaceTarget, { recursive: true, force: true })
const instanceData = await deps.instanceStore.read(workspace.path)
await writeFile(path.join(exportRoot, "instance-data.json"), JSON.stringify(instanceData, null, 2), "utf-8")
const configDir = getWorkspaceOpencodeConfigDir(workspace.id)
if (existsSync(configDir)) {
await cp(configDir, path.join(exportRoot, "opencode-config"), { recursive: true, force: true })
}
if (payload.includeConfig) {
const config = deps.configStore.get()
await writeFile(path.join(exportRoot, "user-config.json"), JSON.stringify(config, null, 2), "utf-8")
}
const metadata = {
exportedAt: new Date().toISOString(),
workspacePath: workspace.path,
workspaceId: workspace.id,
}
await writeFile(path.join(exportRoot, "metadata.json"), JSON.stringify(metadata, null, 2), "utf-8")
return { destination: exportRoot }
})
app.get<{ Params: { id: string } }>("/api/workspaces/:id/mcp-config", async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
const configPath = path.join(workspace.path, ".mcp.json")
if (!existsSync(configPath)) {
return { path: configPath, exists: false, config: { mcpServers: {} } }
}
try {
const raw = await readFile(configPath, "utf-8")
const parsed = raw ? JSON.parse(raw) : {}
return { path: configPath, exists: true, config: parsed }
} catch (error) {
request.log.error({ err: error }, "Failed to read MCP config")
reply.code(500)
return { error: "Failed to read MCP config" }
}
})
app.put<{ Params: { id: string } }>("/api/workspaces/:id/mcp-config", async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
const body = request.body as { config?: unknown }
if (!body || typeof body.config !== "object" || body.config === null) {
reply.code(400)
return { error: "Invalid MCP config payload" }
}
const configPath = path.join(workspace.path, ".mcp.json")
try {
await writeFile(configPath, JSON.stringify(body.config, null, 2), "utf-8")
// Auto-load MCP config into the manager after saving
const { getMcpManager } = await import("../../mcp/client")
const mcpManager = getMcpManager()
await mcpManager.loadConfig(workspace.path)
return { path: configPath, exists: true, config: body.config }
} catch (error) {
request.log.error({ err: error }, "Failed to write MCP config")
reply.code(500)
return { error: "Failed to write MCP config" }
}
})
// Get MCP connection status for a workspace
app.get<{ Params: { id: string } }>("/api/workspaces/:id/mcp-status", async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
try {
const { getMcpManager } = await import("../../mcp/client")
const mcpManager = getMcpManager()
// Load config if not already loaded
await mcpManager.loadConfig(workspace.path)
const status = mcpManager.getStatus()
const tools = await mcpManager.getAllTools()
return {
servers: status,
toolCount: tools.length,
tools: tools.map(t => ({ name: t.name, server: t.serverName, description: t.description }))
}
} catch (error) {
request.log.error({ err: error }, "Failed to get MCP status")
reply.code(500)
return { error: "Failed to get MCP status" }
}
})
// Connect all configured MCPs for a workspace
app.post<{ Params: { id: string } }>("/api/workspaces/:id/mcp-connect", async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
try {
const { getMcpManager } = await import("../../mcp/client")
const mcpManager = getMcpManager()
// Load config first
await mcpManager.loadConfig(workspace.path)
// Explicitly connect all servers
const connectionResults = await mcpManager.connectAll()
// Get tools from connected servers
const tools = await mcpManager.getAllTools()
// Transform connection results to status format
const status: Record<string, { connected: boolean }> = {}
for (const [name, result] of Object.entries(connectionResults)) {
status[name] = { connected: result.connected }
}
return {
success: true,
servers: status,
toolCount: tools.length,
connectionDetails: connectionResults
}
} catch (error) {
request.log.error({ err: error }, "Failed to connect MCPs")
reply.code(500)
return { error: "Failed to connect MCPs" }
}
})
app.post<{
Params: { id: string }
Body: { name: string; description?: string; systemPrompt: string; mode?: string }
}>("/api/workspaces/:id/agents", async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
const { name, description, systemPrompt } = request.body
if (!name || !systemPrompt) {
reply.code(400)
return { error: "Name and systemPrompt are required" }
}
try {
const data = await deps.instanceStore.read(workspace.path)
const customAgents = data.customAgents || []
// Update existing or add new
const existingIndex = customAgents.findIndex(a => a.name === name)
const agentData = { name, description, prompt: systemPrompt }
if (existingIndex >= 0) {
customAgents[existingIndex] = agentData
} else {
customAgents.push(agentData)
}
await deps.instanceStore.write(workspace.path, {
...data,
customAgents
})
return { success: true, agent: agentData }
} catch (error) {
request.log.error({ err: error }, "Failed to save custom agent")
reply.code(500)
return { error: "Failed to save custom agent" }
}
})
app.post<{
Body: { source: string; destination: string; includeConfig?: boolean }
}>("/api/workspaces/import", async (request, reply) => {
const payload = request.body ?? { source: "", destination: "" }
const source = payload.source?.trim()
const destination = payload.destination?.trim()
if (!source || !destination) {
reply.code(400)
return { error: "Source and destination are required" }
}
const workspaceSource = path.join(source, "workspace")
if (!existsSync(workspaceSource)) {
reply.code(400)
return { error: "Export workspace folder not found" }
}
await cp(workspaceSource, destination, { recursive: true, force: true })
const workspace = await deps.workspaceManager.create(destination)
const instanceDataPath = path.join(source, "instance-data.json")
if (existsSync(instanceDataPath)) {
const raw = await readFile(instanceDataPath, "utf-8")
await deps.instanceStore.write(workspace.path, JSON.parse(raw))
}
const configSource = path.join(source, "opencode-config")
if (existsSync(configSource)) {
const configTarget = getWorkspaceOpencodeConfigDir(workspace.id)
await cp(configSource, configTarget, { recursive: true, force: true })
}
if (payload.includeConfig) {
const userConfigPath = path.join(source, "user-config.json")
if (existsSync(userConfigPath)) {
const raw = await readFile(userConfigPath, "utf-8")
deps.configStore.replace(JSON.parse(raw))
}
}
return workspace
})
// Serve static files from workspace for preview
app.get<{ Params: { id: string; "*": string } }>("/api/workspaces/:id/serve/*", async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
const relativePath = request.params["*"]
const filePath = path.join(workspace.path, relativePath)
// Security check: ensure file is within workspace.path
if (!filePath.startsWith(workspace.path)) {
reply.code(403)
return { error: "Access denied" }
}
if (!existsSync(filePath)) {
reply.code(404)
return { error: "File not found" }
}
const stat = await readFileStat(filePath)
if (!stat.isFile()) {
reply.code(400)
return { error: "Not a file" }
}
const ext = path.extname(filePath).toLowerCase()
const mimeTypes: Record<string, string> = {
".html": "text/html",
".htm": "text/html",
".js": "application/javascript",
".css": "text/css",
".json": "application/json",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".txt": "text/plain",
}
reply.type(mimeTypes[ext] || "application/octet-stream")
return await readFile(filePath)
})
}
function handleWorkspaceError(error: unknown, reply: FastifyReply) {
if (error instanceof Error && error.message === "Workspace not found") {
reply.code(404)
return { error: "Workspace not found" }
}
reply.code(400)
return { error: error instanceof Error ? error.message : "Unable to fulfill request" }
}