From aa157dfa431263b5052ff430a9717add33933dfb Mon Sep 17 00:00:00 2001 From: Craig Siemens Date: Sat, 18 Oct 2025 17:14:48 -0600 Subject: [PATCH] Use command checks if being run as root Allows the command to provide the xcode-select command to the user to run in a way that works with their environment. --- Package.resolved | 11 ++- Package.swift | 5 + .../Commands/UseCommand.swift | 28 +++++- .../Models/XcodeApplication+Use.swift | 97 +++++++++++++++++++ .../Models/XcodeApplication.swift | 49 ---------- .../Utilities/StandardErrorOutputStream.swift | 10 ++ 6 files changed, 147 insertions(+), 53 deletions(-) create mode 100644 Sources/XcodeVersionManager/Models/XcodeApplication+Use.swift create mode 100644 Sources/XcodeVersionManager/Utilities/StandardErrorOutputStream.swift diff --git a/Package.resolved b/Package.resolved index 6908220..c0d458f 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "a3667ba6371a1f9d2ef80136ea7d0d87fca3a6b13bd95ce0479d96a0fb0c09de", + "originHash" : "3cd787280b3de7c59966e8a78ff770bcec60d32a0e78eef7bcfe62ee92687629", "pins" : [ + { + "identity" : "rainbow", + "kind" : "remoteSourceControl", + "location" : "https://github.com/onevcat/Rainbow", + "state" : { + "revision" : "16da5c62dd737258c6df2e8c430f8a3202f655a7", + "version" : "4.2.0" + } + }, { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 3ed3e04..188bd3b 100644 --- a/Package.swift +++ b/Package.swift @@ -19,6 +19,10 @@ let package = Package( ], dependencies: [ // Dependencies declare other packages that this package depends on. + .package( + url: "https://github.com/onevcat/Rainbow", + from: "4.0.0" + ), .package( url: "https://github.com/apple/swift-argument-parser", from: "1.5.0" @@ -36,6 +40,7 @@ let package = Package( dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "libunxip", package: "unxip"), + .product(name: "Rainbow", package: "Rainbow"), .target(name: "TableKit") ] ), diff --git a/Sources/XcodeVersionManager/Commands/UseCommand.swift b/Sources/XcodeVersionManager/Commands/UseCommand.swift index ad2c63d..fb32e19 100644 --- a/Sources/XcodeVersionManager/Commands/UseCommand.swift +++ b/Sources/XcodeVersionManager/Commands/UseCommand.swift @@ -1,5 +1,6 @@ import ArgumentParser import Foundation +import Rainbow struct UseCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( @@ -22,7 +23,21 @@ struct UseCommand: AsyncParsableCommand { let xcodeVersion = formatter.string(from: xcode) print("Switching to \(xcodeVersion)") - try xcode.use(permissions: xcodeSelectPermissions) + do { + try xcode.use(permissions: xcodeSelectPermissions) + } catch let .sudoRequired(command) { + var standardError = StandardErrorOutputStream() + print( + """ + Requires superuser permissions to be run. + Manually run the following or see `xcvm use --help` for more details. + """.red, + to: &standardError + ) + + print(command) + throw ExitCode(1) + } } } @@ -36,12 +51,19 @@ extension XcodeApplication.XcodeSelectPermissions: EnumerableFlag { case .inherit: ArgumentHelp( "xcode-select will be run with same permissions xcvm has.", - discussion: #"Calling "sudo xcvm ..." may be required if you do not already have superuser permissions."# + discussion: """ + Use one of the following based on you preference for granting superuser permissions. + - Copy the output commands and paste them into your terminal. + - `xcvm ... | bash` or `eval "$(xcvm ...)"` + Automatically evaluate the output commands. + - `sudo xcvm ...` + Give xcvm and its subprocesses superuser permissions. + """ ) case .sudoAskpass: ArgumentHelp( - #"xcvm will call xcode-select with "sudo --askpass ..."."#, + #"xcvm will call xcode-select with `sudo --askpass ...`."#, discussion: """ The behaviour will be based on the system config. 1. If xcode-select has been added to sudoers with NOPASSWD, it won't need to prompt the user. diff --git a/Sources/XcodeVersionManager/Models/XcodeApplication+Use.swift b/Sources/XcodeVersionManager/Models/XcodeApplication+Use.swift new file mode 100644 index 0000000..e169f7c --- /dev/null +++ b/Sources/XcodeVersionManager/Models/XcodeApplication+Use.swift @@ -0,0 +1,97 @@ +import Foundation +import RegexBuilder + +extension XcodeApplication { + enum XcodeSelectPermissions { + case inherit + case sudoAskpass + } + + func use(permissions: XcodeSelectPermissions) throws(UseError) { + let xcodePath = url.absoluteURL.path + + switch permissions { + case .inherit: + // Get the current user id to determine if being run as root + let userId = try execute( + "/usr/bin/id", + arguments: ["-u"] + ) + + let command = "/usr/bin/xcode-select" + let arguments = [ + "--switch", + xcodePath + ] + + // Is root user + if String(decoding: userId, as: UTF8.self) == "0" { + // Works if xcvm is run with superuser permissions + try execute(command, arguments: arguments) + } else { + var command = "/usr/bin/sudo \(command)" + for argument in arguments { + let argument = if argument.contains(.whitespace) { + "'\(argument)'" + } else { + argument + } + + command += " \(argument)" + } + + throw .sudoRequired(command: command) + } + case .sudoAskpass: + // Works if + // xcode-select has NOPASSWD set in sudoers + // touchid is enabled for sudo + // SUDO_ASKPASS environment variable is set with a helper program + // otherwise fails to prompt for password + try execute( + "/usr/bin/sudo", + arguments: [ + "--askpass", + "/usr/bin/xcode-select", + "--switch", + xcodePath + ] + ) + } + + // Register Xcode so plugins work correctly. + // https://nshipster.com/xcode-source-extensions/#using-pluginkit + try execute( + "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister", + arguments: [ + "-f", + xcodePath + ] + ) + } + + @discardableResult + private func execute(_ command: String, arguments: [String]) throws(UseError) -> Data { + do { + return try Process.execute(command, arguments: arguments) + } catch { + throw .other(error) + } + } +} + +extension XcodeApplication { + enum UseError: LocalizedError { + case sudoRequired(command: String) + case other(Error) + + var errorDescription: String? { + switch self { + case let .sudoRequired(command): + return command + case let .other(error): + return String(describing: error) + } + } + } +} diff --git a/Sources/XcodeVersionManager/Models/XcodeApplication.swift b/Sources/XcodeVersionManager/Models/XcodeApplication.swift index 68b704f..fc8eff7 100644 --- a/Sources/XcodeVersionManager/Models/XcodeApplication.swift +++ b/Sources/XcodeVersionManager/Models/XcodeApplication.swift @@ -16,55 +16,6 @@ struct XcodeApplication: Encodable { } } -// MARK: - Use -extension XcodeApplication { - enum XcodeSelectPermissions { - case inherit - case sudoAskpass - } - - func use(permissions: XcodeSelectPermissions) throws { - let xcodePath = url.absoluteURL.path - - switch permissions { - case .inherit: - // Works if xcvm is run with superuser permissions - try Process.execute( - "/usr/bin/xcode-select", - arguments: [ - "--switch", - xcodePath - ] - ) - case .sudoAskpass: - // Works if - // xcode-select has NOPASSWD set in sudoers - // touchid is enabled for sudo - // SUDO_ASKPASS environment variable is set with a helper program - // otherwise fails to prompt for password - try Process.execute( - "/usr/bin/sudo", - arguments: [ - "--askpass", - "/usr/bin/xcode-select", - "--switch", - xcodePath - ] - ) - } - - // Register Xcode so plugins work correctly. - // https://nshipster.com/xcode-source-extensions/#using-pluginkit - try Process.execute( - "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister", - arguments: [ - "-f", - xcodePath - ] - ) - } -} - // MARK: - Comparable extension XcodeApplication: Comparable { static func < (lhs: XcodeApplication, rhs: XcodeApplication) -> Bool { diff --git a/Sources/XcodeVersionManager/Utilities/StandardErrorOutputStream.swift b/Sources/XcodeVersionManager/Utilities/StandardErrorOutputStream.swift new file mode 100644 index 0000000..1a60c54 --- /dev/null +++ b/Sources/XcodeVersionManager/Utilities/StandardErrorOutputStream.swift @@ -0,0 +1,10 @@ +import Foundation + +public struct StandardErrorOutputStream: TextOutputStream { + public init() {} + + public func write(_ string: String) { + let data = Data(string.utf8) + try? FileHandle.standardError.write(contentsOf: data) + } +}