diff --git a/.gitignore b/.gitignore index 7af115bf..437460a7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ xcuserdata/ DerivedData/ .swiftpm/ .vscode/ +**/*.swp diff --git a/.swift-version b/.swift-version index 43f030c7..f9ce5a96 100644 --- a/.swift-version +++ b/.swift-version @@ -1 +1 @@ -5.7 \ No newline at end of file +5.10 diff --git a/DESIGN.md b/DESIGN.md index ed69e853..3ec1adee 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -54,30 +54,25 @@ This is all very similar to how rustup does things, but I figure there's no need ## macOS ### Installation of swiftly -Similar to Linux, the bootstrapping script for macOS will create a `~/.swiftly/bin` directory and drop the swiftly executable in it. A similar `~/.swiftly/env` file will be created and a message will be printed suggesting users add source `~/.swiftly/env` to their `.bash_profile` or `.zshrc`. +Similar to Linux, the bootstrapping script for macOS will create a `~/.local/bin` directory and drop the swiftly executable in it. A `~/.local/share/swiftly/env` file will be created and a message will be printed suggesting users add source `~/.local/share/swiftly/env` to their `.bash_profile` or `.zshrc`. The bootstrapping script will detect if xcode is installed and prompt the user to install it if it isn’t. We could also ask the user if they’d like us to install the xcode command line tools for them via `xcode-select --install`. ### Installation of a Swift toolchain -The contents of `~/.swiftly` would look like this: +The contents of `~/Library/Application Support/swiftly` would look like this: ``` -~/.swiftly - - | - -- bin/ - | - -- active-toolchain/ +~/Library/Application Support/swiftly | -- config.json | – env ``` -Instead of downloading tarballs containing the toolchains and storing them directly in `~/.swiftly/toolchains`, we instead install Swift toolchains to `~/Library/Developer/Toolchains` via the `.pkg` files provided for download at swift.org. (Side note: we’ll need to request that other versions than the latest be made available). To select a toolchain for use, we update the symlink at `~/.swiftly/active-toolchain` to point to the desired toolchain in `~/Library/Developer/Toolchains`. In the env file, we’ll contain a line that looks like `export PATH=”$HOME/.swiftly/active-toolchain/usr/bin:$PATH`, so the version of swift being used will automatically always be from the active toolchain. `config.json` will contain version information about the selected toolchain as well as its actual location on disk. +Instead of downloading tarballs containing the toolchains and storing them directly in `~/.local/share/swiftly/toolchains`, we instead install Swift toolchains to `~/Library/Developer/Toolchains` via the `.pkg` files provided for download at swift.org. To select a toolchain for use, we update the symlinks at `~/Library/Application Support/swiftly/bin` to point to the desired toolchain in `~/Library/Developer/Toolchains`. In the env file, we’ll contain a line that looks like `export PATH="$HOME/Library/Application Support/swiftly:$PATH"`, so the version of swift being used will automatically always be from the active toolchain. `config.json` will contain version information about the selected toolchain as well as its actual location on disk. -This scheme works for ensuring the version of Swift used on the command line can be controlled, but it doesn’t affect the active toolchain used by Xcode. From what I can tell, there doesn’t seem to be a way to tell Xcode which toolchain to use except through the GUI, which won’t work for us. A possible solution would be to have the active-toolchain symlink live with the rest of the toolchains, and then the user could select it from the GUI (we could name it something like “swiftly Active Toolchain” or something to indicate that it’s managed by swiftly). Alternatively, we could figure out how Xcode selects toolchains and do what it does in swiftly manually. +This scheme works for ensuring the version of Swift used on the command line can be controlled, but it doesn’t affect the active toolchain used by Xcode, which uses its own mechanisms for that. Xcode, if it is installed, can find the toolchains installed by swiftly. ## Interface diff --git a/Package.swift b/Package.swift index 0acbf01d..269c2a89 100644 --- a/Package.swift +++ b/Package.swift @@ -1,10 +1,12 @@ -// swift-tools-version:5.7 +// swift-tools-version:5.10 import PackageDescription let package = Package( name: "swiftly", - platforms: [.macOS(.v13)], + platforms: [ + .macOS(.v13), + ], products: [ .executable( name: "swiftly", @@ -24,6 +26,7 @@ let package = Package( .product(name: "ArgumentParser", package: "swift-argument-parser"), .target(name: "SwiftlyCore"), .target(name: "LinuxPlatform", condition: .when(platforms: [.linux])), + .target(name: "MacOSPlatform", condition: .when(platforms: [.macOS])), .product(name: "SwiftToolsSupport-auto", package: "swift-tools-support-core"), ] ), @@ -44,6 +47,12 @@ let package = Package( .linkedLibrary("z"), ] ), + .target( + name: "MacOSPlatform", + dependencies: [ + "SwiftlyCore", + ] + ), .systemLibrary( name: "CLibArchive", pkgConfig: "libarchive", diff --git a/README.md b/README.md index 25dbb7df..6581fe58 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Target: x86_64-unknown-linux-gnu - Linux-based platforms listed on https://swift.org/download - CentOS 7 will not be supported due to some dependencies of swiftly not supporting it, however. -Right now, swiftly is in the very early stages of development and is only supported on Linux, but the long term plan is to also support macOS. For more detailed information about swiftly's intended features and implementation, check out the [design document](DESIGN.md). +Right now, swiftly is in early stages of development and is supported on Linux and macOS. For more detailed information about swiftly's intended features and implementation, check out the [design document](DESIGN.md). ## Command interface overview diff --git a/Sources/LinuxPlatform/Linux.swift b/Sources/LinuxPlatform/Linux.swift index 2126c09e..3129b59d 100644 --- a/Sources/LinuxPlatform/Linux.swift +++ b/Sources/LinuxPlatform/Linux.swift @@ -17,6 +17,18 @@ public struct Linux: Platform { } } + public var swiftlyBinDir: URL { + SwiftlyCore.mockedHomeDir.map { $0.appendingPathComponent("bin", isDirectory: true) } + ?? ProcessInfo.processInfo.environment["SWIFTLY_BIN_DIR"].map { URL(fileURLWithPath: $0) } + ?? FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".local", isDirectory: true) + .appendingPathComponent("bin", isDirectory: true) + } + + public var swiftlyToolchainsDir: URL { + self.swiftlyHomeDir.appendingPathComponent("toolchains", isDirectory: true) + } + public var toolchainFileExtension: String { "tar.gz" } @@ -201,25 +213,7 @@ public struct Linux: Platform { do { try self.runProgram("gpg", "--verify", sigFile.path, archive.path) } catch { - throw Error(message: "Toolchain signature verification failed: \(error)") - } - } - - private func runProgram(_ args: String..., quiet: Bool = false) throws { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = args - - if quiet { - process.standardOutput = nil - process.standardError = nil - } - - try process.run() - process.waitUntilExit() - - guard process.terminationStatus == 0 else { - throw Error(message: "\(args.first!) exited with non-zero status: \(process.terminationStatus)") + throw Error(message: "Toolchain signature verification failed: \(error).") } } diff --git a/Sources/MacOSPlatform/MacOS.swift b/Sources/MacOSPlatform/MacOS.swift new file mode 100644 index 00000000..9d92289b --- /dev/null +++ b/Sources/MacOSPlatform/MacOS.swift @@ -0,0 +1,201 @@ +import Foundation +import SwiftlyCore + +public struct SwiftPkgInfo: Codable { + public var CFBundleIdentifier: String + + public init(CFBundleIdentifier: String) { + self.CFBundleIdentifier = CFBundleIdentifier + } +} + +/// `Platform` implementation for macOS systems. +public struct MacOS: Platform { + public init() {} + + public var appDataDirectory: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support", isDirectory: true) + } + + public var swiftlyBinDir: URL { + SwiftlyCore.mockedHomeDir.map { $0.appendingPathComponent("bin", isDirectory: true) } + ?? ProcessInfo.processInfo.environment["SWIFTLY_BIN_DIR"].map { URL(fileURLWithPath: $0) } + ?? FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/swiftly/bin", isDirectory: true) + } + + public var swiftlyToolchainsDir: URL { + SwiftlyCore.mockedHomeDir.map { $0.appendingPathComponent("Toolchains", isDirectory: true) } + // The toolchains are always installed here by the installer. We bypass the installer in the case of test mocks + ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Developer/Toolchains", isDirectory: true) + } + + public var toolchainFileExtension: String { + "pkg" + } + + public func isSystemDependencyPresent(_: SystemDependency) -> Bool { + // All system dependencies on macOS should be present + true + } + + public func verifySystemPrerequisitesForInstall(requireSignatureValidation _: Bool) throws { + // All system prerequisites should be there for macOS + } + + public func install(from tmpFile: URL, version: ToolchainVersion) throws { + guard tmpFile.fileExists() else { + throw Error(message: "\(tmpFile) doesn't exist") + } + + if !self.swiftlyToolchainsDir.fileExists() { + try FileManager.default.createDirectory(at: self.swiftlyToolchainsDir, withIntermediateDirectories: false) + } + + if SwiftlyCore.mockedHomeDir == nil { + SwiftlyCore.print("Installing package in user home directory...") + try runProgram("installer", "-pkg", tmpFile.path, "-target", "CurrentUserHomeDirectory") + } else { + // In the case of a mock for testing purposes we won't use the installer, perferring a manual process because + // the installer will not install to an arbitrary path, only a volume or user home directory. + let tmpDir = self.getTempFilePath() + let toolchainDir = self.swiftlyToolchainsDir.appendingPathComponent("\(version.identifier).xctoolchain", isDirectory: true) + if !toolchainDir.fileExists() { + try FileManager.default.createDirectory(at: toolchainDir, withIntermediateDirectories: false) + } + try runProgram("pkgutil", "--expand", tmpFile.path, tmpDir.path) + // There's a slight difference in the location of the special Payload file between official swift packages + // and the ones that are mocked here in the test framework. + var payload = tmpDir.appendingPathComponent("Payload") + if !payload.fileExists() { + payload = tmpDir.appendingPathComponent("\(version.identifier)-osx-package.pkg/Payload") + } + try runProgram("tar", "-C", toolchainDir.path, "-xf", payload.path) + } + } + + public func uninstall(_ toolchain: ToolchainVersion) throws { + SwiftlyCore.print("Uninstalling package in user home directory...") + + let toolchainDir = self.swiftlyToolchainsDir.appendingPathComponent("\(toolchain.identifier).xctoolchain", isDirectory: true) + + let decoder = PropertyListDecoder() + let infoPlist = toolchainDir.appendingPathComponent("Info.plist") + guard let data = try? Data(contentsOf: infoPlist) else { + throw Error(message: "could not open \(infoPlist)") + } + + guard let pkgInfo = try? decoder.decode(SwiftPkgInfo.self, from: data) else { + throw Error(message: "could not decode plist at \(infoPlist)") + } + + try FileManager.default.removeItem(at: toolchainDir) + + let homedir = ProcessInfo.processInfo.environment["HOME"]! + try? runProgram("pkgutil", "--volume", homedir, "--forget", pkgInfo.CFBundleIdentifier) + } + + public func use(_ toolchain: ToolchainVersion, currentToolchain: ToolchainVersion?) throws -> Bool { + let toolchainBinURL = self.swiftlyToolchainsDir + .appendingPathComponent(toolchain.identifier + ".xctoolchain", isDirectory: true) + .appendingPathComponent("usr", isDirectory: true) + .appendingPathComponent("bin", isDirectory: true) + + // Delete existing symlinks from previously in-use toolchain. + if let currentToolchain { + try self.unUse(currentToolchain: currentToolchain) + } + + // Ensure swiftly doesn't overwrite any existing executables without getting confirmation first. + let swiftlyBinDirContents = try FileManager.default.contentsOfDirectory(atPath: self.swiftlyBinDir.path) + let toolchainBinDirContents = try FileManager.default.contentsOfDirectory(atPath: toolchainBinURL.path) + let willBeOverwritten = Set(toolchainBinDirContents).intersection(swiftlyBinDirContents) + if !willBeOverwritten.isEmpty { + SwiftlyCore.print("The following existing executables will be overwritten:") + + for executable in willBeOverwritten { + SwiftlyCore.print(" \(self.swiftlyBinDir.appendingPathComponent(executable).path)") + } + + let proceed = SwiftlyCore.readLine(prompt: "Proceed? (y/n)") ?? "n" + + guard proceed == "y" else { + SwiftlyCore.print("Aborting use") + return false + } + } + + for executable in toolchainBinDirContents { + let linkURL = self.swiftlyBinDir.appendingPathComponent(executable) + let executableURL = toolchainBinURL.appendingPathComponent(executable) + + // Deletion confirmed with user above. + try linkURL.deleteIfExists() + + try FileManager.default.createSymbolicLink( + atPath: linkURL.path, + withDestinationPath: executableURL.path + ) + } + + SwiftlyCore.print(""" + NOTE: On macOS it is possible that the shell will pick up the system Swift on the path + instead of the one that swiftly has installed for you. You can run the 'hash -r' + command to update the shell with the latest PATHs. + + hash -r + + """ + ) + + return true + } + + public func unUse(currentToolchain: ToolchainVersion) throws { + let currentToolchainBinURL = self.swiftlyToolchainsDir + .appendingPathComponent(currentToolchain.identifier + ".xctoolchain", isDirectory: true) + .appendingPathComponent("usr", isDirectory: true) + .appendingPathComponent("bin", isDirectory: true) + + for existingExecutable in try FileManager.default.contentsOfDirectory(atPath: currentToolchainBinURL.path) { + guard existingExecutable != "swiftly" else { + continue + } + + let url = self.swiftlyBinDir.appendingPathComponent(existingExecutable) + let vals = try url.resourceValues(forKeys: [URLResourceKey.isSymbolicLinkKey]) + + guard let islink = vals.isSymbolicLink, islink else { + throw Error(message: "Found executable not managed by swiftly in SWIFTLY_BIN_DIR: \(url.path)") + } + let symlinkDest = url.resolvingSymlinksInPath() + guard symlinkDest.deletingLastPathComponent() == currentToolchainBinURL else { + throw Error(message: "Found symlink that points to non-swiftly managed executable: \(symlinkDest.path)") + } + + try self.swiftlyBinDir.appendingPathComponent(existingExecutable).deleteIfExists() + } + } + + public func listAvailableSnapshots(version _: String?) async -> [Snapshot] { + [] + } + + public func getExecutableName(forArch: String) -> String { + "swiftly-\(forArch)-macos-osx" + } + + public func currentToolchain() throws -> ToolchainVersion? { nil } + + public func getTempFilePath() -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID()).pkg") + } + + public func verifySignature(httpClient _: SwiftlyHTTPClient, archiveDownloadURL _: URL, archive _: URL) async throws { + // No signature verification is required on macOS since the pkg files have their own signing + // mechanism and the swift.org downloadables are trusted by stock macOS installations. + } + + public static let currentPlatform: any Platform = MacOS() +} diff --git a/Sources/Swiftly/Install.swift b/Sources/Swiftly/Install.swift index 22aae5d1..31f7dce0 100644 --- a/Sources/Swiftly/Install.swift +++ b/Sources/Swiftly/Install.swift @@ -59,21 +59,18 @@ struct Install: SwiftlyCommand { @Flag(inversion: .prefixedNo, help: "Verify the toolchain's PGP signature before proceeding with installation.") var verify = true - public var httpClient = SwiftlyHTTPClient() - private enum CodingKeys: String, CodingKey { case version, token, use, verify } mutating func run() async throws { let selector = try ToolchainSelector(parsing: self.version) - self.httpClient.githubToken = self.token + SwiftlyCore.httpClient.githubToken = self.token let toolchainVersion = try await self.resolve(selector: selector) var config = try Config.load() try await Self.execute( version: toolchainVersion, &config, - self.httpClient, useInstalledToolchain: self.use, verifySignature: self.verify ) @@ -82,7 +79,6 @@ struct Install: SwiftlyCommand { internal static func execute( version: ToolchainVersion, _ config: inout Config, - _ httpClient: SwiftlyHTTPClient, useInstalledToolchain: Bool, verifySignature: Bool ) async throws { @@ -121,25 +117,19 @@ struct Install: SwiftlyCommand { } url += "swift-\(versionString)-release/" - url += "\(platformString)/" - url += "swift-\(versionString)-RELEASE/" - url += "swift-\(versionString)-RELEASE-\(platformFullString).\(Swiftly.currentPlatform.toolchainFileExtension)" case let .snapshot(release): - let snapshotString: String switch release.branch { case let .release(major, minor): url += "swift-\(major).\(minor)-branch/" - snapshotString = "swift-\(major).\(minor)-DEVELOPMENT-SNAPSHOT" case .main: url += "development/" - snapshotString = "swift-DEVELOPMENT-SNAPSHOT" } - - url += "\(platformString)/" - url += "\(snapshotString)-\(release.date)-a/" - url += "\(snapshotString)-\(release.date)-a-\(platformFullString).\(Swiftly.currentPlatform.toolchainFileExtension)" } + url += "\(platformString)/" + url += "\(version.identifier)/" + url += "\(version.identifier)-\(platformFullString).\(Swiftly.currentPlatform.toolchainFileExtension)" + guard let url = URL(string: url) else { throw Error(message: "Invalid toolchain URL: \(url)") } @@ -152,7 +142,7 @@ struct Install: SwiftlyCommand { var lastUpdate = Date() do { - try await httpClient.downloadFile( + try await SwiftlyCore.httpClient.downloadFile( url: url, to: tmpFile, reportProgress: { progress in @@ -185,7 +175,7 @@ struct Install: SwiftlyCommand { if verifySignature { try await Swiftly.currentPlatform.verifySignature( - httpClient: httpClient, + httpClient: SwiftlyCore.httpClient, archiveDownloadURL: url, archive: tmpFile ) @@ -194,6 +184,7 @@ struct Install: SwiftlyCommand { try Swiftly.currentPlatform.install(from: tmpFile, version: version) config.installedToolchains.insert(version) + try config.save() // If this is the first installed toolchain, mark it as in-use regardless of whether the @@ -212,7 +203,7 @@ struct Install: SwiftlyCommand { case .latest: SwiftlyCore.print("Fetching the latest stable Swift release...") - guard let release = try await self.httpClient.getReleaseToolchains(limit: 1).first else { + guard let release = try await SwiftlyCore.httpClient.getReleaseToolchains(limit: 1).first else { throw Error(message: "couldn't get latest releases") } return .stable(release) @@ -231,7 +222,7 @@ struct Install: SwiftlyCommand { SwiftlyCore.print("Fetching the latest stable Swift \(major).\(minor) release...") // If a patch was not provided, perform a lookup to get the latest patch release // of the provided major/minor version pair. - let toolchain = try await self.httpClient.getReleaseToolchains(limit: 1) { release in + let toolchain = try await SwiftlyCore.httpClient.getReleaseToolchains(limit: 1) { release in release.major == major && release.minor == minor }.first @@ -249,7 +240,7 @@ struct Install: SwiftlyCommand { SwiftlyCore.print("Fetching the latest \(branch) branch snapshot...") // If a date was not provided, perform a lookup to find the most recent snapshot // for the given branch. - let snapshot = try await self.httpClient.getSnapshotToolchains(limit: 1) { snapshot in + let snapshot = try await SwiftlyCore.httpClient.getSnapshotToolchains(limit: 1) { snapshot in snapshot.branch == branch }.first diff --git a/Sources/Swiftly/ListAvailable.swift b/Sources/Swiftly/ListAvailable.swift index 0d49aa9a..3843b503 100644 --- a/Sources/Swiftly/ListAvailable.swift +++ b/Sources/Swiftly/ListAvailable.swift @@ -35,8 +35,6 @@ struct ListAvailable: SwiftlyCommand { )) var toolchainSelector: String? - public var httpClient = SwiftlyHTTPClient() - private enum CodingKeys: String, CodingKey { case toolchainSelector } @@ -53,7 +51,7 @@ struct ListAvailable: SwiftlyCommand { } } - let toolchains = try await self.httpClient.getReleaseToolchains() + let toolchains = try await SwiftlyCore.httpClient.getReleaseToolchains() .map(ToolchainVersion.stable) .filter { selector?.matches(toolchain: $0) ?? true } diff --git a/Sources/Swiftly/SelfUpdate.swift b/Sources/Swiftly/SelfUpdate.swift index 2ccaa172..3bab5edf 100644 --- a/Sources/Swiftly/SelfUpdate.swift +++ b/Sources/Swiftly/SelfUpdate.swift @@ -10,14 +10,12 @@ internal struct SelfUpdate: SwiftlyCommand { abstract: "Update the version of swiftly itself." ) - internal var httpClient = SwiftlyHTTPClient() - private enum CodingKeys: CodingKey {} internal mutating func run() async throws { SwiftlyCore.print("Checking for swiftly updates...") - let release: SwiftlyGitHubRelease = try await self.httpClient.getFromGitHub( + let release: SwiftlyGitHubRelease = try await SwiftlyCore.httpClient.getFromGitHub( url: "https://api.github.com/repos/swift-server/swiftly/releases/latest" ) @@ -48,7 +46,7 @@ internal struct SelfUpdate: SwiftlyCommand { header: "Downloading swiftly \(version)" ) do { - try await self.httpClient.downloadFile( + try await SwiftlyCore.httpClient.downloadFile( url: downloadURL, to: tmpFile, reportProgress: { progress in diff --git a/Sources/Swiftly/Swiftly.swift b/Sources/Swiftly/Swiftly.swift index 21731211..f2fbfac4 100644 --- a/Sources/Swiftly/Swiftly.swift +++ b/Sources/Swiftly/Swiftly.swift @@ -2,11 +2,12 @@ import ArgumentParser import Foundation #if os(Linux) import LinuxPlatform +#elseif os(macOS) +import MacOSPlatform #endif import SwiftlyCore @main -@available(macOS 10.15, *) public struct Swiftly: SwiftlyCommand { public static var configuration = CommandConfiguration( abstract: "A utility for installing and managing Swift toolchains.", @@ -37,6 +38,8 @@ public struct Swiftly: SwiftlyCommand { #if os(Linux) internal static let currentPlatform = Linux.currentPlatform +#elseif os(macOS) + internal static let currentPlatform = MacOS.currentPlatform #endif } diff --git a/Sources/Swiftly/Update.swift b/Sources/Swiftly/Update.swift index 0de6b5fd..bf1caced 100644 --- a/Sources/Swiftly/Update.swift +++ b/Sources/Swiftly/Update.swift @@ -67,8 +67,6 @@ struct Update: SwiftlyCommand { @Flag(inversion: .prefixedNo, help: "Verify the toolchain's PGP signature before proceeding with installation.") var verify = true - public var httpClient = SwiftlyHTTPClient() - private enum CodingKeys: String, CodingKey { case toolchain, assumeYes, verify } @@ -106,7 +104,6 @@ struct Update: SwiftlyCommand { try await Install.execute( version: newToolchain, &config, - self.httpClient, useInstalledToolchain: config.inUse == parameters.oldToolchain, verifySignature: self.verify ) @@ -165,7 +162,7 @@ struct Update: SwiftlyCommand { private func lookupNewToolchain(_ bounds: UpdateParameters) async throws -> ToolchainVersion? { switch bounds { case let .stable(old, range): - return try await self.httpClient.getReleaseToolchains(limit: 1) { release in + return try await SwiftlyCore.httpClient.getReleaseToolchains(limit: 1) { release in switch range { case .latest: return release > old @@ -176,7 +173,7 @@ struct Update: SwiftlyCommand { } }.first.map(ToolchainVersion.stable) case let .snapshot(old): - return try await self.httpClient.getSnapshotToolchains(limit: 1) { snapshot in + return try await SwiftlyCore.httpClient.getSnapshotToolchains(limit: 1) { snapshot in snapshot.branch == old.branch && snapshot.date > old.date }.first.map(ToolchainVersion.snapshot) } diff --git a/Sources/SwiftlyCore/HTTPClient.swift b/Sources/SwiftlyCore/HTTPClient.swift index 2f6fef38..9718e05b 100644 --- a/Sources/SwiftlyCore/HTTPClient.swift +++ b/Sources/SwiftlyCore/HTTPClient.swift @@ -9,7 +9,7 @@ public protocol HTTPRequestExecutor { func execute(_ request: HTTPClientRequest, timeout: TimeAmount) async throws -> HTTPClientResponse } -/// An `HTTPRequestExecutor` backed by an `HTTPClient`. +/// An `HTTPRequestExecutor` backed by the shared `HTTPClient`. internal struct HTTPRequestExecutorImpl: HTTPRequestExecutor { public func execute(_ request: HTTPClientRequest, timeout: TimeAmount) async throws -> HTTPClientResponse { try await HTTPClient.shared.execute(request, timeout: timeout) @@ -29,15 +29,11 @@ public struct SwiftlyHTTPClient { let buffer: ByteBuffer } - private let executor: HTTPRequestExecutor + public init() {} /// The GitHub authentication token to use for any requests made to the GitHub API. public var githubToken: String? - public init(executor: HTTPRequestExecutor? = nil) { - self.executor = executor ?? HTTPRequestExecutorImpl() - } - private func get(url: String, headers: [String: String], maxBytes: Int) async throws -> Response { var request = makeRequest(url: url) @@ -45,7 +41,7 @@ public struct SwiftlyHTTPClient { request.headers.add(name: k, value: v) } - let response = try await self.executor.execute(request, timeout: .seconds(30)) + let response = try await SwiftlyCore.httpRequestExecutor.execute(request, timeout: .seconds(30)) return Response(status: response.status, buffer: try await response.body.collect(upTo: maxBytes)) } @@ -144,7 +140,7 @@ public struct SwiftlyHTTPClient { } let request = makeRequest(url: url.absoluteString) - let response = try await self.executor.execute(request, timeout: .seconds(30)) + let response = try await SwiftlyCore.httpRequestExecutor.execute(request, timeout: .seconds(30)) switch response.status { case .ok: diff --git a/Sources/SwiftlyCore/Platform.swift b/Sources/SwiftlyCore/Platform.swift index 14e2d27c..5087fe32 100644 --- a/Sources/SwiftlyCore/Platform.swift +++ b/Sources/SwiftlyCore/Platform.swift @@ -5,6 +5,17 @@ public protocol Platform { /// supposed to store their custom data. var appDataDirectory: URL { get } + /// The directory which stores the swiftly executable itself as well as symlinks + /// to executables in the "bin" directory of the active toolchain. + /// + /// If a mocked home directory is set, this will be the "bin" subdirectory of the home directory. + /// If not, this will be the SWIFTLY_BIN_DIR environment variable if set. If that's also unset, + /// this will default to the platform's default location. + var swiftlyBinDir: URL { get } + + /// The "toolchains" subdirectory that contains the Swift toolchains managed by swiftly. + var swiftlyToolchainsDir: URL { get } + /// The file extension of the downloaded toolchain for this platform. /// e.g. for Linux systems this is "tar.gz" and on macOS it's "pkg". var toolchainFileExtension: String { get } @@ -72,29 +83,30 @@ extension Platform { ?? self.appDataDirectory.appendingPathComponent("swiftly", isDirectory: true) } - /// The directory which stores the swiftly executable itself as well as symlinks - /// to executables in the "bin" directory of the active toolchain. - /// - /// If a mocked home directory is set, this will be the "bin" subdirectory of the home directory. - /// If not, this will be the SWIFTLY_BIN_DIR environment variable if set. If that's also unset, - /// this will default to ~/.local/bin. - public var swiftlyBinDir: URL { - SwiftlyCore.mockedHomeDir.map { $0.appendingPathComponent("bin", isDirectory: true) } - ?? ProcessInfo.processInfo.environment["SWIFTLY_BIN_DIR"].map { URL(fileURLWithPath: $0) } - ?? FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".local", isDirectory: true) - .appendingPathComponent("bin", isDirectory: true) - } - - /// The "toolchains" subdirectory of swiftly's home directory. Contains the Swift toolchains managed by swiftly. - public var swiftlyToolchainsDir: URL { - self.swiftlyHomeDir.appendingPathComponent("toolchains", isDirectory: true) - } - /// The URL of the configuration file in swiftly's home directory. public var swiftlyConfigFile: URL { self.swiftlyHomeDir.appendingPathComponent("config.json") } + +#if os(macOS) || os(Linux) + public func runProgram(_ args: String..., quiet: Bool = false) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = args + + if quiet { + process.standardOutput = nil + process.standardError = nil + } + + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + throw Error(message: "\(args.first!) exited with non-zero status: \(process.terminationStatus)") + } + } +#endif } public struct SystemDependency {} diff --git a/Sources/SwiftlyCore/SwiftlyCore.swift b/Sources/SwiftlyCore/SwiftlyCore.swift index a038c1f4..562213e8 100644 --- a/Sources/SwiftlyCore/SwiftlyCore.swift +++ b/Sources/SwiftlyCore/SwiftlyCore.swift @@ -6,6 +6,15 @@ public let version = SwiftlyVersion(major: 0, minor: 3, patch: 0) /// home directory location logic. public var mockedHomeDir: URL? +/// This is the default http client that swiftly uses for its network +/// requests. +public var httpClient = SwiftlyHTTPClient() + +/// An HTTP request executor that allows different transport level configuration +/// such as allowing a proxy to be configured, or for the purpose of mocking +/// for tests. +public var httpRequestExecutor: HTTPRequestExecutor = HTTPRequestExecutorImpl() + /// Protocol defining a handler for information swiftly intends to print to stdout. /// This is currently only used to intercept print statements for testing. public protocol OutputHandler { diff --git a/Sources/SwiftlyCore/ToolchainVerison.swift b/Sources/SwiftlyCore/ToolchainVerison.swift index 337b80df..72777343 100644 --- a/Sources/SwiftlyCore/ToolchainVerison.swift +++ b/Sources/SwiftlyCore/ToolchainVerison.swift @@ -175,6 +175,24 @@ public enum ToolchainVersion { } } } + + public var identifier: String { + switch self { + case let .stable(release) where release.patch == 0: + return "swift-\(release.major).\(release.minor)-RELEASE" + case let .stable(release) where release.minor == 0 && release.patch == 0: + return "swift-\(release.major)-RELEASE" + case let .stable(release): + return "swift-\(release.major).\(release.minor).\(release.patch)-RELEASE" + case let .snapshot(release): + switch release.branch { + case .main: + return "swift-DEVELOPMENT-SNAPSHOT-\(release.date)-a" + case let .release(major, minor): + return "swift-\(major).\(minor)-DEVELOPMENT-SNAPSHOT-\(release.date)-a" + } + } + } } extension ToolchainVersion: LosslessStringConvertible { diff --git a/Tests/SwiftlyTests/InstallTests.swift b/Tests/SwiftlyTests/InstallTests.swift index 04fc5ee6..1c11bc54 100644 --- a/Tests/SwiftlyTests/InstallTests.swift +++ b/Tests/SwiftlyTests/InstallTests.swift @@ -16,7 +16,10 @@ final class InstallTests: SwiftlyTests { let config = try Config.load() - XCTAssertTrue(!config.installedToolchains.isEmpty) + guard !config.installedToolchains.isEmpty else { + XCTFail("expected to install latest main snapshot toolchain but installed toolchains is empty in the config") + return + } let installedToolchain = config.installedToolchains.first! @@ -45,7 +48,10 @@ final class InstallTests: SwiftlyTests { let config = try Config.load() - XCTAssertTrue(!config.installedToolchains.isEmpty) + guard !config.installedToolchains.isEmpty else { + XCTFail("expected swiftly install latest to insall release toolchain but installed toolchains is empty in config") + return + } let installedToolchain = config.installedToolchains.first! @@ -125,7 +131,10 @@ final class InstallTests: SwiftlyTests { let config = try Config.load() - XCTAssertTrue(!config.installedToolchains.isEmpty) + guard !config.installedToolchains.isEmpty else { + XCTFail("expected to install latest main snapshot toolchain but installed toolchains is empty in the config") + return + } let installedToolchain = config.installedToolchains.first! @@ -152,7 +161,10 @@ final class InstallTests: SwiftlyTests { let config = try Config.load() - XCTAssertTrue(!config.installedToolchains.isEmpty) + guard !config.installedToolchains.isEmpty else { + XCTFail("expected to install latest main snapshot toolchain but installed toolchains is empty in the config") + return + } let installedToolchain = config.installedToolchains.first! @@ -206,7 +218,7 @@ final class InstallTests: SwiftlyTests { try await cmd.run() // Assert that swiftly didn't attempt to download a new toolchain. - XCTAssertTrue(startTime.timeIntervalSinceNow.magnitude < 5) + XCTAssertTrue(startTime.timeIntervalSinceNow.magnitude < 10) let after = try Config.load() XCTAssertEqual(before, after) diff --git a/Tests/SwiftlyTests/SelfUpdateTests.swift b/Tests/SwiftlyTests/SelfUpdateTests.swift index e3f6f707..b405d294 100644 --- a/Tests/SwiftlyTests/SelfUpdateTests.swift +++ b/Tests/SwiftlyTests/SelfUpdateTests.swift @@ -18,8 +18,8 @@ final class SelfUpdateTests: SwiftlyTests { "\(SwiftlyCore.version.major).\(SwiftlyCore.version.minor).\(SwiftlyCore.version.patch + 1)" } - private static func makeMockHTTPClient(latestVersion: String) -> SwiftlyHTTPClient { - .mocked { request in + private static func mockHTTPHandler(latestVersion: String) -> ((HTTPClientRequest) async throws -> HTTPClientResponse) { + return { request in guard let url = URL(string: request.url) else { throw SwiftlyTestError(message: "invalid url \(request.url)") } @@ -45,8 +45,10 @@ final class SelfUpdateTests: SwiftlyTests { try Data("old".utf8).write(to: swiftlyURL) var update = try self.parseCommand(SelfUpdate.self, ["self-update"]) - update.httpClient = Self.makeMockHTTPClient(latestVersion: latestVersion) - try await update.run() + + try await self.withMockedHTTPRequests(Self.mockHTTPHandler(latestVersion: latestVersion)) { + try await update.run() + } let swiftly = try Data(contentsOf: swiftlyURL) diff --git a/Tests/SwiftlyTests/SwiftlyTests.swift b/Tests/SwiftlyTests/SwiftlyTests.swift index 8981fc0d..896556fb 100644 --- a/Tests/SwiftlyTests/SwiftlyTests.swift +++ b/Tests/SwiftlyTests/SwiftlyTests.swift @@ -6,11 +6,62 @@ import NIO @testable import SwiftlyCore import XCTest +#if os(macOS) +import MacOSPlatform +#endif + +import AsyncHTTPClient +import NIO + struct SwiftlyTestError: LocalizedError { let message: String } +var proxyExecutorInstalled = false + +/// An `HTTPRequestExecutor` backed by an `HTTPClient` that can take http proxy +/// information from the environment in either HTTP_PROXY or HTTPS_PROXY +class ProxyHTTPRequestExecutorImpl: HTTPRequestExecutor { + let httpClient: HTTPClient + public init() { + var proxy: HTTPClient.Configuration.Proxy? + + let environment = ProcessInfo.processInfo.environment + let httpProxy = environment["HTTP_PROXY"] + if let httpProxy, let url = URL(string: httpProxy), let host = url.host, let port = url.port { + proxy = .server(host: host, port: port) + } + + let httpsProxy = environment["HTTPS_PROXY"] + if let httpsProxy, let url = URL(string: httpsProxy), let host = url.host, let port = url.port { + proxy = .server(host: host, port: port) + } + + if proxy != nil { + self.httpClient = HTTPClient(eventLoopGroupProvider: .singleton, configuration: HTTPClient.Configuration(proxy: proxy)) + } else { + self.httpClient = HTTPClient.shared + } + } + + public func execute(_ request: HTTPClientRequest, timeout: TimeAmount) async throws -> HTTPClientResponse { + try await self.httpClient.execute(request, timeout: timeout) + } + + deinit { + if httpClient !== HTTPClient.shared { + try? httpClient.syncShutdown() + } + } +} + class SwiftlyTests: XCTestCase { + override class func setUp() { + if !proxyExecutorInstalled { + SwiftlyCore.httpRequestExecutor = ProxyHTTPRequestExecutorImpl() + } + } + // Below are some constants that can be used to write test cases. static let oldStable = ToolchainVersion(major: 5, minor: 6, patch: 0) static let oldStableNewPatch = ToolchainVersion(major: 5, minor: 6, patch: 3) @@ -38,6 +89,18 @@ class SwiftlyTests: XCTestCase { return v } +#if os(macOS) + return Config( + inUse: nil, + installedToolchains: [], + platform: Config.PlatformDefinition( + name: "xcode", + nameFull: "osx", + namePretty: "macOS", + architecture: nil + ) + ) +#else return Config( inUse: nil, installedToolchains: [], @@ -48,6 +111,7 @@ class SwiftlyTests: XCTestCase { architecture: try? getEnv("SWIFTLY_PLATFORM_ARCH") ) ) +#endif } func parseCommand(_ commandType: T.Type, _ arguments: [String]) throws -> T { @@ -119,6 +183,28 @@ class SwiftlyTests: XCTestCase { } } + func withMockedToolchain(executables: [String]? = nil, f: () async throws -> Void) async throws { + let prevExecutor = SwiftlyCore.httpRequestExecutor + let mockDownloader = MockToolchainDownloader(executables: executables, prevExecutor: prevExecutor) + SwiftlyCore.httpRequestExecutor = mockDownloader + defer { + SwiftlyCore.httpRequestExecutor = prevExecutor + } + + try await f() + } + + func withMockedHTTPRequests(_ handler: @escaping (HTTPClientRequest) async throws -> HTTPClientResponse, _ f: () async throws -> Void) async throws { + let prevExecutor = SwiftlyCore.httpRequestExecutor + let mockedRequestExecutor = MockHTTPRequestExecutor(handler: handler) + SwiftlyCore.httpRequestExecutor = mockedRequestExecutor + defer { + SwiftlyCore.httpRequestExecutor = prevExecutor + } + + try await f() + } + /// Validates that the provided toolchain is the one currently marked as "in use", both by checking the /// configuration file and by executing `swift --version` using the swift executable in the `bin` directory. /// If nil is provided, this validates that no toolchain is currently in use. @@ -175,8 +261,10 @@ class SwiftlyTests: XCTestCase { /// When executed, the mocked executables will simply print the toolchain version and return. func installMockedToolchain(selector: String, args: [String] = [], executables: [String]? = nil) async throws { var install = try self.parseCommand(Install.self, ["install", "\(selector)", "--no-verify"] + args) - install.httpClient = SwiftlyHTTPClient(executor: MockToolchainDownloader(executables: executables)) - try await install.run() + + try await self.withMockedToolchain(executables: executables) { + try await install.run() + } } /// Install a mocked toolchain according to the provided selector that includes the provided list of executables @@ -361,12 +449,6 @@ private struct MockHTTPRequestExecutor: HTTPRequestExecutor { } } -extension SwiftlyHTTPClient { - public static func mocked(_ handler: @escaping (HTTPClientRequest) async throws -> HTTPClientResponse) -> Self { - Self(executor: MockHTTPRequestExecutor(handler: handler)) - } -} - /// An `HTTPRequestExecutor` which will return a mocked response to any toolchain download requests. /// All other requests are performed using an actual HTTP client. public struct MockToolchainDownloader: HTTPRequestExecutor { @@ -376,11 +458,11 @@ public struct MockToolchainDownloader: HTTPRequestExecutor { try! Regex("swift(?:-[0-9]+\\.[0-9]+)?-DEVELOPMENT-SNAPSHOT-[0-9]{4}-[0-9]{2}-[0-9]{2}") private let executables: [String] - private let httpRequestExecutor: HTTPRequestExecutor + public let httpRequestExecutor: HTTPRequestExecutor - public init(executables: [String]? = nil) { + public init(executables: [String]? = nil, prevExecutor: HTTPRequestExecutor) { self.executables = executables ?? ["swift"] - self.httpRequestExecutor = HTTPRequestExecutorImpl() + self.httpRequestExecutor = prevExecutor } public func execute(_ request: HTTPClientRequest, timeout: TimeAmount) async throws -> HTTPClientResponse { @@ -419,6 +501,7 @@ public struct MockToolchainDownloader: HTTPRequestExecutor { return HTTPClientResponse(body: .bytes(ByteBuffer(data: mockedToolchain))) } +#if os(Linux) func makeMockedToolchain(toolchain: ToolchainVersion) throws -> Data { let tmp = FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID())") let toolchainDir = tmp.appendingPathComponent("toolchain", isDirectory: true) @@ -461,4 +544,67 @@ public struct MockToolchainDownloader: HTTPRequestExecutor { return try Data(contentsOf: archive) } + +#elseif os(macOS) + + func makeMockedToolchain(toolchain: ToolchainVersion) throws -> Data { + let tmp = FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID())") + let toolchainDir = tmp.appendingPathComponent("toolchain", isDirectory: true) + let toolchainBinDir = toolchainDir.appendingPathComponent("usr/bin", isDirectory: true) + + try FileManager.default.createDirectory( + at: toolchainBinDir, + withIntermediateDirectories: true + ) + + defer { + try? FileManager.default.removeItem(at: tmp) + } + + for executable in self.executables { + let executablePath = toolchainBinDir.appendingPathComponent(executable) + + let script = """ + #!/usr/bin/env sh + + echo '\(toolchain.name)' + """ + + let data = Data(script.utf8) + try data.write(to: executablePath) + + // make the file executable + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executablePath.path) + } + + // Add a skeletal Info.plist at the top + let encoder = PropertyListEncoder() + encoder.outputFormat = .xml + let pkgInfo = SwiftPkgInfo(CFBundleIdentifier: "org.swift.swift.mock.\(toolchain.name)") + let data = try encoder.encode(pkgInfo) + try data.write(to: toolchainDir.appendingPathComponent("Info.plist")) + + let pkg = tmp.appendingPathComponent("toolchain.pkg") + + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/env") + task.arguments = [ + "pkgbuild", + "--root", + toolchainDir.path, + "--install-location", + "Library/Developer/Toolchains/\(toolchain.identifier).xctoolchain", + "--version", + "\(toolchain.name)", + "--identifier", + pkgInfo.CFBundleIdentifier, + pkg.path, + ] + try task.run() + task.waitUntilExit() + + return try Data(contentsOf: pkg) + } + +#endif } diff --git a/Tests/SwiftlyTests/UpdateTests.swift b/Tests/SwiftlyTests/UpdateTests.swift index 2a5b15b9..b34672f6 100644 --- a/Tests/SwiftlyTests/UpdateTests.swift +++ b/Tests/SwiftlyTests/UpdateTests.swift @@ -4,7 +4,21 @@ import Foundation import XCTest final class UpdateTests: SwiftlyTests { - private let mockHttpClient = SwiftlyHTTPClient(executor: MockToolchainDownloader()) + private var mockedToolchainDownloader: MockToolchainDownloader? + + override public func setUp() { + if !proxyExecutorInstalled { + SwiftlyCore.httpRequestExecutor = ProxyHTTPRequestExecutorImpl() + } + + self.mockedToolchainDownloader = MockToolchainDownloader(prevExecutor: SwiftlyCore.httpRequestExecutor) + SwiftlyCore.httpRequestExecutor = self.mockedToolchainDownloader! + } + + override public func tearDown() { + SwiftlyCore.httpRequestExecutor = self.mockedToolchainDownloader!.httpRequestExecutor + self.mockedToolchainDownloader = nil + } /// Verify updating the most up-to-date toolchain has no effect. func testUpdateLatest() async throws { @@ -14,7 +28,6 @@ final class UpdateTests: SwiftlyTests { let beforeUpdateConfig = try Config.load() var update = try self.parseCommand(Update.self, ["update", "latest", "--no-verify"]) - update.httpClient = self.mockHttpClient try await update.run() XCTAssertEqual(try Config.load(), beforeUpdateConfig) @@ -29,7 +42,6 @@ final class UpdateTests: SwiftlyTests { func testUpdateLatestWithNoToolchains() async throws { try await self.withTestHome { var update = try self.parseCommand(Update.self, ["update", "latest", "--no-verify"]) - update.httpClient = self.mockHttpClient try await update.run() try await validateInstalledToolchains( @@ -44,7 +56,6 @@ final class UpdateTests: SwiftlyTests { try await self.withTestHome { try await self.installMockedToolchain(selector: .stable(major: 5, minor: 0, patch: 0)) var update = try self.parseCommand(Update.self, ["update", "-y", "latest", "--no-verify"]) - update.httpClient = self.mockHttpClient try await update.run() let config = try Config.load() @@ -64,7 +75,6 @@ final class UpdateTests: SwiftlyTests { try await self.withTestHome { try await self.installMockedToolchain(selector: .stable(major: 5, minor: 0, patch: 0)) var update = try self.parseCommand(Update.self, ["update", "-y", "5", "--no-verify"]) - update.httpClient = self.mockHttpClient try await update.run() let config = try Config.load() @@ -86,7 +96,6 @@ final class UpdateTests: SwiftlyTests { try await self.installMockedToolchain(selector: "5.0.0") var update = try self.parseCommand(Update.self, ["update", "-y", "5.0.0", "--no-verify"]) - update.httpClient = self.mockHttpClient try await update.run() let config = try Config.load() @@ -110,7 +119,6 @@ final class UpdateTests: SwiftlyTests { try await self.installMockedToolchain(selector: "5.0.0") var update = try self.parseCommand(Update.self, ["update", "-y", "--no-verify"]) - update.httpClient = self.mockHttpClient try await update.run() let config = try Config.load() @@ -144,7 +152,6 @@ final class UpdateTests: SwiftlyTests { var update = try self.parseCommand( Update.self, ["update", "-y", "\(branch.name)-snapshot", "--no-verify"] ) - update.httpClient = self.mockHttpClient try await update.run() let config = try Config.load() @@ -168,7 +175,6 @@ final class UpdateTests: SwiftlyTests { try await self.installMockedToolchain(selector: "5.0.0") var update = try self.parseCommand(Update.self, ["update", "-y", "5.0", "--no-verify"]) - update.httpClient = self.mockHttpClient try await update.run() let config = try Config.load() @@ -199,7 +205,6 @@ final class UpdateTests: SwiftlyTests { var update = try self.parseCommand( Update.self, ["update", "-y", "\(branch.name)-snapshot", "--no-verify"] ) - update.httpClient = self.mockHttpClient try await update.run() let config = try Config.load() diff --git a/install/swiftly-install.sh b/install/swiftly-install.sh index c5ee35fe..780475cc 100755 --- a/install/swiftly-install.sh +++ b/install/swiftly-install.sh @@ -4,13 +4,14 @@ # Script used to install and configure swiftly. # # This script will download the latest released swiftly executable and install it -# to $SWIFTLY_BIN_DIR, or ~/.local/bin if that variable isn't specified. +# to $SWIFTLY_BIN_DIR, or ~/.local/bin (Linux) and ~/Library/Application Support/swiftly/bin (macOS) +# if that variable isn't specified. # # This script will also create a directory at $SWIFTLY_HOME_DIR, or # $XDG_DATA_HOME/swiftly if that variable isn't specified. If XDG_DATA_HOME is also unset, -# ~/.local/share/swiftly will be used as a default instead. swiftly will use this directory -# to store platform information, downloaded toolchains, and other state required to manage -# the toolchains. +# ~/.local/share/swiftly (Linux) or ~/Library/Application Support/swift (macOS) will be used as a default +# instead. swiftly will use this directory to store platform information, downloaded toolchains, and other +# state required to manage the toolchains. # # After installation, the script will create $SWIFTLY_HOME_DIR/env.{sh,fish}, which can be sourced # to properly set up the environment variables required to run swiftly. Unless --no-modify-profile @@ -330,18 +331,27 @@ if ! has_command "curl" ; then exit 1 fi -if ! verify_getopt_install ; then - echo "Error: getopt must be installed from the util-linux package to run swiftly-install" - exit 1 +IS_MACOS="false" +if [ "$(uname -s)" == "Darwin" ] ; then + IS_MACOS="true" +fi + +if [ "$IS_MACOS" == "false" ]; then + if ! verify_getopt_install ; then + echo "Error: getopt must be installed from the util-linux package to run swiftly-install" + exit 1 + fi fi set -o errexit shopt -s extglob -short_options='yhvp:' -long_options='disable-confirmation,no-modify-profile,no-install-system-deps,help,version,platform:,overwrite' +if [ "$IS_MACOS" == "true" ]; then + args=$(getopt ynohvp: $*) +else + args=$(getopt --options ynohvp: --longoptions disable-confirmation,no-modify-profile,no-install-system-deps,help,version,platform:,overwrite --name swiftly-install -- "${@}") +fi -args=$(getopt --options "$short_options" --longoptions "$long_options" --name "swiftly-install" -- "${@}") eval "set -- ${args}" while [ true ]; do @@ -356,20 +366,24 @@ USAGE: OPTIONS: -y, --disable-confirmation Disable confirmation prompts. - --no-modify-profile Do not attempt to modify the profile file to set environment + -n, --no-modify-profile Do not attempt to modify the profile file to set environment variables (e.g. PATH) on login. - --no-install-system-deps Do not attempt to install Swift's required system dependencies. - --no-import-pgp-keys Do not attempt to import Swift's PGP keys. + --no-install-system-deps Do not attempt to install Swift's required system dependencies (LINUX ONLY) + --no-import-pgp-keys Do not attempt to import Swift's PGP keys. (LINUX ONLY) -p, --platform Specifies which platform's toolchains swiftly will download. If unspecified, the platform will be automatically detected. Available options are "ubuntu22.04", "ubuntu20.04", "ubuntu18.04", "rhel9", and - "amazonlinux2". - --overwrite Overwrite the existing swiftly installation found at the configured + "amazonlinux2". (LINUX ONLY) + -o, --overwrite Overwrite the existing swiftly installation found at the configured SWIFTLY_HOME, if any. If this option is unspecified and an existing installation is found, the swiftly executable will be updated, but the rest of the installation will not be modified. -h, --help Prints help information. - --version Prints version information. + -v, --version Prints version information. + +NOTES: + macOS only works with the short options (e.g. -v and not --version). + EOF exit 0 ;; @@ -379,7 +393,7 @@ EOF shift ;; - "--no-modify-profile") + "--no-modify-profile" | "-n") MODIFY_PROFILE="false" shift ;; @@ -394,7 +408,7 @@ EOF shift ;; - "--version") + "--version" | "-v") echo "$SWIFTLY_INSTALL_VERSION" exit 0 ;; @@ -429,7 +443,7 @@ EOF shift 2 ;; - "--overwrite") + "--overwrite" | "-o") overwrite_existing_intallation="true" shift ;; @@ -438,16 +452,20 @@ EOF shift break ;; - *) - echo "Error: unrecognized option \"$arg\"" + echo "Error: unrecognized option \"$1\"" + if [ "$IS_MACOS" == "true" ]; then + echo "Note that on macOS you must use the short options (e.g. -v, not --version)." + fi exit 1 ;; esac done -if [[ -z "$PLATFORM_NAME" ]]; then - detect_platform +if [ "$IS_MACOS" == "false" ]; then + if [[ -z "$PLATFORM_NAME" ]]; then + detect_platform + fi fi RAW_ARCH="$(uname -m)" @@ -468,7 +486,8 @@ case "$RAW_ARCH" in ;; esac -JSON_OUT=$(cat < "$HOME_DIR/config.json" + mkdir -p "$HOME_DIR" + if [ "$IS_MACOS" == "false" ]; then + echo "$JSON_OUT" > "$HOME_DIR/config.json" + fi # Verify the downloaded executable works. The script will exit if this fails due to errexit. SWIFTLY_HOME_DIR="$HOME_DIR" SWIFTLY_BIN_DIR="$BIN_DIR" "$BIN_DIR/swiftly" --version > /dev/null @@ -632,19 +671,23 @@ if [[ "$detected_existing_installation" != "true" || "$overwrite_existing_intall fi fi -if [[ "$SWIFTLY_INSTALL_SYSTEM_DEPS" != "false" ]]; then - echo "" - echo "Installing Swift's system dependencies via $package_manager (note: this may require root access)..." - install_system_deps +if [ "$IS_MACOS" == "false" ]; then + if [[ "$SWIFTLY_INSTALL_SYSTEM_DEPS" != "false" ]]; then + echo "" + echo "Installing Swift's system dependencies via $package_manager (note: this may require root access)..." + install_system_deps + fi fi -if [[ "$swiftly_import_pgp_keys" != "false" ]]; then - if has_command gpg ; then - echo "" - echo "Importing Swift's PGP keys..." - curl --silent --retry 3 --location --fail https://swift.org/keys/all-keys.asc | gpg --import - - else - echo "gpg not installed, skipping import of Swift's PGP keys." +if [ "$IS_MACOS" == "false" ]; then + if [[ "$swiftly_import_pgp_keys" != "false" ]]; then + if has_command gpg ; then + echo "" + echo "Importing Swift's PGP keys..." + curl --silent --retry 3 --location --fail https://swift.org/keys/all-keys.asc | gpg --import - + else + echo "gpg not installed, skipping import of Swift's PGP keys." + fi fi fi