diff --git a/packages/server/src/server/routes/settings.ts b/packages/server/src/server/routes/settings.ts index 4f5a70eb..e5673275 100644 --- a/packages/server/src/server/routes/settings.ts +++ b/packages/server/src/server/routes/settings.ts @@ -1,7 +1,6 @@ import { FastifyInstance } from "fastify" import { z } from "zod" -import { spawnSync } from "child_process" -import { buildSpawnSpec } from "../../workspaces/runtime" +import { probeBinaryVersion } from "../../workspaces/runtime" import type { SettingsService } from "../../settings/service" import type { Logger } from "../../logger" @@ -15,37 +14,8 @@ const ValidateBinarySchema = z.object({ }) function validateBinaryPath(binaryPath: string): { valid: boolean; version?: string; error?: string } { - if (!binaryPath) { - return { valid: false, error: "Missing binary path" } - } - - const spec = buildSpawnSpec(binaryPath, ["--version"]) - try { - const result = spawnSync(spec.command, spec.args, { - encoding: "utf8", - windowsVerbatimArguments: Boolean((spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments), - }) - - if (result.error) { - return { valid: false, error: result.error.message } - } - if (result.status !== 0) { - const stderr = result.stderr?.trim() - const stdout = result.stdout?.trim() - const combined = stderr || stdout - const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}` - return { valid: false, error } - } - - const stdout = (result.stdout ?? "").trim() - const firstLine = stdout.split(/\r?\n/).find((line) => line.trim().length > 0) - const normalized = firstLine?.trim() - const versionMatch = normalized?.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/) - const version = versionMatch?.[1] - return { valid: true, version } - } catch (error) { - return { valid: false, error: error instanceof Error ? error.message : String(error) } - } + const result = probeBinaryVersion(binaryPath) + return { valid: result.valid, version: result.version, error: result.error } } export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) { diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index a4b50e06..a6d03c66 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -8,7 +8,7 @@ import { FileSystemBrowser } from "../filesystem/browser" import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search" import { clearWorkspaceSearchCache } from "../filesystem/search-cache" import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types" -import { WorkspaceRuntime, ProcessExitInfo } from "./runtime" +import { WorkspaceRuntime, ProcessExitInfo, probeBinaryVersion } from "./runtime" import { Logger } from "../logger" import { getOpencodeConfigDir } from "../opencode-config.js" import { @@ -283,28 +283,22 @@ export class WorkspaceManager { 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") + const result = probeBinaryVersion(resolvedPath) + if (result.valid) { + if (result.version) { + this.options.logger.debug({ binary: resolvedPath, version: result.version }, "Detected binary version") + return result.version } - } catch (error) { - this.options.logger.warn({ binary: resolvedPath, err: error }, "Failed to detect binary version") + if (result.reported) { + this.options.logger.debug({ binary: resolvedPath, reported: result.reported }, "Binary reported version string") + return result.reported + } + return undefined } + if (result.error) { + this.options.logger.warn({ binary: resolvedPath, err: result.error }, "Failed to detect binary version") + } return undefined } diff --git a/packages/server/src/workspaces/runtime.ts b/packages/server/src/workspaces/runtime.ts index a7196d60..0246fbfd 100644 --- a/packages/server/src/workspaces/runtime.ts +++ b/packages/server/src/workspaces/runtime.ts @@ -8,6 +8,8 @@ import { Logger } from "../logger" export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"]) export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"]) +const VERSION_REGEX = /([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/ + export function buildSpawnSpec(binaryPath: string, args: string[]) { if (process.platform !== "win32") { return { command: binaryPath, args, options: {} as const } @@ -40,6 +42,61 @@ export function buildSpawnSpec(binaryPath: string, args: string[]) { return { command: binaryPath, args, options: {} as const } } +export function probeBinaryVersion(binaryPath: string): { + valid: boolean + version?: string + reported?: string + error?: string +} { + if (!binaryPath) { + return { valid: false, error: "Missing binary path" } + } + + const spec = buildSpawnSpec(binaryPath, ["--version"]) + + try { + const result = spawnSync(spec.command, spec.args, { + encoding: "utf8", + windowsVerbatimArguments: Boolean( + (spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments, + ), + }) + + if (result.error) { + return { valid: false, error: result.error.message } + } + + if (result.status !== 0) { + const stderr = result.stderr?.trim() + const stdout = result.stdout?.trim() + const combined = stderr || stdout + const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}` + return { valid: false, error } + } + + const stdoutLines = String(result.stdout ?? "") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + const stderrLines = String(result.stderr ?? "") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + + // Prefer stdout; fall back to stderr (some tools report version there). + const reported = stdoutLines[0] ?? stderrLines[0] + if (!reported) { + return { valid: true } + } + + const versionMatch = reported.match(VERSION_REGEX) + const version = versionMatch?.[1] + return { valid: true, version, reported } + } catch (error) { + return { valid: false, error: error instanceof Error ? error.message : String(error) } + } +} + const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i function redactEnvironment(env: Record): Record {