From c351acd6ec5e3b2d857a2b18842f872ec5e41f26 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Tue, 21 Jul 2026 18:48:52 +0100 Subject: [PATCH 1/7] fix(build): Support hashed xcodemake logs Update the pinned xcodemake revision so absolute DerivedData paths produce safe capture log filenames. Match the new logs by their argument hash before deciding whether to run make directly. Fixes #466 --- CHANGELOG.md | 1 + src/utils/__tests__/xcodemake.test.ts | 27 ++++++++++++++++++++++++--- src/utils/xcodemake.ts | 19 ++++++++++--------- 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dae332aa7..093b0f007 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - Fixed `suppressWarnings` being ignored in settled build, build-run, and test output. The flag was honored only while streaming, so warnings still reached the final MCP tool response ([#447](https://github.com/getsentry/XcodeBuildMCP/issues/447)). - Fixed iOS scaffold orientation and device-family settings, LLDB command isolation and argument escaping, run-destination parsing without an active scheme, concurrent working-directory mutations, blocking physical-device name lookup, and unverified `xcodemake` downloads ([#459](https://github.com/getsentry/XcodeBuildMCP/issues/459)). - Fixed simulator UI launching and keyboard controls to prefer Xcode 27's Device Hub when available, with Simulator.app as the legacy fallback. +- Fixed incremental `xcodemake` builds when DerivedData uses an absolute path by updating the pinned script and recognizing its sanitized, hashed capture logs ([#466](https://github.com/getsentry/XcodeBuildMCP/issues/466)). ## [2.6.2] diff --git a/src/utils/__tests__/xcodemake.test.ts b/src/utils/__tests__/xcodemake.test.ts index 796c40640..e967fd88e 100644 --- a/src/utils/__tests__/xcodemake.test.ts +++ b/src/utils/__tests__/xcodemake.test.ts @@ -58,13 +58,24 @@ describe('executeXcodemakeCommand', () => { }); describe('doesMakeLogFileExist', () => { - it('reads the project directory without mutating process cwd', () => { + it('finds the hashed log for normalized absolute build paths without mutating cwd', () => { const originalCwd = process.cwd(); - const readDirectory = vi.fn(() => ['xcodemake -scheme App.log']); + const readDirectory = vi.fn(() => [ + 'xcodemake_-workspace_App.xcworkspace_-scheme_App_-derivedDataPath_DerivedData_App_build-50984a29036188e0dab05cf48b9b6573.log', + ]); const exists = doesMakeLogFileExist( '/tmp/project', - ['xcodebuild', '-scheme', 'App'], + [ + 'xcodebuild', + '-workspace', + '/tmp/project/App.xcworkspace', + '-scheme', + 'App', + '-derivedDataPath', + '/tmp/project/DerivedData/App', + 'build', + ], readDirectory, ); @@ -72,6 +83,16 @@ describe('doesMakeLogFileExist', () => { expect(readDirectory).toHaveBeenCalledWith('/tmp/project'); expect(process.cwd()).toBe(originalCwd); }); + + it('does not reuse a hashed log from different build arguments', () => { + const readDirectory = vi.fn(() => [ + 'xcodemake_-scheme_Other-68f67685244564619c1ad4d3c9ef3bad.log', + ]); + + expect( + doesMakeLogFileExist('/tmp/project', ['xcodebuild', '-scheme', 'App'], readDirectory), + ).toBe(false); + }); }); describe('xcodemake installer integrity', () => { diff --git a/src/utils/xcodemake.ts b/src/utils/xcodemake.ts index 7df6d481e..bd3e00c1f 100644 --- a/src/utils/xcodemake.ts +++ b/src/utils/xcodemake.ts @@ -10,8 +10,8 @@ import { getConfig } from './config-store.ts'; let overriddenXcodemakePath: string | null = null; -export const XCODEMAKE_COMMIT = '1749682a99794edc257010b3eaa35c39f0f91c50'; -export const XCODEMAKE_SHA256 = 'b84f2e58326a1c009e5349e28895817d2968f2cb655b6a2a7c7c719971007db7'; +export const XCODEMAKE_COMMIT = '7d20fab4ba128f7f533dd27d709daf1bff3c30e2'; +export const XCODEMAKE_SHA256 = '8f2b2c071628959a6564882dd14897489054716eac18766cbcb20164f9d81da8'; export const XCODEMAKE_DOWNLOAD_URL = `https://raw.githubusercontent.com/cameroncooke/xcodemake/${XCODEMAKE_COMMIT}/xcodemake`; interface XcodemakeInstallerDependencies { @@ -180,8 +180,7 @@ export function doesMakeLogFileExist( readDirectory: ReadDirectory = (directory) => readdirSync(directory), ): boolean { try { - const xcodemakeCommand = ['xcodemake', ...command.slice(1)]; - const escapedCommand = xcodemakeCommand.map((arg) => { + const xcodemakeArgs = command.slice(1).map((arg) => { // Remove projectDir from arguments if present at the start const prefix = projectDir + '/'; if (arg.startsWith(prefix)) { @@ -189,13 +188,15 @@ export function doesMakeLogFileExist( } return arg; }); - const commandString = escapedCommand.join(' '); - const logFileName = `${commandString}.log`; - log('debug', `Checking for Makefile log: ${logFileName} in directory: ${projectDir}`); + const argsHash = createHash('md5').update(xcodemakeArgs.join('\0')).digest('hex'); + const logFileSuffix = `-${argsHash}.log`; + log('debug', `Checking for xcodemake log ending in ${logFileSuffix} in ${projectDir}`); const files = readDirectory(projectDir); - const exists = files.includes(logFileName); - log('debug', `Makefile log ${exists ? 'exists' : 'does not exist'}: ${logFileName}`); + const exists = files.some( + (fileName) => fileName.startsWith('xcodemake') && fileName.endsWith(logFileSuffix), + ); + log('debug', `xcodemake log ${exists ? 'exists' : 'does not exist'}: ${logFileSuffix}`); return exists; } catch (error) { log( From db967a5a07717391f189e2a64333ad1af34a88bf Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Tue, 21 Jul 2026 19:11:48 +0100 Subject: [PATCH 2/7] fix(build): Delegate incremental builds to xcodemake Always invoke the pinned wrapper so it owns Makefile argument and freshness validation before delegating to make. Refs #466 --- CHANGELOG.md | 2 +- .../__tests__/build-utils-xcodemake.test.ts | 81 +++++++++++++++++++ src/utils/__tests__/xcodemake.test.ts | 39 --------- src/utils/build-utils.ts | 32 ++------ src/utils/xcodemake.ts | 45 +---------- 5 files changed, 90 insertions(+), 109 deletions(-) create mode 100644 src/utils/__tests__/build-utils-xcodemake.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 093b0f007..ce689a600 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ - Fixed `suppressWarnings` being ignored in settled build, build-run, and test output. The flag was honored only while streaming, so warnings still reached the final MCP tool response ([#447](https://github.com/getsentry/XcodeBuildMCP/issues/447)). - Fixed iOS scaffold orientation and device-family settings, LLDB command isolation and argument escaping, run-destination parsing without an active scheme, concurrent working-directory mutations, blocking physical-device name lookup, and unverified `xcodemake` downloads ([#459](https://github.com/getsentry/XcodeBuildMCP/issues/459)). - Fixed simulator UI launching and keyboard controls to prefer Xcode 27's Device Hub when available, with Simulator.app as the legacy fallback. -- Fixed incremental `xcodemake` builds when DerivedData uses an absolute path by updating the pinned script and recognizing its sanitized, hashed capture logs ([#466](https://github.com/getsentry/XcodeBuildMCP/issues/466)). +- Fixed incremental `xcodemake` builds when DerivedData uses an absolute path by updating the pinned wrapper and delegating Makefile reuse to it so its argument and freshness checks are always applied ([#466](https://github.com/getsentry/XcodeBuildMCP/issues/466)). ## [2.6.2] diff --git a/src/utils/__tests__/build-utils-xcodemake.test.ts b/src/utils/__tests__/build-utils-xcodemake.test.ts new file mode 100644 index 000000000..a3b6eaa80 --- /dev/null +++ b/src/utils/__tests__/build-utils-xcodemake.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { createMockExecutor } from '../../test-utils/mock-executors.ts'; + +const { executeXcodemakeCommandMock } = vi.hoisted(() => ({ + executeXcodemakeCommandMock: vi.fn(), +})); + +vi.mock('../xcodemake.ts', () => ({ + isXcodemakeEnabled: () => true, + isXcodemakeAvailable: () => Promise.resolve(true), + executeXcodemakeCommand: executeXcodemakeCommandMock, +})); + +import { executeXcodeBuildCommand } from '../build-utils.ts'; +import { XcodePlatform } from '../xcode.ts'; + +describe('build-utils xcodemake lifecycle', () => { + let projectDirectory: string; + + beforeEach(() => { + projectDirectory = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-xcodemake-')); + writeFileSync(path.join(projectDirectory, 'Makefile'), 'all:\n\t@true\n'); + executeXcodemakeCommandMock.mockResolvedValue({ success: true, output: 'BUILD SUCCEEDED' }); + }); + + afterEach(() => { + rmSync(projectDirectory, { recursive: true, force: true }); + vi.clearAllMocks(); + }); + + it('delegates existing Makefile validation to xcodemake for external DerivedData', async () => { + const workspacePath = path.join(projectDirectory, 'MyWorkspace.xcworkspace'); + const derivedDataPath = + '/Users/developer/Library/Developer/XcodeBuildMCP/DerivedData/MyWorkspace-57a542dedf16'; + const executorCall = vi.fn(); + const executor = createMockExecutor({ onExecute: executorCall }); + + const result = await executeXcodeBuildCommand( + { + scheme: 'MyScheme', + configuration: 'Debug', + workspacePath, + derivedDataPath, + }, + { + platform: XcodePlatform.iOSSimulator, + simulatorId: 'SIMULATOR-UDID', + logPrefix: 'iOS Simulator Build', + }, + false, + 'build', + executor, + ); + + expect(result.isError).toBeFalsy(); + expect(executorCall).not.toHaveBeenCalled(); + expect(executeXcodemakeCommandMock).toHaveBeenCalledWith( + projectDirectory, + [ + '-workspace', + workspacePath, + '-scheme', + 'MyScheme', + '-configuration', + 'Debug', + '-skipMacroValidation', + '-destination', + 'platform=iOS Simulator,id=SIMULATOR-UDID', + '-collect-test-diagnostics', + 'never', + '-derivedDataPath', + derivedDataPath, + 'build', + ], + 'iOS Simulator Build', + ); + }); +}); diff --git a/src/utils/__tests__/xcodemake.test.ts b/src/utils/__tests__/xcodemake.test.ts index e967fd88e..2142a7dde 100644 --- a/src/utils/__tests__/xcodemake.test.ts +++ b/src/utils/__tests__/xcodemake.test.ts @@ -13,7 +13,6 @@ import { XCODEMAKE_COMMIT, XCODEMAKE_DOWNLOAD_URL, XCODEMAKE_SHA256, - doesMakeLogFileExist, executeXcodemakeCommand, installXcodemake, verifyXcodemakeScript, @@ -57,44 +56,6 @@ describe('executeXcodemakeCommand', () => { }); }); -describe('doesMakeLogFileExist', () => { - it('finds the hashed log for normalized absolute build paths without mutating cwd', () => { - const originalCwd = process.cwd(); - const readDirectory = vi.fn(() => [ - 'xcodemake_-workspace_App.xcworkspace_-scheme_App_-derivedDataPath_DerivedData_App_build-50984a29036188e0dab05cf48b9b6573.log', - ]); - - const exists = doesMakeLogFileExist( - '/tmp/project', - [ - 'xcodebuild', - '-workspace', - '/tmp/project/App.xcworkspace', - '-scheme', - 'App', - '-derivedDataPath', - '/tmp/project/DerivedData/App', - 'build', - ], - readDirectory, - ); - - expect(exists).toBe(true); - expect(readDirectory).toHaveBeenCalledWith('/tmp/project'); - expect(process.cwd()).toBe(originalCwd); - }); - - it('does not reuse a hashed log from different build arguments', () => { - const readDirectory = vi.fn(() => [ - 'xcodemake_-scheme_Other-68f67685244564619c1ad4d3c9ef3bad.log', - ]); - - expect( - doesMakeLogFileExist('/tmp/project', ['xcodebuild', '-scheme', 'App'], readDirectory), - ).toBe(false); - }); -}); - describe('xcodemake installer integrity', () => { it('pins the download URL to an exact commit and checksum', () => { expect(XCODEMAKE_COMMIT).toMatch(/^[a-f0-9]{40}$/); diff --git a/src/utils/build-utils.ts b/src/utils/build-utils.ts index 42faba86e..5231b9c7e 100644 --- a/src/utils/build-utils.ts +++ b/src/utils/build-utils.ts @@ -2,14 +2,7 @@ import { log } from './logger.ts'; import { XcodePlatform, constructDestinationString } from './xcode.ts'; import type { CommandExecutor, CommandExecOptions } from './command.ts'; import type { SharedBuildParams, PlatformBuildOptions } from '../types/common.ts'; -import { - isXcodemakeEnabled, - isXcodemakeAvailable, - executeXcodemakeCommand, - executeMakeCommand, - doesMakefileExist, - doesMakeLogFileExist, -} from './xcodemake.ts'; +import { isXcodemakeEnabled, isXcodemakeAvailable, executeXcodemakeCommand } from './xcodemake.ts'; import path from 'path'; import os from 'node:os'; import { resolveEffectiveDerivedDataPath } from './derived-data-path.ts'; @@ -184,23 +177,12 @@ export async function executeXcodeBuildCommand( let result; if (useXcodemake) { - const makefileExists = doesMakefileExist(projectDir); - log('debug', 'Makefile exists: ' + makefileExists); - - const makeLogFileExists = doesMakeLogFileExist(projectDir, command); - log('debug', 'Makefile log exists: ' + makeLogFileExists); - - if (makefileExists && makeLogFileExists) { - addBuildMessage('ℹ️ Using make for incremental build'); - result = await executeMakeCommand(projectDir, platformOptions.logPrefix); - } else { - addBuildMessage('ℹ️ Generating Makefile with xcodemake (first build may take longer)'); - result = await executeXcodemakeCommand( - projectDir, - command.slice(1), - platformOptions.logPrefix, - ); - } + addBuildMessage('ℹ️ Running incremental build with xcodemake'); + result = await executeXcodemakeCommand( + projectDir, + command.slice(1), + platformOptions.logPrefix, + ); } else { const streamHandlers = pipeline ? { diff --git a/src/utils/xcodemake.ts b/src/utils/xcodemake.ts index bd3e00c1f..61c557b5d 100644 --- a/src/utils/xcodemake.ts +++ b/src/utils/xcodemake.ts @@ -1,7 +1,7 @@ import { log } from './logger.ts'; import type { CommandResponse } from './command.ts'; import { getDefaultCommandExecutor } from './command.ts'; -import { existsSync, readdirSync, statSync } from 'fs'; +import { existsSync, statSync } from 'fs'; import { createHash, randomUUID } from 'node:crypto'; import * as path from 'path'; import * as os from 'os'; @@ -172,41 +172,6 @@ export function doesMakefileExist(projectDir: string): boolean { return existsSync(`${projectDir}/Makefile`); } -type ReadDirectory = (path: string) => string[]; - -export function doesMakeLogFileExist( - projectDir: string, - command: string[], - readDirectory: ReadDirectory = (directory) => readdirSync(directory), -): boolean { - try { - const xcodemakeArgs = command.slice(1).map((arg) => { - // Remove projectDir from arguments if present at the start - const prefix = projectDir + '/'; - if (arg.startsWith(prefix)) { - return arg.substring(prefix.length); - } - return arg; - }); - const argsHash = createHash('md5').update(xcodemakeArgs.join('\0')).digest('hex'); - const logFileSuffix = `-${argsHash}.log`; - log('debug', `Checking for xcodemake log ending in ${logFileSuffix} in ${projectDir}`); - - const files = readDirectory(projectDir); - const exists = files.some( - (fileName) => fileName.startsWith('xcodemake') && fileName.endsWith(logFileSuffix), - ); - log('debug', `xcodemake log ${exists ? 'exists' : 'does not exist'}: ${logFileSuffix}`); - return exists; - } catch (error) { - log( - 'error', - `Error checking for Makefile log: ${error instanceof Error ? error.message : String(error)}`, - ); - return false; - } -} - export async function executeXcodemakeCommand( projectDir: string, buildArgs: string[], @@ -223,11 +188,3 @@ export async function executeXcodemakeCommand( return getDefaultCommandExecutor()(command, logPrefix, false, { cwd: projectDir }); } - -export async function executeMakeCommand( - projectDir: string, - logPrefix: string, -): Promise { - const command = ['make']; - return getDefaultCommandExecutor()(command, logPrefix, false, { cwd: projectDir }); -} From b7eb789fdf3e3e9d8ed4def9043f18d281f668b2 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Tue, 21 Jul 2026 19:22:15 +0100 Subject: [PATCH 3/7] test(build): Cover pinned xcodemake lifecycle Execute the checksum-verified pinned wrapper with fake build tools and verify capture, reuse, argument invalidation, and project freshness behavior. Refs #466 --- .../__tests__/fixtures/xcodemake/README.md | 9 + ...e-7d20fab4ba128f7f533dd27d709daf1bff3c30e2 | 592 ++++++++++++++++++ src/utils/__tests__/xcodemake-wrapper.test.ts | 164 +++++ 3 files changed, 765 insertions(+) create mode 100644 src/utils/__tests__/fixtures/xcodemake/README.md create mode 100644 src/utils/__tests__/fixtures/xcodemake/xcodemake-7d20fab4ba128f7f533dd27d709daf1bff3c30e2 create mode 100644 src/utils/__tests__/xcodemake-wrapper.test.ts diff --git a/src/utils/__tests__/fixtures/xcodemake/README.md b/src/utils/__tests__/fixtures/xcodemake/README.md new file mode 100644 index 000000000..c2c5af278 --- /dev/null +++ b/src/utils/__tests__/fixtures/xcodemake/README.md @@ -0,0 +1,9 @@ +# Pinned xcodemake test data + +`xcodemake-7d20fab4ba128f7f533dd27d709daf1bff3c30e2` is an exact, unmodified copy of +[`cameroncooke/xcodemake` at commit `7d20fab4ba128f7f533dd27d709daf1bff3c30e2`](https://github.com/cameroncooke/xcodemake/blob/7d20fab4ba128f7f533dd27d709daf1bff3c30e2/xcodemake). + +SHA-256: `8f2b2c071628959a6564882dd14897489054716eac18766cbcb20164f9d81da8` + +The wrapper regression test verifies this checksum against the production pin before execution. Do +not edit the fixture independently of `XCODEMAKE_COMMIT` and `XCODEMAKE_SHA256`. diff --git a/src/utils/__tests__/fixtures/xcodemake/xcodemake-7d20fab4ba128f7f533dd27d709daf1bff3c30e2 b/src/utils/__tests__/fixtures/xcodemake/xcodemake-7d20fab4ba128f7f533dd27d709daf1bff3c30e2 new file mode 100644 index 000000000..70fdf333d --- /dev/null +++ b/src/utils/__tests__/fixtures/xcodemake/xcodemake-7d20fab4ba128f7f533dd27d709daf1bff3c30e2 @@ -0,0 +1,592 @@ +#!/usr/bin/env perl -w +# +# "xcodemake" +# +# Derived from: https://github.com/johnno1962/xcodemake +# +# A short script to convert xcodebuild output into a Makefile. +# Once a Makefile has been generated you can use the much +# faster "make" command for most builds instead of launching +# the more ponderous xcodebuild for each iteration. Sure, +# this could be rewritten in python and use ninja instead. +# +# xcodebuild re-run if its arguments change or make fails or project modified. +# Makefile re-generated if script modified or xcodebuild recaptured. +# + +use IO::File; +use strict; +use JSON::PP; +use Digest::MD5 qw(md5_hex); + +my @original_ARGV = @ARGV; + +# --- Global Utility Functions --- +sub escape { + # Escape characters in $_[1] listed in $_[0] with a backslash + $_[1] =~ s/([$_[0]])/\\$1/g; +} +sub unescape { + # Unescape characters in $_[1] listed in $_[0] preceded by a backslash + $_[1] =~ s/\\([$_[0]])/$1/g; +} + +# escape literal '$' for make --> '$$' +sub dollarEscape { + # $_[0] is the variable passed by reference implicitly + $_[0] =~ s/\$/\$\$/g; +} +# escape characters sensitive to the shell/make for file paths +sub shellEscape { + # $_[0] is the variable passed by reference implicitly + escape("()#&\$", $_[0]); # Escape shell metacharacters & Make '$' + # Escape spaces for shell commands separately + $_[0] =~ s/ /\\ /g; +} + +# --- Global Variables --- +my $log_tag = join "_", ("xcodemake", @original_ARGV); +$log_tag =~ s{[/:]+}{_}g; +$log_tag =~ s{[^[:alnum:]._+=,@ -]+}{_}g; +$log_tag =~ s{\s+}{_}g; +$log_tag =~ s{_+}{_}g; +$log_tag =~ s{^_+|_+$}{}g; +$log_tag ||= "xcodemake"; +my $max_log_name_length = 150; +my $log_suffix = "-" . md5_hex(join "\0", @original_ARGV) . ".log"; +$log_tag = substr($log_tag, 0, $max_log_name_length - length($log_suffix)); +$log_tag =~ s{_+$}{}; +my $log = ($log_tag || "xcodemake") . $log_suffix; +my $make = "Makefile"; + +my $xcodebuild = ($ENV{DEVELOPER_BIN_DIR}||'/usr/bin')."/xcodebuild"; +my $archs = $ENV{ARCHS}||'arm64'; +# Define $args based on original_ARGV, but check *original_ARGV* for -config +my $args_for_variant = join ' ', map { "'$_'" } @original_ARGV; +my $has_config_in_original = grep { /^-config$/ } @original_ARGV; +$args_for_variant .= " -config Debug" unless $has_config_in_original; + +my $variant = "$xcodebuild ARCHS=$archs @original_ARGV"; +$variant .= " -config Debug" unless $has_config_in_original; # Match how $args_for_variant is built + +my $builddb = ($ENV{OBJROOT}||'/tmp')."/XCBuildData/build.db"; + +my $EXIT_SUCCESS = 0; +my ($LOG, $fileArg); # $LOG will be lexical to generateMakefile + +# regex for file path argument (handles spaces escaped with backslash) +my $notSpace = "[^\\\\\\s]"; +$fileArg = "$notSpace+(?:\\\\.$notSpace*)*"; + +# --- Main Logic --- + +# Recapture xcodebuild output if paramaters change or project has been edited +captureXcodebuild() if ! -f $log || `find . -name project.pbxproj -newer '$log'`; + +# Regenerate Makefile (if script newer or different xcodebuild or arguments) +# Use the potentially modified $variant string for the grep check +generateMakefile() if ! -f $make || -M $0 < -M $make || !`grep -Fq '$variant' Makefile`; + +exit $EXIT_SUCCESS if $EXIT_SUCCESS == system "make"; + +rename $builddb, $builddb.".save" if -f $builddb; + +# If make failed, try running xcodebuild directly. If it succeeds, regenerate and try make again. +# Prepare command array for direct xcodebuild run (needs env) +my @direct_build_cmd_parts = ($xcodebuild); +# Use the original arguments captured at the start +my @direct_build_args = @original_ARGV; +push @direct_build_args, ('-config', 'Debug') unless $has_config_in_original; +my @direct_build_cmd = (@direct_build_cmd_parts, @direct_build_args); + +my $direct_build_status; +{ + local $ENV{ARCHS} = $archs; # Set environment for direct build too + $direct_build_status = system(@direct_build_cmd); +} + +if ($direct_build_status == $EXIT_SUCCESS) { + captureXcodebuild(); # Recapture if direct build worked + generateMakefile(); + exit $EXIT_SUCCESS if $EXIT_SUCCESS == system "make"; # Try make again +} + +rename $builddb.".save", $builddb if -f $builddb.".save"; +exit !$EXIT_SUCCESS; # Exit with failure if direct build or second make failed + +# --- Subroutines --- + +sub captureXcodebuild { + # Move Xcode's build database to one side + rename $builddb, $builddb.".save" if -f $builddb; + + # --- Prepare Arguments for List system() --- + # Simply use xcodebuild directly, inheriting the environment + my @base_cmd_parts = ($xcodebuild); + + # Use the *original* @ARGV captured at the script start + my @cmd_args = @original_ARGV; + # Add default -config Debug if necessary *to the list* + # Check original args for all possible configuration argument formats + my $has_config = grep { /^-config$/ || /^--config$/ || /^-configuration$/ || /^--configuration$/ } @original_ARGV; + push @cmd_args, ('-config', 'Debug') unless $has_config; + + + # Handle -resultBundlePath removal for the command array + my $rm_cmd; + # Use the potentially modified $variant string for matching here + if (my ($bundle) = $variant =~ /-resultBundlePath ($fileArg)/) { + my $rm_bundle = $bundle; + unescape("'\\\\'", $rm_bundle); + $rm_cmd = ['rm', '-rf', $rm_bundle]; + } + + # --- Execute Commands --- + # 1. Optional: Remove result bundle + if ($rm_cmd) { + warn "Executing: @$rm_cmd\n"; + system(@$rm_cmd) == 0 or warn "Warning: rm command failed: \$? = $?\n"; + } + + # --- Environment for xcodebuild --- + # We need ARCHS=$archs in the environment for the child process + my %child_env; + $child_env{ARCHS} = $archs; + + # 2. Execute Clean Command + my @clean_cmd = (@base_cmd_parts, @cmd_args, 'clean'); + warn "Executing clean: @clean_cmd\n"; + my $clean_status; + { + # Set environment locally for this block + local $ENV{ARCHS} = $child_env{ARCHS}; + $clean_status = system(@clean_cmd); + } + if ($clean_status != $EXIT_SUCCESS) { + # Exit status calculation needs $? which was set by system() + my $clean_raw_status = $?; + my $clean_exit_code = $clean_raw_status >> 8; + my $clean_signal = $clean_raw_status & 0x7F; + warn "Error: xcodebuild clean failed: exit code=$clean_exit_code, signal=$clean_signal, raw(\$?)=$clean_raw_status\n"; + rename $builddb.".save", $builddb if -f $builddb.".save"; + exit !$EXIT_SUCCESS; # Exit with a non-zero status + } + + # 3. Execute Build Command and Capture Output + my @build_cmd = (@base_cmd_parts, @cmd_args); + warn "Executing build and capturing output: @build_cmd\n"; + + my $build_pid; + my $build_fh; + { + # Set environment locally for the fork/exec done by open() + local $ENV{ARCHS} = $child_env{ARCHS}; + $build_pid = open($build_fh, "-|", @build_cmd); + } + unless (defined $build_pid) { # Check if open succeeded + warn "Error: Cannot fork for xcodebuild: $!"; + rename $builddb.".save", $builddb if -f $builddb.".save"; + exit !$EXIT_SUCCESS; # Exit with a non-zero status + } + + # Open log file for writing + my $log_fh; + unless (open $log_fh, ">", $log) { + warn "Error: Cannot open log file $log for writing: $!"; + # Need to ensure we don't leave the child process running + eval { close($build_fh); }; # Try to close the pipe + waitpid($build_pid, 0) if $build_pid > 0; # Wait if pid is valid + rename $builddb.".save", $builddb if -f $builddb.".save"; + exit !$EXIT_SUCCESS; # Exit with a non-zero status + } + + # Read from build output, write to log and stderr (simulate tee) + while (my $output_line = <$build_fh>) { + print $log_fh $output_line; + print STDERR $output_line; # Print to terminal like tee does + } + + close $log_fh; + # Important: Close the file handle *before* checking status + # Closing the pipe sends EOF to the reader and waits for the process + unless (close($build_fh)) { + # close returns false if the process exited non-zero or the pipe had errors + # $? is set by close() on the pipe! + my $build_status = $?; + my $build_exit_code = $build_status >> 8; + my $build_signal = $build_status & 0x7F; + warn "Error: xcodebuild build command failed (detected by close pipe): exit code=$build_exit_code, signal=$build_signal, raw(\$?)=$build_status\n"; + unlink $log if -f $log; # Remove potentially incomplete log + rename $builddb.".save", $builddb if -f $builddb.".save"; + exit !$EXIT_SUCCESS; # Exit with a non-zero status + } + + # If close($build_fh) succeeded, $? should reflect the child's exit status + # (0 for success, or potentially > 0 if xcodebuild reported success but exited non-zero) + my $build_status = $?; + my $build_exit_code = $build_status >> 8; + my $build_signal = $build_status & 0x7F; + + # Explicitly check for exit code 0 and no signal for success + if ($build_exit_code != $EXIT_SUCCESS || $build_signal != 0) { + warn "Error: xcodebuild build command finished but indicated failure: exit code=$build_exit_code, signal=$build_signal, raw(\$?)=$build_status\n"; + unlink $log if -f $log; # Remove potentially incomplete log + rename $builddb.".save", $builddb if -f $builddb.".save"; + exit !$EXIT_SUCCESS; # Exit with a non-zero status + } + + # Build succeeded! + unlink $make; # Force Makefile regeneration + + rename $builddb.".save", $builddb if -f $builddb.".save"; +} + + +sub generateMakefile { + # Use lexical filehandle for safety + my $log_fh; + unless (open $log_fh, "<", $log) { + die "Could not open $log: $!"; + } + my $make_fh; + unless (open $make_fh, ">", $make) { + die "Could not create $make: $!"; + } + + # Get next line from log + # Using closure to capture $log_fh + my $nextLine = sub { + my $line = <$log_fh>; + return undef unless defined $line; # Return undef on EOF + chomp $line; + return $line; + }; + + # Get next 'cd' line and prepare prefix for make recipe + # Using closure to capture $nextLine + my $nextCD = sub { + my $line = &$nextLine(); + # Check if it's actually a cd line before trying to process + return undef unless defined $line && $line =~ s/^\s*cd //; + # Handle potential '/usr/bin/time' prefix added by the script itself if re-parsing + $line =~ s{^\s*/usr/bin/time\s+}{}; + my $cd = $line; + unescape("^\$'()&", $cd); + escape(" ", $cd); + dollarEscape($cd); + return "\t\tcd $cd\n\t\t"; # Just return the cd line and tabs + }; + + # Extract values for the option (e.g., -o file, -primary-file file) from a command line + # Using closure to capture $fileArg + my $extractOption = sub { + my ($line, $option) = @_; + # Match " option path" where path is captured by $fileArg + my @values = $line =~ m/ $option\s+($fileArg)/g; + # Unescape paths extracted via regex + for (@values) { + unescape("'\\\\'", $_); + } + return @values; + }; + + # --- Makefile Generation --- + print $make_fh <new->utf8; # For decoding JSON output map + + while (defined(my $line = &$nextLine())) { + print $make_fh "# $line\n"; + + # --- CompileC Handling (Objective-C/C/C++) --- + if (my ($object, $source) = $line =~ + /^CompileC ($fileArg) ($fileArg) /) { + my $cd = &$nextCD(); + unless (defined $cd) { + warn "Warning: Expected 'cd' after CompileC line, but not found. Skipping rule for $object.\n"; + next; + } + my $compile_cmd_line; + do { + $compile_cmd_line = &$nextLine(); + last unless defined $compile_cmd_line; # Break if EOF + } while ($compile_cmd_line =~ /^\s*$|response file/); # Skip blank lines/response file info + + unless (defined $compile_cmd_line && $compile_cmd_line !~ /^\s*$/) { + warn "Warning: Expected compile command after 'cd' for $object, but not found. Skipping rule.\n"; + next; + } + + # Prepare paths for Make rule + my $make_target_object = $object; + my $make_dep_source = $source; + unescape("{}()", $make_target_object); + escape("\$& ", $make_target_object); + dollarEscape($make_dep_source); + unescape("'", $make_dep_source); + unescape(" ", $make_dep_source); + escape(" ", $make_dep_source); + + # Add rule to Makefile + print $make_fh "\n$make_target_object: $make_dep_source\n"; + + # Prepare command for recipe + dollarEscape($compile_cmd_line); + + # Touch command needs escaped object path suitable for shell + my $shell_touch_object = $object; + shellEscape($shell_touch_object); + + print $make_fh "$cd$compile_cmd_line && touch $shell_touch_object\n\n"; + $rules{$make_target_object} = 1; # Mark object as known (using Make-escaped form) + + # --- SwiftDriver Handling (Xcode 16.3+) --- + } elsif ($line =~ /^SwiftDriver .* com\.apple\.xcode\.tools\.swift\.compiler/) { + my $cd = &$nextCD(); + unless (defined $cd) { + warn "Warning: Expected 'cd' after SwiftDriver line, but not found. Skipping Swift rules.\n"; + next; + } + + # Get the line containing the actual command invocation + my $build_invocation_line = &$nextLine(); + unless (defined $build_invocation_line) { + warn "Warning: Expected command line after 'cd' for SwiftDriver, but found EOF.\nSkipping Swift rules.\n"; + next; + } + + # Extract the actual command (e.g., /path/to/swiftc ...) by removing the prefix + my $swift_actual_cmd = $build_invocation_line; + unless ($swift_actual_cmd =~ s/^\s*builtin-SwiftDriver\s+--\s+//) { + warn "Warning: Expected 'builtin-SwiftDriver -- ' prefix, but found: $build_invocation_line\nSkipping Swift rules.\n"; + next; + } + # Now $swift_actual_cmd contains "/Applications/.../swiftc ..." + + $swift_actual_cmd =~ s/\s+-use-frontend-parseable-output//; + + # Extract the output file map path *from the original build invocation line* + my ($output_map_file) = $build_invocation_line =~ /-output-file-map\s+($fileArg)/; + unless (defined $output_map_file) { + # Fallback: check the extracted swiftc command itself? Less likely needed. + ($output_map_file) = $swift_actual_cmd =~ /-output-file-map\s+($fileArg)/; + } + unless (defined $output_map_file) { + warn "Warning: Could not find -output-file-map in SwiftDriver line: $build_invocation_line\nSkipping Swift rules.\n"; + next; + } + unescape("'\\\\'", $output_map_file); + + # Read and parse the JSON Output File Map + my $map_content; + my $MAP_FH; + eval { + $MAP_FH = IO::File->new("< $output_map_file") or die "Cannot open output map $output_map_file: $!"; + local $/; # Slurp mode + $map_content = <$MAP_FH>; + close $MAP_FH; + }; + if ($@ || !defined $map_content) { + warn "Warning: Could not read output map file $output_map_file: $@\nSkipping Swift rules.\n"; + next; + } + + my $output_map; + eval { + $output_map = $json_decoder->decode($map_content); + }; + if ($@ || ref $output_map ne 'HASH') { + warn "Warning: Could not decode JSON from output map file $output_map_file: $@\nSkipping Swift rules.\n"; + next; + } + + # Prepare the actual command for the Makefile recipe + my $make_swift_cmd = $swift_actual_cmd; # Use the extracted command + dollarEscape($make_swift_cmd); + + # Generate rules for each source file in the map + foreach my $source (keys %{$output_map}) { + next unless $source =~ /\.swift$/; # Process only Swift source entries + next unless exists $output_map->{$source}{object}; # Ensure object mapping exists + + my $object = $output_map->{$source}{object}; + + # Prepare paths for Make rule + my $make_target_object = $object; + my $make_dep_source = $source; + unescape("{}()", $make_target_object); + escape("\$& ", $make_target_object); + dollarEscape($make_dep_source); + unescape("'", $make_dep_source); + unescape(" ", $make_dep_source); + escape(" ", $make_dep_source); + + # Use make-escaped version for the hash key check + next if $rules{$make_target_object}++; + + print $make_fh "\n$make_target_object: $make_dep_source\n"; + + # Touch command needs object path escaped for shell + my $shell_touch_object = $object; + shellEscape($shell_touch_object); + + # Use the extracted swiftc command for the recipe + print $make_fh "$cd$make_swift_cmd && touch $shell_touch_object\n\n"; + } + + # --- SwiftCompile Handling (Older Xcode / Fallback) --- + } elsif ($line =~ /^SwiftCompile \w+ \w+/) { + my $cd = &$nextCD(); + unless (defined $cd) { + # Might be an informational line without a command, skip silently + next; + } + my $compile_cmd_line = &$nextLine(); + # Check if it looks like a real frontend command + unless (defined $compile_cmd_line && $compile_cmd_line =~ m/swift-frontend.*-c /) { + # Likely an informational line or unexpected format + next; + } + + # Attempt to extract options like the original script + my @sources = &$extractOption($compile_cmd_line, "-primary-file"); + my @objects = &$extractOption($compile_cmd_line, "-o"); + + unless (@sources && @objects && $#sources == $#objects) { + warn "Warning: Could not extract matching -primary-file/-o pairs from SwiftCompile command. This might be an unsupported format or whole module optimization.\nCMD: $compile_cmd_line\n"; + next; # Skip if we can't get source/object pairs + } + + # Prepare command for recipe + dollarEscape($compile_cmd_line); + + foreach my $i (0..$#sources) { + # Prepare paths for Make rule + my $make_target_object = $objects[$i]; + my $make_dep_source = $sources[$i]; + unescape("{}()", $make_target_object); + escape("\$& ", $make_target_object); + dollarEscape($make_dep_source); + unescape("'", $make_dep_source); + unescape(" ", $make_dep_source); + escape(" ", $make_dep_source); + + # Use make-escaped version for hash key check + next if $rules{$make_target_object}++; + + print $make_fh "\n$make_target_object: $make_dep_source\n"; + + # Touch command needs object path escaped for shell + my $shell_touch_object = $objects[$i]; # Use original path here + shellEscape($shell_touch_object); + + print $make_fh "$cd$compile_cmd_line && touch $shell_touch_object\n\n"; + } + + # --- Ld Handling (Linking) --- + } elsif (my ($executable) = $line =~ /^Ld ($fileArg) /) { + my $cd = &$nextCD(); + unless (defined $cd) { + warn "Warning: Expected 'cd' after Ld line for $executable, but not found. Trying to continue.\n"; + $cd = "\t\t"; # Use empty cd prefix + } + + my $link_cmd_line = &$nextLine(); + unless (defined $link_cmd_line && $link_cmd_line !~ /^\s*$/) { + warn "Warning: Expected link command after 'cd' for $executable, but not found. Skipping rule.\n"; + next; + } + + # Use lookbehind to avoid matching -Xlinker immediately before -filelist + my ($linkfile) = $link_cmd_line =~ /(?new("< $linkfile") or die "Cannot open link file list $linkfile: $!"; + }; + unless (defined $OBJS_FH) { + warn "Warning: Could not open link file list $linkfile for $executable: $@. Linking without specific object dependencies.\n"; + } else { + # Add object file dependencies if linkfile was read + while (my $object = <$OBJS_FH>) { + chomp $object; + + # Create the canonical, Make-escaped version for hash lookup + my $canonical_object = $object; + unescape("{}()", $canonical_object); + escape("\$& ", $canonical_object); + + if ($rules{$canonical_object}) { # Check if we know how to build this object + # Escape object path for dependency list (spaces are separators) + my $dep_object = $object; + $dep_object =~ s/ /\\ /g; + escape("'", $dep_object); + print $make_fh " $dep_object"; + } else { + # Also check if it's a module object file that might have a rule + # Some objects might not be in %rules if they're from packages + if ($object =~ /\.o$/) { + # Escape object path for dependency list anyway + my $dep_object = $object; + $dep_object =~ s/ /\\ /g; + escape("'", $dep_object); + print $make_fh " $dep_object"; + } + # Optional: Warn about objects in link list not generated by known rules? + # warn "Object $object listed in $linkfile but no rule found to build it."; + } + } + close $OBJS_FH; + } + } + # Finish rule definition and add recipe + print $make_fh "\n$cd$make_link_cmd_line\n\n"; + + # Add executable/dylib to the list for the 'main' target + if ($executable !~ /\.o$/) { + # Use the Make-escaped version for the @linked array + push @linked, $make_target_executable; + } + + # --- codesign / touch Handling --- + } elsif ($line =~ m@^\s+/usr/bin/(codesign|touch)@) { + dollarEscape($line); + push @codesigns, $line; + } + } + + # --- Final 'main' Target --- + print $make_fh "main: @linked\n"; + print $make_fh join "\n", map {"\t$_"} @codesigns; # Use stored codesign/touch lines + print $make_fh "\n"; + + close $make_fh; + close $log_fh; +} diff --git a/src/utils/__tests__/xcodemake-wrapper.test.ts b/src/utils/__tests__/xcodemake-wrapper.test.ts new file mode 100644 index 000000000..a37cf98f8 --- /dev/null +++ b/src/utils/__tests__/xcodemake-wrapper.test.ts @@ -0,0 +1,164 @@ +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + utimesSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { XCODEMAKE_COMMIT, XCODEMAKE_SHA256 } from '../xcodemake.ts'; + +const PINNED_FIXTURE_PATH = path.join( + process.cwd(), + 'src', + 'utils', + '__tests__', + 'fixtures', + 'xcodemake', + `xcodemake-${XCODEMAKE_COMMIT}`, +); + +function writeExecutable(filePath: string, contents: string): void { + writeFileSync(filePath, contents); + chmodSync(filePath, 0o755); +} + +function readLines(filePath: string): string[] { + if (!existsSync(filePath)) { + return []; + } + + const contents = readFileSync(filePath, 'utf8').trim(); + return contents.length > 0 ? contents.split('\n') : []; +} + +describe('pinned xcodemake wrapper lifecycle', () => { + let temporaryDirectory: string; + let projectDirectory: string; + let projectFile: string; + let fakeBinDirectory: string; + let xcodebuildInvocationLog: string; + let makeInvocationLog: string; + + beforeEach(() => { + temporaryDirectory = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-xcodemake-wrapper-')); + projectDirectory = path.join(temporaryDirectory, 'project'); + fakeBinDirectory = path.join(temporaryDirectory, 'bin'); + xcodebuildInvocationLog = path.join(temporaryDirectory, 'xcodebuild-invocations.log'); + makeInvocationLog = path.join(temporaryDirectory, 'make-invocations.log'); + + mkdirSync(path.join(projectDirectory, 'MyWorkspace.xcworkspace'), { recursive: true }); + mkdirSync(fakeBinDirectory, { recursive: true }); + projectFile = path.join(projectDirectory, 'MyWorkspace.xcodeproj', 'project.pbxproj'); + mkdirSync(path.dirname(projectFile), { recursive: true }); + writeFileSync(projectFile, '// test project\n'); + + writeExecutable( + path.join(fakeBinDirectory, 'xcodebuild'), + '#!/bin/sh\nprintf \'%s\\n\' "$*" >> "$XCODEMAKE_TEST_XCODEBUILD_LOG"\n', + ); + writeExecutable( + path.join(fakeBinDirectory, 'make'), + '#!/bin/sh\nprintf \'make\\n\' >> "$XCODEMAKE_TEST_MAKE_LOG"\n', + ); + }); + + afterEach(() => { + rmSync(temporaryDirectory, { recursive: true, force: true }); + }); + + function runWrapper(arguments_: string[]): void { + execFileSync('perl', [PINNED_FIXTURE_PATH, ...arguments_], { + cwd: projectDirectory, + encoding: 'utf8', + stdio: 'pipe', + env: { + ...process.env, + PATH: `${fakeBinDirectory}${path.delimiter}${process.env.PATH ?? ''}`, + DEVELOPER_BIN_DIR: fakeBinDirectory, + OBJROOT: path.join(temporaryDirectory, 'objroot'), + XCODEMAKE_TEST_XCODEBUILD_LOG: xcodebuildInvocationLog, + XCODEMAKE_TEST_MAKE_LOG: makeInvocationLog, + }, + }); + } + + function captureLogs(): string[] { + return readdirSync(projectDirectory).filter( + (fileName) => fileName.startsWith('xcodemake') && fileName.endsWith('.log'), + ); + } + + it('captures and reuses builds while invalidating changed or stale state', () => { + const fixtureChecksum = createHash('sha256') + .update(readFileSync(PINNED_FIXTURE_PATH)) + .digest('hex'); + expect(fixtureChecksum).toBe(XCODEMAKE_SHA256); + + const derivedDataPath = + '/Users/developer/Library/Developer/XcodeBuildMCP/DerivedData/MyWorkspace-57a542dedf16'; + const initialArguments = [ + '-workspace', + 'MyWorkspace.xcworkspace', + '-scheme', + 'MyScheme', + '-configuration', + 'Debug', + '-derivedDataPath', + derivedDataPath, + 'build', + ]; + + runWrapper(initialArguments); + + const initialXcodebuildInvocations = readLines(xcodebuildInvocationLog); + expect(initialXcodebuildInvocations).toHaveLength(2); + expect(initialXcodebuildInvocations[0]).toContain(derivedDataPath); + expect(initialXcodebuildInvocations[0]).toMatch(/ clean$/); + expect(initialXcodebuildInvocations[1]).not.toMatch(/ clean$/); + expect(readLines(makeInvocationLog)).toHaveLength(1); + expect(captureLogs()).toHaveLength(1); + expect(readFileSync(path.join(projectDirectory, 'Makefile'), 'utf8')).toContain( + derivedDataPath, + ); + + runWrapper(initialArguments); + + expect(readLines(xcodebuildInvocationLog)).toHaveLength(2); + expect(readLines(makeInvocationLog)).toHaveLength(2); + expect(captureLogs()).toHaveLength(1); + + const changedArguments = initialArguments.map((argument) => + argument === 'Debug' ? 'Release' : argument, + ); + runWrapper(changedArguments); + + expect(readLines(xcodebuildInvocationLog)).toHaveLength(4); + expect(readLines(makeInvocationLog)).toHaveLength(3); + expect(captureLogs()).toHaveLength(2); + expect(readFileSync(path.join(projectDirectory, 'Makefile'), 'utf8')).toContain( + '-configuration Release', + ); + + const newestCaptureTime = Math.max( + ...captureLogs().map((fileName) => statSync(path.join(projectDirectory, fileName)).mtimeMs), + ); + const newerProjectTime = new Date(newestCaptureTime + 5_000); + utimesSync(projectFile, newerProjectTime, newerProjectTime); + + runWrapper(changedArguments); + + expect(readLines(xcodebuildInvocationLog)).toHaveLength(6); + expect(readLines(makeInvocationLog)).toHaveLength(4); + expect(captureLogs()).toHaveLength(2); + }); +}); From 5d244c95f8d317063f354de8fb7940d1950feecc Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Tue, 21 Jul 2026 19:55:15 +0100 Subject: [PATCH 4/7] fix(build): preserve xcodemake lifecycle state Repin the bundled wrapper to the upstream lifecycle fixes and add regression coverage for long configuration arguments, direct fallback, and Makefile reuse. --- .../__tests__/fixtures/xcodemake/README.md | 6 +-- ...-75f47d4b69c1604cb886ab37d348c2c245d18329} | 31 +++++++++++----- src/utils/__tests__/xcodemake-wrapper.test.ts | 37 ++++++++++++++++++- src/utils/xcodemake.ts | 4 +- 4 files changed, 62 insertions(+), 16 deletions(-) rename src/utils/__tests__/fixtures/xcodemake/{xcodemake-7d20fab4ba128f7f533dd27d709daf1bff3c30e2 => xcodemake-75f47d4b69c1604cb886ab37d348c2c245d18329} (97%) diff --git a/src/utils/__tests__/fixtures/xcodemake/README.md b/src/utils/__tests__/fixtures/xcodemake/README.md index c2c5af278..f230d46da 100644 --- a/src/utils/__tests__/fixtures/xcodemake/README.md +++ b/src/utils/__tests__/fixtures/xcodemake/README.md @@ -1,9 +1,9 @@ # Pinned xcodemake test data -`xcodemake-7d20fab4ba128f7f533dd27d709daf1bff3c30e2` is an exact, unmodified copy of -[`cameroncooke/xcodemake` at commit `7d20fab4ba128f7f533dd27d709daf1bff3c30e2`](https://github.com/cameroncooke/xcodemake/blob/7d20fab4ba128f7f533dd27d709daf1bff3c30e2/xcodemake). +`xcodemake-75f47d4b69c1604cb886ab37d348c2c245d18329` is an exact, unmodified copy of +[`cameroncooke/xcodemake` at commit `75f47d4b69c1604cb886ab37d348c2c245d18329`](https://github.com/cameroncooke/xcodemake/blob/75f47d4b69c1604cb886ab37d348c2c245d18329/xcodemake). -SHA-256: `8f2b2c071628959a6564882dd14897489054716eac18766cbcb20164f9d81da8` +SHA-256: `0934f784661f8b295f51064b8e94659c986ca25affc5062f20d5210ae0e25201` The wrapper regression test verifies this checksum against the production pin before execution. Do not edit the fixture independently of `XCODEMAKE_COMMIT` and `XCODEMAKE_SHA256`. diff --git a/src/utils/__tests__/fixtures/xcodemake/xcodemake-7d20fab4ba128f7f533dd27d709daf1bff3c30e2 b/src/utils/__tests__/fixtures/xcodemake/xcodemake-75f47d4b69c1604cb886ab37d348c2c245d18329 similarity index 97% rename from src/utils/__tests__/fixtures/xcodemake/xcodemake-7d20fab4ba128f7f533dd27d709daf1bff3c30e2 rename to src/utils/__tests__/fixtures/xcodemake/xcodemake-75f47d4b69c1604cb886ab37d348c2c245d18329 index 70fdf333d..3e0e09326 100644 --- a/src/utils/__tests__/fixtures/xcodemake/xcodemake-7d20fab4ba128f7f533dd27d709daf1bff3c30e2 +++ b/src/utils/__tests__/fixtures/xcodemake/xcodemake-75f47d4b69c1604cb886ab37d348c2c245d18329 @@ -44,6 +44,23 @@ sub shellEscape { $_[0] =~ s/ /\\ /g; } +sub hasConfigurationArgument { + return scalar grep { /^--?(?:config|configuration)$/ } @_; +} + +sub fileContainsLiteral { + my ($file, $literal) = @_; + open my $fh, "<", $file or return 0; + while (my $line = <$fh>) { + if (index($line, $literal) != -1) { + close $fh; + return 1; + } + } + close $fh; + return 0; +} + # --- Global Variables --- my $log_tag = join "_", ("xcodemake", @original_ARGV); $log_tag =~ s{[/:]+}{_}g; @@ -61,13 +78,10 @@ my $make = "Makefile"; my $xcodebuild = ($ENV{DEVELOPER_BIN_DIR}||'/usr/bin')."/xcodebuild"; my $archs = $ENV{ARCHS}||'arm64'; -# Define $args based on original_ARGV, but check *original_ARGV* for -config -my $args_for_variant = join ' ', map { "'$_'" } @original_ARGV; -my $has_config_in_original = grep { /^-config$/ } @original_ARGV; -$args_for_variant .= " -config Debug" unless $has_config_in_original; +my $has_config_in_original = hasConfigurationArgument(@original_ARGV); my $variant = "$xcodebuild ARCHS=$archs @original_ARGV"; -$variant .= " -config Debug" unless $has_config_in_original; # Match how $args_for_variant is built +$variant .= " -config Debug" unless $has_config_in_original; my $builddb = ($ENV{OBJROOT}||'/tmp')."/XCBuildData/build.db"; @@ -83,9 +97,8 @@ $fileArg = "$notSpace+(?:\\\\.$notSpace*)*"; # Recapture xcodebuild output if paramaters change or project has been edited captureXcodebuild() if ! -f $log || `find . -name project.pbxproj -newer '$log'`; -# Regenerate Makefile (if script newer or different xcodebuild or arguments) -# Use the potentially modified $variant string for the grep check -generateMakefile() if ! -f $make || -M $0 < -M $make || !`grep -Fq '$variant' Makefile`; +# Regenerate Makefile if the script, xcodebuild path, or arguments changed. +generateMakefile() if ! -f $make || -M $0 < -M $make || !fileContainsLiteral($make, $variant); exit $EXIT_SUCCESS if $EXIT_SUCCESS == system "make"; @@ -128,7 +141,7 @@ sub captureXcodebuild { my @cmd_args = @original_ARGV; # Add default -config Debug if necessary *to the list* # Check original args for all possible configuration argument formats - my $has_config = grep { /^-config$/ || /^--config$/ || /^-configuration$/ || /^--configuration$/ } @original_ARGV; + my $has_config = hasConfigurationArgument(@original_ARGV); push @cmd_args, ('-config', 'Debug') unless $has_config; diff --git a/src/utils/__tests__/xcodemake-wrapper.test.ts b/src/utils/__tests__/xcodemake-wrapper.test.ts index a37cf98f8..8f83be163 100644 --- a/src/utils/__tests__/xcodemake-wrapper.test.ts +++ b/src/utils/__tests__/xcodemake-wrapper.test.ts @@ -68,7 +68,7 @@ describe('pinned xcodemake wrapper lifecycle', () => { ); writeExecutable( path.join(fakeBinDirectory, 'make'), - '#!/bin/sh\nprintf \'make\\n\' >> "$XCODEMAKE_TEST_MAKE_LOG"\n', + '#!/bin/sh\nprintf \'make\\n\' >> "$XCODEMAKE_TEST_MAKE_LOG"\nattempts=0\nwhile IFS= read -r _; do attempts=$((attempts + 1)); done < "$XCODEMAKE_TEST_MAKE_LOG"\nif [ "${XCODEMAKE_TEST_FAIL_FIRST_MAKE:-0}" = "1" ] && [ "$attempts" -eq 1 ]; then\n exit 1\nfi\n', ); }); @@ -76,7 +76,7 @@ describe('pinned xcodemake wrapper lifecycle', () => { rmSync(temporaryDirectory, { recursive: true, force: true }); }); - function runWrapper(arguments_: string[]): void { + function runWrapper(arguments_: string[], environment: Record = {}): void { execFileSync('perl', [PINNED_FIXTURE_PATH, ...arguments_], { cwd: projectDirectory, encoding: 'utf8', @@ -88,6 +88,7 @@ describe('pinned xcodemake wrapper lifecycle', () => { OBJROOT: path.join(temporaryDirectory, 'objroot'), XCODEMAKE_TEST_XCODEBUILD_LOG: xcodebuildInvocationLog, XCODEMAKE_TEST_MAKE_LOG: makeInvocationLog, + ...environment, }, }); } @@ -122,6 +123,9 @@ describe('pinned xcodemake wrapper lifecycle', () => { const initialXcodebuildInvocations = readLines(xcodebuildInvocationLog); expect(initialXcodebuildInvocations).toHaveLength(2); + expect(initialXcodebuildInvocations).not.toContainEqual( + expect.stringContaining('-config Debug'), + ); expect(initialXcodebuildInvocations[0]).toContain(derivedDataPath); expect(initialXcodebuildInvocations[0]).toMatch(/ clean$/); expect(initialXcodebuildInvocations[1]).not.toMatch(/ clean$/); @@ -131,11 +135,16 @@ describe('pinned xcodemake wrapper lifecycle', () => { derivedDataPath, ); + const makefilePath = path.join(projectDirectory, 'Makefile'); + const futureMakefileTime = new Date(Date.now() + 60_000); + utimesSync(makefilePath, futureMakefileTime, futureMakefileTime); + runWrapper(initialArguments); expect(readLines(xcodebuildInvocationLog)).toHaveLength(2); expect(readLines(makeInvocationLog)).toHaveLength(2); expect(captureLogs()).toHaveLength(1); + expect(statSync(makefilePath).mtimeMs).toBeGreaterThan(Date.now() + 30_000); const changedArguments = initialArguments.map((argument) => argument === 'Debug' ? 'Release' : argument, @@ -161,4 +170,28 @@ describe('pinned xcodemake wrapper lifecycle', () => { expect(readLines(makeInvocationLog)).toHaveLength(4); expect(captureLogs()).toHaveLength(2); }); + + it('preserves the long configuration option when direct xcodebuild fallback is required', () => { + runWrapper( + [ + '-workspace', + 'MyWorkspace.xcworkspace', + '-scheme', + 'MyScheme', + '-configuration', + 'Release', + 'build', + ], + { XCODEMAKE_TEST_FAIL_FIRST_MAKE: '1' }, + ); + + const xcodebuildInvocations = readLines(xcodebuildInvocationLog); + expect(xcodebuildInvocations).toHaveLength(5); + expect( + xcodebuildInvocations.every((invocation) => invocation.includes('-configuration Release')), + ).toBe(true); + expect(xcodebuildInvocations).not.toContainEqual(expect.stringContaining('-config Debug')); + expect(xcodebuildInvocations[2]).not.toMatch(/ clean$/); + expect(readLines(makeInvocationLog)).toHaveLength(2); + }); }); diff --git a/src/utils/xcodemake.ts b/src/utils/xcodemake.ts index 61c557b5d..dce4b4751 100644 --- a/src/utils/xcodemake.ts +++ b/src/utils/xcodemake.ts @@ -10,8 +10,8 @@ import { getConfig } from './config-store.ts'; let overriddenXcodemakePath: string | null = null; -export const XCODEMAKE_COMMIT = '7d20fab4ba128f7f533dd27d709daf1bff3c30e2'; -export const XCODEMAKE_SHA256 = '8f2b2c071628959a6564882dd14897489054716eac18766cbcb20164f9d81da8'; +export const XCODEMAKE_COMMIT = '75f47d4b69c1604cb886ab37d348c2c245d18329'; +export const XCODEMAKE_SHA256 = '0934f784661f8b295f51064b8e94659c986ca25affc5062f20d5210ae0e25201'; export const XCODEMAKE_DOWNLOAD_URL = `https://raw.githubusercontent.com/cameroncooke/xcodemake/${XCODEMAKE_COMMIT}/xcodemake`; interface XcodemakeInstallerDependencies { From 6c0f5c0ff3161030d36d54ee310080e9032f51a1 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Tue, 21 Jul 2026 20:03:51 +0100 Subject: [PATCH 5/7] test(build): Resolve xcodemake fixture by module Keep the wrapper lifecycle test independent of the process working directory. --- src/utils/__tests__/xcodemake-wrapper.test.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/utils/__tests__/xcodemake-wrapper.test.ts b/src/utils/__tests__/xcodemake-wrapper.test.ts index 8f83be163..2e4445db9 100644 --- a/src/utils/__tests__/xcodemake-wrapper.test.ts +++ b/src/utils/__tests__/xcodemake-wrapper.test.ts @@ -14,17 +14,12 @@ import { } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { XCODEMAKE_COMMIT, XCODEMAKE_SHA256 } from '../xcodemake.ts'; -const PINNED_FIXTURE_PATH = path.join( - process.cwd(), - 'src', - 'utils', - '__tests__', - 'fixtures', - 'xcodemake', - `xcodemake-${XCODEMAKE_COMMIT}`, +const PINNED_FIXTURE_PATH = fileURLToPath( + new URL(`./fixtures/xcodemake/xcodemake-${XCODEMAKE_COMMIT}`, import.meta.url), ); function writeExecutable(filePath: string, contents: string): void { From e25a539b2adbb455cadc48e1408f915d8a4b4f26 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Tue, 21 Jul 2026 20:15:12 +0100 Subject: [PATCH 6/7] test(build): Stabilize wrapper mtime assertion Compare the observed Makefile timestamp with the explicit sentinel using a filesystem-resolution tolerance. --- src/utils/__tests__/xcodemake-wrapper.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/utils/__tests__/xcodemake-wrapper.test.ts b/src/utils/__tests__/xcodemake-wrapper.test.ts index 2e4445db9..30f1aad21 100644 --- a/src/utils/__tests__/xcodemake-wrapper.test.ts +++ b/src/utils/__tests__/xcodemake-wrapper.test.ts @@ -18,6 +18,7 @@ import { fileURLToPath } from 'node:url'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { XCODEMAKE_COMMIT, XCODEMAKE_SHA256 } from '../xcodemake.ts'; +const FILESYSTEM_TIMESTAMP_TOLERANCE_MS = 1_000; const PINNED_FIXTURE_PATH = fileURLToPath( new URL(`./fixtures/xcodemake/xcodemake-${XCODEMAKE_COMMIT}`, import.meta.url), ); @@ -139,7 +140,9 @@ describe('pinned xcodemake wrapper lifecycle', () => { expect(readLines(xcodebuildInvocationLog)).toHaveLength(2); expect(readLines(makeInvocationLog)).toHaveLength(2); expect(captureLogs()).toHaveLength(1); - expect(statSync(makefilePath).mtimeMs).toBeGreaterThan(Date.now() + 30_000); + expect( + Math.abs(statSync(makefilePath).mtimeMs - futureMakefileTime.getTime()), + ).toBeLessThanOrEqual(FILESYSTEM_TIMESTAMP_TOLERANCE_MS); const changedArguments = initialArguments.map((argument) => argument === 'Debug' ? 'Release' : argument, From c3d0536f244a2d5a31b5a126f1c92d46a18fd5ba Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Wed, 22 Jul 2026 09:45:51 +0100 Subject: [PATCH 7/7] fix: default xcodemake cwd to current directory --- .../__tests__/build-utils-xcodemake.test.ts | 30 +++++++++++++++++++ src/utils/build-utils.ts | 2 +- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/utils/__tests__/build-utils-xcodemake.test.ts b/src/utils/__tests__/build-utils-xcodemake.test.ts index a3b6eaa80..5d7b0a484 100644 --- a/src/utils/__tests__/build-utils-xcodemake.test.ts +++ b/src/utils/__tests__/build-utils-xcodemake.test.ts @@ -78,4 +78,34 @@ describe('build-utils xcodemake lifecycle', () => { 'iOS Simulator Build', ); }); + + it('uses the current working directory when no project or workspace path is provided', async () => { + const derivedDataPath = + '/Users/developer/Library/Developer/XcodeBuildMCP/DerivedData/MyScheme-57a542dedf16'; + const executorCall = vi.fn(); + const executor = createMockExecutor({ onExecute: executorCall }); + + const result = await executeXcodeBuildCommand( + { + scheme: 'MyScheme', + derivedDataPath, + }, + { + platform: XcodePlatform.iOSSimulator, + simulatorId: 'SIMULATOR-UDID', + logPrefix: 'iOS Simulator Build', + }, + false, + 'build', + executor, + ); + + expect(result.isError).toBeFalsy(); + expect(executorCall).not.toHaveBeenCalled(); + expect(executeXcodemakeCommandMock).toHaveBeenCalledWith( + process.cwd(), + expect.arrayContaining(['-derivedDataPath', derivedDataPath]), + 'iOS Simulator Build', + ); + }); }); diff --git a/src/utils/build-utils.ts b/src/utils/build-utils.ts index 5231b9c7e..3618a19a0 100644 --- a/src/utils/build-utils.ts +++ b/src/utils/build-utils.ts @@ -82,7 +82,7 @@ export async function executeXcodeBuildCommand( projectPath, }); - let projectDir = ''; + let projectDir = process.cwd(); if (workspacePath) { projectDir = path.dirname(workspacePath); command.push('-workspace', workspacePath);