Split workspace into electron and ui packages

This commit is contained in:
Shantur Rathore
2025-11-17 12:06:58 +00:00
parent aa77ca2931
commit 89bd32814f
137 changed files with 407 additions and 1371 deletions

View File

@@ -0,0 +1,243 @@
import { ipcMain, BrowserWindow, dialog } from "electron"
import { processManager } from "./process-manager"
import { randomBytes } from "crypto"
import * as fs from "fs"
import * as path from "path"
import { spawn } from "child_process"
import ignore from "ignore"
interface Instance {
id: string
folder: string
port: number
pid: number
status: "starting" | "ready" | "error" | "stopped"
error?: string
}
const instances = new Map<string, Instance>()
function generateId(): string {
return randomBytes(16).toString("hex")
}
function runBinaryVersion(binaryPath: string, timeoutMs = 5000): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn(binaryPath, ["-v"], {
stdio: ["ignore", "pipe", "pipe"],
})
let stdout = ""
let stderr = ""
const timeout = setTimeout(() => {
child.kill("SIGTERM")
reject(new Error("Version check timed out"))
}, timeoutMs)
child.stdout?.on("data", (data) => {
stdout += data.toString()
})
child.stderr?.on("data", (data) => {
stderr += data.toString()
})
child.on("error", (error) => {
clearTimeout(timeout)
reject(error)
})
child.on("close", (code) => {
clearTimeout(timeout)
if (code === 0) {
resolve(stdout.trim())
} else {
reject(new Error(stderr.trim() || `Binary exited with code ${code}`))
}
})
})
}
export function setupInstanceIPC(mainWindow: BrowserWindow) {
processManager.setMainWindow(mainWindow)
ipcMain.handle("dialog:selectFolder", async () => {
const result = await dialog.showOpenDialog(mainWindow!, {
title: "Select Project Folder",
properties: ["openDirectory"],
})
if (result.canceled || !result.filePaths.length) {
return null
}
return result.filePaths[0]
})
ipcMain.handle(
"instance:create",
async (event, id: string, folder: string, binaryPath?: string, environmentVariables?: Record<string, string>) => {
const instance: Instance = {
id,
folder,
port: 0,
pid: 0,
status: "starting",
}
instances.set(id, instance)
try {
const {
pid,
port,
binaryPath: actualBinaryPath,
} = await processManager.spawn(folder, id, binaryPath, environmentVariables)
instance.port = port
instance.pid = pid
instance.status = "ready"
mainWindow.webContents.send("instance:started", { id, port, pid, binaryPath: actualBinaryPath })
const meta = processManager.getAllProcesses().get(pid)
if (meta) {
meta.childProcess.on("exit", (code, signal) => {
instance.status = "stopped"
mainWindow.webContents.send("instance:stopped", { id })
})
}
return { id, port, pid, binaryPath: actualBinaryPath }
} catch (error) {
instance.status = "error"
instance.error = error instanceof Error ? error.message : String(error)
mainWindow.webContents.send("instance:error", {
id,
error: instance.error,
})
throw error
}
},
)
ipcMain.handle("instance:stop", async (event, pid: number) => {
await processManager.kill(pid)
for (const [id, instance] of instances.entries()) {
if (instance.pid === pid) {
instance.status = "stopped"
break
}
}
})
ipcMain.handle("instance:status", async (event, pid: number) => {
return processManager.getStatus(pid)
})
ipcMain.handle("instance:list", async () => {
return Array.from(instances.values())
})
ipcMain.handle("fs:scanDirectory", async (event, workspaceFolder: string) => {
const ig = ignore()
ig.add([".git", "node_modules"])
const gitignorePath = path.join(workspaceFolder, ".gitignore")
if (fs.existsSync(gitignorePath)) {
const content = fs.readFileSync(gitignorePath, "utf-8")
ig.add(content)
}
function scanDir(dirPath: string, baseDir: string): string[] {
const results: string[] = []
try {
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name)
const relativePath = path.relative(baseDir, fullPath)
if (ig.ignores(relativePath)) {
continue
}
if (entry.isDirectory()) {
const dirWithSlash = relativePath + "/"
if (!ig.ignores(dirWithSlash)) {
results.push(dirWithSlash)
const subFiles = scanDir(fullPath, baseDir)
results.push(...subFiles)
}
} else {
results.push(relativePath)
}
}
} catch (error) {
console.warn(`Error scanning ${dirPath}:`, error)
}
return results
}
return scanDir(workspaceFolder, workspaceFolder)
})
// OpenCode binary operations
ipcMain.handle("dialog:selectOpenCodeBinary", async () => {
const result = await dialog.showOpenDialog(mainWindow!, {
title: "Select OpenCode Binary",
filters: [
{ name: "Executable Files", extensions: ["exe", "cmd", "bat", "sh", "command", "app", ""] },
{ name: "All Files", extensions: ["*"] },
],
properties: ["openFile"],
})
if (result.canceled || !result.filePaths.length) {
return null
}
return result.filePaths[0]
})
ipcMain.handle("opencode:validateBinary", async (event, binaryPath: string) => {
try {
// Special handling for system PATH binary
const isSystemPath = binaryPath === "opencode"
if (!isSystemPath) {
// Check if file exists and is executable for custom paths
if (!fs.existsSync(binaryPath)) {
return { valid: false, error: "File does not exist" }
}
const stats = fs.statSync(binaryPath)
if (!stats.isFile()) {
return { valid: false, error: "Path is not a file" }
}
}
// Try to get version once via -v flag
try {
const version = await runBinaryVersion(binaryPath)
return { valid: true, version }
} catch (error) {
return {
valid: false,
error: error instanceof Error ? error.message : String(error),
}
}
} catch (error) {
return {
valid: false,
error: error instanceof Error ? error.message : String(error),
}
}
})
}

