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()