diff --git a/README.md b/README.md index 602d246..ecf756f 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,10 @@ updates to the universal artifact, while an architecture-specific installation updates to its matching architecture. Every downloaded executable is verified against its published SHA-256 checksum before replacement. +Homebrew-managed installations are updated with `brew upgrade fxcodex`. +`fxcodex update` and automatic executable replacement intentionally defer to +Homebrew for those installations. + Automatic checks run at most once every 24 hours and do not prevent the requested command from running if a check fails. Configure an update policy anchored at a minimum version with: diff --git a/Sources/fxcodex-cli/AppCommand.swift b/Sources/fxcodex-cli/AppCommand.swift index 872faf1..734dbb6 100644 --- a/Sources/fxcodex-cli/AppCommand.swift +++ b/Sources/fxcodex-cli/AppCommand.swift @@ -6,7 +6,7 @@ import FXCodexClient @main internal struct AppCommand: AsyncParsableCommand { - internal static let version: String = "0.1.0" + internal static let version: String = "0.1.1" internal static let machineEncodingFailureResponse: String = """ { "api_version": 1, @@ -96,10 +96,12 @@ internal struct AppCommand: AsyncParsableCommand { @Dependency(\.fxCodexClient) var client: FXCodexClient guard let version = SemanticVersion(Self.version) else { throw ValidationError("fxcodex has an invalid embedded version.") } + let executableURL: URL = currentExecutableURL() return try await client.applyAutomaticPreferences( version, - currentExecutableURL(), - !(try environmentSwitch( + executableURL, + !isHomebrewManagedExecutable(executableURL) + && !(try environmentSwitch( named: "FXCODEX_DISABLE_AUTO_UPDATE" ) ?? false) diff --git a/Sources/fxcodex-cli/Commands/UpdateCommand.swift b/Sources/fxcodex-cli/Commands/UpdateCommand.swift index 71da166..2b4c08b 100644 --- a/Sources/fxcodex-cli/Commands/UpdateCommand.swift +++ b/Sources/fxcodex-cli/Commands/UpdateCommand.swift @@ -1,5 +1,6 @@ import ArgumentParser import Dependencies +import Foundation import FXCodexClient extension AppCommand { @@ -38,13 +39,19 @@ extension AppCommand { internal init() {} internal func run() async throws { + try await self.run(executableURL: currentExecutableURL()) + } + + internal func run(executableURL: URL) async throws { + guard !isHomebrewManagedExecutable(executableURL) + else { throw FXCodexError.homebrewManagedUpdate } @Dependency(\.fxCodexClient) var client: FXCodexClient guard let currentVersion = SemanticVersion(AppCommand.version) else { throw ValidationError("fxcodex has an invalid embedded version.") } let result: UpdateResult = try await client.update( currentVersion, self.channel.value, - currentExecutableURL() + executableURL ) if machineOutputRequested(self.json) { diff --git a/Sources/fxcodex-cli/Helpers/CLIUtilities.swift b/Sources/fxcodex-cli/Helpers/CLIUtilities.swift index 7358bb8..62ecdc9 100644 --- a/Sources/fxcodex-cli/Helpers/CLIUtilities.swift +++ b/Sources/fxcodex-cli/Helpers/CLIUtilities.swift @@ -123,6 +123,16 @@ internal func currentExecutableURL() -> URL { .resolvingSymlinksInPath() } +internal func isHomebrewManagedExecutable(_ executableURL: URL) -> Bool { + let components: [String] = executableURL.standardizedFileURL + .resolvingSymlinksInPath() + .pathComponents + guard let cellarIndex = components.firstIndex(of: "Cellar") + else { return false } + return components.indices.contains(cellarIndex + 1) + && components[cellarIndex + 1] == "fxcodex" +} + internal func machineOutputRequested( _ localValue: Bool? ) -> Bool { diff --git a/Sources/fxcodex-cli/Helpers/SelfInstallationClient.swift b/Sources/fxcodex-cli/Helpers/SelfInstallationClient.swift index bb962c7..1d96c0f 100644 --- a/Sources/fxcodex-cli/Helpers/SelfInstallationClient.swift +++ b/Sources/fxcodex-cli/Helpers/SelfInstallationClient.swift @@ -16,7 +16,7 @@ extension DependencyValues { static var liveValue: SelfInstallationClient { .init(uninstall: { executableURL in let executableURL: URL = executableURL.standardizedFileURL.resolvingSymlinksInPath() - if Self.isHomebrewManaged(executableURL) { + if isHomebrewManagedExecutable(executableURL) { let exitCode: Int32 = try runProcess(.init( executable: "brew", arguments: ["uninstall", "fxcodex"], @@ -30,14 +30,6 @@ extension DependencyValues { return .direct }) } - - private static func isHomebrewManaged(_ executableURL: URL) -> Bool { - let components: [String] = executableURL.pathComponents - guard let cellarIndex = components.firstIndex(of: "Cellar") - else { return false } - return components.indices.contains(cellarIndex + 1) - && components[cellarIndex + 1] == "fxcodex" - } } internal var _fxcodexSelfInstallation: SelfInstallationClient { diff --git a/Sources/fxcodex-client/Common/FXCodexError.swift b/Sources/fxcodex-client/Common/FXCodexError.swift index 2ee821c..35cb570 100644 --- a/Sources/fxcodex-client/Common/FXCodexError.swift +++ b/Sources/fxcodex-client/Common/FXCodexError.swift @@ -6,6 +6,7 @@ public enum FXCodexError: Error, Equatable, LocalizedError, Sendable { case ambiguousApplicationInstances([Int32]) case codexExecutableNotFound case homebrewNotFound + case homebrewManagedUpdate case invalidWorkspaceName(String) case primaryWorkspaceMutation case raycastBetaUnsupportedPlatform @@ -38,6 +39,9 @@ public enum FXCodexError: Error, Equatable, LocalizedError, Sendable { case .homebrewNotFound: "homebrew_not_found" + case .homebrewManagedUpdate: + "homebrew_managed_update" + case .invalidWorkspaceName: "invalid_workspace_name" @@ -99,6 +103,9 @@ public enum FXCodexError: Error, Equatable, LocalizedError, Sendable { case .homebrewNotFound: "Homebrew could not be found in PATH." + case .homebrewManagedUpdate: + "This fxcodex installation is managed by Homebrew. Run 'brew upgrade fxcodex' instead." + case let .invalidWorkspaceName(name): "Invalid workspace name '\(name)'. Use lowercase letters, numbers, and hyphens." diff --git a/Tests/fxcodex-cli-tests/MachineResponseTests.swift b/Tests/fxcodex-cli-tests/MachineResponseTests.swift index e545d2c..f1e6d0c 100644 --- a/Tests/fxcodex-cli-tests/MachineResponseTests.swift +++ b/Tests/fxcodex-cli-tests/MachineResponseTests.swift @@ -136,6 +136,7 @@ struct MachineResponseTests { (.ambiguousApplicationInstances([42]), "ambiguous_application_instances"), (.codexExecutableNotFound, "codex_executable_not_found"), (.homebrewNotFound, "homebrew_not_found"), + (.homebrewManagedUpdate, "homebrew_managed_update"), (.invalidWorkspaceName("Work"), "invalid_workspace_name"), (.primaryWorkspaceMutation, "primary_workspace_mutation"), (.raycastBetaUnsupportedPlatform, "raycast_beta_unsupported_platform"), diff --git a/Tests/fxcodex-cli-tests/UpdateCommandTests.swift b/Tests/fxcodex-cli-tests/UpdateCommandTests.swift index a60d79d..96e00ea 100644 --- a/Tests/fxcodex-cli-tests/UpdateCommandTests.swift +++ b/Tests/fxcodex-cli-tests/UpdateCommandTests.swift @@ -1,4 +1,6 @@ import ArgumentParser +import Foundation +import FXCodexClient import Testing @testable import FXCodexCLI @@ -35,4 +37,20 @@ struct UpdateCommandTests { #expect(command is AppCommand.UpdateCommand) } + + @Test("Defers Homebrew-managed updates to Homebrew") + func homebrewManaged() async throws { + let executableURL: URL = .init( + fileURLWithPath: "/opt/homebrew/Cellar/fxcodex/0.1.0/bin/fxcodex" + ) + let command: AppCommand.UpdateCommand = try .parse([]) + + await #expect(throws: FXCodexError.homebrewManagedUpdate) { + try await command.run(executableURL: executableURL) + } + #expect(isHomebrewManagedExecutable(executableURL)) + #expect(!isHomebrewManagedExecutable( + .init(fileURLWithPath: "/Users/example/.local/bin/fxcodex") + )) + } }