View File

@@ -0,0 +1,102 @@
import { app, BrowserWindow, dialog, ipcMain, nativeImage, nativeTheme, session } from "electron"
import { join } from "path"
import { createApplicationMenu } from "./menu"
import { setupInstanceIPC } from "./ipc"
import { setupStorageIPC } from "./storage"
const isMac = process.platform === "darwin"
if (isMac) {
app.commandLine.appendSwitch("disable-spell-checking")
}
// Setup IPC handlers before creating windows
setupStorageIPC()
let mainWindow: BrowserWindow | null = null
function getIconPath() {
if (app.isPackaged) {
return join(process.resourcesPath, "icon.png")
}
return join(app.getAppPath(), "electron/resources/icon.png")
}
function createWindow() {
const prefersDark = true //nativeTheme.shouldUseDarkColors
const backgroundColor = prefersDark ? "#1a1a1a" : "#ffffff"
const iconPath = getIconPath()
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
minWidth: 800,
minHeight: 600,
backgroundColor,
icon: iconPath,
webPreferences: {
preload: join(__dirname, "../preload/index.js"),
contextIsolation: true,
nodeIntegration: false,
spellcheck: !isMac,
},
})
if (isMac) {
// Disable macOS spell server to avoid input lag
mainWindow.webContents.session.setSpellCheckerEnabled(false)
}
if (process.env.NODE_ENV === "development") {
mainWindow.loadURL("http://localhost:3000")
mainWindow.webContents.openDevTools()
} else {
mainWindow.loadFile(join(__dirname, "../renderer/index.html"))
}
createApplicationMenu(mainWindow)
setupInstanceIPC(mainWindow)
mainWindow.on("closed", () => {
mainWindow = null
})
}
if (isMac) {
app.on("web-contents-created", (_, contents) => {
contents.session.setSpellCheckerEnabled(false)
})
}
app.whenReady().then(() => {
if (isMac) {
session.defaultSession.setSpellCheckerEnabled(false)
app.on("browser-window-created", (_, window) => {
window.webContents.session.setSpellCheckerEnabled(false)
})
if (app.dock) {
const dockIcon = nativeImage.createFromPath(getIconPath())
if (!dockIcon.isEmpty()) {
app.dock.setIcon(dockIcon)
}
}
}
console.log("[spellcheck] default session enabled:", session.defaultSession.isSpellCheckerEnabled())
createWindow()
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
})
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit()
}
})

