354 lines
10 KiB
TypeScript
354 lines
10 KiB
TypeScript
import { spawn, execSync, ChildProcess } from "child_process"
|
|
import { app, BrowserWindow } from "electron"
|
|
import { existsSync, statSync } from "fs"
|
|
import { buildUserShellCommand, getUserShellEnv, runUserShellCommandSync, supportsUserShell } from "./user-shell"
|
|
|
|
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 useUserShell = supportsUserShell()
|
|
const logAttempt = (message: string) => {
|
|
console.info(`[ProcessManager] ${message}`)
|
|
this.sendLog(instanceId, "debug", message)
|
|
}
|
|
|
|
const env = useUserShell ? getUserShellEnv() : { ...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}`)
|
|
}
|
|
}
|
|
|
|
let targetBinary: string
|
|
if (!binaryPath || binaryPath === "opencode") {
|
|
targetBinary = useUserShell ? "opencode" : this.validateOpenCodeBinary(logAttempt)
|
|
} else {
|
|
targetBinary = this.validateCustomBinary(binaryPath, logAttempt)
|
|
}
|
|
|
|
const spawnCommand = useUserShell
|
|
? this.buildShellServeCommand(targetBinary)
|
|
: { command: targetBinary, args: this.buildServeArgs() }
|
|
|
|
const launchDetail = `${spawnCommand.command} ${spawnCommand.args.join(" ")}`.trim()
|
|
this.sendLog(instanceId, "debug", `Launching process with: ${launchDetail}`)
|
|
|
|
this.sendLog(
|
|
instanceId,
|
|
"info",
|
|
`Starting OpenCode server for ${folder} using ${targetBinary}...`,
|
|
)
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(spawnCommand.command, spawnCommand.args, {
|
|
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: targetBinary })
|
|
}
|
|
|
|
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(logAttempt?: (message: string) => void): string {
|
|
const log = logAttempt ?? ((message: string) => console.info(`[ProcessManager] ${message}`))
|
|
|
|
if (process.platform === "win32") {
|
|
log("Checking PATH via 'where opencode'")
|
|
return this.resolveBinaryViaLocator("where opencode", log)
|
|
}
|
|
|
|
const shellCheck = buildUserShellCommand("command -v opencode")
|
|
const shellPreview = [shellCheck.command, ...shellCheck.args].join(" ")
|
|
log(`Checking PATH via shell: ${shellPreview}`)
|
|
|
|
try {
|
|
const resolved = runUserShellCommandSync("command -v opencode")
|
|
const path = this.pickFirstPath(resolved)
|
|
if (path) {
|
|
log(`Shell located opencode at ${path}`)
|
|
return path
|
|
}
|
|
throw new Error("Empty result from shell lookup")
|
|
} catch (shellError) {
|
|
const message = shellError instanceof Error ? shellError.message : String(shellError)
|
|
log(`Shell lookup failed: ${message}`)
|
|
try {
|
|
log("Fallback to 'which opencode'")
|
|
return this.resolveBinaryViaLocator("which opencode", log)
|
|
} catch (locatorError) {
|
|
const locatorMessage = locatorError instanceof Error ? locatorError.message : String(locatorError)
|
|
log(`Locator fallback failed: ${locatorMessage}`)
|
|
throw new Error(
|
|
"opencode binary not found in PATH. Please install OpenCode CLI first: npm install -g @opencode/cli",
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private validateCustomBinary(binaryPath: string, log?: (message: string) => void): string {
|
|
log?.(`Validating custom binary at ${binaryPath}`)
|
|
|
|
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
|
|
}
|
|
|
|
private resolveBinaryViaLocator(command: string, log?: (message: string) => void): string {
|
|
log?.(`Running locator command: ${command}`)
|
|
const output = execSync(command, { stdio: "pipe", encoding: "utf-8" })
|
|
log?.(`Locator output: ${output.trim() || "<empty>"}`)
|
|
const path = this.pickFirstPath(output)
|
|
if (!path) {
|
|
throw new Error("opencode binary not found in PATH")
|
|
}
|
|
return path
|
|
}
|
|
|
|
private pickFirstPath(output: string): string | null {
|
|
const line = output
|
|
.split("\n")
|
|
.map((entry) => entry.trim())
|
|
.find((entry) => entry.length > 0)
|
|
return line ?? null
|
|
}
|
|
|
|
private buildServeArgs(): string[] {
|
|
return ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"]
|
|
}
|
|
|
|
private buildShellServeCommand(binaryPath: string): { command: string; args: string[] } {
|
|
const args = this.buildServeArgs()
|
|
.map((arg) => JSON.stringify(arg))
|
|
.join(" ")
|
|
return buildUserShellCommand(`exec ${JSON.stringify(binaryPath)} ${args}`)
|
|
}
|
|
}
|
|
|
|
export const processManager = new ProcessManager()
|
|
|
|
app.on("before-quit", async (event) => {
|
|
event.preventDefault()
|
|
await processManager.cleanup()
|
|
app.exit(0)
|
|
})
|