From 21f2abfa947de89c46d0b5dcea5b82ae5d1f3abe Mon Sep 17 00:00:00 2001 From: "james.balamuta@gmail.com" Date: Wed, 16 Sep 2026 16:22:12 -0500 Subject: [PATCH 1/4] Size the README logo by width so pkgdown honors it pkgdown's Bootstrap CSS sets `main img { height: auto }`, which overrides the HTML height attribute, so the hex logo rendered at its intrinsic 400x462 instead of the intended 170px tall. Setting width instead survives the cascade and renders 150x173, matching the other packages. --- README.Rmd | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.Rmd b/README.Rmd index bf8521b..a591c62 100644 --- a/README.Rmd +++ b/README.Rmd @@ -17,7 +17,7 @@ knitr::opts_chunk$set( - macrtools hex logo + macrtools hex logo # macrtools diff --git a/README.md b/README.md index 6e13d48..7342159 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ - macrtools hex logo + macrtools hex logo # macrtools From d1b411d39caeb923214f925cab743556d942be98 Mon Sep 17 00:00:00 2001 From: "james.balamuta@gmail.com" Date: Wed, 16 Sep 2026 16:40:13 -0500 Subject: [PATCH 2/4] Detect the Xcode toolchain outside its default location is_xcode_cli_installed() and is_xcode_app_installed() compared the active developer directory against a single hardcoded path with identical(), so both returned FALSE whenever anything else was selected, even with a working toolchain installed. A versioned bundle such as /Applications/Xcode_16.4.app/Contents/Developer is the normal multi-Xcode layout and is what every GitHub-hosted macOS runner selects, so this was live on this package's own CI. macos_rtools_install() treated the two predicates as exhaustive and would start a 10-15 minute softwareupdate install of Command Line Tools that were already present. Split the conflated question in three, following Homebrew: CLT package presence, any-Xcode-installed, and whether a compiler is actually reachable via xcrun. The install step now gates on the last of these. Path comparisons go through normalize_developer_dir(), and DEVELOPER_DIR is honored where it was previously ignored. Keep the destructive path keyed on package presence. Widening is_xcode_cli_installed() to mean "a usable toolchain is selected" would let a machine with Xcode selected delete the Command Line Tools. Add xcode_toolchains() to list every selectable developer directory, and let xcode_cli_switch() take one rather than always forcing the CLT. --- DESCRIPTION | 2 +- NAMESPACE | 4 + NEWS.md | 117 +++++++++ R/toolchain.R | 143 +++++++---- R/xcode.R | 423 +++++++++++++++++++++++++++----- _pkgdown.yml | 4 + man/developer_dir_is_app.Rd | 24 ++ man/normalize_developer_dir.Rd | 24 ++ man/xcode-app-ide.Rd | 24 +- man/xcode-cli.Rd | 43 +++- man/xcode-toolchain.Rd | 66 +++++ man/xcode_app_bundles.Rd | 23 ++ man/xcode_app_version.Rd | 18 ++ tests/testthat/test-toolchain.R | 138 ++++++++++- tests/testthat/test-xcode.R | 416 +++++++++++++++++++++++++++---- 15 files changed, 1297 insertions(+), 172 deletions(-) create mode 100644 man/developer_dir_is_app.Rd create mode 100644 man/normalize_developer_dir.Rd create mode 100644 man/xcode-toolchain.Rd create mode 100644 man/xcode_app_bundles.Rd create mode 100644 man/xcode_app_version.Rd diff --git a/DESCRIPTION b/DESCRIPTION index 25272b3..b7d1c45 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: macrtools Title: macOS Rtools package -Version: 0.0.8 +Version: 0.0.9 Authors@R: c( person(given = "James Joseph", family = "Balamuta", diff --git a/NAMESPACE b/NAMESPACE index fd39219..cba42b2 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -8,6 +8,7 @@ export(assert_macos_supported) export(assert_r_version_supported) export(assert_x86_64) export(binary_download) +export(developer_dir) export(gfortran_install) export(gfortran_uninstall) export(gfortran_update) @@ -21,6 +22,7 @@ export(is_gfortran_installed) export(is_openmp_installed) export(is_xcode_app_installed) export(is_xcode_cli_installed) +export(is_xcode_toolchain_usable) export(macos_rtools_install) export(macos_rtools_uninstall) export(openmp_install) @@ -34,8 +36,10 @@ export(xcode_cli_path) export(xcode_cli_reset) export(xcode_cli_switch) export(xcode_cli_uninstall) +export(xcode_cli_version) export(xcode_select) export(xcode_select_path) export(xcode_select_version) +export(xcode_toolchains) export(xcodebuild) export(xcodebuild_version) diff --git a/NEWS.md b/NEWS.md index 29f61e7..d4e25a9 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,120 @@ +# macrtools 0.0.9 + +## Bug Fixes + +- Fixed Xcode toolchain detection when the tools are not in the default + location. `is_xcode_cli_installed()` and `is_xcode_app_installed()` compared + the active developer directory against a single hardcoded path with + `identical()`, so both returned `FALSE` whenever anything else was selected, + even with a perfectly usable toolchain installed: + + ```r + is_xcode_cli_installed() + #> [1] FALSE + xcode_cli_path() + #> [1] "/Applications/Xcode_16.4.app/Contents/Developer" + ``` + + A versioned Xcode bundle like that is the normal multi-Xcode layout, and it + is what every GitHub-hosted macOS runner selects, so this affected the + package's own CI. `macos_rtools_install()` treated the two predicates as + exhaustive and would start a 10-15 minute `softwareupdate` install of Command + Line Tools that were already present. + +- `xcode_cli_uninstall()` now returns `FALSE` when the Command Line Tools were + not installed, instead of `TRUE`. `macos_rtools_uninstall()` reported "Xcode + CLI: Successfully removed" on that path even though nothing had been removed. + The summary now distinguishes a removal from a component that was absent. + +- `macos_rtools_install()` no longer reports the output of + `xcode-select --version` as the Command Line Tools version. That command + reports the version of `xcode-select` itself and never changes with the + toolchain. The version now comes from the package receipt. + +## Features + +- Added `xcode_toolchains()`, which lists every developer directory + `xcode-select` could be pointed at: the Command Line Tools plus each + installed Xcode bundle, with its version and whether it is active. + + ```r + xcode_toolchains() + #> path type version active + #> 1 /Applications/Xcode.app/Contents/Developer app 27.0 TRUE + #> 2 /Library/Developer/CommandLineTools cli 27.0.0.0.1788430756 FALSE + ``` + + Xcode bundles are found by bundle identifier through Spotlight, so a renamed + or relocated install is still detected. A `/Applications/Xcode*.app` glob + covers machines where Spotlight indexing is disabled, which catches the + versioned names but not installs outside `/Applications`. + This lists developer directories, not `.xctoolchain` bundles, which are a + separate axis selected via `xcrun --toolchain` and `TOOLCHAINS`. + +- Added `is_xcode_toolchain_usable()`, which reports whether a compiler is + actually reachable via `xcrun --find clang`. This is the condition that + determines whether R can build a package, since R is configured with a bare + `clang` that resolves through the active developer directory. It is now what + gates the install step. + +- Added `developer_dir()`, which resolves the active developer directory and + honors `DEVELOPER_DIR`. That variable overrides the `xcode-select` selection + for `xcrun`, the `/usr/bin` shims and `xcodebuild`, and none of the package's + detection accounted for it. + +- Added `xcode_cli_version()`, reading the real Command Line Tools version from + the `com.apple.pkg.CLTools_Executables` receipt. + +- `xcode_cli_switch()` now takes a `path`, so a specific toolchain from + `xcode_toolchains()` can be selected rather than always forcing the Command + Line Tools. The target is validated before use, the displaced selection is + named, and a set `DEVELOPER_DIR` is flagged because it silently overrides the + stored selection. + +- `macos_rtools_install()` now reports which toolchain is active rather than + assuming it is a full Xcode at `/Applications/Xcode.app`, and reports a + failure, instead of "no action needed", when the Command Line Tools are on + disk but the active developer directory is stale so nothing can compile. It + also verifies that a compiler is reachable *after* installing, rather than + trusting the installer's return value, since a stale selection or a + `DEVELOPER_DIR` override still shadows freshly installed tools. + +- `xcode_cli_install()` no longer skips the installation merely because an + Xcode bundle exists somewhere on disk. An unselected Xcode provides no + compiler, so the skip now requires a toolchain that is actually active. + +## Breaking Changes + +- `is_xcode_cli_installed()` now reports whether the Command Line Tools + **package** is installed, rather than whether it is the selected developer + directory. Selection is a separate question, answered by `developer_dir()` + and `is_xcode_toolchain_usable()`. The split is deliberate: the destructive + path in `xcode_cli_uninstall()` must stay keyed on package presence, because + widening it to mean "a usable toolchain is selected" would let a machine with + Xcode selected delete the Command Line Tools. + +- `is_xcode_app_installed()` now reports whether any Xcode bundle is installed + anywhere, rather than whether `/Applications/Xcode.app` is the selected + developer directory. + +- `xcode_cli_switch()` takes `path` as its first argument, where `password` + used to be. A positional call such as `xcode_cli_switch(pw)` now fails with a + clear error rather than switching. + +## Internal + +- Path comparisons are normalized through `normalize_developer_dir()`, which + expands an `.app` bundle to its developer directory, strips trailing slashes + and resolves symlinks. macOS reports these paths in forms that `identical()` + would never match, for instance `/tmp` against `/private/tmp`. +- Added tests covering non-standard, versioned and relocated toolchain layouts, + plus a test pinning the invariant that `xcode_cli_uninstall()` never derives + its removal target from the active selection. +- The `xcode-select --switch` target is shell-quoted, so the caller-supplied + `path` cannot split on spaces. `xcode_cli_switch()` also declines to echo + that argument in its error message, because it took `password`'s position and + a legacy positional call would otherwise print a sudo password. + # macrtools 0.0.8 ## Features diff --git a/R/toolchain.R b/R/toolchain.R index c99d466..165dee4 100644 --- a/R/toolchain.R +++ b/R/toolchain.R @@ -134,61 +134,112 @@ rtools_install_xcode_cli <- function(entered_password, verbose, describe_steps, cli::cli_text("") # Add spacing result_xcode <- TRUE - if(!is_xcode_app_installed()) { - if(!is_xcode_cli_installed()) { - if (verbose) { - cli::cli_progress_update(id = pb_id, set = 10) - cli::cli_alert_info("{.pkg macrtools}: Need to install Xcode Command Line Tools.") - cli::cli_bullets(c( - "Source: Apple Software Update", - "Installation method: softwareupdate command", - "Status: Not installed", - "Estimated time: 10-15 minutes" - )) - cli::cli_text("") # Add spacing - } + # Gate on whether a compiler is actually REACHABLE. The previous check + # compared the active developer directory against two hardcoded paths and + # so misread every non-standard layout, including the versioned Xcode that + # every GitHub runner selects, as "no toolchain installed". + if(is_xcode_toolchain_usable()) { + if(describe_steps) { + active_dir <- developer_dir() - if (verbose) cli::cli_progress_update(id = pb_id, set = 20) - result_xcode <- xcode_cli_install(password = entered_password, verbose = describe_steps) + if(developer_dir_is_app(active_dir)) { + bundle <- base::sub("/Contents/Developer$", "", active_dir) + active_version <- xcode_app_version(bundle) - if(!result_xcode) { - cli::cli_abort(c( - "{.pkg macrtools}: Failed to install Xcode Command Line Tools.", - "This is a required component for R package development.", - "Installation status: Failed", - "i" = "Try installing manually by running 'sudo xcode-select --install' in Terminal." + cli::cli_alert_info("{.pkg macrtools}: Full Xcode.app IDE is active.") + cli::cli_bullets(c( + "Location: {.path {bundle}}", + "Version: {.val {active_version}}", + "Status: Pre-installed, skipping Command Line Tools installation" )) - } - - if (verbose) cli::cli_progress_update(id = pb_id, set = 30) - } else { - if(describe_steps) { - # Get Xcode CLI version information - xcode_version <- exec_text('xcode-select', '--version') + } else { + active_location <- if(base::is.na(active_dir)) { + install_directory_xcode_cli() + } else { + active_dir + } + # xcode-select --version reports the version of xcode-select + # itself and never changes with the toolchain, so read the + # package receipt instead. + active_version <- xcode_cli_version() cli::cli_alert_info("{.pkg macrtools}: Xcode Command Line Tools already installed.") cli::cli_bullets(c( - "Location: {.path {xcode_cli_path()}}", - "Version: {.val {xcode_version}}", + "Location: {.path {active_location}}", + "Version: {.val {active_version}}", "Status: Pre-installed, no action needed" )) - cli::cli_text("") # Add spacing } - if (verbose) cli::cli_progress_update(id = pb_id, set = 30) + cli::cli_text("") # Add spacing + } + if (verbose) cli::cli_progress_update(id = pb_id, set = 30) + } else if(!is_xcode_cli_installed()) { + if (verbose) { + cli::cli_progress_update(id = pb_id, set = 10) + cli::cli_alert_info("{.pkg macrtools}: Need to install Xcode Command Line Tools.") + cli::cli_bullets(c( + "Source: Apple Software Update", + "Installation method: softwareupdate command", + "Status: Not installed", + "Estimated time: 10-15 minutes" + )) + cli::cli_text("") # Add spacing } - } else { - if(describe_steps) { - # Get full Xcode app version information - xcode_app_info <- exec_text('xcodebuild', '-version') - cli::cli_alert_info("{.pkg macrtools}: Full Xcode.app IDE is installed.") + if (verbose) cli::cli_progress_update(id = pb_id, set = 20) + result_xcode <- xcode_cli_install(password = entered_password, verbose = describe_steps) + + if(!result_xcode) { + cli::cli_abort(c( + "{.pkg macrtools}: Failed to install Xcode Command Line Tools.", + "This is a required component for R package development.", + "Installation status: Failed", + "i" = "Try installing manually by running 'sudo xcode-select --install' in Terminal." + )) + } + + # Installing does not guarantee the tools became REACHABLE: a stale + # selection or a DEVELOPER_DIR override still shadows them. Verify + # rather than trusting the installer's return value, so the summary + # cannot claim a working toolchain on a machine that cannot compile. + if(!is_xcode_toolchain_usable()) { + active_dir <- developer_dir() + selected <- if(base::is.na(active_dir)) "none selected" else active_dir + + cli::cli_alert_warning(base::paste( + "{.pkg macrtools}: Xcode Command Line Tools were installed, but", + "no compiler is reachable yet." + )) cli::cli_bullets(c( - "Location: {.path {'/Applications/Xcode.app'}}", - "Version information: {.val {xcode_app_info}}", - "Status: Pre-installed, skipping Command Line Tools installation" + "Active developer directory: {.val {selected}}", + "i" = "Run {.code macrtools::xcode_cli_switch()} to select them, then retry." )) cli::cli_text("") # Add spacing + + result_xcode <- FALSE } + + if (verbose) cli::cli_progress_update(id = pb_id, set = 30) + } else { + # The Command Line Tools are on disk but no compiler is reachable, so + # the active developer directory is stale or broken. Reinstalling does + # not fix a bad selection, and reporting success here would claim a + # working toolchain on a machine that cannot compile. + active_dir <- developer_dir() + selected <- if(base::is.na(active_dir)) "none selected" else active_dir + + cli::cli_alert_warning(base::paste( + "{.pkg macrtools}: Xcode Command Line Tools are installed, but no", + "compiler is reachable." + )) + cli::cli_bullets(c( + "Command Line Tools: {.path {install_directory_xcode_cli()}}", + "Active developer directory: {.val {selected}}", + "i" = "Run {.code macrtools::xcode_cli_switch()} to select them, then retry." + )) + cli::cli_text("") # Add spacing + + result_xcode <- FALSE if (verbose) cli::cli_progress_update(id = pb_id, set = 30) } @@ -359,13 +410,15 @@ macos_rtools_uninstall <- function( # Step 1: Uninstall Xcode CLI result_xcode <- TRUE + removed_xcode <- FALSE if(is_xcode_cli_installed()) { if (verbose) { cli::cli_progress_update(id = pb_id, set = 30) cli::cli_alert_info("{.pkg macrtools}: Uninstalling Xcode CLI...") cli::cli_text("") # Add spacing } - result_xcode <- xcode_cli_uninstall(password = password, verbose = verbose) + removed_xcode <- xcode_cli_uninstall(password = password, verbose = verbose) + result_xcode <- removed_xcode if(!result_xcode) { cli::cli_abort("{.pkg macrtools}: Failed to uninstall Xcode CLI. Please see manual steps.") } @@ -379,12 +432,14 @@ macos_rtools_uninstall <- function( # Step 2: Uninstall gfortran if (verbose) cli::cli_progress_update(id = pb_id, set = 70) result_gfortran <- TRUE + removed_gfortran <- FALSE if(is_gfortran_installed()) { if (verbose) { cli::cli_alert_info("{.pkg macrtools}: Uninstalling gfortran...") cli::cli_text("") # Add spacing } - result_gfortran <- gfortran_uninstall(password = password, verbose = verbose) + removed_gfortran <- gfortran_uninstall(password = password, verbose = verbose) + result_gfortran <- removed_gfortran if(!result_gfortran) { cli::cli_abort("{.pkg macrtools}: Failed to uninstall gfortran. Please see manual steps.") } @@ -405,8 +460,8 @@ macos_rtools_uninstall <- function( if(clean) { cli::cli_alert_success("{.pkg macrtools}: Uninstallation complete.") cli::cli_bullets(c( - "Xcode CLI: Successfully removed", - "Gfortran: Successfully removed", + base::paste0("Xcode CLI: ", if (removed_xcode) "Successfully removed" else "Was not installed"), + base::paste0("Gfortran: ", if (removed_gfortran) "Successfully removed" else "Was not installed"), "Note: This did not uninstall any binaries from the recipes project" )) } diff --git a/R/xcode.R b/R/xcode.R index fbb4f60..4756d5b 100644 --- a/R/xcode.R +++ b/R/xcode.R @@ -58,25 +58,20 @@ xcodebuild_version <- function() { #' Detect if the Xcode.app IDE is Installed #' -#' Checks to see whether Xcode.app Integrated Developer Environment -#' (IDE) was installed and is active under the default location. +#' Checks whether an Xcode.app Integrated Developer Environment (IDE) is +#' installed anywhere on the system. #' #' @details +#' Installed, not *selected*. Xcode is routinely installed under a versioned +#' name such as `/Applications/Xcode_26.6.app`, which is what every +#' GitHub-hosted macOS runner does, and `man xcode-select` documents +#' non-default locations as supported. Bundles are therefore located by bundle +#' identifier rather than by path, so a renamed or relocated install is still +#' found. #' -#' Checks to see if Xcode.app IDE is active by running: -#' -#' ```sh -#' xcode-select -p -#' ``` -#' -#' If it returns: -#' -#' ```sh -#' /Applications/Xcode.app/Contents/Developer -#' ``` -#' -#' We consider the full Xcode.app to be **installed** and **selected** as -#' the command line tools to use. +#' To ask instead which toolchain is *active*, use [developer_dir()]; to ask +#' whether a compiler can actually be reached, use +#' [is_xcode_toolchain_usable()]. #' #' @rdname xcode-app-ide #' @export @@ -86,12 +81,181 @@ xcodebuild_version <- function() { is_xcode_app_installed <- function() { assert_mac() - # Check to see if the Xcode path is set - path_info <- xcode_select_path() + base::length(xcode_app_bundles()) > 0L +} + +#' Normalize a Developer Directory Path +#' +#' @details +#' `xcode-select --switch` accepts either an `Xcode.app` bundle or a developer +#' directory inside one, so both spellings are normalized to the developer +#' directory. Trailing slashes and symlinks are resolved too, because macOS +#' reports paths in forms that [base::identical()] will never match. +#' +#' @param path A bundle path or developer directory. +#' @return The normalized developer directory. +#' @keywords internal +normalize_developer_dir <- function(path) { + path <- base::sub("/+$", "", path) + + is_bundle <- base::grepl("\\.app$", path) + path[is_bundle] <- base::file.path(path[is_bundle], "Contents", "Developer") + + base::normalizePath(path, mustWork = FALSE) +} + +#' Inspect the Active Xcode Toolchain +#' +#' Report which developer directory is active, whether a compiler can actually +#' be reached through it, and which toolchains are available to switch to. +#' +#' @details +#' `developer_dir()` resolves the active developer directory. +#' `DEVELOPER_DIR` overrides the selection stored by `xcode-select`, for +#' `xcrun`, the `/usr/bin` shims and `xcodebuild` alike, so it is consulted +#' first. Note that `xcode-select --print-path` echoes its value and exits `0` +#' even when the directory does not exist, so the exit status alone proves +#' nothing; the path is verified before being returned. +#' +#' @return The active developer directory, or `NA_character_` when nothing +#' usable is selected. +#' @name xcode-toolchain +#' @rdname xcode-toolchain +#' @export +#' @examples +#' # Which developer directory is active +#' developer_dir() +developer_dir <- function() { + from_env <- base::Sys.getenv("DEVELOPER_DIR", unset = "") + + candidate <- if (base::nzchar(from_env)) { + from_env + } else { + # xcode-select may be missing entirely, so failing to run it must + # resolve to "nothing selected" rather than propagating an error. + path_info <- base::tryCatch(xcode_select_path(), error = function(e) NULL) + if (!base::is.null(path_info) && base::identical(path_info$status, 0L)) { + path_info$output + } else { + "" + } + } + + if (base::length(candidate) != 1L || base::is.na(candidate) || + !base::nzchar(candidate)) { + return(NA_character_) + } + + candidate <- normalize_developer_dir(candidate) + + if (!base::dir.exists(candidate)) NA_character_ else candidate +} + +#' Locate Every Installed Xcode Bundle +#' +#' @details +#' Xcode is routinely installed under a versioned name, which is what every +#' GitHub-hosted macOS runner does (`/Applications/Xcode_26.6.app`), and +#' `man xcode-select` documents non-default locations as supported. Spotlight +#' is queried by bundle identifier so the name and location do not matter, with +#' a glob fallback for machines where Spotlight indexing is disabled. Time +#' Machine copies are discarded. +#' +#' @return Paths to the installed Xcode bundles, possibly empty. +#' @keywords internal +xcode_app_bundles <- function() { + from_spotlight <- exec_text( + "mdfind", + "kMDItemCFBundleIdentifier == 'com.apple.dt.Xcode'", + fallback = base::character(0) + ) + + candidates <- base::unique(c( + from_spotlight, + base::Sys.glob("/Applications/Xcode*.app") + )) + + candidates <- candidates[!base::is.na(candidates) & base::nzchar(candidates)] + candidates <- candidates[ + !base::grepl("/Backups.backupdb/", candidates, fixed = TRUE) + ] + candidates <- candidates[ + base::dir.exists(base::file.path(candidates, "Contents", "Developer")) + ] + + base::unique(base::normalizePath(candidates, mustWork = FALSE)) +} + +#' Version of an Xcode Bundle +#' +#' @param bundle Path to an `Xcode.app` bundle. +#' @return The bundle's short version string, or `NA_character_`. +#' @keywords internal +xcode_app_version <- function(bundle) { + plist <- base::file.path(bundle, "Contents", "version.plist") + + if (!base::file.exists(plist)) { + return(NA_character_) + } + + out <- exec_text( + "/usr/libexec/PlistBuddy", + c("-c", "Print :CFBundleShortVersionString", plist), + fallback = NA_character_ + ) + + if (base::length(out) != 1L || base::is.na(out) || !base::nzchar(out)) { + NA_character_ + } else { + out + } +} + +#' Check if a Developer Directory is a Full Xcode +#' +#' @details +#' A full Xcode carries the platform bundles; the Command Line Tools do not. +#' This distinguishes the two without shelling out to `xcodebuild`, which is +#' slow, errors when the Command Line Tools are active, and can stall on the +#' license agreement. +#' +#' @param dir Developer directory to test, defaulting to the active one. +#' @return TRUE if `dir` is a full Xcode developer directory, FALSE otherwise +#' @keywords internal +developer_dir_is_app <- function(dir = developer_dir()) { + !base::is.na(dir) && + base::dir.exists(base::file.path(dir, "Platforms", "MacOSX.platform")) +} + +#' Check if the Active Toolchain Can Actually Compile +#' +#' @details +#' This is the question that matters for building R packages. R is configured +#' with a bare `clang`, which resolves to the `/usr/bin` shim, so whether a +#' package can be compiled depends on the active developer directory +#' dispatching successfully, not on where the tools live. +#' +#' `xcrun --find clang` resolves through the active directory and honors +#' `DEVELOPER_DIR`. It can, however, exit `0` while printing an error for a +#' degraded bundle, so the returned path is confirmed to exist and be +#' executable rather than trusting the exit status. +#' +#' @return TRUE if a usable compiler is reachable, FALSE otherwise +#' @rdname xcode-toolchain +#' @export +#' @examples +#' # Check whether a compiler is actually reachable +#' is_xcode_toolchain_usable() +is_xcode_toolchain_usable <- function() { + assert_mac() + + found <- exec_text("xcrun", c("--find", "clang"), fallback = NA_character_) + + if (base::length(found) != 1L || base::is.na(found) || !base::nzchar(found)) { + return(FALSE) + } - base::identical(path_info$status, 0L) && - base::identical(path_info$output, install_directory_xcode_app()) && - base::dir.exists(path_info$output) + base::file.exists(found) && base::identical(base::file.access(found, 1L)[[1]], 0L) } #' Find, Install, or Uninstall XCode CLI @@ -101,13 +265,20 @@ is_xcode_app_installed <- function() { #' #' @section Check if XCode CLI is installed: #' -#' Checks to see if Xcode CLI returns a viable path to the default Xcode CLI -#' location by checking the output of: +#' `is_xcode_cli_installed()` reports whether the Command Line Tools *package* +#' is present, by looking for the compiler it installs: #' #' ```sh -#' xcode-select -p +#' /Library/Developer/CommandLineTools/usr/bin/clang #' ``` #' +#' This is independent of which developer directory is selected. The previous +#' implementation compared `xcode-select -p` against that path with +#' `identical()`, so it reported `FALSE` on every machine where anything else +#' was active, including the versioned Xcode that CI runners select. +#' +#' For the selection, see [developer_dir()] and [is_xcode_toolchain_usable()]. +#' #' @rdname xcode-cli #' @export #' @examples @@ -116,11 +287,101 @@ is_xcode_app_installed <- function() { is_xcode_cli_installed <- function() { assert_mac() - path_info <- xcode_select_path() + base::file.exists( + base::file.path(install_directory_xcode_cli(), "usr", "bin", "clang") + ) +} + +#' @return The Command Line Tools version from the package receipt, or +#' `NA_character_` when they are not installed. +#' @rdname xcode-cli +#' @export +#' @examples +#' # Version of the installed Command Line Tools +#' xcode_cli_version() +xcode_cli_version <- function() { + if (!is_xcode_cli_installed()) { + return(NA_character_) + } + + info <- exec_text( + "pkgutil", + "--pkg-info=com.apple.pkg.CLTools_Executables", + fallback = NA_character_ + ) + + version_line <- base::grep("^version:", info, value = TRUE) + + if (base::length(version_line) == 0L) { + return(NA_character_) + } + + base::trimws(base::sub("^version:", "", version_line[[1]])) +} + +#' @section Listing the Available Toolchains: +#' +#' `xcode_toolchains()` reports every developer directory `xcode-select` could +#' be pointed at: the Command Line Tools, plus each installed Xcode bundle +#' wherever it lives. Pass a `path` from this table to [xcode_cli_switch()]. +#' +#' Note that this lists *developer directories*, not `.xctoolchain` bundles. +#' Those are a separate axis selected through `xcrun --toolchain` and the +#' `TOOLCHAINS` variable, and are mostly Swift snapshots. +#' +#' @return A data frame with one row per selectable developer directory and +#' columns `path`, `type` (`"cli"` or `"app"`), `version` and `active`. +#' @rdname xcode-toolchain +#' @export +#' @examples +#' # List every toolchain xcode-select could use +#' xcode_toolchains() +xcode_toolchains <- function() { + assert_mac() + + empty <- base::data.frame( + path = base::character(0), + type = base::character(0), + version = base::character(0), + active = base::logical(0), + stringsAsFactors = FALSE + ) + + rows <- base::list() + + if (is_xcode_cli_installed()) { + rows[[base::length(rows) + 1L]] <- base::data.frame( + path = install_directory_xcode_cli(), + type = "cli", + version = xcode_cli_version(), + stringsAsFactors = FALSE + ) + } + + for (bundle in xcode_app_bundles()) { + rows[[base::length(rows) + 1L]] <- base::data.frame( + path = base::file.path(bundle, "Contents", "Developer"), + type = "app", + version = xcode_app_version(bundle), + stringsAsFactors = FALSE + ) + } + + if (base::length(rows) == 0L) { + return(empty) + } + + out <- base::do.call(base::rbind, rows) - base::identical(path_info$status, 0L) && - base::identical(path_info$output, install_directory_xcode_cli()) && - base::dir.exists(path_info$output) + # developer_dir() is normalized, so normalize this side too rather than + # relying on the literals happening to already be in canonical form. + active <- developer_dir() + out$active <- !base::is.na(active) & + normalize_developer_dir(out$path) == active + + out <- out[base::order(out$type, out$path), , drop = FALSE] + base::row.names(out) <- NULL + out } #' @rdname xcode-cli @@ -211,10 +472,16 @@ xcode_cli_install <- function(password = base::getOption("macrtools.password"), return(base::invisible(TRUE)) } - if (base::isTRUE(is_xcode_app_installed())) { + # Skipping requires a toolchain that is actually ACTIVE. An Xcode sitting + # on disk unselected provides no compiler, so is_xcode_app_installed(), + # which now means "installed anywhere", must not gate this. + if (base::isTRUE(is_xcode_toolchain_usable())) { if(verbose) { - cli::cli_alert_info("{.pkg macrtools}: Xcode.app IDE is installed.") - cli::cli_text("Skipping the commandline installation.") + cli::cli_alert_info("{.pkg macrtools}: A usable Xcode toolchain is already active.") + cli::cli_bullets(c( + "Location: {.path {developer_dir()}}", + "Skipping the commandline installation." + )) cli::cli_text("") # Add spacing } return(base::invisible(TRUE)) @@ -322,26 +589,26 @@ xcode_cli_available_label <- function() { xcode_cli_uninstall <- function(password = base::getOption("macrtools.password"), verbose = TRUE){ assert_mac() + # Returns FALSE so callers cannot report a removal that never happened. if(base::isFALSE(is_xcode_cli_installed())) { if(verbose) { cli::cli_alert_info("{.pkg macrtools}: Xcode CLI is not installed.") cli::cli_text("") # Add spacing } - return(base::invisible(TRUE)) + return(base::invisible(FALSE)) } - xcli_path <- xcode_cli_path() - - # We should never hit this line of code as the is_xcode_cli_installed() now - # focuses on only CLI. But, we might want to change that. - if(xcli_path == install_directory_xcode_app()) { - cli::cli_alert_warning("{.pkg macrtools}: Full Xcode application detected.") - cli::cli_bullets(c( - "Path: {.path {xcode_cli_path()}}", - "Please uninstall it using the App Store or manually." + # Removing the Command Line Tools invalidates the selection if they are the + # active developer directory, so warn before, not after. + active_dir <- developer_dir() + if (!base::is.na(active_dir) && + base::identical(active_dir, normalize_developer_dir(install_directory_xcode_cli()))) { + cli::cli_alert_warning(base::paste( + "{.pkg macrtools}: The Command Line Tools are the active developer", + "directory. After removal, select another toolchain with", + "{.code xcode_cli_switch()}." )) cli::cli_text("") # Add spacing - return(base::invisible(TRUE)) } if (verbose) { @@ -373,24 +640,68 @@ xcode_cli_uninstall <- function(password = base::getOption("macrtools.password") #' @section Change Xcode CLI Location: -#' If the Xcode Application has been previously installed, the underlying -#' path reported by `xcode-select` may not reflect the Xcode CLI location. -#' The situation can be rectified by using the [xcode_cli_switch()] function, -#' which changes the command line tool directory away from the Xcode application -#' location to the Xcode CLI location. This uses the _default_ Xcode CLI -#' path. +#' The path reported by `xcode-select` may not point at the toolchain you want +#' to build against. [xcode_cli_switch()] changes it, defaulting to the +#' Command Line Tools: #' #' ```sh #' sudo xcode-select --switch /Library/Developer/CommandLineTools #' ``` #' +#' Pass `path` to select something else, using a `path` from +#' [xcode_toolchains()]. The target is validated first, and the selection being +#' replaced is named, since the change is machine-wide and affects every build. +#' +#' Note that `DEVELOPER_DIR` overrides the stored selection, so this has no +#' visible effect while that variable is set. +#' #' If this does not fix the issue, we recommend using the [xcode_cli_reset()] #' function. #' @export #' @rdname xcode-cli -xcode_cli_switch <- function(password = base::getOption("macrtools.password"), verbose = TRUE) { - # The path matches with the default install directory location. - if (xcode_cli_path() == install_directory_xcode_cli()) { +#' @param path Developer directory, or `Xcode.app` bundle, to make active. +#' Defaults to the Command Line Tools location. Use [xcode_toolchains()] to +#' see the available options. +xcode_cli_switch <- function(path = install_directory_xcode_cli(), + password = base::getOption("macrtools.password"), + verbose = TRUE) { + assert_mac() + + target <- normalize_developer_dir(path) + + # The value is deliberately NOT echoed. `path` used to be `password`, so a + # legacy positional call lands here carrying a sudo password. + if (!base::dir.exists(target) || + !base::dir.exists(base::file.path(target, "usr", "bin"))) { + cli::cli_abort(c( + "{.pkg macrtools}: The {.arg path} argument is not a developer directory.", + "i" = "Run {.code xcode_toolchains()} to see the available toolchains." + )) + } + + # DEVELOPER_DIR overrides the stored selection for xcrun and the /usr/bin + # shims, but `xcode-select --switch` writes the STORED selection. Comparing + # against the override would skip a switch that has not happened, so the + # stored value decides whether this is a no-op. + stored <- base::tryCatch(xcode_select_path(), error = function(e) NULL) + current <- if (!base::is.null(stored) && base::identical(stored$status, 0L) && + base::length(stored$output) == 1L && base::nzchar(stored$output)) { + normalize_developer_dir(stored$output) + } else { + NA_character_ + } + + if (base::nzchar(base::Sys.getenv("DEVELOPER_DIR", unset = "")) && verbose) { + cli::cli_alert_warning(base::paste( + "{.pkg macrtools}: {.envvar DEVELOPER_DIR} is set and overrides the", + "stored selection, so this change will not take effect until it is", + "unset." + )) + cli::cli_text("") # Add spacing + } + + # The target is already the stored selection. + if (!base::is.na(current) && base::identical(current, target)) { if (verbose) { cli::cli_alert_info("{.pkg macrtools}: Xcode CLI path is correctly set.") cli::cli_text("") # Add spacing @@ -398,17 +709,19 @@ xcode_cli_switch <- function(password = base::getOption("macrtools.password"), v return(base::invisible(TRUE)) } - # Need to modify the location to the current CLI path + # Switching away from a deliberately selected toolchain is machine-wide and + # affects every build, so say what is being displaced rather than doing it + # quietly. if (verbose) { cli::cli_alert_info("{.pkg macrtools}: Setting the Xcode CLI path.") cli::cli_bullets(c( - "Current path: {.path {xcode_cli_path()}}", - "Target path: {.path {install_directory_xcode_cli()}}" + "Current path: {.path {if (base::is.na(current)) '' else current}}", + "Target path: {.path {target}}" )) cli::cli_text("") # Add spacing } - cmd <- base::paste("xcode-select", "--switch", install_directory_xcode_cli()) + cmd <- base::paste("xcode-select", "--switch", base::shQuote(target)) # Change the directory xcli_switch_status <- shell_execute(cmd, diff --git a/_pkgdown.yml b/_pkgdown.yml index 1093b00..9b9dea6 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -57,8 +57,12 @@ reference: - xcode_cli_switch - xcode_cli_reset - xcode_cli_path + - xcode_cli_version - is_xcode_cli_installed - is_xcode_app_installed + - developer_dir + - is_xcode_toolchain_usable + - xcode_toolchains - xcode_select - xcode_select_path - xcode_select_version diff --git a/man/developer_dir_is_app.Rd b/man/developer_dir_is_app.Rd new file mode 100644 index 0000000..956b2d6 --- /dev/null +++ b/man/developer_dir_is_app.Rd @@ -0,0 +1,24 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/xcode.R +\name{developer_dir_is_app} +\alias{developer_dir_is_app} +\title{Check if a Developer Directory is a Full Xcode} +\usage{ +developer_dir_is_app(dir = developer_dir()) +} +\arguments{ +\item{dir}{Developer directory to test, defaulting to the active one.} +} +\value{ +TRUE if \code{dir} is a full Xcode developer directory, FALSE otherwise +} +\description{ +Check if a Developer Directory is a Full Xcode +} +\details{ +A full Xcode carries the platform bundles; the Command Line Tools do not. +This distinguishes the two without shelling out to \code{xcodebuild}, which is +slow, errors when the Command Line Tools are active, and can stall on the +license agreement. +} +\keyword{internal} diff --git a/man/normalize_developer_dir.Rd b/man/normalize_developer_dir.Rd new file mode 100644 index 0000000..9afc4a2 --- /dev/null +++ b/man/normalize_developer_dir.Rd @@ -0,0 +1,24 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/xcode.R +\name{normalize_developer_dir} +\alias{normalize_developer_dir} +\title{Normalize a Developer Directory Path} +\usage{ +normalize_developer_dir(path) +} +\arguments{ +\item{path}{A bundle path or developer directory.} +} +\value{ +The normalized developer directory. +} +\description{ +Normalize a Developer Directory Path +} +\details{ +\code{xcode-select --switch} accepts either an \code{Xcode.app} bundle or a developer +directory inside one, so both spellings are normalized to the developer +directory. Trailing slashes and symlinks are resolved too, because macOS +reports paths in forms that \code{\link[base:identical]{base::identical()}} will never match. +} +\keyword{internal} diff --git a/man/xcode-app-ide.Rd b/man/xcode-app-ide.Rd index eb38f97..c23a6ec 100644 --- a/man/xcode-app-ide.Rd +++ b/man/xcode-app-ide.Rd @@ -7,22 +7,20 @@ is_xcode_app_installed() } \description{ -Checks to see whether Xcode.app Integrated Developer Environment -(IDE) was installed and is active under the default location. +Checks whether an Xcode.app Integrated Developer Environment (IDE) is +installed anywhere on the system. } \details{ -Checks to see if Xcode.app IDE is active by running: +Installed, not \emph{selected}. Xcode is routinely installed under a versioned +name such as \verb{/Applications/Xcode_26.6.app}, which is what every +GitHub-hosted macOS runner does, and \verb{man xcode-select} documents +non-default locations as supported. Bundles are therefore located by bundle +identifier rather than by path, so a renamed or relocated install is still +found. -\if{html}{\out{
}}\preformatted{xcode-select -p -}\if{html}{\out{
}} - -If it returns: - -\if{html}{\out{
}}\preformatted{/Applications/Xcode.app/Contents/Developer -}\if{html}{\out{
}} - -We consider the full Xcode.app to be \strong{installed} and \strong{selected} as -the command line tools to use. +To ask instead which toolchain is \emph{active}, use \code{\link[=developer_dir]{developer_dir()}}; to ask +whether a compiler can actually be reached, use +\code{\link[=is_xcode_toolchain_usable]{is_xcode_toolchain_usable()}}. } \examples{ # Check if Xcode.app IDE is on the path diff --git a/man/xcode-cli.Rd b/man/xcode-cli.Rd index 7b6b123..11dc1d2 100644 --- a/man/xcode-cli.Rd +++ b/man/xcode-cli.Rd @@ -2,6 +2,7 @@ % Please edit documentation in R/xcode.R \name{is_xcode_cli_installed} \alias{is_xcode_cli_installed} +\alias{xcode_cli_version} \alias{xcode_cli_path} \alias{xcode_cli_install} \alias{xcode_cli_uninstall} @@ -11,6 +12,8 @@ \usage{ is_xcode_cli_installed() +xcode_cli_version() + xcode_cli_path() xcode_cli_install( @@ -24,6 +27,7 @@ xcode_cli_uninstall( ) xcode_cli_switch( + path = install_directory_xcode_cli(), password = base::getOption("macrtools.password"), verbose = TRUE ) @@ -37,6 +41,14 @@ xcode_cli_reset( \item{password}{User password to access \code{sudo}.} \item{verbose}{Display status messages} + +\item{path}{Developer directory, or \code{Xcode.app} bundle, to make active. +Defaults to the Command Line Tools location. Use \code{\link[=xcode_toolchains]{xcode_toolchains()}} to +see the available options.} +} +\value{ +The Command Line Tools version from the package receipt, or +\code{NA_character_} when they are not installed. } \description{ Set of functions that seek to identify whether XCode CLI was installed, @@ -45,11 +57,18 @@ allow XCode CLI to be installed, and removing XCode CLI. \section{Check if XCode CLI is installed}{ -Checks to see if Xcode CLI returns a viable path to the default Xcode CLI -location by checking the output of: +\code{is_xcode_cli_installed()} reports whether the Command Line Tools \emph{package} +is present, by looking for the compiler it installs: -\if{html}{\out{
}}\preformatted{xcode-select -p +\if{html}{\out{
}}\preformatted{/Library/Developer/CommandLineTools/usr/bin/clang }\if{html}{\out{
}} + +This is independent of which developer directory is selected. The previous +implementation compared \code{xcode-select -p} against that path with +\code{identical()}, so it reported \code{FALSE} on every machine where anything else +was active, including the versioned Xcode that CI runners select. + +For the selection, see \code{\link[=developer_dir]{developer_dir()}} and \code{\link[=is_xcode_toolchain_usable]{is_xcode_toolchain_usable()}}. } \section{XCode CLI Installation}{ @@ -142,16 +161,20 @@ app using the following steps: \section{Change Xcode CLI Location}{ -If the Xcode Application has been previously installed, the underlying -path reported by \code{xcode-select} may not reflect the Xcode CLI location. -The situation can be rectified by using the \code{\link[=xcode_cli_switch]{xcode_cli_switch()}} function, -which changes the command line tool directory away from the Xcode application -location to the Xcode CLI location. This uses the \emph{default} Xcode CLI -path. +The path reported by \code{xcode-select} may not point at the toolchain you want +to build against. \code{\link[=xcode_cli_switch]{xcode_cli_switch()}} changes it, defaulting to the +Command Line Tools: \if{html}{\out{
}}\preformatted{sudo xcode-select --switch /Library/Developer/CommandLineTools }\if{html}{\out{
}} +Pass \code{path} to select something else, using a \code{path} from +\code{\link[=xcode_toolchains]{xcode_toolchains()}}. The target is validated first, and the selection being +replaced is named, since the change is machine-wide and affects every build. + +Note that \code{DEVELOPER_DIR} overrides the stored selection, so this has no +visible effect while that variable is set. + If this does not fix the issue, we recommend using the \code{\link[=xcode_cli_reset]{xcode_cli_reset()}} function. } @@ -170,6 +193,8 @@ We use an \emph{R} sanitized \emph{shell} version of: \examples{ # Check if Xcode CLI is installed is_xcode_cli_installed() +# Version of the installed Command Line Tools +xcode_cli_version() # Determine the path location of Xcode CLI xcode_cli_path() } diff --git a/man/xcode-toolchain.Rd b/man/xcode-toolchain.Rd new file mode 100644 index 0000000..bf504d7 --- /dev/null +++ b/man/xcode-toolchain.Rd @@ -0,0 +1,66 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/xcode.R +\name{xcode-toolchain} +\alias{xcode-toolchain} +\alias{developer_dir} +\alias{is_xcode_toolchain_usable} +\alias{xcode_toolchains} +\title{Inspect the Active Xcode Toolchain} +\usage{ +developer_dir() + +is_xcode_toolchain_usable() + +xcode_toolchains() +} +\value{ +The active developer directory, or \code{NA_character_} when nothing +usable is selected. + +TRUE if a usable compiler is reachable, FALSE otherwise + +A data frame with one row per selectable developer directory and +columns \code{path}, \code{type} (\code{"cli"} or \code{"app"}), \code{version} and \code{active}. +} +\description{ +Report which developer directory is active, whether a compiler can actually +be reached through it, and which toolchains are available to switch to. +} +\details{ +\code{developer_dir()} resolves the active developer directory. +\code{DEVELOPER_DIR} overrides the selection stored by \code{xcode-select}, for +\code{xcrun}, the \verb{/usr/bin} shims and \code{xcodebuild} alike, so it is consulted +first. Note that \code{xcode-select --print-path} echoes its value and exits \code{0} +even when the directory does not exist, so the exit status alone proves +nothing; the path is verified before being returned. + +This is the question that matters for building R packages. R is configured +with a bare \code{clang}, which resolves to the \verb{/usr/bin} shim, so whether a +package can be compiled depends on the active developer directory +dispatching successfully, not on where the tools live. + +\verb{xcrun --find clang} resolves through the active directory and honors +\code{DEVELOPER_DIR}. It can, however, exit \code{0} while printing an error for a +degraded bundle, so the returned path is confirmed to exist and be +executable rather than trusting the exit status. +} +\section{Listing the Available Toolchains}{ + + +\code{xcode_toolchains()} reports every developer directory \code{xcode-select} could +be pointed at: the Command Line Tools, plus each installed Xcode bundle +wherever it lives. Pass a \code{path} from this table to \code{\link[=xcode_cli_switch]{xcode_cli_switch()}}. + +Note that this lists \emph{developer directories}, not \code{.xctoolchain} bundles. +Those are a separate axis selected through \code{xcrun --toolchain} and the +\code{TOOLCHAINS} variable, and are mostly Swift snapshots. +} + +\examples{ +# Which developer directory is active +developer_dir() +# Check whether a compiler is actually reachable +is_xcode_toolchain_usable() +# List every toolchain xcode-select could use +xcode_toolchains() +} diff --git a/man/xcode_app_bundles.Rd b/man/xcode_app_bundles.Rd new file mode 100644 index 0000000..eb86e8a --- /dev/null +++ b/man/xcode_app_bundles.Rd @@ -0,0 +1,23 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/xcode.R +\name{xcode_app_bundles} +\alias{xcode_app_bundles} +\title{Locate Every Installed Xcode Bundle} +\usage{ +xcode_app_bundles() +} +\value{ +Paths to the installed Xcode bundles, possibly empty. +} +\description{ +Locate Every Installed Xcode Bundle +} +\details{ +Xcode is routinely installed under a versioned name, which is what every +GitHub-hosted macOS runner does (\verb{/Applications/Xcode_26.6.app}), and +\verb{man xcode-select} documents non-default locations as supported. Spotlight +is queried by bundle identifier so the name and location do not matter, with +a glob fallback for machines where Spotlight indexing is disabled. Time +Machine copies are discarded. +} +\keyword{internal} diff --git a/man/xcode_app_version.Rd b/man/xcode_app_version.Rd new file mode 100644 index 0000000..af79177 --- /dev/null +++ b/man/xcode_app_version.Rd @@ -0,0 +1,18 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/xcode.R +\name{xcode_app_version} +\alias{xcode_app_version} +\title{Version of an Xcode Bundle} +\usage{ +xcode_app_version(bundle) +} +\arguments{ +\item{bundle}{Path to an \code{Xcode.app} bundle.} +} +\value{ +The bundle's short version string, or \code{NA_character_}. +} +\description{ +Version of an Xcode Bundle +} +\keyword{internal} diff --git a/tests/testthat/test-toolchain.R b/tests/testthat/test-toolchain.R index be00985..0948383 100644 --- a/tests/testthat/test-toolchain.R +++ b/tests/testthat/test-toolchain.R @@ -89,13 +89,20 @@ test_that("rtools_install_xcode_cli installs when missing and aborts on failure" mockery::stub(rtools_install_xcode_cli, "cli::cli_alert_info", function(...) NULL) mockery::stub(rtools_install_xcode_cli, "cli::cli_bullets", function(...) NULL) mockery::stub(rtools_install_xcode_cli, "cli::cli_progress_update", function(...) NULL) + # Unusable before the install, usable after, which is what a successful + # install actually looks like. + installed <- FALSE local_mocked_bindings( - is_xcode_app_installed = function() FALSE, + is_xcode_toolchain_usable = function() installed, is_xcode_cli_installed = function() FALSE, - xcode_cli_install = function(...) TRUE + xcode_cli_install = function(...) { + installed <<- TRUE + TRUE + } ) expect_true(rtools_install_xcode_cli("pw", FALSE, FALSE, NULL)) + installed <- FALSE local_mocked_bindings(xcode_cli_install = function(...) FALSE) expect_error(rtools_install_xcode_cli("pw", FALSE, FALSE, NULL), "Failed to install Xcode") }) @@ -186,3 +193,130 @@ test_that("macos_rtools_uninstall handles component failures", { expect_error(macos_rtools_uninstall(verbose = TRUE), "Uninstallation failed") }) + +test_that("rtools_install_xcode_cli skips installing when a toolchain is already usable", { + # Regression: a versioned Xcode.app (what every GitHub runner selects) used + # to be misread as "no toolchain" and triggered a 10-15 minute install of + # Command Line Tools that were already present. + mockery::stub(rtools_install_xcode_cli, "cli::cli_h3", function(...) NULL) + mockery::stub(rtools_install_xcode_cli, "cli::cli_text", function(...) NULL) + mockery::stub(rtools_install_xcode_cli, "cli::cli_ul", function(...) NULL) + mockery::stub(rtools_install_xcode_cli, "cli::cli_alert_info", function(...) NULL) + mockery::stub(rtools_install_xcode_cli, "cli::cli_bullets", function(...) NULL) + mockery::stub(rtools_install_xcode_cli, "cli::cli_progress_update", function(...) NULL) + local_mocked_bindings( + is_xcode_toolchain_usable = function() TRUE, + developer_dir = function() "/Applications/Xcode_26.6.app/Contents/Developer", + xcode_app_bundles = function() "/Applications/Xcode_26.6.app", + xcode_app_version = function(bundle) "26.6", + xcode_cli_install = function(...) stop("must not install when a toolchain is usable") + ) + + expect_true(rtools_install_xcode_cli("pw", FALSE, FALSE, NULL)) +}) + +# Helper: capture the cli message templates emitted by the install step. +capture_xcode_cli_messages <- function(...) { + seen <- base::character(0) + record <- function(...) { + seen <<- c(seen, base::unlist(base::list(...))) + NULL + } + for (fn in c("cli::cli_h3", "cli::cli_text", "cli::cli_ul", + "cli::cli_progress_update")) { + mockery::stub(rtools_install_xcode_cli, fn, function(...) NULL) + } + for (fn in c("cli::cli_alert_info", "cli::cli_alert_warning", + "cli::cli_alert_success", "cli::cli_bullets")) { + mockery::stub(rtools_install_xcode_cli, fn, record) + } + result <- rtools_install_xcode_cli("pw", FALSE, TRUE, NULL) + base::list(result = result, messages = base::paste(seen, collapse = " | ")) +} + +test_that("a CLT-only machine is not reported as having the full Xcode IDE", { + # Regression: the install gate moved to is_xcode_toolchain_usable() but the + # reporting branch still announced "Full Xcode.app IDE is installed" for + # any usable toolchain, including a machine with no Xcode at all. + local_mocked_bindings( + is_xcode_toolchain_usable = function() TRUE, + is_xcode_cli_installed = function() TRUE, + developer_dir = function() "/Library/Developer/CommandLineTools", + developer_dir_is_app = function(...) FALSE, + xcode_cli_version = function() "27.0.0.0.1788430756", + xcode_cli_install = function(...) stop("must not install") + ) + + out <- capture_xcode_cli_messages() + + expect_true(out$result) + expect_no_match(out$messages, "Full Xcode.app IDE", fixed = TRUE) + expect_match(out$messages, "Command Line Tools", fixed = TRUE) +}) + +test_that("an active full Xcode is reported as such, at its real location", { + local_mocked_bindings( + is_xcode_toolchain_usable = function() TRUE, + is_xcode_cli_installed = function() FALSE, + developer_dir = function() "/Applications/Xcode_26.6.app/Contents/Developer", + developer_dir_is_app = function(...) TRUE, + xcode_app_version = function(bundle) "26.6", + xcode_cli_install = function(...) stop("must not install") + ) + + out <- capture_xcode_cli_messages() + + expect_true(out$result) + expect_match(out$messages, "Xcode", fixed = TRUE) +}) + +test_that("CLT on disk with an unusable toolchain is reported as a failure", { + # Regression: this state returned TRUE and printed "Pre-installed, no + # action needed", so the install summary claimed success on a machine that + # could not compile. + local_mocked_bindings( + is_xcode_toolchain_usable = function() FALSE, + is_xcode_cli_installed = function() TRUE, + developer_dir = function() NA_character_, + xcode_cli_install = function(...) stop("reinstalling cannot fix a bad selection") + ) + + out <- capture_xcode_cli_messages() + + expect_false(out$result) + expect_no_match(out$messages, "no action needed", fixed = TRUE) +}) + +test_that("an install that leaves the toolchain unreachable is not reported as success", { + # Installing the Command Line Tools does not guarantee they became + # reachable: a stale DEVELOPER_DIR or selection still shadows them, so the + # component must verify rather than trust the installer's return value. + local_mocked_bindings( + is_xcode_toolchain_usable = function() FALSE, + is_xcode_cli_installed = function() FALSE, + developer_dir = function() "/nonexistent/stale", + xcode_cli_install = function(...) TRUE + ) + + out <- capture_xcode_cli_messages() + + expect_false(out$result) +}) + +test_that("an install that yields a reachable toolchain is reported as success", { + # First call gates the install, the second is the post-install check. + calls <- 0L + local_mocked_bindings( + is_xcode_toolchain_usable = function() { + calls <<- calls + 1L + calls > 1L + }, + is_xcode_cli_installed = function() FALSE, + xcode_cli_install = function(...) TRUE + ) + + out <- capture_xcode_cli_messages() + + expect_true(out$result) + expect_equal(calls, 2L) +}) diff --git a/tests/testthat/test-xcode.R b/tests/testthat/test-xcode.R index eed235e..3df4b5c 100644 --- a/tests/testthat/test-xcode.R +++ b/tests/testthat/test-xcode.R @@ -24,82 +24,65 @@ test_that("xcode_select_path returns correct output", { expect_equal(result$output, "/Library/Developer/CommandLineTools") }) -test_that("is_xcode_cli_installed correctly identifies installed CLI tools", { - # First, mock dependencies for success scenario +test_that("is_xcode_cli_installed reports CLT package presence, not the selection", { + # New semantics: the CLT package either is or is not installed. Which + # developer directory happens to be selected is a different question, + # answered by is_xcode_toolchain_usable(). local_mocked_bindings( assert_mac = function() TRUE, - # Mock successful path detection - xcode_select_path = function() { - base::structure( - base::list( - output = "/Library/Developer/CommandLineTools", - error = "", - status = 0L - ), - class = c("xcodeselect", "cli") - ) - }, - # Mock installation directory exists install_directory_xcode_cli = function() "/Library/Developer/CommandLineTools" ) - mockery::stub(is_xcode_cli_installed, "base::dir.exists", function(path) TRUE) + # The stub is argument-aware so the test pins WHICH path is probed, not + # merely that file.exists() was consulted. + clang <- "/Library/Developer/CommandLineTools/usr/bin/clang" + mockery::stub(is_xcode_cli_installed, "base::file.exists", + function(path) base::identical(path, clang)) expect_true(is_xcode_cli_installed()) - # Now mock failure scenarios + mockery::stub(is_xcode_cli_installed, "base::file.exists", + function(path) base::identical(path, "/some/other/clang")) + expect_false(is_xcode_cli_installed()) +}) + +test_that("is_xcode_cli_installed ignores a non-standard active developer directory", { + # The reported bug: a versioned Xcode.app is selected, CLT is installed, + # and the old exact-equality predicate returned FALSE. local_mocked_bindings( assert_mac = function() TRUE, + install_directory_xcode_cli = function() "/Library/Developer/CommandLineTools", xcode_select_path = function() { base::structure( base::list( - output = "/Library/Developer/CommandLineTools", + output = "/Applications/Xcode_16.4.app/Contents/Developer", error = "", - status = 1L # Non-zero status + status = 0L ), class = c("xcodeselect", "cli") ) } ) + mockery::stub( + is_xcode_cli_installed, "base::file.exists", + function(path) base::identical( + path, "/Library/Developer/CommandLineTools/usr/bin/clang" + ) + ) - expect_false(is_xcode_cli_installed()) + expect_true(is_xcode_cli_installed()) }) -test_that("is_xcode_app_installed correctly identifies installed Xcode app", { - # Mock dependencies for success scenario +test_that("is_xcode_app_installed accepts any Xcode bundle location", { local_mocked_bindings( assert_mac = function() TRUE, - # Mock successful path detection - xcode_select_path = function() { - base::structure( - base::list( - output = "/Applications/Xcode.app/Contents/Developer", - error = "", - status = 0L - ), - class = c("xcodeselect", "cli") - ) - }, - # Mock installation directory exists - install_directory_xcode_app = function() "/Applications/Xcode.app/Contents/Developer" + xcode_app_bundles = function() "/Applications/Xcode_16.4.app" ) - mockery::stub(is_xcode_app_installed, "base::dir.exists", function(path) TRUE) - expect_true(is_xcode_app_installed()) - # Now mock failure scenarios local_mocked_bindings( - xcode_select_path = function() { - base::structure( - base::list( - output = "/Library/Developer/CommandLineTools", # Wrong path - error = "", - status = 0L - ), - class = c("xcodeselect", "cli") - ) - } + assert_mac = function() TRUE, + xcode_app_bundles = function() base::character(0) ) - expect_false(is_xcode_app_installed()) }) @@ -164,3 +147,340 @@ test_that("xcode_cli_install skips when Xcode app is installed", { result <- xcode_cli_install(verbose = TRUE) expect_true(result) }) + +test_that("normalize_developer_dir expands a bundle path and strips trailing slashes", { + expect_equal( + normalize_developer_dir("/Applications/Xcode_16.4.app"), + "/Applications/Xcode_16.4.app/Contents/Developer" + ) + expect_equal( + normalize_developer_dir("/Library/Developer/CommandLineTools/"), + "/Library/Developer/CommandLineTools" + ) + # An already-expanded developer directory is left alone. + expect_equal( + normalize_developer_dir("/Applications/Xcode.app/Contents/Developer"), + "/Applications/Xcode.app/Contents/Developer" + ) +}) + +test_that("normalize_developer_dir is vectorized", { + # xcode_toolchains() normalizes a whole column at once, so a scalar-only + # implementation errors with "the condition has length > 1". + expect_equal( + normalize_developer_dir(c( + "/Applications/Xcode_16.4.app", + "/Library/Developer/CommandLineTools", + "/Applications/Xcode.app/Contents/Developer" + )), + c( + "/Applications/Xcode_16.4.app/Contents/Developer", + "/Library/Developer/CommandLineTools", + "/Applications/Xcode.app/Contents/Developer" + ) + ) + expect_equal(normalize_developer_dir(base::character(0)), base::character(0)) +}) + +test_that("developer_dir prefers DEVELOPER_DIR over the stored selection", { + # DEVELOPER_DIR overrides xcode-select for xcrun and the /usr/bin shims, + # so it must win here too. + local_mocked_bindings( + xcode_select_path = function() { + base::structure( + base::list(output = "/Library/Developer/CommandLineTools", + error = "", status = 0L), + class = c("xcodeselect", "cli") + ) + } + ) + mockery::stub(developer_dir, "base::Sys.getenv", + function(...) "/Applications/Xcode_16.4.app/Contents/Developer") + mockery::stub(developer_dir, "base::dir.exists", function(path) TRUE) + + expect_equal(developer_dir(), "/Applications/Xcode_16.4.app/Contents/Developer") +}) + +test_that("developer_dir falls back to the stored selection and is NA when unusable", { + local_mocked_bindings( + xcode_select_path = function() { + base::structure( + base::list(output = "/Applications/Xcode_26.6.app/Contents/Developer", + error = "", status = 0L), + class = c("xcodeselect", "cli") + ) + } + ) + mockery::stub(developer_dir, "base::Sys.getenv", function(...) "") + mockery::stub(developer_dir, "base::dir.exists", function(path) TRUE) + expect_equal(developer_dir(), "/Applications/Xcode_26.6.app/Contents/Developer") + + # xcode-select echoes a path and exits 0 even when it does not exist, so a + # stale selection must resolve to NA rather than a bogus path. + mockery::stub(developer_dir, "base::dir.exists", function(path) FALSE) + expect_true(base::is.na(developer_dir())) +}) + +test_that("developer_dir is NA when nothing is selected", { + local_mocked_bindings( + xcode_select_path = function() { + base::structure( + base::list(output = "", error = "unable to get active developer directory", + status = 2L), + class = c("xcodeselect", "cli") + ) + } + ) + mockery::stub(developer_dir, "base::Sys.getenv", function(...) "") + expect_true(base::is.na(developer_dir())) +}) + +test_that("xcode_cli_version reads the pkgutil receipt", { + local_mocked_bindings( + is_xcode_cli_installed = function() TRUE, + exec_text = function(...) c( + "package-id: com.apple.pkg.CLTools_Executables", + "version: 27.0.0.0.1788430756", + "volume: /" + ) + ) + expect_equal(xcode_cli_version(), "27.0.0.0.1788430756") + + local_mocked_bindings(is_xcode_cli_installed = function() FALSE) + expect_true(base::is.na(xcode_cli_version())) +}) + +test_that("xcode_app_bundles drops Time Machine hits and bundles without a developer dir", { + local_mocked_bindings( + exec_text = function(...) c( + "/Applications/Xcode.app", + "/Volumes/TM/Backups.backupdb/mac/2026-01-01/Applications/Xcode.app", + "/Applications/Broken.app" + ) + ) + mockery::stub(xcode_app_bundles, "base::Sys.glob", function(...) base::character(0)) + mockery::stub(xcode_app_bundles, "base::dir.exists", + function(path) !base::grepl("Broken", path)) + mockery::stub(xcode_app_bundles, "base::normalizePath", function(path, ...) path) + + expect_equal(xcode_app_bundles(), "/Applications/Xcode.app") +}) + +test_that("is_xcode_toolchain_usable requires an executable compiler, not just exit 0", { + # xcrun can exit 0 while printing an error for a degraded bundle, so the + # returned path must be confirmed to exist and be executable. + local_mocked_bindings( + exec_text = function(...) "/Applications/Xcode.app/Contents/Developer/usr/bin/clang" + ) + mockery::stub(is_xcode_toolchain_usable, "base::file.exists", function(path) TRUE) + mockery::stub(is_xcode_toolchain_usable, "base::file.access", function(...) 0L) + expect_true(is_xcode_toolchain_usable()) + + mockery::stub(is_xcode_toolchain_usable, "base::file.exists", function(path) FALSE) + expect_false(is_xcode_toolchain_usable()) +}) + +test_that("is_xcode_toolchain_usable is FALSE when xcrun fails", { + local_mocked_bindings(exec_text = function(..., fallback = NA_character_) NA_character_) + expect_false(is_xcode_toolchain_usable()) +}) + +test_that("xcode_toolchains lists CLT and every Xcode bundle, flagging the active one", { + local_mocked_bindings( + assert_mac = function() TRUE, + is_xcode_cli_installed = function() TRUE, + xcode_cli_version = function() "27.0.0.0.1788430756", + install_directory_xcode_cli = function() "/Library/Developer/CommandLineTools", + xcode_app_bundles = function() c("/Applications/Xcode.app", "/Applications/Xcode_16.4.app"), + xcode_app_version = function(bundle) if (base::grepl("16.4", bundle)) "16.4" else "27.0", + developer_dir = function() "/Applications/Xcode_16.4.app/Contents/Developer" + ) + + out <- xcode_toolchains() + + expect_s3_class(out, "data.frame") + expect_equal(base::nrow(out), 3L) + expect_equal(base::names(out), c("path", "type", "version", "active")) + expect_equal(base::sum(out$active), 1L) + expect_equal(out$path[out$active], "/Applications/Xcode_16.4.app/Contents/Developer") + expect_true("cli" %in% out$type) + expect_equal(out$version[out$type == "cli"], "27.0.0.0.1788430756") +}) + +test_that("xcode_toolchains returns a zero-row frame when nothing is installed", { + local_mocked_bindings( + assert_mac = function() TRUE, + is_xcode_cli_installed = function() FALSE, + xcode_app_bundles = function() base::character(0), + developer_dir = function() NA_character_ + ) + + out <- xcode_toolchains() + expect_s3_class(out, "data.frame") + expect_equal(base::nrow(out), 0L) + expect_equal(base::names(out), c("path", "type", "version", "active")) +}) + +test_that("xcode_cli_switch rejects a target that is not a developer directory", { + local_mocked_bindings( + assert_mac = function() TRUE, + shell_execute = function(...) stop("shell_execute must not run for an invalid target") + ) + mockery::stub(xcode_cli_switch, "base::dir.exists", function(path) FALSE) + + expect_error( + xcode_cli_switch("/Applications/NotXcode.app", verbose = FALSE), + regexp = "not a developer directory" + ) +}) + +test_that("xcode_cli_switch is a no-op when the target is already the stored selection", { + local_mocked_bindings( + assert_mac = function() TRUE, + xcode_select_path = function() { + base::structure( + base::list(output = "/Library/Developer/CommandLineTools", error = "", status = 0L), + class = c("xcodeselect", "cli") + ) + }, + shell_execute = function(...) stop("shell_execute must not run for a no-op switch") + ) + mockery::stub(xcode_cli_switch, "base::dir.exists", function(path) TRUE) + + expect_true(xcode_cli_switch("/Library/Developer/CommandLineTools", verbose = FALSE)) +}) + +test_that("xcode_cli_switch still switches when only DEVELOPER_DIR matches the target", { + # DEVELOPER_DIR overrides xcrun and the shims, but --switch writes the + # STORED selection. Treating the override as "already active" would skip a + # switch that never happened, and unsetting the variable would revert. + commands <- base::character(0) + local_mocked_bindings( + assert_mac = function() TRUE, + developer_dir = function() "/Library/Developer/CommandLineTools", + xcode_select_path = function() { + base::structure( + base::list(output = "/Applications/Xcode.app/Contents/Developer", error = "", status = 0L), + class = c("xcodeselect", "cli") + ) + }, + shell_execute = function(cmd, ...) { + commands <<- c(commands, cmd) + 0L + } + ) + mockery::stub(xcode_cli_switch, "base::dir.exists", function(path) TRUE) + + expect_true(xcode_cli_switch("/Library/Developer/CommandLineTools", verbose = FALSE)) + expect_length(commands, 1L) +}) + +test_that("xcode_cli_switch does not echo its path argument, which may be a password", { + # `path` took `password`'s position, so a legacy positional call arrives + # here carrying a sudo password. It must never reach the error message. + local_mocked_bindings( + assert_mac = function() TRUE, + shell_execute = function(...) stop("must not run for an invalid target") + ) + mockery::stub(xcode_cli_switch, "base::dir.exists", function(path) FALSE) + + err <- base::tryCatch( + xcode_cli_switch("sup3rs3cret", verbose = FALSE), + error = function(e) base::conditionMessage(e) + ) + expect_no_match(err, "sup3rs3cret", fixed = TRUE) + expect_match(err, "not a developer directory", fixed = TRUE) +}) + +test_that("xcode_cli_switch rejects an existing directory that is not a developer dir", { + local_mocked_bindings( + assert_mac = function() TRUE, + shell_execute = function(...) stop("must not run for an invalid target") + ) + # The directory exists, but has no usr/bin beneath it. + mockery::stub(xcode_cli_switch, "base::dir.exists", + function(path) !base::grepl("usr/bin", path, fixed = TRUE)) + + expect_error(xcode_cli_switch("/tmp", verbose = FALSE), "not a developer directory") +}) + +test_that("xcode_cli_switch accepts an explicit non-CLT target", { + commands <- base::character(0) + local_mocked_bindings( + assert_mac = function() TRUE, + xcode_select_path = function() { + base::structure( + base::list(output = "/Library/Developer/CommandLineTools", error = "", status = 0L), + class = c("xcodeselect", "cli") + ) + }, + shell_execute = function(cmd, ...) { + commands <<- c(commands, cmd) + 0L + } + ) + mockery::stub(xcode_cli_switch, "base::dir.exists", function(path) TRUE) + + expect_true(xcode_cli_switch("/Applications/Xcode_16.4.app", verbose = FALSE)) + expect_length(commands, 1L) + # The target is shell-quoted so a path containing spaces cannot split. + expect_match( + commands[1], + base::paste("--switch", base::shQuote("/Applications/Xcode_16.4.app/Contents/Developer")), + fixed = TRUE + ) +}) + +test_that("xcode_cli_switch quotes a target containing spaces", { + commands <- base::character(0) + local_mocked_bindings( + assert_mac = function() TRUE, + xcode_select_path = function() { + base::structure( + base::list(output = "/Library/Developer/CommandLineTools", error = "", status = 0L), + class = c("xcodeselect", "cli") + ) + }, + shell_execute = function(cmd, ...) { + commands <<- c(commands, cmd) + 0L + } + ) + mockery::stub(xcode_cli_switch, "base::dir.exists", function(path) TRUE) + mockery::stub(xcode_cli_switch, "base::normalizePath", function(path, ...) path) + + xcode_cli_switch("/Volumes/My Disk/Xcode.app", verbose = FALSE) + + expect_match(commands[1], "'/Volumes/My Disk/Xcode.app/Contents/Developer'", fixed = TRUE) +}) + +test_that("xcode_cli_uninstall never derives its removal target from the selection", { + # Safety invariant: the rm target is a literal. A non-standard or versioned + # Xcode selection must never appear in a destructive command. + commands <- base::character(0) + local_mocked_bindings( + assert_mac = function() TRUE, + is_xcode_cli_installed = function() TRUE, + developer_dir = function() "/Applications/Xcode_16.4.app/Contents/Developer", + shell_execute = function(cmd, ...) { + commands <<- c(commands, cmd) + 0L + } + ) + + xcode_cli_uninstall(verbose = FALSE) + + expect_length(commands, 1L) + expect_equal(commands[1], "rm -rf /Library/Developer/CommandLineTools") + expect_false(base::any(base::grepl("Xcode_16.4", commands))) +}) + +test_that("xcode_cli_uninstall reports not-installed rather than success", { + local_mocked_bindings( + assert_mac = function() TRUE, + is_xcode_cli_installed = function() FALSE, + shell_execute = function(...) stop("must not run when CLT is absent") + ) + # FALSE means "nothing was removed", so callers cannot report success. + expect_false(xcode_cli_uninstall(verbose = FALSE)) +}) From 0628dc9df315315bdb5bea9b3a93c31287c97f69 Mon Sep 17 00:00:00 2001 From: "james.balamuta@gmail.com" Date: Wed, 16 Sep 2026 17:51:29 -0500 Subject: [PATCH 3/4] Keep the path-normalization tests independent of the filesystem --- tests/testthat/test-xcode.R | 59 +++++++++++++++++++++++++++---------- 1 file changed, 43 insertions(+), 16 deletions(-) diff --git a/tests/testthat/test-xcode.R b/tests/testthat/test-xcode.R index 3df4b5c..22afb69 100644 --- a/tests/testthat/test-xcode.R +++ b/tests/testthat/test-xcode.R @@ -149,39 +149,66 @@ test_that("xcode_cli_install skips when Xcode app is installed", { }) test_that("normalize_developer_dir expands a bundle path and strips trailing slashes", { + # Deliberately under a root that cannot exist. normalizePath() resolves + # symlinks for real paths, and on CI runners /Applications/Xcode.app is a + # symlink to the versioned default, so using real paths here would assert + # the filesystem rather than this function's own logic. + root <- "/nonexistent-macrtools-test" + expect_equal( - normalize_developer_dir("/Applications/Xcode_16.4.app"), - "/Applications/Xcode_16.4.app/Contents/Developer" + normalize_developer_dir(base::file.path(root, "Xcode_16.4.app")), + base::file.path(root, "Xcode_16.4.app/Contents/Developer") ) expect_equal( - normalize_developer_dir("/Library/Developer/CommandLineTools/"), - "/Library/Developer/CommandLineTools" + normalize_developer_dir(base::file.path(root, "CommandLineTools/")), + base::file.path(root, "CommandLineTools") ) # An already-expanded developer directory is left alone. expect_equal( - normalize_developer_dir("/Applications/Xcode.app/Contents/Developer"), - "/Applications/Xcode.app/Contents/Developer" + normalize_developer_dir(base::file.path(root, "Xcode.app/Contents/Developer")), + base::file.path(root, "Xcode.app/Contents/Developer") ) }) test_that("normalize_developer_dir is vectorized", { # xcode_toolchains() normalizes a whole column at once, so a scalar-only # implementation errors with "the condition has length > 1". + root <- "/nonexistent-macrtools-test" + expect_equal( - normalize_developer_dir(c( - "/Applications/Xcode_16.4.app", - "/Library/Developer/CommandLineTools", - "/Applications/Xcode.app/Contents/Developer" - )), - c( - "/Applications/Xcode_16.4.app/Contents/Developer", - "/Library/Developer/CommandLineTools", - "/Applications/Xcode.app/Contents/Developer" - ) + normalize_developer_dir(base::file.path(root, c( + "Xcode_16.4.app", + "CommandLineTools", + "Xcode.app/Contents/Developer" + ))), + base::file.path(root, c( + "Xcode_16.4.app/Contents/Developer", + "CommandLineTools", + "Xcode.app/Contents/Developer" + )) ) expect_equal(normalize_developer_dir(base::character(0)), base::character(0)) }) +test_that("normalize_developer_dir resolves a symlinked bundle", { + # This is what makes exact path equality unusable: a runner's + # /Applications/Xcode.app is a symlink to the versioned bundle it defaults + # to, so the two spellings must compare equal after normalization. + scratch <- base::file.path(base::tempdir(), "macrtools-symlink-test") + base::dir.create(base::file.path(scratch, "Real.app", "Contents", "Developer"), + recursive = TRUE, showWarnings = FALSE) + on.exit(base::unlink(scratch, recursive = TRUE), add = TRUE) + + link <- base::file.path(scratch, "Link.app") + skip_if_not(base::file.symlink(base::file.path(scratch, "Real.app"), link), + "could not create a symlink") + + expect_equal( + normalize_developer_dir(link), + normalize_developer_dir(base::file.path(scratch, "Real.app")) + ) +}) + test_that("developer_dir prefers DEVELOPER_DIR over the stored selection", { # DEVELOPER_DIR overrides xcode-select for xcrun and the /usr/bin shims, # so it must win here too. From a48ba0e7978b0c6d0c0b3fbd09367bb012ca3232 Mon Sep 17 00:00:00 2001 From: "james.balamuta@gmail.com" Date: Wed, 16 Sep 2026 18:12:44 -0500 Subject: [PATCH 4/4] Consolidate the unreleased NEWS into 0.0.8 --- DESCRIPTION | 2 +- NEWS.md | 212 +++++++++++++--------------------------------------- 2 files changed, 53 insertions(+), 161 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index b7d1c45..25272b3 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: macrtools Title: macOS Rtools package -Version: 0.0.9 +Version: 0.0.8 Authors@R: c( person(given = "James Joseph", family = "Balamuta", diff --git a/NEWS.md b/NEWS.md index d4e25a9..08a9ece 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,177 +1,69 @@ -# macrtools 0.0.9 - -## Bug Fixes +# macrtools 0.0.8 -- Fixed Xcode toolchain detection when the tools are not in the default - location. `is_xcode_cli_installed()` and `is_xcode_app_installed()` compared - the active developer directory against a single hardcoded path with - `identical()`, so both returned `FALSE` whenever anything else was selected, - even with a perfectly usable toolchain installed: - - ```r - is_xcode_cli_installed() - #> [1] FALSE - xcode_cli_path() - #> [1] "/Applications/Xcode_16.4.app/Contents/Developer" - ``` - - A versioned Xcode bundle like that is the normal multi-Xcode layout, and it - is what every GitHub-hosted macOS runner selects, so this affected the - package's own CI. `macos_rtools_install()` treated the two predicates as - exhaustive and would start a 10-15 minute `softwareupdate` install of Command - Line Tools that were already present. - -- `xcode_cli_uninstall()` now returns `FALSE` when the Command Line Tools were - not installed, instead of `TRUE`. `macos_rtools_uninstall()` reported "Xcode - CLI: Successfully removed" on that path even though nothing had been removed. - The summary now distinguishes a removal from a component that was absent. - -- `macos_rtools_install()` no longer reports the output of - `xcode-select --version` as the Command Line Tools version. That command - reports the version of `xcode-select` itself and never changes with the - toolchain. The version now comes from the package receipt. +## Breaking changes -## Features +- `is_xcode_cli_installed()` now reports whether the Command Line Tools package + is installed, rather than whether it is the selected developer directory. Use + `developer_dir()` or `is_xcode_toolchain_usable()` to ask about the selection. -- Added `xcode_toolchains()`, which lists every developer directory - `xcode-select` could be pointed at: the Command Line Tools plus each - installed Xcode bundle, with its version and whether it is active. - - ```r - xcode_toolchains() - #> path type version active - #> 1 /Applications/Xcode.app/Contents/Developer app 27.0 TRUE - #> 2 /Library/Developer/CommandLineTools cli 27.0.0.0.1788430756 FALSE - ``` - - Xcode bundles are found by bundle identifier through Spotlight, so a renamed - or relocated install is still detected. A `/Applications/Xcode*.app` glob - covers machines where Spotlight indexing is disabled, which catches the - versioned names but not installs outside `/Applications`. - This lists developer directories, not `.xctoolchain` bundles, which are a - separate axis selected via `xcrun --toolchain` and `TOOLCHAINS`. - -- Added `is_xcode_toolchain_usable()`, which reports whether a compiler is - actually reachable via `xcrun --find clang`. This is the condition that - determines whether R can build a package, since R is configured with a bare - `clang` that resolves through the active developer directory. It is now what - gates the install step. - -- Added `developer_dir()`, which resolves the active developer directory and - honors `DEVELOPER_DIR`. That variable overrides the `xcode-select` selection - for `xcrun`, the `/usr/bin` shims and `xcodebuild`, and none of the package's - detection accounted for it. - -- Added `xcode_cli_version()`, reading the real Command Line Tools version from - the `com.apple.pkg.CLTools_Executables` receipt. - -- `xcode_cli_switch()` now takes a `path`, so a specific toolchain from - `xcode_toolchains()` can be selected rather than always forcing the Command - Line Tools. The target is validated before use, the displaced selection is - named, and a set `DEVELOPER_DIR` is flagged because it silently overrides the - stored selection. - -- `macos_rtools_install()` now reports which toolchain is active rather than - assuming it is a full Xcode at `/Applications/Xcode.app`, and reports a - failure, instead of "no action needed", when the Command Line Tools are on - disk but the active developer directory is stale so nothing can compile. It - also verifies that a compiler is reachable *after* installing, rather than - trusting the installer's return value, since a stale selection or a - `DEVELOPER_DIR` override still shadows freshly installed tools. - -- `xcode_cli_install()` no longer skips the installation merely because an - Xcode bundle exists somewhere on disk. An unselected Xcode provides no - compiler, so the skip now requires a toolchain that is actually active. - -## Breaking Changes - -- `is_xcode_cli_installed()` now reports whether the Command Line Tools - **package** is installed, rather than whether it is the selected developer - directory. Selection is a separate question, answered by `developer_dir()` - and `is_xcode_toolchain_usable()`. The split is deliberate: the destructive - path in `xcode_cli_uninstall()` must stay keyed on package presence, because - widening it to mean "a usable toolchain is selected" would let a machine with - Xcode selected delete the Command Line Tools. - -- `is_xcode_app_installed()` now reports whether any Xcode bundle is installed - anywhere, rather than whether `/Applications/Xcode.app` is the selected - developer directory. +- `is_xcode_app_installed()` now reports whether any Xcode is installed + anywhere, rather than whether `/Applications/Xcode.app` is selected. - `xcode_cli_switch()` takes `path` as its first argument, where `password` - used to be. A positional call such as `xcode_cli_switch(pw)` now fails with a - clear error rather than switching. + used to be. -## Internal +## New features -- Path comparisons are normalized through `normalize_developer_dir()`, which - expands an `.app` bundle to its developer directory, strips trailing slashes - and resolves symlinks. macOS reports these paths in forms that `identical()` - would never match, for instance `/tmp` against `/private/tmp`. -- Added tests covering non-standard, versioned and relocated toolchain layouts, - plus a test pinning the invariant that `xcode_cli_uninstall()` never derives - its removal target from the active selection. -- The `xcode-select --switch` target is shell-quoted, so the caller-supplied - `path` cannot split on spaces. `xcode_cli_switch()` also declines to echo - that argument in its error message, because it took `password`'s position and - a legacy positional call would otherwise print a sudo password. +- Added support for macOS 27 (Golden Gate). -# macrtools 0.0.8 +- `xcode_toolchains()` lists every developer directory `xcode-select` can be + pointed at, with its version and whether it is active. -## Features +- `is_xcode_toolchain_usable()` reports whether a compiler is reachable through + the active developer directory, which is what decides whether R can build a + package. -- Added support for macOS Golden Gate (27.x). +- `developer_dir()` resolves the active developer directory, honoring + `DEVELOPER_DIR`. -## Bug Fixes +- `xcode_cli_version()` reports the installed Command Line Tools version. -- Fixed the macOS version check so that macOS 27 is recognized as supported. - `is_macos_r_supported()` compares against an *exclusive* upper bound, so the - previous value of `"27.0"` rejected every macOS 27 release, including 27.0 - itself. On macOS 27 this aborted `macos_rtools_install()`, `gfortran_install()` - and `openmp_install()`, and printed an "unsupported" warning on attach. - This is the same off-by-one that affected Tahoe - ([#28](https://github.com/coatless-mac/macrtools/issues/28)). - -- Fixed macOS version comparisons for `.0` releases. `sw_vers -productVersion` - reports only two components for a `.0` release (macOS 27.0 reports `"27.0"`, - never `"27.0.0"`), and `utils::compareVersion()` ranks a shorter string below - an otherwise-equal longer one. Comparing those against three-component bounds - placed every `.0` release in the *previous* release's range: `"12.0"` was - reported as Big Sur rather than Monterey, `"11.0"` matched nothing at all, and - `"10.13"` was rejected as unsupported by an error message that named 10.13 as - supported. Versions and bounds are now padded to three components before - comparison. +- `xcode_cli_switch()` gained a `path` argument, so a toolchain from + `xcode_toolchains()` can be selected instead of always forcing the Command + Line Tools. -## Internal +## Bug fixes + +- Xcode is detected outside its default location. Both predicates compared the + active developer directory against a hardcoded path, so a versioned bundle + such as `/Applications/Xcode_16.4.app` reported nothing installed and + triggered a needless `softwareupdate` run. + +- macOS 27 is recognized as supported. The version ceiling is exclusive, so the + previous bound rejected every 27.x release and aborted + `macos_rtools_install()`, `gfortran_install()` and `openmp_install()`. + +- macOS versions ending in `.0` compare correctly. `sw_vers` reports only two + components for those releases, which placed each one in the previous + release's range: `"12.0"` read as Big Sur, `"11.0"` matched nothing, and + `"10.13"` was rejected as unsupported. + +- `xcode_cli_install()` no longer skips installation when an unselected Xcode + exists elsewhere on disk, since it provides no compiler. + +- `macos_rtools_install()` reports which toolchain is active instead of assuming + a full Xcode, and reports a failure rather than success when nothing can + compile. + +- `macos_rtools_uninstall()` no longer reports a component as removed when it + was not installed. + +- The Command Line Tools version no longer comes from `xcode-select --version`, + which reports the version of `xcode-select` itself. -- Centralized the macOS support window behind `minimum_supported_macos_version()` - and `first_unsupported_macos_version()`, mirroring the R version window added - in 0.0.7. The ceiling is now named for what it is, the first *rejected* - version, which is the distinction both previous off-by-one bugs got wrong. -- The supported range shown by `assert_macos_supported()` and the startup - message is now rendered by a single `macos_support_range()` helper that - derives the version number from the bound. Previously the range was written - out as a literal in two files, with different wording, and neither was tested. -- Added upper-bound tests for `is_macos_r_supported()`. The suite previously - exercised only the lower bound, which is why both off-by-one bugs shipped - without a test failure. Added a test asserting that exactly one named - `is_macos_*()` predicate matches any given release, so a new release cannot be - swallowed by its predecessor's range. -- Added `is_macos_golden_gate()` for 27.x. -- Added `pad_version()` to normalize version strings before comparison, and - extended the predicate test to cover the two-component `.0` form that - `sw_vers` actually emits. -- Corrected the `recipes_binary_install()` documentation, which stated that the - `darwin` repository is chosen by comparing against the macOS version. It is - chosen by comparing against the Darwin kernel version, and the two are no - longer a fixed offset apart: Darwin stayed on 25 for macOS 26, then realigned - to 27 for macOS 27, skipping 26. -- Noted in `R/openmp.R` that Apple renumbered Apple clang from 17.0.0 - (`clang-1700.x`) to 21.0.0 (`clang-2100.x`) at Xcode 26.4, so Xcode 26.4 and - newer extrapolate onto the LLVM OpenMP 19.1.5 row. Upstream documents that - runtime only through Xcode 26.3, but 19.1.5 remains the newest build published - on mac.r-project.org, so the selection is unchanged and still correct. -- Bumped the declared `testthat` minimum to 3.1.7, the version that introduced - `local_mocked_bindings()`, which the suite already relies on throughout. +- `recipes_binary_install()` documentation now states that the `darwin` + repository is chosen by Darwin kernel version, not macOS version. The two are + no longer a fixed offset apart. # macrtools 0.0.7