Skip to content

Commit

Permalink
Merge pull request #95 from interstateone/install-latest
Browse files Browse the repository at this point in the history
Add install flags for --latest and --latest-prerelease
  • Loading branch information
Brandon Evans authored Aug 28, 2020
2 parents 63985e2 + 2b802c0 commit 41b2f00
Show file tree
Hide file tree
Showing 9 changed files with 167 additions and 53 deletions.
8 changes: 4 additions & 4 deletions Package.resolved
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
"repositoryURL": "https://github.com/nsomar/Guaka.git",
"state": {
"branch": null,
"revision": "90c03a9e4c894808a8882a16ccf56743b4b2d40c",
"version": "0.4.0"
"revision": "6fb29b2378166a30d72120980e1c099c664598de",
"version": "0.4.1"
}
},
{
Expand Down Expand Up @@ -60,8 +60,8 @@
"repositoryURL": "https://github.com/getGuaka/StringScanner.git",
"state": {
"branch": null,
"revision": "39f9b77e37c69ab6fec14aadf79a066468ce63e3",
"version": "0.4.0"
"revision": "de1685ad202cb586d626ed52d6de904dd34189f3",
"version": "0.4.1"
}
},
{
Expand Down
17 changes: 17 additions & 0 deletions Sources/XcodesKit/DateFormatter+.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Foundation

extension DateFormatter {
/// Date format used in JSON returned from `URL.downloads`
static let downloadsDateModified: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "MM/dd/yy HH:mm"
return formatter
}()

/// Date format used in HTML returned from `URL.download`
static let downloadsReleaseDate: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "MMMM d, yyyy"
return formatter
}()
}
6 changes: 6 additions & 0 deletions Sources/XcodesKit/Foundation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,9 @@ public extension NumberFormatter {
return string(from: number as! NSNumber)
}
}

