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 28 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
28 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
bf60fef
feat: add helper in fs to identify symlinks
louisunlimited Jun 5, 2025
12f246e
feat!: check for individual files in bin/home for deletion.
louisunlimited Jun 5, 2025
dc42197
feat: check for lincensePaths, and left out Toolchains dir
louisunlimited Jun 5, 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 savely removed with the following command:
Copy link
Member

Choose a reason for hiding this comment

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

nitpick: s/savely/safely/ here


`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
119 changes: 119 additions & 0 deletions Sources/Swiftly/SelfUninstall.swift
Original file line number Diff line number Diff line change
@@ -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)
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.

Copy link
Contributor Author

@louisunlimited louisunlimited Jun 5, 2025

Choose a reason for hiding this comment

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

Are we making this self-uninstall in a way that it also deletes the toolchains directory? I was thinking it might be better to delegate this to the user since they might still want to use the toolchains, just not with swiftly managing them.

We already have a note here prior to uninstall:

        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")
            }
        }

https://github.com/louisunlimited/swiftly/blob/dc421977600c0f04156bf4265834d4bcfc84fe96/Sources/Swiftly/SelfUninstall.swift#L37


await ctx.print("Removing swiftly home directory from \(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
129 changes: 129 additions & 0 deletions Tests/SwiftlyTests/SelfUninstallTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
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(), .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
Copy link
Member

Choose a reason for hiding this comment

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

question: Can this test invoke a swiftly init to do the usual install behaviour instead of duplicating the logic here?

Copy link
Contributor Author

@louisunlimited louisunlimited May 28, 2025

Choose a reason for hiding this comment

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

Well yeah ofc, my thought was to keep the tests as independent as possible so future changes to swiftly init won't interfere with something here, but i guess they are strongly coupled anyways. So what I did here was manually creating shell profiles only and see if the source lines are removed correctly. This might've been better if it's a unit test for that particular line of code removing the sourcelines:

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])
        }
    }
}

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")
}
}
}