From dba99b355b4e75cc97fddaf78bbf9c5a3058ca34 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 8 Aug 2026 23:42:36 -0700 Subject: [PATCH] fix(mac): fail fast without full Xcode (#120909) --- scripts/lib/swift-toolchain.sh | 8 ++ test/scripts/package-mac-app.test.ts | 169 ++++++++++++++++++++------ test/scripts/package-mac-dist.test.ts | 11 ++ 3 files changed, 148 insertions(+), 40 deletions(-) diff --git a/scripts/lib/swift-toolchain.sh b/scripts/lib/swift-toolchain.sh index edff1fc5e957..30ac5462b068 100644 --- a/scripts/lib/swift-toolchain.sh +++ b/scripts/lib/swift-toolchain.sh @@ -4,6 +4,14 @@ REQUIRED_SWIFT_TOOLS_MAJOR=6 REQUIRED_SWIFT_TOOLS_MINOR=2 require_swift_toolchain() { + if ! xcrun xcodebuild -version >/dev/null 2>&1; then + echo "ERROR: OpenClaw macOS app packaging requires a full Xcode developer directory." >&2 + echo " Command Line Tools do not include the required SwiftUI macro plugins." >&2 + echo " Use: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" >&2 + echo " Or set: DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer" >&2 + return 1 + fi + local swift_version if ! swift_version="$(swift --version 2>&1)"; then printf '%s\n' "$swift_version" >&2 diff --git a/test/scripts/package-mac-app.test.ts b/test/scripts/package-mac-app.test.ts index 770f36e6230d..222876b2690d 100644 --- a/test/scripts/package-mac-app.test.ts +++ b/test/scripts/package-mac-app.test.ts @@ -58,6 +58,81 @@ function getSwiftToolchainBlock(): string { return script.slice(start, end); } +function runSwiftToolchainHarness(options: { + swiftVersion: string; + selectedDeveloperDir: "command-line-tools" | "custom-xcode" | "invalid" | "xcode"; + developerDirOverride?: "custom-xcode" | "invalid" | "xcode"; +}) { + const root = tempDirs.make("openclaw-package-swift-root-"); + const toolsDir = path.join(root, "tools"); + const commandLineToolsDir = path.join(root, "Library", "Developer", "CommandLineTools"); + const xcodeDeveloperDir = path.join(root, "Applications", "Xcode.app", "Contents", "Developer"); + const customXcodeDeveloperDir = path.join(root, "MountedToolchains", "CustomDeveloper"); + const invalidDeveloperDir = path.join(root, "InvalidDeveloper"); + const developerDirs = { + "command-line-tools": commandLineToolsDir, + "custom-xcode": customXcodeDeveloperDir, + invalid: invalidDeveloperDir, + xcode: xcodeDeveloperDir, + } as const; + const selectedDeveloperDir = developerDirs[options.selectedDeveloperDir]; + + mkdirSync(toolsDir, { recursive: true }); + mkdirSync(commandLineToolsDir, { recursive: true }); + for (const developerDir of [xcodeDeveloperDir, customXcodeDeveloperDir]) { + const xcodebuild = path.join(developerDir, "usr", "bin", "xcodebuild"); + mkdirSync(path.dirname(xcodebuild), { recursive: true }); + writeFileSync( + xcodebuild, + ["#!/usr/bin/env bash", '[[ "$*" == "-version" ]] || exit 2', "echo 'Xcode 26.0'", ""].join( + "\n", + ), + "utf8", + ); + chmodSync(xcodebuild, 0o755); + } + writeFileSync( + path.join(toolsDir, "xcrun"), + [ + "#!/usr/bin/env bash", + '[[ "${1:-}" == "xcodebuild" && "${2:-}" == "-version" ]] || exit 2', + 'developer_dir="${DEVELOPER_DIR:-$MOCK_SELECTED_DEVELOPER_DIR}"', + 'xcodebuild="$developer_dir/usr/bin/xcodebuild"', + 'if [[ ! -x "$xcodebuild" ]]; then', + ' echo "xcrun: error: unable to find utility xcodebuild" >&2', + " exit 1", + "fi", + 'exec "$xcodebuild" "${@:2}"', + "", + ].join("\n"), + "utf8", + ); + writeFileSync( + path.join(toolsDir, "swift"), + [ + "#!/usr/bin/env bash", + `echo 'swift-driver version: 1.120.0 Apple Swift version ${options.swiftVersion} (swiftlang-${options.swiftVersion} clang-1700.0.13.5)'`, + "", + ].join("\n"), + "utf8", + ); + for (const tool of ["xcrun", "swift"]) { + chmodSync(path.join(toolsDir, tool), 0o755); + } + + const developerDirOverride = options.developerDirOverride + ? `export DEVELOPER_DIR=${JSON.stringify(developerDirs[options.developerDirOverride])}` + : "unset DEVELOPER_DIR"; + return runHelper(` + set -euo pipefail + PATH=${JSON.stringify(`${toolsDir}:/usr/bin:/bin`)} + export MOCK_SELECTED_DEVELOPER_DIR=${JSON.stringify(selectedDeveloperDir)} + ${developerDirOverride} + ${getSwiftToolchainBlock()} + require_swift_toolchain + `); +} + function getSparkleBuildHelperBlock(): string { const script = readFileSync(scriptPath, "utf8"); const start = script.indexOf("sparkle_canonical_build_from_version()"); @@ -734,60 +809,74 @@ describe("package-mac-app plist stamping", () => { }); it("fails with an actionable error when Swift tools are too old", () => { - const helperBlock = getSwiftToolchainBlock(); - const toolsDir = tempDirs.make("openclaw-package-swift-tools-"); - - const swiftPath = path.join(toolsDir, "swift"); - writeFileSync( - swiftPath, - [ - "#!/usr/bin/env bash", - "echo 'swift-driver version: 1.115.1 Apple Swift version 6.0.3 (swiftlang-6.0.3.1.10 clang-1600.0.30.1)'", - "", - ].join("\n"), - "utf8", - ); - chmodSync(swiftPath, 0o755); - - const result = runHelper(` - set -euo pipefail - PATH=${JSON.stringify(`${toolsDir}:/usr/bin:/bin`)} - ${helperBlock} - require_swift_toolchain - `); + const result = runSwiftToolchainHarness({ + swiftVersion: "6.0.3", + selectedDeveloperDir: "xcode", + }); expect(result.status).toBe(1); expect(result.stderr).toContain("OpenClaw macOS app packaging requires Swift tools 6.2+"); expect(result.stderr).toContain("Current Swift is 6.0"); }); - it("accepts Swift tools 6.2 or newer", () => { - const helperBlock = getSwiftToolchainBlock(); - const toolsDir = tempDirs.make("openclaw-package-swift-tools-"); + it("rejects Command Line Tools even when they provide Swift 6.2", () => { + const result = runSwiftToolchainHarness({ + swiftVersion: "6.2.1", + selectedDeveloperDir: "command-line-tools", + }); - const swiftPath = path.join(toolsDir, "swift"); - writeFileSync( - swiftPath, - [ - "#!/usr/bin/env bash", - "echo 'swift-driver version: 1.120.0 Apple Swift version 6.2.1 (swiftlang-6.2.1 clang-1700.0.13.5)'", - "", - ].join("\n"), - "utf8", + expect(result.status).toBe(1); + expect(result.stderr).toContain("requires a full Xcode developer directory"); + expect(result.stderr).toContain( + "Command Line Tools do not include the required SwiftUI macro plugins", ); - chmodSync(swiftPath, 0o755); + expect(result.stderr).toContain( + "sudo xcode-select -s /Applications/Xcode.app/Contents/Developer", + ); + expect(result.stderr).toContain("DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer"); + }); - const result = runHelper(` - set -euo pipefail - PATH=${JSON.stringify(`${toolsDir}:/usr/bin:/bin`)} - ${helperBlock} - require_swift_toolchain - `); + it("accepts Swift 6.2 from a selected full Xcode developer directory", () => { + const result = runSwiftToolchainHarness({ + swiftVersion: "6.2.1", + selectedDeveloperDir: "xcode", + }); expect(result.status).toBe(0); expect(result.stderr).toBe(""); }); + it("honors DEVELOPER_DIR when the global selection is Command Line Tools", () => { + const result = runSwiftToolchainHarness({ + swiftVersion: "6.2.1", + selectedDeveloperDir: "command-line-tools", + developerDirOverride: "xcode", + }); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + }); + + it("accepts usable full Xcode tooling from a custom developer directory", () => { + const result = runSwiftToolchainHarness({ + swiftVersion: "6.2.1", + selectedDeveloperDir: "custom-xcode", + }); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + }); + + it("rejects an unusable selected developer directory", () => { + const result = runSwiftToolchainHarness({ + swiftVersion: "6.2.1", + selectedDeveloperDir: "invalid", + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("requires a full Xcode developer directory"); + }); + it("runs Sparkle build metadata derivation from the repository root", () => { const helperBlock = getSparkleBuildHelperBlock(); const tempRoot = tempDirs.make("openclaw-package-sparkle-root-"); diff --git a/test/scripts/package-mac-dist.test.ts b/test/scripts/package-mac-dist.test.ts index e656d5324018..9b25211bd553 100644 --- a/test/scripts/package-mac-dist.test.ts +++ b/test/scripts/package-mac-dist.test.ts @@ -154,6 +154,17 @@ describe("package-mac-dist plist validation", () => { "utf8", ); chmodSync(path.join(toolsDir, "swift"), 0o755); + writeFileSync( + path.join(toolsDir, "xcrun"), + [ + "#!/usr/bin/env bash", + '[[ "${1:-}" == "xcodebuild" && "${2:-}" == "-version" ]] || exit 2', + "echo 'Xcode 26.0'", + "", + ].join("\n"), + "utf8", + ); + chmodSync(path.join(toolsDir, "xcrun"), 0o755); writeFileSync( path.join(toolsDir, "node"), [