Add CLI server and move UI to HTTP API

This commit is contained in:
Shantur Rathore
2025-11-17 18:18:45 +00:00
parent 89bd32814f
commit 08d81f8bb5
40 changed files with 3153 additions and 462 deletions

View File

@@ -0,0 +1,143 @@
import type {
AppConfig,
AppConfigUpdateRequest,
BinaryCreateRequest,
BinaryListResponse,
BinaryUpdateRequest,
BinaryValidationResult,
FileSystemEntry,
InstanceData,
ServerMeta,
WorkspaceCreateRequest,
WorkspaceDescriptor,
WorkspaceFileResponse,
WorkspaceLogEntry,
WorkspaceEventPayload,
WorkspaceEventType,
} from "../../../cli/src/api-types"
const DEFAULT_BASE = typeof window !== "undefined" ? window.__CODENOMAD_API_BASE__ ?? "" : ""
const DEFAULT_EVENTS_URL = typeof window !== "undefined" ? window.__CODENOMAD_EVENTS_URL__ ?? "/api/events" : "/api/events"
const API_BASE = import.meta.env.VITE_CODENOMAD_API_BASE ?? DEFAULT_BASE
const EVENTS_URL = API_BASE ? `${API_BASE}${DEFAULT_EVENTS_URL}` : DEFAULT_EVENTS_URL
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const url = API_BASE ? new URL(path, API_BASE).toString() : path
const headers: HeadersInit = {
"Content-Type": "application/json",
...(init?.headers ?? {}),
}
const response = await fetch(url, { ...init, headers })
if (!response.ok) {
const message = await response.text()
throw new Error(message || `Request failed with ${response.status}`)
}
if (response.status === 204) {
return undefined as T
}
return (await response.json()) as T
}
export const cliApi = {
fetchWorkspaces(): Promise<WorkspaceDescriptor[]> {
return request<WorkspaceDescriptor[]>("/api/workspaces")
},
createWorkspace(payload: WorkspaceCreateRequest): Promise<WorkspaceDescriptor> {
return request<WorkspaceDescriptor>("/api/workspaces", {
method: "POST",
body: JSON.stringify(payload),
})
},
fetchServerMeta(): Promise<ServerMeta> {
return request<ServerMeta>("/api/meta")
},
deleteWorkspace(id: string): Promise<void> {
return request(`/api/workspaces/${encodeURIComponent(id)}`, { method: "DELETE" })
},
listWorkspaceFiles(id: string, relativePath = "."): Promise<FileSystemEntry[]> {
const params = new URLSearchParams({ path: relativePath })
return request<FileSystemEntry[]>(`/api/workspaces/${encodeURIComponent(id)}/files?${params.toString()}`)
},
readWorkspaceFile(id: string, relativePath: string): Promise<WorkspaceFileResponse> {
const params = new URLSearchParams({ path: relativePath })
return request<WorkspaceFileResponse>(
`/api/workspaces/${encodeURIComponent(id)}/files/content?${params.toString()}`,
)
},
fetchConfig(): Promise<AppConfig> {
return request<AppConfig>("/api/config/app")
},
updateConfig(payload: AppConfig): Promise<AppConfig> {
return request<AppConfig>("/api/config/app", {
method: "PUT",
body: JSON.stringify(payload),
})
},
patchConfig(payload: AppConfigUpdateRequest): Promise<AppConfig> {
return request<AppConfig>("/api/config/app", {
method: "PATCH",
body: JSON.stringify(payload),
})
},
listBinaries(): Promise<BinaryListResponse> {
return request<BinaryListResponse>("/api/config/binaries")
},
createBinary(payload: BinaryCreateRequest) {
return request<{ binary: BinaryListResponse["binaries"][number] }>("/api/config/binaries", {
method: "POST",
body: JSON.stringify(payload),
})
},
updateBinary(id: string, updates: BinaryUpdateRequest) {
return request<{ binary: BinaryListResponse["binaries"][number] }>(`/api/config/binaries/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(updates),
})
},
deleteBinary(id: string): Promise<void> {
return request(`/api/config/binaries/${encodeURIComponent(id)}`, { method: "DELETE" })
},
validateBinary(path: string): Promise<BinaryValidationResult> {
return request<BinaryValidationResult>("/api/config/binaries/validate", {
method: "POST",
body: JSON.stringify({ path }),
})
},
listFileSystem(relativePath = "."): Promise<FileSystemEntry[]> {
const params = new URLSearchParams({ path: relativePath })
return request<FileSystemEntry[]>(`/api/filesystem?${params.toString()}`)
},
readInstanceData(id: string): Promise<InstanceData> {
return request<InstanceData>(`/api/storage/instances/${encodeURIComponent(id)}`)
},
writeInstanceData(id: string, data: InstanceData): Promise<void> {
return request(`/api/storage/instances/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify(data),
})
},
deleteInstanceData(id: string): Promise<void> {
return request(`/api/storage/instances/${encodeURIComponent(id)}`, { method: "DELETE" })
},
connectEvents(onEvent: (event: WorkspaceEventPayload) => void, onError?: () => void) {
const source = new EventSource(EVENTS_URL)
source.onmessage = (event) => {
try {
const payload = JSON.parse(event.data) as WorkspaceEventPayload
onEvent(payload)
} catch (error) {
console.error("Failed to parse SSE event", error)
}
}
source.onerror = () => {
onError?.()
}
return source
},
}
export type { WorkspaceDescriptor, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType }

