fix(scripts): resolve Crabbox shims on Windows

This commit is contained in:
Vincent Koc
2026-05-23 13:07:04 +02:00
parent 68bcd4e39d
commit 5c535df0a2
2 changed files with 131 additions and 9 deletions

View File

@@ -1,13 +1,14 @@
#!/usr/bin/env node
import { spawn, spawnSync } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, isAbsolute, relative, resolve } from "node:path";
import { delimiter, dirname, extname, isAbsolute, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { resolvePathEnvKey } from "./windows-cmd-helpers.mjs";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const repoLocal = resolve(repoRoot, "../crabbox/bin/crabbox");
const binary = existsSync(repoLocal) ? repoLocal : "crabbox";
const repoLocal = resolveCrabboxBinary(process.env, process.platform);
const binary = repoLocal ?? resolvePathBinary("crabbox", process.env, process.platform);
const args = process.argv.slice(2);
if (args[0] === "--") {
@@ -18,11 +19,95 @@ if (args[userArgStart] === "--") {
args.splice(userArgStart, 1);
}
function commandCandidates(command, platform) {
if (platform !== "win32") {
return [command];
}
if (extname(command)) {
return [command];
}
return [`${command}.exe`, `${command}.cmd`, `${command}.bat`, `${command}.com`, command];
}
function resolveCrabboxBinary(env, platform) {
const base = resolve(repoRoot, "../crabbox/bin/crabbox");
for (const candidate of commandCandidates(base, platform)) {
if (isFile(candidate)) {
return candidate;
}
}
return null;
}
function resolvePathBinary(command, env, platform) {
if (platform !== "win32") {
return command;
}
for (const candidate of commandCandidates(command, platform)) {
if (isFile(candidate)) {
return candidate;
}
}
const pathValue = env[resolvePathEnvKey(env)] ?? "";
for (const dir of pathValue.split(delimiter).filter(Boolean)) {
for (const candidate of commandCandidates(command, platform)) {
const fullPath = resolve(dir, candidate);
if (isFile(fullPath)) {
return fullPath;
}
}
}
return command;
}
function isFile(path) {
try {
return statSync(path).isFile();
} catch {
return false;
}
}
function spawnInvocation(command, commandArgs, env, platform) {
const extension = extname(command).toLowerCase();
if (platform === "win32" && (extension === ".cmd" || extension === ".bat")) {
return {
command: env.ComSpec ?? "cmd.exe",
args: ["/d", "/s", "/c", buildBatchCommandLine(command, commandArgs)],
windowsVerbatimArguments: true,
};
}
return { command, args: commandArgs };
}
const cmdMetaCharactersRe = /([()\][%!^"`<>&|;, *?])/g;
function escapeBatchCommand(command) {
return `${command}`.replace(cmdMetaCharactersRe, "^$1");
}
function escapeBatchArgument(arg) {
let escaped = `${arg}`;
escaped = escaped.replace(/(?=(\\+?)?)\1"/g, '$1$1\\"');
escaped = escaped.replace(/(?=(\\+?)?)\1$/, "$1$1");
escaped = `"${escaped}"`;
escaped = escaped.replace(cmdMetaCharactersRe, "^$1");
return escaped.replace(cmdMetaCharactersRe, "^$1");
}
function buildBatchCommandLine(command, commandArgs) {
const escapedCommand = escapeBatchCommand(command);
const escapedArgs = commandArgs.map(escapeBatchArgument);
return `"${[escapedCommand, ...escapedArgs].join(" ")}"`;
}
function checkedOutput(command, commandArgs) {
const result = spawnSync(command, commandArgs, {
const invocation = spawnInvocation(command, commandArgs, process.env, process.platform);
const result = spawnSync(invocation.command, invocation.args, {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
});
return {
status: result.status ?? 1,
@@ -31,10 +116,13 @@ function checkedOutput(command, commandArgs) {
}
function gitOutput(commandArgs) {
const result = spawnSync("git", commandArgs, {
const gitBinary = resolvePathBinary("git", process.env, process.platform);
const invocation = spawnInvocation(gitBinary, commandArgs, process.env, process.platform);
const result = spawnSync(invocation.command, invocation.args, {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
});
return {
status: result.status ?? 1,
@@ -609,10 +697,12 @@ if (
}
const childArgs = childCwd === repoRoot ? args : absolutizeLocalRunPaths(args);
const child = spawn(binary, childArgs, {
const childInvocation = spawnInvocation(binary, childArgs, childEnv, process.platform);
const child = spawn(childInvocation.command, childInvocation.args, {
cwd: childCwd,
stdio: "inherit",
env: childEnv,
windowsVerbatimArguments: childInvocation.windowsVerbatimArguments,
});
const signalExitCodes = new Map([

View File

@@ -54,6 +54,7 @@ function makeFakeGit(responses: Record<string, { status?: number; stdout?: strin
"process.exit(response.status ?? 0);",
].join("\n");
writeFileSync(gitPath, `${script}\n`, "utf8");
writeFileSync(`${gitPath}.cmd`, `@echo off\r\n"${process.execPath}" "%~dp0git" %*\r\n`, "utf8");
chmodSync(gitPath, 0o755);
return binDir;
}
@@ -61,7 +62,10 @@ function makeFakeGit(responses: Record<string, { status?: number; stdout?: strin
function runWrapper(
helpText: string,
args: string[],
options: { gitResponses?: Record<string, { status?: number; stdout?: string; stderr?: string }> } = {},
options: {
extraPathEntries?: string[];
gitResponses?: Record<string, { status?: number; stdout?: string; stderr?: string }>;
} = {},
) {
const binDir = makeFakeCrabbox(helpText);
const gitBinDir = options.gitResponses ? makeFakeGit(options.gitResponses) : "";
@@ -70,7 +74,9 @@ function runWrapper(
encoding: "utf8",
env: {
...process.env,
PATH: [binDir, gitBinDir, process.env.PATH ?? ""].filter(Boolean).join(path.delimiter),
PATH: [...(options.extraPathEntries ?? []), binDir, gitBinDir, process.env.PATH ?? ""]
.filter(Boolean)
.join(path.delimiter),
...(options.gitResponses
? { OPENCLAW_FAKE_GIT_RESPONSES: JSON.stringify(options.gitResponses) }
: {}),
@@ -116,6 +122,32 @@ describe("scripts/crabbox-wrapper", () => {
);
});
if (process.platform === "win32") {
it("preserves shell metacharacters through Windows Crabbox command shims", () => {
const remoteCommand = "pnpm build && pnpm test | more < in.txt > out.txt %PATH%";
const result = runWrapper("provider: aws\n", ["run", "--shell", "--", remoteCommand]);
expect(result.status).toBe(0);
expect(parseFakeCrabboxOutput(result).args).toEqual(["run", "--shell", "--", remoteCommand]);
});
}
if (process.platform !== "win32") {
it("keeps POSIX PATH lookup semantics for non-executable entries", () => {
const staleBinDir = mkdtempSync(path.join(tmpdir(), "openclaw-stale-crabbox-"));
tempDirs.push(staleBinDir);
writeFileSync(path.join(staleBinDir, "crabbox"), "not executable\n", "utf8");
const result = runWrapper(
"provider: aws\n",
["run", "--provider", "aws", "--", "echo ok"],
{ extraPathEntries: [staleBinDir] },
);
expect(result.status).toBe(0);
expect(parseFakeCrabboxOutput(result).args).toContain("aws");
});
}
it("accepts Crabbox provider aliases when their canonical provider is advertised", () => {
const helpText = [
"provider: hetzner, aws, gcp, local-container, blacksmith-testbox,",