diff --git a/scripts/crabbox-wrapper.mjs b/scripts/crabbox-wrapper.mjs index 0b2e7527bb4..3a1280e02d0 100755 --- a/scripts/crabbox-wrapper.mjs +++ b/scripts/crabbox-wrapper.mjs @@ -141,6 +141,14 @@ const jsRuntimeEntrypoints = new Set([ const awsMacosCorepackEntrypoints = new Set(["pnpm", "yarn", "corepack"]); const awsMacosBunEntrypoints = new Set(["bun", "bunx"]); const awsMacosBunVersion = "1.3.14"; +const awsMacosSwiftEntrypoints = new Set(["swift", "xcodebuild"]); +const awsMacosSwiftScriptTargets = new Set([ + "mac:package", + "mac:restart", + "scripts/build-and-run-mac.sh", + "scripts/package-mac-app.sh", + "scripts/restart-mac.sh", +]); const minimumBlacksmithCrabboxVersion = [0, 22, 0]; const shellControlCommandPrefixes = new Set([ "if", @@ -955,6 +963,89 @@ function commandNeedsAwsMacosBun(commandArgs) { return commandNeedsEntrypoint(commandArgs, awsMacosBunEntrypoints); } +function commandNeedsAwsMacosSwiftToolchain(commandArgs) { + if (commandArgs.length === 1) { + return shellCommandWordCandidates(commandArgs[0]).some(commandWordsNeedAwsMacosSwiftToolchain); + } + return commandWordsNeedAwsMacosSwiftToolchain(normalizedCommandWords(commandArgs)); +} + +function commandWordsNeedAwsMacosSwiftToolchain(wordsInput) { + let words = wordsInput; + words = normalizeExecutableWords(words); + const first = (words[0] ?? "").split("/").pop(); + if (isSupportedSystemEnvCommand(first)) { + const targetWords = [...words]; + if (stripEnvCommandOptions(targetWords, { canShimIgnoreEnvironment: true })) { + return commandWordsNeedAwsMacosSwiftToolchain(targetWords); + } + } + if (awsMacosSwiftEntrypoints.has(first)) { + return true; + } + + if (first === "pnpm") { + const scriptName = words[1] === "run" ? words[2] : words[1]; + if (awsMacosSwiftScriptTargets.has(scriptName)) { + return true; + } + } + + if (isAwsMacosSwiftScriptTarget(words[0])) { + return true; + } + + if (commandWordsRunAwsMacosSwiftScript(words)) { + return true; + } + + const inlineCommand = shellInlineCommand(words); + if (!inlineCommand) { + return false; + } + return shellCommandWordCandidates(inlineCommand).some(commandWordsNeedAwsMacosSwiftToolchain); +} + +function isAwsMacosSwiftScriptTarget(word) { + if (!word) { + return false; + } + const normalized = word.replace(/^\.\//u, ""); + return ( + awsMacosSwiftScriptTargets.has(normalized) || + awsMacosSwiftScriptTargets.has(normalized.split("/").pop() ?? "") + ); +} + +function commandWordsRunAwsMacosSwiftScript(words) { + const first = (words[0] ?? "").split("/").pop(); + if (!shellInlineCommandInterpreters.has(first)) { + return false; + } + + for (let index = 1; index < words.length; index += 1) { + const word = words[index] ?? ""; + if (!word) { + return false; + } + if (word === "--") { + continue; + } + if (word === "-c" || /^-[^-]*c/u.test(word)) { + return false; + } + if (shellInlineCommandOptionConsumesNextValue(word)) { + index += 1; + continue; + } + if (word.startsWith("-") || word.startsWith("+")) { + continue; + } + return isAwsMacosSwiftScriptTarget(word); + } + return false; +} + function commandNeedsEntrypoint(commandArgs, entrypoints) { if (commandArgs.length === 1) { return shellCommandWordCandidates(commandArgs[0]).some((words) => @@ -2078,6 +2169,62 @@ function injectRemoteAwsMacosJsBootstrap(commandArgs, providerName) { return normalizedArgs; } +function remoteAwsMacosSwiftBootstrap() { + return [ + "openclaw_crabbox_require_macos_swift_62() {", + 'openclaw_xcode="";', + 'for openclaw_candidate in /Applications/Xcode_26.1.app /Applications/Xcode_26*.app /Applications/Xcode-26*.app; do if [ -d "$openclaw_candidate" ]; then openclaw_xcode="$openclaw_candidate"; fi; done;', + 'if [ -n "$openclaw_xcode" ]; then openclaw_developer="$openclaw_xcode/Contents/Developer"; if [ ! -d "$openclaw_developer" ]; then openclaw_developer="$openclaw_xcode"; fi; sudo xcode-select -s "$openclaw_developer" || return 1; fi;', + 'openclaw_swift_version="$(swift --version 2>&1)" || { status=$?; printf "%s\\n" "$openclaw_swift_version" >&2; return "$status"; };', + 'printf "%s\\n" "$openclaw_swift_version" >&2;', + 'openclaw_swift_major_minor="$(printf "%s\\n" "$openclaw_swift_version" | sed -nE "s/.*Apple Swift version ([0-9]+)\\.([0-9]+).*/\\1 \\2/p" | head -n 1)";', + 'if [ -z "$openclaw_swift_major_minor" ]; then echo "[crabbox] OpenClaw macOS app proof requires Swift tools 6.2+; unable to parse swift --version." >&2; return 2; fi;', + "set -- $openclaw_swift_major_minor;", + 'if [ "$1" -lt 6 ] || { [ "$1" -eq 6 ] && [ "$2" -lt 2 ]; }; then', + 'echo "[crabbox] OpenClaw macOS app proof requires Swift tools 6.2+ (Xcode 26.x)." >&2;', + 'echo "[crabbox] current Swift is $1.$2; select/install Xcode 26.x or use a Blacksmith macOS runner with Xcode_26.1.app." >&2;', + "return 2;", + "fi;", + "};", + "openclaw_crabbox_require_macos_swift_62", + ].join(" "); +} + +function injectRemoteAwsMacosSwiftBootstrap(commandArgs, providerName, force = false) { + const runArgs = runCommandArgs(commandArgs); + if ( + !isAwsMacosRemoteTarget(commandArgs, providerName) || + (!force && !commandNeedsAwsMacosSwiftToolchain(runArgs)) + ) { + return commandArgs; + } + + const { start, optionEnd } = runCommandBounds(commandArgs); + if (start < 0) { + return commandArgs; + } + + const normalizedArgs = [...commandArgs]; + const remoteCommand = normalizedArgs.slice(start); + const originalShellCommand = + hasOption(normalizedArgs, "--shell") && remoteCommand.length === 1 + ? remoteCommand[0] + : shellJoin(remoteCommand); + const shellCommand = `${remoteAwsMacosSwiftBootstrap()} && { ${originalShellCommand}\n}`; + + if (!hasOption(normalizedArgs, "--shell")) { + normalizedArgs.splice(optionEnd, 0, "--shell"); + } + + const updatedBounds = runCommandBounds(normalizedArgs); + normalizedArgs.splice( + updatedBounds.start, + normalizedArgs.length - updatedBounds.start, + shellCommand, + ); + return normalizedArgs; +} + function hasRunOption(commandArgs, name) { if (commandArgs[0] !== "run") { return false; @@ -2136,11 +2283,11 @@ function prepareAwsMacosScriptStdinBootstrap(commandArgs, providerName) { function createAwsMacosScriptStdinWrapper(script) { const requirements = awsMacosScriptBootstrapRequirements(script); if (!script.startsWith("#!")) { - return `${remoteAwsMacosJsBootstrap(requirements)} || exit $?\n${script}`; + return `${remoteAwsMacosScriptBootstrap(requirements)} || exit $?\n${script}`; } const delimiterValue = uniqueHereDocDelimiter(script); return [ - `${remoteAwsMacosJsBootstrap(requirements)} || exit $?`, + `${remoteAwsMacosScriptBootstrap(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", @@ -2153,22 +2300,33 @@ function createAwsMacosScriptStdinWrapper(script) { ].join("\n"); } +function remoteAwsMacosScriptBootstrap(requirements) { + const bootstraps = [remoteAwsMacosJsBootstrap(requirements)]; + if (requirements.swift) { + bootstraps.push(remoteAwsMacosSwiftBootstrap()); + } + return bootstraps.join(" && "); +} + function awsMacosScriptBootstrapRequirements(script) { - const requirements = { packageManager: false, bun: false }; + const requirements = { packageManager: false, bun: false, swift: false }; const firstLine = script.match(/^[^\r\n]*/u)?.[0] ?? ""; if (firstLine.startsWith("#!")) { const words = firstLine.slice(2).trim().split(/\s+/u).filter(Boolean); requirements.packageManager = commandWordsNeedEntrypoint(words, awsMacosCorepackEntrypoints); requirements.bun = commandWordsNeedEntrypoint(words, awsMacosBunEntrypoints); + requirements.swift = commandWordsNeedAwsMacosSwiftToolchain(words); if (commandWordsShellEntrypoint(words)) { const body = script.slice(firstLine.length).replace(/^\r?\n/u, ""); requirements.packageManager ||= commandNeedsAwsMacosPackageManager([body]); requirements.bun ||= commandNeedsAwsMacosBun([body]); + requirements.swift ||= commandNeedsAwsMacosSwiftToolchain([body]); } return requirements; } requirements.packageManager = commandNeedsAwsMacosPackageManager([script]); requirements.bun = commandNeedsAwsMacosBun([script]); + requirements.swift = commandNeedsAwsMacosSwiftToolchain([script]); return requirements; } @@ -2677,15 +2835,26 @@ if ( } const remoteMarkedArgs = injectRemoteChangedGateEnvironment(normalizedArgs); +const remoteMarkedNeedsAwsMacosSwift = + isAwsMacosRemoteTarget(remoteMarkedArgs, provider) && + commandNeedsAwsMacosSwiftToolchain(runCommandArgs(remoteMarkedArgs)); const childArgs = childCwd === repoRoot ? injectRemoteWindowsHydratedNodeModulesBootstrap( - injectRemoteAwsMacosJsBootstrap(remoteMarkedArgs, provider), + injectRemoteAwsMacosSwiftBootstrap( + injectRemoteAwsMacosJsBootstrap(remoteMarkedArgs, provider), + provider, + remoteMarkedNeedsAwsMacosSwift, + ), provider, ) : injectRemoteChangedGateGitBootstrap( injectRemoteWindowsHydratedNodeModulesBootstrap( - injectRemoteAwsMacosJsBootstrap(absolutizeLocalRunPaths(remoteMarkedArgs), provider), + injectRemoteAwsMacosSwiftBootstrap( + injectRemoteAwsMacosJsBootstrap(absolutizeLocalRunPaths(remoteMarkedArgs), provider), + provider, + remoteMarkedNeedsAwsMacosSwift, + ), provider, ), remoteChangedGateBase, diff --git a/test/scripts/crabbox-wrapper.test.ts b/test/scripts/crabbox-wrapper.test.ts index f221eff8e1d..f572defa8da 100644 --- a/test/scripts/crabbox-wrapper.test.ts +++ b/test/scripts/crabbox-wrapper.test.ts @@ -962,6 +962,111 @@ describe.concurrent("scripts/crabbox-wrapper", () => { expectGroupedShellCommand(remoteCommand, "node --version"); }); + it("preflights Swift 6.2 for raw AWS macOS Swift app builds", () => { + const result = runWrapper( + "provider: hetzner, aws, local-container, blacksmith-testbox, or cloudflare\n", + [ + "run", + "--provider", + "aws", + "--target", + "macos", + "--", + "swift", + "build", + "--package-path", + "apps/macos", + "--product", + "OpenClaw", + ], + ); + + const output = parseFakeCrabboxOutput(result); + const remoteCommand = normalizeShellLineEndings(output.args.at(-1) ?? ""); + expect(result.status).toBe(0); + expect(output.args).toContain("--shell"); + expect(remoteCommand).toContain("openclaw_crabbox_require_macos_swift_62"); + expect(remoteCommand).toContain("/Applications/Xcode_26.1.app"); + expect(remoteCommand).toContain("/Applications/Xcode-26*.app"); + expect(remoteCommand).toContain('sudo xcode-select -s "$openclaw_developer"'); + expect(remoteCommand).toContain("OpenClaw macOS app proof requires Swift tools 6.2+"); + expect(remoteCommand).not.toContain("openclaw_crabbox_bootstrap_macos_js"); + expectGroupedShellCommand( + remoteCommand, + "swift build --package-path apps/macos --product OpenClaw", + ); + }); + + it("preflights Swift and JS tooling for raw AWS macOS package scripts", () => { + const result = runWrapper( + "provider: hetzner, aws, local-container, blacksmith-testbox, or cloudflare\n", + ["run", "--provider", "aws", "--target", "macos", "--", "pnpm", "mac:package"], + ); + + const output = parseFakeCrabboxOutput(result); + const remoteCommand = normalizeShellLineEndings(output.args.at(-1) ?? ""); + expect(result.status).toBe(0); + expect(output.args).toContain("--shell"); + expect(remoteCommand).toContain("openclaw_crabbox_bootstrap_macos_js"); + expect(remoteCommand).toContain("pnpm --version >&2"); + expect(remoteCommand).toContain("openclaw_crabbox_require_macos_swift_62"); + expect(remoteCommand).toContain("OpenClaw macOS app proof requires Swift tools 6.2+"); + expectGroupedShellCommand(remoteCommand, "pnpm mac:package"); + }); + + it("preserves sanitized env pnpm package commands when Swift preflight is needed", () => { + const result = runWrapper( + "provider: hetzner, aws, local-container, blacksmith-testbox, or cloudflare\n", + ["run", "--provider", "aws", "--target", "macos", "--", "env", "-i", "pnpm", "mac:package"], + ); + + const output = parseFakeCrabboxOutput(result); + const remoteCommand = normalizeShellLineEndings(output.args.at(-1) ?? ""); + expect(result.status).toBe(0); + expect(output.args).toContain("--shell"); + expect(remoteCommand).toContain("openclaw_crabbox_bootstrap_macos_js"); + expect(remoteCommand).toContain("openclaw_crabbox_require_macos_swift_62"); + expectGroupedShellCommand(remoteCommand, "openclaw_crabbox_env -i pnpm mac:package"); + }); + + it("preflights Swift for raw AWS macOS shell-launched package scripts", () => { + const result = runWrapper( + "provider: hetzner, aws, local-container, blacksmith-testbox, or cloudflare\n", + ["run", "--provider", "aws", "--target", "macos", "--", "bash", "scripts/package-mac-app.sh"], + ); + + const output = parseFakeCrabboxOutput(result); + const remoteCommand = normalizeShellLineEndings(output.args.at(-1) ?? ""); + expect(result.status).toBe(0); + expect(output.args).toContain("--shell"); + expect(remoteCommand).toContain("openclaw_crabbox_require_macos_swift_62"); + expect(remoteCommand).not.toContain("openclaw_crabbox_bootstrap_macos_js"); + expectGroupedShellCommand(remoteCommand, "bash scripts/package-mac-app.sh"); + }); + + it("does not preflight Swift for raw AWS macOS commands that only mention package scripts", () => { + const result = runWrapper( + "provider: hetzner, aws, local-container, blacksmith-testbox, or cloudflare\n", + ["run", "--provider", "aws", "--target", "macos", "--", "echo", "scripts/package-mac-app.sh"], + ); + + const output = parseFakeCrabboxOutput(result); + expect(result.status).toBe(0); + expect(output.args).not.toContain("--shell"); + expect(output.args).toEqual([ + "run", + "--provider", + "aws", + "--target", + "macos", + "--market", + "on-demand", + "--", + "echo", + "scripts/package-mac-app.sh", + ]); + }); + it("normalizes inherited Linux UTF-8 locale names for raw AWS macOS bootstrap", () => { const result = runWrapper( "provider: hetzner, aws, local-container, blacksmith-testbox, or cloudflare\n", @@ -1545,6 +1650,43 @@ describe.concurrent("scripts/crabbox-wrapper", () => { expect(output.scriptContent).toContain(`\n${script}\n`); }); + it("preflights Swift for AWS macOS script-stdin Swift builds", () => { + const script = [ + "set -euo pipefail", + "swift build --package-path apps/macos --product OpenClaw", + ].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("openclaw_crabbox_bootstrap_macos_js"); + expect(output.scriptContent).toContain("openclaw_crabbox_require_macos_swift_62"); + expect(output.scriptContent).toContain("openclaw_crabbox_require_macos_swift_62 || exit $?"); + expect(output.scriptContent).toContain("OpenClaw macOS app proof requires Swift tools 6.2+"); + expect(output.scriptContent).toContain(`\n${script}`); + }); + + it("preflights Swift and JS for AWS macOS script-stdin package scripts", () => { + const script = ["#!/usr/bin/env bash", "set -euo pipefail", "pnpm mac:package"].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("openclaw_crabbox_bootstrap_macos_js"); + expect(output.scriptContent).toContain("pnpm --version >&2"); + expect(output.scriptContent).toContain("openclaw_crabbox_require_macos_swift_62"); + expect(output.scriptContent).toContain("openclaw_crabbox_require_macos_swift_62 || exit $?"); + expect(output.scriptContent).toContain(`\n${script}\n`); + }); + it("bootstraps Corepack for AWS macOS script-stdin env shebangs with option values", () => { const script = ["#!/usr/bin/env -C /tmp -u OPENCLAW_FAKE_VAR pnpm", "--version"].join("\n"); const result = runWrapper(