diff --git a/README.md b/README.md index f604184..459756a 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ Xcode will be installed to /Applications by default, but you can provide the pat - `uninstall`: Uninstall a specific version of Xcode - `update`: Update the list of available versions of Xcode - `version`: Print the version number of xcodes itself +- `signout`: Clears the stored username and password ### Shell Completion Scripts diff --git a/Sources/AppleAPI/Client.swift b/Sources/AppleAPI/Client.swift index ef4cec4..dc656c2 100644 --- a/Sources/AppleAPI/Client.swift +++ b/Sources/AppleAPI/Client.swift @@ -16,6 +16,7 @@ public class Client { case unexpectedSignInResponse(statusCode: Int, message: String?) case appleIDAndPrivacyAcknowledgementRequired case noTrustedPhoneNumbers + case notAuthenticated public var errorDescription: String? { switch self { @@ -27,6 +28,8 @@ public class Client { return "Not a valid phone number index. Expecting a whole number between \(min)-\(max), but was given \(given ?? "nothing")." case .noTrustedPhoneNumbers: return "Your account doesn't have any trusted phone numbers, but they're required for two-factor authentication. See https://support.apple.com/en-ca/HT204915." + case .notAuthenticated: + return "You are already signed out" default: return String(describing: self) } diff --git a/Sources/XcodesKit/XcodeInstaller.swift b/Sources/XcodesKit/XcodeInstaller.swift index 5f97664..30fecca 100644 --- a/Sources/XcodesKit/XcodeInstaller.swift +++ b/Sources/XcodesKit/XcodeInstaller.swift @@ -399,6 +399,25 @@ public final class XcodeInstaller { } } } + + public func logout() -> Promise { + guard let username = findUsername() else { return Promise(error: Client.Error.notAuthenticated) } + + return Promise { seal in + // Remove cookies in the shared URLSession + AppleAPI.Current.network.session.reset { + seal.fulfill(()) + } + } + .done { + // Remove all keychain items + try Current.keychain.remove(username) + + // Set `defaultUsername` in Configuration to nil + self.configuration.defaultUsername = nil + try self.configuration.save() + } + } let xcodesUsername = "XCODES_USERNAME" let xcodesPassword = "XCODES_PASSWORD" diff --git a/Sources/xcodes/main.swift b/Sources/xcodes/main.swift index 090fdd2..7b9a80e 100644 --- a/Sources/xcodes/main.swift +++ b/Sources/xcodes/main.swift @@ -55,7 +55,7 @@ struct Xcodes: ParsableCommand { static var configuration = CommandConfiguration( abstract: "Manage the Xcodes installed on your Mac", shouldDisplay: true, - subcommands: [Download.self, Install.self, Installed.self, List.self, Select.self, Uninstall.self, Update.self, Version.self] + subcommands: [Download.self, Install.self, Installed.self, List.self, Select.self, Uninstall.self, Update.self, Version.self, Signout.self] ) static var xcodesConfiguration = Configuration() @@ -422,6 +422,31 @@ struct Xcodes: ParsableCommand { Current.logging.log(XcodesKit.version.description) } } + + struct Signout: ParsableCommand { + static var configuration = CommandConfiguration( + abstract: "Clears the stored username and password" + ) + + @OptionGroup + var globalColor: GlobalColorOption + + func run() { + Rainbow.enabled = Rainbow.enabled && globalColor.color + + installer.logout() + .done { + Current.logging.log("Successfully signed out".green) + Signout.exit() + } + .recover { error in + Current.logging.log(error.legibleLocalizedDescription) + Signout.exit() + } + + RunLoop.current.run() + } + } } // @main doesn't work yet because of https://bugs.swift.org/browse/SR-12683 diff --git a/Tests/XcodesKitTests/XcodesKitTests.swift b/Tests/XcodesKitTests/XcodesKitTests.swift index 64176b4..947716e 100644 --- a/Tests/XcodesKitTests/XcodesKitTests.swift +++ b/Tests/XcodesKitTests/XcodesKitTests.swift @@ -1212,5 +1212,47 @@ final class XcodesKitTests: XCTestCase { """ ) } + + func test_Signout_WithExistingSession() { + var keychainDidRemove = false + Current.keychain.remove = { _ in + keychainDidRemove = true + } + + var customConfig = Configuration() + customConfig.defaultUsername = "test@example.com" + let customInstaller = XcodeInstaller(configuration: customConfig, xcodeList: XcodeList()) + + let expectation = self.expectation(description: "Signout complete") + + customInstaller.logout() + .ensure { expectation.fulfill() } + .catch { + XCTFail($0.localizedDescription) + } + + waitForExpectations(timeout: 1.0) + + XCTAssertTrue(keychainDidRemove) + } + + func test_Signout_WithoutExistingSession() { + var customConfig = Configuration() + customConfig.defaultUsername = nil + let customInstaller = XcodeInstaller(configuration: customConfig, xcodeList: XcodeList()) + + var capturedError: Error? + + let expectation = self.expectation(description: "Signout complete") + + customInstaller.logout() + .ensure { expectation.fulfill() } + .catch { error in + capturedError = error + } + waitForExpectations(timeout: 1.0) + + XCTAssertEqual(capturedError as? Client.Error, Client.Error.notAuthenticated) + } }