@neuralnomads/codenomad 0.7.1 → 0.7.3

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 {
package/dist/index.js CHANGED
@@ -77,10 +77,16 @@ function parsePort(input) {
77
77
  return value;
78
78
  }
79
79
  function resolveHost(input) {
80
- if (input && input.trim() === "0.0.0.0") {
80
+ const trimmed = input?.trim();
81
+ if (!trimmed)
82
+ return DEFAULT_HOST;
83
+ if (trimmed === "0.0.0.0") {
81
84
  return "0.0.0.0";
82
85
  }
83
- return DEFAULT_HOST;
86
+ if (trimmed === "localhost") {
87
+ return DEFAULT_HOST;
88
+ }
89
+ return trimmed;
84
90
  }
85
91
  async function main() {
86
92
  const options = parseCliOptions(process.argv.slice(2));
@@ -94,11 +100,12 @@ async function main() {
94
100
  };
95
101
  logger.info({ options: logOptions }, "Starting CodeNomad CLI server");
96
102
  const eventBus = new EventBus(eventLogger);
103
+ const isLoopbackHost = (host) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.");
97
104
  const serverMeta = {
98
105
  httpBaseUrl: `http://${options.host}:${options.port}`,
99
106
  eventsUrl: `/api/events`,
100
107
  host: options.host,
101
- listeningMode: options.host === "0.0.0.0" ? "all" : "local",
108
+ listeningMode: isLoopbackHost(options.host) ? "local" : "all",
102
109
  port: options.port,
103
110
  hostLabel: options.host,
104
111
  workspaceRoot: options.rootDir,
@@ -56,6 +56,7 @@ export function createHttpServer(deps) {
56
56
  done();
57
57
  });
58
58
  const allowedDevOrigins = new Set(["http://localhost:3000", "http://127.0.0.1:3000"]);
59
+ const isLoopbackHost = (host) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.");
59
60
  app.register(cors, {
60
61
  origin: (origin, cb) => {
61
62
  if (!origin) {
@@ -77,6 +78,11 @@ export function createHttpServer(deps) {
77
78
  cb(null, true);
78
79
  return;
79
80
  }
81
+ // When we bind to a non-loopback host (e.g., 0.0.0.0 or LAN IP), allow cross-origin UI access.
82
+ if (deps.host === "0.0.0.0" || !isLoopbackHost(deps.host)) {
83
+ cb(null, true);
84
+ return;
85
+ }
80
86
  cb(null, false);
81
87
  },
82
88
  credentials: true,
@@ -212,12 +218,12 @@ export function createHttpServer(deps) {
212
218
  actualPort = address.port;
213
219
  }
214
220
  }
215
- const displayHost = deps.host === "0.0.0.0" ? "127.0.0.1" : deps.host === "127.0.0.1" ? "localhost" : deps.host;
221
+ const displayHost = deps.host === "127.0.0.1" ? "localhost" : deps.host;
216
222
  const serverUrl = `http://${displayHost}:${actualPort}`;
217
223
  deps.serverMeta.httpBaseUrl = serverUrl;
218
224
  deps.serverMeta.host = deps.host;
219
225
  deps.serverMeta.port = actualPort;
220
- deps.serverMeta.listeningMode = deps.host === "0.0.0.0" ? "all" : "local";
226
+ deps.serverMeta.listeningMode = deps.host === "0.0.0.0" || !isLoopbackHost(deps.host) ? "all" : "local";
221
227
  deps.logger.info({ port: actualPort, host: deps.host }, "HTTP server listening");
222
228
  console.log(`CodeNomad Server is ready at ${serverUrl}`);
223
229
  return { port: actualPort, url: serverUrl, displayHost };
@@ -8,7 +8,7 @@ function buildMetaResponse(meta) {
8
8
  return {
9
9
  ...meta,
10
10
  port,
11
- listeningMode: meta.host === "0.0.0.0" ? "all" : "local",
11
+ listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local",
12
12
  addresses,
13
13
  };
14
14
  }
@@ -25,6 +25,9 @@ function resolvePort(meta) {
25
25
  return 0;
26
26
  }
27
27
  }
28
+ function isLoopbackHost(host) {
29
+ return host === "127.0.0.1" || host === "::1" || host.startsWith("127.");
30
+ }
28
31
  function resolveAddresses(port, host) {
29
32
  const interfaces = os.networkInterfaces();
30
33
  const seen = new Set();
@@ -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.3",
4
4
  "description": "CodeNomad Server",
5
5
  "author": {
6
6
  "name": "Neural Nomads",