Skip to content

Commit bb69067

Browse files
SpaceMolt DevTeamclaude
andcommitted
v0.2.10: Fix Windows compiled binary crash on startup (reported by @LT1428 and @tom Ramen)
On Windows, Bun.main in compiled binaries returns a virtual path like "/" instead of the actual executable path. dirname("/") on Windows gives "\", causing SessionManager to try mkdirSync("\") which fails with EPERM. Fix: extract path resolution into paths.ts using process.execPath for compiled binaries with fallback to process.cwd(). Includes isRootPath() safety check to reject root-like directories. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 750e8e8 commit bb69067

4 files changed

Lines changed: 162 additions & 8 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@spacemolt/commander",
3-
"version": "0.2.9",
3+
"version": "0.2.10",
44
"type": "module",
55
"scripts": {
66
"start": "bun run src/commander.ts",

src/commander.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { readFileSync, existsSync } from "fs";
2-
import { join, dirname } from "path";
2+
import { join } from "path";
33
import type { Context, Message } from "@mariozechner/pi-ai";
44
import { resolveModel } from "./model.js";
55
import { SpaceMoltAPI } from "./api.js";
@@ -8,14 +8,13 @@ import { allTools } from "./tools.js";
88
import { fetchGameCommands, formatCommandList } from "./schema.js";
99
import { runAgentTurn, generateSessionHandoff, type CompactionState } from "./loop.js";
1010
import { log, logError, setDebug, logNotifications, formatNotifications } from "./ui.js";
11+
import { resolveProjectRoot } from "./paths.js";
1112
import DEFAULT_PROMPT from "../prompt.md" with { type: "text" };
1213

13-
// When running from source (bun run src/commander.ts), Bun.main is the .ts file
14-
// so dirname(dirname) correctly gives the repo root.
15-
// When running as a compiled binary, Bun.main is the binary itself,
16-
// so we only need dirname once to get the containing directory.
17-
const isSource = Bun.main.endsWith(".ts") || Bun.main.endsWith(".js");
18-
const PROJECT_ROOT = isSource ? dirname(dirname(Bun.main)) : dirname(Bun.main);
14+
// Resolve project root safely. In compiled binaries on Windows, Bun.main may
15+
// return a virtual path like "/" which breaks path resolution. resolveProjectRoot
16+
// handles this by preferring process.execPath and falling back to process.cwd().
17+
const PROJECT_ROOT = resolveProjectRoot(Bun.main, process.execPath, process.cwd());
1918
const TURN_INTERVAL = 2000; // ms between turns
2019

2120
function printUsage(): void {

src/paths.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { describe, test, expect } from "bun:test";
2+
import { resolveProjectRoot, isRootPath } from "./paths.js";
3+
4+
describe("isRootPath", () => {
5+
test("detects Unix root /", () => {
6+
expect(isRootPath("/")).toBe(true);
7+
});
8+
9+
test("detects backslash root (Windows)", () => {
10+
expect(isRootPath("\\")).toBe(true);
11+
});
12+
13+
test("detects Windows drive roots", () => {
14+
expect(isRootPath("C:\\")).toBe(true);
15+
expect(isRootPath("D:\\")).toBe(true);
16+
expect(isRootPath("C:")).toBe(true);
17+
});
18+
19+
test("detects empty string as root-like", () => {
20+
expect(isRootPath("")).toBe(true);
21+
});
22+
23+
test("detects multiple slashes as root", () => {
24+
expect(isRootPath("//")).toBe(true);
25+
expect(isRootPath("\\\\")).toBe(true);
26+
});
27+
28+
test("rejects normal directories", () => {
29+
expect(isRootPath("/home/user/project")).toBe(false);
30+
expect(isRootPath("/Users/clodpod/projects")).toBe(false);
31+
expect(isRootPath("E:\\code\\commander")).toBe(false);
32+
});
33+
});
34+
35+
describe("resolveProjectRoot", () => {
36+
test("uses dirname(dirname(Bun.main)) for source .ts files", () => {
37+
const result = resolveProjectRoot(
38+
"/home/user/commander/src/commander.ts",
39+
"/usr/bin/bun",
40+
"/home/user"
41+
);
42+
expect(result).toBe("/home/user/commander");
43+
});
44+
45+
test("uses dirname(dirname(Bun.main)) for source .js files", () => {
46+
const result = resolveProjectRoot(
47+
"/home/user/commander/src/commander.js",
48+
"/usr/bin/bun",
49+
"/home/user"
50+
);
51+
expect(result).toBe("/home/user/commander");
52+
});
53+
54+
test("uses execPath for compiled binary", () => {
55+
const result = resolveProjectRoot(
56+
"/internal/bundle", // Bun.main in compiled binary (not .ts/.js)
57+
"/home/user/commander/commander-linux-x64",
58+
"/home/user"
59+
);
60+
expect(result).toBe("/home/user/commander");
61+
});
62+
63+
test("BUG REPRO: does NOT return root when Bun.main is '/' in compiled binary", () => {
64+
// This is the exact bug: on Windows compiled binaries, Bun.main returns "/"
65+
// which causes dirname("/") -> "\" on Windows, leading to mkdir("\") -> EPERM
66+
const result = resolveProjectRoot(
67+
"/", // Bun.main returns "/" in compiled binary on Windows
68+
"/home/user/commander/commander-exe", // but execPath is valid
69+
"/home/user"
70+
);
71+
// Should NOT be "/" or "\" — should use execPath instead
72+
expect(isRootPath(result)).toBe(false);
73+
expect(result).toBe("/home/user/commander");
74+
});
75+
76+
test("falls back to cwd when both Bun.main and execPath are root paths", () => {
77+
const result = resolveProjectRoot(
78+
"/",
79+
"/",
80+
"/home/user/commander"
81+
);
82+
expect(result).toBe("/home/user/commander");
83+
});
84+
85+
test("falls back to cwd when both paths are empty", () => {
86+
const result = resolveProjectRoot(
87+
"",
88+
"",
89+
"/home/user/commander"
90+
);
91+
expect(result).toBe("/home/user/commander");
92+
});
93+
});

src/paths.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { dirname, resolve, parse } from "path";
2+
3+
/**
4+
* Resolve the project root directory.
5+
*
6+
* When running from source (bun run src/commander.ts), Bun.main is the .ts
7+
* file so we go up two directories (src/ -> repo root).
8+
*
9+
* When running as a compiled binary, Bun.main may return an internal virtual
10+
* path (e.g. "/" on Windows) instead of the real executable path. We use
11+
* process.execPath as the primary source for compiled binaries, falling back
12+
* to process.cwd() if that also fails.
13+
*/
14+
export function resolveProjectRoot(bunMain: string, execPath: string, cwd: string): string {
15+
const isSource = bunMain.endsWith(".ts") || bunMain.endsWith(".js");
16+
17+
if (isSource) {
18+
return dirname(dirname(bunMain));
19+
}
20+
21+
// Compiled binary: prefer process.execPath over Bun.main since Bun.main
22+
// may return a virtual filesystem path in compiled binaries.
23+
// Try execPath first, then bunMain, then fall back to cwd.
24+
for (const candidate of [execPath, bunMain]) {
25+
if (candidate) {
26+
const dir = dirname(resolve(candidate));
27+
// Sanity check: reject root-only paths (e.g. "/" or "\" or "C:\")
28+
if (!isRootPath(dir)) {
29+
return dir;
30+
}
31+
}
32+
}
33+
34+
// Last resort: use current working directory
35+
return cwd;
36+
}
37+
38+
/**
39+
* Check if a path is a filesystem root (e.g. "/", "\", "C:\", "D:/").
40+
* We never want to use a root as PROJECT_ROOT since creating subdirectories
41+
* there will fail with permission errors.
42+
*
43+
* Works cross-platform: handles both "/" (posix) and "\" / "C:\" (Windows).
44+
*/
45+
export function isRootPath(p: string): boolean {
46+
if (!p) return true;
47+
48+
// Normalize: trim trailing separators for comparison
49+
const normalized = p.replace(/[/\\]+$/, "");
50+
51+
// Empty after stripping means it was purely slashes (e.g. "/" or "\")
52+
if (normalized === "") return true;
53+
54+
// Windows drive root: "C:" or "D:" (after stripping trailing separator)
55+
if (/^[A-Za-z]:$/.test(normalized)) return true;
56+
57+
// Use path.parse as additional check (works for the native platform)
58+
const parsed = parse(p);
59+
if (parsed.base === "" || p === parsed.root) return true;
60+
61+
return false;
62+
}

0 commit comments

Comments
 (0)