diff --git a/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md b/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md index 553b4a92..fc2fb289 100644 --- a/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md +++ b/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md @@ -545,3 +545,81 @@ The script will receive the argument '+abcde' followed by '+xyz'. +## link + +Link swiftly so it resumes management of the active toolchain. + +``` +swiftly link [] [--assume-yes] [--verbose] [--version] [--help] +``` + +**toolchain-selector:** + +*Links swiftly if it has been disabled.* + + +Links swiftly if it has been disabled. + + +**--assume-yes:** + +*Disable confirmation prompts by assuming 'yes'* + + +**--verbose:** + +*Enable verbose reporting from swiftly* + + +**--version:** + +*Show the version.* + + +**--help:** + +*Show help information.* + + + + +## unlink + +Unlinks swiftly so it no longer manages the active toolchain. + +``` +swiftly unlink [] [--assume-yes] [--verbose] [--version] [--help] +``` + +**toolchain-selector:** + +*Unlinks swiftly, allowing the system default toolchain to be used.* + + +Unlinks swiftly until swiftly is linked again with: + + $ swiftly link + + +**--assume-yes:** + +*Disable confirmation prompts by assuming 'yes'* + + +**--verbose:** + +*Enable verbose reporting from swiftly* + + +**--version:** + +*Show the version.* + + +**--help:** + +*Show help information.* + + + + diff --git a/Sources/Swiftly/Init.swift b/Sources/Swiftly/Init.swift index 60a51cb8..3dea0b8d 100644 --- a/Sources/Swiftly/Init.swift +++ b/Sources/Swiftly/Init.swift @@ -297,14 +297,7 @@ struct Init: SwiftlyCommand { } if let postInstall { - await ctx.print(""" - There are some dependencies that should be installed before using this toolchain. - You can run the following script as the system administrator (e.g. root) to prepare - your system: - - \(postInstall) - - """) + await ctx.print(Messages.postInstall(postInstall)) } } } diff --git a/Sources/Swiftly/Install.swift b/Sources/Swiftly/Install.swift index 902844a2..50a152f4 100644 --- a/Sources/Swiftly/Install.swift +++ b/Sources/Swiftly/Install.swift @@ -86,33 +86,11 @@ struct Install: SwiftlyCommand { defer { versionUpdateReminder() } + try await validateLinked(ctx) var config = try await Config.load(ctx) + let toolchainVersion = try await Self.determineToolchainVersion(ctx, version: self.version, config: &config) - var selector: ToolchainSelector - - if let version = self.version { - selector = try ToolchainSelector(parsing: version) - } else { - if case let (_, result) = try await selectToolchain(ctx, config: &config), - case let .swiftVersionFile(_, sel, error) = result - { - if let sel = sel { - selector = sel - } else if let error = error { - throw error - } else { - throw SwiftlyError(message: "Internal error selecting toolchain to install.") - } - } else { - throw SwiftlyError( - message: - "Swiftly couldn't determine the toolchain version to install. Please set a version like this and try again: `swiftly install latest`" - ) - } - } - - let toolchainVersion = try await Self.resolve(ctx, config: config, selector: selector) let (postInstallScript, pathChanged) = try await Self.execute( ctx, version: toolchainVersion, @@ -164,6 +142,101 @@ struct Install: SwiftlyCommand { } } + public static func setupProxies( + _ ctx: SwiftlyCoreContext, + version: ToolchainVersion, + verbose: Bool, + assumeYes: Bool + ) async throws -> Bool { + var pathChanged = false + + // Create proxies if we have a location where we can point them + if let proxyTo = try? await Swiftly.currentPlatform.findSwiftlyBin(ctx) { + // Ensure swiftly doesn't overwrite any existing executables without getting confirmation first. + let swiftlyBinDir = Swiftly.currentPlatform.swiftlyBinDir(ctx) + let swiftlyBinDirContents = + (try? await fs.ls(atPath: swiftlyBinDir)) ?? [String]() + let toolchainBinDir = Swiftly.currentPlatform.findToolchainBinDir(ctx, version) + let toolchainBinDirContents = try await fs.ls(atPath: toolchainBinDir) + + var existingProxies: [String] = [] + + for bin in swiftlyBinDirContents { + do { + let linkTarget = try await fs.readlink(atPath: swiftlyBinDir / bin) + if linkTarget == proxyTo { + existingProxies.append(bin) + } + } catch { continue } + } + + let overwrite = Set(toolchainBinDirContents).subtracting(existingProxies).intersection( + swiftlyBinDirContents) + if !overwrite.isEmpty && !assumeYes { + await ctx.print("The following existing executables will be overwritten:") + + for executable in overwrite { + await ctx.print(" \(swiftlyBinDir / executable)") + } + + guard await ctx.promptForConfirmation(defaultBehavior: false) else { + throw SwiftlyError(message: "Toolchain installation has been cancelled") + } + } + + if verbose { + await ctx.print("Setting up toolchain proxies...") + } + + let proxiesToCreate = Set(toolchainBinDirContents).subtracting(swiftlyBinDirContents).union( + overwrite) + + for p in proxiesToCreate { + let proxy = Swiftly.currentPlatform.swiftlyBinDir(ctx) / p + + if try await fs.exists(atPath: proxy) { + try await fs.remove(atPath: proxy) + } + + try await fs.symlink(atPath: proxy, linkPath: proxyTo) + + pathChanged = true + } + } + return pathChanged + } + + static func determineToolchainVersion( + _ ctx: SwiftlyCoreContext, + version: String?, + config: inout Config + ) async throws -> ToolchainVersion { + let selector: ToolchainSelector + + if let version = version { + selector = try ToolchainSelector(parsing: version) + } else { + if case let (_, result) = try await selectToolchain(ctx, config: &config), + case let .swiftVersionFile(_, sel, error) = result + { + if let sel = sel { + selector = sel + } else if let error = error { + throw error + } else { + throw SwiftlyError(message: "Internal error selecting toolchain to install.") + } + } else { + throw SwiftlyError( + message: + "Swiftly couldn't determine the toolchain version to install. Please set a version like this and try again: `swiftly install latest`" + ) + } + } + + return try await Self.resolve(ctx, config: config, selector: selector) + } + public static func execute( _ ctx: SwiftlyCoreContext, version: ToolchainVersion, @@ -275,61 +348,12 @@ struct Install: SwiftlyCommand { try await Swiftly.currentPlatform.install(ctx, from: tmpFile, version: version, verbose: verbose) - var pathChanged = false - - // Create proxies if we have a location where we can point them - if let proxyTo = try? await Swiftly.currentPlatform.findSwiftlyBin(ctx) { - // Ensure swiftly doesn't overwrite any existing executables without getting confirmation first. - let swiftlyBinDir = Swiftly.currentPlatform.swiftlyBinDir(ctx) - let swiftlyBinDirContents = - (try? await fs.ls(atPath: swiftlyBinDir)) ?? [String]() - let toolchainBinDir = Swiftly.currentPlatform.findToolchainBinDir(ctx, version) - let toolchainBinDirContents = try await fs.ls(atPath: toolchainBinDir) - - var existingProxies: [String] = [] - - for bin in swiftlyBinDirContents { - do { - let linkTarget = try await fs.readlink(atPath: swiftlyBinDir / bin) - if linkTarget == proxyTo { - existingProxies.append(bin) - } - } catch { continue } - } - - let overwrite = Set(toolchainBinDirContents).subtracting(existingProxies).intersection( - swiftlyBinDirContents) - if !overwrite.isEmpty && !assumeYes { - await ctx.print("The following existing executables will be overwritten:") - - for executable in overwrite { - await ctx.print(" \(swiftlyBinDir / executable)") - } - - guard await ctx.promptForConfirmation(defaultBehavior: false) else { - throw SwiftlyError(message: "Toolchain installation has been cancelled") - } - } - - if verbose { - await ctx.print("Setting up toolchain proxies...") - } - - let proxiesToCreate = Set(toolchainBinDirContents).subtracting(swiftlyBinDirContents).union( - overwrite) - - for p in proxiesToCreate { - let proxy = Swiftly.currentPlatform.swiftlyBinDir(ctx) / p - - if try await fs.exists(atPath: proxy) { - try await fs.remove(atPath: proxy) - } - - try await fs.symlink(atPath: proxy, linkPath: proxyTo) - - pathChanged = true - } - } + let pathChanged = try await Self.setupProxies( + ctx, + version: version, + verbose: verbose, + assumeYes: assumeYes + ) config.installedToolchains.insert(version) diff --git a/Sources/Swiftly/Link.swift b/Sources/Swiftly/Link.swift new file mode 100644 index 00000000..cd6af197 --- /dev/null +++ b/Sources/Swiftly/Link.swift @@ -0,0 +1,57 @@ +import ArgumentParser +import Foundation +import SwiftlyCore + +struct Link: SwiftlyCommand { + public static let configuration = CommandConfiguration( + abstract: "Link swiftly so it resumes management of the active toolchain." + ) + + @Argument(help: ArgumentHelp( + "Links swiftly if it has been disabled.", + discussion: """ + + Links swiftly if it has been disabled. + """ + )) + var toolchainSelector: String? + + @OptionGroup var root: GlobalOptions + + mutating func run() async throws { + try await self.run(Swiftly.createDefaultContext()) + } + + mutating func run(_ ctx: SwiftlyCoreContext) async throws { + let versionUpdateReminder = try await validateSwiftly(ctx) + defer { + versionUpdateReminder() + } + + var config = try await Config.load(ctx) + let toolchainVersion = try await Install.determineToolchainVersion( + ctx, + version: config.inUse?.name, + config: &config + ) + + let pathChanged = try await Install.setupProxies( + ctx, + version: toolchainVersion, + verbose: self.root.verbose, + assumeYes: self.root.assumeYes + ) + + if pathChanged { + await ctx.print(""" + Linked swiftly to Swift \(toolchainVersion.name). + + \(Messages.refreshShell) + """) + } else { + await ctx.print(""" + Swiftly is already linked to Swift \(toolchainVersion.name). + """) + } + } +} diff --git a/Sources/Swiftly/List.swift b/Sources/Swiftly/List.swift index a6212d71..20694a51 100644 --- a/Sources/Swiftly/List.swift +++ b/Sources/Swiftly/List.swift @@ -43,12 +43,11 @@ struct List: SwiftlyCommand { versionUpdateReminder() } + var config = try await Config.load(ctx) let selector = try self.toolchainSelector.map { input in try ToolchainSelector(parsing: input) } - var config = try await Config.load(ctx) - let toolchains = config.listInstalledToolchains(selector: selector).sorted { $0 > $1 } let (inUse, _) = try await selectToolchain(ctx, config: &config) diff --git a/Sources/Swiftly/ListAvailable.swift b/Sources/Swiftly/ListAvailable.swift index 8e9a6224..2eacd6ed 100644 --- a/Sources/Swiftly/ListAvailable.swift +++ b/Sources/Swiftly/ListAvailable.swift @@ -49,12 +49,11 @@ struct ListAvailable: SwiftlyCommand { versionUpdateReminder() } + var config = try await Config.load(ctx) let selector = try self.toolchainSelector.map { input in try ToolchainSelector(parsing: input) } - var config = try await Config.load(ctx) - let tc: [ToolchainVersion] switch selector { diff --git a/Sources/Swiftly/Run.swift b/Sources/Swiftly/Run.swift index 8f8ff6d7..a3126b4e 100644 --- a/Sources/Swiftly/Run.swift +++ b/Sources/Swiftly/Run.swift @@ -62,14 +62,13 @@ struct Run: SwiftlyCommand { defer { versionUpdateReminder() } + var config = try await Config.load(ctx) // Handle the specific case where help is requested of the run subcommand if command == ["--help"] { throw CleanExit.helpRequest(self) } - var config = try await Config.load(ctx) - let (command, selector) = try Self.extractProxyArguments(command: self.command) let toolchain: ToolchainVersion? diff --git a/Sources/Swiftly/Swiftly.swift b/Sources/Swiftly/Swiftly.swift index 7acb55a8..4ef95b7a 100644 --- a/Sources/Swiftly/Swiftly.swift +++ b/Sources/Swiftly/Swiftly.swift @@ -10,7 +10,7 @@ import SystemPackage typealias fs = SwiftlyCore.FileSystem -extension FilePath: ExpressibleByArgument { +extension FilePath: @retroactive ExpressibleByArgument { public init?(argument: String) { self.init(argument) } @@ -46,6 +46,8 @@ public struct Swiftly: SwiftlyCommand { Init.self, SelfUpdate.self, Run.self, + Link.self, + Unlink.self, ] ) diff --git a/Sources/Swiftly/Unlink.swift b/Sources/Swiftly/Unlink.swift new file mode 100644 index 00000000..71c7b484 --- /dev/null +++ b/Sources/Swiftly/Unlink.swift @@ -0,0 +1,103 @@ +import ArgumentParser +import Foundation +import SwiftlyCore +import SystemPackage + +struct Unlink: SwiftlyCommand { + public static let configuration = CommandConfiguration( + abstract: "Unlinks swiftly so it no longer manages the active toolchain." + ) + + @Argument(help: ArgumentHelp( + "Unlinks swiftly, allowing the system default toolchain to be used.", + discussion: """ + + Unlinks swiftly until swiftly is linked again with: + + $ swiftly link + """ + )) + var toolchainSelector: String? + + @OptionGroup var root: GlobalOptions + + mutating func run() async throws { + try await self.run(Swiftly.createDefaultContext()) + } + + mutating func run(_ ctx: SwiftlyCoreContext) async throws { + let versionUpdateReminder = try await validateSwiftly(ctx) + defer { + versionUpdateReminder() + } + + var pathChanged = false + let existingProxies = try await symlinkedProxies(ctx) + + for p in existingProxies { + let proxy = Swiftly.currentPlatform.swiftlyBinDir(ctx) / p + + if try await fs.exists(atPath: proxy) { + try await fs.remove(atPath: proxy) + pathChanged = true + } + } + + if pathChanged { + await ctx.print(Messages.unlinkSuccess) + await ctx.print(Messages.refreshShell) + } + } + + func symlinkedProxies(_ ctx: SwiftlyCoreContext) async throws -> [String] { + if let proxyTo = try? await Swiftly.currentPlatform.findSwiftlyBin(ctx) { + let swiftlyBinDir = Swiftly.currentPlatform.swiftlyBinDir(ctx) + let swiftlyBinDirContents = (try? await fs.ls(atPath: swiftlyBinDir)) ?? [String]() + var proxies = [String]() + for file in swiftlyBinDirContents { + let linkTarget = try? await fs.readlink(atPath: swiftlyBinDir / file) + if linkTarget == proxyTo { + proxies.append(file) + } + } + return proxies + } + return [] + } +} + +extension SwiftlyCommand { + /// Checks if swiftly is currently linked to manage the active toolchain. + /// - Parameter ctx: The Swiftly context. + func validateLinked(_ ctx: SwiftlyCoreContext) async throws { + if try await !self.isLinked(ctx) { + await ctx.print(Messages.currentlyUnlinked) + } + } + + private func isLinked(_ ctx: SwiftlyCoreContext) async throws -> Bool { + guard let proxyTo = try? await Swiftly.currentPlatform.findSwiftlyBin(ctx) else { + return false + } + + let swiftlyBinDir = Swiftly.currentPlatform.swiftlyBinDir(ctx) + guard let swiftlyBinDirContents = try? await fs.ls(atPath: swiftlyBinDir) else { + return false + } + + for file in swiftlyBinDirContents { + // A way to test swiftly locally is to symlink the swiftly executable + // in the bin dir to one being built from their local swiftly repo. + if file == "swiftly" { + continue + } + + let potentialProxyPath = swiftlyBinDir / file + if let linkTarget = try? await fs.readlink(atPath: potentialProxyPath), linkTarget == proxyTo { + return true + } + } + + return false + } +} diff --git a/Sources/Swiftly/Update.swift b/Sources/Swiftly/Update.swift index d550e3a5..e5194226 100644 --- a/Sources/Swiftly/Update.swift +++ b/Sources/Swiftly/Update.swift @@ -87,9 +87,9 @@ struct Update: SwiftlyCommand { defer { versionUpdateReminder() } + try await validateLinked(ctx) var config = try await Config.load(ctx) - guard let parameters = try await self.resolveUpdateParameters(ctx, &config) else { if let toolchain = self.toolchain { await ctx.print("No installed toolchain matched \"\(toolchain)\"") @@ -146,15 +146,7 @@ struct Update: SwiftlyCommand { } if pathChanged { - await ctx.print(""" - NOTE: Swiftly has updated some elements in your path and your shell may not yet be - aware of the changes. You can update your shell's environment by running - - hash -r - - or restarting your shell. - - """) + await ctx.print(Messages.refreshShell) } } diff --git a/Sources/Swiftly/Use.swift b/Sources/Swiftly/Use.swift index efde6791..47c990a8 100644 --- a/Sources/Swiftly/Use.swift +++ b/Sources/Swiftly/Use.swift @@ -67,6 +67,8 @@ struct Use: SwiftlyCommand { var config = try await Config.load(ctx) + try await validateLinked(ctx) + // This is the bare use command where we print the selected toolchain version (or the path to it) guard let toolchain = self.toolchain else { let (selectedVersion, result) = try await selectToolchain(ctx, config: &config, globalDefault: self.globalDefault) diff --git a/Sources/SwiftlyCore/Messages.swift b/Sources/SwiftlyCore/Messages.swift new file mode 100644 index 00000000..8ab694f8 --- /dev/null +++ b/Sources/SwiftlyCore/Messages.swift @@ -0,0 +1,40 @@ +public enum Messages { + public static let refreshShell = """ + NOTE: Swiftly has updated some elements in your path and your shell may not yet be + aware of the changes. You can update your shell's environment by running + + hash -r + + or restarting your shell. + + """ + + public static let unlinkSuccess = """ + Swiftly is now unlinked and will not manage the active toolchain until the following + command is run: + + $ swiftly link + + + """ + + public static let currentlyUnlinked = """ + Swiftly is currently unlinked and will not manage the active toolchain. You can run + the following command to link swiftly to the active toolchain: + + $ swiftly link + + + """ + + public static func postInstall(_ command: String) -> String { + """ + There are some dependencies that should be installed before using this toolchain. + You can run the following script as the system administrator (e.g. root) to prepare + your system: + + \(command) + + """ + } +} diff --git a/Sources/SwiftlyCore/Platform.swift b/Sources/SwiftlyCore/Platform.swift index d540febe..23687eb6 100644 --- a/Sources/SwiftlyCore/Platform.swift +++ b/Sources/SwiftlyCore/Platform.swift @@ -455,7 +455,12 @@ extension Platform { } // If we're running inside an xctest then we don't have a location for this swiftly. - guard let cmdAbsolute, !cmdAbsolute.string.hasSuffix("xctest") else { + guard let cmdAbsolute, + !( + (cmdAbsolute.string.hasSuffix("xctest") || cmdAbsolute.string.hasSuffix("swiftpm-testing-helper")) + && CommandLine.arguments.contains { $0.contains("InstallTests") } + ) + else { return nil } diff --git a/Tests/SwiftlyTests/LinkTests.swift b/Tests/SwiftlyTests/LinkTests.swift new file mode 100644 index 00000000..162337ab --- /dev/null +++ b/Tests/SwiftlyTests/LinkTests.swift @@ -0,0 +1,38 @@ +import Foundation +@testable import Swiftly +@testable import SwiftlyCore +import Testing + +@Suite struct LinkTests { + /// Tests that enabling swiftly results in swiftlyBinDir being populated with symlinks. + @Test(.testHomeMockedToolchain()) func testLink() async throws { + try await SwiftlyTests.withTestHome { + let swiftlyBinDir = Swiftly.currentPlatform.swiftlyBinDir(SwiftlyTests.ctx) + let swiftlyBinaryPath = swiftlyBinDir / "swiftly" + let swiftVersionFilename = SwiftlyTests.ctx.currentDirectory / ".swift-version" + + // Configure a mock toolchain + let versionString = "6.0.3" + let toolchainVersion = try ToolchainVersion(parsing: versionString) + try versionString.write(to: swiftVersionFilename, atomically: true, encoding: .utf8) + + // And start creating a mock folder structure for that toolchain. + try "swiftly binary".write(to: swiftlyBinaryPath, atomically: true, encoding: .utf8) + + let toolchainDir = Swiftly.currentPlatform.findToolchainLocation(SwiftlyTests.ctx, toolchainVersion) / "usr" / "bin" + try await fs.mkdir(.parents, atPath: toolchainDir) + + let proxies = ["swift-build", "swift-test", "swift-run"] + for proxy in proxies { + let proxyPath = toolchainDir / proxy + try await fs.symlink(atPath: proxyPath, linkPath: swiftlyBinaryPath) + } + + _ = try await SwiftlyTests.runWithMockedIO(Link.self, ["link"]) + + let enabledSwiftlyBinDirContents = try await fs.ls(atPath: swiftlyBinDir).sorted() + let expectedProxies = (["swiftly"] + proxies).sorted() + #expect(enabledSwiftlyBinDirContents == expectedProxies) + } + } +} diff --git a/Tests/SwiftlyTests/UnlinkTests.swift b/Tests/SwiftlyTests/UnlinkTests.swift new file mode 100644 index 00000000..a9a80d29 --- /dev/null +++ b/Tests/SwiftlyTests/UnlinkTests.swift @@ -0,0 +1,26 @@ +import Foundation +@testable import Swiftly +@testable import SwiftlyCore +import Testing + +@Suite struct UnlinkTests { + /// Tests that disabling swiftly results in swiftlyBinDir with no symlinks to toolchain binaries in it. + @Test(.testHomeMockedToolchain()) func testUnlink() async throws { + try await SwiftlyTests.withTestHome { + let swiftlyBinDir = Swiftly.currentPlatform.swiftlyBinDir(SwiftlyTests.ctx) + let swiftlyBinaryPath = swiftlyBinDir / "swiftly" + try "mockBinary".write(to: swiftlyBinaryPath, atomically: true, encoding: .utf8) + + let proxies = ["swift-build", "swift-test", "swift-run"] + for proxy in proxies { + let proxyPath = swiftlyBinDir / proxy + try await fs.symlink(atPath: proxyPath, linkPath: swiftlyBinaryPath) + } + + _ = try await SwiftlyTests.runWithMockedIO(Unlink.self, ["unlink"]) + + let disabledSwiftlyBinDirContents = try await fs.ls(atPath: swiftlyBinDir) + #expect(disabledSwiftlyBinDirContents == ["swiftly"]) + } + } +}