Skip to content

feat(cli): flue update — fetch, verify, swap, and restart the daemon - #38

Merged
karngyan merged 1 commit into
mainfrom
feat/flue-update
Aug 9, 2026
Merged

feat(cli): flue update — fetch, verify, swap, and restart the daemon#38
karngyan merged 1 commit into
mainfrom
feat/flue-update

Conversation

@karngyan

@karngyan karngyan commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

The gap

flue's update story was advisory-only. The daemon checks GitHub every 12 hours (cmd/flue/release.go), the sidebar shows an update card with brew upgrade karnstack/tap/flue / the curl one-liner — and that is where it ended. Nothing self-updated, and the sharper half of the problem: after installing a new binary the old daemon keeps serving. flue enable deliberately never restarts a healthy daemon (its sessions must survive a re-run), and no other restart path existed anywhere. A user could brew-upgrade, see the new version in flue version, and still have every shell spawned by last month's build.

flue update closes the loop end to end.

What it does, step by step

  1. Resolve — the latest release comes through releaseChecker.fetch, the same code and HTTP seam the daemon's 12-hourly check uses; no second GitHub client. fetch now returns the raw tag (leading v intact) because the tag is the release's download address; Release()'s cached/rendered form is trimmed exactly as before.

  2. Comparenewer()'s semver comparison. Already newest (or ahead of the tag, the routine state of a build off main): one line, exit 0. A "dev" build is refused politely — it corresponds to no release, and the refusal says exactly how a from-source build updates: git pull && make build.

  3. Swap — for script/manual installs: download flue_{version}_{os}_{arch}.tar.gz, verify its sha256 against checksums.txt (the exact contract scripts/install.sh and .goreleaser.yaml share), extract the flue file at the archive root, and atomically rename(2) it over the running executable's real path with the old file's mode preserved.

  4. Restartservice.Manager gains Restart(). When the login service is installed, the update restarts it and then polls until a daemon identifies itself as the new build — the daemon's version is read from ReleasePath's Current field, the same authenticated loopback read the sidebar uses — so the final checkmark reports the version that actually answered, not the version the CLI hoped for. No service but a daemon running: the transcript says the daemon still runs the old build and prints the two commands that fix it. Nothing running: nothing to restart.

  5. TranscriptrunEnable's voice, checkmark for checkmark:

      ✓ flue 0.6.0 downloaded and verified
      ✓ installed to /usr/local/bin/flue
      ✓ daemon restarted, running flue 0.6.0 on 127.0.0.1:7717
    

Decisions defended

Brew runs the brew-owned upgrade. If the executable resolves into a Homebrew Caskroom (or Cellar) path, flue never swaps the file itself — brew's bookkeeping would still believe the old version is installed, the next brew upgrade would clobber ours, and brew uninstall would half-work. Instead, when brew is on PATH, flue update runs brew upgrade karnstack/tap/flue with its output passed straight through, then continues to the restart step — which is the part brew alone can never do. Only when the binary is Caskroom-resolved but brew is somehow absent does it stop at naming the command.

Symlinks resolved for the swap, deliberately opposite the service manager. defaultServiceManager records the unresolved os.Executable() so the plist survives upgrades (a brew upgrade deletes the version-pinned Caskroom dir; the /opt/homebrew/bin/flue symlink is the stable name). A file swap needs the opposite: renaming a binary over a symlink would replace the link and orphan the real file, quietly converting a managed install into an unmanaged one. updateTarget resolves through EvalSymlinks so the swap replaces the bytes and every name pointing at them still does — and the resolution is also what makes Caskroom detection work at all.

Rename, never write-in-place. Renaming over a running executable is fine on unix — the running process keeps its inode — while opening it for writing is ETXTBSY on Linux and corruption elsewhere. The staging file is created first, in the target's own directory: that's the writability probe (an unwritable target refuses with a sudo flue update hint before any bytes are downloaded) and it's what makes the final rename an atomic same-filesystem move. No failure path can leave the target half-written.

