-
Notifications
You must be signed in to change notification settings - Fork 51
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
base: main
Are you sure you want to change the base?
Changes from all commits
fa3641d
a829f89
319ba23
874391b
30af030
98f9a58
e1e1444
3bd901d
6007d97
16be142
b24ad4e
b4594d3
fab0d69
3f619a2
6da4d84
8c01659
655fa68
6e3b94c
24d57f5
d922af8
20ec956
e02006c
823de2f
988e0f1
999fb0d
bf60fef
12f246e
dc42197
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,200 @@ | ||
// 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)") | ||
} | ||
} | ||
} | ||
|
||
// 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") | ||
} | ||
|
||
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") | ||
} | ||
|
||
// 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...") | ||
} | ||
try await fs.remove(atPath: swiftlyHome) | ||
} | ||
|
||
await ctx.print("Swiftly is successfully uninstalled.") | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,104 @@ | ||
import Foundation | ||
@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 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" | ||
) | ||
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" | ||
) | ||
} | ||
} | ||
} | ||
|
||
@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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)" | ||
) | ||
} | ||
} | ||
} | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
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.