View File

@@ -0,0 +1,52 @@
import type { WorkspaceEventPayload, WorkspaceEventType } from "../../../cli/src/api-types"
import { cliApi } from "./api-client"
const RETRY_BASE_DELAY = 1000
const RETRY_MAX_DELAY = 10000
class CliEvents {
private handlers = new Map<WorkspaceEventType | "*", Set<(event: WorkspaceEventPayload) => void>>()
private source: EventSource | null = null
private retryDelay = RETRY_BASE_DELAY
constructor() {
this.connect()
}
private connect() {
if (this.source) {
this.source.close()
}
this.source = cliApi.connectEvents((event) => this.dispatch(event), () => this.scheduleReconnect())
this.source.onopen = () => {
this.retryDelay = RETRY_BASE_DELAY
}
}
private scheduleReconnect() {
if (this.source) {
this.source.close()
this.source = null
}
setTimeout(() => {
this.retryDelay = Math.min(this.retryDelay * 2, RETRY_MAX_DELAY)
this.connect()
}, this.retryDelay)
}
private dispatch(event: WorkspaceEventPayload) {
this.handlers.get("*")?.forEach((handler) => handler(event))
this.handlers.get(event.type)?.forEach((handler) => handler(event))
}
on(type: WorkspaceEventType | "*", handler: (event: WorkspaceEventPayload) => void): () => void {
if (!this.handlers.has(type)) {
this.handlers.set(type, new Set())
}
const bucket = this.handlers.get(type)!
bucket.add(handler)
return () => bucket.delete(handler)
}
}
export const cliEvents = new CliEvents()

View File

