Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "codenomad-workspace",
"version": "0.7.1",
"version": "0.7.2",
"private": true,
"description": "CodeNomad monorepo workspace",
"workspaces": {
Expand Down
2 changes: 1 addition & 1 deletion packages/electron-app/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 2 additions & 2 deletions packages/server/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.7.1",
"version": "0.7.2",
"description": "CodeNomad Server",
"author": {
"name": "Neural Nomads",
Expand Down
41 changes: 38 additions & 3 deletions packages/server/src/background-processes/manager.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -321,6 +332,30 @@ export class BackgroundProcessManager {
}


private buildShellSpawn(command: string): { shellCommand: string; shellArgs: string[]; spawnOptions?: Record<string, unknown> } {
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"
Expand Down
40 changes: 38 additions & 2 deletions packages/server/src/config/binaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down
27 changes: 23 additions & 4 deletions packages/server/src/workspaces/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand Down
44 changes: 41 additions & 3 deletions packages/server/src/workspaces/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""<script> <args>"" pattern ensures paths with spaces are handled.
const commandLine = `""${binaryPath}" ${args.join(" ")}"`

return {
command: comspec,
args: ["/d", "/s", "/c", commandLine],
options: { windowsVerbatimArguments: true } as const,
}
}

if (WINDOWS_POWERSHELL_EXTENSIONS.has(extension)) {
// powershell.exe ships with Windows. (pwsh may not.)
return {
command: "powershell.exe",
args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", binaryPath, ...args],
options: {} as const,
}
}

return { command: binaryPath, args, options: {} as const }
}

const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i

function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> {
Expand Down Expand Up @@ -73,22 +108,25 @@ export class WorkspaceRuntime {
}

return new Promise((resolve, reject) => {
const commandLine = [options.binaryPath, ...args].join(" ")
const spec = buildSpawnSpec(options.binaryPath, args)
const commandLine = [spec.command, ...spec.args].join(" ")
this.logger.info(
{
workspaceId: options.workspaceId,
folder: options.folder,
binary: options.binaryPath,
args,
spawnCommand: spec.command,
spawnArgs: spec.args,
commandLine,
env: redactEnvironment(env),
},
"Launching OpenCode process",
)
const child = spawn(options.binaryPath, args, {
const child = spawn(spec.command, spec.args, {
cwd: options.folder,
env,
stdio: ["ignore", "pipe", "pipe"],
...spec.options,
})

const managed: ManagedProcess = { child, requestedStop: false }
Expand Down
2 changes: 1 addition & 1 deletion packages/tauri-app/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@codenomad/tauri-app",
"version": "0.7.1",
"version": "0.7.2",
"private": true,
"scripts": {
"dev": "tauri dev",
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@codenomad/ui",
"version": "0.7.1",
"version": "0.7.2",
"private": true,
"type": "module",
"scripts": {
Expand Down