mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e93b5dbf7 | ||
|
|
833573681a | ||
|
|
6bc820aa7b | ||
|
|
a9e1aa81e1 | ||
|
|
f12911d4bc | ||
|
|
5763eb2d80 | ||
|
|
6dcc51e591 |
No files matched your search
@@ -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>
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -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>
|
||||
@@ -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
|
||||
|
||||
+83
-8
@@ -1,14 +1,30 @@
|
||||
# Installing Zero
|
||||
|
||||
Zero is distributed as:
|
||||
Pick whichever fits how you already install things. None of these is the
|
||||
blessed one.
|
||||
|
||||
- an npm package, `@gitlawb/zero`
|
||||
- release archives on GitHub Releases
|
||||
- source builds with Go 1.26.5+
|
||||
| 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 |
|
||||
|
||||
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.
|
||||
`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
|
||||
|
||||
@@ -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/[email protected]
|
||||
```
|
||||
|
||||
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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
// <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
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"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.
|
||||
//
|
||||
// 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
|
||||
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
|
||||
// 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 := 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) {
|
||||
// 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.
|
||||
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(target, filepath.Join(real, "bin", "zero")) {
|
||||
t.Error("a keg with a receipt was not recognised as Homebrew")
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(keg, "bin"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if isHomebrewPath(target, filepath.Join(keg, "bin", "zero")) {
|
||||
t.Error("a directory named INSTALL_RECEIPT.json was accepted as a receipt")
|
||||
}
|
||||
}
|
||||
|
||||
// A Homebrew install is a keg under <prefix>/Cellar with <prefix>/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)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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)
|
||||
}
|
||||
// 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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
restore := currentExecutable
|
||||
currentExecutable = func() (string, error) { return binary, nil }
|
||||
t.Cleanup(func() { currentExecutable = restore })
|
||||
|
||||
_, err := Apply(context.Background(), Options{
|
||||
CurrentVersion: "0.1.0",
|
||||
Endpoint: localReleaseEndpoint(t, "0.7.0"),
|
||||
})
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -311,6 +311,17 @@ 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.
|
||||
//
|
||||
// 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 != "" {
|
||||
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."
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user