View File

@@ -0,0 +1,84 @@
import { Menu, BrowserWindow, MenuItemConstructorOptions } from "electron"
export function createApplicationMenu(mainWindow: BrowserWindow) {
const isMac = process.platform === "darwin"
const template: MenuItemConstructorOptions[] = [
...(isMac
? [
{
label: "CodeNomad",
submenu: [
{ role: "about" as const },
{ type: "separator" as const },
{ role: "hide" as const },
{ role: "hideOthers" as const },
{ role: "unhide" as const },
{ type: "separator" as const },
{ role: "quit" as const },
],
},
]
: []),
{
label: "File",
submenu: [
{
label: "New Instance",
accelerator: "CmdOrCtrl+N",
click: () => {
mainWindow.webContents.send("menu:newInstance")
},
},
{ type: "separator" as const },
isMac ? { role: "close" as const } : { role: "quit" as const },
],
},
{
label: "Edit",
submenu: [
{ role: "undo" as const },
{ role: "redo" as const },
{ type: "separator" as const },
{ role: "cut" as const },
{ role: "copy" as const },
{ role: "paste" as const },
...(isMac
? [{ role: "pasteAndMatchStyle" as const }, { role: "delete" as const }, { role: "selectAll" as const }]
: [{ role: "delete" as const }, { type: "separator" as const }, { role: "selectAll" as const }]),
],
},
{
label: "View",
submenu: [
{ role: "reload" as const },
{ role: "forceReload" as const },
{ role: "toggleDevTools" as const },
{ type: "separator" as const },
{ role: "resetZoom" as const },
{ role: "zoomIn" as const },
{ role: "zoomOut" as const },
{ type: "separator" as const },
{ role: "togglefullscreen" as const },
],
},
{
label: "Window",
submenu: [
{ role: "minimize" as const },
{ role: "zoom" as const },
...(isMac
? [
{ type: "separator" as const },
{ role: "front" as const },
{ type: "separator" as const },
{ role: "window" as const },
]
: [{ role: "close" as const }]),
],
},
]
const menu = Menu.buildFromTemplate(template)
Menu.setApplicationMenu(menu)
}

View File

@@ -0,0 +1,353 @@
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)
})

View File

