From 6dcc51e59130dd95bc67faa6a4b462e5533c7155 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 15 Aug 2026 21:21:55 +0530 Subject: [PATCH 1/5] feat(update): recognise Homebrew installs and add mise, go install docs --- docs/INSTALL.md | 83 ++++++++++-- internal/update/apply.go | 14 +- internal/update/installmethod.go | 52 +++++++- .../update/installmethod_homebrew_test.go | 126 ++++++++++++++++++ internal/update/update.go | 5 + packaging/homebrew/zero.rb | 65 +++++++++ 6 files changed, 333 insertions(+), 12 deletions(-) create mode 100644 internal/update/installmethod_homebrew_test.go create mode 100644 packaging/homebrew/zero.rb diff --git a/docs/INSTALL.md b/docs/INSTALL.md index dea5b7b70..7b2e0c291 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -1,14 +1,25 @@ # Installing Zero -Zero is distributed as: - -- an npm package, `@gitlawb/zero` -- release archives on GitHub Releases -- source builds with Go 1.26.5+ - -The install scripts download a platform-specific release archive and require a -published GitHub Release for the requested version. The npm package is -self-contained: the platform binary installs from the npm registry. +Pick whichever fits how you already install things. None of these is the +blessed one. + +| Method | Command | Self-update | +| --- | --- | --- | +| Install script (Linux, macOS) | `curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh \| bash` | `zero upgrade` | +| Install script (Windows) | `irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 \| iex` | `zero upgrade` | +| npm | `npm install -g @gitlawb/zero` | `zero upgrade` (runs npm) | +| Release archive | download from [Releases](https://github.com/Gitlawb/zero/releases) | `zero upgrade` | +| mise | `mise use -g ubi:Gitlawb/zero` | `mise upgrade` | +| `go install` | `go install github.com/Gitlawb/zero/cmd/zero@latest` | rerun the command | +| Source | `go build -o zero ./cmd/zero` | rebuild | + +`zero upgrade` knows which of these you used and does the right thing, or +refuses and tells you the command that works. It never fights a package manager +for control of its own binary. + +Release archives are the substrate for most of the above: the install scripts +and the npm fallback both download a platform archive from a published GitHub +Release and verify its checksum. ## npm @@ -111,6 +122,45 @@ Defaults: - Version: latest GitHub release - Install path: `%LOCALAPPDATA%\zero\bin\zero.exe` +## mise + +[mise](https://mise.jdx.dev/) installs Zero straight from the GitHub Release +archives through its `ubi` backend, so there is nothing extra to publish and no +registry in the middle: + +```bash +mise use -g ubi:Gitlawb/zero +``` + +Pin a version the same way you would any other tool: + +```bash +mise use -g ubi:Gitlawb/zero@0.7.1 +``` + +Updates come from `mise upgrade`. `zero upgrade` also works, because a +mise-managed binary is an ordinary standalone install, but then mise's records +describe a version that is no longer on disk. Prefer `mise upgrade`. + +## go install + +```bash +go install github.com/Gitlawb/zero/cmd/zero@latest +``` + +This builds from source, so it needs Go 1.26.6+ and it does not go through the +release archives. Two consequences worth knowing before you pick it: + +- On Linux you also need the sandbox helper, which is a separate binary and is + not installed by this command. See + [Sandbox Helpers For Source Builds](#sandbox-helpers-for-source-builds). + Without it, native sandboxing is unavailable. +- `zero upgrade` treats the result as a standalone install and will replace the + binary with a release build rather than rebuilding from source. If you chose + `go install` deliberately, rerun it instead. + +macOS and Windows need no extra helper. + ## From Source ```bash @@ -242,3 +292,18 @@ zero upgrade ``` See the [update guide](UPDATE.md) for update modes, flags, and platform details. + +`zero upgrade` behaves differently depending on how Zero was installed, because +overwriting a binary a package manager owns leaves that manager describing a +version that is no longer there: + +- **npm**: runs `npm install -g @gitlawb/zero@latest` for you. +- **Homebrew**: refuses, and tells you to run `brew upgrade zero`. Replacing the + keg binary directly would be reverted by the next `brew upgrade` or + `brew reinstall`. +- **everything else**: downloads the verified release archive and replaces the + binary in place. + +Homebrew is detected by the binary living inside a Cellar keg, so an ordinary +install under `/usr/local/bin` is left alone even on an Intel Mac where that is +also the Homebrew prefix. diff --git a/internal/update/apply.go b/internal/update/apply.go index 62ec95eee..4c70c1ba2 100644 --- a/internal/update/apply.go +++ b/internal/update/apply.go @@ -66,7 +66,9 @@ func Apply(ctx context.Context, options Options) (ApplyResult, error) { executablePath = resolved } method := DetectInstallMethod(executablePath) - if method != InstallMethodNpm { + // Only the standalone path rewrites the binary in place, so only it needs the + // recovery state. npm and Homebrew hand the work to the package manager. + if method == InstallMethodStandalone { if err := preflightRecoveryState(executablePath); err != nil { return ApplyResult{}, err } @@ -76,6 +78,16 @@ func Apply(ctx context.Context, options Options) (ApplyResult, error) { } switch method { + case InstallMethodHomebrew: + // Refused rather than performed. Running `brew upgrade` for the user would + // touch a package manager they own, and writing the binary directly is + // worse: the keg is what Homebrew's own records describe, so a self-update + // leaves brew reporting a version that is no longer installed, and the next + // `brew upgrade` or `brew reinstall` silently reverts it. + return ApplyResult{}, fmt.Errorf( + "this zero was installed with Homebrew (%s); run `brew upgrade zero` instead, so Homebrew's records match the binary on disk", + executablePath, + ) case InstallMethodNpm: if err := applyNpmUpdate(ctx); err != nil { return ApplyResult{}, err diff --git a/internal/update/installmethod.go b/internal/update/installmethod.go index d02de8cfc..e69dc4dc3 100644 --- a/internal/update/installmethod.go +++ b/internal/update/installmethod.go @@ -4,6 +4,8 @@ import ( "encoding/json" "os" "path/filepath" + "runtime" + "strings" ) // npmPackageName is the published package name for the npm distribution of @@ -18,12 +20,38 @@ type InstallMethod string const ( InstallMethodNpm InstallMethod = "npm" + InstallMethodHomebrew InstallMethod = "homebrew" InstallMethodStandalone InstallMethod = "standalone" ) -// DetectInstallMethod inspects the directory containing executablePath for -// npm-install markers left by scripts/postinstall.mjs. +// homebrewCellar is the directory every Homebrew keg lives under: +// /Cellar///bin/. +// +// Matching the Cellar segment rather than the Homebrew PREFIX is deliberate. +// The prefix on Intel macOS is /usr/local, which is also where people put +// hand-installed binaries, so treating the prefix as the signal would classify +// an ordinary /usr/local/bin/zero as Homebrew-managed and refuse an update that +// works fine. Every keg is under Cellar and nothing else is, so this errs +// toward leaving self-update enabled, which is the recoverable direction. +// +// HOMEBREW_CELLAR needs no separate check: it defaults to /Cellar and +// Homebrew does not support renaming it. +const homebrewCellar = "Cellar" + +// DetectInstallMethod reports how the binary at executablePath was installed. +// +// Symlinks are resolved here rather than at the call sites. Homebrew links +// /bin/zero to the keg, and Check did not resolve while Apply did, so +// the two disagreed about what a Homebrew install was: the check printed +// standalone guidance for an install the apply path would have handled +// differently. func DetectInstallMethod(executablePath string) InstallMethod { + if resolved, err := filepath.EvalSymlinks(executablePath); err == nil { + executablePath = resolved + } + if isHomebrewPath(runtime.GOOS, executablePath) { + return InstallMethodHomebrew + } dir := filepath.Dir(executablePath) if _, err := os.Stat(filepath.Join(dir, ".zero-binary-version")); err == nil { return InstallMethodNpm @@ -50,3 +78,23 @@ func DetectInstallMethod(executablePath string) InstallMethod { } return InstallMethodStandalone } + +// isHomebrewPath reports whether executablePath is inside a Homebrew keg. +// +// goos is a parameter rather than runtime.GOOS so the decision can be tested on +// every target from any machine. Gating it on the real GOOS made the one test +// that matters skip on Windows, which is how a platform rule ends up unverified +// on the platform it excludes. +func isHomebrewPath(goos string, executablePath string) bool { + if goos == "windows" { + // Homebrew does not run here, and a Windows path is far likelier to hold + // an unrelated directory called Cellar than a keg. + return false + } + for _, segment := range strings.Split(filepath.ToSlash(executablePath), "/") { + if segment == homebrewCellar { + return true + } + } + return false +} diff --git a/internal/update/installmethod_homebrew_test.go b/internal/update/installmethod_homebrew_test.go new file mode 100644 index 000000000..e01b05ce6 --- /dev/null +++ b/internal/update/installmethod_homebrew_test.go @@ -0,0 +1,126 @@ +package update + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// The path rule itself, checked for every target from any machine. Gating this +// on runtime.GOOS would skip it on Windows, leaving the Windows branch of the +// rule to be verified by nobody. +func TestIsHomebrewPathPerTarget(t *testing.T) { + cases := []struct { + goos string + path string + want bool + }{ + {"darwin", "/opt/homebrew/Cellar/zero/0.7.1/bin/zero", true}, + {"darwin", "/usr/local/Cellar/zero/0.7.1/bin/zero", true}, + {"linux", "/home/linuxbrew/.linuxbrew/Cellar/zero/0.7.1/bin/zero", true}, + {"darwin", "/usr/local/bin/zero", false}, + {"darwin", "/Users/someone/.local/bin/zero", false}, + {"linux", "/opt/cellar/bin/zero", false}, // lowercase is not a keg + {"linux", "/opt/CellarX/bin/zero", false}, // segment must match exactly + {"windows", `C:\Cellar\zero\bin\zero.exe`, false}, // Homebrew does not run here + } + for _, testCase := range cases { + if got := isHomebrewPath(testCase.goos, testCase.path); got != testCase.want { + t.Errorf("isHomebrewPath(%q, %q) = %v, want %v", testCase.goos, testCase.path, got, testCase.want) + } + } +} + +// A Homebrew install is a keg under /Cellar with /bin/zero +// linked to it. Detection has to survive both spellings, because Check passes +// the unresolved path and Apply passes the resolved one. +func TestDetectInstallMethodRecognisesAHomebrewKeg(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("DetectInstallMethod is GOOS-gated; the rule itself is covered by TestIsHomebrewPathPerTarget") + } + prefix := t.TempDir() + keg := filepath.Join(prefix, "Cellar", "zero", "0.7.1", "bin") + if err := os.MkdirAll(keg, 0o755); err != nil { + t.Fatal(err) + } + binary := filepath.Join(keg, "zero") + if err := os.WriteFile(binary, []byte("binary"), 0o755); err != nil { + t.Fatal(err) + } + if got := DetectInstallMethod(binary); got != InstallMethodHomebrew { + t.Errorf("keg path: got %q, want %q", got, InstallMethodHomebrew) + } + + linkDir := filepath.Join(prefix, "bin") + if err := os.MkdirAll(linkDir, 0o755); err != nil { + t.Fatal(err) + } + link := filepath.Join(linkDir, "zero") + if err := os.Symlink(binary, link); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + // The path Check sees. Without symlink resolution this reads as standalone + // and the guidance tells a Homebrew user to run `zero upgrade`. + if got := DetectInstallMethod(link); got != InstallMethodHomebrew { + t.Errorf("linked path: got %q, want %q", got, InstallMethodHomebrew) + } +} + +// The failure that matters more than a missed detection: refusing to update an +// install Homebrew has never heard of. /usr/local is a Homebrew prefix on Intel +// macOS and an ordinary install location everywhere. +func TestDetectInstallMethodLeavesOrdinaryInstallsAlone(t *testing.T) { + root := t.TempDir() + for _, dir := range []string{ + filepath.Join(root, "usr", "local", "bin"), + filepath.Join(root, "home", "user", ".local", "bin"), + filepath.Join(root, "opt", "homebrewish", "bin"), + } { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + binary := filepath.Join(dir, "zero") + if err := os.WriteFile(binary, []byte("binary"), 0o755); err != nil { + t.Fatal(err) + } + if got := DetectInstallMethod(binary); got != InstallMethodStandalone { + t.Errorf("%s: got %q, want %q", dir, got, InstallMethodStandalone) + } + } +} + +// An npm install must not start reading as Homebrew now that a second check +// runs first. +func TestDetectInstallMethodStillRecognisesNpm(t *testing.T) { + dir := t.TempDir() + binary := filepath.Join(dir, "zero") + if err := os.WriteFile(binary, []byte("binary"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".zero-binary-version"), []byte("0.7.1"), 0o644); err != nil { + t.Fatal(err) + } + if got := DetectInstallMethod(binary); got != InstallMethodNpm { + t.Errorf("got %q, want %q", got, InstallMethodNpm) + } +} + +func TestUpgradeGuidanceSendsHomebrewUsersToBrew(t *testing.T) { + guidance := upgradeGuidance(AssetCheck{}, "", InstallMethodHomebrew) + if !strings.Contains(guidance, "brew upgrade zero") { + t.Errorf("guidance does not name the command that works: %q", guidance) + } + if strings.Contains(guidance, "Run `zero upgrade`") { + t.Errorf("guidance still offers the command that refuses: %q", guidance) + } +} + +// A custom source flag must not talk a Homebrew user back into `zero upgrade`. +func TestUpgradeGuidanceIgnoresSourceFlagForHomebrew(t *testing.T) { + guidance := upgradeGuidance(AssetCheck{}, "--source", InstallMethodHomebrew) + if !strings.Contains(guidance, "brew upgrade zero") { + t.Errorf("source flag changed the Homebrew answer: %q", guidance) + } +} diff --git a/internal/update/update.go b/internal/update/update.go index ea68afd77..647d48813 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -311,6 +311,11 @@ func upgradeGuidance(asset AssetCheck, sourceFlag string, installMethod InstallM } return guidance + " `zero upgrade` installs onto this machine (" + local + ") instead." } + if installMethod == InstallMethodHomebrew { + // Said before the source-flag branch below: whatever source the check read, + // the answer for a keg is the same and `zero upgrade` is never it. + return "This Homebrew-managed installation is updated with `brew upgrade zero`. `zero upgrade` refuses here, because replacing the keg binary would leave Homebrew's records describing a version that is no longer installed." + } if sourceFlag != "" { if installMethod == InstallMethodNpm { return "This npm-managed installation can be updated with `npm install -g " + npmPackageName + "@latest`, which installs the official npm package. The custom `" + sourceFlag + "` source only affects the release check and update gating, not the npm install source." diff --git a/packaging/homebrew/zero.rb b/packaging/homebrew/zero.rb new file mode 100644 index 000000000..25ab7a65e --- /dev/null +++ b/packaging/homebrew/zero.rb @@ -0,0 +1,65 @@ +# Homebrew formula for Zero. +# +# NOT PUBLISHED. This is a draft for review. Nothing installs from it until it +# is copied into a tap (Gitlawb/homebrew-tap, Formula/zero.rb) and the release +# workflow is taught to bump the version and checksums on each tag. Both of +# those are release decisions, so they are deliberately not made here. +# +# Verify locally without a tap: +# +# brew install --build-from-source ./packaging/homebrew/zero.rb +# brew audit --strict --formula ./packaging/homebrew/zero.rb +# +# The checksums below are the real ones published with v0.7.0, read from the +# .sha256 files that ship beside each archive, so this draft is installable as +# written rather than being a skeleton with placeholders. +class Zero < Formula + desc "Terminal coding agent" + homepage "https://github.com/Gitlawb/zero" + version "0.7.0" + license "MIT" + + on_macos do + on_arm do + url "https://github.com/Gitlawb/zero/releases/download/v0.7.0/zero-v0.7.0-macos-arm64.tar.gz" + sha256 "75e859fe25f3f63785f512f20b9c9501c67394166f10746f80e374672a1a8b7f" + end + on_intel do + url "https://github.com/Gitlawb/zero/releases/download/v0.7.0/zero-v0.7.0-macos-x64.tar.gz" + sha256 "184256abd5738b77d44cf4a99d71ac32d0a0355714ebc698a2221a69aeb71976" + end + end + + on_linux do + on_arm do + url "https://github.com/Gitlawb/zero/releases/download/v0.7.0/zero-v0.7.0-linux-arm64.tar.gz" + sha256 "dd0355f78b6ab044e1181184e29432d0ab7652a1dc27a161960f06e8520b4f21" + end + on_intel do + url "https://github.com/Gitlawb/zero/releases/download/v0.7.0/zero-v0.7.0-linux-x64.tar.gz" + sha256 "f5120c2cc1e9f45ebf69d472b6026eb8e37eee2c113211efedd7d0917437490c" + end + end + + def install + bin.install "zero" + # The Linux archive carries the sandbox helper beside the binary. Without it + # on PATH, native sandboxing is silently unavailable rather than broken, so + # install it whenever the archive provides one. + bin.install "zero-linux-sandbox" if File.exist?("zero-linux-sandbox") + end + + def caveats + <<~EOS + Update with `brew upgrade zero`. + + `zero upgrade` refuses on a Homebrew install on purpose: replacing the keg + binary would leave Homebrew describing a version that is no longer on disk, + and the next `brew upgrade` would revert it. + EOS + end + + test do + assert_match version.to_s, shell_output("#{bin}/zero --version") + end +end From 5763eb2d808eb6eeec25bd06d54e47c490f37926 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 15 Aug 2026 21:32:51 +0530 Subject: [PATCH 2/5] fix(update): match the keg shape, correct the docs, cover Apply and cross-target --- README.md | 4 +- README_ZH.md | 4 +- docs/INSTALL.md | 12 ++- internal/update/installmethod.go | 19 ++++- .../update/installmethod_homebrew_test.go | 74 +++++++++++++++++++ 5 files changed, 103 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 0c247e9ea..3d3590814 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

