diff --git a/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift b/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift index 0d87a349..8028bde8 100644 --- a/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift +++ b/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift @@ -165,6 +165,101 @@ final class UpdateChecker { return AppVersion.normalize(minCliVersionForUpdate).compare(normalizedInstalled, options: .numeric) == .orderedDescending } + /// The package-manager invocation that updates the CLI in place, derived + /// from where the running CLI binary actually lives. Returns nil when no + /// known manager is recognizable; callers fall back to showing the manual + /// command rather than guessing at a mutation. + nonisolated static func cliUpdateInvocation(cliPath: String, fileExists: (String) -> Bool = { FileManager.default.fileExists(atPath: $0) }) -> [String]? { + let dir = (cliPath as NSString).deletingLastPathComponent + if cliPath.contains("/homebrew/") || cliPath.contains("/Cellar/") { + for brew in ["\(dir)/brew", "/opt/homebrew/bin/brew", "/usr/local/bin/brew"] where fileExists(brew) { + return [brew, "upgrade", "codeburn"] + } + return nil + } + // npm-managed installs (plain npm -g, nvm, volta, asdf shims) keep npm + // in the same bin directory as the codeburn launcher. + for npm in ["\(dir)/npm", "/opt/homebrew/bin/npm", "/usr/local/bin/npm"] where fileExists(npm) { + return [npm, "install", "-g", "codeburn@latest", "--force"] + } + return nil + } + + /// One click, both updates: the CLI first (so the new `menubar --force` + /// installer runs from the version it ships with), then the app itself. + /// Each stage surfaces its own error and stops the sequence. + func performFullUpdate() { + installedCliVersion = Self.queryInstalledCliVersion() + guard !isUpdating else { return } + + if cliUpdateAvailable || cliTooOldForUpdate { + isUpdating = true + updateError = nil + let cliPath = CodeburnCLI.baseArgv().first ?? "" + guard let argv = Self.cliUpdateInvocation(cliPath: cliPath), let bin = argv.first else { + isUpdating = false + updateError = "Could not find the package manager for \(cliPath.isEmpty ? "the CLI" : cliPath). Run \u{201C}\(cliUpdateCommand)\u{201D} manually, then try again." + return + } + let process = Process() + process.executableURL = URL(fileURLWithPath: bin) + process.arguments = Array(argv.dropFirst()) + runCaptured(process) { [weak self] status, stderr in + Task { @MainActor in + guard let self else { return } + if status != 0 { + self.isUpdating = false + self.updateError = stderr.isEmpty ? "CLI update failed (exit \(status))" : stderr + NSLog("CodeBurn: CLI update failed (exit \(status)): \(stderr)") + return + } + self.installedCliVersion = Self.queryInstalledCliVersion() + self.latestCliVersion = self.installedCliVersion ?? self.latestCliVersion + self.isUpdating = false + if self.updateAvailable { + self.performUpdate() + } + } + } + return + } + + if updateAvailable { performUpdate() } + } + + /// Shared spawn-with-timeout-and-stderr-capture used by both update stages. + nonisolated private func runCaptured(_ process: Process, onExit: @escaping @Sendable (Int32, String) -> Void) { + let errPipe = Pipe() + let errBuffer = LockedDataBuffer() + process.standardOutput = FileHandle.nullDevice + process.standardError = errPipe + errPipe.fileHandleForReading.readabilityHandler = { handle in + let chunk = handle.availableData + guard !chunk.isEmpty else { return } + errBuffer.append(chunk, limit: maxUpdateStderrBytes) + } + let timeoutTask = Task.detached(priority: .utility) { + try? await Task.sleep(nanoseconds: updateTimeoutSeconds * 1_000_000_000) + if process.isRunning { + NSLog("CodeBurn: update subprocess timed out after %llus - terminating", updateTimeoutSeconds) + process.terminate() + } + } + process.terminationHandler = { proc in + timeoutTask.cancel() + errPipe.fileHandleForReading.readabilityHandler = nil + let stderr = Self.sanitizeForDisplay(String(data: errBuffer.snapshot(), encoding: .utf8) ?? "") + onExit(proc.terminationStatus, stderr) + } + do { + try process.run() + } catch { + timeoutTask.cancel() + errPipe.fileHandleForReading.readabilityHandler = nil + onExit(-1, error.localizedDescription) + } + } + func performUpdate() { installedCliVersion = Self.queryInstalledCliVersion() if cliTooOldForUpdate { diff --git a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift index a1c945c6..b182561b 100644 --- a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift +++ b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift @@ -389,7 +389,7 @@ private struct Header: View { .foregroundStyle(.secondary) } Spacer() - if updateChecker.updateAvailable || updateChecker.updateError != nil { + if updateChecker.updateAvailable || updateChecker.cliUpdateAvailable || updateChecker.updateError != nil { UpdateBadge() } AccentPicker() @@ -519,8 +519,8 @@ private struct UpdateBadge: View { var body: some View { Button { - if updateChecker.updateAvailable { - updateChecker.performUpdate() + if updateChecker.updateAvailable || updateChecker.cliUpdateAvailable { + updateChecker.performFullUpdate() } else { Task { await updateChecker.check() } } @@ -547,7 +547,7 @@ private struct UpdateBadge: View { .tint(Theme.brandAccent) .controlSize(.mini) .disabled(updateChecker.isUpdating) - .help(updateChecker.updateError ?? "Install the latest menubar build") + .help(updateChecker.updateError ?? "Update the CLI and menubar to the latest release") } } @@ -584,6 +584,17 @@ struct CLIUpdateBanner: View { .font(.system(size: 10.5, weight: .medium)) .foregroundStyle(.primary) + Button { + updateChecker.performFullUpdate() + } label: { + Text(updateChecker.isUpdating ? "Updating..." : "Update now") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.blue) + } + .buttonStyle(.plain) + .disabled(updateChecker.isUpdating) + .help("Update the CLI (and the menubar if one is available) automatically") + Button { NSPasteboard.general.clearContents() NSPasteboard.general.setString(updateChecker.cliUpdateCommand, forType: .string) diff --git a/mac/Tests/CodeBurnMenubarTests/UpdateCheckerTests.swift b/mac/Tests/CodeBurnMenubarTests/UpdateCheckerTests.swift index 63084d9a..6c4a99fa 100644 --- a/mac/Tests/CodeBurnMenubarTests/UpdateCheckerTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/UpdateCheckerTests.swift @@ -58,3 +58,45 @@ struct UpdateCheckerTests { #expect(!UpdateChecker.isCliTooOld(installed: "")) } } + + +// MARK: - one-click full update: package-manager resolution + +@Suite("cliUpdateInvocation") +struct CliUpdateInvocationTests { + @Test("homebrew path resolves brew upgrade") + func homebrewPath() { + let argv = UpdateChecker.cliUpdateInvocation(cliPath: "/opt/homebrew/bin/codeburn", fileExists: { $0 == "/opt/homebrew/bin/brew" }) + #expect(argv == ["/opt/homebrew/bin/brew", "upgrade", "codeburn"]) + } + + @Test("Cellar path resolves brew upgrade") + func cellarPath() { + let argv = UpdateChecker.cliUpdateInvocation(cliPath: "/usr/local/Cellar/codeburn/0.9.18/bin/codeburn", fileExists: { $0 == "/usr/local/bin/brew" }) + #expect(argv == ["/usr/local/bin/brew", "upgrade", "codeburn"]) + } + + @Test("sibling npm wins over global npm so the update lands in the same toolchain") + func siblingNpmWins() { + let exists: (String) -> Bool = { $0 == "/Users/u/.nvm/versions/node/v22.1.0/bin/npm" || $0 == "/opt/homebrew/bin/npm" } + let argv = UpdateChecker.cliUpdateInvocation(cliPath: "/Users/u/.nvm/versions/node/v22.1.0/bin/codeburn", fileExists: exists) + #expect(argv == ["/Users/u/.nvm/versions/node/v22.1.0/bin/npm", "install", "-g", "codeburn@latest", "--force"]) + } + + @Test("falls back to well-known npm locations") + func fallbackNpm() { + let argv = UpdateChecker.cliUpdateInvocation(cliPath: "/some/odd/place/codeburn", fileExists: { $0 == "/usr/local/bin/npm" }) + #expect(argv == ["/usr/local/bin/npm", "install", "-g", "codeburn@latest", "--force"]) + } + + @Test("no known manager returns nil instead of guessing") + func unknownManager() { + #expect(UpdateChecker.cliUpdateInvocation(cliPath: "/some/odd/place/codeburn", fileExists: { _ in false }) == nil) + } + + @Test("homebrew CLI without a findable brew never falls through to npm") + func brewMissingStaysNil() { + // Falling through to npm --force would create a second, conflicting install. + #expect(UpdateChecker.cliUpdateInvocation(cliPath: "/opt/homebrew/bin/codeburn", fileExists: { $0.hasSuffix("/npm") }) == nil) + } +}