@@ -0,0 +1,121 @@
import { app, ipcMain } from "electron"
import { join } from "path"
import { readFile, writeFile, mkdir, unlink, stat } from "fs/promises"
import { existsSync } from "fs"
const CONFIG_DIR = join(app.getPath("home"), ".config", "codenomad")
const CONFIG_FILE = join(CONFIG_DIR, "config.json")
const INSTANCES_DIR = join(CONFIG_DIR, "instances")
// File watching for config changes
let configWatchers = new Set<number>()
let configLastModified = 0
let configCache: string | null = null
async function ensureDirectories() {
try {
await mkdir(CONFIG_DIR, { recursive: true })
await mkdir(INSTANCES_DIR, { recursive: true })
} catch (error) {
console.error("Failed to create directories:", error)
}
}
async function readConfigWithCache(): Promise<string> {
try {
const stats = await stat(CONFIG_FILE)
const currentModified = stats.mtime.getTime()
// If file hasn't been modified since last read, return cache
if (configCache && configLastModified >= currentModified) {
return configCache
}
const content = await readFile(CONFIG_FILE, "utf-8")
configCache = content
configLastModified = currentModified
return content
} catch (error) {
// File doesn't exist or can't be read
configCache = null
configLastModified = 0
throw error
}
}
function invalidateConfigCache() {
configCache = null
configLastModified = 0
}
export function setupStorageIPC() {
ensureDirectories()
ipcMain.handle("storage:getConfigPath", async () => CONFIG_FILE)
ipcMain.handle("storage:getInstancesDir", async () => INSTANCES_DIR)
ipcMain.handle("storage:readConfigFile", async () => {
try {
return await readConfigWithCache()
} catch (error) {
// Return empty config if file doesn't exist
return JSON.stringify({ preferences: { showThinkingBlocks: false }, recentFolders: [] }, null, 2)
}
})
ipcMain.handle("storage:writeConfigFile", async (_, content: string) => {
try {
await writeFile(CONFIG_FILE, content, "utf-8")
invalidateConfigCache()
// Notify other renderer processes about config change
const windows = require("electron").BrowserWindow.getAllWindows()
windows.forEach((win: any) => {
if (win.webContents && !win.webContents.isDestroyed()) {
win.webContents.send("storage:configChanged")
}
})
} catch (error) {
console.error("Failed to write config file:", error)
throw error
}
})
ipcMain.handle("storage:readInstanceFile", async (_, filename: string) => {
const instanceFile = join(INSTANCES_DIR, `${filename}.json`)
try {
return await readFile(instanceFile, "utf-8")
} catch (error) {
// Return empty instance data if file doesn't exist
return JSON.stringify({ messageHistory: [] }, null, 2)
}
})
ipcMain.handle("storage:writeInstanceFile", async (_, filename: string, content: string) => {
const instanceFile = join(INSTANCES_DIR, `${filename}.json`)
try {
await writeFile(instanceFile, content, "utf-8")
} catch (error) {
console.error(`Failed to write instance file for ${filename}:`, error)
throw error
}
})
ipcMain.handle("storage:deleteInstanceFile", async (_, filename: string) => {
const instanceFile = join(INSTANCES_DIR, `${filename}.json`)
try {
if (existsSync(instanceFile)) {
await unlink(instanceFile)
}
} catch (error) {
console.error(`Failed to delete instance file for ${filename}:`, error)
throw error
}
})
}
// Clean up on app quit
app.on("before-quit", () => {
configCache = null
configLastModified = 0
})

View File

@@ -0,0 +1,139 @@
import { spawn, spawnSync } from "child_process"
import path from "path"
interface ShellCommand {
command: string
args: string[]
}
const isWindows = process.platform === "win32"
function getDefaultShellPath(): string {
if (process.env.SHELL && process.env.SHELL.trim().length > 0) {
return process.env.SHELL
}
if (process.platform === "darwin") {
return "/bin/zsh"
}
return "/bin/bash"
}
function wrapCommandForShell(command: string, shellPath: string): string {
const shellName = path.basename(shellPath)
if (shellName.includes("bash")) {
return 'if [ -f ~/.bashrc ]; then source ~/.bashrc >/dev/null 2>&1; fi; ' + command
}
if (shellName.includes("zsh")) {
return 'if [ -f ~/.zshrc ]; then source ~/.zshrc >/dev/null 2>&1; fi; ' + command
}
return command
}
function buildShellArgs(shellPath: string): string[] {
const shellName = path.basename(shellPath)
if (shellName.includes("zsh")) {
return ["-l", "-i", "-c"]
}
return ["-l", "-c"]
}
function sanitizeShellEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const cleaned = { ...env }
delete cleaned.npm_config_prefix
delete cleaned.NPM_CONFIG_PREFIX
return cleaned
}
export function supportsUserShell(): boolean {
return !isWindows
}
export function buildUserShellCommand(userCommand: string): ShellCommand {
if (!supportsUserShell()) {
throw new Error("User shell invocation is only supported on POSIX platforms")
}
const shellPath = getDefaultShellPath()
const script = wrapCommandForShell(userCommand, shellPath)
const args = buildShellArgs(shellPath)
return {
command: shellPath,
args: [...args, script],
}
}
export function getUserShellEnv(): NodeJS.ProcessEnv {
if (!supportsUserShell()) {
throw new Error("User shell invocation is only supported on POSIX platforms")
}
return sanitizeShellEnv(process.env)
}
export function runUserShellCommand(userCommand: string, timeoutMs = 5000): Promise<string> {
if (!supportsUserShell()) {
return Promise.reject(new Error("User shell invocation is only supported on POSIX platforms"))
}
const { command, args } = buildUserShellCommand(userCommand)
const env = getUserShellEnv()
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
stdio: ["ignore", "pipe", "pipe"],
env,
})
let stdout = ""
let stderr = ""
const timeout = setTimeout(() => {
child.kill("SIGTERM")
reject(new Error(`Shell command timed out after ${timeoutMs}ms`))
}, timeoutMs)
child.stdout?.on("data", (data) => {
stdout += data.toString()
})
child.stderr?.on("data", (data) => {
stderr += data.toString()
})
child.on("error", (error) => {
clearTimeout(timeout)
reject(error)
})
child.on("close", (code) => {
clearTimeout(timeout)
if (code === 0) {
resolve(stdout.trim())
} else {
reject(new Error(stderr.trim() || `Shell command exited with code ${code}`))
}
})
})
}
export function runUserShellCommandSync(userCommand: string): string {
if (!supportsUserShell()) {
throw new Error("User shell invocation is only supported on POSIX platforms")
}
const { command, args } = buildUserShellCommand(userCommand)
const env = getUserShellEnv()
const result = spawnSync(command, args, { encoding: "utf-8", env })
if (result.status !== 0) {
const stderr = (result.stderr || "").toString().trim()
throw new Error(stderr || "Shell command failed")
}
return (result.stdout || "").toString().trim()
}

