diff --git a/package-lock.json b/package-lock.json index b20438c3..876231de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codenomad-workspace", - "version": "0.7.1", + "version": "0.7.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codenomad-workspace", - "version": "0.7.1", + "version": "0.7.2", "dependencies": { "7zip-bin": "^5.2.0", "google-auth-library": "^10.5.0" @@ -7389,7 +7389,7 @@ }, "packages/electron-app": { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.7.1", + "version": "0.7.2", "dependencies": { "@codenomad/ui": "file:../ui", "@neuralnomads/codenomad": "file:../server" @@ -7423,7 +7423,7 @@ }, "packages/server": { "name": "@neuralnomads/codenomad", - "version": "0.7.1", + "version": "0.7.2", "dependencies": { "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", @@ -7458,14 +7458,14 @@ }, "packages/tauri-app": { "name": "@codenomad/tauri-app", - "version": "0.7.1", + "version": "0.7.2", "devDependencies": { "@tauri-apps/cli": "^2.9.4" } }, "packages/ui": { "name": "@codenomad/ui", - "version": "0.7.1", + "version": "0.7.2", "dependencies": { "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", diff --git a/package.json b/package.json index 342ccae9..97063365 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codenomad-workspace", - "version": "0.7.1", + "version": "0.7.2", "private": true, "description": "CodeNomad monorepo workspace", "workspaces": { diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index 0942fca2..52c08829 100644 --- a/packages/electron-app/package.json +++ b/packages/electron-app/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.7.1", + "version": "0.7.2", "description": "CodeNomad - AI coding assistant", "author": { "name": "Neural Nomads", diff --git a/packages/server/package-lock.json b/packages/server/package-lock.json index e764af11..74a8f33e 100644 --- a/packages/server/package-lock.json +++ b/packages/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "@neuralnomads/codenomad", - "version": "0.7.1", + "version": "0.7.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@neuralnomads/codenomad", - "version": "0.7.1", + "version": "0.7.2", "dependencies": { "@fastify/cors": "^8.5.0", "commander": "^12.1.0", diff --git a/packages/server/package.json b/packages/server/package.json index eb70730a..e6e7ca77 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad", - "version": "0.7.1", + "version": "0.7.2", "description": "CodeNomad Server", "author": { "name": "Neural Nomads", diff --git a/packages/server/src/background-processes/manager.ts b/packages/server/src/background-processes/manager.ts index 6864f180..53fdf919 100644 --- a/packages/server/src/background-processes/manager.ts +++ b/packages/server/src/background-processes/manager.ts @@ -1,4 +1,4 @@ -import { spawn, type ChildProcess } from "child_process" +import { spawn, spawnSync, type ChildProcess } from "child_process" import { createWriteStream, existsSync, promises as fs } from "fs" import path from "path" import { randomBytes } from "crypto" @@ -60,10 +60,13 @@ export class BackgroundProcessManager { const outputStream = createWriteStream(outputPath, { flags: "a" }) - const child = spawn("bash", ["-c", command], { + const { shellCommand, shellArgs, spawnOptions } = this.buildShellSpawn(command) + + const child = spawn(shellCommand, shellArgs, { cwd: workspace.path, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32", + ...spawnOptions, }) child.on("exit", () => { @@ -274,7 +277,15 @@ export class BackgroundProcessManager { const pid = child.pid if (!pid) return - if (process.platform !== "win32") { + if (process.platform === "win32") { + const args = this.buildWindowsTaskkillArgs(pid, signal) + try { + spawnSync("taskkill", args, { stdio: "ignore" }) + return + } catch { + // Fall back to killing the direct child. + } + } else { try { process.kill(-pid, signal) return @@ -321,6 +332,30 @@ export class BackgroundProcessManager { } + private buildShellSpawn(command: string): { shellCommand: string; shellArgs: string[]; spawnOptions?: Record } { + if (process.platform === "win32") { + const comspec = process.env.ComSpec || "cmd.exe" + return { + shellCommand: comspec, + shellArgs: ["/d", "/s", "/c", command], + spawnOptions: { windowsVerbatimArguments: true }, + } + } + + // Keep bash for macOS/Linux. + return { shellCommand: "bash", shellArgs: ["-c", command] } + } + + private buildWindowsTaskkillArgs(pid: number, signal: NodeJS.Signals): string[] { + // Default to graceful termination (no /F), then force kill when we escalate. + const force = signal === "SIGKILL" + const args = ["/PID", String(pid), "/T"] + if (force) { + args.push("/F") + } + return args + } + private statusFromExit(code: number | null): BackgroundProcessStatus { if (code === null) return "stopped" if (code === 0) return "stopped" diff --git a/packages/server/src/config/binaries.ts b/packages/server/src/config/binaries.ts index 7b3d4f52..56d86d50 100644 --- a/packages/server/src/config/binaries.ts +++ b/packages/server/src/config/binaries.ts @@ -4,10 +4,12 @@ import { BinaryUpdateRequest, BinaryValidationResult, } from "../api-types" +import { spawnSync } from "child_process" import { ConfigStore } from "./store" import { EventBus } from "../events/bus" import type { ConfigFile } from "./schema" import { Logger } from "../logger" +import { buildSpawnSpec } from "../workspaces/runtime" export class BinaryRegistry { constructor( @@ -135,8 +137,42 @@ export class BinaryRegistry { } private validateRecord(record: BinaryRecord): BinaryValidationResult { - // TODO: call actual binary -v check. - return { valid: true, version: record.version } + const inputPath = record.path + if (!inputPath) { + return { valid: false, error: "Missing binary path" } + } + + const spec = buildSpawnSpec(inputPath, ["--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) } + } } private buildFallbackRecord(path: string): BinaryRecord { diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index ee5a5aea..1b1d863f 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -225,13 +225,15 @@ export class WorkspaceManager { try { const result = spawnSync(locator, [identifier], { encoding: "utf8" }) if (result.status === 0 && result.stdout) { - const resolved = result.stdout + const candidates = result.stdout .split(/\r?\n/) .map((line) => line.trim()) - .find((line) => line.length > 0) + .filter((line) => line.length > 0) + .filter((line) => !/^INFO:/i.test(line)) - if (resolved) { - this.options.logger.debug({ identifier, resolved }, "Resolved binary path from system PATH") + if (candidates.length > 0) { + const resolved = this.pickBinaryCandidate(candidates) + this.options.logger.debug({ identifier, resolved, candidates }, "Resolved binary path from system PATH") return resolved } } else if (result.error) { @@ -244,6 +246,23 @@ export class WorkspaceManager { return identifier } + private pickBinaryCandidate(candidates: string[]): string { + if (process.platform !== "win32") { + return candidates[0] ?? "" + } + + const extensionPreference = [".exe", ".cmd", ".bat", ".ps1"] + + for (const ext of extensionPreference) { + const match = candidates.find((candidate) => candidate.toLowerCase().endsWith(ext)) + if (match) { + return match + } + } + + return candidates[0] ?? "" + } + private detectBinaryVersion(resolvedPath: string): string | undefined { if (!resolvedPath) { return undefined diff --git a/packages/server/src/workspaces/runtime.ts b/packages/server/src/workspaces/runtime.ts index 6861f385..203d984a 100644 --- a/packages/server/src/workspaces/runtime.ts +++ b/packages/server/src/workspaces/runtime.ts @@ -5,6 +5,41 @@ import { EventBus } from "../events/bus" import { LogLevel, WorkspaceLogEntry } from "../api-types" import { Logger } from "../logger" +export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"]) +export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"]) + +export function buildSpawnSpec(binaryPath: string, args: string[]) { + if (process.platform !== "win32") { + return { command: binaryPath, args, options: {} as const } + } + + const extension = path.extname(binaryPath).toLowerCase() + + if (WINDOWS_CMD_EXTENSIONS.has(extension)) { + const comspec = process.env.ComSpec || "cmd.exe" + // cmd.exe requires the full command as a single string. + // Using the ""