mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-22 06:54:26 +00:00
menubar: one-click update for the CLI and the app together (#778)
The Update badge previously replaced only the menubar (via the CLI's menubar --force installer), and the CLI banner only copied the update command to the clipboard. One click now runs the whole sequence: update the CLI in place through the package manager it was installed with (brew upgrade, or npm install -g codeburn@latest --force; the npm next to the codeburn launcher wins so nvm/volta/asdf installs update inside their own toolchain), re-read the installed version, then run the app replacement from the freshly updated CLI. No recognizable package manager surfaces the manual command instead of guessing at a mutation; a homebrew CLI without a findable brew never falls through to npm, which would create a second conflicting install. The badge now also appears for CLI-only updates, and the banner's primary action is Update now with the copyable command kept as secondary. Six resolution tests, mutation-verified. Co-authored-by: reviewer <review@local>
This commit is contained in:
parent
ae1d1c026b
commit
00160275cd
3 changed files with 152 additions and 4 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue