-
Notifications
You must be signed in to change notification settings - Fork 48
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
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,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 | ||
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. 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.") | ||
} | ||
} |
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 { | ||
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(), .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) { | ||
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. 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 | ||
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. question: Can this test invoke a |
||
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") | ||
} | ||
} | ||
} |
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.
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.