@@ -7,7 +7,6 @@ import { registerEscapeShortcut, setEscapeStateChangeHandler } from "../shortcut
import { keyboardRegistry } from "../keyboard-registry"
import { abortSession, getSessions, isSessionBusy } from "../../stores/sessions"
import { showCommandPalette, hideCommandPalette } from "../../stores/command-palette"
import { addLog, updateInstance } from "../../stores/instances"
import type { Instance } from "../../types/instance"
interface UseAppLifecycleOptions {
@@ -148,29 +147,6 @@ export function useAppLifecycle(options: UseAppLifecycleOptions) {
window.addEventListener("keydown", handleKeyDown)
window.electronAPI.onNewInstance(() => {
options.handleNewInstanceRequest()
})
window.electronAPI.onInstanceStarted(({ id, port, pid, binaryPath }) => {
console.log("Instance started:", { id, port, pid, binaryPath })
updateInstance(id, { port, pid, status: "ready", binaryPath })
})
window.electronAPI.onInstanceError(({ id, error }) => {
console.error("Instance error:", { id, error })
updateInstance(id, { status: "error", error })
})
window.electronAPI.onInstanceStopped(({ id }) => {
console.log("Instance stopped:", id)
updateInstance(id, { status: "stopped" })
})
window.electronAPI.onInstanceLog(({ id, entry }) => {
addLog(id, entry)
})
onCleanup(() => {
window.removeEventListener("keydown", handleKeyDown)
})

View File

@@ -0,0 +1,20 @@
import type { ServerMeta } from "../../../cli/src/api-types"
import { cliApi } from "./api-client"
let cachedMeta: ServerMeta | null = null
let pendingMeta: Promise<ServerMeta> | null = null
export async function getServerMeta(): Promise<ServerMeta> {
if (cachedMeta) {
return cachedMeta
}
if (pendingMeta) {
return pendingMeta
}
pendingMeta = cliApi.fetchServerMeta().then((meta) => {
cachedMeta = meta
pendingMeta = null
return meta
})
return pendingMeta
}

View File

@@ -1,162 +1,48 @@
import type { Preferences, RecentFolder, OpenCodeBinary } from "../stores/preferences"
import type { AppConfig, InstanceData } from "../../../cli/src/api-types"
import { cliApi } from "./api-client"
import { cliEvents } from "./cli-events"
export interface ConfigData {
preferences: Preferences
recentFolders: RecentFolder[]
opencodeBinaries: OpenCodeBinary[]
theme?: "light" | "dark" | "system"
}
export type ConfigData = AppConfig
export interface InstanceData {
messageHistory: string[]
}
export class FileStorage {
private configPath: string | undefined
private instancesDir: string | undefined
export class ServerStorage {
private configChangeListeners: Set<() => void> = new Set()
private initialized = false
constructor() {
this.initialize()
cliEvents.on("config.appChanged", () => this.notifyConfigChanged())
}
private async initialize() {
if (this.initialized) return
this.configPath = await window.electronAPI.getConfigPath()
this.instancesDir = await window.electronAPI.getInstancesDir()
// Listen for config changes from other instances
window.electronAPI.onConfigChanged(() => {
this.configChangeListeners.forEach((listener) => listener())
})
this.initialized = true
}
private async ensureInitialized() {
if (!this.initialized) {
await this.initialize()
}
}
private parseConfig(content: string): ConfigData {
const trimmed = content.trim()
try {
return JSON.parse(trimmed)
} catch (error) {
const firstBrace = trimmed.indexOf("{")
const lastBrace = trimmed.lastIndexOf("}")
if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
const sanitized = trimmed.slice(firstBrace, lastBrace + 1)
if (sanitized.length !== trimmed.length) {
console.warn("Config file contained trailing data; attempting recovery")
}
try {
return JSON.parse(sanitized)
} catch {
// Fall through to rethrow original error below
}
}
throw error
}
}
// Config operations
async loadConfig(): Promise<ConfigData> {
await this.ensureInitialized()
try {
const content = await window.electronAPI.readConfigFile()
return this.parseConfig(content)
} catch (error) {
console.warn("Failed to load config, using defaults:", error)
return {
preferences: {
showThinkingBlocks: false,
environmentVariables: {},
modelRecents: [],
agentModelSelections: {},
diffViewMode: "split",
toolOutputExpansion: "expanded",
diagnosticsExpansion: "expanded",
},
recentFolders: [],
opencodeBinaries: [],
}
}
const config = await cliApi.fetchConfig()
return config
}
async saveConfig(config: ConfigData): Promise<void> {
await this.ensureInitialized()
try {
await window.electronAPI.writeConfigFile(JSON.stringify(config, null, 2))
} catch (error) {
console.error("Failed to save config:", error)
throw error
}
await cliApi.updateConfig(config)
}
// Instance operations
async loadInstanceData(instanceId: string): Promise<InstanceData> {
await this.ensureInitialized()
try {
const filename = this.instanceIdToFilename(instanceId)
const content = await window.electronAPI.readInstanceFile(filename)
return JSON.parse(content)
} catch (error) {
console.warn(`Failed to load instance data for ${instanceId}, using defaults:`, error)
return {
messageHistory: [],
}
}
return cliApi.readInstanceData(instanceId)
}
async saveInstanceData(instanceId: string, data: InstanceData): Promise<void> {
await this.ensureInitialized()
try {
const filename = this.instanceIdToFilename(instanceId)
await window.electronAPI.writeInstanceFile(filename, JSON.stringify(data, null, 2))
} catch (error) {
console.error(`Failed to save instance data for ${instanceId}:`, error)
throw error
}
await cliApi.writeInstanceData(instanceId, data)
}
async deleteInstanceData(instanceId: string): Promise<void> {
await this.ensureInitialized()
try {
const filename = this.instanceIdToFilename(instanceId)
await window.electronAPI.deleteInstanceFile(filename)
} catch (error) {
console.error(`Failed to delete instance data for ${instanceId}:`, error)
throw error
}
await cliApi.deleteInstanceData(instanceId)
}
// Convert folder path to safe filename
private instanceIdToFilename(instanceId: string): string {
// Convert folder path to safe filename
// Replace path separators and other invalid characters
return instanceId
.replace(/[\\/]/g, "_") // Replace path separators
.replace(/[^a-zA-Z0-9_.-]/g, "_") // Replace other invalid chars
.replace(/_{2,}/g, "_") // Replace multiple underscores with single
.replace(/^_|_$/g, "") // Remove leading/trailing underscores
.toLowerCase()
}
// Config change listeners
onConfigChanged(listener: () => void): () => void {
this.configChangeListeners.add(listener)
return () => this.configChangeListeners.delete(listener)
}
private notifyConfigChanged() {
for (const listener of this.configChangeListeners) {
listener()
}
}
}
// Singleton instance
export const storage = new FileStorage()
export const storage = new ServerStorage()