Add CLI server and move UI to HTTP API
This commit is contained in:
49
packages/cli/src/server/http-server.ts
Normal file
49
packages/cli/src/server/http-server.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import Fastify from "fastify"
|
||||
import cors from "@fastify/cors"
|
||||
import { WorkspaceManager } from "../workspaces/manager"
|
||||
import { ConfigStore } from "../config/store"
|
||||
import { BinaryRegistry } from "../config/binaries"
|
||||
import { FileSystemBrowser } from "../filesystem/browser"
|
||||
import { EventBus } from "../events/bus"
|
||||
import { registerWorkspaceRoutes } from "./routes/workspaces"
|
||||
import { registerConfigRoutes } from "./routes/config"
|
||||
import { registerFilesystemRoutes } from "./routes/filesystem"
|
||||
import { registerMetaRoutes } from "./routes/meta"
|
||||
import { registerEventRoutes } from "./routes/events"
|
||||
import { registerStorageRoutes } from "./routes/storage"
|
||||
import { ServerMeta } from "../api-types"
|
||||
import { InstanceStore } from "../storage/instance-store"
|
||||
|
||||
interface HttpServerDeps {
|
||||
host: string
|
||||
port: number
|
||||
workspaceManager: WorkspaceManager
|
||||
configStore: ConfigStore
|
||||
binaryRegistry: BinaryRegistry
|
||||
fileSystemBrowser: FileSystemBrowser
|
||||
eventBus: EventBus
|
||||
serverMeta: ServerMeta
|
||||
instanceStore: InstanceStore
|
||||
}
|
||||
|
||||
export function createHttpServer(deps: HttpServerDeps) {
|
||||
const app = Fastify({ logger: false })
|
||||
|
||||
app.register(cors, {
|
||||
origin: true,
|
||||
credentials: true,
|
||||
})
|
||||
|
||||
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
|
||||
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
|
||||
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
||||
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
|
||||
registerEventRoutes(app, { eventBus: deps.eventBus })
|
||||
registerStorageRoutes(app, { instanceStore: deps.instanceStore })
|
||||
|
||||
return {
|
||||
instance: app,
|
||||
start: () => app.listen({ port: deps.port, host: deps.host }),
|
||||
stop: () => app.close(),
|
||||
}
|
||||
}
|
||||
68
packages/cli/src/server/routes/config.ts
Normal file
68
packages/cli/src/server/routes/config.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import { ConfigStore } from "../../config/store"
|
||||
import { BinaryRegistry } from "../../config/binaries"
|
||||
import { ConfigFileSchema, ConfigFileUpdateSchema } from "../../config/schema"
|
||||
|
||||
interface RouteDeps {
|
||||
configStore: ConfigStore
|
||||
binaryRegistry: BinaryRegistry
|
||||
}
|
||||
|
||||
const BinaryCreateSchema = z.object({
|
||||
path: z.string(),
|
||||
label: z.string().optional(),
|
||||
makeDefault: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const BinaryUpdateSchema = z.object({
|
||||
label: z.string().optional(),
|
||||
makeDefault: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const BinaryValidateSchema = z.object({
|
||||
path: z.string(),
|
||||
})
|
||||
|
||||
export function registerConfigRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get("/api/config/app", async () => deps.configStore.get())
|
||||
|
||||
app.put("/api/config/app", async (request) => {
|
||||
const body = ConfigFileSchema.parse(request.body ?? {})
|
||||
deps.configStore.update(body)
|
||||
return deps.configStore.get()
|
||||
})
|
||||
|
||||
app.patch("/api/config/app", async (request) => {
|
||||
const body = ConfigFileUpdateSchema.parse(request.body ?? {})
|
||||
deps.configStore.update(body)
|
||||
return deps.configStore.get()
|
||||
})
|
||||
|
||||
app.get("/api/config/binaries", async () => {
|
||||
return { binaries: deps.binaryRegistry.list() }
|
||||
})
|
||||
|
||||
app.post("/api/config/binaries", async (request, reply) => {
|
||||
const body = BinaryCreateSchema.parse(request.body ?? {})
|
||||
const binary = deps.binaryRegistry.create(body)
|
||||
reply.code(201)
|
||||
return { binary }
|
||||
})
|
||||
|
||||
app.patch<{ Params: { id: string } }>("/api/config/binaries/:id", async (request) => {
|
||||
const body = BinaryUpdateSchema.parse(request.body ?? {})
|
||||
const binary = deps.binaryRegistry.update(request.params.id, body)
|
||||
return { binary }
|
||||
})
|
||||
|
||||
app.delete<{ Params: { id: string } }>("/api/config/binaries/:id", async (request, reply) => {
|
||||
deps.binaryRegistry.remove(request.params.id)
|
||||
reply.code(204)
|
||||
})
|
||||
|
||||
app.post("/api/config/binaries/validate", async (request) => {
|
||||
const body = BinaryValidateSchema.parse(request.body ?? {})
|
||||
return deps.binaryRegistry.validatePath(body.path)
|
||||
})
|
||||
}
|
||||
37
packages/cli/src/server/routes/events.ts
Normal file
37
packages/cli/src/server/routes/events.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { EventBus } from "../../events/bus"
|
||||
import { WorkspaceEventPayload } from "../../api-types"
|
||||
|
||||
interface RouteDeps {
|
||||
eventBus: EventBus
|
||||
}
|
||||
|
||||
export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get("/api/events", (request, reply) => {
|
||||
const origin = request.headers.origin ?? "*"
|
||||
reply.raw.setHeader("Access-Control-Allow-Origin", origin)
|
||||
reply.raw.setHeader("Access-Control-Allow-Credentials", "true")
|
||||
reply.raw.setHeader("Content-Type", "text/event-stream")
|
||||
reply.raw.setHeader("Cache-Control", "no-cache")
|
||||
reply.raw.setHeader("Connection", "keep-alive")
|
||||
reply.raw.flushHeaders?.()
|
||||
reply.hijack()
|
||||
|
||||
const send = (event: WorkspaceEventPayload) => {
|
||||
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`)
|
||||
}
|
||||
|
||||
const unsubscribe = deps.eventBus.onEvent(send)
|
||||
const heartbeat = setInterval(() => {
|
||||
reply.raw.write(`:hb ${Date.now()}\n\n`)
|
||||
}, 15000)
|
||||
|
||||
const close = () => {
|
||||
clearInterval(heartbeat)
|
||||
unsubscribe()
|
||||
}
|
||||
|
||||
request.raw.on("close", close)
|
||||
request.raw.on("error", close)
|
||||
})
|
||||
}
|
||||
25
packages/cli/src/server/routes/filesystem.ts
Normal file
25
packages/cli/src/server/routes/filesystem.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import { FileSystemBrowser } from "../../filesystem/browser"
|
||||
|
||||
interface RouteDeps {
|
||||
fileSystemBrowser: FileSystemBrowser
|
||||
}
|
||||
|
||||
const FilesystemQuerySchema = z.object({
|
||||
path: z.string().optional(),
|
||||
})
|
||||
|
||||
export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get("/api/filesystem", async (request, reply) => {
|
||||
const query = FilesystemQuerySchema.parse(request.query ?? {})
|
||||
const targetPath = query.path ?? "."
|
||||
|
||||
try {
|
||||
return deps.fileSystemBrowser.list(targetPath)
|
||||
} catch (error) {
|
||||
reply.code(400)
|
||||
return { error: (error as Error).message }
|
||||
}
|
||||
})
|
||||
}
|
||||
10
packages/cli/src/server/routes/meta.ts
Normal file
10
packages/cli/src/server/routes/meta.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { ServerMeta } from "../../api-types"
|
||||
|
||||
interface RouteDeps {
|
||||
serverMeta: ServerMeta
|
||||
}
|
||||
|
||||
export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get("/api/meta", async () => deps.serverMeta)
|
||||
}
|
||||
44
packages/cli/src/server/routes/storage.ts
Normal file
44
packages/cli/src/server/routes/storage.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import { InstanceStore } from "../../storage/instance-store"
|
||||
|
||||
interface RouteDeps {
|
||||
instanceStore: InstanceStore
|
||||
}
|
||||
|
||||
const InstanceDataSchema = z.object({
|
||||
messageHistory: z.array(z.string()).default([]),
|
||||
})
|
||||
|
||||
export function registerStorageRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => {
|
||||
try {
|
||||
const data = await deps.instanceStore.read(request.params.id)
|
||||
return data
|
||||
} catch (error) {
|
||||
reply.code(500)
|
||||
return { error: error instanceof Error ? error.message : "Failed to read instance data" }
|
||||
}
|
||||
})
|
||||
|
||||
app.put<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => {
|
||||
try {
|
||||
const body = InstanceDataSchema.parse(request.body ?? {})
|
||||
await deps.instanceStore.write(request.params.id, body)
|
||||
reply.code(204)
|
||||
} catch (error) {
|
||||
reply.code(400)
|
||||
return { error: error instanceof Error ? error.message : "Failed to save instance data" }
|
||||
}
|
||||
})
|
||||
|
||||
app.delete<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => {
|
||||
try {
|
||||
await deps.instanceStore.delete(request.params.id)
|
||||
reply.code(204)
|
||||
} catch (error) {
|
||||
reply.code(500)
|
||||
return { error: error instanceof Error ? error.message : "Failed to delete instance data" }
|
||||
}
|
||||
})
|
||||
}
|
||||
80
packages/cli/src/server/routes/workspaces.ts
Normal file
80
packages/cli/src/server/routes/workspaces.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { FastifyInstance, FastifyReply } from "fastify"
|
||||
import { z } from "zod"
|
||||
import { WorkspaceManager } from "../../workspaces/manager"
|
||||
|
||||
interface RouteDeps {
|
||||
workspaceManager: WorkspaceManager
|
||||
}
|
||||
|
||||
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(),
|
||||
})
|
||||
|
||||
export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get("/api/workspaces", async () => {
|
||||
return deps.workspaceManager.list()
|
||||
})
|
||||
|
||||
app.post("/api/workspaces", async (request, reply) => {
|
||||
const body = WorkspaceCreateSchema.parse(request.body ?? {})
|
||||
const workspace = await deps.workspaceManager.create(body.path, body.name)
|
||||
reply.code(201)
|
||||
return workspace
|
||||
})
|
||||
|
||||
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: { 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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" }
|
||||
}
|
||||
Reference in New Issue
Block a user