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 16 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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:

`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 {
Copy link
Member

Choose a reason for hiding this comment

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

issue: Since this is a prompt for confirmation, it should also support an "--assume-yes" override in case of an automated workflow that might install and then uninstall swiftly afterwards.

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
Copy link
Member

Choose a reason for hiding this comment

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

suggestion (blocking): The current state of the user's shell may not be the same as when they initially installed swiftly. In the future, swiftly might actually install itself into all supported shells because users (like myself) switch shells quite often. So, instead of picking the current profileHome, this code should check all possible profiles for the source lines, and remove them.


let profileHome: FilePath
if shell.hasSuffix("zsh") {
profileHome = userHome / ".zprofile"
} else if shell.hasSuffix("bash") {
if case let p = userHome / ".bash_profile", try await fs.exists(atPath: p) {
profileHome = p
} else if case let p = userHome / ".bash_login", try await fs.exists(atPath: p) {
profileHome = p
} else {
profileHome = userHome / ".profile"
}
} else if shell.hasSuffix("fish") {
if let xdgConfigHome = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"], case let xdgConfigURL = FilePath(xdgConfigHome) {
profileHome = xdgConfigURL / "fish/conf.d/swiftly.fish"
} else {
profileHome = userHome / ".config/fish/conf.d/swiftly.fish"
}
} else {
profileHome = userHome / ".profile"
}

await ctx.print("Removing swiftly from shell profile at \(profileHome)...")

if try await fs.exists(atPath: profileHome) {
if case let profileContents = try String(contentsOf: profileHome, encoding: .utf8), profileContents.contains(sourceLine) {
let newContents = profileContents.replacingOccurrences(of: sourceLine, with: "")
try Data(newContents.utf8).write(to: profileHome, options: [.atomic])
}
}

let swiftlyBin = Swiftly.currentPlatform.swiftlyBinDir(ctx)
let swiftlyHome = Swiftly.currentPlatform.swiftlyHomeDir(ctx)

await ctx.print("Removing swiftly binary from \(swiftlyBin)...")
try await fs.remove(atPath: swiftlyBin)

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

await ctx.print("Swiftly uninstalled successfully.")
}
}
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) {
Copy link
Member

Choose a reason for hiding this comment

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

suggestion: Add a specific withShell() to the SwiftlyTests utility functions instead of access the task local directly like this.

// 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?

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