Fix WSL UNC OpenCode binaries on Windows (#341)
## Summary - support Windows validation and launch of OpenCode binaries stored under WSL UNC paths like \\wsl.localhost\... - harden the existing manual directory browser so absolute, UNC, and WSL paths can be pasted and navigated reliably - harden WSL env/path propagation, UNC workspace handling, runtime shutdown, and add targeted tests Partially addresses #5. ## Testing - node --test --import tsx src/workspaces/__tests__/spawn.test.ts - npm run typecheck --workspace @neuralnomads/codenomad - npm run typecheck --workspace @codenomad/ui
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import { probeBinaryVersion } from "../../workspaces/runtime"
|
||||
import { probeBinaryVersion } from "../../workspaces/spawn"
|
||||
import type { SettingsService } from "../../settings/service"
|
||||
import type { Logger } from "../../logger"
|
||||
import { sanitizeConfigDoc, sanitizeConfigOwner } from "../../settings/public-config"
|
||||
|
||||
193
packages/server/src/workspaces/__tests__/spawn.test.ts
Normal file
193
packages/server/src/workspaces/__tests__/spawn.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
|
||||
import { buildWindowsSpawnSpec, buildWslSignalSpec, parseWslUncPath, resolveWslWorkingDirectory } from "../spawn"
|
||||
|
||||
describe("parseWslUncPath", () => {
|
||||
it("parses WSL UNC paths into distro and linux path", () => {
|
||||
assert.deepEqual(parseWslUncPath(String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`), {
|
||||
distro: "Ubuntu",
|
||||
linuxPath: "/home/dev/.opencode/bin/opencode",
|
||||
})
|
||||
})
|
||||
|
||||
it("supports the legacy wsl$ UNC prefix", () => {
|
||||
assert.deepEqual(parseWslUncPath(String.raw`\\wsl$\Ubuntu\home\dev`), {
|
||||
distro: "Ubuntu",
|
||||
linuxPath: "/home/dev",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveWslWorkingDirectory", () => {
|
||||
it("keeps WSL workspace folders in the same distro", () => {
|
||||
assert.equal(
|
||||
JSON.stringify(resolveWslWorkingDirectory(String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`, "Ubuntu")),
|
||||
JSON.stringify({ kind: "linux", path: "/home/dev/workspace" }),
|
||||
)
|
||||
})
|
||||
|
||||
it("keeps Windows drive paths so WSL can resolve them with wslpath", () => {
|
||||
assert.equal(
|
||||
JSON.stringify(resolveWslWorkingDirectory(String.raw`C:\Users\dev\workspace`, "Ubuntu")),
|
||||
JSON.stringify({ kind: "windows", path: String.raw`C:\Users\dev\workspace` }),
|
||||
)
|
||||
})
|
||||
|
||||
it("keeps UNC network paths so WSL can resolve them with wslpath", () => {
|
||||
assert.equal(
|
||||
JSON.stringify(resolveWslWorkingDirectory(String.raw`\\server\share\workspace`, "Ubuntu")),
|
||||
JSON.stringify({ kind: "windows", path: String.raw`\\server\share\workspace` }),
|
||||
)
|
||||
})
|
||||
|
||||
it("rejects WSL workspace folders from a different distro", () => {
|
||||
assert.equal(resolveWslWorkingDirectory(String.raw`\\wsl.localhost\Debian\home\dev\workspace`, "Ubuntu"), null)
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildWindowsSpawnSpec", () => {
|
||||
it("wraps WSL binaries with wsl.exe and propagates required env vars", () => {
|
||||
const spec = buildWindowsSpawnSpec(
|
||||
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
|
||||
["serve", "--port", "0"],
|
||||
{
|
||||
cwd: String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`,
|
||||
env: {
|
||||
OPENCODE_CONFIG_DIR: String.raw`C:\Users\dev\AppData\Roaming\CodeNomad\opencode-config`,
|
||||
CODENOMAD_INSTANCE_ID: "workspace-123",
|
||||
OPENCODE_SERVER_PASSWORD: "secret",
|
||||
},
|
||||
propagateEnvKeys: ["OPENCODE_CONFIG_DIR", "CODENOMAD_INSTANCE_ID", "OPENCODE_SERVER_PASSWORD"],
|
||||
},
|
||||
)
|
||||
|
||||
assert.equal(spec.command, "wsl.exe")
|
||||
assert.deepEqual(spec.args, [
|
||||
"--distribution",
|
||||
"Ubuntu",
|
||||
"--cd",
|
||||
"/home/dev/workspace",
|
||||
"--exec",
|
||||
"/home/dev/.opencode/bin/opencode",
|
||||
"serve",
|
||||
"--port",
|
||||
"0",
|
||||
])
|
||||
assert.equal(spec.cwd, undefined)
|
||||
assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_DIR/p:CODENOMAD_INSTANCE_ID:OPENCODE_SERVER_PASSWORD")
|
||||
})
|
||||
|
||||
it("upgrades existing WSLENV path entries to include /p", () => {
|
||||
const spec = buildWindowsSpawnSpec(
|
||||
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
|
||||
["serve"],
|
||||
{
|
||||
env: {
|
||||
OPENCODE_CONFIG_DIR: String.raw`C:\Users\dev\AppData\Roaming\CodeNomad\opencode-config`,
|
||||
WSLENV: "OPENCODE_CONFIG_DIR:CODENOMAD_INSTANCE_ID/u",
|
||||
},
|
||||
propagateEnvKeys: ["OPENCODE_CONFIG_DIR", "CODENOMAD_INSTANCE_ID"],
|
||||
},
|
||||
)
|
||||
|
||||
assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_DIR/p:CODENOMAD_INSTANCE_ID/u")
|
||||
})
|
||||
|
||||
it("propagates inherited known path variables even when they are not explicitly requested", () => {
|
||||
const spec = buildWindowsSpawnSpec(
|
||||
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
|
||||
["serve"],
|
||||
{
|
||||
env: {
|
||||
NODE_EXTRA_CA_CERTS: String.raw`C:\certs\root.pem`,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert.equal(spec.env?.WSLENV, "NODE_EXTRA_CA_CERTS/p")
|
||||
})
|
||||
|
||||
it("uses wslpath for Windows workspace folders instead of assuming /mnt", () => {
|
||||
const spec = buildWindowsSpawnSpec(
|
||||
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
|
||||
["serve", "--port", "0"],
|
||||
{
|
||||
cwd: String.raw`C:\Users\dev\workspace`,
|
||||
},
|
||||
)
|
||||
|
||||
assert.equal(spec.command, "wsl.exe")
|
||||
assert.deepEqual(spec.args, [
|
||||
"--distribution",
|
||||
"Ubuntu",
|
||||
"--exec",
|
||||
"sh",
|
||||
"-lc",
|
||||
'cd "$(wslpath -au "$1")" && shift && exec "$@"',
|
||||
"codenomad-wsl-launch",
|
||||
String.raw`C:\Users\dev\workspace`,
|
||||
"/home/dev/.opencode/bin/opencode",
|
||||
"serve",
|
||||
"--port",
|
||||
"0",
|
||||
])
|
||||
})
|
||||
|
||||
it("uses wslpath for UNC network workspace folders", () => {
|
||||
const spec = buildWindowsSpawnSpec(
|
||||
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
|
||||
["serve"],
|
||||
{
|
||||
cwd: String.raw`\\server\share\workspace`,
|
||||
},
|
||||
)
|
||||
|
||||
assert.equal(spec.command, "wsl.exe")
|
||||
assert.deepEqual(spec.args, [
|
||||
"--distribution",
|
||||
"Ubuntu",
|
||||
"--exec",
|
||||
"sh",
|
||||
"-lc",
|
||||
'cd "$(wslpath -au "$1")" && shift && exec "$@"',
|
||||
"codenomad-wsl-launch",
|
||||
String.raw`\\server\share\workspace`,
|
||||
"/home/dev/.opencode/bin/opencode",
|
||||
"serve",
|
||||
])
|
||||
})
|
||||
|
||||
it("can wrap WSL launches to emit the Linux PID marker", () => {
|
||||
const spec = buildWindowsSpawnSpec(
|
||||
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
|
||||
["serve"],
|
||||
{
|
||||
cwd: String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`,
|
||||
wslPidMarker: "__CODENOMAD_WSL_PID__:",
|
||||
},
|
||||
)
|
||||
|
||||
assert.equal(spec.command, "wsl.exe")
|
||||
assert.deepEqual(spec.args, [
|
||||
"--distribution",
|
||||
"Ubuntu",
|
||||
"--exec",
|
||||
"sh",
|
||||
"-lc",
|
||||
`printf '%s%s\\n' '__CODENOMAD_WSL_PID__:' "$$" && cd "$1" && shift && exec "$@"`,
|
||||
"codenomad-wsl-launch",
|
||||
"/home/dev/workspace",
|
||||
"/home/dev/.opencode/bin/opencode",
|
||||
"serve",
|
||||
])
|
||||
assert.equal(spec.wsl?.pidMarker, "__CODENOMAD_WSL_PID__:")
|
||||
})
|
||||
|
||||
it("builds the WSL kill command for tracked Linux PIDs", () => {
|
||||
const spec = buildWslSignalSpec("Ubuntu", 4321, "SIGTERM")
|
||||
|
||||
assert.equal(spec.command, "wsl.exe")
|
||||
assert.deepEqual(spec.args, ["--distribution", "Ubuntu", "--exec", "kill", "-TERM", "4321"])
|
||||
})
|
||||
})
|
||||
@@ -4,100 +4,10 @@ import path from "path"
|
||||
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"])
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
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) }
|
||||
}
|
||||
}
|
||||
import { buildSpawnSpec, buildWslSignalSpec } from "./spawn"
|
||||
|
||||
const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i
|
||||
const WSL_PID_MARKER = "__CODENOMAD_WSL_PID__:"
|
||||
|
||||
function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> {
|
||||
const redacted: Record<string, string | undefined> = {}
|
||||
@@ -130,6 +40,10 @@ export interface ProcessExitInfo {
|
||||
interface ManagedProcess {
|
||||
child: ChildProcess
|
||||
requestedStop: boolean
|
||||
wsl?: {
|
||||
distro: string
|
||||
linuxPid: number | null
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkspaceRuntime {
|
||||
@@ -167,7 +81,13 @@ export class WorkspaceRuntime {
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const spec = buildSpawnSpec(options.binaryPath, args)
|
||||
const propagatedEnvKeys = Object.keys(options.environment ?? {})
|
||||
const spec = buildSpawnSpec(options.binaryPath, args, {
|
||||
cwd: options.folder,
|
||||
env,
|
||||
propagateEnvKeys: propagatedEnvKeys,
|
||||
wslPidMarker: WSL_PID_MARKER,
|
||||
})
|
||||
const commandLine = [spec.command, ...spec.args].join(" ")
|
||||
this.logger.info(
|
||||
{
|
||||
@@ -197,14 +117,18 @@ export class WorkspaceRuntime {
|
||||
)
|
||||
const detached = process.platform !== "win32"
|
||||
const child = spawn(spec.command, spec.args, {
|
||||
cwd: options.folder,
|
||||
env,
|
||||
cwd: spec.cwd,
|
||||
env: spec.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached,
|
||||
...spec.options,
|
||||
})
|
||||
|
||||
const managed: ManagedProcess = { child, requestedStop: false }
|
||||
const managed: ManagedProcess = {
|
||||
child,
|
||||
requestedStop: false,
|
||||
...(spec.wsl ? { wsl: { distro: spec.wsl.distro, linuxPid: null } } : {}),
|
||||
}
|
||||
this.processes.set(options.workspaceId, managed)
|
||||
|
||||
let stdoutBuffer = ""
|
||||
@@ -284,6 +208,15 @@ export class WorkspaceRuntime {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
|
||||
if (managed.wsl && trimmed.startsWith(WSL_PID_MARKER)) {
|
||||
const linuxPid = Number.parseInt(trimmed.slice(WSL_PID_MARKER.length), 10)
|
||||
if (Number.isFinite(linuxPid) && linuxPid > 0) {
|
||||
managed.wsl.linuxPid = linuxPid
|
||||
this.logger.debug({ workspaceId: options.workspaceId, linuxPid }, "Captured WSL OpenCode PID")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
recentStdout.push(trimmed)
|
||||
if (recentStdout.length > MAX_OUTPUT_LINES) {
|
||||
recentStdout.shift()
|
||||
@@ -398,11 +331,44 @@ export class WorkspaceRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
const trySignalWslProcess = (signal: NodeJS.Signals) => {
|
||||
if (process.platform !== "win32" || !managed.wsl?.linuxPid) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const spec = buildWslSignalSpec(managed.wsl.distro, managed.wsl.linuxPid, signal)
|
||||
const result = spawnSync(spec.command, spec.args, { encoding: "utf8" })
|
||||
const exitCode = result.status
|
||||
if (exitCode === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
const stderr = (result.stderr ?? "").toString().toLowerCase()
|
||||
const stdout = (result.stdout ?? "").toString().toLowerCase()
|
||||
const combined = `${stdout}\n${stderr}`
|
||||
if (combined.includes("no such process") || combined.includes("not found")) {
|
||||
return true
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
{ workspaceId, pid, linuxPid: managed.wsl.linuxPid, distro: managed.wsl.distro, exitCode, stderr: result.stderr, stdout: result.stdout },
|
||||
"WSL kill failed",
|
||||
)
|
||||
return false
|
||||
} catch (error) {
|
||||
this.logger.debug({ workspaceId, pid, linuxPid: managed.wsl.linuxPid, distro: managed.wsl.distro, err: error }, "WSL kill failed to execute")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const sendStopSignal = (signal: NodeJS.Signals) => {
|
||||
if (process.platform === "win32") {
|
||||
// Best-effort: terminate the whole process tree rooted at pid.
|
||||
// Use /F only for escalation.
|
||||
tryTaskkill(signal === "SIGKILL")
|
||||
// WSL-backed launches need a Linux signal first because the tracked Windows PID belongs to wsl.exe.
|
||||
if (!trySignalWslProcess(signal)) {
|
||||
// Fallback to the Windows process tree rooted at pid. Use /F only for escalation.
|
||||
tryTaskkill(signal === "SIGKILL")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
307
packages/server/src/workspaces/spawn.ts
Normal file
307
packages/server/src/workspaces/spawn.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { spawnSync } from "child_process"
|
||||
import path from "path"
|
||||
|
||||
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.-]+)/
|
||||
const WSL_UNC_PATH_REGEX = /^\\\\wsl(?:\.localhost|\$)\\([^\\/]+)(?:[\\/](.*))?$/i
|
||||
const WSL_PATH_ENV_KEYS = new Set(["OPENCODE_CONFIG_DIR", "NODE_EXTRA_CA_CERTS"])
|
||||
|
||||
export interface SpawnSpec {
|
||||
command: string
|
||||
args: string[]
|
||||
options: {
|
||||
windowsVerbatimArguments?: boolean
|
||||
}
|
||||
cwd?: string
|
||||
env?: NodeJS.ProcessEnv
|
||||
wsl?: {
|
||||
distro: string
|
||||
pidMarker?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface BuildSpawnSpecOptions {
|
||||
cwd?: string
|
||||
env?: NodeJS.ProcessEnv
|
||||
propagateEnvKeys?: string[]
|
||||
wslPidMarker?: string
|
||||
}
|
||||
|
||||
interface WslPath {
|
||||
distro: string
|
||||
linuxPath: string
|
||||
}
|
||||
|
||||
export type WslWorkingDirectory =
|
||||
| { kind: "linux"; path: string }
|
||||
| { kind: "windows"; path: string }
|
||||
|
||||
export function parseWslUncPath(input: string): WslPath | null {
|
||||
const normalized = input.trim().replace(/\//g, "\\")
|
||||
const match = normalized.match(WSL_UNC_PATH_REGEX)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const distro = match[1] ?? ""
|
||||
const remainder = match[2] ?? ""
|
||||
const segments = remainder.split(/\\+/).filter((segment) => segment.length > 0)
|
||||
|
||||
return {
|
||||
distro,
|
||||
linuxPath: segments.length > 0 ? `/${segments.join("/")}` : "/",
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveWslWorkingDirectory(folder: string, distro: string): WslWorkingDirectory | null {
|
||||
const wslFolder = parseWslUncPath(folder)
|
||||
if (wslFolder) {
|
||||
return wslFolder.distro.toLowerCase() === distro.toLowerCase() ? { kind: "linux", path: wslFolder.linuxPath } : null
|
||||
}
|
||||
|
||||
const windowsFolder = normalizeWindowsPath(folder)
|
||||
return windowsFolder ? { kind: "windows", path: windowsFolder } : null
|
||||
}
|
||||
|
||||
export function buildWindowsSpawnSpec(binaryPath: string, args: string[], options: BuildSpawnSpecOptions = {}): SpawnSpec {
|
||||
const wslPath = parseWslUncPath(binaryPath)
|
||||
if (wslPath) {
|
||||
return buildWslSpawnSpec(wslPath, args, options)
|
||||
}
|
||||
|
||||
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 },
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
}
|
||||
}
|
||||
|
||||
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: {},
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
command: binaryPath,
|
||||
args,
|
||||
options: {},
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSpawnSpec(binaryPath: string, args: string[], options: BuildSpawnSpecOptions = {}): SpawnSpec {
|
||||
if (process.platform !== "win32") {
|
||||
return {
|
||||
command: binaryPath,
|
||||
args,
|
||||
options: {},
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
}
|
||||
}
|
||||
|
||||
return buildWindowsSpawnSpec(binaryPath, args, options)
|
||||
}
|
||||
|
||||
export function buildWslSignalSpec(distro: string, linuxPid: number, signal: NodeJS.Signals): SpawnSpec {
|
||||
return {
|
||||
command: "wsl.exe",
|
||||
args: ["--distribution", distro, "--exec", "kill", signal === "SIGKILL" ? "-KILL" : "-TERM", String(linuxPid)],
|
||||
options: {},
|
||||
wsl: { distro },
|
||||
}
|
||||
}
|
||||
|
||||
export function probeBinaryVersion(binaryPath: string): {
|
||||
valid: boolean
|
||||
version?: string
|
||||
reported?: string
|
||||
error?: string
|
||||
} {
|
||||
if (!binaryPath) {
|
||||
return { valid: false, error: "Missing binary path" }
|
||||
}
|
||||
|
||||
try {
|
||||
const spec = buildSpawnSpec(binaryPath, ["--version"])
|
||||
const result = spawnSync(spec.command, spec.args, {
|
||||
encoding: "utf8",
|
||||
cwd: spec.cwd,
|
||||
env: spec.env,
|
||||
windowsVerbatimArguments: Boolean(spec.options.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) }
|
||||
}
|
||||
}
|
||||
|
||||
function buildWslSpawnSpec(wslPath: WslPath, args: string[], options: BuildSpawnSpecOptions): SpawnSpec {
|
||||
const workingDirectory = options.cwd ? resolveWslWorkingDirectory(options.cwd, wslPath.distro) : undefined
|
||||
if (options.cwd && !workingDirectory) {
|
||||
throw new Error(
|
||||
`Unable to translate workspace folder for WSL binary in distro "${wslPath.distro}": ${options.cwd}`,
|
||||
)
|
||||
}
|
||||
|
||||
const wslArgs = ["--distribution", wslPath.distro]
|
||||
const shouldWrapWithShell = Boolean(options.wslPidMarker) || workingDirectory?.kind === "windows"
|
||||
|
||||
if (!shouldWrapWithShell && workingDirectory?.kind === "linux") {
|
||||
wslArgs.push("--cd", workingDirectory.path)
|
||||
}
|
||||
|
||||
if (shouldWrapWithShell) {
|
||||
const launchScript = buildWslLaunchScript(workingDirectory ?? undefined, options.wslPidMarker)
|
||||
wslArgs.push(
|
||||
"--exec",
|
||||
"sh",
|
||||
"-lc",
|
||||
launchScript,
|
||||
"codenomad-wsl-launch",
|
||||
)
|
||||
if (workingDirectory) {
|
||||
wslArgs.push(workingDirectory.path)
|
||||
}
|
||||
wslArgs.push(
|
||||
wslPath.linuxPath,
|
||||
...args,
|
||||
)
|
||||
} else {
|
||||
wslArgs.push("--exec", wslPath.linuxPath, ...args)
|
||||
}
|
||||
|
||||
return {
|
||||
command: "wsl.exe",
|
||||
args: wslArgs,
|
||||
options: {},
|
||||
env: buildWslEnvironment(options.env, options.propagateEnvKeys),
|
||||
wsl: { distro: wslPath.distro, pidMarker: options.wslPidMarker },
|
||||
}
|
||||
}
|
||||
|
||||
function buildWslLaunchScript(workingDirectory: WslWorkingDirectory | undefined, pidMarker: string | undefined): string {
|
||||
const steps: string[] = []
|
||||
|
||||
if (pidMarker) {
|
||||
steps.push(`printf '%s%s\\n' '${pidMarker}' "$$"`)
|
||||
}
|
||||
|
||||
if (workingDirectory?.kind === "linux") {
|
||||
steps.push('cd "$1"')
|
||||
steps.push("shift")
|
||||
} else if (workingDirectory?.kind === "windows") {
|
||||
steps.push('cd "$(wslpath -au "$1")"')
|
||||
steps.push("shift")
|
||||
}
|
||||
|
||||
steps.push('exec "$@"')
|
||||
return steps.join(" && ")
|
||||
}
|
||||
|
||||
function normalizeWindowsPath(input: string): string | null {
|
||||
const normalized = path.win32.normalize(input.trim().replace(/\//g, "\\"))
|
||||
if (!normalized) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (/^[A-Za-z]:/.test(normalized) || normalized.startsWith("\\\\")) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function buildWslEnvironment(env: NodeJS.ProcessEnv | undefined, propagateEnvKeys: string[] | undefined): NodeJS.ProcessEnv | undefined {
|
||||
if (!env) {
|
||||
return env
|
||||
}
|
||||
|
||||
const keysToPropagate = Array.from(
|
||||
new Set([
|
||||
...(propagateEnvKeys ?? []).filter((key) => env[key] !== undefined),
|
||||
...Array.from(WSL_PATH_ENV_KEYS).filter((key) => env[key] !== undefined),
|
||||
]),
|
||||
)
|
||||
if (keysToPropagate.length === 0) {
|
||||
return env
|
||||
}
|
||||
|
||||
const next = { ...env }
|
||||
const entries = (next.WSLENV ?? "").split(":").filter((entry) => entry.length > 0)
|
||||
const byName = new Map(entries.map((entry) => [entry.split("/")[0] ?? entry, entry]))
|
||||
|
||||
for (const key of keysToPropagate) {
|
||||
const existingEntry = byName.get(key)
|
||||
if (existingEntry) {
|
||||
byName.set(key, ensureWslenvEntry(existingEntry, WSL_PATH_ENV_KEYS.has(key)))
|
||||
continue
|
||||
}
|
||||
byName.set(key, WSL_PATH_ENV_KEYS.has(key) ? `${key}/p` : key)
|
||||
}
|
||||
|
||||
next.WSLENV = Array.from(byName.values()).join(":")
|
||||
return next
|
||||
}
|
||||
|
||||
function ensureWslenvEntry(entry: string, requiresPathTranslation: boolean): string {
|
||||
if (!requiresPathTranslation) {
|
||||
return entry
|
||||
}
|
||||
|
||||
const [name, rawFlags = ""] = entry.split("/")
|
||||
if (rawFlags.includes("p")) {
|
||||
return entry
|
||||
}
|
||||
|
||||
return rawFlags.length > 0 ? `${name}/${rawFlags}p` : `${name}/p`
|
||||
}
|
||||
@@ -58,6 +58,16 @@ function resolveAbsolutePath(root: string, relativePath: string) {
|
||||
return `${trimmedRoot}${normalized}`
|
||||
}
|
||||
|
||||
function getAbsolutePathFromMetadata(metadata: FileSystemListingMetadata | null) {
|
||||
if (!metadata || metadata.pathKind === "drives") {
|
||||
return ""
|
||||
}
|
||||
if (metadata.pathKind === "relative") {
|
||||
return resolveAbsolutePath(metadata.rootPath, metadata.currentPath)
|
||||
}
|
||||
return metadata.displayPath
|
||||
}
|
||||
|
||||
type FolderRow =
|
||||
| { type: "up"; path: string }
|
||||
| { type: "folder"; entry: FileSystemEntry }
|
||||
@@ -67,6 +77,8 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
const [rootPath, setRootPath] = createSignal("")
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
const [pathInput, setPathInput] = createSignal("")
|
||||
const [pathInputDirty, setPathInputDirty] = createSignal(false)
|
||||
const [creatingFolder, setCreatingFolder] = createSignal(false)
|
||||
const [directoryChildren, setDirectoryChildren] = createSignal<Map<string, FileSystemEntry[]>>(new Map())
|
||||
const [loadingPaths, setLoadingPaths] = createSignal<Set<string>>(new Set())
|
||||
@@ -75,12 +87,16 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
|
||||
const metadataCache = new Map<string, FileSystemListingMetadata>()
|
||||
const inFlightRequests = new Map<string, Promise<FileSystemListingMetadata>>()
|
||||
let latestNavigationId = 0
|
||||
|
||||
function resetState() {
|
||||
setRootPath("")
|
||||
setDirectoryChildren(new Map<string, FileSystemEntry[]>())
|
||||
setLoadingPaths(new Set<string>())
|
||||
setCurrentPathKey(null)
|
||||
setCurrentMetadata(null)
|
||||
setPathInput("")
|
||||
setPathInputDirty(false)
|
||||
metadataCache.clear()
|
||||
inFlightRequests.clear()
|
||||
setError(null)
|
||||
@@ -109,11 +125,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
async function initialize() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const metadata = await loadDirectory()
|
||||
applyMetadata(metadata)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : t("directoryBrowser.load.errorFallback")
|
||||
setError(message)
|
||||
await navigateTo()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -197,13 +209,22 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
}
|
||||
|
||||
async function navigateTo(path?: string) {
|
||||
const navigationId = ++latestNavigationId
|
||||
setError(null)
|
||||
try {
|
||||
const metadata = await loadDirectory(path)
|
||||
if (navigationId !== latestNavigationId) {
|
||||
return null
|
||||
}
|
||||
applyMetadata(metadata)
|
||||
return metadata
|
||||
} catch (err) {
|
||||
if (navigationId !== latestNavigationId) {
|
||||
return null
|
||||
}
|
||||
const message = err instanceof Error ? err.message : t("directoryBrowser.load.errorFallback")
|
||||
setError(message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,31 +246,58 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
})
|
||||
|
||||
function handleNavigateTo(path: string) {
|
||||
setPathInputDirty(false)
|
||||
void navigateTo(path)
|
||||
}
|
||||
|
||||
function handleNavigateUp() {
|
||||
const parent = currentMetadata()?.parentPath
|
||||
if (parent) {
|
||||
setPathInputDirty(false)
|
||||
void navigateTo(parent)
|
||||
}
|
||||
}
|
||||
|
||||
const currentAbsolutePath = createMemo(() => {
|
||||
const metadata = currentMetadata()
|
||||
if (!metadata) {
|
||||
return ""
|
||||
return getAbsolutePathFromMetadata(currentMetadata())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const absolutePath = currentAbsolutePath()
|
||||
if (!pathInputDirty()) {
|
||||
setPathInput(absolutePath)
|
||||
}
|
||||
if (metadata.pathKind === "drives") {
|
||||
return ""
|
||||
}
|
||||
if (metadata.pathKind === "relative") {
|
||||
return resolveAbsolutePath(metadata.rootPath, metadata.currentPath)
|
||||
}
|
||||
return metadata.displayPath
|
||||
})
|
||||
|
||||
const canSelectCurrent = createMemo(() => Boolean(currentAbsolutePath()))
|
||||
const canSubmitPath = createMemo(() => pathInput().trim().length > 0)
|
||||
|
||||
async function handlePathSubmit() {
|
||||
const target = pathInput().trim()
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
const metadata = await navigateTo(target)
|
||||
if (!metadata) {
|
||||
return
|
||||
}
|
||||
setPathInputDirty(false)
|
||||
setPathInput(getAbsolutePathFromMetadata(metadata))
|
||||
}
|
||||
|
||||
async function handleSelectCurrent() {
|
||||
const target = pathInput().trim()
|
||||
const metadata = target && target !== currentAbsolutePath() ? await navigateTo(target) : currentMetadata()
|
||||
if (!metadata) {
|
||||
return
|
||||
}
|
||||
setPathInputDirty(false)
|
||||
const absolute = getAbsolutePathFromMetadata(metadata)
|
||||
if (absolute) {
|
||||
setPathInput(absolute)
|
||||
props.onSelect(absolute)
|
||||
}
|
||||
}
|
||||
|
||||
function handleEntrySelect(entry: FileSystemEntry) {
|
||||
const absolutePath = entry.absolutePath
|
||||
@@ -262,10 +310,13 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
|
||||
async function handleCreateFolder() {
|
||||
if (creatingFolder()) return
|
||||
const metadata = currentMetadata()
|
||||
const target = pathInput().trim()
|
||||
const metadata = target && target !== currentAbsolutePath() ? await navigateTo(target) : currentMetadata()
|
||||
if (!metadata || metadata.pathKind === "drives") {
|
||||
return
|
||||
}
|
||||
setPathInputDirty(false)
|
||||
setPathInput(getAbsolutePathFromMetadata(metadata))
|
||||
|
||||
const name =
|
||||
(await showPromptDialog(t("directoryBrowser.createFolder.promptMessage"), {
|
||||
@@ -338,19 +389,29 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
<div class="directory-browser-current">
|
||||
<div class="directory-browser-current-meta">
|
||||
<span class="directory-browser-current-label">{t("directoryBrowser.currentFolder")}</span>
|
||||
<span class="directory-browser-current-path">{currentAbsolutePath()}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={pathInput()}
|
||||
onInput={(event) => {
|
||||
setPathInput(event.currentTarget.value)
|
||||
setPathInputDirty(true)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void handlePathSubmit()
|
||||
}
|
||||
}}
|
||||
spellcheck={false}
|
||||
class="selector-input directory-browser-current-path"
|
||||
/>
|
||||
</div>
|
||||
<div class="directory-browser-current-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary directory-browser-select directory-browser-current-select"
|
||||
disabled={!canSelectCurrent() || creatingFolder()}
|
||||
onClick={() => {
|
||||
const absolute = currentAbsolutePath()
|
||||
if (absolute) {
|
||||
props.onSelect(absolute)
|
||||
}
|
||||
}}
|
||||
disabled={(!canSelectCurrent() && !canSubmitPath()) || creatingFolder()}
|
||||
onClick={() => void handleSelectCurrent()}
|
||||
>
|
||||
{t("directoryBrowser.selectCurrent")}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user