Skip to content
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<p align="center">
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.26.5+" src="https://img.shields.io/badge/Go-1.26.5+-00ADD8?logo=go&logoColor=white">
<img alt="Go 1.26.6+" src="https://img.shields.io/badge/Go-1.26.6+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
<br>
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions README_ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<p align="center">
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.26.5+" src="https://img.shields.io/badge/Go-1.26.5+-00ADD8?logo=go&logoColor=white">
<img alt="Go 1.26.6+" src="https://img.shields.io/badge/Go-1.26.6+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
<br>
Expand Down Expand Up @@ -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
Expand Down
95 changes: 85 additions & 10 deletions docs/INSTALL.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,30 @@
# 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` 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
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
Release and verify its checksum.

## npm

Expand Down Expand Up @@ -111,6 +127,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:
Comment thread
coderabbitai[bot] marked this conversation as resolved.

- 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
Expand All @@ -125,7 +180,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

Expand Down Expand Up @@ -242,3 +297,23 @@ 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 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.
14 changes: 13 additions & 1 deletion internal/update/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
Expand Down
93 changes: 91 additions & 2 deletions internal/update/installmethod.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
)

// npmPackageName is the published package name for the npm distribution of
Expand All @@ -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:
// <prefix>/Cellar/<formula>/<version>/bin/<binary>.
//
// 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 <prefix>/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
// <prefix>/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
Expand All @@ -50,3 +78,64 @@ 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.
//
// 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 <prefix>/Cellar/<formula>/<version> 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
}
// <prefix>/Cellar/<formula>/<version>/bin/<binary> — 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 < 5 {
continue
}
return strings.Join(segments[:index+3], "/"), true
}
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()
}
Loading
Loading