Add CLI server and move UI to HTTP API
This commit is contained in:
143
packages/ui/src/lib/api-client.ts
Normal file
143
packages/ui/src/lib/api-client.ts
Normal 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 }
|
||||
52
packages/ui/src/lib/cli-events.ts
Normal file
52
packages/ui/src/lib/cli-events.ts
Normal 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()
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
20
packages/ui/src/lib/server-meta.ts
Normal file
20
packages/ui/src/lib/server-meta.ts
Normal 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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user