Add CLI server and move UI to HTTP API
This commit is contained in:
153
packages/cli/src/api-types.ts
Normal file
153
packages/cli/src/api-types.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import type {
|
||||
AgentModelSelections,
|
||||
ConfigFile,
|
||||
ModelPreference,
|
||||
OpenCodeBinary,
|
||||
Preferences,
|
||||
RecentFolder,
|
||||
} from "./config/schema"
|
||||
|
||||
/**
|
||||
* Canonical HTTP/SSE contract for the CLI server.
|
||||
* These types are consumed by both the CLI implementation and any UI clients.
|
||||
*/
|
||||
|
||||
export type WorkspaceStatus = "starting" | "ready" | "stopped" | "error"
|
||||
|
||||
export interface WorkspaceDescriptor {
|
||||
id: string
|
||||
/** Absolute path on the server host. */
|
||||
path: string
|
||||
name?: string
|
||||
status: WorkspaceStatus
|
||||
/** PID/port are populated when the workspace is running. */
|
||||
pid?: number
|
||||
port?: number
|
||||
/** Identifier of the binary resolved from config. */
|
||||
binaryId: string
|
||||
binaryLabel: string
|
||||
binaryVersion?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
/** Present when `status` is "error". */
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface WorkspaceCreateRequest {
|
||||
path: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export type WorkspaceCreateResponse = WorkspaceDescriptor
|
||||
export type WorkspaceListResponse = WorkspaceDescriptor[]
|
||||
export type WorkspaceDetailResponse = WorkspaceDescriptor
|
||||
|
||||
export interface WorkspaceDeleteResponse {
|
||||
id: string
|
||||
status: WorkspaceStatus
|
||||
}
|
||||
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error"
|
||||
|
||||
export interface WorkspaceLogEntry {
|
||||
workspaceId: string
|
||||
timestamp: string
|
||||
level: LogLevel
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface FileSystemEntry {
|
||||
name: string
|
||||
/** Path relative to the CLI server root ("." represents the root itself). */
|
||||
path: string
|
||||
type: "file" | "directory"
|
||||
size?: number
|
||||
/** ISO timestamp of last modification when available. */
|
||||
modifiedAt?: string
|
||||
}
|
||||
|
||||
export type FileSystemListResponse = FileSystemEntry[]
|
||||
|
||||
export interface WorkspaceFileResponse {
|
||||
workspaceId: string
|
||||
relativePath: string
|
||||
/** UTF-8 file contents; binary files should be base64 encoded by the caller. */
|
||||
contents: string
|
||||
}
|
||||
|
||||
export interface InstanceData {
|
||||
messageHistory: string[]
|
||||
}
|
||||
|
||||
export interface BinaryRecord {
|
||||
id: string
|
||||
path: string
|
||||
label: string
|
||||
version?: string
|
||||
/** Indicates that this binary will be picked when workspaces omit an explicit choice. */
|
||||
isDefault: boolean
|
||||
lastValidatedAt?: string
|
||||
validationError?: string
|
||||
}
|
||||
|
||||
export type AppConfig = ConfigFile
|
||||
export type AppConfigResponse = AppConfig
|
||||
export type AppConfigUpdateRequest = Partial<AppConfig>
|
||||
|
||||
export interface BinaryListResponse {
|
||||
binaries: BinaryRecord[]
|
||||
}
|
||||
|
||||
export interface BinaryCreateRequest {
|
||||
path: string
|
||||
label?: string
|
||||
makeDefault?: boolean
|
||||
}
|
||||
|
||||
export interface BinaryUpdateRequest {
|
||||
label?: string
|
||||
makeDefault?: boolean
|
||||
}
|
||||
|
||||
export interface BinaryValidationResult {
|
||||
valid: boolean
|
||||
version?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type WorkspaceEventType =
|
||||
| "workspace.created"
|
||||
| "workspace.started"
|
||||
| "workspace.error"
|
||||
| "workspace.stopped"
|
||||
| "workspace.log"
|
||||
| "config.appChanged"
|
||||
| "config.binariesChanged"
|
||||
|
||||
export type WorkspaceEventPayload =
|
||||
| { type: "workspace.created"; workspace: WorkspaceDescriptor }
|
||||
| { type: "workspace.started"; workspace: WorkspaceDescriptor }
|
||||
| { type: "workspace.error"; workspace: WorkspaceDescriptor }
|
||||
| { type: "workspace.stopped"; workspaceId: string }
|
||||
| { type: "workspace.log"; entry: WorkspaceLogEntry }
|
||||
| { type: "config.appChanged"; config: AppConfig }
|
||||
| { type: "config.binariesChanged"; binaries: BinaryRecord[] }
|
||||
|
||||
export interface ServerMeta {
|
||||
/** Base URL clients should target for REST calls (useful for Electron embedding). */
|
||||
httpBaseUrl: string
|
||||
/** SSE endpoint advertised to clients (`/api/events` by default). */
|
||||
eventsUrl: string
|
||||
/** Display label for the host (e.g., hostname or friendly name). */
|
||||
hostLabel: string
|
||||
/** Absolute path of the filesystem root exposed to clients. */
|
||||
workspaceRoot: string
|
||||
}
|
||||
|
||||
export type {
|
||||
Preferences,
|
||||
ModelPreference,
|
||||
AgentModelSelections,
|
||||
RecentFolder,
|
||||
OpenCodeBinary,
|
||||
}
|
||||
144
packages/cli/src/config/binaries.ts
Normal file
144
packages/cli/src/config/binaries.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import {
|
||||
BinaryCreateRequest,
|
||||
BinaryRecord,
|
||||
BinaryUpdateRequest,
|
||||
BinaryValidationResult,
|
||||
} from "../api-types"
|
||||
import { ConfigStore } from "./store"
|
||||
import { EventBus } from "../events/bus"
|
||||
import type { ConfigFileUpdate } from "./schema"
|
||||
|
||||
export class BinaryRegistry {
|
||||
constructor(private readonly configStore: ConfigStore, private readonly eventBus?: EventBus) {}
|
||||
|
||||
list(): BinaryRecord[] {
|
||||
return this.mapRecords()
|
||||
}
|
||||
|
||||
resolveDefault(): BinaryRecord {
|
||||
const binaries = this.mapRecords()
|
||||
if (binaries.length === 0) {
|
||||
return this.buildFallbackRecord("opencode")
|
||||
}
|
||||
return binaries.find((binary) => binary.isDefault) ?? binaries[0]
|
||||
}
|
||||
|
||||
create(request: BinaryCreateRequest): BinaryRecord {
|
||||
const entry = {
|
||||
path: request.path,
|
||||
version: undefined,
|
||||
lastUsed: Date.now(),
|
||||
label: request.label,
|
||||
}
|
||||
|
||||
const config = this.configStore.get()
|
||||
const deduped = config.opencodeBinaries.filter((binary) => binary.path !== request.path)
|
||||
|
||||
const update: ConfigFileUpdate = {
|
||||
opencodeBinaries: [entry, ...deduped],
|
||||
}
|
||||
|
||||
if (request.makeDefault) {
|
||||
update.preferences = { lastUsedBinary: request.path }
|
||||
}
|
||||
|
||||
this.configStore.update(update)
|
||||
const record = this.getById(request.path)
|
||||
this.emitChange()
|
||||
return record
|
||||
}
|
||||
|
||||
update(id: string, updates: BinaryUpdateRequest): BinaryRecord {
|
||||
const config = this.configStore.get()
|
||||
const updatedEntries = config.opencodeBinaries.map((binary) =>
|
||||
binary.path === id ? { ...binary, label: updates.label ?? binary.label } : binary,
|
||||
)
|
||||
|
||||
const update: ConfigFileUpdate = {
|
||||
opencodeBinaries: updatedEntries,
|
||||
}
|
||||
|
||||
if (updates.makeDefault) {
|
||||
update.preferences = { lastUsedBinary: id }
|
||||
}
|
||||
|
||||
this.configStore.update(update)
|
||||
const record = this.getById(id)
|
||||
this.emitChange()
|
||||
return record
|
||||
}
|
||||
|
||||
remove(id: string) {
|
||||
const config = this.configStore.get()
|
||||
const remaining = config.opencodeBinaries.filter((binary) => binary.path !== id)
|
||||
const update: ConfigFileUpdate = { opencodeBinaries: remaining }
|
||||
|
||||
if (config.preferences.lastUsedBinary === id) {
|
||||
update.preferences = { lastUsedBinary: remaining[0]?.path }
|
||||
}
|
||||
|
||||
this.configStore.update(update)
|
||||
this.emitChange()
|
||||
}
|
||||
|
||||
validatePath(path: string): BinaryValidationResult {
|
||||
return this.validateRecord({
|
||||
id: path,
|
||||
path,
|
||||
label: this.prettyLabel(path),
|
||||
isDefault: false,
|
||||
})
|
||||
}
|
||||
|
||||
private mapRecords(): BinaryRecord[] {
|
||||
const config = this.configStore.get()
|
||||
const configuredBinaries = config.opencodeBinaries.map<BinaryRecord>((binary) => ({
|
||||
id: binary.path,
|
||||
path: binary.path,
|
||||
label: binary.label ?? this.prettyLabel(binary.path),
|
||||
version: binary.version,
|
||||
isDefault: false,
|
||||
}))
|
||||
|
||||
const defaultPath = config.preferences.lastUsedBinary ?? configuredBinaries[0]?.path ?? "opencode"
|
||||
|
||||
const annotated = configuredBinaries.map((binary) => ({
|
||||
...binary,
|
||||
isDefault: binary.path === defaultPath,
|
||||
}))
|
||||
|
||||
if (!annotated.some((binary) => binary.path === defaultPath)) {
|
||||
annotated.unshift(this.buildFallbackRecord(defaultPath))
|
||||
}
|
||||
|
||||
return annotated
|
||||
}
|
||||
|
||||
private getById(id: string): BinaryRecord {
|
||||
return this.mapRecords().find((binary) => binary.id === id) ?? this.buildFallbackRecord(id)
|
||||
}
|
||||
|
||||
private emitChange() {
|
||||
this.eventBus?.publish({ type: "config.binariesChanged", binaries: this.mapRecords() })
|
||||
}
|
||||
|
||||
private validateRecord(record: BinaryRecord): BinaryValidationResult {
|
||||
// TODO: call actual binary -v check.
|
||||
return { valid: true, version: record.version }
|
||||
}
|
||||
|
||||
private buildFallbackRecord(path: string): BinaryRecord {
|
||||
return {
|
||||
id: path,
|
||||
path,
|
||||
label: this.prettyLabel(path),
|
||||
isDefault: true,
|
||||
}
|
||||
}
|
||||
|
||||
private prettyLabel(path: string) {
|
||||
const parts = path.split(/[\\/]/)
|
||||
const last = parts[parts.length - 1] || path
|
||||
return last || path
|
||||
}
|
||||
}
|
||||
80
packages/cli/src/config/schema.ts
Normal file
80
packages/cli/src/config/schema.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { z } from "zod"
|
||||
|
||||
const ModelPreferenceSchema = z.object({
|
||||
providerId: z.string(),
|
||||
modelId: z.string(),
|
||||
})
|
||||
|
||||
const AgentModelSelectionSchema = z.record(z.string(), ModelPreferenceSchema)
|
||||
const AgentModelSelectionsSchema = z.record(z.string(), AgentModelSelectionSchema)
|
||||
|
||||
const PreferencesSchema = z.object({
|
||||
showThinkingBlocks: z.boolean().default(false),
|
||||
lastUsedBinary: z.string().optional(),
|
||||
environmentVariables: z.record(z.string()).default({}),
|
||||
modelRecents: z.array(ModelPreferenceSchema).default([]),
|
||||
agentModelSelections: AgentModelSelectionsSchema.default({}),
|
||||
diffViewMode: z.enum(["split", "unified"]).default("split"),
|
||||
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||
})
|
||||
|
||||
const PreferencesUpdateSchema = z.object({
|
||||
showThinkingBlocks: z.boolean().optional(),
|
||||
lastUsedBinary: z.string().optional(),
|
||||
environmentVariables: z.record(z.string()).optional(),
|
||||
modelRecents: z.array(ModelPreferenceSchema).optional(),
|
||||
agentModelSelections: AgentModelSelectionsSchema.optional(),
|
||||
diffViewMode: z.enum(["split", "unified"]).optional(),
|
||||
toolOutputExpansion: z.enum(["expanded", "collapsed"]).optional(),
|
||||
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).optional(),
|
||||
})
|
||||
|
||||
const RecentFolderSchema = z.object({
|
||||
path: z.string(),
|
||||
lastAccessed: z.number().nonnegative(),
|
||||
})
|
||||
|
||||
const OpenCodeBinarySchema = z.object({
|
||||
path: z.string(),
|
||||
version: z.string().optional(),
|
||||
lastUsed: z.number().nonnegative(),
|
||||
label: z.string().optional(),
|
||||
})
|
||||
|
||||
const ConfigFileSchema = z.object({
|
||||
preferences: PreferencesSchema.default({}),
|
||||
recentFolders: z.array(RecentFolderSchema).default([]),
|
||||
opencodeBinaries: z.array(OpenCodeBinarySchema).default([]),
|
||||
theme: z.enum(["light", "dark", "system"]).optional(),
|
||||
})
|
||||
|
||||
const ConfigFileUpdateSchema = z.object({
|
||||
preferences: PreferencesUpdateSchema.optional(),
|
||||
recentFolders: z.array(RecentFolderSchema).optional(),
|
||||
opencodeBinaries: z.array(OpenCodeBinarySchema).optional(),
|
||||
theme: z.enum(["light", "dark", "system"]).optional(),
|
||||
})
|
||||
|
||||
const DEFAULT_CONFIG = ConfigFileSchema.parse({})
|
||||
|
||||
export {
|
||||
ModelPreferenceSchema,
|
||||
AgentModelSelectionSchema,
|
||||
AgentModelSelectionsSchema,
|
||||
PreferencesSchema,
|
||||
RecentFolderSchema,
|
||||
OpenCodeBinarySchema,
|
||||
ConfigFileSchema,
|
||||
ConfigFileUpdateSchema,
|
||||
DEFAULT_CONFIG,
|
||||
}
|
||||
|
||||
export type ModelPreference = z.infer<typeof ModelPreferenceSchema>
|
||||
export type AgentModelSelection = z.infer<typeof AgentModelSelectionSchema>
|
||||
export type AgentModelSelections = z.infer<typeof AgentModelSelectionsSchema>
|
||||
export type Preferences = z.infer<typeof PreferencesSchema>
|
||||
export type RecentFolder = z.infer<typeof RecentFolderSchema>
|
||||
export type OpenCodeBinary = z.infer<typeof OpenCodeBinarySchema>
|
||||
export type ConfigFile = z.infer<typeof ConfigFileSchema>
|
||||
export type ConfigFileUpdate = z.infer<typeof ConfigFileUpdateSchema>
|
||||
111
packages/cli/src/config/store.ts
Normal file
111
packages/cli/src/config/store.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { EventBus } from "../events/bus"
|
||||
import {
|
||||
AgentModelSelections,
|
||||
ConfigFile,
|
||||
ConfigFileUpdate,
|
||||
ConfigFileSchema,
|
||||
ConfigFileUpdateSchema,
|
||||
DEFAULT_CONFIG,
|
||||
} from "./schema"
|
||||
|
||||
export class ConfigStore {
|
||||
private cache: ConfigFile = DEFAULT_CONFIG
|
||||
private loaded = false
|
||||
|
||||
constructor(private readonly configPath: string, private readonly eventBus?: EventBus) {}
|
||||
|
||||
load(): ConfigFile {
|
||||
if (this.loaded) {
|
||||
return this.cache
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = this.resolvePath(this.configPath)
|
||||
if (fs.existsSync(resolved)) {
|
||||
const content = fs.readFileSync(resolved, "utf-8")
|
||||
const parsed = JSON.parse(content)
|
||||
this.cache = ConfigFileSchema.parse(parsed)
|
||||
} else {
|
||||
this.cache = DEFAULT_CONFIG
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to load config", error)
|
||||
this.cache = DEFAULT_CONFIG
|
||||
}
|
||||
|
||||
this.loaded = true
|
||||
return this.cache
|
||||
}
|
||||
|
||||
get(): ConfigFile {
|
||||
return this.load()
|
||||
}
|
||||
|
||||
update(partial: ConfigFile | ConfigFileUpdate) {
|
||||
const safePartial =
|
||||
"recentFolders" in partial && "opencodeBinaries" in partial
|
||||
? ConfigFileSchema.parse(partial)
|
||||
: ConfigFileUpdateSchema.parse(partial ?? {})
|
||||
const merged = this.mergeConfig(this.load(), safePartial)
|
||||
this.cache = ConfigFileSchema.parse(merged)
|
||||
this.persist()
|
||||
this.eventBus?.publish({ type: "config.appChanged", config: this.cache })
|
||||
}
|
||||
|
||||
private mergeConfig(current: ConfigFile, partial: ConfigFile | ConfigFileUpdate): ConfigFile {
|
||||
const mergedPreferences = {
|
||||
...current.preferences,
|
||||
...partial.preferences,
|
||||
environmentVariables: {
|
||||
...current.preferences.environmentVariables,
|
||||
...(partial.preferences?.environmentVariables ?? {}),
|
||||
},
|
||||
agentModelSelections: this.mergeAgentSelections(
|
||||
current.preferences.agentModelSelections,
|
||||
partial.preferences?.agentModelSelections,
|
||||
),
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
...partial,
|
||||
preferences: mergedPreferences,
|
||||
recentFolders: partial.recentFolders ?? current.recentFolders,
|
||||
opencodeBinaries: partial.opencodeBinaries ?? current.opencodeBinaries,
|
||||
}
|
||||
}
|
||||
|
||||
private mergeAgentSelections(base: AgentModelSelections, update?: AgentModelSelections) {
|
||||
if (!update) {
|
||||
return base
|
||||
}
|
||||
|
||||
const result: AgentModelSelections = { ...base }
|
||||
for (const [instanceId, agentMap] of Object.entries(update)) {
|
||||
result[instanceId] = {
|
||||
...(base[instanceId] ?? {}),
|
||||
...agentMap,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private persist() {
|
||||
try {
|
||||
const resolved = this.resolvePath(this.configPath)
|
||||
fs.mkdirSync(path.dirname(resolved), { recursive: true })
|
||||
fs.writeFileSync(resolved, JSON.stringify(this.cache, null, 2), "utf-8")
|
||||
} catch (error) {
|
||||
console.warn("Failed to persist config", error)
|
||||
}
|
||||
}
|
||||
|
||||
private resolvePath(filePath: string) {
|
||||
if (filePath.startsWith("~/")) {
|
||||
return path.join(process.env.HOME ?? "", filePath.slice(2))
|
||||
}
|
||||
return path.resolve(filePath)
|
||||
}
|
||||
}
|
||||
28
packages/cli/src/events/bus.ts
Normal file
28
packages/cli/src/events/bus.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { EventEmitter } from "events"
|
||||
import { WorkspaceEventPayload } from "../api-types"
|
||||
|
||||
export class EventBus extends EventEmitter {
|
||||
publish(event: WorkspaceEventPayload): boolean {
|
||||
return super.emit(event.type, event)
|
||||
}
|
||||
|
||||
onEvent(listener: (event: WorkspaceEventPayload) => void) {
|
||||
const handler = (event: WorkspaceEventPayload) => listener(event)
|
||||
this.on("workspace.created", handler)
|
||||
this.on("workspace.started", handler)
|
||||
this.on("workspace.error", handler)
|
||||
this.on("workspace.stopped", handler)
|
||||
this.on("workspace.log", handler)
|
||||
this.on("config.appChanged", handler)
|
||||
this.on("config.binariesChanged", handler)
|
||||
return () => {
|
||||
this.off("workspace.created", handler)
|
||||
this.off("workspace.started", handler)
|
||||
this.off("workspace.error", handler)
|
||||
this.off("workspace.stopped", handler)
|
||||
this.off("workspace.log", handler)
|
||||
this.off("config.appChanged", handler)
|
||||
this.off("config.binariesChanged", handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
54
packages/cli/src/filesystem/browser.ts
Normal file
54
packages/cli/src/filesystem/browser.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { FileSystemEntry } from "../api-types"
|
||||
|
||||
interface FileSystemBrowserOptions {
|
||||
rootDir: string
|
||||
}
|
||||
|
||||
export class FileSystemBrowser {
|
||||
private readonly root: string
|
||||
|
||||
constructor(options: FileSystemBrowserOptions) {
|
||||
this.root = path.resolve(options.rootDir)
|
||||
}
|
||||
|
||||
list(relativePath: string): FileSystemEntry[] {
|
||||
const resolved = this.toAbsolute(relativePath)
|
||||
const entries = fs.readdirSync(resolved, { withFileTypes: true })
|
||||
|
||||
return entries.flatMap<FileSystemEntry>((entry) => {
|
||||
const entryPath = path.join(relativePath, entry.name)
|
||||
const absolutePath = this.toAbsolute(entryPath)
|
||||
const stats = fs.statSync(absolutePath)
|
||||
|
||||
const current: FileSystemEntry = {
|
||||
name: entry.name,
|
||||
path: entryPath,
|
||||
type: entry.isDirectory() ? "directory" : "file",
|
||||
size: entry.isDirectory() ? undefined : stats.size,
|
||||
modifiedAt: stats.mtime.toISOString(),
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const nested = this.list(entryPath)
|
||||
return [current, ...nested]
|
||||
}
|
||||
|
||||
return [current]
|
||||
})
|
||||
}
|
||||
|
||||
readFile(relativePath: string): string {
|
||||
const resolved = this.toAbsolute(relativePath)
|
||||
return fs.readFileSync(resolved, "utf-8")
|
||||
}
|
||||
|
||||
private toAbsolute(relativePath: string) {
|
||||
const target = path.resolve(this.root, relativePath)
|
||||
if (!target.startsWith(this.root)) {
|
||||
throw new Error("Access outside of root is not allowed")
|
||||
}
|
||||
return target
|
||||
}
|
||||
}
|
||||
89
packages/cli/src/index.ts
Normal file
89
packages/cli/src/index.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* CLI entry point.
|
||||
* For now this only wires the typed modules together; actual command handling comes later.
|
||||
*/
|
||||
import { createHttpServer } from "./server/http-server"
|
||||
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 { ServerMeta } from "./api-types"
|
||||
import { InstanceStore } from "./storage/instance-store"
|
||||
|
||||
interface CliOptions {
|
||||
port: number
|
||||
host: string
|
||||
rootDir: string
|
||||
configPath: string
|
||||
}
|
||||
|
||||
function parseCliOptions(argv: string[]): CliOptions {
|
||||
// TODO: replace with commander/yargs; this is placeholder logic.
|
||||
const args = new Map<string, string>()
|
||||
for (let i = 0; i < argv.length; i += 2) {
|
||||
const key = argv[i]
|
||||
const value = argv[i + 1]
|
||||
if (key && key.startsWith("--") && value) {
|
||||
args.set(key.slice(2), value)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
port: Number(args.get("port") ?? process.env.CLI_PORT ?? 5777),
|
||||
host: args.get("host") ?? process.env.CLI_HOST ?? "127.0.0.1",
|
||||
rootDir: args.get("root") ?? process.cwd(),
|
||||
configPath: args.get("config") ?? process.env.CLI_CONFIG ?? "~/.config/codenomad/config.json",
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseCliOptions(process.argv.slice(2))
|
||||
|
||||
const eventBus = new EventBus()
|
||||
const configStore = new ConfigStore(options.configPath, eventBus)
|
||||
const binaryRegistry = new BinaryRegistry(configStore, eventBus)
|
||||
const workspaceManager = new WorkspaceManager({
|
||||
rootDir: options.rootDir,
|
||||
configStore,
|
||||
binaryRegistry,
|
||||
eventBus,
|
||||
})
|
||||
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir })
|
||||
const instanceStore = new InstanceStore()
|
||||
|
||||
const serverMeta: ServerMeta = {
|
||||
httpBaseUrl: `http://${options.host}:${options.port}`,
|
||||
eventsUrl: `/api/events`,
|
||||
hostLabel: options.host,
|
||||
workspaceRoot: options.rootDir,
|
||||
}
|
||||
|
||||
const server = createHttpServer({
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
workspaceManager,
|
||||
configStore,
|
||||
binaryRegistry,
|
||||
fileSystemBrowser,
|
||||
eventBus,
|
||||
serverMeta,
|
||||
instanceStore,
|
||||
})
|
||||
|
||||
await server.start()
|
||||
|
||||
const shutdown = async () => {
|
||||
await server.stop()
|
||||
await workspaceManager.shutdown()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.on("SIGINT", shutdown)
|
||||
process.on("SIGTERM", shutdown)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("CLI server crashed", error)
|
||||
process.exit(1)
|
||||
})
|
||||
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" }
|
||||
}
|
||||
63
packages/cli/src/storage/instance-store.ts
Normal file
63
packages/cli/src/storage/instance-store.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import fs from "fs"
|
||||
import { promises as fsp } from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import type { InstanceData } from "../api-types"
|
||||
|
||||
const DEFAULT_INSTANCE_DATA: InstanceData = {
|
||||
messageHistory: [],
|
||||
}
|
||||
|
||||
export class InstanceStore {
|
||||
private readonly instancesDir: string
|
||||
|
||||
constructor(baseDir = path.join(os.homedir(), ".config", "codenomad", "instances")) {
|
||||
this.instancesDir = baseDir
|
||||
fs.mkdirSync(this.instancesDir, { recursive: true })
|
||||
}
|
||||
|
||||
async read(id: string): Promise<InstanceData> {
|
||||
try {
|
||||
const filePath = this.resolvePath(id)
|
||||
const content = await fsp.readFile(filePath, "utf-8")
|
||||
const parsed = JSON.parse(content)
|
||||
return { ...DEFAULT_INSTANCE_DATA, ...parsed }
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return DEFAULT_INSTANCE_DATA
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async write(id: string, data: InstanceData): Promise<void> {
|
||||
const filePath = this.resolvePath(id)
|
||||
await fsp.mkdir(path.dirname(filePath), { recursive: true })
|
||||
await fsp.writeFile(filePath, JSON.stringify(data, null, 2), "utf-8")
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
try {
|
||||
const filePath = this.resolvePath(id)
|
||||
await fsp.unlink(filePath)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private resolvePath(id: string): string {
|
||||
const filename = this.sanitizeId(id)
|
||||
return path.join(this.instancesDir, `${filename}.json`)
|
||||
}
|
||||
|
||||
private sanitizeId(id: string): string {
|
||||
return id
|
||||
.replace(/[\\/]/g, "_")
|
||||
.replace(/[^a-zA-Z0-9_.-]/g, "_")
|
||||
.replace(/_{2,}/g, "_")
|
||||
.replace(/^_|_$/g, "")
|
||||
.toLowerCase()
|
||||
}
|
||||
}
|
||||
148
packages/cli/src/workspaces/manager.ts
Normal file
148
packages/cli/src/workspaces/manager.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import path from "path"
|
||||
import { EventBus } from "../events/bus"
|
||||
import { ConfigStore } from "../config/store"
|
||||
import { BinaryRegistry } from "../config/binaries"
|
||||
import { FileSystemBrowser } from "../filesystem/browser"
|
||||
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
|
||||
import { WorkspaceRuntime } from "./runtime"
|
||||
|
||||
interface WorkspaceManagerOptions {
|
||||
rootDir: string
|
||||
configStore: ConfigStore
|
||||
binaryRegistry: BinaryRegistry
|
||||
eventBus: EventBus
|
||||
}
|
||||
|
||||
interface WorkspaceRecord extends WorkspaceDescriptor {}
|
||||
|
||||
export class WorkspaceManager {
|
||||
private readonly workspaces = new Map<string, WorkspaceRecord>()
|
||||
private readonly runtime: WorkspaceRuntime
|
||||
|
||||
constructor(private readonly options: WorkspaceManagerOptions) {
|
||||
this.runtime = new WorkspaceRuntime(this.options.eventBus)
|
||||
}
|
||||
|
||||
list(): WorkspaceDescriptor[] {
|
||||
return Array.from(this.workspaces.values())
|
||||
}
|
||||
|
||||
get(id: string): WorkspaceDescriptor | undefined {
|
||||
return this.workspaces.get(id)
|
||||
}
|
||||
|
||||
listFiles(workspaceId: string, relativePath = "."): FileSystemEntry[] {
|
||||
const workspace = this.requireWorkspace(workspaceId)
|
||||
const browser = new FileSystemBrowser({ rootDir: workspace.path })
|
||||
return browser.list(relativePath)
|
||||
}
|
||||
|
||||
readFile(workspaceId: string, relativePath: string): WorkspaceFileResponse {
|
||||
const workspace = this.requireWorkspace(workspaceId)
|
||||
const browser = new FileSystemBrowser({ rootDir: workspace.path })
|
||||
const contents = browser.readFile(relativePath)
|
||||
return {
|
||||
workspaceId,
|
||||
relativePath,
|
||||
contents,
|
||||
}
|
||||
}
|
||||
|
||||
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
|
||||
const id = `${Date.now().toString(36)}`
|
||||
const binary = this.options.binaryRegistry.resolveDefault()
|
||||
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
|
||||
|
||||
const descriptor: WorkspaceRecord = {
|
||||
id,
|
||||
path: workspacePath,
|
||||
name,
|
||||
status: "starting",
|
||||
binaryId: binary.id,
|
||||
binaryLabel: binary.label,
|
||||
binaryVersion: binary.version,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
this.workspaces.set(id, descriptor)
|
||||
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
|
||||
|
||||
const environment = this.options.configStore.get().preferences.environmentVariables ?? {}
|
||||
|
||||
try {
|
||||
const { pid, port } = await this.runtime.launch({
|
||||
workspaceId: id,
|
||||
folder: workspacePath,
|
||||
binaryPath: binary.path,
|
||||
environment,
|
||||
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
||||
})
|
||||
|
||||
descriptor.pid = pid
|
||||
descriptor.port = port
|
||||
descriptor.status = "ready"
|
||||
descriptor.updatedAt = new Date().toISOString()
|
||||
this.options.eventBus.publish({ type: "workspace.started", workspace: descriptor })
|
||||
return descriptor
|
||||
} catch (error) {
|
||||
descriptor.status = "error"
|
||||
descriptor.error = error instanceof Error ? error.message : String(error)
|
||||
descriptor.updatedAt = new Date().toISOString()
|
||||
this.options.eventBus.publish({ type: "workspace.error", workspace: descriptor })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<WorkspaceDescriptor | undefined> {
|
||||
const workspace = this.workspaces.get(id)
|
||||
if (!workspace) return undefined
|
||||
|
||||
const wasRunning = Boolean(workspace.pid)
|
||||
if (wasRunning) {
|
||||
await this.runtime.stop(id).catch(() => {})
|
||||
}
|
||||
|
||||
this.workspaces.delete(id)
|
||||
if (!wasRunning) {
|
||||
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: id })
|
||||
}
|
||||
return workspace
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
for (const [id] of this.workspaces) {
|
||||
if (this.workspaces.get(id)?.pid) {
|
||||
await this.runtime.stop(id).catch(() => {})
|
||||
}
|
||||
}
|
||||
this.workspaces.clear()
|
||||
}
|
||||
|
||||
private requireWorkspace(id: string): WorkspaceRecord {
|
||||
const workspace = this.workspaces.get(id)
|
||||
if (!workspace) {
|
||||
throw new Error("Workspace not found")
|
||||
}
|
||||
return workspace
|
||||
}
|
||||
|
||||
private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) {
|
||||
const workspace = this.workspaces.get(workspaceId)
|
||||
if (!workspace) return
|
||||
|
||||
workspace.pid = undefined
|
||||
workspace.port = undefined
|
||||
workspace.updatedAt = new Date().toISOString()
|
||||
|
||||
if (info.requested || info.code === 0) {
|
||||
workspace.status = "stopped"
|
||||
workspace.error = undefined
|
||||
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId })
|
||||
} else {
|
||||
workspace.status = "error"
|
||||
workspace.error = `Process exited with code ${info.code}`
|
||||
this.options.eventBus.publish({ type: "workspace.error", workspace })
|
||||
}
|
||||
}
|
||||
}
|
||||
180
packages/cli/src/workspaces/runtime.ts
Normal file
180
packages/cli/src/workspaces/runtime.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { ChildProcess, spawn } from "child_process"
|
||||
import { existsSync, statSync } from "fs"
|
||||
import path from "path"
|
||||
import { EventBus } from "../events/bus"
|
||||
import { LogLevel, WorkspaceLogEntry } from "../api-types"
|
||||
|
||||
interface LaunchOptions {
|
||||
workspaceId: string
|
||||
folder: string
|
||||
binaryPath: string
|
||||
environment?: Record<string, string>
|
||||
onExit?: (info: ProcessExitInfo) => void
|
||||
}
|
||||
|
||||
interface ProcessExitInfo {
|
||||
workspaceId: string
|
||||
code: number | null
|
||||
signal: NodeJS.Signals | null
|
||||
requested: boolean
|
||||
}
|
||||
|
||||
interface ManagedProcess {
|
||||
child: ChildProcess
|
||||
requestedStop: boolean
|
||||
}
|
||||
|
||||
export class WorkspaceRuntime {
|
||||
private processes = new Map<string, ManagedProcess>()
|
||||
|
||||
constructor(private readonly eventBus: EventBus) {}
|
||||
|
||||
async launch(options: LaunchOptions): Promise<{ pid: number; port: number }> {
|
||||
this.validateFolder(options.folder)
|
||||
|
||||
const args = ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"]
|
||||
const env = { ...process.env, ...(options.environment ?? {}) }
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(options.binaryPath, args, {
|
||||
cwd: options.folder,
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
})
|
||||
|
||||
const managed: ManagedProcess = { child, requestedStop: false }
|
||||
this.processes.set(options.workspaceId, managed)
|
||||
|
||||
let stdoutBuffer = ""
|
||||
let stderrBuffer = ""
|
||||
let portFound = false
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill("SIGKILL")
|
||||
reject(new Error("Server startup timeout (10s exceeded)"))
|
||||
}, 10000)
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout)
|
||||
child.stdout?.removeAllListeners()
|
||||
child.stderr?.removeAllListeners()
|
||||
child.removeListener("error", handleError)
|
||||
}
|
||||
|
||||
const handleExit = (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
this.processes.delete(options.workspaceId)
|
||||
if (!portFound) {
|
||||
cleanup()
|
||||
const reason = stderrBuffer || `Process exited with code ${code}`
|
||||
reject(new Error(reason))
|
||||
} else {
|
||||
options.onExit?.({ workspaceId: options.workspaceId, code, signal, requested: managed.requestedStop })
|
||||
}
|
||||
}
|
||||
|
||||
const handleError = (error: Error) => {
|
||||
cleanup()
|
||||
this.processes.delete(options.workspaceId)
|
||||
child.removeListener("exit", handleExit)
|
||||
reject(error)
|
||||
}
|
||||
|
||||
child.on("error", handleError)
|
||||
child.on("exit", handleExit)
|
||||
|
||||
child.stdout?.on("data", (data: Buffer) => {
|
||||
const text = data.toString()
|
||||
stdoutBuffer += text
|
||||
const lines = stdoutBuffer.split("\n")
|
||||
stdoutBuffer = lines.pop() ?? ""
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
this.emitLog(options.workspaceId, "info", line)
|
||||
|
||||
if (!portFound) {
|
||||
const portMatch = line.match(/opencode server listening on http:\/\/.+:(\d+)/i)
|
||||
if (portMatch) {
|
||||
portFound = true
|
||||
cleanup()
|
||||
resolve({ pid: child.pid!, port: parseInt(portMatch[1], 10) })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
child.stderr?.on("data", (data: Buffer) => {
|
||||
const text = data.toString()
|
||||
stderrBuffer += text
|
||||
const lines = stderrBuffer.split("\n")
|
||||
stderrBuffer = lines.pop() ?? ""
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
this.emitLog(options.workspaceId, "error", line)
|
||||
}
|
||||
})
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
this.processes.delete(options.workspaceId)
|
||||
if (!portFound) {
|
||||
cleanup()
|
||||
const reason = stderrBuffer || `Process exited with code ${code}`
|
||||
reject(new Error(reason))
|
||||
}
|
||||
options.onExit?.({ workspaceId: options.workspaceId, code, signal, requested: managed.requestedStop })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async stop(workspaceId: string): Promise<void> {
|
||||
const managed = this.processes.get(workspaceId)
|
||||
if (!managed) return
|
||||
|
||||
managed.requestedStop = true
|
||||
const child = managed.child
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onExit = () => {
|
||||
child.removeListener("error", onError)
|
||||
resolve()
|
||||
}
|
||||
const onError = (error: Error) => {
|
||||
child.removeListener("exit", onExit)
|
||||
reject(error)
|
||||
}
|
||||
|
||||
child.once("exit", onExit)
|
||||
child.once("error", onError)
|
||||
|
||||
child.kill("SIGTERM")
|
||||
setTimeout(() => {
|
||||
if (!child.killed) {
|
||||
child.kill("SIGKILL")
|
||||
}
|
||||
}, 2000)
|
||||
})
|
||||
}
|
||||
|
||||
private emitLog(workspaceId: string, level: LogLevel, message: string) {
|
||||
const entry: WorkspaceLogEntry = {
|
||||
workspaceId,
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
message: message.trim(),
|
||||
}
|
||||
|
||||
this.eventBus.publish({ type: "workspace.log", entry })
|
||||
}
|
||||
|
||||
private validateFolder(folder: string) {
|
||||
const resolved = path.resolve(folder)
|
||||
if (!existsSync(resolved)) {
|
||||
throw new Error(`Folder does not exist: ${resolved}`)
|
||||
}
|
||||
const stats = statSync(resolved)
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`Path is not a directory: ${resolved}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user