diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9a3385b..9beb43f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -78,3 +78,42 @@ jobs: else gh release create "$GITHUB_REF_NAME" --generate-notes --verify-tag out/* fi + + # The tap comes LAST. The formula's urls point at the assets attached + # above, so a tap published any earlier serves a formula whose downloads + # 404 — and had that upload then failed, one advertising a release that + # never existed. + - name: Check out the Homebrew tap + uses: actions/checkout@v4 + with: + repository: lntvan166/homebrew-paddock + # github.token is scoped to THIS repository. Pushing to the tap with + # it fails 403 at the very end of an otherwise green release, so the + # tap gets its own fine-grained token, contents:write on that repo + # and nothing else. + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + path: tap + + - name: Render and push the formula + run: | + set -eu + VERSION="${GITHUB_REF_NAME#v}" + mkdir -p tap/Formula + bun run scripts/render-formula.ts "$VERSION" out/SHA256SUMS tap/Formula/paddock.rb + cd tap + git add Formula/paddock.rb + # Re-running a tag renders byte-identical output. That is not a + # failure, but `git commit` with nothing staged exits 1 and would + # fail the job on a legitimate re-run — so the no-op is named + # explicitly. Suppressing the exit code instead would hide a real + # failure here too, which is the thing CLAUDE.md forbids. + if git diff --cached --quiet; then + echo "formula already current for $VERSION — nothing to push" + else + # A literal identity, not an address: this repository is public and + # CLAUDE.md forbids committing email addresses. git does not + # require user.email to be one. + git -c user.name=paddock-release -c user.email=paddock-release \ + commit -m "paddock $VERSION" + git push + fi diff --git a/.gitignore b/.gitignore index 9f6a681..2f43dce 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,11 @@ build/ paddock paddock-* +# Release staging: the compiled binaries, their SHA256SUMS, and the rendered +# Homebrew formula (`make formula`). Built by the release workflow in CI and +# reproducible locally; nothing here is source. +out/ + # Generated by scripts/gen-embedded.ts (`make embed`). Vite content-hashes # asset names, so a committed copy would silently drift from the bundle it # claims to describe. diff --git a/Makefile b/Makefile index 5a81a2e..c310cc5 100644 --- a/Makefile +++ b/Makefile @@ -17,7 +17,7 @@ export GID := $(shell id -g) TAG := $(firstword $(shell git tag --points-at HEAD)) VERSION := $(if $(TAG),$(TAG:v%=%),0.0.0-dev) -.PHONY: dev types icons check check-clean embed build-web test build up down logs restart +.PHONY: dev types icons check check-clean embed build-web test build formula up down logs restart # A real directory target, deliberately NOT in .PHONY: make compares its mtime # against package.json and bun.lock, so this installs on a fresh clone or after @@ -91,6 +91,14 @@ build: check check-clean test --define 'process.env.PADDOCK_VERSION="$(VERSION)"' \ src/server/index.ts --outfile paddock +# Renders the tap formula so it can be eyeballed without cutting a release. +# The release workflow runs the same script against the real SHA256SUMS; point +# this at any sums file: make formula SUMS=/tmp/SHA256SUMS +SUMS ?= out/SHA256SUMS +FORMULA_OUT ?= out/paddock.rb +formula: + bun run scripts/render-formula.ts $(VERSION) $(SUMS) $(FORMULA_OUT) + up: docker compose up -d --build diff --git a/README.md b/README.md index cddc169..c999e8e 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,19 @@ Installs to `~/.local/bin/paddock`, no `sudo`, checksum verified before anything is written · [read it first](https://lntvan166.github.io/paddock/install.sh) · [binaries](https://github.com/lntvan166/paddock/releases) +Or with Homebrew, which pulls in herdr as a dependency: + +```bash +brew install lntvan166/paddock/paddock +``` + +One command — the fully-qualified name taps and trusts this single formula. +Homebrew 6.0.0 requires explicit trust for a non-official tap, so a bare +`brew install paddock` cannot reach a tap; that name belongs to +`homebrew/core`, which paddock does not qualify for (`docs/decisions.md`). +Homebrew then owns the install, so upgrade with `brew upgrade paddock` — +`paddock update` detects the keg and declines rather than desyncing it. + ### herdr version paddock talks to herdr over herdr's own socket protocol. This release is built diff --git a/docs/decisions.md b/docs/decisions.md index 61de5c4..db5738d 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -534,3 +534,37 @@ session does not silently re-litigate them. way a failed key press or reply already is, the affordance is left in place for a retry, and the cursor is left exactly where it was so the retry asks for the same page rather than skipping ahead. + +19. **Homebrew ships from a personal tap, not `homebrew/core`, and `paddock + update` refuses under it.** Core is closed to paddock on two independent + counts, and neither is a matter of effort. Notability: a self-submission + by the repository owner needs 90 forks, 90 watchers or 225 stars + (`Package-Acceptance-Policy.md`). Self-update: *"Software that updates + itself conflicts with Homebrew's version and upgrade management"* + (`Acceptable-Formulae.md`) — which is `paddock update`, exactly. The + obvious escape hatch is closed by name: casks are for pre-built + distributions, and *"Open-source command-line-only software normally + belongs in homebrew/core as a formula built from source… A rejection from + homebrew/core does not by itself make the software eligible for + homebrew/cask."* + + herdr, by contrast, IS a core formula — built from a source tarball with + `rust` and `zig` as build deps, bottled by Homebrew's own CI, at ~32k + stars. That is the template if paddock ever qualifies: source build, no + self-update. It is also why the tap formula carries `depends_on "herdr"` — + a tap formula may depend on a core one, so brew can guarantee the thing + paddock is useless without. No version constraint, because paddock's herdr + check is directional and core never moves backwards. + + The bare name `paddock` is free in both core and cask and is deliberately + left unclaimed elsewhere, so a future core submission can still have it. + Until then the install is `brew install lntvan166/paddock/paddock`: since + Homebrew 6.0.0 a non-official tap needs explicit trust, and the + fully-qualified form grants it for that one formula in a single command. + + Under brew, `paddock update` refuses rather than warning-and-proceeding. + Warning and proceeding would leave `brew info` lying about what is + installed and let the next `brew upgrade` silently revert the operator — + and disabling self-update is a precondition for core anyway, so a clean + refusal is the same direction the project would have to move regardless. + diff --git a/docs/gotchas.md b/docs/gotchas.md index 6d149a2..3e256d7 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -25,6 +25,7 @@ one, recorded here so they are not reintroduced. | A test passes locally and fails in CI with "Attempted to assign to readonly property" | Bun runs every test file in ONE process, and `tests/support/dom.ts` makes globals readonly — so whether `globalThis.window = …` works depends on which file ran first. Adding test files changes that order | Fake a global with `Object.defineProperty`, restore its real descriptor, and put the setup INSIDE the `try` so a partial fake still unwinds | | One test fails and takes an unrelated test in another file with it | Globals faked before the `try`, so a throw skipped the restore and the next DOM file rendered against a two-property `window` | Setup inside the `try`; the restore is what must be unconditional | | `make test` fails about one run in twenty, looking like a timer flake | A test picked its port by arithmetic on `performance.now()` within a range that contains a real listener — paddock's own default port, on the machine of anyone running paddock | `tests/support/port.ts` asks the OS for a free port; a range that "looks unused" is a guess about someone else's machine | +| `brew info paddock` reports a version that is not the bytes on disk | The Homebrew prefix is USER-owned, so `paddock update`'s `rename(2)` over a keg SUCCEEDS. The existing "installed by a package manager" hint only fires when rename FAILS, so nothing was said — and the next `brew upgrade` reverted the operator's update without either side mentioning it | `update` resolves `realpath(selfPath)` and refuses when a `/Cellar/` segment is present, naming `brew upgrade paddock`. Refused BEFORE the download, and matched as a path SEGMENT so `~/Cellars/…` is not a false positive. `--check` still reports, because it writes nothing — and names the brew command, not `paddock update` | ## herdr protocol specifics diff --git a/packaging/homebrew/paddock.rb.tmpl b/packaging/homebrew/paddock.rb.tmpl new file mode 100644 index 0000000..cab6f3c --- /dev/null +++ b/packaging/homebrew/paddock.rb.tmpl @@ -0,0 +1,74 @@ +# Rendered by scripts/render-formula.ts and pushed to the tap by +# .github/workflows/release.yml. The copy in the tap repository is GENERATED — +# edit this template, never that file, or the next release overwrites the fix. +# +# This is a tap formula, not a homebrew-core one. Core requires a build from +# source and rejects software that updates itself; see docs/decisions.md. +class Paddock < Formula + desc "Watch and answer your coding agents from your phone" + homepage "https://github.com/lntvan166/paddock" + version "{{version}}" + license "MIT" + + # paddock reads herdr's own socket protocol and does nothing without it, so + # the dependency is real rather than a convenience. herdr is in + # homebrew-core, and a tap formula may depend on a core formula (the reverse + # is what Homebrew forbids), so brew can guarantee herdr is present instead + # of `paddock doctor` reporting its absence after the install. + # + # No version constraint, deliberately: paddock's herdr check is directional + # (README) — a NEWER herdr is accepted, only an older one is refused. Core + # never moves backwards, so tracking whatever it ships stays correct. + depends_on "herdr" + + on_macos do + on_arm do + url "https://github.com/lntvan166/paddock/releases/download/v{{version}}/paddock-macos-aarch64" + sha256 "{{sha256:paddock-macos-aarch64}}" + end + on_intel do + url "https://github.com/lntvan166/paddock/releases/download/v{{version}}/paddock-macos-x86_64" + sha256 "{{sha256:paddock-macos-x86_64}}" + end + end + + on_linux do + on_arm do + url "https://github.com/lntvan166/paddock/releases/download/v{{version}}/paddock-linux-aarch64" + sha256 "{{sha256:paddock-linux-aarch64}}" + end + on_intel do + url "https://github.com/lntvan166/paddock/releases/download/v{{version}}/paddock-linux-x86_64" + sha256 "{{sha256:paddock-linux-x86_64}}" + end + end + + livecheck do + url :stable + strategy :github_latest + end + + def install + # The release assets are bare binaries, not archives, so Homebrew stages + # each one under its own platform-specific name. Exactly one is present. + asset = Dir["paddock-*"].first + odie "no paddock binary in the staged download" if asset.nil? + bin.install asset => "paddock" + end + + def caveats + <<~CAVEAT + Homebrew owns this install, so `paddock update` will decline and send you + back here. Upgrade with: + brew upgrade paddock + CAVEAT + end + + test do + # The failure this catches has happened here before: a binary that reports + # 0.0.0-dev because a build-time define never reached it. Every test in the + # suite stayed green while every released binary was unupdatable. + ENV["PADDOCK_NO_UPDATE_CHECK"] = "1" + assert_match version.to_s, shell_output("#{bin}/paddock --version") + end +end diff --git a/scripts/render-formula.ts b/scripts/render-formula.ts new file mode 100644 index 0000000..7e5d191 --- /dev/null +++ b/scripts/render-formula.ts @@ -0,0 +1,66 @@ +/** + * Renders `packaging/homebrew/paddock.rb.tmpl` into the Homebrew formula the + * tap repository serves. + * + * The checksums come from the release's own SHA256SUMS rather than being + * recomputed here, so the formula and the published artifacts cannot disagree: + * there is one source for both, and it is the one `install.sh` and + * `paddock update` already verify against. + * + * The template names its own platforms. This renderer resolves whatever + * `{{sha256:}}` placeholders it finds, so adding or dropping a platform + * is a template edit and nothing here changes. + */ + +/** Parses `sha256sum` output: ` `, or ` *` in binary mode. */ +function digests(sums: string): Map { + const out = new Map(); + for (const line of sums.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + const [digest, ...rest] = trimmed.split(/\s+/); + const asset = rest.join(" ").replace(/^\*/, ""); + if (digest && asset) out.set(asset, digest); + } + return out; +} + +export function renderFormula(tmpl: string, version: string, sums: string): string { + const known = digests(sums); + const missing: string[] = []; + + const out = tmpl + .replaceAll("{{version}}", version) + .replace(/\{\{sha256:([^}]+)\}\}/g, (_match, asset: string) => { + const digest = known.get(asset); + if (!digest) { + // Collected rather than thrown at the first miss, so a release that + // published none of its assets reports all four instead of sending + // whoever is debugging it round the loop once per platform. + missing.push(asset); + return ""; + } + return digest; + }); + + if (missing.length > 0) { + throw new Error( + `render-formula: not listed in SHA256SUMS: ${missing.sort().join(", ")}`, + ); + } + return out; +} + +// Run as a program only when invoked directly, so the tests above can import +// the renderer without it trying to read a release's files. +if (import.meta.main) { + const [version, sumsPath, outPath] = process.argv.slice(2); + if (!version || !sumsPath || !outPath) { + console.error("usage: bun run scripts/render-formula.ts "); + process.exit(2); + } + const tmpl = await Bun.file("packaging/homebrew/paddock.rb.tmpl").text(); + const sums = await Bun.file(sumsPath).text(); + await Bun.write(outPath, renderFormula(tmpl, version.replace(/^v/, ""), sums)); + console.log(`render-formula: wrote ${outPath} for ${version}`); +} diff --git a/src/server/index.ts b/src/server/index.ts index 8ea30c3..10fd7f7 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -32,7 +32,7 @@ import { preflight, tunnelHint } from "@server/tunnel/preflight"; import { runTunnel } from "@server/tunnel/run"; import { VERSION } from "@server/version"; import { runDoctor } from "@server/doctor"; -import { runUpdate } from "@server/update"; +import { detectManagedBy, runUpdate } from "@server/update"; import { noUpdateCheckRequested, scheduleUpdateChecks } from "@server/update-check"; import { say, warn } from "@server/term"; import { BootLog } from "@server/boot-log"; @@ -293,7 +293,19 @@ const updateChecks = scheduleUpdateChecks( }, ); -const hub = new Hub({ build: currentBuildId, latestKnown: () => latestKnown }); +/** + * Which package manager owns this binary, resolved ONCE at boot. + * + * A constant, unlike `latestKnown`: the path of a running executable does not + * change, and `update` refuses inside a keg so it will not be swapped + * underneath either. It rides the WS envelope because the client cannot know + * which upgrade command applies — `paddock update` declines under Homebrew, + * so a banner naming it there would label the notice with an action that + * refuses. + */ +const managedBy = await detectManagedBy(process.execPath); + +const hub = new Hub({ build: currentBuildId, latestKnown: () => latestKnown, managedBy }); const settings = new SettingsStore(defaultConfigDir()); await settings.load(); @@ -576,6 +588,7 @@ const appDeps = { lastNotifyError: notifier.lastError, version: VERSION, latestKnown, + managedBy, herdrProtocol: DEMO ? HERDR_PROTOCOL : herdrProtocol, // Read from the supervisor rather than cached here, for the same reason // herdrConnected reads the stream: a copy can go stale and then lie. diff --git a/src/server/routes.ts b/src/server/routes.ts index b0bae34..fd822f6 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -19,7 +19,7 @@ import { EMBEDDED } from "@server/embedded"; import { allowWrite, hostOf, refusalReason } from "@server/origin"; import { warn } from "@server/term"; import type { JournalReader } from "@server/journal/read"; -import { isNavKey, type NotifyTrigger, type SettingsPatch } from "@shared/types"; +import { isNavKey, type ManagedBy, type NotifyTrigger, type SettingsPatch } from "@shared/types"; import { diffScreens, digestOf } from "@shared/screen"; import type { HerdrAgentSession } from "@shared/herdr-api"; @@ -58,6 +58,16 @@ export interface HealthBody { * field from `health()` must be a type error, not a silently missing key. */ latestKnown: string | null; + /** + * The package manager that owns this install, or null for the ordinary case. + * + * Exposed because the UPGRADE COMMAND depends on it: `paddock update` + * refuses inside a Homebrew keg, so anything telling an operator to run it + * there is wrong. Required rather than optional, for the same reason as + * `latestKnown` above — a future edit to `health()` that drops it must be a + * type error, not a silently missing key a phone then reads as "unmanaged". + */ + managedBy: ManagedBy | null; /** * The protocol the LIVE herdr reports, or null before it has answered. * diff --git a/src/server/update.ts b/src/server/update.ts index 7f1258a..1bfcc72 100644 --- a/src/server/update.ts +++ b/src/server/update.ts @@ -1,14 +1,62 @@ -import { chmod, rename, rm, writeFile } from "node:fs/promises"; +import { chmod, realpath, rename, rm, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; +import type { ManagedBy } from "@shared/types"; import { say } from "@server/term"; const REPO = "lntvan166/paddock"; +/** + * Which package manager owns the binary at `execPath`, if any. + * + * Called once at boot rather than per request: the path of a running + * executable does not change, and `update` refuses inside a keg so it will not + * be swapped underneath either. + * + * Resolves the symlink first — Homebrew leaves one in `/bin` pointing + * at the keg, and `process.execPath` may hand back either. A path that cannot + * be resolved is reported as unmanaged rather than thrown: a source checkout's + * execPath is the operator's `bun`, and boot must not die because a binary was + * moved underneath a running process. + */ +export async function detectManagedBy(execPath: string): Promise { + let resolved = execPath; + try { + resolved = await realpath(execPath); + } catch (e) { + // Said out loud, not swallowed. This only fires when the executable cannot + // be resolved at all — a binary moved or deleted underneath a running + // process — which is worth one line at boot. `console.info` rather than a + // thrown error, and the literal path is still checked below, because + // failing to classify the install must not stop the server starting. + console.info(`paddock: could not resolve ${execPath}: ${(e as Error).message}`); + } + return isBrewManaged(resolved) ? "homebrew" : null; +} + /** * The same mapping `install.sh` uses, so the installer and the updater cannot * disagree about which asset to fetch. Windows returns null deliberately: * herdr publishes no Windows build, so there is nothing to connect to. */ +/** + * Whether this binary lives inside a Homebrew keg. + * + * A tap installs the SAME released asset this updater downloads, so the two + * are byte-identical and there is no build-time flag to tell them apart. The + * install path is the only signal, and `/Cellar/` is the one invariant worth + * matching: EVERY Homebrew prefix — /opt/homebrew, /usr/local, Linuxbrew's + * default (a `.linuxbrew` directory inside a home directory), or a custom one + * — puts kegs under /Cellar///. Enumerating the known + * prefixes instead would silently stop recognising a custom one. + * + * Matched as a whole path SEGMENT, not a substring: `~/Cellars/paddock` and + * `~/wine-cellar/bin/paddock` are ordinary paths and must not be mistaken for + * kegs, which is what `includes("/Cellar")` would do. + */ +export function isBrewManaged(path: string): boolean { + return path.split("/").includes("Cellar"); +} + export function assetName(platform: string, arch: string): string | null { const os = platform === "darwin" ? "macos" : platform === "linux" ? "linux" : null; const cpu = arch === "arm64" ? "aarch64" : arch === "x64" ? "x86_64" : null; @@ -128,7 +176,53 @@ export async function runUpdate(o: UpdateOpts): Promise { return 0; } log(`paddock: ${o.current} -> ${latest}`); - if (o.checkOnly) { log("paddock: run `paddock update` to install it"); return 0; } + + // Where the binary REALLY is, before anything decides whether to write it. + // + // process.execPath may hand back the symlink Homebrew leaves in + // /bin rather than the keg it points at, and a guard that inspected + // only the literal string would miss that and overwrite the keg THROUGH the + // link. Resolved here rather than at the top so the common "already current" + // path above costs no syscall. + let resolved = o.selfPath; + try { + resolved = await realpath(o.selfPath); + } catch (e) { + // Not silenced, and not fatal. A path that cannot be resolved is still + // the path a write would target, so the check below remains meaningful + // for the literal — but an operator whose binary path just failed to + // resolve should hear about it rather than find out via a later error. + log(`paddock: could not resolve ${o.selfPath}: ${(e as Error).message}`); + } + const brewed = isBrewManaged(resolved); + + if (o.checkOnly) { + // --check writes nothing, so brew is no reason to refuse it — the in-app + // update banner reads this signal. It must name the command that actually + // works for this install, though: telling a brew user to run `paddock + // update` sends them to the command the next branch refuses. + log( + brewed + ? "paddock: run `brew upgrade paddock` to install it" + : "paddock: run `paddock update` to install it", + ); + return 0; + } + + // Refused BEFORE the download: pulling 83MB to then say no would be a silly + // way to say it. + // + // The Homebrew prefix is user-owned, so the rename(2) below would SUCCEED + // here — leaving `brew info paddock` reporting a version that is no longer + // the bytes on disk, and `brew upgrade` later reverting the operator's + // update without either side saying anything. The "installed by a package + // manager" hint further down only fires when rename FAILS, which under brew + // it does not. + if (brewed) { + log(`paddock: this binary is managed by Homebrew (${resolved})`); + log("paddock: run `brew upgrade paddock` instead"); + return 1; + } const base = `https://github.com/${REPO}/releases/download/v${latest}`; let bytes: Uint8Array; diff --git a/src/server/ws/hub.ts b/src/server/ws/hub.ts index 13f3d40..a31258a 100644 --- a/src/server/ws/hub.ts +++ b/src/server/ws/hub.ts @@ -1,4 +1,4 @@ -import type { Agent, ServerMessage } from "@shared/types"; +import type { Agent, ManagedBy, ServerMessage } from "@shared/types"; export interface HubClient { send(data: string): void; @@ -18,6 +18,12 @@ export interface HubOptions { * value is produced (a GitHub release check, cached on disk), only that * it is read fresh on every snapshot/heartbeat. */ latestKnown?: () => string | null; + /** The package manager owning this install, if any. + * + * A VALUE, not a getter like `build` and `latestKnown`: the path of the + * running executable cannot change while the process runs, and `update` + * refuses inside a keg, so there is nothing to re-read. */ + managedBy?: ManagedBy | null; } /** @@ -41,6 +47,7 @@ export class Hub { */ private readonly build: () => string | null; private readonly latestKnown: () => string | null; + private readonly managedBy: ManagedBy | null; constructor(opts: HubOptions = {}) { this.coalesceMs = opts.coalesceMs ?? 100; @@ -48,6 +55,7 @@ export class Hub { this.now = opts.now ?? Date.now; this.build = opts.build ?? (() => null); this.latestKnown = opts.latestKnown ?? (() => null); + this.managedBy = opts.managedBy ?? null; } get clientCount(): number { @@ -82,6 +90,7 @@ export class Hub { sendHeartbeat(): void { const msg: ServerMessage = { type: "heartbeat", serverTime: this.now(), build: this.build(), latestKnown: this.latestKnown(), + managedBy: this.managedBy, }; for (const client of [...this.clients]) this.sendTo(client, msg); } @@ -97,7 +106,7 @@ export class Hub { sendSnapshot(client: HubClient, hostId: string, agents: Agent[]): void { this.sendTo(client, { type: "snapshot", hostId, agents, serverTime: this.now(), - build: this.build(), latestKnown: this.latestKnown(), + build: this.build(), latestKnown: this.latestKnown(), managedBy: this.managedBy, }); } diff --git a/src/shared/types.ts b/src/shared/types.ts index acfa0d2..f51e9eb 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -262,6 +262,18 @@ export type ServerMessage = * riding it gives eventual consistency for free. */ latestKnown?: string | null; + /** + * The package manager that owns this install, or null for the ordinary + * case (the installer's `~/.local/bin`, a container, a source build). + * + * On the wire because the UPGRADE COMMAND differs and the client cannot + * know which one applies: `paddock update` refuses inside a Homebrew keg + * (see `update.ts`), so a banner naming it would be telling the operator + * to run something that declines. Absent and null are different claims — + * "this frame does not say" versus "nothing owns it" — and the store + * distinguishes them. + */ + managedBy?: ManagedBy | null; } | { type: "delta"; upserted: Agent[]; removedIds: string[]; serverTime: number } /** @@ -280,7 +292,21 @@ export type ServerMessage = * is running stale JavaScript. `index.html` is `no-cache`, which fixes fresh * loads and does nothing for a tab left open on a phone for days. */ - | { type: "heartbeat"; serverTime: number; build?: string | null; latestKnown?: string | null }; + | { + type: "heartbeat"; + serverTime: number; + build?: string | null; + latestKnown?: string | null; + managedBy?: ManagedBy | null; + }; + +/** + * A package manager that owns a paddock install and therefore owns its + * upgrades. One member today; a union rather than a boolean because the + * command to print differs per manager, so a second entry adds a case rather + * than a second field. + */ +export type ManagedBy = "homebrew"; export const SECTION_ORDER = ["needs-you", "working", "idle"] as const; export type Section = (typeof SECTION_ORDER)[number]; diff --git a/src/web/components/App.tsx b/src/web/components/App.tsx index 2dcf8b6..926f1fc 100644 --- a/src/web/components/App.tsx +++ b/src/web/components/App.tsx @@ -18,7 +18,7 @@ import { readPrefs, themeAttr } from "@web/prefs"; export function App() { const { - agents, hostId, connected, lastMessageAt, updateAvailable, latestKnown, connect, + agents, hostId, connected, lastMessageAt, updateAvailable, latestKnown, managedBy, connect, } = useStore(); const [now, setNow] = useState(() => Date.now()); // Expanded by default. Collapsed, idle agents render as chips that carry a @@ -123,6 +123,7 @@ export function App() { {shouldShowRelease(latestKnown, dismissedVersion) && ( { dismissRelease(latestKnown!); setDismissedVersion(latestKnown); diff --git a/src/web/components/ReleaseBanner.tsx b/src/web/components/ReleaseBanner.tsx index 3923750..2b53e8f 100644 --- a/src/web/components/ReleaseBanner.tsx +++ b/src/web/components/ReleaseBanner.tsx @@ -14,12 +14,20 @@ * what it is — and the dismiss control is a real button, not a hover-revealed * affordance, because on a phone there is no hover. */ +import type { ManagedBy } from "@shared/types"; + export function ReleaseBanner({ - version, onDismiss, + version, managedBy, onDismiss, }: { version: string; + managedBy: ManagedBy | null; onDismiss: () => void; }) { + // Named from what actually owns the install, never guessed. `paddock update` + // refuses inside a Homebrew keg (src/server/update.ts), so printing it there + // labels the notice with an action that declines — the same defect as a + // mislabelled Approve button, which CLAUDE.md rules out for the same reason. + const command = managedBy === "homebrew" ? "brew upgrade paddock" : "paddock update"; return (
paddock {version} is available — run{" "} - paddock update on the machine running it + {command} on the machine running it