license - Go 1.26.5+ + Go 1.26.6+ 25+ providers Discord
@@ -76,7 +76,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie ### From source -Source builds require Go 1.26.5+. +Source builds require Go 1.26.6+. ```bash git clone https://github.com/Gitlawb/zero.git diff --git a/README_ZH.md b/README_ZH.md index f8c09cdad..ff5186a6d 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -6,7 +6,7 @@

license - Go 1.26.5+ + Go 1.26.6+ 25+ providers Discord
@@ -57,7 +57,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie ### 从源码构建 -源码构建需要 Go 1.26.5+。 +源码构建需要 Go 1.26.6+。 ```bash git clone https://github.com/Gitlawb/zero.git diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 7b2e0c291..0767d524e 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -13,9 +13,13 @@ blessed one. | `go install` | `go install github.com/Gitlawb/zero/cmd/zero@latest` | rerun the command | | Source | `go build -o zero ./cmd/zero` | rebuild | -`zero upgrade` knows which of these you used and does the right thing, or -refuses and tells you the command that works. It never fights a package manager -for control of its own binary. +`zero upgrade` follows whatever owns the binary. It delegates to npm for an npm +install, refuses outright for a Homebrew keg and names `brew upgrade zero`, and +replaces the binary in place for everything else. + +mise is the one case it cannot detect: a mise-managed binary is an ordinary +standalone install on disk, so `zero upgrade` will replace it and leave mise +describing a version that is no longer there. Use `mise upgrade` instead. Release archives are the substrate for most of the above: the install scripts and the npm fallback both download a platform archive from a published GitHub @@ -175,7 +179,7 @@ Build a local binary: go build -o zero ./cmd/zero ``` -Source builds require Go 1.26.5+. +Source builds require Go 1.26.6+. ### Sandbox Helpers For Source Builds diff --git a/internal/update/installmethod.go b/internal/update/installmethod.go index e69dc4dc3..07eb48c05 100644 --- a/internal/update/installmethod.go +++ b/internal/update/installmethod.go @@ -91,8 +91,23 @@ func isHomebrewPath(goos string, executablePath string) bool { // an unrelated directory called Cellar than a keg. return false } - for _, segment := range strings.Split(filepath.ToSlash(executablePath), "/") { - if segment == homebrewCellar { + // The keg SHAPE, not merely the segment: /Cellar/// + // and then at least the binary. A bare Cellar segment would also match a + // user's own directory that happens to be called that, and the cost of a + // false positive here is refusing to update an install Homebrew has never + // touched. Requiring the two segments Homebrew always inserts costs nothing + // and no filesystem access. + // + // Deliberately not matched: the formula name, because a tap may name it + // something other than zero; and Homebrew's receipt metadata, because reading + // it would put filesystem I/O on a path that runs on every version check, to + // tighten a case a path-shape check already covers. + segments := strings.Split(filepath.ToSlash(executablePath), "/") + for index, segment := range segments { + if segment != homebrewCellar { + continue + } + if len(segments)-index >= 4 { return true } } diff --git a/internal/update/installmethod_homebrew_test.go b/internal/update/installmethod_homebrew_test.go index e01b05ce6..e4784db43 100644 --- a/internal/update/installmethod_homebrew_test.go +++ b/internal/update/installmethod_homebrew_test.go @@ -1,6 +1,8 @@ package update import ( + "context" + "net/url" "os" "path/filepath" "runtime" @@ -25,6 +27,10 @@ func TestIsHomebrewPathPerTarget(t *testing.T) { {"linux", "/opt/cellar/bin/zero", false}, // lowercase is not a keg {"linux", "/opt/CellarX/bin/zero", false}, // segment must match exactly {"windows", `C:\Cellar\zero\bin\zero.exe`, false}, // Homebrew does not run here + // A user directory that happens to be called Cellar. Too shallow to be a + // keg, and refusing to update this install would be the expensive mistake. + {"linux", "/home/someone/Cellar/zero", false}, + {"darwin", "/Users/someone/Cellar/bin/zero", false}, } for _, testCase := range cases { if got := isHomebrewPath(testCase.goos, testCase.path); got != testCase.want { @@ -124,3 +130,71 @@ func TestUpgradeGuidanceIgnoresSourceFlagForHomebrew(t *testing.T) { t.Errorf("source flag changed the Homebrew answer: %q", guidance) } } + +// A CROSS-TARGET check outranks the install method, on purpose. +// +// Homebrew is a property of the binary on THIS machine. When the check was asked +// about a different target, the answer is about that other machine, and +// `brew upgrade zero` would change this one instead. Pinned as a test because it +// reads like an ordering bug until you see which question is being answered. +func TestUpgradeGuidanceKeepsCrossTargetAnswerForHomebrew(t *testing.T) { + local := localReleaseTarget() + other := "linux-arm64" + if local == other { + other = "macos-x64" + } + target, err := ResolveTarget(other) + if err != nil { + t.Fatalf("resolve target: %v", err) + } + asset := AssetCheck{Platform: target.Platform, Arch: target.Arch} + + guidance := upgradeGuidance(asset, "", InstallMethodHomebrew) + if strings.Contains(guidance, "brew upgrade zero") { + t.Errorf("a question about %s was answered with a command that changes this machine: %q", other, guidance) + } + if !strings.Contains(guidance, other) { + t.Errorf("cross-target guidance does not name the target asked about: %q", guidance) + } +} + +// Apply must refuse a Homebrew keg outright: no download, no write, and an error +// that names the command which does work. +func TestApplyRefusesToUpdateAHomebrewKeg(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("DetectInstallMethod is GOOS-gated; see TestIsHomebrewPathPerTarget") + } + prefix := t.TempDir() + keg := filepath.Join(prefix, "Cellar", "zero", "0.7.0", "bin") + if err := os.MkdirAll(keg, 0o755); err != nil { + t.Fatal(err) + } + binary := filepath.Join(keg, "zero") + original := []byte("original binary") + if err := os.WriteFile(binary, original, 0o755); err != nil { + t.Fatal(err) + } + + restore := currentExecutable + currentExecutable = func() (string, error) { return binary, nil } + t.Cleanup(func() { currentExecutable = restore }) + + payload := url.QueryEscape(`{"tag_name":"v0.7.0","html_url":"https://example.test/release","assets":[]}`) + _, err := Apply(context.Background(), Options{ + CurrentVersion: "0.1.0", + Endpoint: "data:application/json," + payload, + }) + if err == nil { + t.Fatal("Apply updated a Homebrew keg instead of refusing") + } + if !strings.Contains(err.Error(), "brew upgrade zero") { + t.Errorf("refusal does not name the command that works: %v", err) + } + after, readErr := os.ReadFile(binary) + if readErr != nil { + t.Fatalf("read binary: %v", readErr) + } + if string(after) != string(original) { + t.Error("Apply rewrote the keg binary it claimed to refuse") + } +} From f12911d4bca11d41f0cecdb89e66ca4c1c7866a5 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 15 Aug 2026 21:37:41 +0530 Subject: [PATCH 3/5] test(update): build the release fixture from the running platform --- .../update/installmethod_homebrew_test.go | 42 ++++++++++++++++++- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/internal/update/installmethod_homebrew_test.go b/internal/update/installmethod_homebrew_test.go index e4784db43..33e6eed73 100644 --- a/internal/update/installmethod_homebrew_test.go +++ b/internal/update/installmethod_homebrew_test.go @@ -158,6 +158,45 @@ func TestUpgradeGuidanceKeepsCrossTargetAnswerForHomebrew(t *testing.T) { } } +// localReleaseEndpoint builds a data: release whose assets match THIS platform. +// +// Apply runs Check first, and Check fails outright when the release carries no +// archive for the running target. An empty asset list therefore made the +// Homebrew test pass on the error it was looking for and fail on the message, +// which is a fixture that proves nothing about the branch it was written for. +func localReleaseEndpoint(t *testing.T, version string) string { + t.Helper() + target := localReleaseTarget() + if target == "" { + t.Skip("no published release target for this platform") + } + extension := ".tar.gz" + if strings.HasPrefix(target, "windows") { + extension = ".zip" + } + archive := "zero-v" + version + "-" + target + extension + payload := url.QueryEscape(`{"tag_name":"v` + version + `","html_url":"https://example.test/release","assets":[` + + `{"name":"` + archive + `","browser_download_url":"https://example.test/` + archive + `"},` + + `{"name":"` + archive + `.sha256","browser_download_url":"https://example.test/` + archive + `.sha256"}]}`) + return "data:application/json," + payload +} + +// Guards the helper above on EVERY platform, including the ones where the +// Homebrew test that uses it has to skip. The fixture broke on macOS while +// passing everywhere it was not exercised, so the fixture gets its own check. +func TestLocalReleaseEndpointIsAcceptedByCheck(t *testing.T) { + result, err := Check(context.Background(), Options{ + CurrentVersion: "0.1.0", + Endpoint: localReleaseEndpoint(t, "0.7.0"), + }) + if err != nil { + t.Fatalf("Check rejected the fixture release for this platform: %v", err) + } + if !result.UpdateAvailable { + t.Errorf("fixture release 0.7.0 should be newer than 0.1.0") + } +} + // Apply must refuse a Homebrew keg outright: no download, no write, and an error // that names the command which does work. func TestApplyRefusesToUpdateAHomebrewKeg(t *testing.T) { @@ -179,10 +218,9 @@ func TestApplyRefusesToUpdateAHomebrewKeg(t *testing.T) { currentExecutable = func() (string, error) { return binary, nil } t.Cleanup(func() { currentExecutable = restore }) - payload := url.QueryEscape(`{"tag_name":"v0.7.0","html_url":"https://example.test/release","assets":[]}`) _, err := Apply(context.Background(), Options{ CurrentVersion: "0.1.0", - Endpoint: "data:application/json," + payload, + Endpoint: localReleaseEndpoint(t, "0.7.0"), }) if err == nil { t.Fatal("Apply updated a Homebrew keg instead of refusing") From 833573681a529ec869779898a3c518f1f30a78e2 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Wed, 19 Aug 2026 17:04:24 +0530 Subject: [PATCH 4/5] fix(update): require a Homebrew receipt before refusing self-update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the keg check was shape-only, and it was right: a directory tree a user built themselves can have exactly a keg's shape. Classifying one as Homebrew refuses a `zero upgrade` that would have worked perfectly, on a machine Homebrew has never touched. Two changes, cheap one first. The shape rule now requires the full /Cellar///bin/ depth rather than one segment fewer, which already excludes the exact path the review raised. Then, only for a path that still looks like a keg, one Stat for INSTALL_RECEIPT.json — the file Homebrew writes into every keg and nothing else does. The receipt is what the old comment argued against, on the grounds that it would put filesystem I/O on a path that runs at every version check. That is no longer true of it: the Stat happens only after the shape test has already matched, so an ordinary install still reaches no filesystem here at all. A directory named INSTALL_RECEIPT.json is not a receipt, which a bare Stat would have accepted silently. NOT taken: the same review asked for the Homebrew branch to be moved ahead of the cross-target one in upgradeGuidance. That ordering is deliberate and already pinned by TestUpgradeGuidanceKeepsCrossTargetAnswerForHomebrew, whose comment predicted exactly this reading — "it reads like an ordering bug until you see which question is being answered". Homebrew is a property of the binary on THIS machine; when the check was asked about a different target the answer belongs to that other machine, and `brew upgrade zero` would change this one instead. The reasoning is now recorded at the branch as well as in the test, so the next reader finds it in both places. The install docs claimed detection was the Cellar path alone, which undersold it, and stated the self-update rule absolutely in one paragraph before excepting mise in the next. Both say the same thing now. --- docs/INSTALL.md | 18 +++-- internal/update/installmethod.go | 56 ++++++++++---- .../update/installmethod_homebrew_test.go | 74 +++++++++++++++++-- internal/update/update.go | 6 ++ 4 files changed, 128 insertions(+), 26 deletions(-) diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 0767d524e..d0163169b 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -13,9 +13,10 @@ blessed one. | `go install` | `go install github.com/Gitlawb/zero/cmd/zero@latest` | rerun the command | | Source | `go build -o zero ./cmd/zero` | rebuild | -`zero upgrade` follows whatever owns the binary. It delegates to npm for an npm -install, refuses outright for a Homebrew keg and names `brew upgrade zero`, and -replaces the binary in place for everything else. +`zero upgrade` follows whatever owns the binary, with one exception it cannot +detect. It delegates to npm for an npm install, refuses outright for a Homebrew +keg and names `brew upgrade zero`, and replaces the binary in place for +everything else — including a mise-managed one, which is the exception. mise is the one case it cannot detect: a mise-managed binary is an ordinary standalone install on disk, so `zero upgrade` will replace it and leave mise @@ -308,6 +309,11 @@ version that is no longer there: - **everything else**: downloads the verified release archive and replaces the binary in place. -Homebrew is detected by the binary living inside a Cellar keg, so an ordinary -install under `/usr/local/bin` is left alone even on an Intel Mac where that is -also the Homebrew prefix. +Homebrew is detected by the binary living inside a Cellar keg that carries +Homebrew's own `INSTALL_RECEIPT.json`. The keg shape alone is not enough: a +directory tree you built yourself can have exactly that shape, and refusing to +update an install Homebrew never touched would be a real cost for a made-up +reason. The receipt is the thing only Homebrew writes. + +An ordinary install under `/usr/local/bin` is left alone even on an Intel Mac +where that is also the Homebrew prefix. diff --git a/internal/update/installmethod.go b/internal/update/installmethod.go index 07eb48c05..ca737f520 100644 --- a/internal/update/installmethod.go +++ b/internal/update/installmethod.go @@ -85,31 +85,57 @@ func DetectInstallMethod(executablePath string) InstallMethod { // every target from any machine. Gating it on the real GOOS made the one test // that matters skip on Windows, which is how a platform rule ends up unverified // on the platform it excludes. +// +// TWO CHECKS, CHEAP ONE FIRST. The path shape is a filter, not the answer: a +// user's own /work/Cellar/tools/bin/zero has exactly the shape a keg does, and +// classifying it as Homebrew would refuse an update that works. The keg is only +// confirmed by Homebrew's own receipt, which it writes into every keg and +// nothing else does. +// +// The receipt costs one Stat, and only on a path that already looks like a keg — +// so the version check that runs for ordinary installs still does no filesystem +// work here at all. func isHomebrewPath(goos string, executablePath string) bool { + keg, ok := homebrewKeg(goos, executablePath) + if !ok { + return false + } + return hasHomebrewReceipt(keg) +} + +// homebrewKeg returns the /Cellar// directory that +// executablePath sits inside, if its shape is a keg's. +// +// Deliberately not matched: the formula name, because a tap may name it +// something other than zero. +func homebrewKeg(goos string, executablePath string) (string, bool) { if goos == "windows" { // Homebrew does not run here, and a Windows path is far likelier to hold // an unrelated directory called Cellar than a keg. - return false + return "", false } - // The keg SHAPE, not merely the segment: /Cellar/// - // and then at least the binary. A bare Cellar segment would also match a - // user's own directory that happens to be called that, and the cost of a - // false positive here is refusing to update an install Homebrew has never - // touched. Requiring the two segments Homebrew always inserts costs nothing - // and no filesystem access. - // - // Deliberately not matched: the formula name, because a tap may name it - // something other than zero; and Homebrew's receipt metadata, because reading - // it would put filesystem I/O on a path that runs on every version check, to - // tighten a case a path-shape check already covers. + // /Cellar///bin/ — the two segments + // Homebrew always inserts, then the bin directory and the binary itself. + // Anything shorter cannot be a keg however it is named. segments := strings.Split(filepath.ToSlash(executablePath), "/") for index, segment := range segments { if segment != homebrewCellar { continue } - if len(segments)-index >= 4 { - return true + if len(segments)-index < 5 { + continue } + return strings.Join(segments[:index+3], "/"), true } - return false + return "", false +} + +// homebrewReceipt is the file Homebrew writes into every keg it installs. Its +// presence is what distinguishes a keg from a directory tree that merely looks +// like one. +const homebrewReceipt = "INSTALL_RECEIPT.json" + +func hasHomebrewReceipt(kegDir string) bool { + info, err := os.Stat(filepath.Join(kegDir, homebrewReceipt)) + return err == nil && !info.IsDir() } diff --git a/internal/update/installmethod_homebrew_test.go b/internal/update/installmethod_homebrew_test.go index 33e6eed73..14a479f2e 100644 --- a/internal/update/installmethod_homebrew_test.go +++ b/internal/update/installmethod_homebrew_test.go @@ -13,7 +13,11 @@ import ( // The path rule itself, checked for every target from any machine. Gating this // on runtime.GOOS would skip it on Windows, leaving the Windows branch of the // rule to be verified by nobody. -func TestIsHomebrewPathPerTarget(t *testing.T) { +// +// The keg SHAPE only. Whether a real keg lives there is a separate question with +// a separate answer on disk — see TestOnlyAKegWithAReceiptIsHomebrew, which is +// the check that stops a user's own Cellar directory being classified. +func TestHomebrewKegShapePerTarget(t *testing.T) { cases := []struct { goos string path string @@ -27,15 +31,75 @@ func TestIsHomebrewPathPerTarget(t *testing.T) { {"linux", "/opt/cellar/bin/zero", false}, // lowercase is not a keg {"linux", "/opt/CellarX/bin/zero", false}, // segment must match exactly {"windows", `C:\Cellar\zero\bin\zero.exe`, false}, // Homebrew does not run here - // A user directory that happens to be called Cellar. Too shallow to be a - // keg, and refusing to update this install would be the expensive mistake. + // User directories called Cellar. Too shallow to be a keg. {"linux", "/home/someone/Cellar/zero", false}, {"darwin", "/Users/someone/Cellar/bin/zero", false}, + // The case the reviewer of #910 raised. It is one segment short of a keg, + // so the depth rule alone already refuses it. + {"linux", "/work/Cellar/tools/bin/zero", false}, + // One segment deeper it IS a keg by shape, and still not a keg. Nothing in + // the path can tell the difference — only the receipt can, which is what + // TestOnlyAKegWithAReceiptIsHomebrew covers. + {"linux", "/work/Cellar/tools/1.0/bin/zero", true}, } for _, testCase := range cases { - if got := isHomebrewPath(testCase.goos, testCase.path); got != testCase.want { - t.Errorf("isHomebrewPath(%q, %q) = %v, want %v", testCase.goos, testCase.path, got, testCase.want) + if _, got := homebrewKeg(testCase.goos, testCase.path); got != testCase.want { + t.Errorf("homebrewKeg(%q, %q) = %v, want %v", testCase.goos, testCase.path, got, testCase.want) + } + } +} + +// THE REGRESSION THE REVIEWER ASKED FOR, and the reason the receipt check +// exists: a directory tree a user built themselves can have exactly a keg's +// shape. Classifying it as Homebrew refuses a `zero upgrade` that would have +// worked perfectly, on a machine Homebrew has never touched. +// +// Homebrew writes INSTALL_RECEIPT.json into every keg it installs and nothing +// else does, so its presence is what separates the two. +func TestOnlyAKegWithAReceiptIsHomebrew(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("the Homebrew branch is off on Windows; TestHomebrewKegShapePerTarget covers that") + } + root := t.TempDir() + + // Same shape, same depth. The only difference is the receipt. + real := filepath.Join(root, "prefix", "Cellar", "zero", "0.7.1") + fake := filepath.Join(root, "work", "Cellar", "tools", "0.7.1") + for _, keg := range []string{real, fake} { + if err := os.MkdirAll(filepath.Join(keg, "bin"), 0o755); err != nil { + t.Fatal(err) } + if err := os.WriteFile(filepath.Join(keg, "bin", "zero"), []byte("binary"), 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(real, "INSTALL_RECEIPT.json"), []byte(`{"source":{}}`), 0o644); err != nil { + t.Fatal(err) + } + + if !isHomebrewPath(runtime.GOOS, filepath.Join(real, "bin", "zero")) { + t.Error("a keg with a receipt was not recognised as Homebrew") + } + if isHomebrewPath(runtime.GOOS, filepath.Join(fake, "bin", "zero")) { + t.Error("a user's own Cellar-shaped directory was classified as Homebrew; `zero upgrade` would refuse for nothing") + } +} + +// A receipt that is a DIRECTORY is not a receipt. Cheap to get wrong with a +// bare Stat, and the failure would be silent. +func TestADirectoryNamedLikeTheReceiptIsNotOne(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("the Homebrew branch is off on Windows") + } + keg := filepath.Join(t.TempDir(), "prefix", "Cellar", "zero", "0.7.1") + if err := os.MkdirAll(filepath.Join(keg, "INSTALL_RECEIPT.json"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(keg, "bin"), 0o755); err != nil { + t.Fatal(err) + } + if isHomebrewPath(runtime.GOOS, filepath.Join(keg, "bin", "zero")) { + t.Error("a directory named INSTALL_RECEIPT.json was accepted as a receipt") } } diff --git a/internal/update/update.go b/internal/update/update.go index 647d48813..6d0e980f9 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -314,6 +314,12 @@ func upgradeGuidance(asset AssetCheck, sourceFlag string, installMethod InstallM if installMethod == InstallMethodHomebrew { // Said before the source-flag branch below: whatever source the check read, // the answer for a keg is the same and `zero upgrade` is never it. + // + // AFTER the cross-target branch above, which is not an ordering bug and is + // pinned by TestUpgradeGuidanceKeepsCrossTargetAnswerForHomebrew. Homebrew + // is a property of the binary on THIS machine; when the check was asked + // about a different target the answer belongs to that other machine, and + // `brew upgrade zero` would change this one instead. return "This Homebrew-managed installation is updated with `brew upgrade zero`. `zero upgrade` refuses here, because replacing the keg binary would leave Homebrew's records describing a version that is no longer installed." } if sourceFlag != "" { From 5e93b5dbf77142859955fea600865a32edd8a7ae Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 20 Aug 2026 18:46:00 +0530 Subject: [PATCH 5/5] test(update): give the Homebrew fixtures a receipt, and stop gating the receipt rule on the host The previous commit made a receipt the thing that distinguishes a keg from any directory sitting under a path containing "Cellar". Two fixtures still built a keg without one, so detection correctly answered standalone and both failed: TestDetectInstallMethodRecognisesAHomebrewKeg keg path: got "standalone", want "homebrew" TestApplyRefusesToUpdateAHomebrewKeg refusal does not name the command that works: download release archive: dial tcp: lookup example.test The second is worth reading twice: with detection returning standalone, Apply stopped refusing and went to the network, so the failure surfaced as a DNS error rather than as a missing refusal. Both fixtures write a receipt now. The reason this reached CI at all is the second half. Every test of the receipt rule passed runtime.GOOS to isHomebrewPath, which already takes the target as a parameter, so all of them skipped on Windows and the rule could only ever break somewhere else. They pass an explicit target now and run everywhere. The two that exercise DetectInstallMethod stay host-gated, because those are about the wired entry point rather than the rule, but the rule underneath them is no longer invisible here. Verified the receipt path arithmetic on both non-Windows targets rather than trusting the host-gated tests: isHomebrewPath is false before the receipt is written and true after, with the receipt at the keg directory hasHomebrewReceipt actually reads. --- .../update/installmethod_homebrew_test.go | 35 ++++++++++++++----- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/internal/update/installmethod_homebrew_test.go b/internal/update/installmethod_homebrew_test.go index 14a479f2e..1f457802b 100644 --- a/internal/update/installmethod_homebrew_test.go +++ b/internal/update/installmethod_homebrew_test.go @@ -57,9 +57,13 @@ func TestHomebrewKegShapePerTarget(t *testing.T) { // Homebrew writes INSTALL_RECEIPT.json into every keg it installs and nothing // else does, so its presence is what separates the two. func TestOnlyAKegWithAReceiptIsHomebrew(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("the Homebrew branch is off on Windows; TestHomebrewKegShapePerTarget covers that") - } + // An explicit target rather than runtime.GOOS, so the receipt rule is checked + // on every platform including the one it is switched off for. isHomebrewPath + // already takes the target, so the skip bought nothing and cost the ability to + // catch a break anywhere but CI. Two tests broke exactly that way when the + // receipt requirement landed: they build a keg with no receipt, and only + // macOS and Linux ever ran them. + const target = "darwin" root := t.TempDir() // Same shape, same depth. The only difference is the receipt. @@ -77,10 +81,10 @@ func TestOnlyAKegWithAReceiptIsHomebrew(t *testing.T) { t.Fatal(err) } - if !isHomebrewPath(runtime.GOOS, filepath.Join(real, "bin", "zero")) { + if !isHomebrewPath(target, filepath.Join(real, "bin", "zero")) { t.Error("a keg with a receipt was not recognised as Homebrew") } - if isHomebrewPath(runtime.GOOS, filepath.Join(fake, "bin", "zero")) { + if isHomebrewPath(target, filepath.Join(fake, "bin", "zero")) { t.Error("a user's own Cellar-shaped directory was classified as Homebrew; `zero upgrade` would refuse for nothing") } } @@ -88,9 +92,9 @@ func TestOnlyAKegWithAReceiptIsHomebrew(t *testing.T) { // A receipt that is a DIRECTORY is not a receipt. Cheap to get wrong with a // bare Stat, and the failure would be silent. func TestADirectoryNamedLikeTheReceiptIsNotOne(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("the Homebrew branch is off on Windows") - } + // Explicit target, same reason as above: the rule is worth checking wherever + // the tests run, not only where the feature is switched on. + const target = "darwin" keg := filepath.Join(t.TempDir(), "prefix", "Cellar", "zero", "0.7.1") if err := os.MkdirAll(filepath.Join(keg, "INSTALL_RECEIPT.json"), 0o755); err != nil { t.Fatal(err) @@ -98,7 +102,7 @@ func TestADirectoryNamedLikeTheReceiptIsNotOne(t *testing.T) { if err := os.MkdirAll(filepath.Join(keg, "bin"), 0o755); err != nil { t.Fatal(err) } - if isHomebrewPath(runtime.GOOS, filepath.Join(keg, "bin", "zero")) { + if isHomebrewPath(target, filepath.Join(keg, "bin", "zero")) { t.Error("a directory named INSTALL_RECEIPT.json was accepted as a receipt") } } @@ -115,6 +119,12 @@ func TestDetectInstallMethodRecognisesAHomebrewKeg(t *testing.T) { if err := os.MkdirAll(keg, 0o755); err != nil { t.Fatal(err) } + // The receipt is what separates a keg from any directory that happens to sit + // under a path containing "Cellar". Without it detection correctly answers + // standalone, which is what this test would otherwise be asserting against. + if err := os.WriteFile(filepath.Join(filepath.Dir(keg), homebrewReceipt), []byte(`{"source":{}}`), 0o644); err != nil { + t.Fatal(err) + } binary := filepath.Join(keg, "zero") if err := os.WriteFile(binary, []byte("binary"), 0o755); err != nil { t.Fatal(err) @@ -272,6 +282,13 @@ func TestApplyRefusesToUpdateAHomebrewKeg(t *testing.T) { if err := os.MkdirAll(keg, 0o755); err != nil { t.Fatal(err) } + // Without the receipt this is not a keg, Apply proceeds to the download, and + // the assertion below fails on a network error instead of on the refusal. + // Without the receipt this is not a keg, Apply proceeds to the download, and + // the assertion below fails on a network error instead of on the refusal. + if err := os.WriteFile(filepath.Join(filepath.Dir(keg), homebrewReceipt), []byte(`{"source":{}}`), 0o644); err != nil { + t.Fatal(err) + } binary := filepath.Join(keg, "zero") original := []byte("original binary") if err := os.WriteFile(binary, original, 0o755); err != nil {