From fa3641de954350965c07c942a2f832fe7b223682 Mon Sep 17 00:00:00 2001 From: Louis Qian Date: Mon, 5 May 2025 15:28:02 -0500 Subject: [PATCH 01/27] feat: SelfUninstall skeleton --- Sources/Swiftly/SelfUninstall.swift | 50 +++++++++++++++++++++++++++++ Sources/Swiftly/Swiftly.swift | 1 + 2 files changed, 51 insertions(+) create mode 100644 Sources/Swiftly/SelfUninstall.swift diff --git a/Sources/Swiftly/SelfUninstall.swift b/Sources/Swiftly/SelfUninstall.swift new file mode 100644 index 00000000..5bede553 --- /dev/null +++ b/Sources/Swiftly/SelfUninstall.swift @@ -0,0 +1,50 @@ +import Foundation +import ArgumentParser +import SwiftlyCore + + +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) + let swiftlyHome = Swiftly.currentPlatform.swiftlyHomeDir(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." + ) + } + + let _ = try await Self.execute(ctx, verbose: self.root.verbose) + } + + public static func execute(_ ctx: SwiftlyCoreContext, verbose: Bool) async throws { + await ctx.print("Uninstalling swiftly...") + + 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, ] From a829f89fc25b9ffcf7d0b34730a443ce9483fe16 Mon Sep 17 00:00:00 2001 From: Louis Qian Date: Mon, 5 May 2025 15:33:33 -0500 Subject: [PATCH 02/27] feat: prompt irreversible action confirmation --- Sources/Swiftly/SelfUninstall.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Sources/Swiftly/SelfUninstall.swift b/Sources/Swiftly/SelfUninstall.swift index 5bede553..effcf596 100644 --- a/Sources/Swiftly/SelfUninstall.swift +++ b/Sources/Swiftly/SelfUninstall.swift @@ -30,10 +30,19 @@ struct SelfUninstall: SwiftlyCommand { ) } - let _ = try await Self.execute(ctx, verbose: self.root.verbose) + 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. + 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 swiftlyBin = Swiftly.currentPlatform.swiftlyBinDir(ctx) From 319ba2384ed4b77ca0fdefe44cc11fad5ff47baf Mon Sep 17 00:00:00 2001 From: Louis Qian Date: Mon, 5 May 2025 15:51:06 -0500 Subject: [PATCH 03/27] test: add preliminary tests for self-uninstall --- Tests/SwiftlyTests/SelfUninstallTests.swift | 26 +++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 Tests/SwiftlyTests/SelfUninstallTests.swift diff --git a/Tests/SwiftlyTests/SelfUninstallTests.swift b/Tests/SwiftlyTests/SelfUninstallTests.swift new file mode 100644 index 00000000..3ca67891 --- /dev/null +++ b/Tests/SwiftlyTests/SelfUninstallTests.swift @@ -0,0 +1,26 @@ +import Foundation +import Testing +@testable import Swiftly +@testable import SwiftlyCore + +@Suite struct SelfUninstallTests { + // Test that swiftly uninstall successfully removes the swiftly binary and the bin directory + @Test(.mockedSwiftlyVersion()) func uninstall() async throws { + try await SwiftlyTests.withTestHome { + let swiftlyBinDir = Swiftly.currentPlatform.swiftlyBinDir(SwiftlyTests.ctx) + let swiftlyHomeDir = Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx) + + 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" + ) + + } + } +} \ No newline at end of file From 874391b2abbb4135364b43b69c0ca38fd72fb6bd Mon Sep 17 00:00:00 2001 From: Louis Qian Date: Mon, 5 May 2025 16:32:49 -0500 Subject: [PATCH 04/27] test: rename test --- Tests/SwiftlyTests/SelfUninstallTests.swift | 58 +++++++++++++++++++-- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/Tests/SwiftlyTests/SelfUninstallTests.swift b/Tests/SwiftlyTests/SelfUninstallTests.swift index 3ca67891..6a50a0dc 100644 --- a/Tests/SwiftlyTests/SelfUninstallTests.swift +++ b/Tests/SwiftlyTests/SelfUninstallTests.swift @@ -1,12 +1,13 @@ import Foundation -import Testing @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 uninstall() async throws { - try await SwiftlyTests.withTestHome { + @Test(.mockedSwiftlyVersion()) func removesHomeAndBinDir() async throws { + try await SwiftlyTests.withTestHome { let swiftlyBinDir = Swiftly.currentPlatform.swiftlyBinDir(SwiftlyTests.ctx) let swiftlyHomeDir = Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx) @@ -20,7 +21,54 @@ import Testing try await fs.exists(atPath: swiftlyHomeDir) == false, "swiftly home directory should be removed" ) - } } -} \ No newline at end of file + + // @Test(.mockedSwiftlyVersion(), .testHome(), arguments: [ + // "/bin/bash", + // "/bin/zsh", + // "/bin/fish", + // ]) func removesEntryFromShell(_ shell: String) async throws { + // var ctx = SwiftlyTests.ctx + // ctx.mockedShell = shell + + // try await SwiftlyTests.$ctx.withValue(ctx) { + // let envScript: FilePath? + // if shell.hasSuffix("bash") || shell.hasSuffix("zsh") { + // envScript = Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx) / "env.sh" + // } else if shell.hasSuffix("fish") { + // envScript = Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx) / "env.fish" + // } else { + // envScript = nil + // } + + // // if let envScript { + // // print(envScript.string) + // // } + + // // WHEN: swiftly is invoked to uninstall + // try await SwiftlyTests.runCommand(SelfUninstall.self, ["self-uninstall"]) + + // // AND: it removes the source line from the user profile + // // var sourceLineExist = false + // 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) { + // // print profile contents only + // if let profileContents = try? String(contentsOf: profile) { + // print("contents of profile \(profileContents)") + // // sourceLineExist = profileContents.contains(envScript.string) + // } + + // } + // } + // // #expect(sourceLineExist == false, "source line should be removed from the profile") + // } + // } +} From 30af030bc795fab25a2536b3f10e730f86ffb4f8 Mon Sep 17 00:00:00 2001 From: Louis Qian Date: Mon, 5 May 2025 16:32:55 -0500 Subject: [PATCH 05/27] chore: lint --- Sources/Swiftly/SelfUninstall.swift | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/Sources/Swiftly/SelfUninstall.swift b/Sources/Swiftly/SelfUninstall.swift index effcf596..0ce27572 100644 --- a/Sources/Swiftly/SelfUninstall.swift +++ b/Sources/Swiftly/SelfUninstall.swift @@ -1,8 +1,7 @@ -import Foundation import ArgumentParser +import Foundation import SwiftlyCore - struct SelfUninstall: SwiftlyCommand { public static let configuration = CommandConfiguration( abstract: "Uninstall swiftly itself.", @@ -21,7 +20,6 @@ struct SelfUninstall: SwiftlyCommand { mutating func run(_ ctx: SwiftlyCoreContext) async throws { let _ = try await validateSwiftly(ctx) let swiftlyBin = Swiftly.currentPlatform.swiftlyBinDir(ctx) - let swiftlyHome = Swiftly.currentPlatform.swiftlyHomeDir(ctx) guard try await fs.exists(atPath: swiftlyBin) else { throw SwiftlyError( @@ -33,7 +31,7 @@ struct SelfUninstall: SwiftlyCommand { try await Self.execute(ctx, verbose: self.root.verbose) } - public static func execute(_ ctx: SwiftlyCoreContext, verbose: Bool) async throws { + 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. @@ -49,10 +47,10 @@ struct SelfUninstall: SwiftlyCommand { let swiftlyHome = Swiftly.currentPlatform.swiftlyHomeDir(ctx) await ctx.print("Removing swiftly binary from \(swiftlyBin)...") - // try await fs.remove(atPath: swiftlyBin) + try await fs.remove(atPath: swiftlyBin) await ctx.print("Removing swiftly home directory from \(swiftlyHome)...") - // try await fs.remove(atPath: swiftlyHome) + try await fs.remove(atPath: swiftlyHome) await ctx.print("Swiftly uninstalled successfully.") } From 98f9a58b4d85a1de0483d63bdb1fca57e0823ad5 Mon Sep 17 00:00:00 2001 From: Louis Qian Date: Mon, 5 May 2025 18:24:31 -0500 Subject: [PATCH 06/27] test: add expects & skeleton for more --- Tests/SwiftlyTests/SelfUninstallTests.swift | 62 +++++---------------- 1 file changed, 15 insertions(+), 47 deletions(-) diff --git a/Tests/SwiftlyTests/SelfUninstallTests.swift b/Tests/SwiftlyTests/SelfUninstallTests.swift index 6a50a0dc..a9b4a255 100644 --- a/Tests/SwiftlyTests/SelfUninstallTests.swift +++ b/Tests/SwiftlyTests/SelfUninstallTests.swift @@ -10,6 +10,14 @@ import Testing 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"]) @@ -24,51 +32,11 @@ import Testing } } - // @Test(.mockedSwiftlyVersion(), .testHome(), arguments: [ - // "/bin/bash", - // "/bin/zsh", - // "/bin/fish", - // ]) func removesEntryFromShell(_ shell: String) async throws { - // var ctx = SwiftlyTests.ctx - // ctx.mockedShell = shell - - // try await SwiftlyTests.$ctx.withValue(ctx) { - // let envScript: FilePath? - // if shell.hasSuffix("bash") || shell.hasSuffix("zsh") { - // envScript = Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx) / "env.sh" - // } else if shell.hasSuffix("fish") { - // envScript = Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx) / "env.fish" - // } else { - // envScript = nil - // } - - // // if let envScript { - // // print(envScript.string) - // // } - - // // WHEN: swiftly is invoked to uninstall - // try await SwiftlyTests.runCommand(SelfUninstall.self, ["self-uninstall"]) - - // // AND: it removes the source line from the user profile - // // var sourceLineExist = false - // 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) { - // // print profile contents only - // if let profileContents = try? String(contentsOf: profile) { - // print("contents of profile \(profileContents)") - // // sourceLineExist = profileContents.contains(envScript.string) - // } - - // } - // } - // // #expect(sourceLineExist == false, "source line should be removed from the profile") - // } - // } + @Test(.testHome(), arguments: [ + "/bin/bash", + "/bin/zsh", + "/bin/fish", + ]) func removesEntryFromShellProfile(_ shell: String) async throws { + #expect(true) + } } From e1e1444838d17f27f223aff5c56207f4d8186bb9 Mon Sep 17 00:00:00 2001 From: Louis Qian Date: Mon, 5 May 2025 19:29:48 -0500 Subject: [PATCH 07/27] chore: lint --- Tests/SwiftlyTests/SelfUninstallTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/SwiftlyTests/SelfUninstallTests.swift b/Tests/SwiftlyTests/SelfUninstallTests.swift index a9b4a255..73f2aa8d 100644 --- a/Tests/SwiftlyTests/SelfUninstallTests.swift +++ b/Tests/SwiftlyTests/SelfUninstallTests.swift @@ -36,7 +36,7 @@ import Testing "/bin/bash", "/bin/zsh", "/bin/fish", - ]) func removesEntryFromShellProfile(_ shell: String) async throws { + ]) func removesEntryFromShellProfile(_: String) async throws { #expect(true) } } From 3bd901d50a127db2d13ea90b8ee4a2dd5457b8c2 Mon Sep 17 00:00:00 2001 From: Louis Qian Date: Tue, 6 May 2025 19:39:06 -0500 Subject: [PATCH 08/27] feat: remove sourceLine from shell profile --- Sources/Swiftly/SelfUninstall.swift | 59 +++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/Sources/Swiftly/SelfUninstall.swift b/Sources/Swiftly/SelfUninstall.swift index 0ce27572..4ade4b08 100644 --- a/Sources/Swiftly/SelfUninstall.swift +++ b/Sources/Swiftly/SelfUninstall.swift @@ -1,6 +1,7 @@ import ArgumentParser import Foundation import SwiftlyCore +import SystemPackage struct SelfUninstall: SwiftlyCommand { public static let configuration = CommandConfiguration( @@ -43,6 +44,64 @@ struct SelfUninstall: SwiftlyCommand { } 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 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) From 6007d9749b0adb8d11e1fa8c27131d770367cff4 Mon Sep 17 00:00:00 2001 From: Louis Qian Date: Tue, 6 May 2025 20:56:02 -0500 Subject: [PATCH 09/27] test: removesEntryFromShellProfile tests --- Tests/SwiftlyTests/SelfUninstallTests.swift | 80 ++++++++++++++++++++- 1 file changed, 77 insertions(+), 3 deletions(-) diff --git a/Tests/SwiftlyTests/SelfUninstallTests.swift b/Tests/SwiftlyTests/SelfUninstallTests.swift index 73f2aa8d..6a147a52 100644 --- a/Tests/SwiftlyTests/SelfUninstallTests.swift +++ b/Tests/SwiftlyTests/SelfUninstallTests.swift @@ -32,11 +32,85 @@ import Testing } } - @Test(.testHome(), arguments: [ + @Test(.mockedSwiftlyVersion(), .testHome(), arguments: [ "/bin/bash", "/bin/zsh", "/bin/fish", - ]) func removesEntryFromShellProfile(_: String) async throws { - #expect(true) + ]) 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) + + // then call swiftly uninstall + try await SwiftlyTests.runCommand(SelfUninstall.self, ["self-uninstall"]) + + 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: "")) + sourceLineRemoved = false + break + } + } + } + #expect(sourceLineRemoved, "swiftly should be removed from the profile file") + } } } From 16be142a7828171a373fdf0a8010ea57ce60d182 Mon Sep 17 00:00:00 2001 From: Louis Qian Date: Tue, 6 May 2025 21:04:31 -0500 Subject: [PATCH 10/27] feat: add warning for unisntalling toolchains --- Sources/Swiftly/SelfUninstall.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/Swiftly/SelfUninstall.swift b/Sources/Swiftly/SelfUninstall.swift index 4ade4b08..3c6a4278 100644 --- a/Sources/Swiftly/SelfUninstall.swift +++ b/Sources/Swiftly/SelfUninstall.swift @@ -36,6 +36,7 @@ struct SelfUninstall: SwiftlyCommand { 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. """) From b24ad4e59ff166bb13f2e13db0af969159fd6791 Mon Sep 17 00:00:00 2001 From: Louis Qian Date: Tue, 6 May 2025 21:09:52 -0500 Subject: [PATCH 11/27] test: modify shell profile after existence check --- Sources/Swiftly/SelfUninstall.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Sources/Swiftly/SelfUninstall.swift b/Sources/Swiftly/SelfUninstall.swift index 3c6a4278..75c2069d 100644 --- a/Sources/Swiftly/SelfUninstall.swift +++ b/Sources/Swiftly/SelfUninstall.swift @@ -98,9 +98,11 @@ struct SelfUninstall: SwiftlyCommand { await ctx.print("Removing swiftly from shell profile at \(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]) + 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) From b4594d3a557d543f1b22bcd25db407e5c7747bf0 Mon Sep 17 00:00:00 2001 From: Louis Qian Date: Tue, 6 May 2025 21:14:52 -0500 Subject: [PATCH 12/27] doc: update self-uninstall in README --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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! From fab0d6978473eeec557a89018234bcde511687e3 Mon Sep 17 00:00:00 2001 From: Louis Qian Date: Tue, 6 May 2025 21:28:20 -0500 Subject: [PATCH 13/27] doc: generate docc reference for self-uninstall --- .../SwiftlyDocs.docc/swiftly-cli-reference.md | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) 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. From 3f619a25304327d5f8ed4c20fad6f2644dc027b6 Mon Sep 17 00:00:00 2001 From: Louis Qian Date: Tue, 6 May 2025 22:32:18 -0500 Subject: [PATCH 14/27] test: add check for shell profile existence --- Tests/SwiftlyTests/SelfUninstallTests.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Tests/SwiftlyTests/SelfUninstallTests.swift b/Tests/SwiftlyTests/SelfUninstallTests.swift index 6a147a52..53bca7df 100644 --- a/Tests/SwiftlyTests/SelfUninstallTests.swift +++ b/Tests/SwiftlyTests/SelfUninstallTests.swift @@ -95,6 +95,11 @@ import Testing 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"]) @@ -111,6 +116,10 @@ import Testing } } #expect(sourceLineRemoved, "swiftly should be removed from the profile file") + #expect( + try await fs.exists(atPath: profileHome) == true, + "shell profile file should still exist" + ) } } } From 6da4d8421f6b2a3c600fb6ec2756c3a7d94adc33 Mon Sep 17 00:00:00 2001 From: Louis Qian Date: Tue, 6 May 2025 22:37:37 -0500 Subject: [PATCH 15/27] test: move expect up --- Tests/SwiftlyTests/SelfUninstallTests.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Tests/SwiftlyTests/SelfUninstallTests.swift b/Tests/SwiftlyTests/SelfUninstallTests.swift index 53bca7df..6dadd150 100644 --- a/Tests/SwiftlyTests/SelfUninstallTests.swift +++ b/Tests/SwiftlyTests/SelfUninstallTests.swift @@ -103,6 +103,11 @@ import Testing // 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 @@ -116,10 +121,6 @@ import Testing } } #expect(sourceLineRemoved, "swiftly should be removed from the profile file") - #expect( - try await fs.exists(atPath: profileHome) == true, - "shell profile file should still exist" - ) } } } From 8c01659719aaecfaa3fe3ea32e7e9c6ec60306c7 Mon Sep 17 00:00:00 2001 From: Louis Qian Date: Wed, 7 May 2025 01:23:26 -0500 Subject: [PATCH 16/27] test: add expect comment --- Tests/SwiftlyTests/SelfUninstallTests.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Tests/SwiftlyTests/SelfUninstallTests.swift b/Tests/SwiftlyTests/SelfUninstallTests.swift index 6dadd150..738c9d16 100644 --- a/Tests/SwiftlyTests/SelfUninstallTests.swift +++ b/Tests/SwiftlyTests/SelfUninstallTests.swift @@ -114,7 +114,10 @@ import Testing 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: "")) + #expect( + profileContents == shellProfileContents.replacingOccurrences(of: sourceLine, with: ""), + "the original profile contents should not be changed" + ) sourceLineRemoved = false break } From 6e3b94ca52afd7f5571cad1bba3e045ab9eb8a37 Mon Sep 17 00:00:00 2001 From: Louis Qian Date: Wed, 28 May 2025 16:37:56 -0500 Subject: [PATCH 17/27] feat: add `assume-yes` check for self uninstall --- Sources/Swiftly/SelfUninstall.swift | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/Sources/Swiftly/SelfUninstall.swift b/Sources/Swiftly/SelfUninstall.swift index 75c2069d..d1219aca 100644 --- a/Sources/Swiftly/SelfUninstall.swift +++ b/Sources/Swiftly/SelfUninstall.swift @@ -28,21 +28,24 @@ struct SelfUninstall: SwiftlyCommand { "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 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") + } + } 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 { From 24d57f5ad24dd7830d11241e71c57fca94bd0337 Mon Sep 17 00:00:00 2001 From: Louis Qian Date: Wed, 28 May 2025 16:58:37 -0500 Subject: [PATCH 18/27] feat: checks all possible shell profiles for removing source lines --- Sources/Swiftly/SelfUninstall.swift | 88 ++++++++++++----------------- 1 file changed, 35 insertions(+), 53 deletions(-) diff --git a/Sources/Swiftly/SelfUninstall.swift b/Sources/Swiftly/SelfUninstall.swift index d1219aca..aef8bb36 100644 --- a/Sources/Swiftly/SelfUninstall.swift +++ b/Sources/Swiftly/SelfUninstall.swift @@ -28,7 +28,7 @@ struct SelfUninstall: SwiftlyCommand { "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. @@ -48,69 +48,51 @@ struct SelfUninstall: SwiftlyCommand { public static func execute(_ ctx: SwiftlyCoreContext, verbose _: Bool) async throws { 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 userHome = ctx.mockedHomeDir ?? fs.home + let swiftlyHome = Swiftly.currentPlatform.swiftlyHomeDir(ctx) + let swiftlyBin = Swiftly.currentPlatform.swiftlyBinDir(ctx) - let envFile: FilePath - let sourceLine: String - if shell.hasSuffix("fish") { - envFile = Swiftly.currentPlatform.swiftlyHomeDir(ctx) / "env.fish" - sourceLine = """ + let fishSourceLine = """ + # Added by swiftly - # Added by swiftly - source "\(envFile)" - """ - } else { - envFile = Swiftly.currentPlatform.swiftlyHomeDir(ctx) / "env.sh" - sourceLine = """ + source "\(swiftlyHome / "env.fish")" + """ - # Added by swiftly - . "\(envFile)" - """ - } + let shSourceLine = """ + # Added by swiftly - let userHome = ctx.mockedHomeDir ?? fs.home + . "\(swiftlyHome / "env.sh")" + """ - 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" - } + var profilePaths: [FilePath] = [ + userHome / ".zprofile", + userHome / ".bash_profile", + userHome / ".bash_login", + userHome / ".profile", + ] + + // Handle fish shell config + if let xdgConfigHome = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"] { + profilePaths.append(FilePath(xdgConfigHome) / "fish/conf.d/swiftly.fish") } else { - profileHome = userHome / ".profile" + profilePaths.append(userHome / ".config/fish/conf.d/swiftly.fish") } - 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]) + await ctx.print("Scanning shell profile files to remove swiftly source line...") + + // remove swiftly source line from shell profile files + for path in profilePaths { + if try await fs.exists(atPath: path) { + await ctx.print("Removing swiftly source line from \(path)...") + let isFishProfile = path.extension == "fish" + let sourceLine = isFishProfile ? fishSourceLine : shSourceLine + if case let profileContents = try String(contentsOf: path, encoding: .utf8), profileContents.contains(sourceLine) { + let newContents = profileContents.replacingOccurrences(of: sourceLine, with: "") + try Data(newContents.utf8).write(to: path, 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) From d922af8527a34f888457e27e5974845bc13e6ee1 Mon Sep 17 00:00:00 2001 From: Louis Qian Date: Wed, 28 May 2025 17:22:30 -0500 Subject: [PATCH 19/27] test: `self-uninstall` successfully removes the sourcelines in all shell profiles --- Tests/SwiftlyTests/SelfUninstallTests.swift | 97 +++++++-------------- 1 file changed, 30 insertions(+), 67 deletions(-) diff --git a/Tests/SwiftlyTests/SelfUninstallTests.swift b/Tests/SwiftlyTests/SelfUninstallTests.swift index 738c9d16..4a379c2d 100644 --- a/Tests/SwiftlyTests/SelfUninstallTests.swift +++ b/Tests/SwiftlyTests/SelfUninstallTests.swift @@ -37,93 +37,56 @@ import Testing "/bin/zsh", "/bin/fish", ]) func removesEntryFromShellProfile(_ shell: String) async throws { + // Fresh user without swiftly installed + try? await fs.remove(atPath: Swiftly.currentPlatform.swiftlyConfigFile(SwiftlyTests.ctx)) + 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" - } + try await SwiftlyTests.runCommand(Init.self, ["init", "--assume-yes", "--skip-install"]) - let envFile: FilePath - let sourceLine: String - if shell.hasSuffix("fish") { - envFile = Swiftly.currentPlatform.swiftlyHomeDir(ctx) / "env.fish" - sourceLine = """ + let fishSourceLine = """ + # Added by swiftly - # 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 + source "\(Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx) / "env.fish")" """ - try Data(shellProfileContents.utf8).write(to: profileHome) + let shSourceLine = """ + # Added by swiftly - #expect( - try await fs.exists(atPath: profileHome) == true, - "shell profile file should exist" - ) + . "\(Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx) / "env.sh")" + """ - // then call swiftly uninstall - try await SwiftlyTests.runCommand(SelfUninstall.self, ["self-uninstall"]) + // 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]) + } + } + } - #expect( - try await fs.exists(atPath: profileHome) == true, - "shell profile file should still exist" - ) + try await SwiftlyTests.runCommand(SelfUninstall.self, ["self-uninstall", "--assume-yes"]) - 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 + 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 == shellProfileContents.replacingOccurrences(of: sourceLine, with: ""), - "the original profile contents should not be changed" + !profileContents.contains(sourceLine), + "swiftly source line should be removed from \(profile.string)" ) - sourceLineRemoved = false - break } } } - #expect(sourceLineRemoved, "swiftly should be removed from the profile file") } } } From 20ec956341022009397aab074cefbd823b373113 Mon Sep 17 00:00:00 2001 From: Louis Qian Date: Wed, 28 May 2025 17:46:50 -0500 Subject: [PATCH 20/27] test: get shell with withShell() --- Tests/SwiftlyTests/SelfUninstallTests.swift | 24 ++++++++++++--------- Tests/SwiftlyTests/SwiftlyTests.swift | 21 ++++++++++++++++++ 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/Tests/SwiftlyTests/SelfUninstallTests.swift b/Tests/SwiftlyTests/SelfUninstallTests.swift index 4a379c2d..61eb1246 100644 --- a/Tests/SwiftlyTests/SelfUninstallTests.swift +++ b/Tests/SwiftlyTests/SelfUninstallTests.swift @@ -32,18 +32,22 @@ import Testing } } - @Test(.mockedSwiftlyVersion(), .testHome(), arguments: [ - "/bin/bash", - "/bin/zsh", - "/bin/fish", - ]) func removesEntryFromShellProfile(_ shell: String) async throws { - // Fresh user without swiftly installed - try? await fs.remove(atPath: Swiftly.currentPlatform.swiftlyConfigFile(SwiftlyTests.ctx)) + @Test(.mockedSwiftlyVersion(), .withShell("/bin/bash")) func removesEntryFromShellProfile_bash() async throws { + try await self.shellProfileRemovalTest() + } - var ctx = SwiftlyTests.ctx - ctx.mockedShell = shell + @Test(.mockedSwiftlyVersion(), .withShell("/bin/zsh")) func removesEntryFromShellProfile_zsh() async throws { + try await self.shellProfileRemovalTest() + } - try await SwiftlyTests.$ctx.withValue(ctx) { + @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 = """ 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() From e02006c0b7da5ad0a91a2c7f035277f421e3d0e5 Mon Sep 17 00:00:00 2001 From: Louis Qian Date: Thu, 29 May 2025 01:24:25 -0500 Subject: [PATCH 21/27] chore: general clean up --- Sources/Swiftly/SelfUninstall.swift | 49 ++++++++++++++--------------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/Sources/Swiftly/SelfUninstall.swift b/Sources/Swiftly/SelfUninstall.swift index aef8bb36..6d47f9d8 100644 --- a/Sources/Swiftly/SelfUninstall.swift +++ b/Sources/Swiftly/SelfUninstall.swift @@ -1,11 +1,13 @@ +// SelfUninstall.swift + import ArgumentParser import Foundation import SwiftlyCore import SystemPackage struct SelfUninstall: SwiftlyCommand { - public static let configuration = CommandConfiguration( - abstract: "Uninstall swiftly itself.", + static let configuration = CommandConfiguration( + abstract: "Uninstall swiftly itself." ) @OptionGroup var root: GlobalOptions @@ -19,24 +21,22 @@ struct SelfUninstall: SwiftlyCommand { } mutating func run(_ ctx: SwiftlyCoreContext) async throws { - let _ = try await validateSwiftly(ctx) + _ = 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." + 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 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`. + 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") } @@ -45,7 +45,7 @@ struct SelfUninstall: SwiftlyCommand { try await Self.execute(ctx, verbose: self.root.verbose) } - public static func execute(_ ctx: SwiftlyCoreContext, verbose _: Bool) async throws { + static func execute(_ ctx: SwiftlyCoreContext, verbose _: Bool) async throws { await ctx.print("Uninstalling swiftly...") let userHome = ctx.mockedHomeDir ?? fs.home @@ -71,32 +71,31 @@ struct SelfUninstall: SwiftlyCommand { userHome / ".profile", ] - // Handle fish shell config + // 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("Scanning shell profile files to remove swiftly source line...") - - // remove swiftly source line from shell profile files - for path in profilePaths { - if try await fs.exists(atPath: path) { - await ctx.print("Removing swiftly source line from \(path)...") - let isFishProfile = path.extension == "fish" - let sourceLine = isFishProfile ? fishSourceLine : shSourceLine - if case let profileContents = try String(contentsOf: path, encoding: .utf8), profileContents.contains(sourceLine) { - let newContents = profileContents.replacingOccurrences(of: sourceLine, with: "") - try Data(newContents.utf8).write(to: path, options: [.atomic]) - } + 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) { + await ctx.print("Updating \(path)...") + let isFish = path.extension == "fish" + let sourceLine = isFish ? fishSourceLine : shSourceLine + let contents = try String(contentsOf: path, encoding: .utf8) + if contents.contains(sourceLine) { + let updated = contents.replacingOccurrences(of: sourceLine, with: "") + try Data(updated.utf8).write(to: path, options: [.atomic]) } } - await ctx.print("Removing swiftly binary from \(swiftlyBin)...") + await ctx.print("Removing swiftly binary at \(swiftlyBin)...") try await fs.remove(atPath: swiftlyBin) - await ctx.print("Removing swiftly home directory from \(swiftlyHome)...") + await ctx.print("Removing swiftly home directory at \(swiftlyHome)...") try await fs.remove(atPath: swiftlyHome) await ctx.print("Swiftly uninstalled successfully.") From 823de2fa7b993c3386aaddafa50c8a6bd0fca331 Mon Sep 17 00:00:00 2001 From: Louis Qian Date: Fri, 30 May 2025 14:59:44 -0500 Subject: [PATCH 22/27] doc: correct typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 809c61dd..840a0890 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ This command checks to see if there are new versions of swiftly itself and upgra ## Uninstalling swiftly -swiftly can be savely removed with the following command: +swiftly can be safely removed with the following command: `swiftly self-uninstall` From 988e0f1b953fba3b02f50e8067bc789312e9579c Mon Sep 17 00:00:00 2001 From: Louis Qian Date: Sun, 1 Jun 2025 21:56:37 -0500 Subject: [PATCH 23/27] feat: if verbose notify shell profile was updated --- Sources/Swiftly/SelfUninstall.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Sources/Swiftly/SelfUninstall.swift b/Sources/Swiftly/SelfUninstall.swift index 6d47f9d8..d3ac9157 100644 --- a/Sources/Swiftly/SelfUninstall.swift +++ b/Sources/Swiftly/SelfUninstall.swift @@ -45,7 +45,7 @@ struct SelfUninstall: SwiftlyCommand { try await Self.execute(ctx, verbose: self.root.verbose) } - static func execute(_ ctx: SwiftlyCoreContext, verbose _: Bool) async throws { + static func execute(_ ctx: SwiftlyCoreContext, verbose: Bool) async throws { await ctx.print("Uninstalling swiftly...") let userHome = ctx.mockedHomeDir ?? fs.home @@ -82,13 +82,18 @@ struct SelfUninstall: SwiftlyCommand { // Remove swiftly source lines from shell profiles for path in profilePaths where try await fs.exists(atPath: path) { - await ctx.print("Updating \(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) if contents.contains(sourceLine) { let updated = contents.replacingOccurrences(of: sourceLine, with: "") try Data(updated.utf8).write(to: path, options: [.atomic]) + if verbose { + await ctx.print("\(path) was updated to remove swiftly source line.") + } } } From 999fb0db82d470db4bb1ea10d9a0c5fe14f2cbda Mon Sep 17 00:00:00 2001 From: Louis Qian Date: Sun, 1 Jun 2025 22:02:34 -0500 Subject: [PATCH 24/27] feat: check for individual shell lines and remove them accordingly --- Sources/Swiftly/SelfUninstall.swift | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/Sources/Swiftly/SelfUninstall.swift b/Sources/Swiftly/SelfUninstall.swift index d3ac9157..779b2560 100644 --- a/Sources/Swiftly/SelfUninstall.swift +++ b/Sources/Swiftly/SelfUninstall.swift @@ -52,15 +52,14 @@ struct SelfUninstall: SwiftlyCommand { let swiftlyHome = Swiftly.currentPlatform.swiftlyHomeDir(ctx) let swiftlyBin = Swiftly.currentPlatform.swiftlyBinDir(ctx) - let fishSourceLine = """ + let commentLine = """ # Added by swiftly - + """ + let fishSourceLine = """ source "\(swiftlyHome / "env.fish")" """ let shSourceLine = """ - # Added by swiftly - . "\(swiftlyHome / "env.sh")" """ @@ -88,11 +87,13 @@ struct SelfUninstall: SwiftlyCommand { let isFish = path.extension == "fish" let sourceLine = isFish ? fishSourceLine : shSourceLine let contents = try String(contentsOf: path, encoding: .utf8) - if contents.contains(sourceLine) { - let updated = contents.replacingOccurrences(of: sourceLine, with: "") - try Data(updated.utf8).write(to: path, options: [.atomic]) + 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 source line.") + await ctx.print("\(path) was updated to remove swiftly line: \(line)") } } } From bf60fef3d47a20293aa9466a3cc3f70cf5b084be Mon Sep 17 00:00:00 2001 From: Louis Qian Date: Wed, 4 Jun 2025 23:29:16 -0500 Subject: [PATCH 25/27] feat: add helper in fs to identify symlinks --- Sources/SwiftlyCore/FileManager+FilePath.swift | 4 ++++ 1 file changed, 4 insertions(+) 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) } From 12f246e6e59c42b51dd3bf5c5ee64f9d79b0c610 Mon Sep 17 00:00:00 2001 From: Louis Qian Date: Wed, 4 Jun 2025 23:29:48 -0500 Subject: [PATCH 26/27] feat!: check for individual files in bin/home for deletion. --- Sources/Swiftly/SelfUninstall.swift | 75 +++++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 5 deletions(-) diff --git a/Sources/Swiftly/SelfUninstall.swift b/Sources/Swiftly/SelfUninstall.swift index 779b2560..69066026 100644 --- a/Sources/Swiftly/SelfUninstall.swift +++ b/Sources/Swiftly/SelfUninstall.swift @@ -98,12 +98,77 @@ struct SelfUninstall: SwiftlyCommand { } } - await ctx.print("Removing swiftly binary at \(swiftlyBin)...") - try await fs.remove(atPath: swiftlyBin) + // 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") + } - await ctx.print("Removing swiftly home directory at \(swiftlyHome)...") - try await fs.remove(atPath: swiftlyHome) + 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") + } + // if now the swiftly home directory is empty, remove it + let homeEntries = try await fs.ls(atPath: swiftlyHome) + 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 uninstalled successfully.") + await ctx.print("Swiftly is successfully uninstalled.") } } From dc421977600c0f04156bf4265834d4bcfc84fe96 Mon Sep 17 00:00:00 2001 From: Louis Qian Date: Wed, 4 Jun 2025 23:43:52 -0500 Subject: [PATCH 27/27] feat: check for lincensePaths, and left out Toolchains dir --- Sources/Swiftly/SelfUninstall.swift | 30 +++++++++++++++++++-- Tests/SwiftlyTests/SelfUninstallTests.swift | 16 ++++++++--- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/Sources/Swiftly/SelfUninstall.swift b/Sources/Swiftly/SelfUninstall.swift index 69066026..cc4417af 100644 --- a/Sources/Swiftly/SelfUninstall.swift +++ b/Sources/Swiftly/SelfUninstall.swift @@ -126,7 +126,7 @@ struct SelfUninstall: SwiftlyCommand { 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 { @@ -156,12 +156,38 @@ struct SelfUninstall: SwiftlyCommand { } if let homeFiles = homeFiles, homeFiles.contains("env.fish") { if verbose { - await ctx.print("Removing swiftly env.fish file at \(swiftlyHome / "env.fish")...") + 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...") diff --git a/Tests/SwiftlyTests/SelfUninstallTests.swift b/Tests/SwiftlyTests/SelfUninstallTests.swift index 61eb1246..c00d3298 100644 --- a/Tests/SwiftlyTests/SelfUninstallTests.swift +++ b/Tests/SwiftlyTests/SelfUninstallTests.swift @@ -25,10 +25,18 @@ import Testing 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" - ) + 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" + ) + } } }