extension Sequence {
func sorted<Value: Comparable>(_ keyPath: KeyPath<Element, Value>) -> [Element] {
sorted(by: { $0[keyPath: keyPath] < $1[keyPath: keyPath] })
}
}
5 changes: 4 additions & 1 deletion Sources/XcodesKit/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,13 @@ public struct Xcode: Codable {
public let version: Version
public let url: URL
public let filename: String
public let releaseDate: Date?

public init(version: Version, url: URL, filename: String) {
public init(version: Version, url: URL, filename: String, releaseDate: Date?) {
self.version = version
self.url = url
self.filename = filename
self.releaseDate = releaseDate
}
}

Expand All @@ -58,6 +60,7 @@ struct Downloads: Codable {
public struct Download: Codable {
public let name: String
public let files: [File]
public let dateModified: Date

public struct File: Codable {
public let remotePath: String
Expand Down
3 changes: 3 additions & 0 deletions Sources/XcodesKit/Version+.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,7 @@ public extension Version {
}
return base
}

var isPrerelease: Bool { prereleaseIdentifiers.isEmpty == false }
var isNotPrerelease: Bool { prereleaseIdentifiers.isEmpty == true }
}
133 changes: 99 additions & 34 deletions Sources/XcodesKit/XcodeInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ public final class XcodeInstaller {
case unsupportedFileFormat(extension: String)
case missingSudoerPassword
case unavailableVersion(Version)
case noNonPrereleaseVersionAvailable
case noPrereleaseVersionAvailable
case missingUsernameOrPassword
case versionAlreadyInstalled(InstalledXcode)
case invalidVersion(String)
Expand Down Expand Up @@ -54,6 +56,10 @@ public final class XcodeInstaller {
return "Missing password. Please try again."
case let .unavailableVersion(version):
return "Could not find version \(version.xcodeDescription)."
case .noNonPrereleaseVersionAvailable:
return "No non-prerelease versions available."
case .noPrereleaseVersionAvailable:
return "No prerelease versions available."
case .missingUsernameOrPassword:
return "Missing username or a password. Please try again."
case let .versionAlreadyInstalled(installedXcode):
Expand Down Expand Up @@ -117,23 +123,67 @@ public final class XcodeInstaller {
self.configuration = configuration
self.xcodeList = xcodeList
}

public enum InstallationType {
case version(String)
case url(String, Path)
case latest
case latestPrerelease
}

public func install(_ versionString: String, _ urlString: String?) -> Promise<Void> {
public func install(_ installationType: InstallationType) -> Promise<Void> {
return firstly { () -> Promise<(Xcode, URL)> in
guard let version = Version(xcodeVersion: versionString) ?? versionFromXcodeVersionFile() else {
throw Error.invalidVersion(versionString)
}

if let installedXcode = Current.files.installedXcodes().first(where: { $0.version.isEqualWithoutBuildMetadataIdentifiers(to: version) }) {
throw Error.versionAlreadyInstalled(installedXcode)
}
switch installationType {
case .latest:
Current.logging.log("Updating...")

return update()
.then { availableXcodes -> Promise<(Xcode, URL)> in
guard let latestNonPrereleaseXcode = availableXcodes.filter(\.version.isNotPrerelease).sorted(\.version).last else {
throw Error.noNonPrereleaseVersionAvailable
}
Current.logging.log("Latest non-prerelease version available is \(latestNonPrereleaseXcode.version.xcodeDescription)")

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

if let urlString = urlString {
let url = URL(fileURLWithPath: urlString, relativeTo: nil)
let xcode = Xcode(version: version, url: url, filename: String(url.path.suffix(fromLast: "/")))
return Promise.value((xcode, url))
}
else {
return self.downloadXcode(version: latestNonPrereleaseXcode.version)
}
case .latestPrerelease:
Current.logging.log("Updating...")

return update()
.then { availableXcodes -> Promise<(Xcode, URL)> in
guard let latestPrereleaseXcode = availableXcodes
.filter({ $0.version.isPrerelease })
.filter({ $0.releaseDate != nil })
.sorted(by: { $0.releaseDate! < $1.releaseDate! })
.last
else {
throw Error.noNonPrereleaseVersionAvailable
}
Current.logging.log("Latest prerelease version available is \(latestPrereleaseXcode.version.xcodeDescription)")

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

return self.downloadXcode(version: latestPrereleaseXcode.version)
}
case .url(let versionString, let path):
guard let version = Version(xcodeVersion: versionString) ?? versionFromXcodeVersionFile() else {
throw Error.invalidVersion(versionString)
}
let xcode = Xcode(version: version, url: path.url, filename: String(path.string.suffix(fromLast: "/")), releaseDate: nil)
return Promise.value((xcode, path.url))
case .version(let versionString):
guard let version = Version(xcodeVersion: versionString) ?? versionFromXcodeVersionFile() else {
throw Error.invalidVersion(versionString)
}
if let installedXcode = Current.files.installedXcodes().first(where: { $0.version.isEqualWithoutBuildMetadataIdentifiers(to: version) }) {
throw Error.versionAlreadyInstalled(installedXcode)
}
return self.downloadXcode(version: version)
}
}
Expand Down Expand Up @@ -383,36 +433,45 @@ public final class XcodeInstaller {
}
}

public func updateAndPrint() -> Promise<Void> {
func update() -> Promise<[Xcode]> {
return firstly { () -> Promise<Void> in
loginIfNeeded()
}
.then { () -> Promise<[Xcode]> in
self.xcodeList.update()
}
.then { xcodes -> Promise<Void> in
self.printAvailableXcodes(xcodes, installed: Current.files.installedXcodes())
}
.done {
Current.shell.exit(0)
}
}

public func updateAndPrint() -> Promise<Void> {
update()
.then { xcodes -> Promise<Void> in
self.printAvailableXcodes(xcodes, installed: Current.files.installedXcodes())
}
.done {
Current.shell.exit(0)
}
}

public func printAvailableXcodes(_ xcodes: [Xcode], installed installedXcodes: [InstalledXcode]) -> Promise<Void> {
var allXcodeVersions = xcodes.map { $0.version }
struct ReleasedVersion {
let version: Version
let releaseDate: Date?
}

var allXcodeVersions = xcodes.map { ReleasedVersion(version: $0.version, releaseDate: $0.releaseDate) }
for installedXcode in installedXcodes {
// If an installed version isn't listed online, add the installed version
if !allXcodeVersions.contains(where: { version in
version.isEquivalentForDeterminingIfInstalled(toInstalled: installedXcode.version)
if !allXcodeVersions.contains(where: { releasedVersion in
releasedVersion.version.isEquivalentForDeterminingIfInstalled(toInstalled: installedXcode.version)
}) {
allXcodeVersions.append(installedXcode.version)
allXcodeVersions.append(ReleasedVersion(version: installedXcode.version, releaseDate: nil))
}
// If an installed version is the same as one that's listed online which doesn't have build metadata, replace it with the installed version with build metadata
else if let index = allXcodeVersions.firstIndex(where: { version in
version.isEquivalentForDeterminingIfInstalled(toInstalled: installedXcode.version) &&
version.buildMetadataIdentifiers.isEmpty
else if let index = allXcodeVersions.firstIndex(where: { releasedVersion in
releasedVersion.version.isEquivalentForDeterminingIfInstalled(toInstalled: installedXcode.version) &&
releasedVersion.version.buildMetadataIdentifiers.isEmpty
}) {
allXcodeVersions[index] = installedXcode.version
allXcodeVersions[index] = ReleasedVersion(version: installedXcode.version, releaseDate: nil)
}
}

Expand All @@ -421,11 +480,17 @@ public final class XcodeInstaller {
let selectedInstalledXcodeVersion = installedXcodes.first { output.out.hasPrefix($0.path.string) }.map { $0.version }

allXcodeVersions
.sorted { $0 < $1 }
.forEach { xcodeVersion in
var output = xcodeVersion.xcodeDescription
if installedXcodes.contains(where: { xcodeVersion.isEquivalentForDeterminingIfInstalled(toInstalled: $0.version) }) {
if xcodeVersion == selectedInstalledXcodeVersion {
.sorted { first, second -> Bool in
// Sort prereleases by release date, otherwise sort by version
if first.version.isPrerelease, second.version.isPrerelease, let firstDate = first.releaseDate, let secondDate = second.releaseDate {
return firstDate < secondDate
}
return first.version < second.version
}
.forEach { releasedVersion in
var output = releasedVersion.version.xcodeDescription
if installedXcodes.contains(where: { releasedVersion.version.isEquivalentForDeterminingIfInstalled(toInstalled: $0.version) }) {
if releasedVersion.version == selectedInstalledXcodeVersion {
output += " (Installed, Selected)"
}
else {
Expand Down
9 changes: 6 additions & 3 deletions Sources/XcodesKit/XcodeList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ extension XcodeList {
Current.network.dataTask(with: URLRequest.downloads)
}
.map { (data, response) -> [Xcode] in
let downloads = try JSONDecoder().decode(Downloads.self, from: data)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .formatted(.downloadsDateModified)
let downloads = try decoder.decode(Downloads.self, from: data)
let xcodes = downloads
.downloads
.filter { $0.name.range(of: "^Xcode [0-9]", options: .regularExpression) != nil }
Expand All @@ -66,7 +68,7 @@ extension XcodeList {
let version = Version(xcodeVersion: download.name)
else { return nil }

return Xcode(version: version, url: url, filename: String(xcodeFile.remotePath.suffix(fromLast: "/")))
return Xcode(version: version, url: url, filename: String(xcodeFile.remotePath.suffix(fromLast: "/")), releaseDate: download.dateModified)
}
return xcodes
}
Expand All @@ -88,13 +90,14 @@ extension XcodeList {
guard
let xcodeHeader = try document.select("h2:containsOwn(Xcode)").first(),
let productBuildVersion = try xcodeHeader.parent()?.select("li:contains(Build)").text().replacingOccurrences(of: "Build", with: ""),
let releaseDateString = try xcodeHeader.parent()?.select("li:contains(Released)").text().replacingOccurrences(of: "Released", with: ""),
let version = Version(xcodeVersion: try xcodeHeader.text(), buildMetadataIdentifier: productBuildVersion),
let path = try document.select(".direct-download[href*=xip]").first()?.attr("href"),
let url = URL(string: "https://developer.apple.com" + path)
else { return [] }

let filename = String(path.suffix(fromLast: "/"))

return [Xcode(version: version, url: url, filename: filename)]
return [Xcode(version: version, url: url, filename: filename, releaseDate: DateFormatter.downloadsReleaseDate.date(from: releaseDateString))]
}
}
19 changes: 17 additions & 2 deletions Sources/xcodes/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,17 +93,32 @@ let update = Command(usage: "update",
app.add(subCommand: update)

let urlFlag = Flag(longName: "url", type: String.self, description: "Local path to Xcode .xip")
let latestFlag = Flag(longName: "latest", value: false, description: "Update and then install the latest non-prerelease version available.")
let latestPrereleaseFlag = Flag(longName: "latest-prerelease", value: false, description: "Update and then install the latest prerelease version available, including GM seeds and GMs.")
let install = Command(usage: "install <version>",
shortMessage: "Download and install a specific version of Xcode",
flags: [urlFlag],
flags: [urlFlag, latestFlag, latestPrereleaseFlag],
example: """
xcodes install 10.2.1
xcodes install 11 Beta 7
xcodes install 11.2 GM seed
xcodes install 9.0 --url ~/Archive/Xcode_9.xip
xcodes install --latest-prerelease
""") { flags, args in
let versionString = args.joined(separator: " ")
installer.install(versionString, flags.getString(name: "url"))

let installation: XcodeInstaller.InstallationType
if flags.getBool(name: "latest") == true {
installation = .latest
} else if flags.getBool(name: "latest-prerelease") == true {
installation = .latestPrerelease
} else if let url = flags.getString(name: "url"), let path = Path(url) {
installation = .url(versionString, path)
} else {
installation = .version(versionString)
}

installer.install(installation)
.catch { error in
switch error {
case Process.PMKError.execution(let process, let standardOutput, let standardError):
Expand Down
Loading

0 comments on commit 41b2f00

Please sign in to comment.