refactor: consolidate executable check into assertExecutable helper

- Add assertExecutable() to cli-shared that checks and attempts chmod
- Simplify CLI and SDK spawn code to use the shared helper
- Fix cli-shared package.json exports (.js not .mjs)
- Add global install instructions to SDK error message
This commit is contained in:
Nathan Flurry 2026-02-02 18:38:06 -08:00
parent 048dcc5693
commit 7f07428621
4 changed files with 95 additions and 77 deletions

View file

@ -15,7 +15,7 @@
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.mjs", "import": "./dist/index.js",
"require": "./dist/index.cjs" "require": "./dist/index.cjs"
} }
}, },

View file

@ -10,18 +10,54 @@ export type NonExecutableBinaryMessageOptions = {
genericInstallCommands?: string[]; genericInstallCommands?: string[];
}; };
export type FsSubset = {
accessSync: (path: string, mode?: number) => void;
chmodSync: (path: string, mode: number) => void;
constants: { X_OK: number };
};
export function isBunRuntime(): boolean { export function isBunRuntime(): boolean {
if (typeof process?.versions?.bun === "string") return true; if (typeof process?.versions?.bun === "string") return true;
const userAgent = process?.env?.npm_config_user_agent || ""; const userAgent = process?.env?.npm_config_user_agent || "";
return userAgent.includes("bun/"); return userAgent.includes("bun/");
} }
const PERMISSION_ERRORS = new Set(["EACCES", "EPERM", "ENOEXEC"]); const PERMISSION_ERRORS = new Set(["EACCES", "EPERM", "ENOEXEC"]);
export function isPermissionError(error: unknown): boolean { function isPermissionError(error: unknown): boolean {
if (!error || typeof error !== "object") return false; if (!error || typeof error !== "object") return false;
const code = (error as { code?: unknown }).code; const code = (error as { code?: unknown }).code;
return typeof code === "string" && PERMISSION_ERRORS.has(code); return typeof code === "string" && PERMISSION_ERRORS.has(code);
}
/**
* Checks if a binary is executable and attempts to make it executable if not.
* Returns true if the binary is (or was made) executable, false if it couldn't
* be made executable due to permission errors. Throws for other errors.
*
* Requires fs to be passed in to avoid static imports that break browser builds.
*/
export function assertExecutable(binPath: string, fs: FsSubset): boolean {
if (process.platform === "win32") {
return true;
}
try {
fs.accessSync(binPath, fs.constants.X_OK);
return true;
} catch {
// Not executable, try to fix
}
try {
fs.chmodSync(binPath, 0o755);
return true;
} catch (error) {
if (isPermissionError(error)) {
return false;
}
throw error;
}
} }
export function formatNonExecutableBinaryMessage( export function formatNonExecutableBinaryMessage(

View file

@ -1,8 +1,8 @@
#!/usr/bin/env node #!/usr/bin/env node
const { execFileSync } = require("child_process"); const { execFileSync } = require("child_process");
const { const {
assertExecutable,
formatNonExecutableBinaryMessage, formatNonExecutableBinaryMessage,
isPermissionError,
} = require("@sandbox-agent/cli-shared"); } = require("@sandbox-agent/cli-shared");
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
@ -10,29 +10,27 @@ const path = require("path");
const TRUST_PACKAGES = const TRUST_PACKAGES =
"@sandbox-agent/cli-linux-x64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64"; "@sandbox-agent/cli-linux-x64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64";
function printExecutableHint(binPath) { function formatHint(binPath) {
console.error( return formatNonExecutableBinaryMessage({
formatNonExecutableBinaryMessage({ binPath,
binPath, trustPackages: TRUST_PACKAGES,
trustPackages: TRUST_PACKAGES, bunInstallBlocks: [
bunInstallBlocks: [ {
{ label: "Project install",
label: "Project install", commands: [
commands: [ `bun pm trust ${TRUST_PACKAGES}`,
`bun pm trust ${TRUST_PACKAGES}`, "bun add @sandbox-agent/cli",
"bun add @sandbox-agent/cli", ],
], },
}, {
{ label: "Global install",
label: "Global install", commands: [
commands: [ `bun pm -g trust ${TRUST_PACKAGES}`,
`bun pm -g trust ${TRUST_PACKAGES}`, "bun add -g @sandbox-agent/cli",
"bun add -g @sandbox-agent/cli", ],
], },
}, ],
], });
}),
);
} }
const PLATFORMS = { const PLATFORMS = {
@ -53,29 +51,13 @@ try {
const pkgPath = require.resolve(`${pkg}/package.json`); const pkgPath = require.resolve(`${pkg}/package.json`);
const bin = process.platform === "win32" ? "sandbox-agent.exe" : "sandbox-agent"; const bin = process.platform === "win32" ? "sandbox-agent.exe" : "sandbox-agent";
const binPath = path.join(path.dirname(pkgPath), "bin", bin); const binPath = path.join(path.dirname(pkgPath), "bin", bin);
if (process.platform !== "win32") {
try { if (!assertExecutable(binPath, fs)) {
fs.accessSync(binPath, fs.constants.X_OK); console.error(formatHint(binPath));
} catch (error) { process.exit(1);
try {
fs.chmodSync(binPath, 0o755);
} catch (chmodError) {
if (isPermissionError(chmodError)) {
printExecutableHint(binPath);
}
console.error(`Failed to make ${binPath} executable.`);
throw chmodError;
}
}
}
try {
execFileSync(binPath, process.argv.slice(2), { stdio: "inherit" });
} catch (execError) {
if (isPermissionError(execError)) {
printExecutableHint(binPath);
}
throw execError;
} }
execFileSync(binPath, process.argv.slice(2), { stdio: "inherit" });
} catch (e) { } catch (e) {
if (e.status !== undefined) process.exit(e.status); if (e.status !== undefined) process.exit(e.status);
throw e; throw e;

View file

@ -1,8 +1,8 @@
import type { ChildProcess } from "node:child_process"; import type { ChildProcess } from "node:child_process";
import type { AddressInfo } from "node:net"; import type { AddressInfo } from "node:net";
import { import {
assertExecutable,
formatNonExecutableBinaryMessage, formatNonExecutableBinaryMessage,
isPermissionError,
} from "@sandbox-agent/cli-shared"; } from "@sandbox-agent/cli-shared";
export type SandboxAgentSpawnLogMode = "inherit" | "pipe" | "silent"; export type SandboxAgentSpawnLogMode = "inherit" | "pipe" | "silent";
@ -73,29 +73,29 @@ export async function spawnSandboxAgent(
throw new Error("sandbox-agent binary not found. Install @sandbox-agent/cli or set SANDBOX_AGENT_BIN."); throw new Error("sandbox-agent binary not found. Install @sandbox-agent/cli or set SANDBOX_AGENT_BIN.");
} }
if (process.platform !== "win32") { if (!assertExecutable(binaryPath, fs)) {
try { throw new Error(
fs.accessSync(binaryPath, fs.constants.X_OK); formatNonExecutableBinaryMessage({
} catch (error) { binPath: binaryPath,
if (isPermissionError(error)) { trustPackages: TRUST_PACKAGES,
throw new Error( bunInstallBlocks: [
formatNonExecutableBinaryMessage({ {
binPath: binaryPath, label: "Project install",
trustPackages: TRUST_PACKAGES, commands: [
bunInstallBlocks: [ `bun pm trust ${TRUST_PACKAGES}`,
{ "bun add sandbox-agent",
label: "Project install",
commands: [
`bun pm trust ${TRUST_PACKAGES}`,
"bun add sandbox-agent",
],
},
], ],
}), },
); {
} label: "Global install",
throw error; commands: [
} `bun pm -g trust ${TRUST_PACKAGES}`,
"bun add -g sandbox-agent",
],
},
],
}),
);
} }
const stdio = logMode === "inherit" ? "inherit" : logMode === "silent" ? "ignore" : "pipe"; const stdio = logMode === "inherit" ? "inherit" : logMode === "silent" ? "ignore" : "pipe";