Resolve CLI binary metadata for UI
This commit is contained in:
@@ -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.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 ?? {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user