-
Notifications
You must be signed in to change notification settings - Fork 49
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
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,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) | ||
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. issue (blocking): Thinking on this a little bit I think that we will need to take some extra care here. 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 For toolchains directory, remove all of the known toolchains first, and then check if it is empty. 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. 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.") | ||
} | ||
} |
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 { | ||
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. 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 | ||
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)" | ||
) | ||
} | ||
} | ||
} | ||
} | ||
} | ||
} |
Uh oh!
There was an error while loading. Please reload this page.