Files
CodeNomad/electron/main/process-manager.ts
2025-11-13 10:46:03 +00:00

282 lines
7.7 KiB
TypeScript

import { spawn, ChildProcess } from "child_process"
import { app, BrowserWindow } from "electron"
import { existsSync, statSync } from "fs"
import { execSync } from "child_process"
export interface ProcessInfo {
pid: number
port: number
binaryPath: string
}
interface ProcessMeta {
pid: number
port: number
folder: string
startTime: number
childProcess: ChildProcess
logs: string[]
instanceId: string
}
class ProcessManager {
private processes = new Map<number, ProcessMeta>()
private mainWindow: BrowserWindow | null = null
setMainWindow(window: BrowserWindow) {
this.mainWindow = window
}
private parseLogLevel(message: string): "info" | "error" | "warn" | "debug" {
const upperMessage = message.toUpperCase()
if (upperMessage.includes("[ERROR]") || upperMessage.includes("ERROR:")) return "error"
if (upperMessage.includes("[WARN]") || upperMessage.includes("WARN:")) return "warn"
if (upperMessage.includes("[DEBUG]") || upperMessage.includes("DEBUG:")) return "debug"
if (upperMessage.includes("[INFO]") || upperMessage.includes("INFO:")) return "info"
return "info"
}
private sendLog(instanceId: string, level: "info" | "error" | "warn" | "debug", message: string) {
if (this.mainWindow && message.trim()) {
const parsedLevel = this.parseLogLevel(message)
this.mainWindow.webContents.send("instance:log", {
id: instanceId,
entry: {
timestamp: Date.now(),
level: parsedLevel,
message: message.trim(),
},
})
}
}
async spawn(
folder: string,
instanceId: string,
binaryPath?: string,
environmentVariables?: Record<string, string>,
): Promise<ProcessInfo> {
this.validateFolder(folder)
const actualBinaryPath =
binaryPath && binaryPath !== "opencode" ? this.validateCustomBinary(binaryPath) : this.validateOpenCodeBinary()
this.sendLog(
instanceId,
"info",
`Starting OpenCode server for ${folder} using ${binaryPath || "opencode"} (${actualBinaryPath})...`,
)
// Merge environment variables with process environment
const env = { ...process.env }
if (environmentVariables) {
Object.assign(env, environmentVariables)
this.sendLog(
instanceId,
"info",
`Using ${Object.keys(environmentVariables).length} custom environment variables:`,
)
// Log each environment variable
for (const [key, value] of Object.entries(environmentVariables)) {
this.sendLog(instanceId, "info", ` ${key}=${value}`)
}
}
return new Promise((resolve, reject) => {
const child = spawn(actualBinaryPath, ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"], {
cwd: folder,
stdio: ["ignore", "pipe", "pipe"],
env,
shell: false,
})
const timeout = setTimeout(() => {
child.kill("SIGKILL")
this.sendLog(instanceId, "error", "Server startup timeout (10s exceeded)")
reject(new Error("Server startup timeout (10s exceeded)"))
}, 10000)
let stdoutBuffer = ""
let stderrBuffer = ""
let portFound = false
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.sendLog(instanceId, "info", line)
const portMatch = line.match(/opencode server listening on http:\/\/[^:]+:(\d+)/)
if (portMatch && !portFound) {
portFound = true
const port = parseInt(portMatch[1], 10)
clearTimeout(timeout)
const meta: ProcessMeta = {
pid: child.pid!,
port,
folder,
startTime: Date.now(),
childProcess: child,
logs: [line],
instanceId,
}
this.processes.set(child.pid!, meta)
resolve({ pid: child.pid!, port, binaryPath: actualBinaryPath })
}
const meta = this.processes.get(child.pid!)
if (meta) {
meta.logs.push(line)
}
}
})
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.sendLog(instanceId, "error", line)
const meta = this.processes.get(child.pid!)
if (meta) {
meta.logs.push(line)
}
}
})
child.on("error", (error) => {
clearTimeout(timeout)
if (error.message.includes("ENOENT")) {
reject(new Error("opencode binary not found in PATH"))
} else {
reject(error)
}
})
child.on("exit", (code, signal) => {
clearTimeout(timeout)
this.processes.delete(child.pid!)
if (!portFound) {
const errorMsg = stderrBuffer || `Process exited with code ${code}`
reject(new Error(errorMsg))
}
})
})
}
async kill(pid: number): Promise<void> {
const meta = this.processes.get(pid)
if (!meta) {
// Treat unknown processes as already stopped so tabs close cleanly
return
}
return new Promise((resolve, reject) => {
const child = meta.childProcess
const killTimeout = setTimeout(() => {
child.kill("SIGKILL")
}, 2000)
child.on("exit", () => {
clearTimeout(killTimeout)
this.processes.delete(pid)
resolve()
})
child.kill("SIGTERM")
})
}
getStatus(pid: number): "running" | "stopped" | "unknown" {
if (!this.processes.has(pid)) {
return "unknown"
}
try {
process.kill(pid, 0)
return "running"
} catch {
return "stopped"
}
}
getAllProcesses(): Map<number, ProcessMeta> {
return new Map(this.processes)
}
async cleanup(): Promise<void> {
const killPromises = Array.from(this.processes.keys()).map((pid) => this.kill(pid).catch(() => {}))
await Promise.all(killPromises)
}
private validateFolder(folder: string): void {
if (!existsSync(folder)) {
throw new Error(`Folder does not exist: ${folder}`)
}
const stats = statSync(folder)
if (!stats.isDirectory()) {
throw new Error(`Path is not a directory: ${folder}`)
}
}
private validateOpenCodeBinary(): string {
const command = process.platform === "win32" ? "where opencode" : "which opencode"
try {
const output = execSync(command, { stdio: "pipe", encoding: "utf-8" })
const paths = output.trim().split("\n")
return paths[0].trim()
} catch {
throw new Error(
"opencode binary not found in PATH. Please install OpenCode CLI first: npm install -g @opencode/cli",
)
}
}
private validateCustomBinary(binaryPath: string): string {
if (!existsSync(binaryPath)) {
throw new Error(`OpenCode binary not found: ${binaryPath}`)
}
const stats = statSync(binaryPath)
if (!stats.isFile()) {
throw new Error(`Path is not a file: ${binaryPath}`)
}
// Check if executable (on Unix systems)
if (process.platform !== "win32") {
try {
execSync(`test -x "${binaryPath}"`, { stdio: "pipe" })
} catch {
throw new Error(`Binary is not executable: ${binaryPath}`)
}
}
return binaryPath
}
}
export const processManager = new ProcessManager()
app.on("before-quit", async (event) => {
event.preventDefault()
await processManager.cleanup()
app.exit(0)
})