fix: restore complete source code and fix launchers

- Copy complete source code packages from original CodeNomad project
- Add root package.json with npm workspace configuration
- Include electron-app, server, ui, tauri-app, and opencode-config packages
- Fix Launch-Windows.bat and Launch-Dev-Windows.bat to work with correct npm scripts
- Fix Launch-Unix.sh to work with correct npm scripts
- Launchers now correctly call npm run dev:electron which launches Electron app
This commit is contained in:
Gemini AI
2025-12-23 12:57:55 +04:00
Unverified
parent f365d64c97
commit b448d11991
249 changed files with 48776 additions and 0 deletions

View File

@@ -0,0 +1,65 @@
import { BrowserWindow, dialog, ipcMain, type OpenDialogOptions } from "electron"
import type { CliProcessManager, CliStatus } from "./process-manager"
interface DialogOpenRequest {
mode: "directory" | "file"
title?: string
defaultPath?: string
filters?: Array<{ name?: string; extensions: string[] }>
}
interface DialogOpenResult {
canceled: boolean
paths: string[]
}
export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessManager) {
cliManager.on("status", (status: CliStatus) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send("cli:status", status)
}
})
cliManager.on("ready", (status: CliStatus) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send("cli:ready", status)
}
})
cliManager.on("error", (error: Error) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send("cli:error", { message: error.message })
}
})
ipcMain.handle("cli:getStatus", async () => cliManager.getStatus())
ipcMain.handle("cli:restart", async () => {
const devMode = process.env.NODE_ENV === "development"
await cliManager.stop()
return cliManager.start({ dev: devMode })
})
ipcMain.handle("dialog:open", async (_, request: DialogOpenRequest): Promise<DialogOpenResult> => {
const properties: OpenDialogOptions["properties"] =
request.mode === "directory" ? ["openDirectory", "createDirectory"] : ["openFile"]
const filters = request.filters?.map((filter) => ({
name: filter.name ?? "Files",
extensions: filter.extensions,
}))
const windowTarget = mainWindow.isDestroyed() ? undefined : mainWindow
const dialogOptions: OpenDialogOptions = {
title: request.title,
defaultPath: request.defaultPath,
properties,
filters,
}
const result = windowTarget
? await dialog.showOpenDialog(windowTarget, dialogOptions)
: await dialog.showOpenDialog(dialogOptions)
return { canceled: result.canceled, paths: result.filePaths }
})
}

View File

