diff --git a/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md b/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md index f8c0b1f6..ce64293d 100644 --- a/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md +++ b/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md @@ -469,7 +469,7 @@ swiftly init [--no-modify-profile] [--overwrite] [--platform=] [--skip Update the version of swiftly itself. ``` -swiftly self-update [--assume-yes] [--verbose] [--version] [--help] +swiftly self-update [--assume-yes] [--verbose] [--version] [--help] ``` **--assume-yes:** diff --git a/Sources/Swiftly/SelfUpdate.swift b/Sources/Swiftly/SelfUpdate.swift index afe9b3ca..eb26b14e 100644 --- a/Sources/Swiftly/SelfUpdate.swift +++ b/Sources/Swiftly/SelfUpdate.swift @@ -5,6 +5,12 @@ import SwiftlyWebsiteAPI @preconcurrency import TSCBasic import TSCUtility +extension SwiftlyVersion: ExpressibleByArgument { + public init?(argument: String) { + try? self.init(parsing: argument) + } +} + struct SelfUpdate: SwiftlyCommand { public static let configuration = CommandConfiguration( abstract: "Update the version of swiftly itself." @@ -12,8 +18,10 @@ struct SelfUpdate: SwiftlyCommand { @OptionGroup var root: GlobalOptions + @Option(help: .hidden) var toVersion: SwiftlyVersion + private enum CodingKeys: String, CodingKey { - case root + case root, toVersion } mutating func run() async throws { @@ -31,50 +39,80 @@ struct SelfUpdate: SwiftlyCommand { ) } - let _ = try await Self.execute(ctx, verbose: self.root.verbose) + let _ = try await Self.execute(ctx, verbose: self.root.verbose, version: self.toVersion) } - public static func execute(_ ctx: SwiftlyCoreContext, verbose: Bool) async throws + public static func execute(_ ctx: SwiftlyCoreContext, verbose: Bool, version swiftlyVersion: SwiftlyVersion?) async throws -> SwiftlyVersion { + var downloadURL: Foundation.URL? + var version: SwiftlyVersion? = swiftlyVersion + await ctx.message("Checking for swiftly updates...") - let swiftlyRelease = try await ctx.httpClient.getCurrentSwiftlyRelease() + if let version { +#if os(macOS) + downloadURL = URL(string: "https://download.swift.org/swiftly/darwin/swiftly-\(version).pkg") +#elseif os(Linux) +#if arch(x86_64) + downloadURL = URL(string: "https://download.swift.org/swiftly/linux/swiftly-\(version)-x86_64.tar.gz") +#elseif arch(arm64) + downloadURL = URL(string: "https://download.swift.org/swiftly/linux/swiftly-\(version)-aarch64.tar.gz") +#else + fatalError("Unsupported architecture") +#endif +#else + fatalError("Unsupported OS") +#endif - guard try swiftlyRelease.swiftlyVersion > SwiftlyCore.version else { - await ctx.message("Already up to date.") - return SwiftlyCore.version + guard version > SwiftlyCore.version else { + await ctx.print("Self-update does not support downgrading to an older version or re-installing the current version. Current version is \(SwiftlyCore.version) and requested version is \(version).") + return SwiftlyCore.version + } + + await ctx.print("Self-update requested to swiftly version \(version)") } - var downloadURL: Foundation.URL? - for platform in swiftlyRelease.platforms { -#if os(macOS) - guard platform.isDarwin else { - continue + if downloadURL == nil { + await ctx.print("Checking for swiftly updates...") + + let swiftlyRelease = try await ctx.httpClient.getCurrentSwiftlyRelease() + + guard try swiftlyRelease.swiftlyVersion > SwiftlyCore.version else { + await ctx.print("Already up to date.") + return SwiftlyCore.version } + for platform in swiftlyRelease.platforms { +#if os(macOS) + guard platform.isDarwin else { + continue + } #elseif os(Linux) - guard platform.isLinux else { - continue - } + guard platform.isLinux else { + continue + } #endif #if arch(x86_64) - downloadURL = try platform.x86_64URL + downloadURL = try platform.x86_64URL #elseif arch(arm64) - downloadURL = try platform.arm64URL + downloadURL = try platform.arm64URL #endif - } + } - guard let downloadURL else { - throw SwiftlyError( - message: - "The newest release of swiftly is incompatible with your current OS and/or processor architecture." - ) - } + guard let downloadURL else { + throw SwiftlyError( + message: + "The newest release of swiftly is incompatible with your current OS and/or processor architecture." + ) + } + + version = try swiftlyRelease.swiftlyVersion - let version = try swiftlyRelease.swiftlyVersion + await ctx.print("A new version of swiftly is available: \(version!)") + } - await ctx.message("A new version is available: \(version)") + guard let version, let downloadURL else { fatalError() } let tmpFile = fs.mktemp() try await fs.create(file: tmpFile, contents: nil) diff --git a/Tests/SwiftlyTests/HTTPClientTests.swift b/Tests/SwiftlyTests/HTTPClientTests.swift index 873304e9..0fa3a8a2 100644 --- a/Tests/SwiftlyTests/HTTPClientTests.swift +++ b/Tests/SwiftlyTests/HTTPClientTests.swift @@ -53,7 +53,17 @@ import Testing } } - @Test(.tags(.large)) func getSwiftlyRelease() async throws { + @Test( + .tags(.large), + arguments: [ + "https://download.swift.org/swiftly/linux/swiftly-x86_64.tar.gz", // Latest + "https://download.swift.org/swiftly/linux/swiftly-1.0.1-x86_64.tar.gz", // Specific version + "https://download.swift.org/swiftly/linux/swiftly-1.0.1-dev-x86_64.tar.gz", // Specific dev prerelease version + "https://download.swift.org/swiftly/linux/swiftly-aarch64.tar.gz", // Latest ARM + "https://download.swift.org/swiftly/linux/swiftly-1.0.1-aarch64.tar.gz", // Specific ARM version + "https://download.swift.org/swiftly/linux/swiftly-1.0.1-dev-aarch64.tar.gz", // Specific dev prerelease version + ] + ) func getSwiftlyLinuxReleases(url: String) async throws { let tmpFile = fs.mktemp() try await fs.create(file: tmpFile, contents: nil) let tmpFileSignature = fs.mktemp(ext: ".sig") @@ -64,7 +74,7 @@ import Testing try await fs.withTemporary(files: tmpFile, tmpFileSignature, keysFile) { let httpClient = SwiftlyHTTPClient(httpRequestExecutor: HTTPRequestExecutorImpl()) - let swiftlyURL = try #require(URL(string: "https://download.swift.org/swiftly/linux/swiftly-x86_64.tar.gz")) + let swiftlyURL = try #require(URL(string: url)) try await retry { try await httpClient.getSwiftlyRelease(url: swiftlyURL).download(to: tmpFile) @@ -82,6 +92,32 @@ import Testing } } + @Test( + .tags(.large), + arguments: [ + "https://download.swift.org/swiftly/darwin/swiftly.pkg", // Latest + "https://download.swift.org/swiftly/darwin/swiftly-1.0.1.pkg", // Specific version + "https://download.swift.org/swiftly/darwin/swiftly-1.0.1-dev.pkg", // Specific dev prerelease version + ] + ) func getSwiftlyMacOSReleases(url: String) async throws { + let tmpFile = fs.mktemp() + try await fs.create(file: tmpFile, contents: nil) + let tmpFileSignature = fs.mktemp(ext: ".sig") + try await fs.create(file: tmpFileSignature, contents: nil) + let keysFile = fs.mktemp(ext: ".asc") + try await fs.create(file: keysFile, contents: nil) + + try await fs.withTemporary(files: tmpFile, tmpFileSignature, keysFile) { + let httpClient = SwiftlyHTTPClient(httpRequestExecutor: HTTPRequestExecutorImpl()) + + let swiftlyURL = try #require(URL(string: url)) + + try await retry { + try await httpClient.getSwiftlyRelease(url: swiftlyURL).download(to: tmpFile) + } + } + } + @Test(.tags(.large)) func getSwiftlyReleaseMetadataFromSwiftOrg() async throws { let httpClient = SwiftlyHTTPClient(httpRequestExecutor: HTTPRequestExecutorImpl()) do { diff --git a/Tests/SwiftlyTests/SelfUpdateTests.swift b/Tests/SwiftlyTests/SelfUpdateTests.swift index 0f5e355d..2deb6890 100644 --- a/Tests/SwiftlyTests/SelfUpdateTests.swift +++ b/Tests/SwiftlyTests/SelfUpdateTests.swift @@ -18,10 +18,14 @@ import Testing SwiftlyVersion(major: SwiftlyCore.version.major, minor: SwiftlyCore.version.minor, patch: SwiftlyCore.version.patch + 1) } + private static var newDevVersion: SwiftlyVersion { + SwiftlyVersion(major: SwiftlyCore.version.major, minor: SwiftlyCore.version.minor, patch: SwiftlyCore.version.patch + 1, suffix: "dev") + } + func runSelfUpdateTest(latestVersion: SwiftlyVersion) async throws { try await SwiftlyTests.withTestHome { try await SwiftlyTests.withMockedSwiftlyVersion(latestSwiftlyVersion: latestVersion) { - let updatedVersion = try await SelfUpdate.execute(SwiftlyTests.ctx, verbose: true) + let updatedVersion = try await SelfUpdate.execute(SwiftlyTests.ctx, verbose: true, version: nil) #expect(latestVersion == updatedVersion) } } @@ -37,4 +41,26 @@ import Testing @Test func selfUpdateAlreadyUpToDate() async throws { try await self.runSelfUpdateTest(latestVersion: SwiftlyCore.version) } + + @Test func selfUpdateToUserSpecifiedVersion() async throws { + try await SwiftlyTests.withTestHome { + // GIVEN: swiftly is installed, and at the latest published version + try await SwiftlyTests.withMockedSwiftlyVersion(latestSwiftlyVersion: SwiftlyCore.version) { + // WHEN: An attempt is made to self-update to an equal version + var updatedVersion = try await SelfUpdate.execute(SwiftlyTests.ctx, verbose: true, version: SwiftlyCore.version) + // THEN: There is no change to the swiftly version + #expect(updatedVersion == SwiftlyCore.version) + + // WHEN: An attempt is made to self-update to an older version + updatedVersion = try await SelfUpdate.execute(SwiftlyTests.ctx, verbose: true, version: SwiftlyVersion(major: SwiftlyCore.version.major - 1, minor: 0, patch: 0)) + // THEN: There is no change to the swiftly version + #expect(updatedVersion == SwiftlyCore.version) + + // WHEN: An attempt is made to self-update to a newer development version + updatedVersion = try await SelfUpdate.execute(SwiftlyTests.ctx, verbose: true, version: Self.newDevVersion) + // THEN: swiftly is updated to the new version + #expect(updatedVersion == Self.newDevVersion) + } + } + } }