@neuralnomads/codenomad 0.7.1 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { spawn } from "child_process";
1
+ import { spawn, spawnSync } from "child_process";
2
2
  import { createWriteStream, existsSync, promises as fs } from "fs";
3
3
  import path from "path";
4
4
  import { randomBytes } from "crypto";
@@ -33,10 +33,12 @@ export class BackgroundProcessManager {
33
33
  const processDir = await this.ensureProcessDir(workspaceId, id);
34
34
  const outputPath = path.join(processDir, OUTPUT_FILE);
35
35
  const outputStream = createWriteStream(outputPath, { flags: "a" });
36
- const child = spawn("bash", ["-c", command], {
36
+ const { shellCommand, shellArgs, spawnOptions } = this.buildShellSpawn(command);
37
+ const child = spawn(shellCommand, shellArgs, {
37
38
  cwd: workspace.path,
38
39
  stdio: ["ignore", "pipe", "pipe"],
39
40
  detached: process.platform !== "win32",
41
+ ...spawnOptions,
40
42
  });
41
43
  child.on("exit", () => {
42
44
  this.killProcessTree(child, "SIGTERM");
@@ -211,7 +213,17 @@ export class BackgroundProcessManager {
211
213
  const pid = child.pid;
212
214
  if (!pid)
213
215
  return;
214
- if (process.platform !== "win32") {
216
+ if (process.platform === "win32") {
217
+ const args = this.buildWindowsTaskkillArgs(pid, signal);
218
+ try {
219
+ spawnSync("taskkill", args, { stdio: "ignore" });
220
+ return;
221
+ }
222
+ catch {
223
+ // Fall back to killing the direct child.
224
+ }
225
+ }
226
+ else {
215
227
  try {
216
228
  process.kill(-pid, signal);
217
229
  return;
@@ -254,6 +266,27 @@ export class BackgroundProcessManager {
254
266
  clearTimeout(killTimeout);
255
267
  }
256
268
  }
269
+ buildShellSpawn(command) {
270
+ if (process.platform === "win32") {
271
+ const comspec = process.env.ComSpec || "cmd.exe";
272
+ return {
273
+ shellCommand: comspec,
274
+ shellArgs: ["/d", "/s", "/c", command],
275
+ spawnOptions: { windowsVerbatimArguments: true },
276
+ };
277
+ }
278
+ // Keep bash for macOS/Linux.
279
+ return { shellCommand: "bash", shellArgs: ["-c", command] };
280
+ }
281
+ buildWindowsTaskkillArgs(pid, signal) {
282
+ // Default to graceful termination (no /F), then force kill when we escalate.
283
+ const force = signal === "SIGKILL";
284
+ const args = ["/PID", String(pid), "/T"];
285
+ if (force) {
286
+ args.push("/F");
287
+ }
288
+ return args;
289
+ }
257
290
  statusFromExit(code) {
258
291
  if (code === null)
259
292
  return "stopped";
@@ -1,3 +1,5 @@
1
+ import { spawnSync } from "child_process";
2
+ import { buildSpawnSpec } from "../workspaces/runtime";
1
3
  export class BinaryRegistry {
2
4
  constructor(configStore, eventBus, logger) {
3
5
  this.configStore = configStore;
@@ -99,8 +101,36 @@ export class BinaryRegistry {
99
101
  this.eventBus?.publish({ type: "config.binariesChanged", binaries: this.mapRecords() });
100
102
  }
101
103
  validateRecord(record) {
102
- // TODO: call actual binary -v check.
103
- return { valid: true, version: record.version };
104
+ const inputPath = record.path;
105
+ if (!inputPath) {
106
+ return { valid: false, error: "Missing binary path" };
107
+ }
108
+ const spec = buildSpawnSpec(inputPath, ["--version"]);
109
+ try {
110
+ const result = spawnSync(spec.command, spec.args, {
111
+ encoding: "utf8",
112
+ windowsVerbatimArguments: Boolean(spec.options.windowsVerbatimArguments),
113
+ });
114
+ if (result.error) {
115
+ return { valid: false, error: result.error.message };
116
+ }
117
+ if (result.status !== 0) {
118
+ const stderr = result.stderr?.trim();
119
+ const stdout = result.stdout?.trim();
120
+ const combined = stderr || stdout;
121
+ const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`;
122
+ return { valid: false, error };
123
+ }
124
+ const stdout = (result.stdout ?? "").trim();
125
+ const firstLine = stdout.split(/\r?\n/).find((line) => line.trim().length > 0);
126
+ const normalized = firstLine?.trim();
127
+ const versionMatch = normalized?.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/);
128
+ const version = versionMatch?.[1];
129
+ return { valid: true, version };
130
+ }
131
+ catch (error) {
132
+ return { valid: false, error: error instanceof Error ? error.message : String(error) };
133
+ }
104
134
  }
105
135
  buildFallbackRecord(path) {
106
136
  return {
@@ -170,12 +170,14 @@ export class WorkspaceManager {
170
170
  try {
171
171
  const result = spawnSync(locator, [identifier], { encoding: "utf8" });
172
172
  if (result.status === 0 && result.stdout) {
173
- const resolved = result.stdout
173
+ const candidates = result.stdout
174
174
  .split(/\r?\n/)
175
175
  .map((line) => line.trim())
176
- .find((line) => line.length > 0);
177
- if (resolved) {
178
- this.options.logger.debug({ identifier, resolved }, "Resolved binary path from system PATH");
176
+ .filter((line) => line.length > 0)
177
+ .filter((line) => !/^INFO:/i.test(line));
178
+ if (candidates.length > 0) {
179
+ const resolved = this.pickBinaryCandidate(candidates);
180
+ this.options.logger.debug({ identifier, resolved, candidates }, "Resolved binary path from system PATH");
179
181
  return resolved;
180
182
  }
181
183
  }
@@ -188,6 +190,19 @@ export class WorkspaceManager {
188
190
  }
189
191
  return identifier;
190
192
  }
193
+ pickBinaryCandidate(candidates) {
194
+ if (process.platform !== "win32") {
195
+ return candidates[0] ?? "";
196
+ }
197
+ const extensionPreference = [".exe", ".cmd", ".bat", ".ps1"];
198
+ for (const ext of extensionPreference) {
199
+ const match = candidates.find((candidate) => candidate.toLowerCase().endsWith(ext));
200
+ if (match) {
201
+ return match;
202
+ }
203
+ }
204
+ return candidates[0] ?? "";
205
+ }
191
206
  detectBinaryVersion(resolvedPath) {
192
207
  if (!resolvedPath) {
193
208
  return undefined;
@@ -1,6 +1,34 @@
1
1
  import { spawn } from "child_process";
2
2
  import { existsSync, statSync } from "fs";
3
3
  import path from "path";
4
+ export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"]);
5
+ export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"]);
6
+ export function buildSpawnSpec(binaryPath, args) {
7
+ if (process.platform !== "win32") {
8
+ return { command: binaryPath, args, options: {} };
9
+ }
10
+ const extension = path.extname(binaryPath).toLowerCase();
11
+ if (WINDOWS_CMD_EXTENSIONS.has(extension)) {
12
+ const comspec = process.env.ComSpec || "cmd.exe";
13
+ // cmd.exe requires the full command as a single string.
14
+ // Using the ""<script> <args>"" pattern ensures paths with spaces are handled.
15
+ const commandLine = `""${binaryPath}" ${args.join(" ")}"`;
16
+ return {
17
+ command: comspec,
18
+ args: ["/d", "/s", "/c", commandLine],
19
+ options: { windowsVerbatimArguments: true },
20
+ };
21
+ }
22
+ if (WINDOWS_POWERSHELL_EXTENSIONS.has(extension)) {
23
+ // powershell.exe ships with Windows. (pwsh may not.)
24
+ return {
25
+ command: "powershell.exe",
26
+ args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", binaryPath, ...args],
27
+ options: {},
28
+ };
29
+ }
30
+ return { command: binaryPath, args, options: {} };
31
+ }
4
32
  const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i;
5
33
  function redactEnvironment(env) {
6
34
  const redacted = {};
@@ -44,19 +72,22 @@ export class WorkspaceRuntime {
44
72
  return combined.join("\n");
45
73
  };
46
74
  return new Promise((resolve, reject) => {
47
- const commandLine = [options.binaryPath, ...args].join(" ");
75
+ const spec = buildSpawnSpec(options.binaryPath, args);
76
+ const commandLine = [spec.command, ...spec.args].join(" ");
48
77
  this.logger.info({
49
78
  workspaceId: options.workspaceId,
50
79
  folder: options.folder,
51
80
  binary: options.binaryPath,
52
- args,
81
+ spawnCommand: spec.command,
82
+ spawnArgs: spec.args,
53
83
  commandLine,
54
84
  env: redactEnvironment(env),
55
85
  }, "Launching OpenCode process");
56
- const child = spawn(options.binaryPath, args, {
86
+ const child = spawn(spec.command, spec.args, {
57
87
  cwd: options.folder,
58
88
  env,
59
89
  stdio: ["ignore", "pipe", "pipe"],
90
+ ...spec.options,
60
91
  });
61
92
  const managed = { child, requestedStop: false };
62
93
  this.processes.set(options.workspaceId, managed);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neuralnomads/codenomad",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "CodeNomad Server",
5
5
  "author": {
6
6
  "name": "Neural Nomads",