Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ Install a specific version of Xcode using a command like one of these:
xcodes install 10.2.1
xcodes install 11 Beta 7
xcodes install 11.2 GM seed
xcodes install --latest
xcodes install 9.0 --path ~/Archive/Xcode_9.xip
xcodes install --latest-prerelease
xcodes install --latest --directory "/Volumes/Bag Of Holding/"
```

You'll then be prompted to enter your Apple ID username and password. You can also provide these with the `XCODES_USERNAME` and `XCODES_PASSWORD` environment variables.
Expand All @@ -88,6 +90,8 @@ Xcode 11.2.0 has been installed to /Applications/Xcode-11.2.0.app

If you have [aria2](https://aria2.github.io) installed (it's available in Homebrew, `brew install aria2`), then xcodes will default to use it for downloads. It uses up to 16 connections to download Xcode 3-5x faster than URLSession.

Xcode will be installed to /Applications by default, but you can provide the path to a different directory with the `--directory` option or the `XCODES_DIRECTORY` environment variable. All of the xcodes commands support this option, like `select` and `uninstall`, so you can manage Xcode versions that aren't in /Applications. xcodes supports having all of your Xcode versions installed in _one_ directory, wherever that may be.

### Commands

- `install <version>`: Download and install a specific version of Xcode
Expand Down
4 changes: 2 additions & 2 deletions Sources/XcodesKit/Environment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -238,8 +238,8 @@ public struct Files {

public var installedXcodes = XcodesKit.installedXcodes
}
private func installedXcodes() -> [InstalledXcode] {
((try? Path.root.join("Applications").ls()) ?? [])
private func installedXcodes(directory: Path) -> [InstalledXcode] {
((try? directory.ls()) ?? [])
.filter { $0.isAppBundle && $0.infoPlist?.bundleID == "com.apple.dt.Xcode" }
.map { $0.path }
.compactMap(InstalledXcode.init)
Expand Down
48 changes: 24 additions & 24 deletions Sources/XcodesKit/XcodeInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public final class XcodeInstaller {

public enum Error: LocalizedError, Equatable {
case damagedXIP(url: URL)
case failedToMoveXcodeToApplications
case failedToMoveXcodeToDestination(Path)
case failedSecurityAssessment(xcode: InstalledXcode, output: String)
case codesignVerifyFailed(output: String)
case unexpectedCodeSigningIdentity(identifier: String, certificateAuthority: [String])
Expand All @@ -30,8 +30,8 @@ public final class XcodeInstaller {
switch self {
case .damagedXIP(let url):
return "The archive \"\(url.lastPathComponent)\" is damaged and can't be expanded."
case .failedToMoveXcodeToApplications:
return "Failed to move Xcode to the /Applications directory."
case .failedToMoveXcodeToDestination(let destination):
return "Failed to move Xcode to the \(destination.string) directory."
case .failedSecurityAssessment(let xcode, let output):
return """
Xcode \(xcode.version) failed its security assessment with the following output:
Expand Down Expand Up @@ -150,22 +150,22 @@ public final class XcodeInstaller {
case aria2(Path)
}

public func install(_ installationType: InstallationType, downloader: Downloader) -> Promise<Void> {
public func install(_ installationType: InstallationType, downloader: Downloader, destination: Path) -> Promise<Void> {
return firstly { () -> Promise<InstalledXcode> in
return self.install(installationType, downloader: downloader, attemptNumber: 0)
return self.install(installationType, downloader: downloader, destination: destination, attemptNumber: 0)
}
.done { xcode in
Current.logging.log("\nXcode \(xcode.version.descriptionWithoutBuildMetadata) has been installed to \(xcode.path.string)")
Current.shell.exit(0)
}
}

private func install(_ installationType: InstallationType, downloader: Downloader, attemptNumber: Int) -> Promise<InstalledXcode> {
private func install(_ installationType: InstallationType, downloader: Downloader, destination: Path, attemptNumber: Int) -> Promise<InstalledXcode> {
return firstly { () -> Promise<(Xcode, URL)> in
return self.getXcodeArchive(installationType, downloader: downloader, willInstall: true)
return self.getXcodeArchive(installationType, downloader: downloader, destination: destination, willInstall: true)
}
.then { xcode, url -> Promise<InstalledXcode> in
return self.installArchivedXcode(xcode, at: url)
return self.installArchivedXcode(xcode, at: url, to: destination)
}
.recover { error -> Promise<InstalledXcode> in
switch error {
Expand All @@ -182,7 +182,7 @@ public final class XcodeInstaller {
Current.logging.log(error.legibleLocalizedDescription)
Current.logging.log("Removing damaged XIP and re-attempting installation.\n")
try Current.files.removeItem(at: damagedXIPURL)
return self.install(installationType, downloader: downloader, attemptNumber: attemptNumber + 1)
return self.install(installationType, downloader: downloader, destination: destination, attemptNumber: attemptNumber + 1)
}
}
default:
Expand All @@ -193,7 +193,7 @@ public final class XcodeInstaller {

public func download(_ installation: InstallationType, downloader: Downloader, destinationDirectory: Path) -> Promise<Void> {
return firstly { () -> Promise<(Xcode, URL)> in
return self.getXcodeArchive(installation, downloader: downloader, willInstall: false)
return self.getXcodeArchive(installation, downloader: downloader, destination: destinationDirectory, willInstall: false)
}
.map { (xcode, url) -> (Xcode, URL) in
let destination = destinationDirectory.url.appendingPathComponent(url.lastPathComponent)
Expand All @@ -206,7 +206,7 @@ public final class XcodeInstaller {
}
}

private func getXcodeArchive(_ installationType: InstallationType, downloader: Downloader, willInstall: Bool) -> Promise<(Xcode, URL)> {
private func getXcodeArchive(_ installationType: InstallationType, downloader: Downloader, destination: Path, willInstall: Bool) -> Promise<(Xcode, URL)> {
return firstly { () -> Promise<(Xcode, URL)> in
switch installationType {
case .latest:
Expand All @@ -219,7 +219,7 @@ public final class XcodeInstaller {
}
Current.logging.log("Latest non-prerelease version available is \(latestNonPrereleaseXcode.version.xcodeDescription)")

if willInstall, let installedXcode = Current.files.installedXcodes().first(where: { $0.version.isEqualWithoutBuildMetadataIdentifiers(to: latestNonPrereleaseXcode.version) }) {
if willInstall, let installedXcode = Current.files.installedXcodes(destination).first(where: { $0.version.isEqualWithoutBuildMetadataIdentifiers(to: latestNonPrereleaseXcode.version) }) {
throw Error.versionAlreadyInstalled(installedXcode)
}

Expand All @@ -240,7 +240,7 @@ public final class XcodeInstaller {
}
Current.logging.log("Latest prerelease version available is \(latestPrereleaseXcode.version.xcodeDescription)")

if willInstall, let installedXcode = Current.files.installedXcodes().first(where: { $0.version.isEqualWithoutBuildMetadataIdentifiers(to: latestPrereleaseXcode.version) }) {
if willInstall, let installedXcode = Current.files.installedXcodes(destination).first(where: { $0.version.isEqualWithoutBuildMetadataIdentifiers(to: latestPrereleaseXcode.version) }) {
throw Error.versionAlreadyInstalled(installedXcode)
}

Expand All @@ -256,7 +256,7 @@ public final class XcodeInstaller {
guard let version = Version(xcodeVersion: versionString) ?? versionFromXcodeVersionFile() else {
throw Error.invalidVersion(versionString)
}
if willInstall, let installedXcode = Current.files.installedXcodes().first(where: { $0.version.isEqualWithoutBuildMetadataIdentifiers(to: version) }) {
if willInstall, let installedXcode = Current.files.installedXcodes(destination).first(where: { $0.version.isEqualWithoutBuildMetadataIdentifiers(to: version) }) {
throw Error.versionAlreadyInstalled(installedXcode)
}
return self.downloadXcode(version: version, downloader: downloader, willInstall: willInstall)
Expand Down Expand Up @@ -472,7 +472,7 @@ public final class XcodeInstaller {
}
}

public func installArchivedXcode(_ xcode: Xcode, at archiveURL: URL) -> Promise<InstalledXcode> {
public func installArchivedXcode(_ xcode: Xcode, at archiveURL: URL, to destination: Path) -> Promise<InstalledXcode> {
let passwordInput = {
Promise<String> { seal in
Current.logging.log("xcodes requires superuser privileges in order to finish installation.")
Expand All @@ -482,15 +482,15 @@ public final class XcodeInstaller {
}

return firstly { () -> Promise<InstalledXcode> in
let destinationURL = Path.root.join("Applications").join("Xcode-\(xcode.version.descriptionWithoutBuildMetadata).app").url
let destinationURL = destination.join("Xcode-\(xcode.version.descriptionWithoutBuildMetadata).app").url
switch archiveURL.pathExtension {
case "xip":
return unarchiveAndMoveXIP(at: archiveURL, to: destinationURL).map { xcodeURL in
guard
let path = Path(url: xcodeURL),
Current.files.fileExists(atPath: path.string),
let installedXcode = InstalledXcode(path: path)
else { throw Error.failedToMoveXcodeToApplications }
else { throw Error.failedToMoveXcodeToDestination(destination) }
return installedXcode
}
case "dmg":
Expand Down Expand Up @@ -521,13 +521,13 @@ public final class XcodeInstaller {
}
}

public func uninstallXcode(_ versionString: String) -> Promise<Void> {
public func uninstallXcode(_ versionString: String, directory: Path) -> Promise<Void> {
return firstly { () -> Promise<(InstalledXcode, URL)> in
guard let version = Version(xcodeVersion: versionString) else {
throw Error.invalidVersion(versionString)
}

guard let installedXcode = Current.files.installedXcodes().first(withVersion: version) else {
guard let installedXcode = Current.files.installedXcodes(directory).first(withVersion: version) else {
throw Error.versionNotInstalled(version)
}

Expand All @@ -540,7 +540,7 @@ public final class XcodeInstaller {
Current.shell.xcodeSelectPrintPath()
.then { output -> Promise<(InstalledXcode, URL)> in
if output.out.hasPrefix(installedXcode.path.string),
let latestInstalledXcode = Current.files.installedXcodes().sorted(by: { $0.version < $1.version }).last {
let latestInstalledXcode = Current.files.installedXcodes(directory).sorted(by: { $0.version < $1.version }).last {
return selectXcodeAtPath(latestInstalledXcode.path.string)
.map { output in
Current.logging.log("Selected \(output.out)")
Expand All @@ -567,10 +567,10 @@ public final class XcodeInstaller {
}
}

public func updateAndPrint() -> Promise<Void> {
public func updateAndPrint(directory: Path) -> Promise<Void> {
update()
.then { xcodes -> Promise<Void> in
self.printAvailableXcodes(xcodes, installed: Current.files.installedXcodes())
self.printAvailableXcodes(xcodes, installed: Current.files.installedXcodes(directory))
}
.done {
Current.shell.exit(0)
Expand Down Expand Up @@ -627,10 +627,10 @@ public final class XcodeInstaller {
}
}

public func printInstalledXcodes() -> Promise<Void> {
public func printInstalledXcodes(directory: Path) -> Promise<Void> {
Current.shell.xcodeSelectPrintPath()
.done { pathOutput in
Current.files.installedXcodes()
Current.files.installedXcodes(directory)
.sorted { $0.version < $1.version }
.forEach { installedXcode in
var output = installedXcode.version.xcodeDescription
Expand Down
16 changes: 8 additions & 8 deletions Sources/XcodesKit/XcodeSelect.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import PromiseKit
import Path
import Version

public func selectXcode(shouldPrint: Bool, pathOrVersion: String) -> Promise<Void> {
public func selectXcode(shouldPrint: Bool, pathOrVersion: String, directory: Path) -> Promise<Void> {
firstly { () -> Promise<ProcessOutput> in
Current.shell.xcodeSelectPrintPath()
}
Expand All @@ -22,7 +22,7 @@ public func selectXcode(shouldPrint: Bool, pathOrVersion: String) -> Promise<Voi
}

if let version = Version(xcodeVersion: pathOrVersion),
let installedXcode = Current.files.installedXcodes().first(withVersion: version) {
let installedXcode = Current.files.installedXcodes(directory).first(withVersion: version) {
return selectXcodeAtPath(installedXcode.path.string)
.done { output in
Current.logging.log("Selected \(output.out)")
Expand All @@ -36,7 +36,7 @@ public func selectXcode(shouldPrint: Bool, pathOrVersion: String) -> Promise<Voi
Current.shell.exit(0)
}
.recover { _ in
try selectXcodeInteractively(currentPath: output.out)
try selectXcodeInteractively(currentPath: output.out, directory: directory)
.done { output in
Current.logging.log("Selected \(output.out)")
Current.shell.exit(0)
Expand All @@ -46,11 +46,11 @@ public func selectXcode(shouldPrint: Bool, pathOrVersion: String) -> Promise<Voi
}
}

public func selectXcodeInteractively(currentPath: String, shouldRetry: Bool) -> Promise<ProcessOutput> {
public func selectXcodeInteractively(currentPath: String, directory: Path, shouldRetry: Bool) -> Promise<ProcessOutput> {
if shouldRetry {
func selectWithRetry(currentPath: String) -> Promise<ProcessOutput> {
return firstly {
try selectXcodeInteractively(currentPath: currentPath)
try selectXcodeInteractively(currentPath: currentPath, directory: directory)
}
.recover { error throws -> Promise<ProcessOutput> in
guard case XcodeSelectError.invalidIndex = error else { throw error }
Expand All @@ -63,13 +63,13 @@ public func selectXcodeInteractively(currentPath: String, shouldRetry: Bool) ->
}
else {
return firstly {
try selectXcodeInteractively(currentPath: currentPath)
try selectXcodeInteractively(currentPath: currentPath, directory: directory)
}
}
}

public func selectXcodeInteractively(currentPath: String) throws -> Promise<ProcessOutput> {
let sortedInstalledXcodes = Current.files.installedXcodes().sorted { $0.version < $1.version }
public func selectXcodeInteractively(currentPath: String, directory: Path) throws -> Promise<ProcessOutput> {
let sortedInstalledXcodes = Current.files.installedXcodes(directory).sorted { $0.version < $1.version }

Current.logging.log("Available Xcode versions:")

Expand Down
Loading