From 8b34b8537a90f27d616fb6d84ae740ea7a51fb39 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 18 Jun 2026 19:00:00 +0200 Subject: [PATCH] fix(crabbox): bootstrap bun on aws macos --- scripts/crabbox-wrapper.mjs | 102 ++++++++++++++++++++++----- test/scripts/crabbox-wrapper.test.ts | 57 ++++++++++++++- 2 files changed, 138 insertions(+), 21 deletions(-) diff --git a/scripts/crabbox-wrapper.mjs b/scripts/crabbox-wrapper.mjs index 00f9bff72bde..6d46bb23abac 100755 --- a/scripts/crabbox-wrapper.mjs +++ b/scripts/crabbox-wrapper.mjs @@ -128,8 +128,19 @@ function spawnInvocation(command, commandArgs, env, platform) { } const cmdMetaCharactersRe = /([()\][%!^"`<>&|;, *?])/g; -const jsRuntimeEntrypoints = new Set(["pnpm", "npm", "npx", "corepack", "node", "yarn", "bun"]); +const jsRuntimeEntrypoints = new Set([ + "pnpm", + "npm", + "npx", + "corepack", + "node", + "yarn", + "bun", + "bunx", +]); const awsMacosCorepackEntrypoints = new Set(["pnpm", "yarn", "corepack"]); +const awsMacosBunEntrypoints = new Set(["bun", "bunx"]); +const awsMacosBunVersion = "1.3.14"; const minimumBlacksmithCrabboxVersion = [0, 22, 0]; const shellControlCommandPrefixes = new Set([ "if", @@ -785,17 +796,27 @@ function commandNeedsAwsMacosPackageManager(commandArgs) { if (isChangedGateCommand(commandArgs)) { return true; } - if (commandArgs.length === 1) { - return shellCommandWordCandidates(commandArgs[0]).some(commandWordsNeedAwsMacosPackageManager); - } - return commandWordsNeedAwsMacosPackageManager(normalizedCommandWords(commandArgs)); + return commandNeedsEntrypoint(commandArgs, awsMacosCorepackEntrypoints); } -function commandWordsNeedAwsMacosPackageManager(wordsInput) { +function commandNeedsAwsMacosBun(commandArgs) { + return commandNeedsEntrypoint(commandArgs, awsMacosBunEntrypoints); +} + +function commandNeedsEntrypoint(commandArgs, entrypoints) { + if (commandArgs.length === 1) { + return shellCommandWordCandidates(commandArgs[0]).some((words) => + commandWordsNeedEntrypoint(words, entrypoints), + ); + } + return commandWordsNeedEntrypoint(normalizedCommandWords(commandArgs), entrypoints); +} + +function commandWordsNeedEntrypoint(wordsInput, entrypoints) { let words = wordsInput; words = normalizeExecutableWords(words); const first = (words[0] ?? "").split("/").pop(); - if (awsMacosCorepackEntrypoints.has(first)) { + if (entrypoints.has(first)) { return true; } @@ -803,7 +824,9 @@ function commandWordsNeedAwsMacosPackageManager(wordsInput) { if (!inlineCommand) { return false; } - return shellCommandWordCandidates(inlineCommand).some(commandWordsNeedAwsMacosPackageManager); + return shellCommandWordCandidates(inlineCommand).some((candidateWords) => + commandWordsNeedEntrypoint(candidateWords, entrypoints), + ); } function isChangedGateCommand(commandArgs) { @@ -1668,7 +1691,7 @@ function injectRemoteChangedGateGitBootstrap(commandArgs, changedGateBase) { return normalizedArgs; } -function remoteAwsMacosJsBootstrap({ packageManager = false } = {}) { +function remoteAwsMacosJsBootstrap({ packageManager = false, bun = false } = {}) { const nodeVersion = process.env.OPENCLAW_CRABBOX_MACOS_NODE_VERSION?.trim() || "24.15.0"; const bootstrap = [ "openclaw_crabbox_bootstrap_macos_js() {", @@ -1750,6 +1773,42 @@ function remoteAwsMacosJsBootstrap({ packageManager = false } = {}) { "pnpm --version >&2;", ); } + // Raw AWS macOS boxes skip setup-node-env, so Bun needs its own user-local pin. + if (bun) { + bootstrap.push( + `bun_version=${shellQuote(awsMacosBunVersion)};`, + 'bun_root="$tool_root/bun-v${bun_version}";', + 'bun_ready_marker="$bun_root/.openclaw-crabbox-bun-ready";', + 'export PATH="$bun_root/bin:$PATH";', + 'if [ ! -x "$bun_root/bin/bun" ] || [ ! -f "$bun_ready_marker" ]; then', + 'mkdir -p "$tool_root" || { status=$?; return "$status"; };', + 'bun_install_lock="$tool_root/.bun-${bun_version}.lock";', + "bun_lock_acquired=0;", + "bun_lock_deadline=$((SECONDS + 300));", + "while true; do", + 'if mkdir "$bun_install_lock" 2>/dev/null; then bun_lock_acquired=1; printf "%s\\n" "$$" >"$bun_install_lock/pid" || { status=$?; rm -rf "$bun_install_lock"; return "$status"; }; break; fi;', + 'if [ -x "$bun_root/bin/bun" ] && [ -f "$bun_ready_marker" ]; then break; fi;', + 'if [ "$SECONDS" -ge "$bun_lock_deadline" ]; then', + 'bun_lock_pid="$(cat "$bun_install_lock/pid" 2>/dev/null || true)";', + 'if [ -n "$bun_lock_pid" ] && kill -0 "$bun_lock_pid" 2>/dev/null; then echo "timed out waiting for active macOS Bun install lock: $bun_install_lock pid=$bun_lock_pid" >&2; return 1; fi;', + 'echo "reclaiming stale macOS Bun install lock: $bun_install_lock" >&2;', + 'rm -rf "$bun_install_lock" || return 1;', + "bun_lock_deadline=$((SECONDS + 300));", + "fi;", + "sleep 1;", + "done;", + 'release_bun_install_lock() { if [ "$bun_lock_acquired" = "1" ]; then rm -rf "$bun_install_lock" 2>/dev/null || true; fi; };', + 'if [ ! -x "$bun_root/bin/bun" ] || [ ! -f "$bun_ready_marker" ]; then', + 'rm -rf "$bun_root" || { status=$?; release_bun_install_lock; return "$status"; };', + 'mkdir -p "$bun_root" || { status=$?; release_bun_install_lock; return "$status"; };', + 'npm install --global --prefix "$bun_root" "bun@${bun_version}" || { status=$?; release_bun_install_lock; return "$status"; };', + 'touch "$bun_ready_marker" || { status=$?; release_bun_install_lock; return "$status"; };', + "fi;", + "release_bun_install_lock;", + "fi;", + "bun --version >&2 || return 1;", + ); + } bootstrap.push("};", "openclaw_crabbox_bootstrap_macos_js"); return bootstrap.join(" "); } @@ -1775,6 +1834,7 @@ function scopedAwsMacosEnvCommand(commandArgs) { return { runtimeEntrypoint: targetEntrypoint, packageManager: awsMacosCorepackEntrypoints.has(targetEntrypoint), + bun: awsMacosBunEntrypoints.has(targetEntrypoint), shellCommand: `openclaw_crabbox_env ${shellJoin(commandArgs.slice(1))}`, }; } @@ -1805,6 +1865,7 @@ function injectRemoteAwsMacosJsBootstrap(commandArgs, providerName) { const shellCommand = `${remoteAwsMacosJsBootstrap({ packageManager: directScopedEnvCommand?.packageManager || commandNeedsAwsMacosPackageManager(runArgs), + bun: directScopedEnvCommand?.bun || commandNeedsAwsMacosBun(runArgs), })} && { ${originalShellCommand}\n}`; if (!hasOption(normalizedArgs, "--shell")) { @@ -1876,13 +1937,13 @@ function prepareAwsMacosScriptStdinBootstrap(commandArgs, providerName) { } function createAwsMacosScriptStdinWrapper(script) { - const packageManager = scriptNeedsAwsMacosPackageManager(script); + const requirements = awsMacosScriptBootstrapRequirements(script); if (!script.startsWith("#!")) { - return `${remoteAwsMacosJsBootstrap({ packageManager })} || exit $?\n${script}`; + return `${remoteAwsMacosJsBootstrap(requirements)} || exit $?\n${script}`; } const delimiterValue = uniqueHereDocDelimiter(script); return [ - `${remoteAwsMacosJsBootstrap({ packageManager })} || exit $?`, + `${remoteAwsMacosJsBootstrap(requirements)} || exit $?`, 'tmp_script="$(mktemp "${TMPDIR:-/tmp}/openclaw-crabbox-script.XXXXXX")" || exit $?', 'cleanup_openclaw_crabbox_script() { rm -f "$tmp_script"; }', "trap cleanup_openclaw_crabbox_script EXIT", @@ -1895,7 +1956,8 @@ function createAwsMacosScriptStdinWrapper(script) { ].join("\n"); } -function scriptNeedsAwsMacosPackageManager(script) { +function awsMacosScriptBootstrapRequirements(script) { + const requirements = { packageManager: false, bun: false }; const firstLine = script.match(/^[^\r\n]*/u)?.[0] ?? ""; if (firstLine.startsWith("#!")) { let words = firstLine.slice(2).trim().split(/\s+/u).filter(Boolean); @@ -1905,11 +1967,13 @@ function scriptNeedsAwsMacosPackageManager(script) { words = words.slice(1); } } - if (commandWordsNeedAwsMacosPackageManager(words)) { - return true; - } + requirements.packageManager = commandWordsNeedEntrypoint(words, awsMacosCorepackEntrypoints); + requirements.bun = commandWordsNeedEntrypoint(words, awsMacosBunEntrypoints); + return requirements; } - return commandNeedsAwsMacosPackageManager([script]); + requirements.packageManager = commandNeedsAwsMacosPackageManager([script]); + requirements.bun = commandNeedsAwsMacosBun([script]); + return requirements; } function uniqueHereDocDelimiter(script) { @@ -2369,7 +2433,7 @@ if ( ) { if (isAwsMacosRemoteTarget(normalizedArgs, provider)) { console.error( - `[crabbox] provider=aws macOS raw boxes may lack Node/Corepack/pnpm for ${runtimeEntrypoint || "--script-stdin"}; bootstrapping a pinned user-local Node toolchain before the command`, + `[crabbox] provider=aws macOS raw boxes may lack Node/Corepack/pnpm/Bun for ${runtimeEntrypoint || "--script-stdin"}; bootstrapping pinned user-local JavaScript tooling before the command`, ); } else { const id = optionValue(normalizedArgs, "--id"); @@ -2377,7 +2441,7 @@ if ( ? `pnpm crabbox:hydrate -- --id ${id}` : "pnpm crabbox:warmup, then pnpm crabbox:hydrate -- --id "; console.error( - `[crabbox] warning: provider=aws raw boxes may lack Node/Corepack/pnpm for ${runtimeEntrypoint}; hydrate first (${hydrate}) or pass --provider blacksmith-testbox for OpenClaw CI-like proof; not switching providers automatically`, + `[crabbox] warning: provider=aws raw boxes may lack Node/Corepack/pnpm/Bun for ${runtimeEntrypoint}; hydrate first (${hydrate}) or pass --provider blacksmith-testbox for OpenClaw CI-like proof; not switching providers automatically`, ); } } diff --git a/test/scripts/crabbox-wrapper.test.ts b/test/scripts/crabbox-wrapper.test.ts index 8beaa2c93cb3..43ac0bce941a 100644 --- a/test/scripts/crabbox-wrapper.test.ts +++ b/test/scripts/crabbox-wrapper.test.ts @@ -780,6 +780,41 @@ describe.concurrent("scripts/crabbox-wrapper", () => { expectGroupedShellCommand(remoteCommand, "node --version"); }); + it("bootstraps Bun for raw AWS macOS bun commands", () => { + const result = runWrapper( + "provider: hetzner, aws, local-container, blacksmith-testbox, or cloudflare\n", + ["run", "--provider", "aws", "--target", "macos", "--", "bun", "--version"], + ); + + const output = parseFakeCrabboxOutput(result); + const remoteCommand = normalizeShellLineEndings(output.args.at(-1) ?? ""); + expect(result.status).toBe(0); + expect(output.args).toContain("--shell"); + expect(result.stderr).toContain("Node/Corepack/pnpm/Bun"); + expect(remoteCommand).toContain("openclaw_crabbox_bootstrap_macos_js"); + expect(remoteCommand).toContain("bun_version=1.3.14"); + expect(remoteCommand).toContain('bun_root="$tool_root/bun-v${bun_version}"'); + expect(remoteCommand).toContain( + 'npm install --global --prefix "$bun_root" "bun@${bun_version}"', + ); + expect(remoteCommand).toContain("bun --version >&2 || return 1"); + expect(remoteCommand).not.toContain("corepack enable"); + expectGroupedShellCommand(remoteCommand, "bun --version"); + }); + + it("bootstraps Bun for raw AWS macOS env-prefixed bun commands", () => { + const result = runWrapper( + "provider: hetzner, aws, local-container, blacksmith-testbox, or cloudflare\n", + ["run", "--provider", "aws", "--target", "macos", "--", "env", "-i", "bun", "--version"], + ); + + const output = parseFakeCrabboxOutput(result); + const remoteCommand = normalizeShellLineEndings(output.args.at(-1) ?? ""); + expect(result.status).toBe(0); + expect(remoteCommand).toContain("bun --version >&2 || return 1"); + expectGroupedShellCommand(remoteCommand, "openclaw_crabbox_env -i bun --version"); + }); + it("bootstraps Corepack for raw AWS macOS pnpm commands", () => { const result = runWrapper( "provider: hetzner, aws, local-container, blacksmith-testbox, or cloudflare\n", @@ -791,7 +826,7 @@ describe.concurrent("scripts/crabbox-wrapper", () => { expect(result.status).toBe(0); expect(output.args).toContain("--shell"); expect(result.stderr).toContain( - "bootstrapping a pinned user-local Node toolchain before the command", + "bootstrapping pinned user-local JavaScript tooling before the command", ); expect(remoteCommand).toContain("openclaw_crabbox_bootstrap_macos_js"); expect(remoteCommand).toContain("node-v${node_version}-darwin-${node_arch}.tar.gz"); @@ -1178,7 +1213,7 @@ describe.concurrent("scripts/crabbox-wrapper", () => { expect(output.args).not.toContain("--script-stdin"); expect(output.args).toContain("--script"); expect(result.stderr).toContain( - "bootstrapping a pinned user-local Node toolchain before the command", + "bootstrapping pinned user-local JavaScript tooling before the command", ); expect(output.scriptContent).toContain("openclaw_crabbox_bootstrap_macos_js"); expect(output.scriptContent).toContain('if [ ! -d "$TMPDIR" ]; then mkdir -p "$TMPDIR"'); @@ -1210,6 +1245,24 @@ describe.concurrent("scripts/crabbox-wrapper", () => { expect(output.args.at(-1)).toBe("arg1"); }); + it("bootstraps Bun for AWS macOS script-stdin bun shebangs", () => { + const script = ["#!/usr/bin/env bun", "console.log(Bun.version);"].join("\n"); + const result = runWrapper( + "provider: hetzner, aws, local-container, blacksmith-testbox, or cloudflare\n", + ["run", "--provider", "aws", "--target", "macos", "--script-stdin"], + { input: script }, + ); + + const output = parseFakeCrabboxOutput(result); + expect(result.status).toBe(0); + expect(output.scriptContent).toContain("bun_version=1.3.14"); + expect(output.scriptContent).toContain( + 'npm install --global --prefix "$bun_root" "bun@${bun_version}"', + ); + expect(output.scriptContent).toContain("bun --version >&2 || return 1"); + expect(output.scriptContent).not.toContain("corepack enable"); + }); + it("does not treat run option values as AWS macOS script-stdin flags", () => { const result = runWrapper( "provider: hetzner, aws, local-container, blacksmith-testbox, or cloudflare\n",