Restart is graceful by construction. launchd restarts via bootout + bootstrap (the convergence sequence launchd.go's Enable already uses) rather than kickstart -k, because bootout tears the job down with SIGTERM — the signal cmdServe saves session snapshots on — while kickstart kills. systemd is the plain systemctl --user restart flue, SIGTERM likewise. An update restart exists to carry live sessions onto the new build; the kill spelling would defeat the point.

A hand-started daemon is told about, not killed. With no service installed there is no clean stop path (flue serve may be foreground in someone's terminal), so the transcript prints kill <pid> && flue open — SIGTERM snapshots the sessions and the next daemon revives them — instead of yanking a process out from under a terminal that owns it.

Test evidence

New tests follow cmd/flue's existing seams (fake HTTP through releaseChecker.get, the service.Runner fake, fakeManager, temp-dir binaries via a swapped updateTarget):

  • TestRunUpdateRefusesADevBuild — refusal names git/make, GitHub never asked
  • TestRunUpdateSaysAlreadyNewest — at the tag and ahead of it; binary never located
  • TestRunUpdateRefusesAChecksumMismatch / ...ChecksumsWithoutOurEntry — nothing installed, old bytes intact, no staging litter
  • TestRunUpdateSwapsTheBinary — happy path: bytes swapped, 0700 mode preserved, transcript voice, no litter
  • TestRunUpdateRefusesAnUnwritableTarget — sudo hint, refusal lands before any download
  • TestRunUpdateHandsABrewInstallToBrew / ...PointsAtBrewWhenBrewIsMissing
  • TestRunUpdateRestartsTheServiceAndReportsTheNewVersion — end to end against a real daemon.Server reporting 0.6.0
  • TestRunUpdateTellsTheUserAboutAStaleDaemon
  • internal/service: launchd Restart = bootout,bootstrap with the exact argv, tolerates an unloaded label, reports bootstrap failure; systemd Restart = the exact systemctl --user restart flue, reports failure
make web relay          ok
go test ./...           ok (all packages)
go vet ./...            ok
go vet -tags dev ./...  ok

(One unrelated pre-existing flake surfaced once in internal/transport/relay — a TempDir cleanup race in TestRelayPairingRegistersTheDevice; it passes repeatedly in isolation and on the unmodified tree, and that package is untouched here.)

Smoke-tested the built binary: flue update on the dev build prints the polite from-source refusal, and flue --help lists the new command.

Possible follow-up (not in this PR, scoped to CLI + README as asked): the web sidebar's update card could advertise flue update ahead of the brew/curl lines.

🤖 Generated with Claude Code

The update story was advisory-only: the daemon asked GitHub every 12h,
the sidebar showed a card with the brew/curl lines, and nothing
self-updated — worse, installing a new binary left the old daemon
serving, because flue enable deliberately never restarts a healthy one.

flue update closes the loop:

- resolves the latest release through release.go's existing fetch
  machinery (no second GitHub client) and compares semver; up to date
  is one line and exit 0, a dev build is refused with the from-source
  answer (git pull && make build)
- a binary resolving into Homebrew's Caskroom is brew's to replace:
  brew upgrade karnstack/tap/flue runs when brew is on PATH, and is
  named when it is not — never a hand-swap of files brew owns
- script/manual installs download flue_{version}_{os}_{arch}.tar.gz,
  verify sha256 against checksums.txt (install.sh's exact contract),
  and atomically rename the extracted binary over the running
  executable's resolved real path, mode preserved; an unwritable
  target refuses with a sudo hint before any download
- service.Manager gains Restart — launchd bootout+bootstrap, systemd
  restart, both SIGTERM so sessions snapshot and revive — and the
  transcript's last line reports the version the restarted daemon
  actually answered with

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karngyan
karngyan merged commit 41e3b96 into main Aug 9, 2026
1 check passed
@karngyan
karngyan deleted the feat/flue-update branch August 9, 2026 21:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant