Resolve CLI binary metadata for UI

This commit is contained in:
Shantur Rathore
2025-11-23 11:59:12 +00:00
parent 0a9b7fafed
commit f4de0103a8
7 changed files with 134 additions and 48 deletions

View File

@@ -263,47 +263,32 @@ export class CliProcessManager extends EventEmitter {
private resolveCliEntry(options: StartOptions): CliEntryResolution { private resolveCliEntry(options: StartOptions): CliEntryResolution {
if (options.dev) { if (options.dev) {
const tsxPath = this.resolveTsx() const tsxPath = this.resolveTsx()
const sourceCandidates = [ if (!tsxPath) {
path.resolve(app.getAppPath(), "..", "server", "src", "index.ts"), throw new Error("tsx is required to run the CLI in development mode. Please install dependencies.")
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 }
} }
const devEntry = this.resolveDevEntry()
return { entry: devEntry, runner: "tsx", runnerPath: tsxPath }
} }
const dist = this.tryResolveDist() const distEntry = this.resolveProdEntry()
if (dist) { return { entry: distEntry, runner: "node" }
return { entry: dist, runner: "node" }
}
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Please build @neuralnomads/codenomad.")
} }
private resolveTsx(): string | null { 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 | (() => string)> = [ const candidates: Array<string | (() => string)> = [
() => nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js"), () => nodeRequire.resolve("tsx/cli"),
() => nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js", { paths: [app.getAppPath()] }), () => nodeRequire.resolve("tsx/dist/cli.mjs"),
path.join(app.getAppPath(), "node_modules", "@neuralnomads", "codenomad", "dist", "bin.js"), () => nodeRequire.resolve("tsx/dist/cli.cjs"),
path.resolve(app.getAppPath(), "..", "server", "dist", "bin.js"), path.resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.mjs"),
path.resolve(app.getAppPath(), "..", "packages", "server", "dist", "bin.js"), path.resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.cjs"),
path.join(process.resourcesPath, "app.asar.unpacked", "node_modules", "@neuralnomads", "codenomad", "dist", "bin.js"), 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) { for (const candidate of candidates) {
try { try {
const resolved = typeof candidate === "function" ? candidate() : candidate const resolved = typeof candidate === "function" ? candidate() : candidate
@@ -314,7 +299,28 @@ export class CliProcessManager extends EventEmitter {
continue continue
} }
} }
return null 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

@@ -1,4 +1,5 @@
import path from "path" import path from "path"
import { spawnSync } from "child_process"
import { EventBus } from "../events/bus" import { EventBus } from "../events/bus"
import { ConfigStore } from "../config/store" import { ConfigStore } from "../config/store"
import { BinaryRegistry } from "../config/binaries" import { BinaryRegistry } from "../config/binaries"
@@ -65,10 +66,11 @@ export class WorkspaceManager {
const id = `${Date.now().toString(36)}` const id = `${Date.now().toString(36)}`
const binary = this.options.binaryRegistry.resolveDefault() const binary = this.options.binaryRegistry.resolveDefault()
const resolvedBinaryPath = this.resolveBinaryPath(binary.path)
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder) const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
clearWorkspaceSearchCache(workspacePath) 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` const proxyPath = `/workspaces/${id}/instance`
@@ -79,14 +81,20 @@ export class WorkspaceManager {
name, name,
status: "starting", status: "starting",
proxyPath, proxyPath,
binaryId: binary.id, binaryId: resolvedBinaryPath,
binaryLabel: binary.label, binaryLabel: binary.label,
binaryVersion: binary.version, binaryVersion: binary.version,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
} }
if (!descriptor.binaryVersion) {
descriptor.binaryVersion = this.detectBinaryVersion(resolvedBinaryPath)
}
this.workspaces.set(id, descriptor) this.workspaces.set(id, descriptor)
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor }) this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
const environment = this.options.configStore.get().preferences.environmentVariables ?? {} const environment = this.options.configStore.get().preferences.environmentVariables ?? {}
@@ -95,7 +103,7 @@ export class WorkspaceManager {
const { pid, port } = await this.runtime.launch({ const { pid, port } = await this.runtime.launch({
workspaceId: id, workspaceId: id,
folder: workspacePath, folder: workspacePath,
binaryPath: binary.path, binaryPath: resolvedBinaryPath,
environment, environment,
onExit: (info) => this.handleProcessExit(info.workspaceId, info), onExit: (info) => this.handleProcessExit(info.workspaceId, info),
}) })
@@ -161,6 +169,70 @@ export class WorkspaceManager {
return workspace 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 }) { private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) {
const workspace = this.workspaces.get(workspaceId) const workspace = this.workspaces.get(workspaceId)
if (!workspace) return if (!workspace) return

View File

@@ -37,7 +37,10 @@ export class WorkspaceRuntime {
const env = { ...process.env, ...(options.environment ?? {}) } const env = { ...process.env, ...(options.environment ?? {}) }
return new Promise((resolve, reject) => { 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, { const child = spawn(options.binaryPath, args, {
cwd: options.folder, cwd: options.folder,
env, env,

View File

@@ -31,9 +31,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
// Update selected binary when preferences change // Update selected binary when preferences change
createEffect(() => { createEffect(() => {
const lastUsed = preferences().lastUsedBinary const lastUsed = preferences().lastUsedBinary
if (lastUsed && lastUsed !== selectedBinary()) { if (!lastUsed) return
setSelectedBinary(lastUsed) setSelectedBinary((current) => (current === lastUsed ? current : lastUsed))
}
}) })

View File

@@ -48,6 +48,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
const [isLoadingMetadata, setIsLoadingMetadata] = createSignal(true) const [isLoadingMetadata, setIsLoadingMetadata] = createSignal(true)
const metadata = () => props.instance.metadata const metadata = () => props.instance.metadata
const binaryVersion = () => props.instance.binaryVersion || metadata()?.version
const mcpServers = () => { const mcpServers = () => {
const status = metadata()?.mcpStatus const status = metadata()?.mcpStatus
return status ? parseMcpStatus(status) : [] return status ? parseMcpStatus(status) : []
@@ -104,11 +105,12 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
...(lspStatus ? { lspStatus } : {}), ...(lspStatus ? { lspStatus } : {}),
} }
if (!nextMetadata.version) { if (!nextMetadata.version && instance.binaryVersion) {
nextMetadata.version = "0.15.8" nextMetadata.version = instance.binaryVersion
} }
updateInstance(instanceId, { metadata: nextMetadata }) updateInstance(instanceId, { metadata: nextMetadata })
} catch (error) { } catch (error) {
if (!cancelled) { if (!cancelled) {
console.error("Failed to load instance metadata:", error) console.error("Failed to load instance metadata:", error)
@@ -173,13 +175,13 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
)} )}
</Show> </Show>
<Show when={metadata()?.version}> <Show when={binaryVersion()}>
<div> <div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1"> <div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
OpenCode Version OpenCode Version
</div> </div>
<div class="text-xs px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary"> <div class="text-xs px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
v{metadata()?.version} v{binaryVersion()}
</div> </div>
</div> </div>
</Show> </Show>

View File

@@ -52,7 +52,9 @@ function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instanc
error: descriptor.error, error: descriptor.error,
client: existing?.client ?? null, client: existing?.client ?? null,
metadata: existing?.metadata, 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 ?? {}, environmentVariables: existing?.environmentVariables ?? preferences().environmentVariables ?? {},
} }
} }

View File

@@ -39,5 +39,7 @@ export interface Instance {
client: OpencodeClient | null client: OpencodeClient | null
metadata?: InstanceMetadata metadata?: InstanceMetadata
binaryPath?: string binaryPath?: string
binaryLabel?: string
binaryVersion?: string
environmentVariables?: Record<string, string> environmentVariables?: Record<string, string>
} }