diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a11276ad..ba77ba5b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,14 +8,14 @@ on: jobs: build: - runs-on: ubuntu-18.04 + runs-on: ubuntu-20.04 steps: - name: Download Swift run: | - wget --no-verbose "https://download.swift.org/swift-5.7-release/ubuntu1804/swift-5.7-RELEASE/swift-5.7-RELEASE-ubuntu18.04.tar.gz" - tar -zxf swift-5.7-RELEASE-ubuntu18.04.tar.gz + wget --no-verbose "https://download.swift.org/swift-5.7-release/ubuntu2004/swift-5.7-RELEASE/swift-5.7-RELEASE-ubuntu20.04.tar.gz" + tar -zxf swift-5.7-RELEASE-ubuntu20.04.tar.gz mkdir $HOME/.swift - mv swift-5.7-RELEASE-ubuntu18.04/usr $HOME/.swift + mv swift-5.7-RELEASE-ubuntu20.04/usr $HOME/.swift - name: Update PATH run: echo "$HOME/.swift/usr/bin" >> $GITHUB_PATH @@ -31,7 +31,7 @@ jobs: - name: Run tests run: swift test env: - SWIFTLY_PLATFORM_NAME: ubuntu1804 - SWIFTLY_PLATFORM_NAME_FULL: ubuntu18.04 - SWIFTLY_PLATFORM_NAME_PRETTY: Ubuntu 18.04 + SWIFTLY_PLATFORM_NAME: ubuntu2004 + SWIFTLY_PLATFORM_NAME_FULL: ubuntu20.04 + SWIFTLY_PLATFORM_NAME_PRETTY: Ubuntu 20.04 SWIFTLY_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/DESIGN.md b/DESIGN.md index 69fe95bb..ed69e853 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -31,33 +31,23 @@ We'll need a bootstrapping script which detects information about the OS and dow - CentOS 8 - Amazon Linux 2 -Once it has detected which platform the user is running, the script will then create `$HOME/.swiftly` (or a different path, if the user provides one. For an initial MVP, I think we can always install there). It'll also create `$HOME/.swiftly/bin`, download the prebuilt swiftly executable appropriate for the platform and drop it in there. - -Finally, it will create a `$HOME/.swiftly/env` file, which contains only the following line: - -``` -export PATH="$HOME/.swiftly/bin:$PATH" -``` - -and print a message instructing the user to run source `~/.swiftly/env` and to add it to their shell configuration. We may have to do some discovery to determine which shell the user is running for this. Printing instructions for bash and zsh should be sufficient. +Once it has detected which platform the user is running, the script will then create `$HOME/.local/share/swiftly` (or a different path, if the user provides one. For an initial MVP, I think we can always install there). It'll also create `$HOME/.local/bin` if needed, download the prebuilt swiftly executable appropriate for the platform, and drop it in there. ### Installation of a Swift toolchain A simple setup for managing the toolchains could look like this: ``` -~/.swiftly - | - -- bin/ +~/.local/share/swiftly | -- toolchains/ | -- config.json - | - – env ``` -The toolchains (i.e. the contents of a given Swift download tarball) would be contained in the toolchains directory, each named according to the major/minor/patch version. The bin folder would just contain symlinks to whatever toolchain was selected by `swiftly use`. `config.json` would contain any required metadata (e.g. the latest Swift version, which toolchain is selected, etc.). If pulling in Foundation to use `JSONEncoder`/`JSONDecoder` (or some other JSON tool) would be a problem, we could also use something simpler. +The toolchains (i.e. the contents of a given Swift download tarball) would be contained in the toolchains directory, each named according to the major/minor/patch version. `config.json` would contain any required metadata (e.g. the latest Swift version, which toolchain is selected, etc.). If pulling in Foundation to use `JSONEncoder`/`JSONDecoder` (or some other JSON tool) would be a problem, we could also use something simpler. + +The `~/.local/bin` directory would include symlinks pointing to the `bin` directory of the "active" toolchain, if any. This is all very similar to how rustup does things, but I figure there's no need to reinvent the wheel here. @@ -365,7 +355,7 @@ Finally, swiftly will then get the toolchain's list of system dependencies, if a #### Verifying system dependencies -In order to run Swift on Linux, there are a number of system dependencies that need to be installed. We could consider having swiftly detect and install these dependencies for the user, but we decided that it was best if it doesn't modify the system outside of handling toolchains in `~/.swiftly`. Instead, swiftly will just attempt to detect if any required system libraries are missing and, if so, print helpful, platform-specific messages indicating how a user could install them. In the future, swiftly will use an API from swift.org to discover the list of required dependencies per Swift version / platform. Until then, a list will manually be maintained in this repository. +In order to run Swift on Linux, there are a number of system dependencies that need to be installed. We could consider having swiftly detect and install these dependencies for the user, but we decided that it was best if it doesn't modify the system outside of handling toolchains in `~/.local/share/swiftly`. Instead, swiftly will just attempt to detect if any required system libraries are missing and, if so, print helpful, platform-specific messages indicating how a user could install them. In the future, swiftly will use an API from swift.org to discover the list of required dependencies per Swift version / platform. Until then, a list will manually be maintained in this repository. Determining whether the system has these installed or not is a bit of a tricky problem and varies from platform to platform. The mechanism for doing so on each will be as follows: @@ -410,7 +400,7 @@ Given a version string `main-snapshot[-YYYY-MM-DD]` or `a.b-snapshot[-YYYY-MM-DD #### Uninstalling a toolchain -Given a version string `a.b[.c]`, check that we have such a toolchain installed per config.json. If all of `a.b.c` is provided, this must match exactly. If only `a.b` is provided, all `a.b.c` will match and will be uninstalled. Always prompt the user before proceeding with the uninstallation, confirming all of the uninstallations are correct. If a matching version is installed, first delete the entry in `config.json` associated with that version. Then delete the folder in `~/.swiftly/toolchains` associated with it. If that toolchain was in use, use the installed toolchain with the latest Swift version, if any, per [Using a toolchain](#using-a-toolchain). +Given a version string `a.b[.c]`, check that we have such a toolchain installed per config.json. If all of `a.b.c` is provided, this must match exactly. If only `a.b` is provided, all `a.b.c` will match and will be uninstalled. Always prompt the user before proceeding with the uninstallation, confirming all of the uninstallations are correct. If a matching version is installed, first delete the entry in `config.json` associated with that version. Then delete the folder in `~/.local/share/swiftly/toolchains` associated with it. If that toolchain was in use, use the installed toolchain with the latest Swift version, if any, per [Using a toolchain](#using-a-toolchain). Snapshots work similarly. If a date is provided in the snapshot version, attempt to uninstall only that snapshot. Otherwise, attempt to uninstall all matching snapshots after ensuring this is what the user intended. @@ -461,7 +451,7 @@ https://download.swift.org/swift-5.5.1-release/ubuntu1604/swift-5.5.1-RELEASE/sw `install` accepts a URL pointing to the downloaded `.tar.gz` file and executes the following to install it: ``` -$ tar -xf --directory ~/.swiftly/toolchains +$ tar -xf --directory ~/.local/share/swiftly/toolchains ``` It also updates `config.json` to include this toolchain as the latest for the provided version. If installing a new patch release toolchain, the now-outdated one can be deleted (e.g. `5.5.0` can be deleted when `5.5.1` is installed). @@ -469,7 +459,7 @@ It also updates `config.json` to include this toolchain as the latest for the pr Finally, the use implementation executes the following to update the link: ``` -$ ln -s ~/.swiftly/toolchains//usr/bin/swift ~/.swiftly/bin/swift +$ ln -s ~/.local/share/swiftly/toolchains//usr/bin/swift ~/.local/bin/swift ``` It also updates `config.json` to include this version as the currently selected one. diff --git a/Sources/LinuxPlatform/Linux.swift b/Sources/LinuxPlatform/Linux.swift index 56b0ef00..e2c39f36 100644 --- a/Sources/LinuxPlatform/Linux.swift +++ b/Sources/LinuxPlatform/Linux.swift @@ -5,22 +5,16 @@ import SwiftlyCore /// This implementation can be reused for any supported Linux platform. /// TODO: replace dummy implementations public struct Linux: Platform { - private let platform: Config.PlatformDefinition - - public init(platform: Config.PlatformDefinition) { - self.platform = platform - } - - public var name: String { - self.platform.name - } - - public var nameFull: String { - self.platform.nameFull - } - - public var namePretty: String { - self.platform.namePretty + public init() {} + + public var appDataDirectory: URL { + if let dir = ProcessInfo.processInfo.environment["XDG_DATA_HOME"] { + return URL(fileURLWithPath: dir) + } else { + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".local", isDirectory: true) + .appendingPathComponent("share", isDirectory: true) + } } public var toolchainFileExtension: String { @@ -36,13 +30,12 @@ public struct Linux: Platform { throw Error(message: "\(tmpFile) doesn't exist") } - let toolchainsDir = SwiftlyCore.homeDir.appendingPathComponent("toolchains") - if !toolchainsDir.fileExists() { - try FileManager.default.createDirectory(at: toolchainsDir, withIntermediateDirectories: false) + if !self.swiftlyToolchainsDir.fileExists() { + try FileManager.default.createDirectory(at: self.swiftlyToolchainsDir, withIntermediateDirectories: false) } SwiftlyCore.print("Extracting toolchain...") - let toolchainDir = toolchainsDir.appendingPathComponent(version.name) + let toolchainDir = self.swiftlyToolchainsDir.appendingPathComponent(version.name) if toolchainDir.fileExists() { try FileManager.default.removeItem(at: toolchainDir) @@ -52,34 +45,51 @@ public struct Linux: Platform { // drop swift-a.b.c-RELEASE etc name from the extracted files. let relativePath = name.drop { c in c != "/" }.dropFirst() - // prepend ~/.swiftly/toolchains/ to each file name + // prepend /path/to/swiftlyHomeDir/toolchains/ to each file name return toolchainDir.appendingPathComponent(String(relativePath)) } } public func uninstall(_ toolchain: ToolchainVersion) throws { - let toolchainDir = SwiftlyCore.toolchainsDir.appendingPathComponent(toolchain.name) + let toolchainDir = self.swiftlyToolchainsDir.appendingPathComponent(toolchain.name) try FileManager.default.removeItem(at: toolchainDir) } - public func use(_ toolchain: ToolchainVersion) throws { - let toolchainBinURL = SwiftlyCore.toolchainsDir + public func use(_ toolchain: ToolchainVersion, currentToolchain: ToolchainVersion?) throws -> Bool { + let toolchainBinURL = self.swiftlyToolchainsDir .appendingPathComponent(toolchain.name, isDirectory: true) .appendingPathComponent("usr", isDirectory: true) .appendingPathComponent("bin", isDirectory: true) // Delete existing symlinks from previously in-use toolchain. - for existingExecutable in try FileManager.default.contentsOfDirectory(atPath: SwiftlyCore.binDir.path) { - guard existingExecutable != "swiftly" else { - continue + 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 } - try SwiftlyCore.binDir.appendingPathComponent(existingExecutable).deleteIfExists() } - for executable in try FileManager.default.contentsOfDirectory(atPath: toolchainBinURL.path) { - let linkURL = SwiftlyCore.binDir.appendingPathComponent(executable) + 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( @@ -87,6 +97,34 @@ public struct Linux: Platform { withDestinationPath: executableURL.path ) } + + return true + } + + public func unUse(currentToolchain: ToolchainVersion) throws { + let currentToolchainBinURL = self.swiftlyToolchainsDir + .appendingPathComponent(currentToolchain.name, 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: [.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] { @@ -101,12 +139,5 @@ public struct Linux: Platform { FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID())") } - public static let currentPlatform: any Platform = { - do { - let config = try Config.load() - return Linux(platform: config.platform) - } catch { - fatalError("error loading config: \(error)") - } - }() + public static let currentPlatform: any Platform = Linux() } diff --git a/Sources/SwiftlyCore/Config.swift b/Sources/Swiftly/Config.swift similarity index 77% rename from Sources/SwiftlyCore/Config.swift rename to Sources/Swiftly/Config.swift index e649da50..74b48e23 100644 --- a/Sources/SwiftlyCore/Config.swift +++ b/Sources/Swiftly/Config.swift @@ -1,4 +1,5 @@ import Foundation +import SwiftlyCore /// Struct modelling the config.json file used to track installed toolchains, /// the current in-use tooolchain, and information about the platform. @@ -6,8 +7,18 @@ import Foundation /// TODO: implement cache public struct Config: Codable, Equatable { public struct PlatformDefinition: Codable, Equatable { + /// The name of the platform as it is used in the Swift download URLs. + /// For instance, for Ubuntu 16.04 this would return “ubuntu1604”. + /// For macOS / Xcode, this would return “xcode”. public let name: String + + /// The full name of the platform as it is used in the Swift download URLs. + /// For instance, for Ubuntu 16.04 this would return “ubuntu16.04”. public let nameFull: String + + /// A human-readable / pretty-printed version of the platform’s name, used for terminal + /// output and logging. + /// For example, “Ubuntu 18.04” would be returned on Ubuntu 18.04. public let namePretty: String } @@ -30,11 +41,12 @@ public struct Config: Codable, Equatable { /// Read the config file from disk. public static func load() throws -> Config { do { - let data = try Data(contentsOf: SwiftlyCore.configFile) + let data = try Data(contentsOf: Swiftly.currentPlatform.swiftlyConfigFile) return try JSONDecoder().decode(Config.self, from: data) } catch { let msg = """ - Could not load swiftly's configuration file at \(SwiftlyCore.configFile.path) due to error: \"\(error)\". + Could not load swiftly's configuration file at \(Swiftly.currentPlatform.swiftlyConfigFile.path) due to + error: \"\(error)\". To use swiftly, modify the configuration file to fix the issue or perform a clean installation. """ throw Error(message: msg) @@ -44,7 +56,7 @@ public struct Config: Codable, Equatable { /// Write the contents of this `Config` struct to disk. public func save() throws { let outData = try Self.makeEncoder().encode(self) - try outData.write(to: SwiftlyCore.configFile, options: .atomic) + try outData.write(to: Swiftly.currentPlatform.swiftlyConfigFile, options: .atomic) } public func listInstalledToolchains(selector: ToolchainSelector?) -> [ToolchainVersion] { diff --git a/Sources/Swiftly/Install.swift b/Sources/Swiftly/Install.swift index 38a8b77b..8c13c59e 100644 --- a/Sources/Swiftly/Install.swift +++ b/Sources/Swiftly/Install.swift @@ -61,7 +61,9 @@ struct Install: SwiftlyCommand { } internal static func execute(version: ToolchainVersion) async throws { - guard try !Config.load().installedToolchains.contains(version) else { + var config = try Config.load() + + guard !config.installedToolchains.contains(version) else { SwiftlyCore.print("\(version) is already installed, exiting.") return } @@ -83,9 +85,9 @@ struct Install: SwiftlyCommand { versionString += ".\(stableVersion.patch)" } url += "swift-\(versionString)-release/" - url += "\(Swiftly.currentPlatform.name)/" + url += "\(config.platform.name)/" url += "swift-\(versionString)-RELEASE/" - url += "swift-\(versionString)-RELEASE-\(Swiftly.currentPlatform.nameFull).\(Swiftly.currentPlatform.toolchainFileExtension)" + url += "swift-\(versionString)-RELEASE-\(config.platform.nameFull).\(Swiftly.currentPlatform.toolchainFileExtension)" case let .snapshot(release): let snapshotString: String switch release.branch { @@ -97,9 +99,9 @@ struct Install: SwiftlyCommand { snapshotString = "swift-DEVELOPMENT-SNAPSHOT" } - url += "\(Swiftly.currentPlatform.name)/" + url += "\(config.platform.name)/" url += "\(snapshotString)-\(release.date)-a/" - url += "\(snapshotString)-\(release.date)-a-\(Swiftly.currentPlatform.nameFull).\(Swiftly.currentPlatform.toolchainFileExtension)" + url += "\(snapshotString)-\(release.date)-a-\(config.platform.nameFull).\(Swiftly.currentPlatform.toolchainFileExtension)" } let animation = PercentProgressAnimation( @@ -144,13 +146,12 @@ struct Install: SwiftlyCommand { try Swiftly.currentPlatform.install(from: tmpFile, version: version) - var config = try Config.load() config.installedToolchains.insert(version) try config.save() // If this is the first installed toolchain, mark it as in-use. if config.inUse == nil { - try await Use.execute(version) + try await Use.execute(version, config: &config) } SwiftlyCore.print("\(version) installed successfully!") diff --git a/Sources/Swiftly/Swiftly.swift b/Sources/Swiftly/Swiftly.swift index 606c9bfa..d7f8ad55 100644 --- a/Sources/Swiftly/Swiftly.swift +++ b/Sources/Swiftly/Swiftly.swift @@ -24,6 +24,16 @@ public struct Swiftly: SwiftlyCommand { ] ) + /// The list of directories that swiftly needs to exist in order to execute. + /// If they do not exist when a swiftly command is invoked, they will be created. + public static var requiredDirectories: [URL] { + [ + Swiftly.currentPlatform.swiftlyHomeDir, + Swiftly.currentPlatform.swiftlyBinDir, + Swiftly.currentPlatform.swiftlyToolchainsDir, + ] + } + public init() {} public mutating func run() async throws {} @@ -37,9 +47,13 @@ public protocol SwiftlyCommand: AsyncParsableCommand {} extension SwiftlyCommand { public mutating func validate() throws { - for requiredDir in SwiftlyCore.requiredDirectories { + for requiredDir in Swiftly.requiredDirectories { guard requiredDir.fileExists() else { - try FileManager.default.createDirectory(at: requiredDir, withIntermediateDirectories: true) + do { + try FileManager.default.createDirectory(at: requiredDir, withIntermediateDirectories: true) + } catch { + throw Error(message: "Failed to create required directory \"\(requiredDir.path)\": \(error)") + } continue } } diff --git a/Sources/Swiftly/Uninstall.swift b/Sources/Swiftly/Uninstall.swift index 9460f36b..a167d148 100644 --- a/Sources/Swiftly/Uninstall.swift +++ b/Sources/Swiftly/Uninstall.swift @@ -45,8 +45,8 @@ struct Uninstall: SwiftlyCommand { mutating func run() async throws { let selector = try ToolchainSelector(parsing: self.toolchain) - let config = try Config.load() - let toolchains = config.listInstalledToolchains(selector: selector) + let startingConfig = try Config.load() + let toolchains = startingConfig.listInstalledToolchains(selector: selector) guard !toolchains.isEmpty else { SwiftlyCore.print("No toolchains matched \"\(self.toolchain)\"") @@ -70,42 +70,43 @@ struct Uninstall: SwiftlyCommand { SwiftlyCore.print() for toolchain in toolchains { + var config = try Config.load() + + // If the in-use toolchain was one of the uninstalled toolchains, use a new toolchain. + if toolchain == config.inUse { + let selector: ToolchainSelector + switch toolchain { + case let .stable(sr): + // If a.b.c was previously in use, switch to the latest a.b toolchain. + selector = .stable(major: sr.major, minor: sr.minor, patch: nil) + case let .snapshot(s): + // If a snapshot was previously in use, switch to the latest snapshot associated with that branch. + selector = .snapshot(branch: s.branch, date: nil) + } + + if let toUse = config.listInstalledToolchains(selector: selector) + .filter({ !toolchains.contains($0) }) + .max() + ?? config.listInstalledToolchains(selector: .latest).filter({ !toolchains.contains($0) }).max() + ?? config.installedToolchains.filter({ !toolchains.contains($0) }).max() + { + try await Use.execute(toUse, config: &config) + } else { + // If there are no more toolchains installed, just unuse the currently active toolchain. + try Swiftly.currentPlatform.unUse(currentToolchain: toolchain) + config.inUse = nil + try config.save() + } + } + SwiftlyCore.print("Uninstalling \(toolchain)...", terminator: "") try Swiftly.currentPlatform.uninstall(toolchain) - try Config.update { config in - config.installedToolchains.remove(toolchain) - } + config.installedToolchains.remove(toolchain) + try config.save() SwiftlyCore.print("done") } SwiftlyCore.print() SwiftlyCore.print("\(toolchains.count) toolchain(s) successfully uninstalled") - - var latestConfig = try Config.load() - - // If the in-use toolchain was one of the uninstalled toolchains, use the latest installed - // toolchain. - if let previouslyInUse = latestConfig.inUse, toolchains.contains(previouslyInUse) { - let selector: ToolchainSelector - switch previouslyInUse { - case let .stable(sr): - // If a.b.c was previously in use, switch to the latest a.b toolchain. - selector = .stable(major: sr.major, minor: sr.minor, patch: nil) - case let .snapshot(s): - // If a snapshot was previously in use, switch to the latest snapshot associated with that branch. - selector = .snapshot(branch: s.branch, date: nil) - } - - if let toUse = latestConfig.listInstalledToolchains(selector: selector).max() - ?? latestConfig.listInstalledToolchains(selector: .latest).max() - ?? latestConfig.installedToolchains.max() - { - try await Use.execute(toUse) - } else { - // If there are no more toolchains installed, clear the inUse config entry. - latestConfig.inUse = nil - try latestConfig.save() - } - } } } diff --git a/Sources/Swiftly/Use.swift b/Sources/Swiftly/Use.swift index d0e2be14..72d02069 100644 --- a/Sources/Swiftly/Use.swift +++ b/Sources/Swiftly/Use.swift @@ -40,18 +40,18 @@ internal struct Use: SwiftlyCommand { internal mutating func run() async throws { let selector = try ToolchainSelector(parsing: self.toolchain) - let config = try Config.load() + var config = try Config.load() guard let toolchain = config.listInstalledToolchains(selector: selector).max() else { SwiftlyCore.print("No installed toolchains match \"\(self.toolchain)\"") return } - try await Self.execute(toolchain) + try await Self.execute(toolchain, config: &config) } - internal static func execute(_ toolchain: ToolchainVersion) async throws { - var config = try Config.load() + /// Use a toolchain. This method modifies and saves the input config. + internal static func execute(_ toolchain: ToolchainVersion, config: inout Config) async throws { let previousToolchain = config.inUse guard toolchain != previousToolchain else { @@ -59,7 +59,9 @@ internal struct Use: SwiftlyCommand { return } - try Swiftly.currentPlatform.use(toolchain) + guard try Swiftly.currentPlatform.use(toolchain, currentToolchain: previousToolchain) else { + return + } config.inUse = toolchain try config.save() diff --git a/Sources/SwiftlyCore/Platform.swift b/Sources/SwiftlyCore/Platform.swift index 9deee59c..1c4ee2dd 100644 --- a/Sources/SwiftlyCore/Platform.swift +++ b/Sources/SwiftlyCore/Platform.swift @@ -1,19 +1,9 @@ import Foundation public protocol Platform { - /// The name of the platform as it is used in the Swift download URLs. - /// For instance, for Ubuntu 16.04 this would return “ubuntu1604”. - /// For macOS / Xcode, this would return “xcode”. - var name: String { get } - - /// The full name of the platform as it is used in the Swift download URLs. - /// For instance, for Ubuntu 16.04 this would return “ubuntu16.04”. - var nameFull: String { get } - - /// A human-readable / pretty-printed version of the platform’s name, used for terminal - /// output and logging. - /// For example, “Ubuntu 18.04” would be returned on Ubuntu 18.04. - var namePretty: String { get } + /// The platform-specific location on disk where applications are + /// supposed to store their custom data. + var appDataDirectory: 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". @@ -32,7 +22,11 @@ public protocol Platform { func uninstall(_ version: ToolchainVersion) throws /// Select the toolchain associated with the given version. - func use(_ version: ToolchainVersion) throws + /// Returns whether the selection was successful. + func use(_ version: ToolchainVersion, currentToolchain: ToolchainVersion?) throws -> Bool + + /// Clear the current active toolchain. + func unUse(currentToolchain: ToolchainVersion) throws /// Get a list of snapshot builds for the platform. If a version is specified, only /// return snapshots associated with the version. @@ -48,6 +42,51 @@ public protocol Platform { func getTempFilePath() -> URL } +extension Platform { + /// The location on disk where swiftly will store its configuration, installed toolchains, and symlinks to + /// the active location. + /// + /// The structure of this directory looks like the following: + /// + /// ``` + /// homeDir/ + /// | + /// -- toolchains/ + /// | + /// -- config.json + /// ``` + /// + public var swiftlyHomeDir: URL { + SwiftlyCore.mockedHomeDir + ?? ProcessInfo.processInfo.environment["SWIFTLY_HOME_DIR"].map { URL(fileURLWithPath: $0) } + ?? 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") + } +} + public struct SystemDependency {} public struct Snapshot: Decodable {} diff --git a/Sources/SwiftlyCore/SwiftlyCore.swift b/Sources/SwiftlyCore/SwiftlyCore.swift index 22e220c4..8f5d2551 100644 --- a/Sources/SwiftlyCore/SwiftlyCore.swift +++ b/Sources/SwiftlyCore/SwiftlyCore.swift @@ -1,49 +1,8 @@ import Foundation -/// The location on disk where swiftly will store its configuration, installed toolchains, and symlinks to -/// the active location. -/// -/// The structure of this directory looks like the following: -/// -/// ``` -/// homeDir/ -/// | -/// -- bin/ -/// | -/// -- toolchains/ -/// | -/// -- config.json -/// ``` -/// -/// TODO: support other locations besides ~/.swiftly -public var homeDir = - FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".swiftly", isDirectory: true) - -/// The "bin" subdirectory of swiftly's home directory. Contains the swiftly executable as well as symlinks -/// to executables in the "bin" directory of the active toolchain. -public var binDir: URL { - SwiftlyCore.homeDir.appendingPathComponent("bin", isDirectory: true) -} - -/// The "toolchains" subdirectory of swiftly's home directory. Contains the Swift toolchains managed by swiftly. -public var toolchainsDir: URL { - SwiftlyCore.homeDir.appendingPathComponent("toolchains", isDirectory: true) -} - -/// The URL of the configuration file in swiftly's home directory. -public var configFile: URL { - SwiftlyCore.homeDir.appendingPathComponent("config.json") -} - -/// The list of directories that swiftly needs to exist in order to execute. -/// If they do not exist when a swiftly command is invoked, they will be created. -public var requiredDirectories: [URL] { - [ - SwiftlyCore.homeDir, - SwiftlyCore.binDir, - SwiftlyCore.toolchainsDir, - ] -} +/// A separate home directory to use for testing purposes. This overrides swiftly's default +/// home directory location logic. +public var mockedHomeDir: URL? /// Protocol defining a handler for information swiftly intends to print to stdout. /// This is currently only used to intercept print statements for testing. diff --git a/Tests/SwiftlyTests/SwiftlyTests.swift b/Tests/SwiftlyTests/SwiftlyTests.swift index f6f0ac45..3a6f2e94 100644 --- a/Tests/SwiftlyTests/SwiftlyTests.swift +++ b/Tests/SwiftlyTests/SwiftlyTests.swift @@ -55,16 +55,14 @@ class SwiftlyTests: XCTestCase { name: String = "testHome", _ f: () async throws -> Void ) async throws { - let oldHome = SwiftlyCore.homeDir - let testHome = Self.getTestHomePath(name: name) - SwiftlyCore.homeDir = testHome + SwiftlyCore.mockedHomeDir = testHome defer { - SwiftlyCore.homeDir = oldHome + SwiftlyCore.mockedHomeDir = nil } try testHome.deleteIfExists() - try FileManager.default.createDirectory(at: SwiftlyCore.homeDir, withIntermediateDirectories: false) + try FileManager.default.createDirectory(at: testHome, withIntermediateDirectories: false) defer { try? FileManager.default.removeItem(at: testHome) } @@ -101,8 +99,15 @@ class SwiftlyTests: XCTestCase { try self.installMockedToolchain(toolchain: toolchain) } - var use = try self.parseCommand(Use.self, ["use", inUse?.name ?? "latest"]) - try await use.run() + if !toolchains.isEmpty { + var use = try self.parseCommand(Use.self, ["use", inUse?.name ?? "latest"]) + try await use.run() + } else { + try FileManager.default.createDirectory( + at: Swiftly.currentPlatform.swiftlyBinDir, + withIntermediateDirectories: true + ) + } try await f() } @@ -115,7 +120,7 @@ class SwiftlyTests: XCTestCase { let config = try Config.load() XCTAssertEqual(config.inUse, expected) - let executable = SwiftExecutable(path: SwiftlyCore.binDir.appendingPathComponent("swift")) + let executable = SwiftExecutable(path: Swiftly.currentPlatform.swiftlyBinDir.appendingPathComponent("swift")) XCTAssertEqual(executable.exists(), expected != nil) @@ -141,7 +146,7 @@ class SwiftlyTests: XCTestCase { #if os(Linux) // Verify that the toolchains on disk correspond to those in the config. for toolchain in toolchains { - let toolchainDir = SwiftlyCore.homeDir + let toolchainDir = Swiftly.currentPlatform.swiftlyHomeDir .appendingPathComponent("toolchains") .appendingPathComponent(toolchain.name) XCTAssertTrue(toolchainDir.fileExists()) @@ -163,7 +168,7 @@ class SwiftlyTests: XCTestCase { /// /// When executed, the mocked executables will simply print the toolchain version and return. func installMockedToolchain(toolchain: ToolchainVersion, executables: [String] = ["swift"]) throws { - let toolchainDir = SwiftlyCore.toolchainsDir.appendingPathComponent(toolchain.name) + let toolchainDir = Swiftly.currentPlatform.swiftlyToolchainsDir.appendingPathComponent(toolchain.name) try FileManager.default.createDirectory(at: toolchainDir, withIntermediateDirectories: true) let toolchainBinDir = toolchainDir diff --git a/Tests/SwiftlyTests/UninstallTests.swift b/Tests/SwiftlyTests/UninstallTests.swift index 04129c34..ababea02 100644 --- a/Tests/SwiftlyTests/UninstallTests.swift +++ b/Tests/SwiftlyTests/UninstallTests.swift @@ -240,6 +240,22 @@ final class UninstallTests: SwiftlyTests { } } + /// Tests that uninstalling the last toolchain is handled properly and cleans up any symlinks. + func testUninstallLastToolchain() async throws { + try await self.withMockedHome(homeName: Self.homeName, toolchains: [Self.oldStable], inUse: Self.oldStable) { + var uninstall = try self.parseCommand(Uninstall.self, ["uninstall", Self.oldStable.name]) + _ = try await uninstall.runWithMockedIO(input: ["y"]) + let config = try Config.load() + XCTAssertEqual(config.inUse, nil) + + // Ensure all symlinks have been cleaned up. + let symlinks = try FileManager.default.contentsOfDirectory( + atPath: Swiftly.currentPlatform.swiftlyBinDir.path + ) + XCTAssertEqual(symlinks, []) + } + } + /// Tests that aborting an uninstall works correctly. func testUninstallAbort() async throws { try await self.withMockedHome(homeName: Self.homeName, toolchains: Self.allToolchains, inUse: Self.oldStable) { diff --git a/Tests/SwiftlyTests/UseTests.swift b/Tests/SwiftlyTests/UseTests.swift index 77b3c0d5..13f3e03e 100644 --- a/Tests/SwiftlyTests/UseTests.swift +++ b/Tests/SwiftlyTests/UseTests.swift @@ -14,7 +14,9 @@ final class UseTests: SwiftlyTests { XCTAssertEqual(try Config.load().inUse, expectedVersion) - let toolchainVersion = try self.getMockedToolchainVersion(at: SwiftlyCore.binDir.appendingPathComponent("swift")) + let toolchainVersion = try self.getMockedToolchainVersion( + at: Swiftly.currentPlatform.swiftlyBinDir.appendingPathComponent("swift") + ) XCTAssertEqual(toolchainVersion, expectedVersion) } @@ -225,22 +227,83 @@ final class UseTests: SwiftlyTests { try self.installMockedToolchain(toolchain: toolchain, executables: files) } + // Add an unrelated executable to the binary directory. + let existingFileName = "existing" + let existingExecutableURL = Swiftly.currentPlatform.swiftlyBinDir.appendingPathComponent(existingFileName) + let data = "hello world\n".data(using: .utf8)! + try data.write(to: existingExecutableURL) + for (toolchain, files) in spec { var use = try self.parseCommand(Use.self, ["use", toolchain.name]) try await use.run() // Verify that only the symlinks for the active toolchain remain. - let symlinks = try FileManager.default.contentsOfDirectory(atPath: SwiftlyCore.binDir.path) - XCTAssertEqual(symlinks.sorted(), files.sorted()) + let symlinks = try FileManager.default.contentsOfDirectory( + atPath: Swiftly.currentPlatform.swiftlyBinDir.path + ) + XCTAssertEqual(symlinks.sorted(), (files + [existingFileName]).sorted()) // Verify that any all the symlinks point to the right toolchain. for file in files { + guard file != existingFileName else { + continue + } let observedVersion = try self.getMockedToolchainVersion( - at: SwiftlyCore.binDir.appendingPathComponent(file) + at: Swiftly.currentPlatform.swiftlyBinDir.appendingPathComponent(file) ) XCTAssertEqual(observedVersion, toolchain) } } } } + + /// Tests that any executables that already exist in SWIFTLY_BIN_DIR. + func testExistingExecutablesNotOverwritten() async throws { + try await self.withMockedHome(homeName: Self.homeName, toolchains: []) { + let existingExecutables = ["a", "b", "c"] + let existingText = "existing" + for fileName in existingExecutables { + let existingExecutableURL = Swiftly.currentPlatform.swiftlyBinDir.appendingPathComponent(fileName) + let data = existingText.data(using: .utf8)! + try data.write(to: existingExecutableURL) + } + + let toolchain = ToolchainVersion(major: 7, minor: 2, patch: 3) + try self.installMockedToolchain( + toolchain: toolchain, + executables: ["a", "b", "c", "d", "e"] + ) + + var use = try self.parseCommand(Use.self, ["use", toolchain.name]) + let nOutput = try await use.runWithMockedIO(input: ["n"]) + + for exec in existingExecutables { + // Ensure we were prompted for each existing executable. + XCTAssert(nOutput.contains(where: { $0.contains(exec) })) + + // Ensure files were not overwritten. + let existingExecutableURL = Swiftly.currentPlatform.swiftlyBinDir.appendingPathComponent(exec) + let contents = try String(contentsOf: existingExecutableURL, encoding: .utf8) + XCTAssertEqual(contents, existingText) + } + + let nConfig = try Config.load() + XCTAssertEqual(nConfig.inUse, nil) + + let yOutput = try await use.runWithMockedIO(input: ["y"]) + + // Ensure we were prompted for each existing executable. + for exec in existingExecutables { + XCTAssert(yOutput.contains(where: { $0.contains(exec) })) + + // Ensure files were overwritten. + let existingExecutableURL = Swiftly.currentPlatform.swiftlyBinDir.appendingPathComponent(exec) + let contents = try String(contentsOf: existingExecutableURL, encoding: .utf8) + XCTAssertNotEqual(contents, existingText) + } + + let yConfig = try Config.load() + XCTAssertEqual(yConfig.inUse, toolchain) + } + } }