View File

@@ -0,0 +1,49 @@
import { contextBridge, ipcRenderer } from "electron"
import type { ElectronAPI } from "../../../ui/src/types/electron-api"
const electronAPI: ElectronAPI = {
selectFolder: () => ipcRenderer.invoke("dialog:selectFolder"),
createInstance: (id: string, folder: string, binaryPath?: string, environmentVariables?: Record<string, string>) =>
ipcRenderer.invoke("instance:create", id, folder, binaryPath, environmentVariables),
stopInstance: (pid: number) => ipcRenderer.invoke("instance:stop", pid),
onInstanceStarted: (callback) => {
ipcRenderer.on("instance:started", (_, data) => callback(data))
},
onInstanceError: (callback) => {
ipcRenderer.on("instance:error", (_, data) => callback(data))
},
onInstanceStopped: (callback) => {
ipcRenderer.on("instance:stopped", (_, data) => callback(data))
},
onInstanceLog: (callback) => {
ipcRenderer.on("instance:log", (_, data) => callback(data))
},
onNewInstance: (callback) => {
ipcRenderer.on("menu:newInstance", () => callback())
},
scanDirectory: (workspaceFolder: string) => ipcRenderer.invoke("fs:scanDirectory", workspaceFolder),
// OpenCode binary operations
selectOpenCodeBinary: () => ipcRenderer.invoke("dialog:selectOpenCodeBinary"),
validateOpenCodeBinary: (path: string) => ipcRenderer.invoke("opencode:validateBinary", path),
// Storage operations
getConfigPath: () => ipcRenderer.invoke("storage:getConfigPath"),
getInstancesDir: () => ipcRenderer.invoke("storage:getInstancesDir"),
readConfigFile: () => ipcRenderer.invoke("storage:readConfigFile"),
writeConfigFile: (content: string) => ipcRenderer.invoke("storage:writeConfigFile", content),
readInstanceFile: (filename: string) => ipcRenderer.invoke("storage:readInstanceFile", filename),
writeInstanceFile: (filename: string, content: string) =>
ipcRenderer.invoke("storage:writeInstanceFile", filename, content),
deleteInstanceFile: (filename: string) => ipcRenderer.invoke("storage:deleteInstanceFile", filename),
onConfigChanged: (callback: () => void) => {
ipcRenderer.on("storage:configChanged", () => callback())
return () => ipcRenderer.removeAllListeners("storage:configChanged")
},
}
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
declare global {
interface Window {
electronAPI: ElectronAPI
}
}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -0,0 +1,7 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": true
},
"include": ["./**/*.ts", "./**/*.tsx"]
}