Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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