Skip to content

Add ability to self uninstall swiftly #344

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 25 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
fa3641d
feat: SelfUninstall skeleton
louisunlimited May 5, 2025
a829f89
feat: prompt irreversible action confirmation
louisunlimited May 5, 2025
319ba23
test: add preliminary tests for self-uninstall
louisunlimited May 5, 2025
874391b
test: rename test
louisunlimited May 5, 2025
30af030
chore: lint
louisunlimited May 5, 2025
98f9a58
test: add expects & skeleton for more
louisunlimited May 5, 2025
e1e1444
chore: lint
louisunlimited May 6, 2025
3bd901d
feat: remove sourceLine from shell profile
louisunlimited May 7, 2025
6007d97
test: removesEntryFromShellProfile tests
louisunlimited May 7, 2025
16be142
feat: add warning for unisntalling toolchains
louisunlimited May 7, 2025
b24ad4e
test: modify shell profile after existence check
louisunlimited May 7, 2025
b4594d3
doc: update self-uninstall in README
louisunlimited May 7, 2025
fab0d69
doc: generate docc reference for self-uninstall
louisunlimited May 7, 2025
3f619a2
test: add check for shell profile existence
louisunlimited May 7, 2025
6da4d84
test: move expect up
louisunlimited May 7, 2025
8c01659
test: add expect comment
louisunlimited May 7, 2025
655fa68
Merge branch 'main' into louis/self-uninstall
louisunlimited May 28, 2025
6e3b94c
feat: add `assume-yes` check for self uninstall
louisunlimited May 28, 2025
24d57f5
feat: checks all possible shell profiles for removing source lines
louisunlimited May 28, 2025
d922af8
test: `self-uninstall` successfully removes the sourcelines in all sh…
louisunlimited May 28, 2025
20ec956
test: get shell with withShell()
louisunlimited May 28, 2025
e02006c
chore: general clean up
louisunlimited May 29, 2025
823de2f
doc: correct typo
louisunlimited May 30, 2025
988e0f1
feat: if verbose notify shell profile was updated
louisunlimited Jun 2, 2025
999fb0d
feat: check for individual shell lines and remove them accordingly
louisunlimited Jun 2, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

<details>
<summary>If you want to do so manually, please follow the instructions below:</summary>

NOTE: This will not uninstall any toolchains you have installed unless you do so manually with `swiftly uninstall all`.

Expand 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.

</details>

## Contributing

Welcome to the Swift community!
Expand Down
109 changes: 109 additions & 0 deletions Sources/Swiftly/SelfUninstall.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// 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)")
}
}
}

await ctx.print("Removing swiftly binary at \(swiftlyBin)...")
try await fs.remove(atPath: swiftlyBin)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (blocking): Thinking on this a little bit I think that we will need to take some extra care here. fs.remove is a big hammer. It can remove important directories and files without warning or recourse. The user can set their SWIFTLY_BIN_DIR to a shared directory with other binaries in it.

suggestion: Instead of removing the directory, how about removing the individual proxies by checking if they are symlinks to the swiftly binary, then remove the swiftly binary itself. Finally, if this directory is empty, then remove it.

The same kind of thing should probably be used for the SWIFTLY_HOME_DIR too. Remove the config.json, the environment files, and then remove the directory if it is empty.

For toolchains directory, remove all of the known toolchains first, and then check if it is empty.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's take advantage of the verbose flag here. If it's set then we report every file path that we removed.


await ctx.print("Removing swiftly home directory at \(swiftlyHome)...")
try await fs.remove(atPath: swiftlyHome)

await ctx.print("Swiftly uninstalled successfully.")
}
}
1 change: 1 addition & 0 deletions Sources/Swiftly/Swiftly.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ public struct Swiftly: SwiftlyCommand {
Init.self,
SelfUpdate.self,
Run.self,
SelfUninstall.self,
Link.self,
Unlink.self,
]
Expand Down
96 changes: 96 additions & 0 deletions Tests/SwiftlyTests/SelfUninstallTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import Foundation
@testable import Swiftly
@testable import SwiftlyCore
import SystemPackage
import Testing

@Suite struct SelfUninstallTests {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: It's good to see the tests here for this functionality that are small and fast to run.

// 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(), .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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought: The user might reorganize their profile manually, or invoke some kind of tooling that does that. Some of these lines might exist, and others might not. Maybe a user removes the comment, leaving only the source line. We could perhaps match each line and remove them individually to make this a bit more robust and safe. Each one is unique enough that I think they shouldn't have too many false matches.


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)"
)
}
}
}
}
}
}
21 changes: 21 additions & 0 deletions Tests/SwiftlyTests/SwiftlyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<ToolchainVersion> = .allToolchains()
Expand Down