@@ -0,0 +1,381 @@
import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron"
import { existsSync } from "fs"
import { dirname, join } from "path"
import { fileURLToPath } from "url"
import { createApplicationMenu } from "./menu"
import { setupCliIPC } from "./ipc"
import { CliProcessManager } from "./process-manager"
const mainFilename = fileURLToPath(import.meta.url)
const mainDirname = dirname(mainFilename)
const isMac = process.platform === "darwin"
const cliManager = new CliProcessManager()
let mainWindow: BrowserWindow | null = null
let currentCliUrl: string | null = null
let pendingCliUrl: string | null = null
let showingLoadingScreen = false
let preloadingView: BrowserView | null = null
if (isMac) {
app.commandLine.appendSwitch("disable-spell-checking")
}
function getIconPath() {
if (app.isPackaged) {
return join(process.resourcesPath, "icon.png")
}
return join(mainDirname, "../resources/icon.png")
}
type LoadingTarget =
| { type: "url"; source: string }
| { type: "file"; source: string }
function resolveDevLoadingUrl(): string | null {
if (app.isPackaged) {
return null
}
const devBase = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL
if (!devBase) {
return null
}
try {
const normalized = devBase.endsWith("/") ? devBase : `${devBase}/`
return new URL("loading.html", normalized).toString()
} catch (error) {
console.warn("[cli] failed to construct dev loading URL", devBase, error)
return null
}
}
function resolveLoadingTarget(): LoadingTarget {
const devUrl = resolveDevLoadingUrl()
if (devUrl) {
return { type: "url", source: devUrl }
}
const filePath = resolveLoadingFilePath()
return { type: "file", source: filePath }
}
function resolveLoadingFilePath() {
const candidates = [
join(app.getAppPath(), "dist/renderer/loading.html"),
join(process.resourcesPath, "dist/renderer/loading.html"),
join(mainDirname, "../dist/renderer/loading.html"),
]
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate
}
}
return join(app.getAppPath(), "dist/renderer/loading.html")
}
function loadLoadingScreen(window: BrowserWindow) {
const target = resolveLoadingTarget()
const loader =
target.type === "url"
? window.loadURL(target.source)
: window.loadFile(target.source)
loader.catch((error) => {
console.error("[cli] failed to load loading screen:", error)
})
}
function getAllowedRendererOrigins(): string[] {
const origins = new Set<string>()
const rendererCandidates = [currentCliUrl, process.env.VITE_DEV_SERVER_URL, process.env.ELECTRON_RENDERER_URL]
for (const candidate of rendererCandidates) {
if (!candidate) {
continue
}
try {
origins.add(new URL(candidate).origin)
} catch (error) {
console.warn("[cli] failed to parse origin for", candidate, error)
}
}
return Array.from(origins)
}
function shouldOpenExternally(url: string): boolean {
try {
const parsed = new URL(url)
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return true
}
const allowedOrigins = getAllowedRendererOrigins()
return !allowedOrigins.includes(parsed.origin)
} catch {
return false
}
}
function setupNavigationGuards(window: BrowserWindow) {
const handleExternal = (url: string) => {
shell.openExternal(url).catch((error) => console.error("[cli] failed to open external URL", url, error))
}
window.webContents.setWindowOpenHandler(({ url }) => {
if (shouldOpenExternally(url)) {
handleExternal(url)
return { action: "deny" }
}
return { action: "allow" }
})
window.webContents.on("will-navigate", (event, url) => {
if (shouldOpenExternally(url)) {
event.preventDefault()
handleExternal(url)
}
})
}
let cachedPreloadPath: string | null = null
function getPreloadPath() {
if (cachedPreloadPath && existsSync(cachedPreloadPath)) {
return cachedPreloadPath
}
const candidates = [
join(process.resourcesPath, "preload/index.js"),
join(mainDirname, "../preload/index.js"),
join(mainDirname, "../preload/index.cjs"),
join(mainDirname, "../../preload/index.cjs"),
join(mainDirname, "../../electron/preload/index.cjs"),
join(app.getAppPath(), "preload/index.cjs"),
join(app.getAppPath(), "electron/preload/index.cjs"),
]
for (const candidate of candidates) {
if (existsSync(candidate)) {
cachedPreloadPath = candidate
return candidate
}
}
return join(mainDirname, "../preload/index.js")
}
function destroyPreloadingView(target?: BrowserView | null) {
const view = target ?? preloadingView
if (!view) {
return
}
try {
const contents = view.webContents as any
contents?.destroy?.()
} catch (error) {
console.warn("[cli] failed to destroy preloading view", error)
}
if (!target || view === preloadingView) {
preloadingView = null
}
}
function createWindow() {
const prefersDark = true
const backgroundColor = prefersDark ? "#1a1a1a" : "#ffffff"
const iconPath = getIconPath()
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
minWidth: 800,
minHeight: 600,
backgroundColor,
icon: iconPath,
webPreferences: {
preload: getPreloadPath(),
contextIsolation: true,
nodeIntegration: false,
spellcheck: !isMac,
},
})
setupNavigationGuards(mainWindow)
if (isMac) {
mainWindow.webContents.session.setSpellCheckerEnabled(false)
}
showingLoadingScreen = true
currentCliUrl = null
loadLoadingScreen(mainWindow)
if (process.env.NODE_ENV === "development") {
mainWindow.webContents.openDevTools({ mode: "detach" })
}
createApplicationMenu(mainWindow)
setupCliIPC(mainWindow, cliManager)
mainWindow.on("closed", () => {
destroyPreloadingView()
mainWindow = null
currentCliUrl = null
pendingCliUrl = null
showingLoadingScreen = false
})
if (pendingCliUrl) {
const url = pendingCliUrl
pendingCliUrl = null
startCliPreload(url)
}
}
function showLoadingScreen(force = false) {
if (!mainWindow || mainWindow.isDestroyed()) {
return
}
if (showingLoadingScreen && !force) {
return
}
destroyPreloadingView()
showingLoadingScreen = true
currentCliUrl = null
pendingCliUrl = null
loadLoadingScreen(mainWindow)
}
function startCliPreload(url: string) {
if (!mainWindow || mainWindow.isDestroyed()) {
pendingCliUrl = url
return
}
if (currentCliUrl === url && !showingLoadingScreen) {
return
}
pendingCliUrl = url
destroyPreloadingView()
if (!showingLoadingScreen) {
showLoadingScreen(true)
}
const view = new BrowserView({
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
spellcheck: !isMac,
},
})
preloadingView = view
view.webContents.once("did-finish-load", () => {
if (preloadingView !== view) {
destroyPreloadingView(view)
return
}
finalizeCliSwap(url)
})
view.webContents.loadURL(url).catch((error) => {
console.error("[cli] failed to preload CLI view:", error)
if (preloadingView === view) {
destroyPreloadingView(view)
}
})
}
function finalizeCliSwap(url: string) {
destroyPreloadingView()
if (!mainWindow || mainWindow.isDestroyed()) {
pendingCliUrl = url
return
}
showingLoadingScreen = false
currentCliUrl = url
pendingCliUrl = null
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
}
async function startCli() {
try {
const devMode = process.env.NODE_ENV === "development"
console.info("[cli] start requested (dev mode:", devMode, ")")
await cliManager.start({ dev: devMode })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error("[cli] start failed:", message)
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("cli:error", { message })
}
}
}
cliManager.on("ready", (status) => {
if (!status.url) {
return
}
startCliPreload(status.url)
})
cliManager.on("status", (status) => {
if (status.state !== "ready") {
showLoadingScreen()
}
})
if (isMac) {
app.on("web-contents-created", (_, contents) => {
contents.session.setSpellCheckerEnabled(false)
})
}
app.whenReady().then(() => {
startCli()
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)
}
}
}
createWindow()
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
})
app.on("before-quit", async (event) => {
event.preventDefault()
await cliManager.stop().catch(() => {})
app.exit(0)
})
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,364 @@
import { spawn, type ChildProcess } from "child_process"
import { app } from "electron"
import { createRequire } from "module"
import { EventEmitter } from "events"
import { existsSync, readFileSync } from "fs"
import os from "os"
import path from "path"
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
const nodeRequire = createRequire(import.meta.url)
type CliState = "starting" | "ready" | "error" | "stopped"
type ListeningMode = "local" | "all"
export interface CliStatus {
state: CliState
pid?: number
port?: number
url?: string
error?: string
}
export interface CliLogEntry {
stream: "stdout" | "stderr"
message: string
}
interface StartOptions {
dev: boolean
}
interface CliEntryResolution {
entry: string
runner: "node" | "tsx"
runnerPath?: string
}
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
function resolveConfigPath(configPath?: string): string {
const target = configPath && configPath.trim().length > 0 ? configPath : DEFAULT_CONFIG_PATH
if (target.startsWith("~/")) {
return path.join(os.homedir(), target.slice(2))
}
return path.resolve(target)
}
function resolveHostForMode(mode: ListeningMode): string {
return mode === "local" ? "127.0.0.1" : "0.0.0.0"
}
function readListeningModeFromConfig(): ListeningMode {
try {
const configPath = resolveConfigPath(process.env.CLI_CONFIG)
if (!existsSync(configPath)) return "local"
const content = readFileSync(configPath, "utf-8")
const parsed = JSON.parse(content)
const mode = parsed?.preferences?.listeningMode
if (mode === "local" || mode === "all") {
return mode
}
} catch (error) {
console.warn("[cli] failed to read listening mode from config", error)
}
return "local"
}
export declare interface CliProcessManager {
on(event: "status", listener: (status: CliStatus) => void): this
on(event: "ready", listener: (status: CliStatus) => void): this
on(event: "log", listener: (entry: CliLogEntry) => void): this
on(event: "exit", listener: (status: CliStatus) => void): this
on(event: "error", listener: (error: Error) => void): this
}
export class CliProcessManager extends EventEmitter {
private child?: ChildProcess
private status: CliStatus = { state: "stopped" }
private stdoutBuffer = ""
private stderrBuffer = ""
async start(options: StartOptions): Promise<CliStatus> {
if (this.child) {
await this.stop()
}
this.stdoutBuffer = ""
this.stderrBuffer = ""
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
const cliEntry = this.resolveCliEntry(options)
const listeningMode = this.resolveListeningMode()
const host = resolveHostForMode(listeningMode)
const args = this.buildCliArgs(options, host)
console.info(
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
)
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
env.ELECTRON_RUN_AS_NODE = "1"
const spawnDetails = supportsUserShell()
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
: this.buildDirectSpawn(cliEntry, args)
const child = spawn(spawnDetails.command, spawnDetails.args, {
cwd: process.cwd(),
stdio: ["ignore", "pipe", "pipe"],
env,
shell: false,
})
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
if (!child.pid) {
console.error("[cli] spawn failed: no pid")
}
this.child = child
this.updateStatus({ pid: child.pid ?? undefined })
child.stdout?.on("data", (data: Buffer) => {
this.handleStream(data.toString(), "stdout")
})
child.stderr?.on("data", (data: Buffer) => {
this.handleStream(data.toString(), "stderr")
})
child.on("error", (error) => {
console.error("[cli] failed to start CLI:", error)
this.updateStatus({ state: "error", error: error.message })
this.emit("error", error)
})
child.on("exit", (code, signal) => {
const failed = this.status.state !== "ready"
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
this.updateStatus({ state: failed ? "error" : "stopped", error })
if (failed && error) {
this.emit("error", new Error(error))
}
this.emit("exit", this.status)
this.child = undefined
})
return new Promise<CliStatus>((resolve, reject) => {
const timeout = setTimeout(() => {
this.handleTimeout()
reject(new Error("CLI startup timeout"))
}, 60000)
this.once("ready", (status) => {
clearTimeout(timeout)
resolve(status)
})
this.once("error", (error) => {
clearTimeout(timeout)
reject(error)
})
})
}
async stop(): Promise<void> {
const child = this.child
if (!child) {
this.updateStatus({ state: "stopped" })
return
}
return new Promise((resolve) => {
const killTimeout = setTimeout(() => {
child.kill("SIGKILL")
}, 4000)
child.on("exit", () => {
clearTimeout(killTimeout)
this.child = undefined
console.info("[cli] CLI process exited")
this.updateStatus({ state: "stopped" })
resolve()
})
child.kill("SIGTERM")
})
}
getStatus(): CliStatus {
return { ...this.status }
}
private resolveListeningMode(): ListeningMode {
return readListeningModeFromConfig()
}
private handleTimeout() {
if (this.child) {
this.child.kill("SIGKILL")
this.child = undefined
}
this.updateStatus({ state: "error", error: "CLI did not start in time" })
this.emit("error", new Error("CLI did not start in time"))
}
private handleStream(chunk: string, stream: "stdout" | "stderr") {
if (stream === "stdout") {
this.stdoutBuffer += chunk
this.processBuffer("stdout")
} else {
this.stderrBuffer += chunk
this.processBuffer("stderr")
}
}
private processBuffer(stream: "stdout" | "stderr") {
const buffer = stream === "stdout" ? this.stdoutBuffer : this.stderrBuffer
const lines = buffer.split("\n")
const trailing = lines.pop() ?? ""
if (stream === "stdout") {
this.stdoutBuffer = trailing
} else {
this.stderrBuffer = trailing
}
for (const line of lines) {
if (!line.trim()) continue
console.info(`[cli][${stream}] ${line}`)
this.emit("log", { stream, message: line })
const port = this.extractPort(line)
if (port && this.status.state === "starting") {
const url = `http://127.0.0.1:${port}`
console.info(`[cli] ready on ${url}`)
this.updateStatus({ state: "ready", port, url })
this.emit("ready", this.status)
}
}
}
private extractPort(line: string): number | null {
const readyMatch = line.match(/CodeNomad Server is ready at http:\/\/[^:]+:(\d+)/i)
if (readyMatch) {
return parseInt(readyMatch[1], 10)
}
if (line.toLowerCase().includes("http server listening")) {
const httpMatch = line.match(/:(\d{2,5})(?!.*:\d)/)
if (httpMatch) {
return parseInt(httpMatch[1], 10)
}
try {
const parsed = JSON.parse(line)
if (typeof parsed.port === "number") {
return parsed.port
}
} catch {
// not JSON, ignore
}
}
return null
}
private updateStatus(patch: Partial<CliStatus>) {
this.status = { ...this.status, ...patch }
this.emit("status", this.status)
}
private buildCliArgs(options: StartOptions, host: string): string[] {
const args = ["serve", "--host", host, "--port", "0"]
if (options.dev) {
args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug")
}
return args
}
private buildCommand(cliEntry: CliEntryResolution, args: string[]): string {
const parts = [JSON.stringify(process.execPath)]
if (cliEntry.runner === "tsx" && cliEntry.runnerPath) {
parts.push(JSON.stringify(cliEntry.runnerPath))
}
parts.push(JSON.stringify(cliEntry.entry))
args.forEach((arg) => parts.push(JSON.stringify(arg)))
return parts.join(" ")
}
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
if (cliEntry.runner === "tsx") {
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
}
return { command: process.execPath, args: [cliEntry.entry, ...args] }
}
private resolveCliEntry(options: StartOptions): CliEntryResolution {
if (options.dev) {
const tsxPath = this.resolveTsx()
if (!tsxPath) {
throw new Error("tsx is required to run the CLI in development mode. Please install dependencies.")
}
const devEntry = this.resolveDevEntry()
return { entry: devEntry, runner: "tsx", runnerPath: tsxPath }
}
const distEntry = this.resolveProdEntry()
return { entry: distEntry, runner: "node" }
}
private resolveTsx(): string | null {
const candidates: Array<string | (() => string)> = [
() => nodeRequire.resolve("tsx/cli"),
() => nodeRequire.resolve("tsx/dist/cli.mjs"),
() => nodeRequire.resolve("tsx/dist/cli.cjs"),
path.resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.mjs"),
path.resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.cjs"),
path.resolve(process.cwd(), "..", "node_modules", "tsx", "dist", "cli.mjs"),
path.resolve(process.cwd(), "..", "node_modules", "tsx", "dist", "cli.cjs"),
path.resolve(process.cwd(), "..", "..", "node_modules", "tsx", "dist", "cli.mjs"),
path.resolve(process.cwd(), "..", "..", "node_modules", "tsx", "dist", "cli.cjs"),
path.resolve(app.getAppPath(), "..", "node_modules", "tsx", "dist", "cli.mjs"),
path.resolve(app.getAppPath(), "..", "node_modules", "tsx", "dist", "cli.cjs"),
]
for (const candidate of candidates) {
try {
const resolved = typeof candidate === "function" ? candidate() : candidate
if (resolved && existsSync(resolved)) {
return resolved
}
} catch {
continue
}
}
return null
}
private resolveDevEntry(): string {
const entry = path.resolve(process.cwd(), "..", "server", "src", "index.ts")
if (!existsSync(entry)) {
throw new Error(`Dev CLI entry not found at ${entry}. Run npm run dev:electron from the repository root after installing dependencies.`)
}
return entry
}
private resolveProdEntry(): string {
try {
const entry = nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js")
if (existsSync(entry)) {
return entry
}
} catch {
// fall through to error below
}
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
}
}

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, thinkingBlocksExpansion: "expanded" }, 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,17 @@
const { contextBridge, ipcRenderer } = require("electron")
const electronAPI = {
onCliStatus: (callback) => {
ipcRenderer.on("cli:status", (_, data) => callback(data))
return () => ipcRenderer.removeAllListeners("cli:status")
},
onCliError: (callback) => {
ipcRenderer.on("cli:error", (_, data) => callback(data))
return () => ipcRenderer.removeAllListeners("cli:error")
},
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
restartCli: () => ipcRenderer.invoke("cli:restart"),
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
}
contextBridge.exposeInMainWorld("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"]
}