From 001bf08a2cf5468db588911d8e21ecbeebf1d3ee Mon Sep 17 00:00:00 2001 From: Artem Efimov <41889057+efim0v@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:52:30 +0500 Subject: [PATCH] Don't fail when no Xcode is selected `xcode-select -p` exits with status 2 when no developer directory is selected, e.g. on a new Mac without Xcode or the Command Line Tools. `list`, `update`, `installed` and `select` (also used by `install --select`) all ran it and aborted on that error. Treat that failure as "no selected Xcode" instead: the lists are printed without a selected marker, `select -p` prints "No selected Xcode", and `select` goes on to select the requested Xcode. --- Sources/XcodesKit/Environment.swift | 11 ++ Sources/XcodesKit/XcodeInstaller.swift | 8 +- Sources/XcodesKit/XcodeSelect.swift | 15 +-- Tests/XcodesKitTests/XcodesKitTests.swift | 129 ++++++++++++++++++++++ 4 files changed, 152 insertions(+), 11 deletions(-) diff --git a/Sources/XcodesKit/Environment.swift b/Sources/XcodesKit/Environment.swift index 3b5a637b..2a0fe5b2 100644 --- a/Sources/XcodesKit/Environment.swift +++ b/Sources/XcodesKit/Environment.swift @@ -94,6 +94,17 @@ public struct Shell: Sendable { } public var xcodeSelectPrintPath = Shell.shared.xcodeSelectPrintPath + /// Returns the path printed by `xcode-select -p`, or `nil` if no developer directory is selected. + /// + /// `xcode-select -p` fails when no developer directory is selected, + /// e.g. on a Mac that doesn't have Xcode or the Command Line Tools installed yet. + public func selectedXcodePath() async throws -> String? { + do { + return try await xcodeSelectPrintPath().out + } catch is ProcessExecutionError { + return nil + } + } public var xcodeSelectSwitch = Shell.shared.xcodeSelectSwitch public var isRoot: @Sendable () -> Bool = { NSUserName() == "root" } diff --git a/Sources/XcodesKit/XcodeInstaller.swift b/Sources/XcodesKit/XcodeInstaller.swift index f829d219..ec4b9d43 100644 --- a/Sources/XcodesKit/XcodeInstaller.swift +++ b/Sources/XcodesKit/XcodeInstaller.swift @@ -498,7 +498,7 @@ public final class XcodeInstaller: Sendable { } public func printAvailableXcodes(_ xcodes: [Xcode], installed installedXcodes: [InstalledXcode], dataSource: DataSource = .xcodeReleases, architectures: [ArchitectureFilter] = []) async throws { - let output = try await Current.shell.xcodeSelectPrintPath() + let selectedXcodePath = try await Current.shell.selectedXcodePath() let machineArchitecture = Current.shell.machineArchitecture() let effectiveArchitectures = architectures.isEmpty ? [ArchitectureFilter].defaultForMachine(machineHardwareName: machineArchitecture) @@ -508,7 +508,7 @@ public final class XcodeInstaller: Sendable { .availableRows( availableXcodes: xcodes, installedXcodes: installedXcodes, - selectedXcodePath: output.out, + selectedXcodePath: selectedXcodePath, dataSource: dataSource, architectures: effectiveArchitectures ) @@ -530,12 +530,12 @@ public final class XcodeInstaller: Sendable { } public func printInstalledXcodes(directory: Path) async throws { - let pathOutput = try await Current.shell.xcodeSelectPrintPath() + let selectedXcodePath = try await Current.shell.selectedXcodePath() let selectedString = "(Selected)" let presentationService = XcodeListPresentationService() let rows = presentationService.installedRows( installedXcodes: Current.files.installedXcodes(directory), - selectedXcodePath: pathOutput.out + selectedXcodePath: selectedXcodePath ) for line in presentationService.installedLines(rows: rows, interactive: Current.shell.isatty(), selectedMarker: selectedString) { diff --git a/Sources/XcodesKit/XcodeSelect.swift b/Sources/XcodesKit/XcodeSelect.swift index 42ae96c9..2ac35429 100644 --- a/Sources/XcodesKit/XcodeSelect.swift +++ b/Sources/XcodesKit/XcodeSelect.swift @@ -5,11 +5,11 @@ import Rainbow import XcodesKit public func selectXcodeAsync(shouldPrint: Bool, pathOrVersion: String, directory: Path, fallbackToInteractive: Bool = true) async throws { - let output = try await Current.shell.xcodeSelectPrintPath() + let selectedXcodePath = try await Current.shell.selectedXcodePath() if shouldPrint { - if output.out.isEmpty == false { - Current.logging.log(output.out) + if let selectedXcodePath, selectedXcodePath.isEmpty == false { + Current.logging.log(selectedXcodePath) } else { Current.logging.log("No selected Xcode") } @@ -26,7 +26,7 @@ public func selectXcodeAsync(shouldPrint: Bool, pathOrVersion: String, directory switch selectionService.request( pathOrVersion: pathOrVersion, installedXcodes: installedXcodes, - selectedXcodePath: output.out + selectedXcodePath: selectedXcodePath ?? "" ) { case let .alreadySelectedVersion(version): Current.logging.log("Xcode \(version) is already selected".green) @@ -37,18 +37,19 @@ public func selectXcodeAsync(shouldPrint: Bool, pathOrVersion: String, directory Current.logging.log("Selected \(selectedOutput.out)".green) Current.shell.exit(0) return - case .alreadySelectedPath: + case .alreadySelectedPath where selectedXcodePath != nil: Current.logging.log("Xcode at path \(pathOrVersion) is already selected".green) Current.shell.exit(0) return - case let .selectPath(pathToSelect): + // With no Xcode selected, an empty path comes back as `.alreadySelectedPath`, so handle it like `.selectPath` + case let .alreadySelectedPath(pathToSelect), let .selectPath(pathToSelect): do { let selectedOutput = try await selectXcodeAtPathAsync(pathToSelect) Current.logging.log("Selected \(selectedOutput.out)".green) Current.shell.exit(0) } catch { guard fallbackToInteractive else { throw error } - let selectedOutput = try await selectXcodeInteractivelyAsync(currentPath: output.out, directory: directory) + let selectedOutput = try await selectXcodeInteractivelyAsync(currentPath: selectedXcodePath ?? "", directory: directory) Current.logging.log("Selected \(selectedOutput.out)".green) Current.shell.exit(0) } diff --git a/Tests/XcodesKitTests/XcodesKitTests.swift b/Tests/XcodesKitTests/XcodesKitTests.swift index 900e15ed..39c28988 100644 --- a/Tests/XcodesKitTests/XcodesKitTests.swift +++ b/Tests/XcodesKitTests/XcodesKitTests.swift @@ -16,6 +16,12 @@ private func configureRainbowForTest(outputTarget: OutputTarget, enabled: Bool) final class XcodesKitTests: XCTestCase { static let mockXcode = Xcode(version: Version("0.0.0")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil) + /// `xcode-select -p` exits with status 2 when no developer directory is selected, + /// e.g. on a Mac that doesn't have Xcode or the Command Line Tools installed yet. + static var noSelectedXcodeError: ProcessExecutionError { + ProcessExecutionError(process: Process(), terminationStatus: 2, standardOutput: nil, standardError: nil) + } + var xcodeList: XcodeList! var xcodeInstaller: XcodeInstaller! var sessionService: AppleSessionService! @@ -1033,6 +1039,21 @@ final class XcodesKitTests: XCTestCase { XCTAssertFalse(log.value.contains("Options:")) } + func test_PrintAvailableXcodes_WithNoSelectedXcode_PrintsXcodes() async throws { + let log = LockedBox("") + XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") } + Current.shell.xcodeSelectPrintPath = { throw Self.noSelectedXcodeError } + + try await xcodeInstaller.printAvailableXcodes([Self.mockXcode], installed: [InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!)!]) + + XCTAssertEqual(log.value, """ + 0.0 (ABC123) (Installed) + + Showing Xcodes for this Mac by default: Apple Silicon (arm64). Switch with `--architecture arm64`, `--architecture x86_64`, `--architecture appleSilicon`, or `--architecture universal`. + + """) + } + func test_SelectPrint() async throws { let log = LockedBox("") XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") } @@ -1052,6 +1073,24 @@ final class XcodesKitTests: XCTestCase { """) } + func test_SelectPrint_WithNoSelectedXcode() async throws { + let log = LockedBox("") + XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") } + + Current.files.installedXcodes = { _ in + [InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!)!] + } + + Current.shell.xcodeSelectPrintPath = { throw Self.noSelectedXcodeError } + + try await selectXcodeAsync(shouldPrint: true, pathOrVersion: "", directory: Path.root.join("Applications")) + + XCTAssertEqual(log.value, """ + No selected Xcode + + """) + } + func test_SelectPath() async throws { let log = LockedBox("") XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") } @@ -1199,6 +1238,70 @@ final class XcodesKitTests: XCTestCase { """) } + func test_SelectInteractively_WithNoSelectedXcode() async throws { + let log = LockedBox("") + XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") } + + // There are installed Xcodes + Current.files.installedXcodes = { _ in + [InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!)!, + InstalledXcode(path: Path("/Applications/Xcode-2.0.1.app")!)!] + } + Current.files.contentsAtPath = { path in + if path == "/Applications/Xcode-0.0.0.app/Contents/Info.plist" { + let url = Bundle.module.url(forResource: "Stub-0.0.0.Info", withExtension: "plist", subdirectory: "Fixtures")! + return try? Data(contentsOf: url) + } + else if path == "/Applications/Xcode-2.0.1.app/Contents/Info.plist" { + let url = Bundle.module.url(forResource: "Stub-2.0.1.Info", withExtension: "plist", subdirectory: "Fixtures")! + return try? Data(contentsOf: url) + } + else if path.contains("version.plist") { + let url = Bundle.module.url(forResource: "Stub.version", withExtension: "plist", subdirectory: "Fixtures")! + return try? Data(contentsOf: url) + } + else { + return nil + } + } + Current.files.fileExistsAtPath = { path in + if path == "" { + return false + } + return true + } + // No Xcode is selected until one is switched to + let xcodeSelectPrintPathCallCount = LockedBox(0) + Current.shell.xcodeSelectPrintPath = { + if xcodeSelectPrintPathCallCount.increment() == 1 { + throw Self.noSelectedXcodeError + } + else { + return (status: 0, out: "/Applications/Xcode-2.0.1.app/Contents/Developer", err: "") + } + } + // User enters an index + XcodesCLIKit.Current.shell.readLine = { prompt in + XcodesCLIKit.Current.logging.log(prompt) + return "2" + } + // It successfully switches + Current.shell.xcodeSelectSwitch = { _, _ in + (status: 0, out: "", err: "") + } + + try await selectXcodeAsync(shouldPrint: false, pathOrVersion: "", directory: Path.root.join("Applications")) + + XCTAssertEqual(log.value, """ + Available Xcode versions: + 1) 0.0 (ABC123) + 2) 2.0.1 (ABC123) + Enter the number of the Xcode to select: + Selected /Applications/Xcode-2.0.1.app/Contents/Developer + + """) + } + func test_SelectUsingXcodeVersionFile() async throws { let log = LockedBox("") XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") } @@ -1367,6 +1470,32 @@ final class XcodesKitTests: XCTestCase { ) } + func test_Installed_WithNoSelectedXcode() async throws { + let log = LockedBox("") + XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") } + + // There is an installed Xcode + Current.files.installedXcodes = { _ in + [InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!)!] + } + + // None is selected + Current.shell.xcodeSelectPrintPath = { throw Self.noSelectedXcodeError } + + // Standard output is not an interactive terminal + Current.shell.isatty = { false } + + try await xcodeInstaller.printInstalledXcodes(directory: Path.root/"Applications") + + XCTAssertEqual( + log.value, + """ + 0.0 (ABC123)\t/Applications/Xcode-0.0.0.app + + """ + ) + } + func test_Installed_WithValidVersion_PrintsXcodePath() async throws { let log = LockedBox("") XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") }