diff --git a/packages/electron-app/electron/main/process-manager.ts b/packages/electron-app/electron/main/process-manager.ts index ec100934..4c1877d5 100644 --- a/packages/electron-app/electron/main/process-manager.ts +++ b/packages/electron-app/electron/main/process-manager.ts @@ -263,47 +263,32 @@ export class CliProcessManager extends EventEmitter { private resolveCliEntry(options: StartOptions): CliEntryResolution { if (options.dev) { const tsxPath = this.resolveTsx() - const sourceCandidates = [ - path.resolve(app.getAppPath(), "..", "server", "src", "index.ts"), - path.resolve(app.getAppPath(), "..", "packages", "server", "src", "index.ts"), - path.resolve(process.cwd(), "packages", "server", "src", "index.ts"), - ] - const sourceEntry = sourceCandidates.find((candidate) => existsSync(candidate)) - if (tsxPath && sourceEntry) { - return { entry: sourceEntry, runner: "tsx", runnerPath: tsxPath } + 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 dist = this.tryResolveDist() - if (dist) { - return { entry: dist, runner: "node" } - } - - throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Please build @neuralnomads/codenomad.") + + const distEntry = this.resolveProdEntry() + return { entry: distEntry, runner: "node" } } - + private resolveTsx(): string | null { - try { - const resolved = nodeRequire.resolve("tsx/dist/cli.js") - if (resolved && existsSync(resolved)) { - return resolved - } - } catch { - return null - } - return null - } - - private tryResolveDist(): string | null { const candidates: Array string)> = [ - () => nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js"), - () => nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js", { paths: [app.getAppPath()] }), - path.join(app.getAppPath(), "node_modules", "@neuralnomads", "codenomad", "dist", "bin.js"), - path.resolve(app.getAppPath(), "..", "server", "dist", "bin.js"), - path.resolve(app.getAppPath(), "..", "packages", "server", "dist", "bin.js"), - path.join(process.resourcesPath, "app.asar.unpacked", "node_modules", "@neuralnomads", "codenomad", "dist", "bin.js"), + () => 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 @@ -314,7 +299,28 @@ export class CliProcessManager extends EventEmitter { 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.") + } } + diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index 69186f9e..404e3a10 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -1,4 +1,5 @@ import path from "path" +import { spawnSync } from "child_process" import { EventBus } from "../events/bus" import { ConfigStore } from "../config/store" import { BinaryRegistry } from "../config/binaries" @@ -65,10 +66,11 @@ export class WorkspaceManager { const id = `${Date.now().toString(36)}` const binary = this.options.binaryRegistry.resolveDefault() + const resolvedBinaryPath = this.resolveBinaryPath(binary.path) const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder) clearWorkspaceSearchCache(workspacePath) - this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: binary.path }, "Creating workspace") + this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace") const proxyPath = `/workspaces/${id}/instance` @@ -79,14 +81,20 @@ export class WorkspaceManager { name, status: "starting", proxyPath, - binaryId: binary.id, + binaryId: resolvedBinaryPath, binaryLabel: binary.label, binaryVersion: binary.version, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), } + if (!descriptor.binaryVersion) { + descriptor.binaryVersion = this.detectBinaryVersion(resolvedBinaryPath) + } + this.workspaces.set(id, descriptor) + + this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor }) const environment = this.options.configStore.get().preferences.environmentVariables ?? {} @@ -95,7 +103,7 @@ export class WorkspaceManager { const { pid, port } = await this.runtime.launch({ workspaceId: id, folder: workspacePath, - binaryPath: binary.path, + binaryPath: resolvedBinaryPath, environment, onExit: (info) => this.handleProcessExit(info.workspaceId, info), }) @@ -161,6 +169,70 @@ export class WorkspaceManager { return workspace } + private resolveBinaryPath(identifier: string): string { + if (!identifier) { + return identifier + } + + const looksLikePath = identifier.includes("/") || identifier.includes("\\") || identifier.startsWith(".") + if (path.isAbsolute(identifier) || looksLikePath) { + return identifier + } + + const locator = process.platform === "win32" ? "where" : "which" + + try { + const result = spawnSync(locator, [identifier], { encoding: "utf8" }) + if (result.status === 0 && result.stdout) { + const resolved = result.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.length > 0) + + if (resolved) { + this.options.logger.debug({ identifier, resolved }, "Resolved binary path from system PATH") + return resolved + } + } else if (result.error) { + this.options.logger.warn({ identifier, err: result.error }, "Failed to resolve binary path via locator command") + } + } catch (error) { + this.options.logger.warn({ identifier, err: error }, "Failed to resolve binary path from system PATH") + } + + return identifier + } + + private detectBinaryVersion(resolvedPath: string): string | undefined { + if (!resolvedPath) { + return undefined + } + + try { + const result = spawnSync(resolvedPath, ["--version"], { encoding: "utf8" }) + if (result.status === 0 && result.stdout) { + const line = result.stdout.split(/\r?\n/).find((entry) => entry.trim().length > 0) + if (line) { + const normalized = line.trim() + const versionMatch = normalized.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/) + if (versionMatch) { + const version = versionMatch[1] + this.options.logger.debug({ binary: resolvedPath, version }, "Detected binary version") + return version + } + this.options.logger.debug({ binary: resolvedPath, reported: normalized }, "Binary reported version string") + return normalized + } + } else if (result.error) { + this.options.logger.warn({ binary: resolvedPath, err: result.error }, "Failed to read binary version") + } + } catch (error) { + this.options.logger.warn({ binary: resolvedPath, err: error }, "Failed to detect binary version") + } + + return undefined + } + private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) { const workspace = this.workspaces.get(workspaceId) if (!workspace) return diff --git a/packages/server/src/workspaces/runtime.ts b/packages/server/src/workspaces/runtime.ts index 00fe08a8..b977c115 100644 --- a/packages/server/src/workspaces/runtime.ts +++ b/packages/server/src/workspaces/runtime.ts @@ -37,7 +37,10 @@ export class WorkspaceRuntime { const env = { ...process.env, ...(options.environment ?? {}) } return new Promise((resolve, reject) => { - this.logger.info({ workspaceId: options.workspaceId, folder: options.folder }, "Launching OpenCode process") + this.logger.info( + { workspaceId: options.workspaceId, folder: options.folder, binary: options.binaryPath }, + "Launching OpenCode process", + ) const child = spawn(options.binaryPath, args, { cwd: options.folder, env, diff --git a/packages/ui/src/components/folder-selection-view.tsx b/packages/ui/src/components/folder-selection-view.tsx index a5419ec6..0b077644 100644 --- a/packages/ui/src/components/folder-selection-view.tsx +++ b/packages/ui/src/components/folder-selection-view.tsx @@ -31,9 +31,8 @@ const FolderSelectionView: Component = (props) => { // Update selected binary when preferences change createEffect(() => { const lastUsed = preferences().lastUsedBinary - if (lastUsed && lastUsed !== selectedBinary()) { - setSelectedBinary(lastUsed) - } + if (!lastUsed) return + setSelectedBinary((current) => (current === lastUsed ? current : lastUsed)) }) diff --git a/packages/ui/src/components/instance-info.tsx b/packages/ui/src/components/instance-info.tsx index 37117376..d30dda20 100644 --- a/packages/ui/src/components/instance-info.tsx +++ b/packages/ui/src/components/instance-info.tsx @@ -48,6 +48,7 @@ const InstanceInfo: Component = (props) => { const [isLoadingMetadata, setIsLoadingMetadata] = createSignal(true) const metadata = () => props.instance.metadata + const binaryVersion = () => props.instance.binaryVersion || metadata()?.version const mcpServers = () => { const status = metadata()?.mcpStatus return status ? parseMcpStatus(status) : [] @@ -104,11 +105,12 @@ const InstanceInfo: Component = (props) => { ...(lspStatus ? { lspStatus } : {}), } - if (!nextMetadata.version) { - nextMetadata.version = "0.15.8" + if (!nextMetadata.version && instance.binaryVersion) { + nextMetadata.version = instance.binaryVersion } updateInstance(instanceId, { metadata: nextMetadata }) + } catch (error) { if (!cancelled) { console.error("Failed to load instance metadata:", error) @@ -173,13 +175,13 @@ const InstanceInfo: Component = (props) => { )} - +
OpenCode Version
- v{metadata()?.version} + v{binaryVersion()}
diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index 11af3dab..adb1f7a6 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -52,7 +52,9 @@ function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instanc error: descriptor.error, client: existing?.client ?? null, metadata: existing?.metadata, - binaryPath: descriptor.binaryLabel, + binaryPath: descriptor.binaryId ?? descriptor.binaryLabel ?? existing?.binaryPath, + binaryLabel: descriptor.binaryLabel, + binaryVersion: descriptor.binaryVersion ?? existing?.binaryVersion, environmentVariables: existing?.environmentVariables ?? preferences().environmentVariables ?? {}, } } diff --git a/packages/ui/src/types/instance.ts b/packages/ui/src/types/instance.ts index cc4ea1ce..142c2751 100644 --- a/packages/ui/src/types/instance.ts +++ b/packages/ui/src/types/instance.ts @@ -39,5 +39,7 @@ export interface Instance { client: OpencodeClient | null metadata?: InstanceMetadata binaryPath?: string + binaryLabel?: string + binaryVersion?: string environmentVariables?: Record }