Skip to content

Commit

Permalink
Merge pull request #126 from interstateone/xcodes-destination
Browse files Browse the repository at this point in the history
Add directory argument
  • Loading branch information
Chad Sykes authored Jan 2, 2021
2 parents 5d2d552 + 598571a commit 8c5f17b
Show file tree
Hide file tree
Showing 8 changed files with 315 additions and 68 deletions.
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

0 comments on commit 8c5f17b

Please sign in to comment.