From a22f4dbabb317b0c2f3ee9df40fb9a53af050aa9 Mon Sep 17 00:00:00 2001 From: Mahmood Tahir Date: Thu, 3 Feb 2022 23:54:23 -0500 Subject: [PATCH] Add select and update options to install --- Sources/XcodesKit/XcodeInstaller.swift | 5 + Sources/XcodesKit/XcodeSelect.swift | 23 ++- Sources/xcodes/main.swift | 219 ++++++++++++++++--------- 3 files changed, 156 insertions(+), 91 deletions(-) diff --git a/Sources/XcodesKit/XcodeInstaller.swift b/Sources/XcodesKit/XcodeInstaller.swift index f0c918a..71f76fc 100644 --- a/Sources/XcodesKit/XcodeInstaller.swift +++ b/Sources/XcodesKit/XcodeInstaller.swift @@ -175,6 +175,11 @@ public final class XcodeInstaller { Current.shell.exit(0) } } + + /// Perform the install but don't exit out but return the installed xcode version as output instead + public func installWithoutLogging(_ installationType: InstallationType, dataSource: DataSource, downloader: Downloader, destination: Path, experimentalUnxip: Bool) -> Promise { + self.install(installationType, dataSource: dataSource, downloader: downloader, destination: destination, attemptNumber: 0, experimentalUnxip: experimentalUnxip) + } private func install(_ installationType: InstallationType, dataSource: DataSource, downloader: Downloader, destination: Path, attemptNumber: Int, experimentalUnxip: Bool, emptyTrash: Bool, noSuperuser: Bool) -> Promise { return firstly { () -> Promise<(Xcode, URL)> in diff --git a/Sources/XcodesKit/XcodeSelect.swift b/Sources/XcodesKit/XcodeSelect.swift index af4e3aa..ad974d8 100644 --- a/Sources/XcodesKit/XcodeSelect.swift +++ b/Sources/XcodesKit/XcodeSelect.swift @@ -4,7 +4,7 @@ import Path import Version import Rainbow -public func selectXcode(shouldPrint: Bool, pathOrVersion: String, directory: Path) -> Promise { +public func selectXcode(shouldPrint: Bool, pathOrVersion: String, directory: Path, fallbackToInteractive: Bool = true) -> Promise { firstly { () -> Promise in Current.shell.xcodeSelectPrintPath() } @@ -48,18 +48,23 @@ public func selectXcode(shouldPrint: Bool, pathOrVersion: String, directory: Pat return Promise.value(()) } - return selectXcodeAtPath(pathToSelect) + let selectPromise = selectXcodeAtPath(pathToSelect) .done { output in Current.logging.log("Selected \(output.out)".green) Current.shell.exit(0) } - .recover { _ in - selectXcodeInteractively(currentPath: output.out, directory: directory) - .done { output in - Current.logging.log("Selected \(output.out)".green) - Current.shell.exit(0) - } - } + if fallbackToInteractive { + return selectPromise + .recover { _ in + selectXcodeInteractively(currentPath: output.out, directory: directory) + .done { output in + Current.logging.log("Selected \(output.out)".green) + Current.shell.exit(0) + } + } + } else { + return selectPromise + } } } } diff --git a/Sources/xcodes/main.swift b/Sources/xcodes/main.swift index c98034e..4aeddc8 100644 --- a/Sources/xcodes/main.swift +++ b/Sources/xcodes/main.swift @@ -9,7 +9,7 @@ import Rainbow func getDirectory(possibleDirectory: String?, default: Path = Path.root.join("Applications")) -> Path { let directory = possibleDirectory.flatMap(Path.init) ?? - ProcessInfo.processInfo.environment["XCODES_DIRECTORY"].flatMap(Path.init) ?? + ProcessInfo.processInfo.environment["XCODES_DIRECTORY"].flatMap(Path.init) ?? `default` guard directory.isDirectory else { Current.logging.log("Directory argument must be a directory, but was provided \(directory.string).".red) @@ -19,7 +19,7 @@ func getDirectory(possibleDirectory: String?, default: Path = Path.root.join("Ap } struct GlobalDirectoryOption: ParsableArguments { - @Option(help: "The directory where your Xcodes are installed. Defaults to /Applications.", + @Option(help: "The directory where your Xcodes are installed. Defaults to /Applications.", completion: .directory) var directory: String? } @@ -44,7 +44,7 @@ struct GlobalColorOption: ParsableArguments { help: ArgumentHelp( "Determines whether output should be colored.", discussion: """ - xcodes will also disable colored output if its not running in an interactive terminal, if the NO_COLOR environment variable is set, or if the TERM environment variable is set to "dumb". + xcodes will also disable colored output if its not running in an interactive terminal, if the NO_COLOR environment variable is set, or if the TERM environment variable is set to "dumb". """ ) ) @@ -57,7 +57,7 @@ struct Xcodes: ParsableCommand { shouldDisplay: true, subcommands: [Download.self, Install.self, Installed.self, List.self, Select.self, Uninstall.self, Update.self, Version.self, Signout.self] ) - + static var xcodesConfiguration = Configuration() static let xcodeList = XcodeList() static var installer: XcodeInstaller! @@ -66,10 +66,10 @@ struct Xcodes: ParsableCommand { try? xcodesConfiguration.load() installer = XcodeInstaller(configuration: xcodesConfiguration, xcodeList: xcodeList) migrateApplicationSupportFiles() - + self.main(nil) } - + struct Download: ParsableCommand { static var configuration = CommandConfiguration( abstract: "Download a specific version of Xcode", @@ -84,39 +84,39 @@ struct Xcodes: ParsableCommand { xcodes download --latest-prerelease """ ) - + @Argument(help: "The version to download", completion: .custom { args in xcodeList.availableXcodes.sorted { $0.version < $1.version }.map { $0.version.appleDescription } }) var version: [String] = [] - + @Flag(help: "Update and then download the latest non-prerelease version available.") var latest: Bool = false - + @Flag(help: "Update and then download the latest prerelease version available, including GM seeds and GMs.") var latestPrerelease = false - - @Option(help: "The path to an aria2 executable. Searches $PATH by default.", + + @Option(help: "The path to an aria2 executable. Searches $PATH by default.", completion: .file()) var aria2: String? - + @Flag(help: "Don't use aria2 to download Xcode, even if its available.") var noAria2: Bool = false - - @Option(help: "The directory to download Xcode to. Defaults to ~/Downloads.", + + @Option(help: "The directory to download Xcode to. Defaults to ~/Downloads.", completion: .directory) var directory: String? - + @OptionGroup var globalDataSource: GlobalDataSourceOption @OptionGroup var globalColor: GlobalColorOption - + func run() { Rainbow.enabled = Rainbow.enabled && globalColor.color let versionString = version.joined(separator: " ") - + let installation: XcodeInstaller.InstallationType // Deliberately not using InstallationType.path here as it doesn't make sense to download an Xcode from a .xip that's already on disk if latest { @@ -126,26 +126,29 @@ struct Xcodes: ParsableCommand { } else { installation = .version(versionString) } - + var downloader = XcodeInstaller.Downloader.urlSession if let aria2Path = aria2.flatMap(Path.init) ?? Current.shell.findExecutable("aria2c"), aria2Path.exists, noAria2 == false { downloader = .aria2(aria2Path) } - + let destination = getDirectory(possibleDirectory: directory, default: Path.home.join("Downloads")) installer.download(installation, dataSource: globalDataSource.dataSource, downloader: downloader, destinationDirectory: destination) .catch { error in Install.processDownloadOrInstall(error: error) } - + RunLoop.current.run() } } - + struct Install: ParsableCommand { + enum InstallError: Error { + case notAlreadyInstalled + } static var configuration = CommandConfiguration( abstract: "Download and install a specific version of Xcode", discussion: """ @@ -160,53 +163,62 @@ struct Xcodes: ParsableCommand { xcodes install --latest --directory "/Volumes/Bag Of Holding/" """ ) - + @Argument(help: "The version to install", completion: .custom { args in xcodeList.availableXcodes.sorted { $0.version < $1.version }.map { $0.version.appleDescription } }) var version: [String] = [] - + @Option(name: .customLong("path"), help: "Local path to Xcode .xip", completion: .file(extensions: ["xip"])) var pathString: String? - + @Flag(help: "Update and then install the latest non-prerelease version available.") var latest: Bool = false - + @Flag(help: "Update and then install the latest prerelease version available, including GM seeds and GMs.") var latestPrerelease = false - - @Option(help: "The path to an aria2 executable. Searches $PATH by default.", + + @Option(help: "The path to an aria2 executable. Searches $PATH by default.", completion: .file()) var aria2: String? - + @Flag(help: "Don't use aria2 to download Xcode, even if its available.") var noAria2: Bool = false - + + @Flag(help: "Select the installed xcode version after installation.") + var select: Bool = false + + @Flag(help: "Whether to update the list before installing") + var update: Bool = false + + @ArgumentParser.Flag(name: [.customShort("p"), .customLong("print-path")], help: "Print the path of the selected Xcode") + var print: Bool = false + @Flag(help: "Use the experimental unxip functionality. May speed up unarchiving by up to 2-3x.") var experimentalUnxip: Bool = false @Flag(help: "Don't ask for superuser (root) permission. Some optional steps of the installation will be skipped.") var noSuperuser: Bool = false - + @Flag(help: "Completely delete Xcode .xip after installation, instead of keeping it on the user's Trash.") var emptyTrash: Bool = false - + @Option(help: "The directory to install Xcode into. Defaults to /Applications.", completion: .directory) var directory: String? - + @OptionGroup var globalDataSource: GlobalDataSourceOption @OptionGroup var globalColor: GlobalColorOption - + func run() { Rainbow.enabled = Rainbow.enabled && globalColor.color let versionString = version.joined(separator: " ") - + let installation: XcodeInstaller.InstallationType if latest { installation = .latest @@ -217,69 +229,112 @@ struct Xcodes: ParsableCommand { } else { installation = .version(versionString) } - + var downloader = XcodeInstaller.Downloader.urlSession if let aria2Path = aria2.flatMap(Path.init) ?? Current.shell.findExecutable("aria2c"), aria2Path.exists, noAria2 == false { downloader = .aria2(aria2Path) } - + let destination = getDirectory(possibleDirectory: directory) - - installer.install(installation, dataSource: globalDataSource.dataSource, downloader: downloader, destination: destination, experimentalUnxip: experimentalUnxip, emptyTrash: emptyTrash, noSuperuser: noSuperuser) - .done { Install.exit() } + + if select == false { + // install normally + installer.install(installation, dataSource: globalDataSource.dataSource, downloader: downloader, destination: destination, experimentalUnxip: experimentalUnxip, emptyTrash: emptyTrash, noSuperuser: noSuperuser) + .done { Install.exit() } + .catch { error in + Install.processDownloadOrInstall(error: error) + } + } else { + // check if the version is already installed and try to select it + firstly { () -> Promise in + if case .version(let version) = installation { + return selectXcode(shouldPrint: print, pathOrVersion: version, directory: destination, fallbackToInteractive: false) + } else { + return Promise { _ in + throw InstallError.notAlreadyInstalled + } + } + } + .done { Install.exit() } // successfully selected .catch { error in - Install.processDownloadOrInstall(error: error) + // select failed. Xcode must not be installed. + firstly { () -> Promise in + // update the list before installing only for version type because the other types already update internally + if update, case .version = installation { + Current.logging.log("Updating...") + return xcodeList.update(dataSource: globalDataSource.dataSource) + .then { _ -> Promise in + installer.installWithoutLogging(installation, dataSource: globalDataSource.dataSource, downloader: downloader, destination: destination, experimentalUnxip: experimentalUnxip) + } + } else { + // install + return installer.installWithoutLogging(installation, dataSource: globalDataSource.dataSource, downloader: downloader, destination: destination, experimentalUnxip: experimentalUnxip) + } + } + .then { xcode -> Promise in + Current.logging.log("\nXcode \(xcode.version.descriptionWithoutBuildMetadata) has been installed to \(xcode.path.string)".green) + + // Install was successful, now select it + return selectXcode(shouldPrint: print, pathOrVersion: xcode.path.string, directory: destination, fallbackToInteractive: false) + } + .done { + Install.exit() + } + .catch { error in + Install.processDownloadOrInstall(error: error) + } } - + } + RunLoop.current.run() } } - + struct Installed: ParsableCommand { static var configuration = CommandConfiguration( abstract: "List the versions of Xcode that are installed" ) - + @OptionGroup var globalDirectory: GlobalDirectoryOption - + @OptionGroup var globalColor: GlobalColorOption - + func run() { Rainbow.enabled = Rainbow.enabled && globalColor.color let directory = getDirectory(possibleDirectory: globalDirectory.directory) - + installer.printInstalledXcodes(directory: directory) .done { Installed.exit() } .catch { error in Installed.exit(withLegibleError: error) } - + RunLoop.current.run() } } - + struct List: ParsableCommand { static var configuration = CommandConfiguration( abstract: "List all versions of Xcode that are available to install" ) - + @OptionGroup var globalDirectory: GlobalDirectoryOption - + @OptionGroup var globalDataSource: GlobalDataSourceOption @OptionGroup var globalColor: GlobalColorOption - + func run() { Rainbow.enabled = Rainbow.enabled && globalColor.color let directory = getDirectory(possibleDirectory: globalDirectory.directory) - + firstly { () -> Promise in if xcodeList.shouldUpdate { return installer.updateAndPrint(dataSource: globalDataSource.dataSource, directory: directory) @@ -290,11 +345,11 @@ struct Xcodes: ParsableCommand { } .done { List.exit() } .catch { error in List.exit(withLegibleError: error) } - + RunLoop.current.run() } } - + struct Select: ParsableCommand { static var configuration = CommandConfiguration( abstract: "Change the selected Xcode", @@ -308,33 +363,33 @@ struct Xcodes: ParsableCommand { xcodes select -p """ ) - + @ArgumentParser.Flag(name: [.customShort("p"), .customLong("print-path")], help: "Print the path of the selected Xcode") var print: Bool = false - + @Argument(help: "Version or path", completion: .custom { _ in Current.files.installedXcodes(getDirectory(possibleDirectory: nil)).sorted { $0.version < $1.version }.map { $0.version.appleDescription } }) var versionOrPath: [String] = [] - + @OptionGroup var globalDirectory: GlobalDirectoryOption - + @OptionGroup var globalColor: GlobalColorOption - + func run() { Rainbow.enabled = Rainbow.enabled && globalColor.color let directory = getDirectory(possibleDirectory: globalDirectory.directory) - + selectXcode(shouldPrint: print, pathOrVersion: versionOrPath.joined(separator: " "), directory: directory) .done { Select.exit() } .catch { error in Select.exit(withLegibleError: error) } - + RunLoop.current.run() } } - + struct Uninstall: ParsableCommand { static var configuration = CommandConfiguration( abstract: "Uninstall a version of Xcode", @@ -346,20 +401,20 @@ struct Xcodes: ParsableCommand { xcodes uninstall 11.4.0 """ ) - + @Argument(help: "The version to uninstall", completion: .custom { _ in Current.files.installedXcodes(getDirectory(possibleDirectory: nil)).sorted { $0.version < $1.version }.map { $0.version.appleDescription } }) var version: [String] = [] - + @Flag(help: "Completely delete Xcode, instead of keeping it on the user's Trash.") var emptyTrash: Bool = false - + @OptionGroup var globalDirectory: GlobalDirectoryOption - + @OptionGroup var globalColor: GlobalColorOption - + func run() { Rainbow.enabled = Rainbow.enabled && globalColor.color @@ -368,64 +423,64 @@ struct Xcodes: ParsableCommand { installer.uninstallXcode(version.joined(separator: " "), directory: directory, emptyTrash: emptyTrash) .done { Uninstall.exit() } .catch { error in Uninstall.exit(withLegibleError: error) } - + RunLoop.current.run() } } - + struct Update: ParsableCommand { static var configuration = CommandConfiguration( abstract: "Update the list of available versions of Xcode" ) - + @OptionGroup var globalDirectory: GlobalDirectoryOption - + @OptionGroup var globalDataSource: GlobalDataSourceOption @OptionGroup var globalColor: GlobalColorOption - + func run() { Rainbow.enabled = Rainbow.enabled && globalColor.color let directory = getDirectory(possibleDirectory: globalDirectory.directory) - + installer.updateAndPrint(dataSource: globalDataSource.dataSource, directory: directory) .done { Update.exit() } .catch { error in Update.exit(withLegibleError: error) } - + RunLoop.current.run() } } - + struct Version: ParsableCommand { static var configuration = CommandConfiguration( abstract: "Print the version number of xcodes itself" ) - + @OptionGroup var globalColor: GlobalColorOption - + func run() { Rainbow.enabled = Rainbow.enabled && globalColor.color Current.logging.log(XcodesKit.version.description) } } - + struct Signout: ParsableCommand { static var configuration = CommandConfiguration( abstract: "Clears the stored username and password" ) - + @OptionGroup var globalColor: GlobalColorOption - + func run() { Rainbow.enabled = Rainbow.enabled && globalColor.color - + installer.logout() .done { Current.logging.log("Successfully signed out".green) @@ -435,7 +490,7 @@ struct Xcodes: ParsableCommand { Current.logging.log(error.legibleLocalizedDescription) Signout.exit() } - + RunLoop.current.run() } }