Skip to content

Commit 8306e41

Browse files
Add ability to self uninstall swiftly (#344)
Create a new "self-uninstall" subcommand that removes swiftly from the user's account, including swiftly binary, proxies, configuration files, shell environment scripts, and customization to their shell profile.
1 parent d7d77d6 commit 8306e41

File tree

8 files changed

+498
-1
lines changed

8 files changed

+498
-1
lines changed

Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,36 @@ The script will receive the argument '+abcde' followed by '+xyz'.
582582

583583

584584

585+
## self-uninstall
586+
587+
Uninstall swiftly itself.
588+
589+
```
590+
swiftly self-uninstall [--assume-yes] [--verbose] [--version] [--help]
591+
```
592+
593+
**--assume-yes:**
594+
595+
*Disable confirmation prompts by assuming 'yes'*
596+
597+
598+
**--verbose:**
599+
600+
*Enable verbose reporting from swiftly*
601+
602+
603+
**--version:**
604+
605+
*Show the version.*
606+
607+
608+
**--help:**
609+
610+
*Show help information.*
611+
612+
613+
614+
585615
## link
586616

587617
Link swiftly so it resumes management of the active toolchain.

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,12 @@ This command checks to see if there are new versions of swiftly itself and upgra
5959

6060
## Uninstalling swiftly
6161

62-
Currently, only manual uninstallation is supported. If you need to uninstall swiftly, please follow the instructions below:
62+
swiftly can be safely removed with the following command:
63+
64+
`swiftly self-uninstall`
65+
66+
<details>
67+
<summary>If you want to do so manually, please follow the instructions below:</summary>
6368

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

@@ -76,6 +81,8 @@ NOTE: This will not uninstall any toolchains you have installed unless you do so
7681

7782
4. Restart your shell and check you have correctly removed the swiftly environment.
7883

84+
</details>
85+
7986
## Contributing
8087

8188
Welcome to the Swift community!
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
// SelfUninstall.swift
2+
3+
import ArgumentParser
4+
import Foundation
5+
import SwiftlyCore
6+
import SystemPackage
7+
8+
struct SelfUninstall: SwiftlyCommand {
9+
static let configuration = CommandConfiguration(
10+
abstract: "Uninstall swiftly itself."
11+
)
12+
13+
@OptionGroup var root: GlobalOptions
14+
15+
private enum CodingKeys: String, CodingKey {
16+
case root
17+
}
18+
19+
mutating func run() async throws {
20+
try await self.run(Swiftly.createDefaultContext())
21+
}
22+
23+
mutating func run(_ ctx: SwiftlyCoreContext) async throws {
24+
_ = try await validateSwiftly(ctx)
25+
let swiftlyBin = Swiftly.currentPlatform.swiftlyBinDir(ctx)
26+
27+
guard try await fs.exists(atPath: swiftlyBin) else {
28+
throw SwiftlyError(
29+
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."
30+
)
31+
}
32+
33+
if !self.root.assumeYes {
34+
await ctx.print("""
35+
You are about to uninstall swiftly.
36+
This will remove the swiftly binary and all files in the swiftly home directory.
37+
Installed toolchains will not be removed. To remove them, run `swiftly uninstall all`.
38+
This action is irreversible.
39+
""")
40+
guard await ctx.promptForConfirmation(defaultBehavior: true) else {
41+
throw SwiftlyError(message: "swiftly installation has been cancelled")
42+
}
43+
}
44+
45+
try await Self.execute(ctx, verbose: self.root.verbose)
46+
}
47+
48+
static func execute(_ ctx: SwiftlyCoreContext, verbose: Bool) async throws {
49+
await ctx.print("Uninstalling swiftly...")
50+
51+
let userHome = ctx.mockedHomeDir ?? fs.home
52+
let swiftlyHome = Swiftly.currentPlatform.swiftlyHomeDir(ctx)
53+
let swiftlyBin = Swiftly.currentPlatform.swiftlyBinDir(ctx)
54+
55+
let commentLine = """
56+
# Added by swiftly
57+
"""
58+
let fishSourceLine = """
59+
source "\(swiftlyHome / "env.fish")"
60+
"""
61+
62+
let shSourceLine = """
63+
. "\(swiftlyHome / "env.sh")"
64+
"""
65+
66+
var profilePaths: [FilePath] = [
67+
userHome / ".zprofile",
68+
userHome / ".bash_profile",
69+
userHome / ".bash_login",
70+
userHome / ".profile",
71+
]
72+
73+
// Add fish shell config path
74+
if let xdgConfigHome = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"] {
75+
profilePaths.append(FilePath(xdgConfigHome) / "fish/conf.d/swiftly.fish")
76+
} else {
77+
profilePaths.append(userHome / ".config/fish/conf.d/swiftly.fish")
78+
}
79+
80+
await ctx.print("Cleaning up shell profile files...")
81+
82+
// Remove swiftly source lines from shell profiles
83+
for path in profilePaths where try await fs.exists(atPath: path) {
84+
if verbose {
85+
await ctx.print("Checking \(path)...")
86+
}
87+
let isFish = path.extension == "fish"
88+
let sourceLine = isFish ? fishSourceLine : shSourceLine
89+
let contents = try String(contentsOf: path, encoding: .utf8)
90+
let linesToRemove = [sourceLine, commentLine]
91+
var updatedContents = contents
92+
for line in linesToRemove where contents.contains(line) {
93+
updatedContents = updatedContents.replacingOccurrences(of: line, with: "")
94+
try Data(updatedContents.utf8).write(to: path, options: [.atomic])
95+
if verbose {
96+
await ctx.print("\(path) was updated to remove swiftly line: \(line)")
97+
}
98+
}
99+
}
100+
101+
// Remove swiftly symlinks and binary from Swiftly bin directory
102+
await ctx.print("Checking swiftly bin directory at \(swiftlyBin)...")
103+
if verbose {
104+
await ctx.print("--------------------------")
105+
}
106+
let swiftlyBinary = swiftlyBin / "swiftly"
107+
if try await fs.exists(atPath: swiftlyBin) {
108+
let entries = try await fs.ls(atPath: swiftlyBin)
109+
for entry in entries {
110+
let fullPath = swiftlyBin / entry
111+
guard try await fs.exists(atPath: fullPath) else { continue }
112+
if try await fs.isSymLink(atPath: fullPath) {
113+
let dest = try await fs.readlink(atPath: fullPath)
114+
if dest == swiftlyBinary {
115+
if verbose {
116+
await ctx.print("Removing symlink: \(fullPath) -> \(dest)")
117+
}
118+
try await fs.remove(atPath: fullPath)
119+
}
120+
}
121+
}
122+
}
123+
// then check if the swiftly binary exists
124+
if try await fs.exists(atPath: swiftlyBinary) {
125+
if verbose {
126+
await ctx.print("Swiftly binary found at \(swiftlyBinary), removing it...")
127+
}
128+
try await fs.remove(atPath: swiftlyBin / "swiftly")
129+
}
130+
131+
let entries = try await fs.ls(atPath: swiftlyBin)
132+
if entries.isEmpty {
133+
if verbose {
134+
await ctx.print("Swiftly bin directory at \(swiftlyBin) is empty, removing it...")
135+
}
136+
try await fs.remove(atPath: swiftlyBin)
137+
}
138+
139+
await ctx.print("Checking swiftly home directory at \(swiftlyHome)...")
140+
if verbose {
141+
await ctx.print("--------------------------")
142+
}
143+
let homeFiles = try? await fs.ls(atPath: swiftlyHome)
144+
if let homeFiles = homeFiles, homeFiles.contains("config.json") {
145+
if verbose {
146+
await ctx.print("Removing swiftly config file at \(swiftlyHome / "config.json")...")
147+
}
148+
try await fs.remove(atPath: swiftlyHome / "config.json")
149+
}
150+
// look for env.sh and env.fish
151+
if let homeFiles = homeFiles, homeFiles.contains("env.sh") {
152+
if verbose {
153+
await ctx.print("Removing swiftly env.sh file at \(swiftlyHome / "env.sh")...")
154+
}
155+
try await fs.remove(atPath: swiftlyHome / "env.sh")
156+
}
157+
if let homeFiles = homeFiles, homeFiles.contains("env.fish") {
158+
if verbose {
159+
await ctx.print("Removing swiftly env.fish file at \(swiftlyHome / "env.fish")...")
160+
}
161+
try await fs.remove(atPath: swiftlyHome / "env.fish")
162+
}
163+
164+
// we should also check for share/doc/swiftly/license/LICENSE.txt
165+
let licensePath = swiftlyHome / "share/doc/swiftly/license/LICENSE.txt"
166+
if
167+
try await fs.exists(atPath: licensePath)
168+
{
169+
if verbose {
170+
await ctx.print("Removing swiftly license file at \(licensePath)...")
171+
}
172+
try await fs.remove(atPath: licensePath)
173+
}
174+
175+
// removes each of share/doc/swiftly/license directories if they are empty
176+
let licenseDir = swiftlyHome / "share/doc/swiftly/license"
177+
if try await fs.exists(atPath: licenseDir) {
178+
let licenseEntries = try await fs.ls(atPath: licenseDir)
179+
if licenseEntries.isEmpty {
180+
if verbose {
181+
await ctx.print("Swiftly license directory at \(licenseDir) is empty, removing it...")
182+
}
183+
try await fs.remove(atPath: licenseDir)
184+
}
185+
}
186+
187+
// if now the swiftly home directory is empty, remove it
188+
let homeEntries = try await fs.ls(atPath: swiftlyHome)
189+
await ctx.print("Checking swiftly home directory entries...")
190+
await ctx.print("still present: \(homeEntries.joined(separator: ", "))")
191+
if homeEntries.isEmpty {
192+
if verbose {
193+
await ctx.print("Swiftly home directory at \(swiftlyHome) is empty, removing it...")
194+
}
195+
try await fs.remove(atPath: swiftlyHome)
196+
}
197+
198+
await ctx.print("Swiftly is successfully uninstalled.")
199+
}
200+
}

Sources/Swiftly/Swiftly.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ public struct Swiftly: SwiftlyCommand {
4646
Init.self,
4747
SelfUpdate.self,
4848
Run.self,
49+
SelfUninstall.self,
4950
Link.self,
5051
Unlink.self,
5152
]

Sources/SwiftlyCore/FileManager+FilePath.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ public enum FileSystem {
8383
try FileManager.default.destinationOfSymbolicLink(atPath: atPath)
8484
}
8585

86+
public static func isSymLink(atPath: FilePath) async throws -> Bool {
87+
try FileManager.default.attributesOfItem(atPath: atPath.string)[.type] as? FileAttributeType == .typeSymbolicLink
88+
}
89+
8690
public static func symlink(atPath: FilePath, linkPath: FilePath) async throws {
8791
try FileManager.default.createSymbolicLink(atPath: atPath, withDestinationPath: linkPath)
8892
}

0 commit comments

Comments
 (0)