diff --git a/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md b/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md index fc2fb289..9b354670 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..809c61dd 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 savely 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..75c2069d --- /dev/null +++ b/Sources/Swiftly/SelfUninstall.swift @@ -0,0 +1,119 @@ +import ArgumentParser +import Foundation +import SwiftlyCore +import SystemPackage + +struct SelfUninstall: SwiftlyCommand { + public 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 { + let _ = 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." + ) + } + + try await Self.execute(ctx, verbose: self.root.verbose) + } + + public static func execute(_ ctx: SwiftlyCoreContext, verbose _: Bool) async throws { + await ctx.print(""" + You are about to uninstall swiftly. + This will remove the swiftly binary and all the files in the swiftly home directory. + All installed toolchains will not be removed, if you want to remove them, please do so manually with `swiftly uninstall all`. + This action is irreversible. + """) + + guard await ctx.promptForConfirmation(defaultBehavior: true) else { + throw SwiftlyError(message: "swiftly installation has been cancelled") + } + await ctx.print("Uninstalling swiftly...") + + let shell = if let mockedShell = ctx.mockedShell { + mockedShell + } else { + if let s = ProcessInfo.processInfo.environment["SHELL"] { + s + } else { + try await Swiftly.currentPlatform.getShell() + } + } + + let envFile: FilePath + let sourceLine: String + if shell.hasSuffix("fish") { + envFile = Swiftly.currentPlatform.swiftlyHomeDir(ctx) / "env.fish" + sourceLine = """ + + # Added by swiftly + source "\(envFile)" + """ + } else { + envFile = Swiftly.currentPlatform.swiftlyHomeDir(ctx) / "env.sh" + sourceLine = """ + + # Added by swiftly + . "\(envFile)" + """ + } + + let userHome = ctx.mockedHomeDir ?? fs.home + + let profileHome: FilePath + if shell.hasSuffix("zsh") { + profileHome = userHome / ".zprofile" + } else if shell.hasSuffix("bash") { + if case let p = userHome / ".bash_profile", try await fs.exists(atPath: p) { + profileHome = p + } else if case let p = userHome / ".bash_login", try await fs.exists(atPath: p) { + profileHome = p + } else { + profileHome = userHome / ".profile" + } + } else if shell.hasSuffix("fish") { + if let xdgConfigHome = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"], case let xdgConfigURL = FilePath(xdgConfigHome) { + profileHome = xdgConfigURL / "fish/conf.d/swiftly.fish" + } else { + profileHome = userHome / ".config/fish/conf.d/swiftly.fish" + } + } else { + profileHome = userHome / ".profile" + } + + await ctx.print("Removing swiftly from shell profile at \(profileHome)...") + + if try await fs.exists(atPath: profileHome) { + if case let profileContents = try String(contentsOf: profileHome, encoding: .utf8), profileContents.contains(sourceLine) { + let newContents = profileContents.replacingOccurrences(of: sourceLine, with: "") + try Data(newContents.utf8).write(to: profileHome, options: [.atomic]) + } + } + + let swiftlyBin = Swiftly.currentPlatform.swiftlyBinDir(ctx) + let swiftlyHome = Swiftly.currentPlatform.swiftlyHomeDir(ctx) + + await ctx.print("Removing swiftly binary from \(swiftlyBin)...") + try await fs.remove(atPath: swiftlyBin) + + await ctx.print("Removing swiftly home directory from \(swiftlyHome)...") + try await fs.remove(atPath: swiftlyHome) + + await ctx.print("Swiftly uninstalled successfully.") + } +} 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/Tests/SwiftlyTests/SelfUninstallTests.swift b/Tests/SwiftlyTests/SelfUninstallTests.swift new file mode 100644 index 00000000..738c9d16 --- /dev/null +++ b/Tests/SwiftlyTests/SelfUninstallTests.swift @@ -0,0 +1,129 @@ +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" + ) + #expect( + try await fs.exists(atPath: swiftlyHomeDir) == false, + "swiftly home directory should be removed" + ) + } + } + + @Test(.mockedSwiftlyVersion(), .testHome(), arguments: [ + "/bin/bash", + "/bin/zsh", + "/bin/fish", + ]) func removesEntryFromShellProfile(_ shell: String) async throws { + var ctx = SwiftlyTests.ctx + ctx.mockedShell = shell + + try await SwiftlyTests.$ctx.withValue(ctx) { + // Create a profile file with the source line + let userHome = SwiftlyTests.ctx.mockedHomeDir! + + let profileHome: FilePath + if shell.hasSuffix("zsh") { + profileHome = userHome / ".zprofile" + } else if shell.hasSuffix("bash") { + if case let p = userHome / ".bash_profile", try await fs.exists(atPath: p) { + profileHome = p + } else if case let p = userHome / ".bash_login", try await fs.exists(atPath: p) { + profileHome = p + } else { + profileHome = userHome / ".profile" + } + } else if shell.hasSuffix("fish") { + if let xdgConfigHome = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"], case let xdgConfigURL = FilePath(xdgConfigHome) { + let confDir = xdgConfigURL / "fish/conf.d" + try await fs.mkdir(.parents, atPath: confDir) + profileHome = confDir / "swiftly.fish" + } else { + let confDir = userHome / ".config/fish/conf.d" + try await fs.mkdir(.parents, atPath: confDir) + profileHome = confDir / "swiftly.fish" + } + } else { + profileHome = userHome / ".profile" + } + + let envFile: FilePath + let sourceLine: String + if shell.hasSuffix("fish") { + envFile = Swiftly.currentPlatform.swiftlyHomeDir(ctx) / "env.fish" + sourceLine = """ + + # Added by swiftly + source "\(envFile)" + """ + } else { + envFile = Swiftly.currentPlatform.swiftlyHomeDir(ctx) / "env.sh" + sourceLine = """ + + # Added by swiftly + . "\(envFile)" + """ + } + + let shellProfileContents = """ + some other line before + \(sourceLine) + some other line after + """ + + try Data(shellProfileContents.utf8).write(to: profileHome) + + #expect( + try await fs.exists(atPath: profileHome) == true, + "shell profile file should exist" + ) + + // then call swiftly uninstall + try await SwiftlyTests.runCommand(SelfUninstall.self, ["self-uninstall"]) + + #expect( + try await fs.exists(atPath: profileHome) == true, + "shell profile file should still exist" + ) + + var sourceLineRemoved = true + 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), profileContents.contains(sourceLine) { + // expect only the source line is removed + #expect( + profileContents == shellProfileContents.replacingOccurrences(of: sourceLine, with: ""), + "the original profile contents should not be changed" + ) + sourceLineRemoved = false + break + } + } + } + #expect(sourceLineRemoved, "swiftly should be removed from the profile file") + } + } +}