diff --git a/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md b/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md
index 31e2fd1e..82ed68df 100644
--- a/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md
+++ b/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md
@@ -545,6 +545,36 @@ The script will receive the argument '+abcde' followed by '+xyz'.
+## self-uninstall
+
+Uninstall swiftly itself.
+
+```
+swiftly self-uninstall [--assume-yes] [--verbose] [--version] [--help]
+```
+
+**--assume-yes:**
+
+*Disable confirmation prompts by assuming 'yes'*
+
+
+**--verbose:**
+
+*Enable verbose reporting from swiftly*
+
+
+**--version:**
+
+*Show the version.*
+
+
+**--help:**
+
+*Show help information.*
+
+
+
+
## link
Link swiftly so it resumes management of the active toolchain.
diff --git a/README.md b/README.md
index a8f6991c..840a0890 100644
--- a/README.md
+++ b/README.md
@@ -59,7 +59,12 @@ This command checks to see if there are new versions of swiftly itself and upgra
## Uninstalling swiftly
-Currently, only manual uninstallation is supported. If you need to uninstall swiftly, please follow the instructions below:
+swiftly can be safely removed with the following command:
+
+`swiftly self-uninstall`
+
+
+If you want to do so manually, please follow the instructions below:
NOTE: This will not uninstall any toolchains you have installed unless you do so manually with `swiftly uninstall all`.
@@ -76,6 +81,8 @@ NOTE: This will not uninstall any toolchains you have installed unless you do so
4. Restart your shell and check you have correctly removed the swiftly environment.
+
+
## Contributing
Welcome to the Swift community!
diff --git a/Sources/Swiftly/SelfUninstall.swift b/Sources/Swiftly/SelfUninstall.swift
new file mode 100644
index 00000000..cc4417af
--- /dev/null
+++ b/Sources/Swiftly/SelfUninstall.swift
@@ -0,0 +1,200 @@
+// SelfUninstall.swift
+
+import ArgumentParser
+import Foundation
+import SwiftlyCore
+import SystemPackage
+
+struct SelfUninstall: SwiftlyCommand {
+ static let configuration = CommandConfiguration(
+ abstract: "Uninstall swiftly itself."
+ )
+
+ @OptionGroup var root: GlobalOptions
+
+ private enum CodingKeys: String, CodingKey {
+ case root
+ }
+
+ mutating func run() async throws {
+ try await self.run(Swiftly.createDefaultContext())
+ }
+
+ mutating func run(_ ctx: SwiftlyCoreContext) async throws {
+ _ = try await validateSwiftly(ctx)
+ let swiftlyBin = Swiftly.currentPlatform.swiftlyBinDir(ctx)
+
+ guard try await fs.exists(atPath: swiftlyBin) else {
+ throw SwiftlyError(
+ message: "Self uninstall doesn't work when swiftly has been installed externally. Please uninstall it from the source where you installed it in the first place."
+ )
+ }
+
+ if !self.root.assumeYes {
+ await ctx.print("""
+ You are about to uninstall swiftly.
+ This will remove the swiftly binary and all files in the swiftly home directory.
+ Installed toolchains will not be removed. To remove them, run `swiftly uninstall all`.
+ This action is irreversible.
+ """)
+ guard await ctx.promptForConfirmation(defaultBehavior: true) else {
+ throw SwiftlyError(message: "swiftly installation has been cancelled")
+ }
+ }
+
+ try await Self.execute(ctx, verbose: self.root.verbose)
+ }
+
+ static func execute(_ ctx: SwiftlyCoreContext, verbose: Bool) async throws {
+ await ctx.print("Uninstalling swiftly...")
+
+ let userHome = ctx.mockedHomeDir ?? fs.home
+ let swiftlyHome = Swiftly.currentPlatform.swiftlyHomeDir(ctx)
+ let swiftlyBin = Swiftly.currentPlatform.swiftlyBinDir(ctx)
+
+ let commentLine = """
+ # Added by swiftly
+ """
+ let fishSourceLine = """
+ source "\(swiftlyHome / "env.fish")"
+ """
+
+ let shSourceLine = """
+ . "\(swiftlyHome / "env.sh")"
+ """
+
+ var profilePaths: [FilePath] = [
+ userHome / ".zprofile",
+ userHome / ".bash_profile",
+ userHome / ".bash_login",
+ userHome / ".profile",
+ ]
+
+ // Add fish shell config path
+ if let xdgConfigHome = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"] {
+ profilePaths.append(FilePath(xdgConfigHome) / "fish/conf.d/swiftly.fish")
+ } else {
+ profilePaths.append(userHome / ".config/fish/conf.d/swiftly.fish")
+ }
+
+ await ctx.print("Cleaning up shell profile files...")
+
+ // Remove swiftly source lines from shell profiles
+ for path in profilePaths where try await fs.exists(atPath: path) {
+ if verbose {
+ await ctx.print("Checking \(path)...")
+ }
+ let isFish = path.extension == "fish"
+ let sourceLine = isFish ? fishSourceLine : shSourceLine
+ let contents = try String(contentsOf: path, encoding: .utf8)
+ let linesToRemove = [sourceLine, commentLine]
+ var updatedContents = contents
+ for line in linesToRemove where contents.contains(line) {
+ updatedContents = updatedContents.replacingOccurrences(of: line, with: "")
+ try Data(updatedContents.utf8).write(to: path, options: [.atomic])
+ if verbose {
+ await ctx.print("\(path) was updated to remove swiftly line: \(line)")
+ }
+ }
+ }
+
+ // Remove swiftly symlinks and binary from Swiftly bin directory
+ await ctx.print("Checking swiftly bin directory at \(swiftlyBin)...")
+ if verbose {
+ await ctx.print("--------------------------")
+ }
+ let swiftlyBinary = swiftlyBin / "swiftly"
+ if try await fs.exists(atPath: swiftlyBin) {
+ let entries = try await fs.ls(atPath: swiftlyBin)
+ for entry in entries {
+ let fullPath = swiftlyBin / entry
+ guard try await fs.exists(atPath: fullPath) else { continue }
+ if try await fs.isSymLink(atPath: fullPath) {
+ let dest = try await fs.readlink(atPath: fullPath)
+ if dest == swiftlyBinary {
+ if verbose {
+ await ctx.print("Removing symlink: \(fullPath) -> \(dest)")
+ }
+ try await fs.remove(atPath: fullPath)
+ }
+ }
+ }
+ }
+ // then check if the swiftly binary exists
+ if try await fs.exists(atPath: swiftlyBinary) {
+ if verbose {
+ await ctx.print("Swiftly binary found at \(swiftlyBinary), removing it...")
+ }
+ try await fs.remove(atPath: swiftlyBin / "swiftly")
+ }
+
+ let entries = try await fs.ls(atPath: swiftlyBin)
+ if entries.isEmpty {
+ if verbose {
+ await ctx.print("Swiftly bin directory at \(swiftlyBin) is empty, removing it...")
+ }
+ try await fs.remove(atPath: swiftlyBin)
+ }
+
+ await ctx.print("Checking swiftly home directory at \(swiftlyHome)...")
+ if verbose {
+ await ctx.print("--------------------------")
+ }
+ let homeFiles = try? await fs.ls(atPath: swiftlyHome)
+ if let homeFiles = homeFiles, homeFiles.contains("config.json") {
+ if verbose {
+ await ctx.print("Removing swiftly config file at \(swiftlyHome / "config.json")...")
+ }
+ try await fs.remove(atPath: swiftlyHome / "config.json")
+ }
+ // look for env.sh and env.fish
+ if let homeFiles = homeFiles, homeFiles.contains("env.sh") {
+ if verbose {
+ await ctx.print("Removing swiftly env.sh file at \(swiftlyHome / "env.sh")...")
+ }
+ try await fs.remove(atPath: swiftlyHome / "env.sh")
+ }
+ if let homeFiles = homeFiles, homeFiles.contains("env.fish") {
+ if verbose {
+ await ctx.print("Removing swiftly env.fish file at \(swiftlyHome / "env.fish")...")
+ }
+ try await fs.remove(atPath: swiftlyHome / "env.fish")
+ }
+
+ // we should also check for share/doc/swiftly/license/LICENSE.txt
+ let licensePath = swiftlyHome / "share/doc/swiftly/license/LICENSE.txt"
+ if
+ try await fs.exists(atPath: licensePath)
+ {
+ if verbose {
+ await ctx.print("Removing swiftly license file at \(licensePath)...")
+ }
+ try await fs.remove(atPath: licensePath)
+ }
+
+ // removes each of share/doc/swiftly/license directories if they are empty
+ let licenseDir = swiftlyHome / "share/doc/swiftly/license"
+ if try await fs.exists(atPath: licenseDir) {
+ let licenseEntries = try await fs.ls(atPath: licenseDir)
+ if licenseEntries.isEmpty {
+ if verbose {
+ await ctx.print("Swiftly license directory at \(licenseDir) is empty, removing it...")
+ }
+ try await fs.remove(atPath: licenseDir)
+ }
+ }
+
+ // if now the swiftly home directory is empty, remove it
+ let homeEntries = try await fs.ls(atPath: swiftlyHome)
+ await ctx.print("Checking swiftly home directory entries...")
+ await ctx.print("still present: \(homeEntries.joined(separator: ", "))")
+ if homeEntries.isEmpty {
+ if verbose {
+ await ctx.print("Swiftly home directory at \(swiftlyHome) is empty, removing it...")
+ }
+ try await fs.remove(atPath: swiftlyHome)
+ }
+
+ await ctx.print("Swiftly is successfully uninstalled.")
+ }
+}
diff --git a/Sources/Swiftly/Swiftly.swift b/Sources/Swiftly/Swiftly.swift
index 4ef95b7a..f8dadc72 100644
--- a/Sources/Swiftly/Swiftly.swift
+++ b/Sources/Swiftly/Swiftly.swift
@@ -46,6 +46,7 @@ public struct Swiftly: SwiftlyCommand {
Init.self,
SelfUpdate.self,
Run.self,
+ SelfUninstall.self,
Link.self,
Unlink.self,
]
diff --git a/Sources/SwiftlyCore/FileManager+FilePath.swift b/Sources/SwiftlyCore/FileManager+FilePath.swift
index 5d1453b4..d0ac76b4 100644
--- a/Sources/SwiftlyCore/FileManager+FilePath.swift
+++ b/Sources/SwiftlyCore/FileManager+FilePath.swift
@@ -83,6 +83,10 @@ public enum FileSystem {
try FileManager.default.destinationOfSymbolicLink(atPath: atPath)
}
+ public static func isSymLink(atPath: FilePath) async throws -> Bool {
+ try FileManager.default.attributesOfItem(atPath: atPath.string)[.type] as? FileAttributeType == .typeSymbolicLink
+ }
+
public static func symlink(atPath: FilePath, linkPath: FilePath) async throws {
try FileManager.default.createSymbolicLink(atPath: atPath, withDestinationPath: linkPath)
}
diff --git a/Tests/SwiftlyTests/SelfUninstallTests.swift b/Tests/SwiftlyTests/SelfUninstallTests.swift
new file mode 100644
index 00000000..c00d3298
--- /dev/null
+++ b/Tests/SwiftlyTests/SelfUninstallTests.swift
@@ -0,0 +1,104 @@
+import Foundation
+@testable import Swiftly
+@testable import SwiftlyCore
+import SystemPackage
+import Testing
+
+@Suite struct SelfUninstallTests {
+ // Test that swiftly uninstall successfully removes the swiftly binary and the bin directory
+ @Test(.mockedSwiftlyVersion()) func removesHomeAndBinDir() async throws {
+ try await SwiftlyTests.withTestHome {
+ let swiftlyBinDir = Swiftly.currentPlatform.swiftlyBinDir(SwiftlyTests.ctx)
+ let swiftlyHomeDir = Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx)
+ #expect(
+ try await fs.exists(atPath: swiftlyBinDir) == true,
+ "swiftly bin directory should exist"
+ )
+ #expect(
+ try await fs.exists(atPath: swiftlyHomeDir) == true,
+ "swiftly home directory should exist"
+ )
+
+ try await SwiftlyTests.runCommand(SelfUninstall.self, ["self-uninstall"])
+
+ #expect(
+ try await fs.exists(atPath: swiftlyBinDir) == false,
+ "swiftly bin directory should be removed"
+ )
+ if try await fs.exists(atPath: swiftlyHomeDir) {
+ let contents = try await fs.ls(atPath: swiftlyHomeDir)
+ #expect(
+ contents == ["Toolchains"] || contents.isEmpty,
+ "swiftly home directory should only contain 'toolchains' or be empty"
+ )
+ } else {
+ #expect(
+ true,
+ "swiftly home directory should be removed"
+ )
+ }
+ }
+ }
+
+ @Test(.mockedSwiftlyVersion(), .withShell("/bin/bash")) func removesEntryFromShellProfile_bash() async throws {
+ try await self.shellProfileRemovalTest()
+ }
+
+ @Test(.mockedSwiftlyVersion(), .withShell("/bin/zsh")) func removesEntryFromShellProfile_zsh() async throws {
+ try await self.shellProfileRemovalTest()
+ }
+
+ @Test(.mockedSwiftlyVersion(), .withShell("/bin/fish")) func removesEntryFromShellProfile_fish() async throws {
+ try await self.shellProfileRemovalTest()
+ }
+
+ func shellProfileRemovalTest() async throws {
+ try await SwiftlyTests.withTestHome {
+ // Fresh user without swiftly installed
+ try? await fs.remove(atPath: Swiftly.currentPlatform.swiftlyConfigFile(SwiftlyTests.ctx))
+ try await SwiftlyTests.runCommand(Init.self, ["init", "--assume-yes", "--skip-install"])
+
+ let fishSourceLine = """
+ # Added by swiftly
+
+ source "\(Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx) / "env.fish")"
+ """
+
+ let shSourceLine = """
+ # Added by swiftly
+
+ . "\(Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx) / "env.sh")"
+ """
+
+ // add a few random lines to the profile file(s), both before and after the source line
+ for p in [".profile", ".zprofile", ".bash_profile", ".bash_login", ".config/fish/conf.d/swiftly.fish"] {
+ let profile = SwiftlyTests.ctx.mockedHomeDir! / p
+ if try await fs.exists(atPath: profile) {
+ if let profileContents = try? String(contentsOf: profile) {
+ let newContents = "# Random line before swiftly source\n" +
+ profileContents +
+ "\n# Random line after swiftly source"
+ try Data(newContents.utf8).write(to: profile, options: [.atomic])
+ }
+ }
+ }
+
+ try await SwiftlyTests.runCommand(SelfUninstall.self, ["self-uninstall", "--assume-yes"])
+
+ for p in [".profile", ".zprofile", ".bash_profile", ".bash_login", ".config/fish/conf.d/swiftly.fish"] {
+ let profile = SwiftlyTests.ctx.mockedHomeDir! / p
+ if try await fs.exists(atPath: profile) {
+ if let profileContents = try? String(contentsOf: profile) {
+ // check that the source line is removed
+ let isFishProfile = profile.extension == "fish"
+ let sourceLine = isFishProfile ? fishSourceLine : shSourceLine
+ #expect(
+ !profileContents.contains(sourceLine),
+ "swiftly source line should be removed from \(profile.string)"
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Tests/SwiftlyTests/SwiftlyTests.swift b/Tests/SwiftlyTests/SwiftlyTests.swift
index 60f78838..abc32aeb 100644
--- a/Tests/SwiftlyTests/SwiftlyTests.swift
+++ b/Tests/SwiftlyTests/SwiftlyTests.swift
@@ -159,6 +159,27 @@ extension Trait where Self == MockedSwiftlyVersionTrait {
static func mockedSwiftlyVersion(_ name: String = "testHome") -> Self { Self(name) }
}
+struct WithShellTrait: TestTrait, TestScoping {
+ let shell: String
+
+ init(_ shell: String) {
+ self.shell = shell
+ }
+
+ func provideScope(for _: Test, testCase _: Test.Case?, performing function: @Sendable () async throws -> Void) async throws {
+ var ctx = SwiftlyTests.ctx
+ ctx.mockedShell = self.shell
+ try await SwiftlyTests.$ctx.withValue(ctx) {
+ try await function()
+ }
+ }
+}
+
+extension Trait where Self == WithShellTrait {
+ /// Run the test with the provided shell.
+ static func withShell(_ shell: String) -> Self { Self(shell) }
+}
+
struct MockHomeToolchainsTrait: TestTrait, TestScoping {
var name: String = "testHome"
var toolchains: Set = .allToolchains()