From ab738e8ad799ee15658308f4c40932889885a7a5 Mon Sep 17 00:00:00 2001 From: Patrick Freed Date: Tue, 13 Dec 2022 19:22:40 -0500 Subject: [PATCH 01/10] change homedir from ~/.swiftly to platform-specific location --- DESIGN.md | 18 +++---- Sources/LinuxPlatform/Linux.swift | 52 +++++++----------- Sources/{SwiftlyCore => Swiftly}/Config.swift | 18 +++++-- Sources/Swiftly/Install.swift | 13 ++--- Sources/Swiftly/Swiftly.swift | 16 +++++- Sources/SwiftlyCore/Platform.swift | 54 ++++++++++++++----- Sources/SwiftlyCore/SwiftlyCore.swift | 45 ---------------- 7 files changed, 106 insertions(+), 110 deletions(-) rename Sources/{SwiftlyCore => Swiftly}/Config.swift (77%) diff --git a/DESIGN.md b/DESIGN.md index 69fe95bb..8fd7cc2e 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -31,22 +31,22 @@ 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. +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/share/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: +Finally, it will create a `$HOME/.local/share/swiftly/env` file, which contains only the following line: ``` -export PATH="$HOME/.swiftly/bin:$PATH" +export PATH="$HOME/.local/share/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. +and print a message instructing the user to run source `~/.local/share/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. ### Installation of a Swift toolchain A simple setup for managing the toolchains could look like this: ``` -~/.swiftly +~/.local/share/swiftly | -- bin/ | @@ -365,7 +365,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 +410,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 +461,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 +469,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/share/swiftly/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..a68a83aa 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) @@ -58,26 +51,26 @@ public struct Linux: Platform { } 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 + 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) { + for existingExecutable in try FileManager.default.contentsOfDirectory(atPath: self.swiftlyBinDir.path) { guard existingExecutable != "swiftly" else { continue } - try SwiftlyCore.binDir.appendingPathComponent(existingExecutable).deleteIfExists() + try self.swiftlyBinDir.appendingPathComponent(existingExecutable).deleteIfExists() } for executable in try FileManager.default.contentsOfDirectory(atPath: toolchainBinURL.path) { - let linkURL = SwiftlyCore.binDir.appendingPathComponent(executable) + let linkURL = self.swiftlyBinDir.appendingPathComponent(executable) let executableURL = toolchainBinURL.appendingPathComponent(executable) try linkURL.deleteIfExists() @@ -101,12 +94,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..a483ab3a 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,7 +146,6 @@ struct Install: SwiftlyCommand { try Swiftly.currentPlatform.install(from: tmpFile, version: version) - var config = try Config.load() config.installedToolchains.insert(version) try config.save() diff --git a/Sources/Swiftly/Swiftly.swift b/Sources/Swiftly/Swiftly.swift index 606c9bfa..259c0f6d 100644 --- a/Sources/Swiftly/Swiftly.swift +++ b/Sources/Swiftly/Swiftly.swift @@ -5,6 +5,7 @@ import LinuxPlatform #endif import SwiftlyCore + @main @available(macOS 10.15, *) public struct Swiftly: SwiftlyCommand { @@ -24,6 +25,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 {} @@ -31,13 +42,15 @@ public struct Swiftly: SwiftlyCommand { #if os(Linux) internal static let currentPlatform = Linux.currentPlatform #endif + } 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) continue @@ -48,3 +61,4 @@ extension SwiftlyCommand { _ = try Config.load() } } + diff --git a/Sources/SwiftlyCore/Platform.swift b/Sources/SwiftlyCore/Platform.swift index 9deee59c..02a0cafd 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". @@ -48,6 +38,44 @@ 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/ + /// | + /// -- bin/ + /// | + /// -- toolchains/ + /// | + /// -- config.json + /// ``` + /// + /// TODO: support other locations besides ~/.local/share/swiftly + public var swiftlyHomeDir: URL { + self.appDataDirectory.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 swiftlyBinDir: URL { + self.swiftlyHomeDir.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..4931ec83 100644 --- a/Sources/SwiftlyCore/SwiftlyCore.swift +++ b/Sources/SwiftlyCore/SwiftlyCore.swift @@ -1,50 +1,5 @@ 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, - ] -} - /// 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 { From e60b6f2d06edb950c2276c0167a6721bede7e2a3 Mon Sep 17 00:00:00 2001 From: Patrick Freed Date: Wed, 14 Dec 2022 17:06:55 -0500 Subject: [PATCH 02/10] update tests to use new home directory --- Sources/Swiftly/Swiftly.swift | 4 ---- Sources/SwiftlyCore/Platform.swift | 2 +- Sources/SwiftlyCore/SwiftlyCore.swift | 4 ++++ Tests/SwiftlyTests/SwiftlyTests.swift | 14 ++++++-------- Tests/SwiftlyTests/UseTests.swift | 10 +++++++--- 5 files changed, 18 insertions(+), 16 deletions(-) diff --git a/Sources/Swiftly/Swiftly.swift b/Sources/Swiftly/Swiftly.swift index 259c0f6d..a2647f9c 100644 --- a/Sources/Swiftly/Swiftly.swift +++ b/Sources/Swiftly/Swiftly.swift @@ -5,7 +5,6 @@ import LinuxPlatform #endif import SwiftlyCore - @main @available(macOS 10.15, *) public struct Swiftly: SwiftlyCommand { @@ -42,12 +41,10 @@ public struct Swiftly: SwiftlyCommand { #if os(Linux) internal static let currentPlatform = Linux.currentPlatform #endif - } public protocol SwiftlyCommand: AsyncParsableCommand {} - extension SwiftlyCommand { public mutating func validate() throws { for requiredDir in Swiftly.requiredDirectories { @@ -61,4 +58,3 @@ extension SwiftlyCommand { _ = try Config.load() } } - diff --git a/Sources/SwiftlyCore/Platform.swift b/Sources/SwiftlyCore/Platform.swift index 02a0cafd..6a26a166 100644 --- a/Sources/SwiftlyCore/Platform.swift +++ b/Sources/SwiftlyCore/Platform.swift @@ -56,7 +56,7 @@ extension Platform { /// /// TODO: support other locations besides ~/.local/share/swiftly public var swiftlyHomeDir: URL { - self.appDataDirectory.appendingPathComponent("swiftly", isDirectory: true) + SwiftlyCore.mockedHomeDir ?? self.appDataDirectory.appendingPathComponent("swiftly", isDirectory: true) } /// The "bin" subdirectory of swiftly's home directory. Contains the swiftly executable as well as symlinks diff --git a/Sources/SwiftlyCore/SwiftlyCore.swift b/Sources/SwiftlyCore/SwiftlyCore.swift index 4931ec83..8f5d2551 100644 --- a/Sources/SwiftlyCore/SwiftlyCore.swift +++ b/Sources/SwiftlyCore/SwiftlyCore.swift @@ -1,5 +1,9 @@ import Foundation +/// 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. public protocol OutputHandler { diff --git a/Tests/SwiftlyTests/SwiftlyTests.swift b/Tests/SwiftlyTests/SwiftlyTests.swift index f6f0ac45..539fa035 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) } @@ -115,7 +113,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 +139,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 +161,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/UseTests.swift b/Tests/SwiftlyTests/UseTests.swift index 77b3c0d5..3efd0463 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) } @@ -230,13 +232,15 @@ final class UseTests: SwiftlyTests { try await use.run() // Verify that only the symlinks for the active toolchain remain. - let symlinks = try FileManager.default.contentsOfDirectory(atPath: SwiftlyCore.binDir.path) + let symlinks = try FileManager.default.contentsOfDirectory( + atPath: Swiftly.currentPlatform.swiftlyBinDir.path + ) XCTAssertEqual(symlinks.sorted(), files.sorted()) // Verify that any all the symlinks point to the right toolchain. for file in files { let observedVersion = try self.getMockedToolchainVersion( - at: SwiftlyCore.binDir.appendingPathComponent(file) + at: Swiftly.currentPlatform.swiftlyBinDir.appendingPathComponent(file) ) XCTAssertEqual(observedVersion, toolchain) } From 93d2f93112cfbfed94ec378af6eba5de51e2d8d0 Mon Sep 17 00:00:00 2001 From: Patrick Freed Date: Wed, 14 Dec 2022 17:09:42 -0500 Subject: [PATCH 03/10] update comment --- Sources/LinuxPlatform/Linux.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/LinuxPlatform/Linux.swift b/Sources/LinuxPlatform/Linux.swift index a68a83aa..c84a7d74 100644 --- a/Sources/LinuxPlatform/Linux.swift +++ b/Sources/LinuxPlatform/Linux.swift @@ -45,7 +45,7 @@ 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)) } } From 7e93bd72c4fb37826c4d1aa6d0ce50ea1eafe21d Mon Sep 17 00:00:00 2001 From: Patrick Freed Date: Wed, 14 Dec 2022 17:28:38 -0500 Subject: [PATCH 04/10] correctly check if toolchain is already installed --- Sources/Swiftly/Install.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Swiftly/Install.swift b/Sources/Swiftly/Install.swift index a483ab3a..c6e41751 100644 --- a/Sources/Swiftly/Install.swift +++ b/Sources/Swiftly/Install.swift @@ -63,7 +63,7 @@ struct Install: SwiftlyCommand { internal static func execute(version: ToolchainVersion) async throws { var config = try Config.load() - guard config.installedToolchains.contains(version) else { + guard !config.installedToolchains.contains(version) else { SwiftlyCore.print("\(version) is already installed, exiting.") return } From d80355aefa6569fd775196d5455af86c0d1feefe Mon Sep 17 00:00:00 2001 From: Patrick Freed Date: Thu, 15 Dec 2022 15:28:20 -0500 Subject: [PATCH 05/10] support SWIFTLY_HOME_DIR and SWIFTLY_BIN_DIR env variables --- Sources/SwiftlyCore/Platform.swift | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/Sources/SwiftlyCore/Platform.swift b/Sources/SwiftlyCore/Platform.swift index 6a26a166..9c36297d 100644 --- a/Sources/SwiftlyCore/Platform.swift +++ b/Sources/SwiftlyCore/Platform.swift @@ -47,22 +47,29 @@ extension Platform { /// ``` /// homeDir/ /// | - /// -- bin/ - /// | /// -- toolchains/ /// | /// -- config.json /// ``` /// - /// TODO: support other locations besides ~/.local/share/swiftly public var swiftlyHomeDir: URL { - SwiftlyCore.mockedHomeDir ?? self.appDataDirectory.appendingPathComponent("swiftly", isDirectory: true) + SwiftlyCore.mockedHomeDir + ?? ProcessInfo.processInfo.environment["SWIFTLY_HOME_DIR"].map { URL(fileURLWithPath: $0) } + ?? self.appDataDirectory.appendingPathComponent("swiftly", isDirectory: true) } - /// The "bin" subdirectory of swiftly's home directory. Contains the swiftly executable as well as symlinks + /// 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 unset, this will be + /// ~/.local/bin. public var swiftlyBinDir: URL { - self.swiftlyHomeDir.appendingPathComponent("bin", isDirectory: true) + 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. From 63c96191cdea1e22a1143843a5e973433a5ec216 Mon Sep 17 00:00:00 2001 From: Patrick Freed Date: Thu, 15 Dec 2022 15:30:01 -0500 Subject: [PATCH 06/10] bump actions os to ubuntu 20.04 --- .github/workflows/tests.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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 }} From 0d357403a481add0d50ebc803a93aea6afd6f757 Mon Sep 17 00:00:00 2001 From: Patrick Freed Date: Thu, 15 Dec 2022 15:50:22 -0500 Subject: [PATCH 07/10] update design to reflect directory structure for linux --- DESIGN.md | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 8fd7cc2e..ed69e853 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -31,15 +31,7 @@ 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/.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/share/swiftly/bin`, download the prebuilt swiftly executable appropriate for the platform and drop it in there. - -Finally, it will create a `$HOME/.local/share/swiftly/env` file, which contains only the following line: - -``` -export PATH="$HOME/.local/share/swiftly/bin:$PATH" -``` - -and print a message instructing the user to run source `~/.local/share/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 @@ -47,17 +39,15 @@ A simple setup for managing the toolchains could look like this: ``` ~/.local/share/swiftly - | - -- bin/ | -- 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. @@ -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 ~/.local/share/swiftly/toolchains//usr/bin/swift ~/.local/share/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. From 5c4075d57170b94d8d3fad47cae3c2971d57d1f9 Mon Sep 17 00:00:00 2001 From: Patrick Freed Date: Thu, 15 Dec 2022 17:18:19 -0500 Subject: [PATCH 08/10] properly clean up symlinks after uninstall --- Sources/LinuxPlatform/Linux.swift | 23 ++++++--- Sources/Swiftly/Install.swift | 2 +- Sources/Swiftly/Uninstall.swift | 65 +++++++++++++------------ Sources/Swiftly/Use.swift | 10 ++-- Sources/SwiftlyCore/Platform.swift | 5 +- Tests/SwiftlyTests/UninstallTests.swift | 16 ++++++ Tests/SwiftlyTests/UseTests.swift | 11 ++++- 7 files changed, 86 insertions(+), 46 deletions(-) diff --git a/Sources/LinuxPlatform/Linux.swift b/Sources/LinuxPlatform/Linux.swift index c84a7d74..ae196a41 100644 --- a/Sources/LinuxPlatform/Linux.swift +++ b/Sources/LinuxPlatform/Linux.swift @@ -55,18 +55,15 @@ public struct Linux: Platform { try FileManager.default.removeItem(at: toolchainDir) } - public func use(_ toolchain: ToolchainVersion) throws { + public func use(_ toolchain: ToolchainVersion, currentToolchain: ToolchainVersion?) throws { 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: self.swiftlyBinDir.path) { - guard existingExecutable != "swiftly" else { - continue - } - try self.swiftlyBinDir.appendingPathComponent(existingExecutable).deleteIfExists() + if let currentToolchain { + try self.unUse(currentToolchain: currentToolchain) } for executable in try FileManager.default.contentsOfDirectory(atPath: toolchainBinURL.path) { @@ -82,6 +79,20 @@ public struct Linux: Platform { } } + 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 + } + try self.swiftlyBinDir.appendingPathComponent(existingExecutable).deleteIfExists() + } + } + public func listAvailableSnapshots(version _: String?) async -> [Snapshot] { [] } diff --git a/Sources/Swiftly/Install.swift b/Sources/Swiftly/Install.swift index c6e41751..8c13c59e 100644 --- a/Sources/Swiftly/Install.swift +++ b/Sources/Swiftly/Install.swift @@ -151,7 +151,7 @@ struct Install: SwiftlyCommand { // 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/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..b2ff5dbb 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,7 @@ internal struct Use: SwiftlyCommand { return } - try Swiftly.currentPlatform.use(toolchain) + try Swiftly.currentPlatform.use(toolchain, currentToolchain: previousToolchain) config.inUse = toolchain try config.save() diff --git a/Sources/SwiftlyCore/Platform.swift b/Sources/SwiftlyCore/Platform.swift index 9c36297d..f2d79a11 100644 --- a/Sources/SwiftlyCore/Platform.swift +++ b/Sources/SwiftlyCore/Platform.swift @@ -22,7 +22,10 @@ public protocol Platform { func uninstall(_ version: ToolchainVersion) throws /// Select the toolchain associated with the given version. - func use(_ version: ToolchainVersion) throws + func use(_ version: ToolchainVersion, currentToolchain: ToolchainVersion?) throws + + /// 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. 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 3efd0463..f4145000 100644 --- a/Tests/SwiftlyTests/UseTests.swift +++ b/Tests/SwiftlyTests/UseTests.swift @@ -227,6 +227,12 @@ 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() @@ -235,10 +241,13 @@ final class UseTests: SwiftlyTests { let symlinks = try FileManager.default.contentsOfDirectory( atPath: Swiftly.currentPlatform.swiftlyBinDir.path ) - XCTAssertEqual(symlinks.sorted(), files.sorted()) + 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: Swiftly.currentPlatform.swiftlyBinDir.appendingPathComponent(file) ) From feda9628a189bac41d2858ca8114cd25e7f3075e Mon Sep 17 00:00:00 2001 From: Patrick Freed Date: Mon, 26 Dec 2022 18:42:25 -0500 Subject: [PATCH 09/10] ensure swiftly doesn't overwrite existing installed executables --- Sources/LinuxPlatform/Linux.swift | 38 ++++++++++++++++++-- Sources/Swiftly/Swiftly.swift | 6 +++- Sources/Swiftly/Use.swift | 4 ++- Sources/SwiftlyCore/Platform.swift | 7 ++-- Tests/SwiftlyTests/SwiftlyTests.swift | 11 ++++-- Tests/SwiftlyTests/UseTests.swift | 50 +++++++++++++++++++++++++++ 6 files changed, 107 insertions(+), 9 deletions(-) diff --git a/Sources/LinuxPlatform/Linux.swift b/Sources/LinuxPlatform/Linux.swift index ae196a41..61818dc3 100644 --- a/Sources/LinuxPlatform/Linux.swift +++ b/Sources/LinuxPlatform/Linux.swift @@ -55,7 +55,7 @@ public struct Linux: Platform { try FileManager.default.removeItem(at: toolchainDir) } - public func use(_ toolchain: ToolchainVersion, currentToolchain: ToolchainVersion?) throws { + public func use(_ toolchain: ToolchainVersion, currentToolchain: ToolchainVersion?) throws -> Bool { let toolchainBinURL = self.swiftlyToolchainsDir .appendingPathComponent(toolchain.name, isDirectory: true) .appendingPathComponent("usr", isDirectory: true) @@ -66,10 +66,30 @@ public struct Linux: Platform { try self.unUse(currentToolchain: currentToolchain) } - for executable in try FileManager.default.contentsOfDirectory(atPath: toolchainBinURL.path) { + // 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( @@ -77,6 +97,8 @@ public struct Linux: Platform { withDestinationPath: executableURL.path ) } + + return true } public func unUse(currentToolchain: ToolchainVersion) throws { @@ -89,6 +111,18 @@ public struct Linux: Platform { 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 == currentToolchainBinURL.appendingPathComponent(existingExecutable) else { + throw Error(message: "Found symlink that points to non-swiftly managed executable: \(symlinkDest.path)") + } + try self.swiftlyBinDir.appendingPathComponent(existingExecutable).deleteIfExists() } } diff --git a/Sources/Swiftly/Swiftly.swift b/Sources/Swiftly/Swiftly.swift index a2647f9c..d7f8ad55 100644 --- a/Sources/Swiftly/Swiftly.swift +++ b/Sources/Swiftly/Swiftly.swift @@ -49,7 +49,11 @@ extension SwiftlyCommand { public mutating func validate() throws { 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/Use.swift b/Sources/Swiftly/Use.swift index b2ff5dbb..72d02069 100644 --- a/Sources/Swiftly/Use.swift +++ b/Sources/Swiftly/Use.swift @@ -59,7 +59,9 @@ internal struct Use: SwiftlyCommand { return } - try Swiftly.currentPlatform.use(toolchain, currentToolchain: previousToolchain) + 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 f2d79a11..1c4ee2dd 100644 --- a/Sources/SwiftlyCore/Platform.swift +++ b/Sources/SwiftlyCore/Platform.swift @@ -22,7 +22,8 @@ public protocol Platform { func uninstall(_ version: ToolchainVersion) throws /// Select the toolchain associated with the given version. - func use(_ version: ToolchainVersion, currentToolchain: 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 @@ -65,8 +66,8 @@ extension Platform { /// 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 unset, this will be - /// ~/.local/bin. + /// 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) } diff --git a/Tests/SwiftlyTests/SwiftlyTests.swift b/Tests/SwiftlyTests/SwiftlyTests.swift index 539fa035..3a6f2e94 100644 --- a/Tests/SwiftlyTests/SwiftlyTests.swift +++ b/Tests/SwiftlyTests/SwiftlyTests.swift @@ -99,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() } diff --git a/Tests/SwiftlyTests/UseTests.swift b/Tests/SwiftlyTests/UseTests.swift index f4145000..13f3e03e 100644 --- a/Tests/SwiftlyTests/UseTests.swift +++ b/Tests/SwiftlyTests/UseTests.swift @@ -256,4 +256,54 @@ final class UseTests: SwiftlyTests { } } } + + /// 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) + } + } } From e12e66b826eb1792c7497a84b7d493f330f60a5b Mon Sep 17 00:00:00 2001 From: Patrick Freed Date: Tue, 27 Dec 2022 15:49:37 -0500 Subject: [PATCH 10/10] properly determine if symlinks are swiftly-managed --- Sources/LinuxPlatform/Linux.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/LinuxPlatform/Linux.swift b/Sources/LinuxPlatform/Linux.swift index 61818dc3..e2c39f36 100644 --- a/Sources/LinuxPlatform/Linux.swift +++ b/Sources/LinuxPlatform/Linux.swift @@ -119,7 +119,7 @@ public struct Linux: Platform { throw Error(message: "Found executable not managed by swiftly in SWIFTLY_BIN_DIR: \(url.path)") } let symlinkDest = url.resolvingSymlinksInPath() - guard symlinkDest == currentToolchainBinURL.appendingPathComponent(existingExecutable) else { + guard symlinkDest.deletingLastPathComponent() == currentToolchainBinURL else { throw Error(message: "Found symlink that points to non-swiftly managed executable: \(symlinkDest.path)") }