From ae52238af07c0d584894f3c8b2ade736842c0878 Mon Sep 17 00:00:00 2001
From: PierrunoYT
+
English | 中文
and discord.gg/ resolve to the same
invite; discord.gg is the canonical short form to publish.
Co-Authored-By: Claude Sonnet 5
---
README.md | 4 ++--
README_ZH.md | 4 ++--
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index 71b0aa99a..be1fd9699 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@
-
+
English | 中文
@@ -362,7 +362,7 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
## Community
-Join the [Discord server](https://discord.com/invite/CaQDS6wdFn) for questions,
+Join the [Discord server](https://discord.gg/CaQDS6wdFn) for questions,
feedback, and discussion.
## Contributing
diff --git a/README_ZH.md b/README_ZH.md
index 64f1a7402..ffeb60ad7 100644
--- a/README_ZH.md
+++ b/README_ZH.md
@@ -8,7 +8,7 @@
-
+
English | 中文
@@ -291,7 +291,7 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
## 社区
-加入 [Discord 服务器](https://discord.com/invite/CaQDS6wdFn) 提问、反馈和讨论。
+加入 [Discord 服务器](https://discord.gg/CaQDS6wdFn) 提问、反馈和讨论。
## 贡献
From e63cc0a94c58fb63e9222a77c452a528c5111cf5 Mon Sep 17 00:00:00 2001
From: PierrunoYT
Date: Sun, 26 Jul 2026 22:04:13 +0200
Subject: [PATCH 10/15] fix(update,readme-zh): keep npm --json stdout clean,
add ZH Discussions route
`zero upgrade --json` (and `zero update --apply --json`) on an npm-managed
install wired npm's own stdout directly to the process stdout, so its
progress output landed ahead of the JSON result and made it unparseable.
Route npm's stdout into a buffer when JSON output is requested, only
surfacing it in the error message if the install fails.
README_ZH.md's Community section only pointed to Discord, unlike the
English README, which reserves Discord for real-time chat and routes
Q&A/ideas/sharing to GitHub Discussions per SUPPORT.md/CONTRIBUTING.md
policy. Bring the Chinese section in line with the same split.
Addresses the outstanding findings from jatmn's review on PR #489.
Co-Authored-By: Claude Sonnet 5
---
README_ZH.md | 12 +++-
internal/cli/update.go | 1 +
internal/update/apply.go | 19 +++++-
internal/update/apply_test.go | 108 ++++++++++++++++++++++++++++++++++
internal/update/update.go | 4 ++
5 files changed, 140 insertions(+), 4 deletions(-)
diff --git a/README_ZH.md b/README_ZH.md
index 099fddc2b..f8c09cdad 100644
--- a/README_ZH.md
+++ b/README_ZH.md
@@ -274,7 +274,17 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
## 社区
-加入 [Discord 服务器](https://discord.gg/CaQDS6wdFn) 提问、反馈和讨论。
+实时交流在 [Discord 服务器](https://discord.gg/CaQDS6wdFn) 进行。
+
+提问、安装帮助、想法和分享都在
+[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
+
+| 分类 | 用途 |
+|---|---|
+| [Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a) | 安装帮助、提供商/模型配置、"如何做"类问题 |
+| [Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) | 功能提议和 PR 之前的设计讨论 |
+| [Show and tell](https://github.com/Gitlawb/zero/discussions/categories/show-and-tell) | 你的技能、插件、MCP 配置、主题和工作流 |
+| [Announcements](https://github.com/Gitlawb/zero/discussions/categories/announcements) | 维护者发布的版本和项目动态 |
## 贡献
diff --git a/internal/cli/update.go b/internal/cli/update.go
index 8d2c5d0c9..d468cc27a 100644
--- a/internal/cli/update.go
+++ b/internal/cli/update.go
@@ -60,6 +60,7 @@ func runUpdateCommand(args []string, stdout io.Writer, stderr io.Writer, deps ap
Repository: options.repository,
Endpoint: options.endpoint,
Timeout: options.timeout,
+ JSON: options.json,
}
if options.target != "" {
target, err := update.ResolveTarget(options.target)
diff --git a/internal/update/apply.go b/internal/update/apply.go
index 29736de1a..1f0af835d 100644
--- a/internal/update/apply.go
+++ b/internal/update/apply.go
@@ -1,6 +1,7 @@
package update
import (
+ "bytes"
"context"
"fmt"
"io"
@@ -68,7 +69,7 @@ func Apply(ctx context.Context, options Options) (ApplyResult, error) {
method := DetectInstallMethod(executablePath)
switch method {
case InstallMethodNpm:
- if err := applyNpmUpdate(ctx); err != nil {
+ if err := applyNpmUpdate(ctx, options.JSON); err != nil {
return ApplyResult{}, err
}
return ApplyResult{
@@ -109,15 +110,27 @@ func FormatApply(result ApplyResult) string {
return strings.Join(lines, "\n")
}
-func applyNpmUpdate(ctx context.Context) error {
+func applyNpmUpdate(ctx context.Context, jsonOutput bool) error {
npmPath, err := exec.LookPath("npm")
if err != nil {
return fmt.Errorf("npm not found on PATH: reinstall with `npm install -g %s@latest`", npmPackageName)
}
command := exec.CommandContext(ctx, npmPath, "install", "-g", npmPackageName+"@latest")
- command.Stdout = os.Stdout
command.Stderr = os.Stderr
+ var captured bytes.Buffer
+ if jsonOutput {
+ // The caller writes a JSON result to stdout after this returns;
+ // npm's own progress output must stay off stdout so it doesn't get
+ // interleaved with (and corrupt) that JSON. Still capture it so a
+ // failure can report what npm printed.
+ command.Stdout = &captured
+ } else {
+ command.Stdout = os.Stdout
+ }
if err := command.Run(); err != nil {
+ if jsonOutput && captured.Len() > 0 {
+ return fmt.Errorf("npm install -g %s@latest: %w\n%s", npmPackageName, err, captured.String())
+ }
return fmt.Errorf("npm install -g %s@latest: %w", npmPackageName, err)
}
return nil
diff --git a/internal/update/apply_test.go b/internal/update/apply_test.go
index 379273498..c3136106f 100644
--- a/internal/update/apply_test.go
+++ b/internal/update/apply_test.go
@@ -2,11 +2,13 @@ package update
import (
"context"
+ "io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
+ "strconv"
"strings"
"testing"
@@ -335,3 +337,109 @@ func TestVerifyArchiveChecksumRejectsFilenameMismatch(t *testing.T) {
t.Fatalf("expected error to name both the referenced file and the expected one, got %q", err)
}
}
+
+// jsonOutput=true means the caller writes a JSON result to stdout right
+// after Apply returns, so npm's own progress output must not land on stdout
+// too, or it corrupts the JSON for any script/CI consumer parsing it.
+func TestApplyNpmUpdateKeepsStdoutCleanInJSONMode(t *testing.T) {
+ fakeBin := t.TempDir()
+ writeFakeNPMInstall(t, fakeBin, "npm install output", 0)
+ t.Setenv("PATH", fakeBin+string(os.PathListSeparator)+os.Getenv("PATH"))
+
+ stdout := redirectStdout(t)
+
+ if err := applyNpmUpdate(context.Background(), true); err != nil {
+ t.Fatalf("applyNpmUpdate: %v", err)
+ }
+
+ if captured := stdout(); captured != "" {
+ t.Fatalf("expected nothing written to stdout in JSON mode, got %q", captured)
+ }
+}
+
+func TestApplyNpmUpdateReportsCapturedOutputOnFailureInJSONMode(t *testing.T) {
+ fakeBin := t.TempDir()
+ writeFakeNPMInstall(t, fakeBin, "npm install boom", 1)
+ t.Setenv("PATH", fakeBin+string(os.PathListSeparator)+os.Getenv("PATH"))
+
+ err := applyNpmUpdate(context.Background(), true)
+ if err == nil {
+ t.Fatal("expected error when npm install fails")
+ }
+ if !strings.Contains(err.Error(), "npm install boom") {
+ t.Fatalf("expected error to include npm's captured output, got %q", err)
+ }
+}
+
+func TestApplyNpmUpdateStreamsToStdoutOutsideJSONMode(t *testing.T) {
+ fakeBin := t.TempDir()
+ writeFakeNPMInstall(t, fakeBin, "npm install output", 0)
+ t.Setenv("PATH", fakeBin+string(os.PathListSeparator)+os.Getenv("PATH"))
+
+ stdout := redirectStdout(t)
+
+ if err := applyNpmUpdate(context.Background(), false); err != nil {
+ t.Fatalf("applyNpmUpdate: %v", err)
+ }
+
+ if captured := stdout(); !strings.Contains(captured, "npm install output") {
+ t.Fatalf("expected npm output on stdout outside JSON mode, got %q", captured)
+ }
+}
+
+// redirectStdout swaps os.Stdout for a pipe for the rest of the test and
+// returns a function that restores the original and yields everything
+// written in between.
+func redirectStdout(t *testing.T) func() string {
+ t.Helper()
+ original := os.Stdout
+ reader, writer, err := os.Pipe()
+ if err != nil {
+ t.Fatalf("os.Pipe: %v", err)
+ }
+ os.Stdout = writer
+
+ done := make(chan string, 1)
+ go func() {
+ data, _ := io.ReadAll(reader)
+ done <- string(data)
+ }()
+
+ t.Cleanup(func() {
+ os.Stdout = original
+ })
+
+ return func() string {
+ _ = writer.Close()
+ os.Stdout = original
+ captured := <-done
+ _ = reader.Close()
+ return captured
+ }
+}
+
+// writeFakeNPMInstall writes a fake `npm` on PATH that prints stdoutLine
+// (a single line, no special shell/batch characters) and exits with
+// exitCode.
+func writeFakeNPMInstall(t *testing.T, dir string, stdoutLine string, exitCode int) {
+ t.Helper()
+ if runtime.GOOS == "windows" {
+ path := filepath.Join(dir, "npm.cmd")
+ content := "@echo off\r\n" +
+ "echo " + stdoutLine + "\r\n" +
+ "exit /b " + strconv.Itoa(exitCode) + "\r\n"
+ if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
+ t.Fatalf("WriteFile(%s): %v", path, err)
+ }
+ return
+ }
+ path := filepath.Join(dir, "npm")
+ content := "#!/bin/sh\necho " + shellQuote(stdoutLine) + "\nexit " + strconv.Itoa(exitCode) + "\n"
+ if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
+ t.Fatalf("WriteFile(%s): %v", path, err)
+ }
+}
+
+func shellQuote(s string) string {
+ return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
+}
diff --git a/internal/update/update.go b/internal/update/update.go
index 43e875d84..5b95a13dc 100644
--- a/internal/update/update.go
+++ b/internal/update/update.go
@@ -70,6 +70,10 @@ type Options struct {
Timeout time.Duration
GOOS string
GOARCH string
+ // JSON indicates the caller will print a machine-readable result to
+ // stdout afterward, so any subprocess output (e.g. `npm install -g`)
+ // must be kept off stdout to avoid corrupting it.
+ JSON bool
// Fetch overrides the release fetcher for tests and alternate transports.
Fetch func(context.Context, string) (Release, error)
}
From 7013f9194d5ee5992decf47a208f25ee5f6aec81 Mon Sep 17 00:00:00 2001
From: Amp
Date: Mon, 27 Jul 2026 21:17:17 +0000
Subject: [PATCH 11/15] docs(update): qualify npm-managed JSON output
Document the npm subprocess output caveat instead of promising a single parseable JSON document for apply operations. Drop the JSON-specific updater plumbing and tests so the PR remains focused on documentation and help discovery.
Amp-Thread-ID: https://ampcode.com/threads/T-019fa55a-8bcd-71bc-9bde-9b4c28b78b38
Co-authored-by: Pierre Bruno
---
docs/UPDATE.md | 7 ++-
internal/cli/update.go | 1 -
internal/update/apply.go | 19 +-----
internal/update/apply_test.go | 108 ----------------------------------
internal/update/update.go | 4 --
5 files changed, 8 insertions(+), 131 deletions(-)
diff --git a/docs/UPDATE.md b/docs/UPDATE.md
index a4cd7df4f..06a5367b2 100644
--- a/docs/UPDATE.md
+++ b/docs/UPDATE.md
@@ -12,7 +12,6 @@ zero update --check --target windows-x64
zero upgrade
zero update --apply
-zero update --apply --json
```
`--check` and `--apply` are mutually exclusive. `zero update` requires one of
@@ -37,7 +36,11 @@ them explicitly; `zero upgrade` is `zero update` with `--apply` implied.
later invocation, since it can't be overwritten while running.
- `--target` cannot be combined with `--apply`; it only applies to `--check`,
since applying always installs onto the current machine.
-- `--json` prints the same result in a machine-readable format.
+- `--json` serializes Zero's final result. For npm-managed installs, npm may
+ also write progress output to stdout, so neither
+ `zero update --apply --json` nor `zero upgrade --json` is guaranteed to
+ produce a single parseable JSON document. Use `--check --json` for
+ machine-readable automation.
Useful flags:
diff --git a/internal/cli/update.go b/internal/cli/update.go
index d468cc27a..8d2c5d0c9 100644
--- a/internal/cli/update.go
+++ b/internal/cli/update.go
@@ -60,7 +60,6 @@ func runUpdateCommand(args []string, stdout io.Writer, stderr io.Writer, deps ap
Repository: options.repository,
Endpoint: options.endpoint,
Timeout: options.timeout,
- JSON: options.json,
}
if options.target != "" {
target, err := update.ResolveTarget(options.target)
diff --git a/internal/update/apply.go b/internal/update/apply.go
index 2f8f3cfe2..4f97dd458 100644
--- a/internal/update/apply.go
+++ b/internal/update/apply.go
@@ -1,7 +1,6 @@
package update
import (
- "bytes"
"context"
"fmt"
"io"
@@ -69,7 +68,7 @@ func Apply(ctx context.Context, options Options) (ApplyResult, error) {
method := DetectInstallMethod(executablePath)
switch method {
case InstallMethodNpm:
- if err := applyNpmUpdate(ctx, options.JSON); err != nil {
+ if err := applyNpmUpdate(ctx); err != nil {
return ApplyResult{}, err
}
return ApplyResult{
@@ -110,27 +109,15 @@ func FormatApply(result ApplyResult) string {
return strings.Join(lines, "\n")
}
-func applyNpmUpdate(ctx context.Context, jsonOutput bool) error {
+func applyNpmUpdate(ctx context.Context) error {
npmPath, err := exec.LookPath("npm")
if err != nil {
return fmt.Errorf("npm not found on PATH: reinstall with `npm install -g %s@latest`", npmPackageName)
}
command := exec.CommandContext(ctx, npmPath, "install", "-g", npmPackageName+"@latest")
+ command.Stdout = os.Stdout
command.Stderr = os.Stderr
- var captured bytes.Buffer
- if jsonOutput {
- // The caller writes a JSON result to stdout after this returns;
- // npm's own progress output must stay off stdout so it doesn't get
- // interleaved with (and corrupt) that JSON. Still capture it so a
- // failure can report what npm printed.
- command.Stdout = &captured
- } else {
- command.Stdout = os.Stdout
- }
if err := command.Run(); err != nil {
- if jsonOutput && captured.Len() > 0 {
- return fmt.Errorf("npm install -g %s@latest: %w\n%s", npmPackageName, err, captured.String())
- }
return fmt.Errorf("npm install -g %s@latest: %w", npmPackageName, err)
}
return nil
diff --git a/internal/update/apply_test.go b/internal/update/apply_test.go
index f74623eeb..80e9d9f01 100644
--- a/internal/update/apply_test.go
+++ b/internal/update/apply_test.go
@@ -2,13 +2,11 @@ package update
import (
"context"
- "io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
- "strconv"
"strings"
"testing"
@@ -338,109 +336,3 @@ func TestVerifyArchiveChecksumRejectsFilenameMismatch(t *testing.T) {
t.Fatalf("expected error to name both the referenced file and the expected one, got %q", err)
}
}
-
-// jsonOutput=true means the caller writes a JSON result to stdout right
-// after Apply returns, so npm's own progress output must not land on stdout
-// too, or it corrupts the JSON for any script/CI consumer parsing it.
-func TestApplyNpmUpdateKeepsStdoutCleanInJSONMode(t *testing.T) {
- fakeBin := t.TempDir()
- writeFakeNPMInstall(t, fakeBin, "npm install output", 0)
- t.Setenv("PATH", fakeBin+string(os.PathListSeparator)+os.Getenv("PATH"))
-
- stdout := redirectStdout(t)
-
- if err := applyNpmUpdate(context.Background(), true); err != nil {
- t.Fatalf("applyNpmUpdate: %v", err)
- }
-
- if captured := stdout(); captured != "" {
- t.Fatalf("expected nothing written to stdout in JSON mode, got %q", captured)
- }
-}
-
-func TestApplyNpmUpdateReportsCapturedOutputOnFailureInJSONMode(t *testing.T) {
- fakeBin := t.TempDir()
- writeFakeNPMInstall(t, fakeBin, "npm install boom", 1)
- t.Setenv("PATH", fakeBin+string(os.PathListSeparator)+os.Getenv("PATH"))
-
- err := applyNpmUpdate(context.Background(), true)
- if err == nil {
- t.Fatal("expected error when npm install fails")
- }
- if !strings.Contains(err.Error(), "npm install boom") {
- t.Fatalf("expected error to include npm's captured output, got %q", err)
- }
-}
-
-func TestApplyNpmUpdateStreamsToStdoutOutsideJSONMode(t *testing.T) {
- fakeBin := t.TempDir()
- writeFakeNPMInstall(t, fakeBin, "npm install output", 0)
- t.Setenv("PATH", fakeBin+string(os.PathListSeparator)+os.Getenv("PATH"))
-
- stdout := redirectStdout(t)
-
- if err := applyNpmUpdate(context.Background(), false); err != nil {
- t.Fatalf("applyNpmUpdate: %v", err)
- }
-
- if captured := stdout(); !strings.Contains(captured, "npm install output") {
- t.Fatalf("expected npm output on stdout outside JSON mode, got %q", captured)
- }
-}
-
-// redirectStdout swaps os.Stdout for a pipe for the rest of the test and
-// returns a function that restores the original and yields everything
-// written in between.
-func redirectStdout(t *testing.T) func() string {
- t.Helper()
- original := os.Stdout
- reader, writer, err := os.Pipe()
- if err != nil {
- t.Fatalf("os.Pipe: %v", err)
- }
- os.Stdout = writer
-
- done := make(chan string, 1)
- go func() {
- data, _ := io.ReadAll(reader)
- done <- string(data)
- }()
-
- t.Cleanup(func() {
- os.Stdout = original
- })
-
- return func() string {
- _ = writer.Close()
- os.Stdout = original
- captured := <-done
- _ = reader.Close()
- return captured
- }
-}
-
-// writeFakeNPMInstall writes a fake `npm` on PATH that prints stdoutLine
-// (a single line, no special shell/batch characters) and exits with
-// exitCode.
-func writeFakeNPMInstall(t *testing.T, dir string, stdoutLine string, exitCode int) {
- t.Helper()
- if runtime.GOOS == "windows" {
- path := filepath.Join(dir, "npm.cmd")
- content := "@echo off\r\n" +
- "echo " + stdoutLine + "\r\n" +
- "exit /b " + strconv.Itoa(exitCode) + "\r\n"
- if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
- t.Fatalf("WriteFile(%s): %v", path, err)
- }
- return
- }
- path := filepath.Join(dir, "npm")
- content := "#!/bin/sh\necho " + shellQuote(stdoutLine) + "\nexit " + strconv.Itoa(exitCode) + "\n"
- if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
- t.Fatalf("WriteFile(%s): %v", path, err)
- }
-}
-
-func shellQuote(s string) string {
- return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
-}
diff --git a/internal/update/update.go b/internal/update/update.go
index 5d754792e..b4b11d1f0 100644
--- a/internal/update/update.go
+++ b/internal/update/update.go
@@ -70,10 +70,6 @@ type Options struct {
Timeout time.Duration
GOOS string
GOARCH string
- // JSON indicates the caller will print a machine-readable result to
- // stdout afterward, so any subprocess output (e.g. `npm install -g`)
- // must be kept off stdout to avoid corrupting it.
- JSON bool
// Fetch overrides the release fetcher for tests and alternate transports.
Fetch func(context.Context, string) (Release, error)
}
From 7f23a7a1203be2e9ca06ca97bf1adcf5513ae4f2 Mon Sep 17 00:00:00 2001
From: Amp
Date: Tue, 28 Jul 2026 08:33:04 +0000
Subject: [PATCH 12/15] docs(update): align upgrade guidance
Amp-Thread-ID: https://ampcode.com/threads/T-019fa7a0-1223-701d-9529-48ba5d7cf8c8
Co-authored-by: Pierre Bruno
---
docs/INSTALL.md | 5 +++--
docs/UPDATE.md | 9 +++++----
internal/cli/app.go | 2 +-
internal/cli/app_test.go | 14 ++++++++++++++
internal/update/update.go | 6 +-----
internal/update/update_test.go | 5 ++++-
6 files changed, 28 insertions(+), 13 deletions(-)
diff --git a/docs/INSTALL.md b/docs/INSTALL.md
index b65a0493f..dea5b7b70 100644
--- a/docs/INSTALL.md
+++ b/docs/INSTALL.md
@@ -234,10 +234,11 @@ directory.
## Updating
-Check for a newer release:
+Check for a newer release, then download, verify, and install it in place:
```bash
zero update --check
+zero upgrade
```
-Then reinstall with npm or rerun the install script for the version you want.
+See the [update guide](UPDATE.md) for update modes, flags, and platform details.
diff --git a/docs/UPDATE.md b/docs/UPDATE.md
index 06a5367b2..6422bb61b 100644
--- a/docs/UPDATE.md
+++ b/docs/UPDATE.md
@@ -32,8 +32,9 @@ them explicitly; `zero upgrade` is `zero update` with `--apply` implied.
- Standalone installs download the release archive, verify its checksum,
extract it, and atomically replace the running binary plus any installed
optional sandbox helpers.
-- On Windows, the running executable is renamed aside and cleaned up on a
- later invocation, since it can't be overwritten while running.
+- On Windows, the running executable is renamed aside and cleaned up on the
+ next `zero update --apply` or `zero upgrade`, since it can't be overwritten
+ while running.
- `--target` cannot be combined with `--apply`; it only applies to `--check`,
since applying always installs onto the current machine.
- `--json` serializes Zero's final result. For npm-managed installs, npm may
@@ -46,8 +47,8 @@ Useful flags:
| Flag | Purpose |
|---|---|
-| `--repo ` | Check another GitHub repository. |
-| `--endpoint ` | Check a specific release API URL or repository slug. |
+| `--repo ` | Use another GitHub repository for `--check` and `--apply`/`upgrade`. |
+| `--endpoint ` | Use a specific release API URL or repository slug for `--check` and `--apply`/`upgrade`. |
| `--timeout ` | Override the default release check timeout. |
| `--target ` | Validate release metadata for another supported target (`--check` only). |
diff --git a/internal/cli/app.go b/internal/cli/app.go
index 0827483bc..df0d4dd6a 100644
--- a/internal/cli/app.go
+++ b/internal/cli/app.go
@@ -1196,7 +1196,7 @@ Commands:
mcp Manage MCP backend settings
auth Log in to model providers via OAuth
sandbox Inspect sandbox policy and persistent grants
- update Check for or apply Zero CLI updates
+ update Check or apply Zero CLI updates (requires --check or --apply)
upgrade Download, verify, and install available Zero CLI updates
worktrees Prepare isolated git worktrees
verify Detect and run local verification checks
diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go
index a52cc0086..7a1acbd7b 100644
--- a/internal/cli/app_test.go
+++ b/internal/cli/app_test.go
@@ -1457,6 +1457,20 @@ func TestRunUpdateHelpDocumentsCheckFlag(t *testing.T) {
}
}
+func TestTopLevelHelpDocumentsRequiredUpdateMode(t *testing.T) {
+ var stdout bytes.Buffer
+ var stderr bytes.Buffer
+
+ exitCode := runWithDeps([]string{"--help"}, &stdout, &stderr, appDeps{})
+
+ if exitCode != exitSuccess {
+ t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
+ }
+ if got := stdout.String(); !strings.Contains(got, "update Check or apply Zero CLI updates (requires --check or --apply)") {
+ t.Fatalf("expected top-level help to document required update mode, got %q", got)
+ }
+}
+
func TestRunUpdateReportsUpToDate(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
diff --git a/internal/update/update.go b/internal/update/update.go
index b4b11d1f0..385029f19 100644
--- a/internal/update/update.go
+++ b/internal/update/update.go
@@ -182,11 +182,7 @@ func Format(result Result) string {
"Release: " + result.ReleaseURL,
}
lines = appendAssetLines(lines, result.ReleaseAsset)
- if target := releaseAssetTarget(result.ReleaseAsset); target != "" {
- lines = append(lines, "Download the verified "+target+" release asset, then replace the current zero binary.")
- } else {
- lines = append(lines, "Download the verified release asset, then replace the current zero binary.")
- }
+ lines = append(lines, "Run `zero upgrade` to download, verify, and install the latest release.")
return strings.Join(lines, "\n")
}
lines := []string{
diff --git a/internal/update/update_test.go b/internal/update/update_test.go
index 1341d8c4b..8a80e57ec 100644
--- a/internal/update/update_test.go
+++ b/internal/update/update_test.go
@@ -436,9 +436,12 @@ func TestFormatResult(t *testing.T) {
if !strings.Contains(output, "Release asset: zero-v0.2.0-linux-x64.tar.gz") || !strings.Contains(output, "Checksum asset: zero-v0.2.0-linux-x64.tar.gz.sha256") {
t.Fatalf("update output did not include release assets: %q", output)
}
- if !strings.Contains(output, "Release target: linux-x64") || !strings.Contains(output, "Download the verified linux-x64 release asset") {
+ if !strings.Contains(output, "Release target: linux-x64") {
t.Fatalf("update output did not include target-specific guidance: %q", output)
}
+ if !strings.Contains(output, "Run `zero upgrade` to download, verify, and install") {
+ t.Fatalf("update output did not include upgrade guidance: %q", output)
+ }
if strings.Contains(output, "your platform") {
t.Fatalf("update output should not use ambiguous platform wording: %q", output)
}
From 3b9aba5eb0b37fc772519c18d9b2fc8d6cb34f64 Mon Sep 17 00:00:00 2001
From: PierrunoYT
Date: Tue, 28 Jul 2026 23:06:49 +0200
Subject: [PATCH 13/15] fix(update): keep the upgrade recommendation about this
machine
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`zero update --check --target windows-x64` answers a question about a different
machine, but the new call to action told the user to run `zero upgrade`, which
only ever installs onto this one — replacing the target-specific download
guidance the flag used to print. The recommendation is now conditional: a
current-platform check keeps it, and a cross-target check gets the manual
instruction plus a note naming the machine `zero upgrade` would have touched
instead.
Also align two flag contracts with what the code does: `zero update --help`
described --repo/--endpoint as check-only although apply honors them, and
UPDATE.md did not mention that both are ignored on an npm-managed apply, which
takes its release from the npm registry rather than GitHub.
Co-Authored-By: Claude Opus 5 (1M context)
---
docs/UPDATE.md | 4 +++
internal/cli/update.go | 4 +--
internal/update/update.go | 38 ++++++++++++++++++++++++-
internal/update/update_test.go | 52 ++++++++++++++++++++++++++++++++--
4 files changed, 92 insertions(+), 6 deletions(-)
diff --git a/docs/UPDATE.md b/docs/UPDATE.md
index 6422bb61b..fe20acb6f 100644
--- a/docs/UPDATE.md
+++ b/docs/UPDATE.md
@@ -37,6 +37,10 @@ them explicitly; `zero upgrade` is `zero update` with `--apply` implied.
while running.
- `--target` cannot be combined with `--apply`; it only applies to `--check`,
since applying always installs onto the current machine.
+- `--repo` and `--endpoint` are ignored when applying to an npm-managed
+ install: that path delegates to `npm install -g @gitlawb/zero@latest` and
+ takes its release from the npm registry, not from GitHub. They still apply to
+ `--check` there.
- `--json` serializes Zero's final result. For npm-managed installs, npm may
also write progress output to stdout, so neither
`zero update --apply --json` nor `zero upgrade --json` is guaranteed to
diff --git a/internal/cli/update.go b/internal/cli/update.go
index 8d2c5d0c9..aa35c041c 100644
--- a/internal/cli/update.go
+++ b/internal/cli/update.go
@@ -202,8 +202,8 @@ Flags:
--check Check the latest GitHub release without installing
--apply Download, verify, and install the latest release
--json Print the update result as JSON
- --repo Repository to check when no endpoint is provided
- --endpoint Release API URL or owner/repo slug to check
+ --repo Repository to use when no endpoint is provided (--check and --apply)
+ --endpoint Release API URL or owner/repo slug to use (--check and --apply)
--timeout Release check timeout (default 5s)
--target Release target to verify with --check (for example windows-x64); not valid with --apply
-h, --help Show this help
diff --git a/internal/update/update.go b/internal/update/update.go
index 385029f19..2f9e32853 100644
--- a/internal/update/update.go
+++ b/internal/update/update.go
@@ -182,7 +182,7 @@ func Format(result Result) string {
"Release: " + result.ReleaseURL,
}
lines = appendAssetLines(lines, result.ReleaseAsset)
- lines = append(lines, "Run `zero upgrade` to download, verify, and install the latest release.")
+ lines = append(lines, upgradeGuidance(result.ReleaseAsset))
return strings.Join(lines, "\n")
}
lines := []string{
@@ -214,6 +214,42 @@ func releaseAssetTarget(asset AssetCheck) string {
return asset.Platform + "-" + asset.Arch
}
+// localReleaseTarget is the release target of the machine running this process,
+// or "" when no release archive is published for it (Termux, for example).
+func localReleaseTarget() string {
+ platform, err := releasePlatform(runtime.GOOS)
+ if err != nil {
+ return ""
+ }
+ arch, err := releaseArch(runtime.GOARCH)
+ if err != nil {
+ return ""
+ }
+ return platform + "-" + arch
+}
+
+// upgradeGuidance returns the next step for an available update. `zero upgrade`
+// only ever installs onto THIS machine, so recommending it after a `--check
+// --target ` would answer a question about one machine with an action
+// that changes another. A cross-target check gets the manual instruction
+// instead, and says plainly which machine `zero upgrade` would have touched.
+//
+// An asset with no target recorded is the ordinary current-platform check (the
+// target fields are only populated when a target was resolved), so it keeps the
+// upgrade recommendation.
+func upgradeGuidance(asset AssetCheck) string {
+ target := releaseAssetTarget(asset)
+ local := localReleaseTarget()
+ if target == "" || target == local {
+ return "Run `zero upgrade` to download, verify, and install the latest release."
+ }
+ guidance := "Download the verified " + target + " release asset and replace the zero binary on that machine."
+ if local == "" {
+ return guidance + " `zero upgrade` installs onto this machine only."
+ }
+ return guidance + " `zero upgrade` installs onto this machine (" + local + ") instead."
+}
+
func fetchRelease(ctx context.Context, endpoint string) (release Release, err error) {
if strings.HasPrefix(endpoint, "data:") {
return fetchDataRelease(endpoint)
diff --git a/internal/update/update_test.go b/internal/update/update_test.go
index 8a80e57ec..613947abb 100644
--- a/internal/update/update_test.go
+++ b/internal/update/update_test.go
@@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
+ "runtime"
"strings"
"testing"
"time"
@@ -439,13 +440,26 @@ func TestFormatResult(t *testing.T) {
if !strings.Contains(output, "Release target: linux-x64") {
t.Fatalf("update output did not include target-specific guidance: %q", output)
}
- if !strings.Contains(output, "Run `zero upgrade` to download, verify, and install") {
- t.Fatalf("update output did not include upgrade guidance: %q", output)
- }
if strings.Contains(output, "your platform") {
t.Fatalf("update output should not use ambiguous platform wording: %q", output)
}
+ // The upgrade recommendation is about THIS machine, so it depends on whether
+ // the checked target is this machine — assert it against the local target
+ // rather than the fixed linux-x64 fixture above, which is a cross-target
+ // check anywhere but linux/amd64.
+ local := Format(Result{
+ CurrentVersion: "0.1.0",
+ LatestVersion: "0.2.0",
+ ReleaseURL: "https://github.com/Gitlawb/zero/releases/tag/v0.2.0",
+ TagName: "v0.2.0",
+ ReleaseAsset: assetCheckForTest(t, "v0.2.0", runtime.GOOS, runtime.GOARCH),
+ UpdateAvailable: true,
+ })
+ if !strings.Contains(local, "Run `zero upgrade` to download, verify, and install") {
+ t.Fatalf("current-platform check did not recommend zero upgrade: %q", local)
+ }
+
output = Format(Result{
CurrentVersion: "0.2.0",
LatestVersion: "0.2.0",
@@ -462,6 +476,38 @@ func TestFormatResult(t *testing.T) {
}
}
+// TestFormatCrossTargetCheckDoesNotRecommendLocalUpgrade is the regression test
+// for jatmn's #489 finding: `zero update --check --target ` answers a
+// question about a different machine, but the output recommended `zero upgrade`,
+// which only ever installs onto this one. That replaced the target-specific
+// manual-download guidance the flag used to print.
+func TestFormatCrossTargetCheckDoesNotRecommendLocalUpgrade(t *testing.T) {
+ // Pick a target that is definitely not this machine.
+ goos, goarch := "linux", "amd64"
+ if runtime.GOOS == "linux" && runtime.GOARCH == "amd64" {
+ goos, goarch = "windows", "arm64"
+ }
+ other := assetCheckForTest(t, "v0.2.0", goos, goarch)
+ output := Format(Result{
+ CurrentVersion: "0.1.0",
+ LatestVersion: "0.2.0",
+ ReleaseURL: "https://github.com/Gitlawb/zero/releases/tag/v0.2.0",
+ TagName: "v0.2.0",
+ ReleaseAsset: other,
+ UpdateAvailable: true,
+ })
+ if strings.Contains(output, "Run `zero upgrade`") {
+ t.Fatalf("a cross-target check must not recommend the local upgrade: %q", output)
+ }
+ target := other.Platform + "-" + other.Arch
+ if !strings.Contains(output, "Download the verified "+target+" release asset") {
+ t.Fatalf("cross-target check lost its target-specific guidance: %q", output)
+ }
+ if !strings.Contains(output, "installs onto this machine") {
+ t.Fatalf("cross-target check should say which machine zero upgrade would touch: %q", output)
+ }
+}
+
func releaseForTarget(t *testing.T, tag string, goos string, goarch string) Release {
t.Helper()
check := assetCheckForTest(t, tag, goos, goarch)
From 649bc0f91b667d41ef18fb4317142e11ae50faab Mon Sep 17 00:00:00 2001
From: PierrunoYT
Date: Wed, 29 Jul 2026 14:06:17 +0200
Subject: [PATCH 14/15] fix(update): do not send a custom-source check to the
default source
`zero upgrade` is a fresh invocation: it does not inherit the --repo/--endpoint
of the check that suggested it, so recommending it bare after a custom-source
check told the user to install from a repository they never asked about. The
check now records the flag it was given and the guidance repeats it, naming
where a bare upgrade would install from instead.
ZERO_UPDATE_RELEASE_URL is deliberately not recorded: every Check reads it from
the environment, including the one inside Apply, so a bare upgrade already
follows it and telling the user to repeat it would be wrong.
Two smaller edges from the same review: a cross-target check on a host with no
published release target no longer ends by describing what `zero upgrade` does
there, since it does not work there at all; and the flag table and help text now
carry the npm caveat the prose bullet already had, so docs and help agree.
Co-Authored-By: Claude Opus 5 (1M context)
---
docs/UPDATE.md | 4 +-
internal/cli/update.go | 4 +-
internal/update/update.go | 65 ++++++++++++++++++------
internal/update/update_test.go | 92 ++++++++++++++++++++++++++++++++++
4 files changed, 146 insertions(+), 19 deletions(-)
diff --git a/docs/UPDATE.md b/docs/UPDATE.md
index fe20acb6f..ae6de4841 100644
--- a/docs/UPDATE.md
+++ b/docs/UPDATE.md
@@ -51,8 +51,8 @@ Useful flags:
| Flag | Purpose |
|---|---|
-| `--repo ` | Use another GitHub repository for `--check` and `--apply`/`upgrade`. |
-| `--endpoint ` | Use a specific release API URL or repository slug for `--check` and `--apply`/`upgrade`. |
+| `--repo ` | Use another GitHub repository for `--check` and `--apply`/`upgrade`. Ignored by an npm-managed apply. |
+| `--endpoint ` | Use a specific release API URL or repository slug for `--check` and `--apply`/`upgrade`. Ignored by an npm-managed apply. |
| `--timeout ` | Override the default release check timeout. |
| `--target ` | Validate release metadata for another supported target (`--check` only). |
diff --git a/internal/cli/update.go b/internal/cli/update.go
index aa35c041c..e21ca28be 100644
--- a/internal/cli/update.go
+++ b/internal/cli/update.go
@@ -202,8 +202,8 @@ Flags:
--check Check the latest GitHub release without installing
--apply Download, verify, and install the latest release
--json Print the update result as JSON
- --repo Repository to use when no endpoint is provided (--check and --apply)
- --endpoint Release API URL or owner/repo slug to use (--check and --apply)
+ --repo Repository to use when no endpoint is provided (--check and --apply; ignored by an npm-managed apply)
+ --endpoint Release API URL or owner/repo slug to use (--check and --apply; ignored by an npm-managed apply)
--timeout Release check timeout (default 5s)
--target Release target to verify with --check (for example windows-x64); not valid with --apply
-h, --help Show this help
diff --git a/internal/update/update.go b/internal/update/update.go
index 2f9e32853..c49c7ed7f 100644
--- a/internal/update/update.go
+++ b/internal/update/update.go
@@ -37,6 +37,13 @@ type Result struct {
TagName string `json:"tagName"`
ReleaseAsset AssetCheck `json:"releaseAsset"`
UpdateAvailable bool `json:"updateAvailable"`
+ // SourceFlag is the `--repo`/`--endpoint` argument this check was given, in
+ // the form a caller would repeat on `zero upgrade`. Empty when the check used
+ // the default release source. Format needs it because `zero upgrade` is a
+ // fresh invocation: it does not inherit the flags of the check that suggested
+ // it, so recommending it bare after a custom-source check would send the user
+ // to install from somewhere they did not ask about.
+ SourceFlag string `json:"sourceFlag,omitempty"`
}
type AssetCheck struct {
@@ -172,9 +179,28 @@ func Check(ctx context.Context, options Options) (Result, error) {
TagName: release.TagName,
ReleaseAsset: assetCheck,
UpdateAvailable: compareSemverParts(latestParts, currentParts) > 0,
+ SourceFlag: upgradeSourceFlag(options),
}, nil
}
+// upgradeSourceFlag returns the flag a caller must repeat on `zero upgrade` to
+// install from the same place this check read, or "" when the default source
+// was used.
+//
+// Only the per-invocation FLAGS need repeating. ZERO_UPDATE_RELEASE_URL is read
+// from the environment by every Check, including the one inside Apply, so a bare
+// `zero upgrade` already follows it — naming it here would tell the user to
+// repeat something that is not theirs to drop.
+func upgradeSourceFlag(options Options) string {
+ if endpoint := strings.TrimSpace(options.Endpoint); endpoint != "" {
+ return "--endpoint " + endpoint
+ }
+ if repository := strings.TrimSpace(options.Repository); repository != "" && repository != DefaultRepository {
+ return "--repo " + repository
+ }
+ return ""
+}
+
func Format(result Result) string {
if result.UpdateAvailable {
lines := []string{
@@ -182,7 +208,7 @@ func Format(result Result) string {
"Release: " + result.ReleaseURL,
}
lines = appendAssetLines(lines, result.ReleaseAsset)
- lines = append(lines, upgradeGuidance(result.ReleaseAsset))
+ lines = append(lines, upgradeGuidance(result.ReleaseAsset, result.SourceFlag))
return strings.Join(lines, "\n")
}
lines := []string{
@@ -228,26 +254,35 @@ func localReleaseTarget() string {
return platform + "-" + arch
}
-// upgradeGuidance returns the next step for an available update. `zero upgrade`
-// only ever installs onto THIS machine, so recommending it after a `--check
-// --target ` would answer a question about one machine with an action
-// that changes another. A cross-target check gets the manual instruction
-// instead, and says plainly which machine `zero upgrade` would have touched.
+// upgradeGuidance returns the next step for an available update.
+//
+// `zero upgrade` is a fresh invocation that installs onto THIS machine from the
+// DEFAULT release source, so it is only the right next step when the check
+// matched both. A cross-target check would otherwise answer a question about one
+// machine with an action that changes another; a custom-source check would send
+// the user to install from a repository they did not ask about, because the
+// flags do not carry over.
//
// An asset with no target recorded is the ordinary current-platform check (the
-// target fields are only populated when a target was resolved), so it keeps the
-// upgrade recommendation.
-func upgradeGuidance(asset AssetCheck) string {
+// target fields are only populated when a target was resolved).
+func upgradeGuidance(asset AssetCheck, sourceFlag string) string {
target := releaseAssetTarget(asset)
local := localReleaseTarget()
- if target == "" || target == local {
- return "Run `zero upgrade` to download, verify, and install the latest release."
+ if target != "" && target != local {
+ guidance := "Download the verified " + target + " release asset and replace the zero binary on that machine."
+ if local == "" {
+ // No published release target for this host (a source build on an OS
+ // with no release archive, e.g. Termux). Saying what `zero upgrade`
+ // would do here would be worse than saying nothing: it does not work on
+ // this machine at all.
+ return guidance
+ }
+ return guidance + " `zero upgrade` installs onto this machine (" + local + ") instead."
}
- guidance := "Download the verified " + target + " release asset and replace the zero binary on that machine."
- if local == "" {
- return guidance + " `zero upgrade` installs onto this machine only."
+ if sourceFlag != "" {
+ return "Run `zero upgrade " + sourceFlag + "` to install from the source this check used; a bare `zero upgrade` installs from " + DefaultRepository + "."
}
- return guidance + " `zero upgrade` installs onto this machine (" + local + ") instead."
+ return "Run `zero upgrade` to download, verify, and install the latest release."
}
func fetchRelease(ctx context.Context, endpoint string) (release Release, err error) {
diff --git a/internal/update/update_test.go b/internal/update/update_test.go
index 613947abb..cb4964401 100644
--- a/internal/update/update_test.go
+++ b/internal/update/update_test.go
@@ -476,6 +476,98 @@ func TestFormatResult(t *testing.T) {
}
}
+// TestFormatCustomSourceCheckRepeatsTheSourceFlag covers jatmn's #489 finding:
+// `zero upgrade` is a fresh invocation that reads from the DEFAULT source, so
+// recommending it bare after `--check --repo ` sends the user to install
+// from a repository they never asked about. The pre-PR text pointed at the
+// printed asset URLs instead; this keeps a runnable command by naming the flag
+// to repeat.
+func TestFormatCustomSourceCheckRepeatsTheSourceFlag(t *testing.T) {
+ local := assetCheckForTest(t, "v0.2.0", runtime.GOOS, runtime.GOARCH)
+ for _, tc := range []struct {
+ name string
+ options Options
+ want string
+ }{
+ {name: "repo flag", options: Options{Repository: "someone/fork"}, want: "zero upgrade --repo someone/fork"},
+ {name: "endpoint flag", options: Options{Endpoint: "https://example.test/releases/latest"}, want: "zero upgrade --endpoint https://example.test/releases/latest"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ output := Format(Result{
+ CurrentVersion: "0.1.0",
+ LatestVersion: "0.2.0",
+ ReleaseURL: "https://example.test/release",
+ TagName: "v0.2.0",
+ ReleaseAsset: local,
+ UpdateAvailable: true,
+ SourceFlag: upgradeSourceFlag(tc.options),
+ })
+ if !strings.Contains(output, tc.want) {
+ t.Fatalf("output = %q, want it to repeat %q", output, tc.want)
+ }
+ if !strings.Contains(output, DefaultRepository) {
+ t.Fatalf("output = %q, want it to say where a bare upgrade installs from", output)
+ }
+ })
+ }
+
+ // The default source keeps the plain call to action, including when --repo
+ // names the default repository explicitly.
+ for _, options := range []Options{{}, {Repository: DefaultRepository}} {
+ output := Format(Result{
+ CurrentVersion: "0.1.0",
+ LatestVersion: "0.2.0",
+ ReleaseURL: "https://example.test/release",
+ TagName: "v0.2.0",
+ ReleaseAsset: local,
+ UpdateAvailable: true,
+ SourceFlag: upgradeSourceFlag(options),
+ })
+ if !strings.Contains(output, "Run `zero upgrade` to download") {
+ t.Fatalf("default-source output = %q, want the plain upgrade recommendation", output)
+ }
+ }
+}
+
+// TestCheckRecordsTheSourceFlagItWasGiven pins where SourceFlag comes from: the
+// per-invocation flags, and not ZERO_UPDATE_RELEASE_URL — the env var is read by
+// every Check including the one inside Apply, so a bare `zero upgrade` already
+// follows it and telling the user to repeat it would be wrong.
+func TestCheckRecordsTheSourceFlagItWasGiven(t *testing.T) {
+ t.Setenv("ZERO_UPDATE_RELEASE_URL", "")
+ fetch := func(context.Context, string) (Release, error) {
+ return releaseForTarget(t, "v0.2.0", "linux", "amd64"), nil
+ }
+ base := Options{CurrentVersion: "0.1.0", GOOS: "linux", GOARCH: "amd64", Fetch: fetch}
+
+ withRepo := base
+ withRepo.Repository = "someone/fork"
+ result, err := Check(context.Background(), withRepo)
+ if err != nil {
+ t.Fatalf("Check: %v", err)
+ }
+ if result.SourceFlag != "--repo someone/fork" {
+ t.Fatalf("SourceFlag = %q, want the repo flag recorded", result.SourceFlag)
+ }
+
+ result, err = Check(context.Background(), base)
+ if err != nil {
+ t.Fatalf("Check: %v", err)
+ }
+ if result.SourceFlag != "" {
+ t.Fatalf("SourceFlag = %q, want empty for the default source", result.SourceFlag)
+ }
+
+ t.Setenv("ZERO_UPDATE_RELEASE_URL", "https://example.test/releases/latest")
+ result, err = Check(context.Background(), base)
+ if err != nil {
+ t.Fatalf("Check: %v", err)
+ }
+ if result.SourceFlag != "" {
+ t.Fatalf("SourceFlag = %q, want empty: the env var carries over on its own", result.SourceFlag)
+ }
+}
+
// TestFormatCrossTargetCheckDoesNotRecommendLocalUpgrade is the regression test
// for jatmn's #489 finding: `zero update --check --target ` answers a
// question about a different machine, but the output recommended `zero upgrade`,
From 023227f5134a5e7ec4d65fdea421209888244873 Mon Sep 17 00:00:00 2001
From: Amp
Date: Wed, 29 Jul 2026 23:58:55 +0000
Subject: [PATCH 15/15] fix(update): keep source guidance accurate
Amp-Thread-ID: https://ampcode.com/threads/T-019fafa2-7d9d-75bc-8801-6d0efc8db1df
Co-authored-by: Pierre Bruno
---
internal/cli/update.go | 4 +-
internal/update/installmethod.go | 11 +++-
internal/update/installmethod_test.go | 17 ++++++-
internal/update/update.go | 53 ++++++++++++++++----
internal/update/update_test.go | 72 ++++++++++++++++++++++++---
5 files changed, 135 insertions(+), 22 deletions(-)
diff --git a/internal/cli/update.go b/internal/cli/update.go
index e21ca28be..9d1afa873 100644
--- a/internal/cli/update.go
+++ b/internal/cli/update.go
@@ -202,8 +202,8 @@ Flags:
--check Check the latest GitHub release without installing
--apply Download, verify, and install the latest release
--json Print the update result as JSON
- --repo Repository to use when no endpoint is provided (--check and --apply; ignored by an npm-managed apply)
- --endpoint Release API URL or owner/repo slug to use (--check and --apply; ignored by an npm-managed apply)
+ --repo Repository to use when no endpoint is provided (--check and --apply; ignored for the install source by an npm-managed apply)
+ --endpoint Release API URL or owner/repo slug to use (--check and --apply; ignored for the install source by an npm-managed apply)
--timeout Release check timeout (default 5s)
--target Release target to verify with --check (for example windows-x64); not valid with --apply
-h, --help Show this help
diff --git a/internal/update/installmethod.go b/internal/update/installmethod.go
index b44782b73..d02de8cfc 100644
--- a/internal/update/installmethod.go
+++ b/internal/update/installmethod.go
@@ -33,12 +33,19 @@ func DetectInstallMethod(executablePath string) InstallMethod {
return InstallMethodStandalone
}
var pkg struct {
- Name string `json:"name"`
+ Name string `json:"name"`
+ OS []string `json:"os"`
+ CPU []string `json:"cpu"`
+ Bin json.RawMessage `json:"bin"`
}
if err := json.Unmarshal(data, &pkg); err != nil {
return InstallMethodStandalone
}
- if pkg.Name == npmPackageName {
+ // The native platform package is inert and constrained to one OS/CPU. The
+ // repository's wrapper package has the same name but carries a bin entry and
+ // broad platform lists, so name alone would misclassify `go build -o zero`
+ // from the repository root as an npm-managed install.
+ if pkg.Name == npmPackageName && len(pkg.OS) == 1 && len(pkg.CPU) == 1 && len(pkg.Bin) == 0 {
return InstallMethodNpm
}
return InstallMethodStandalone
diff --git a/internal/update/installmethod_test.go b/internal/update/installmethod_test.go
index a0a900574..c28885a94 100644
--- a/internal/update/installmethod_test.go
+++ b/internal/update/installmethod_test.go
@@ -37,7 +37,7 @@ func TestDetectInstallMethodNpmViaPackageJSON(t *testing.T) {
if err := os.WriteFile(exePath, []byte("binary"), 0o755); err != nil {
t.Fatalf("WriteFile: %v", err)
}
- if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"@gitlawb/zero"}`), 0o644); err != nil {
+ if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"@gitlawb/zero","version":"0.5.0-linux-x64","os":["linux"],"cpu":["x64"]}`), 0o644); err != nil {
t.Fatalf("WriteFile package.json: %v", err)
}
if method := DetectInstallMethod(exePath); method != InstallMethodNpm {
@@ -45,6 +45,21 @@ func TestDetectInstallMethodNpmViaPackageJSON(t *testing.T) {
}
}
+func TestDetectInstallMethodDoesNotTreatRepositoryManifestAsNpmBinary(t *testing.T) {
+ dir := t.TempDir()
+ exePath := filepath.Join(dir, "zero")
+ if err := os.WriteFile(exePath, []byte("binary"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ manifest := `{"name":"@gitlawb/zero","bin":{"zero":"bin/zero.js"},"os":["linux","darwin","win32","android"],"cpu":["x64","arm64"]}`
+ if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(manifest), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if method := DetectInstallMethod(exePath); method != InstallMethodStandalone {
+ t.Fatalf("DetectInstallMethod = %q, want standalone for repository wrapper manifest", method)
+ }
+}
+
func TestDetectInstallMethodIgnoresUnrelatedPackageJSON(t *testing.T) {
dir := t.TempDir()
exePath := filepath.Join(dir, "zero")
diff --git a/internal/update/update.go b/internal/update/update.go
index c49c7ed7f..ea68afd77 100644
--- a/internal/update/update.go
+++ b/internal/update/update.go
@@ -44,6 +44,10 @@ type Result struct {
// it, so recommending it bare after a custom-source check would send the user
// to install from somewhere they did not ask about.
SourceFlag string `json:"sourceFlag,omitempty"`
+ // installMethod is local process state used only to keep human guidance
+ // accurate. ApplyResult exposes the method after an install; adding it to
+ // check JSON would unnecessarily change that API.
+ installMethod InstallMethod
}
type AssetCheck struct {
@@ -172,7 +176,7 @@ func Check(ctx context.Context, options Options) (Result, error) {
if err != nil {
return Result{}, err
}
- return Result{
+ result := Result{
CurrentVersion: currentVersion,
LatestVersion: latestVersion,
ReleaseURL: releaseURL,
@@ -180,7 +184,11 @@ func Check(ctx context.Context, options Options) (Result, error) {
ReleaseAsset: assetCheck,
UpdateAvailable: compareSemverParts(latestParts, currentParts) > 0,
SourceFlag: upgradeSourceFlag(options),
- }, nil
+ }
+ if executablePath, executableErr := os.Executable(); executableErr == nil {
+ result.installMethod = DetectInstallMethod(executablePath)
+ }
+ return result, nil
}
// upgradeSourceFlag returns the flag a caller must repeat on `zero upgrade` to
@@ -193,7 +201,10 @@ func Check(ctx context.Context, options Options) (Result, error) {
// repeat something that is not theirs to drop.
func upgradeSourceFlag(options Options) string {
if endpoint := strings.TrimSpace(options.Endpoint); endpoint != "" {
- return "--endpoint " + endpoint
+ return "--endpoint " + shellQuote(endpoint)
+ }
+ if strings.TrimSpace(os.Getenv("ZERO_UPDATE_RELEASE_URL")) != "" {
+ return ""
}
if repository := strings.TrimSpace(options.Repository); repository != "" && repository != DefaultRepository {
return "--repo " + repository
@@ -201,6 +212,12 @@ func upgradeSourceFlag(options Options) string {
return ""
}
+// shellQuote returns one POSIX-shell argument suitable for the copy/paste
+// commands in human-readable guidance.
+func shellQuote(value string) string {
+ return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
+}
+
func Format(result Result) string {
if result.UpdateAvailable {
lines := []string{
@@ -208,7 +225,7 @@ func Format(result Result) string {
"Release: " + result.ReleaseURL,
}
lines = appendAssetLines(lines, result.ReleaseAsset)
- lines = append(lines, upgradeGuidance(result.ReleaseAsset, result.SourceFlag))
+ lines = append(lines, upgradeGuidance(result.ReleaseAsset, result.SourceFlag, result.installMethod))
return strings.Join(lines, "\n")
}
lines := []string{
@@ -243,15 +260,23 @@ func releaseAssetTarget(asset AssetCheck) string {
// localReleaseTarget is the release target of the machine running this process,
// or "" when no release archive is published for it (Termux, for example).
func localReleaseTarget() string {
- platform, err := releasePlatform(runtime.GOOS)
+ return publishedReleaseTarget(runtime.GOOS, runtime.GOARCH)
+}
+
+func publishedReleaseTarget(goos, goarch string) string {
+ platform, err := releasePlatform(goos)
if err != nil {
return ""
}
- arch, err := releaseArch(runtime.GOARCH)
+ arch, err := releaseArch(goarch)
if err != nil {
return ""
}
- return platform + "-" + arch
+ target := platform + "-" + arch
+ if target == "windows-arm64" {
+ return ""
+ }
+ return target
}
// upgradeGuidance returns the next step for an available update.
@@ -265,11 +290,18 @@ func localReleaseTarget() string {
//
// An asset with no target recorded is the ordinary current-platform check (the
// target fields are only populated when a target was resolved).
-func upgradeGuidance(asset AssetCheck, sourceFlag string) string {
+func upgradeGuidance(asset AssetCheck, sourceFlag string, installMethod InstallMethod) string {
target := releaseAssetTarget(asset)
local := localReleaseTarget()
if target != "" && target != local {
guidance := "Download the verified " + target + " release asset and replace the zero binary on that machine."
+ if sourceFlag != "" {
+ guidance += " The download URLs above are from the custom source selected by `" + sourceFlag + "`; a bare `zero upgrade` does not repeat that source."
+ if local != "" {
+ guidance += " It installs onto this machine (" + local + ") instead."
+ }
+ return guidance
+ }
if local == "" {
// No published release target for this host (a source build on an OS
// with no release archive, e.g. Termux). Saying what `zero upgrade`
@@ -280,7 +312,10 @@ func upgradeGuidance(asset AssetCheck, sourceFlag string) string {
return guidance + " `zero upgrade` installs onto this machine (" + local + ") instead."
}
if sourceFlag != "" {
- return "Run `zero upgrade " + sourceFlag + "` to install from the source this check used; a bare `zero upgrade` installs from " + DefaultRepository + "."
+ 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."
+ }
+ return "Run `zero upgrade " + sourceFlag + "` to install from the source this check used; a bare `zero upgrade` does not repeat that explicit source flag."
}
return "Run `zero upgrade` to download, verify, and install the latest release."
}
diff --git a/internal/update/update_test.go b/internal/update/update_test.go
index cb4964401..692be2faa 100644
--- a/internal/update/update_test.go
+++ b/internal/update/update_test.go
@@ -490,7 +490,7 @@ func TestFormatCustomSourceCheckRepeatsTheSourceFlag(t *testing.T) {
want string
}{
{name: "repo flag", options: Options{Repository: "someone/fork"}, want: "zero upgrade --repo someone/fork"},
- {name: "endpoint flag", options: Options{Endpoint: "https://example.test/releases/latest"}, want: "zero upgrade --endpoint https://example.test/releases/latest"},
+ {name: "endpoint flag", options: Options{Endpoint: "https://example.test/releases/latest?channel=$preview"}, want: "zero upgrade --endpoint 'https://example.test/releases/latest?channel=$preview'"},
} {
t.Run(tc.name, func(t *testing.T) {
output := Format(Result{
@@ -505,8 +505,8 @@ func TestFormatCustomSourceCheckRepeatsTheSourceFlag(t *testing.T) {
if !strings.Contains(output, tc.want) {
t.Fatalf("output = %q, want it to repeat %q", output, tc.want)
}
- if !strings.Contains(output, DefaultRepository) {
- t.Fatalf("output = %q, want it to say where a bare upgrade installs from", output)
+ if !strings.Contains(output, "bare `zero upgrade` does not repeat") {
+ t.Fatalf("output = %q, want it to distinguish a bare upgrade", output)
}
})
}
@@ -559,12 +559,51 @@ func TestCheckRecordsTheSourceFlagItWasGiven(t *testing.T) {
}
t.Setenv("ZERO_UPDATE_RELEASE_URL", "https://example.test/releases/latest")
- result, err = Check(context.Background(), base)
+ for _, options := range []Options{base, withRepo} {
+ result, err = Check(context.Background(), options)
+ if err != nil {
+ t.Fatalf("Check: %v", err)
+ }
+ if result.SourceFlag != "" {
+ t.Fatalf("SourceFlag = %q, want empty: the env var wins over repo and carries over on its own", result.SourceFlag)
+ }
+ }
+
+ withEndpoint := withRepo
+ withEndpoint.Endpoint = "https://option.test/releases/latest"
+ result, err = Check(context.Background(), withEndpoint)
if err != nil {
t.Fatalf("Check: %v", err)
}
- if result.SourceFlag != "" {
- t.Fatalf("SourceFlag = %q, want empty: the env var carries over on its own", result.SourceFlag)
+ if result.SourceFlag != "--endpoint 'https://option.test/releases/latest'" {
+ t.Fatalf("SourceFlag = %q, want explicit endpoint recorded", result.SourceFlag)
+ }
+}
+
+func TestFormatNpmCustomSourceExplainsInstallSource(t *testing.T) {
+ result := Result{
+ CurrentVersion: "0.1.0", LatestVersion: "0.2.0", ReleaseURL: "https://example.test/release",
+ ReleaseAsset: assetCheckForTest(t, "v0.2.0", runtime.GOOS, runtime.GOARCH), UpdateAvailable: true,
+ SourceFlag: "--repo someone/fork", installMethod: InstallMethodNpm,
+ }
+ output := Format(result)
+ if strings.Contains(output, "zero upgrade --repo") || !strings.Contains(output, "npm install -g @gitlawb/zero@latest") || !strings.Contains(output, "only affects the release check and update gating") {
+ t.Fatalf("npm custom-source guidance is misleading: %q", output)
+ }
+}
+
+func TestFormatCrossTargetCustomSourceKeepsAccurateGuidance(t *testing.T) {
+ goos, goarch := "linux", "amd64"
+ if runtime.GOOS == goos && runtime.GOARCH == goarch {
+ goos, goarch = "darwin", "arm64"
+ }
+ output := Format(Result{
+ CurrentVersion: "0.1.0", LatestVersion: "0.2.0", ReleaseURL: "https://example.test/release",
+ ReleaseAsset: assetCheckForTest(t, "v0.2.0", goos, goarch), UpdateAvailable: true,
+ SourceFlag: "--repo someone/fork",
+ })
+ if strings.Contains(output, "Run `zero upgrade") || !strings.Contains(output, "Download the verified") || !strings.Contains(output, "custom source selected by `--repo someone/fork`") || !strings.Contains(output, "bare `zero upgrade` does not repeat that source") {
+ t.Fatalf("cross-target custom-source guidance is misleading: %q", output)
}
}
@@ -577,7 +616,7 @@ func TestFormatCrossTargetCheckDoesNotRecommendLocalUpgrade(t *testing.T) {
// Pick a target that is definitely not this machine.
goos, goarch := "linux", "amd64"
if runtime.GOOS == "linux" && runtime.GOARCH == "amd64" {
- goos, goarch = "windows", "arm64"
+ goos, goarch = "darwin", "arm64"
}
other := assetCheckForTest(t, "v0.2.0", goos, goarch)
output := Format(Result{
@@ -595,11 +634,28 @@ func TestFormatCrossTargetCheckDoesNotRecommendLocalUpgrade(t *testing.T) {
if !strings.Contains(output, "Download the verified "+target+" release asset") {
t.Fatalf("cross-target check lost its target-specific guidance: %q", output)
}
- if !strings.Contains(output, "installs onto this machine") {
+ if localReleaseTarget() != "" && !strings.Contains(output, "installs onto this machine") {
t.Fatalf("cross-target check should say which machine zero upgrade would touch: %q", output)
}
}
+func TestPublishedReleaseTargetRejectsUnsupportedCombinations(t *testing.T) {
+ tests := []struct {
+ goos, goarch, want string
+ }{
+ {goos: "linux", goarch: "amd64", want: "linux-x64"},
+ {goos: "darwin", goarch: "arm64", want: "macos-arm64"},
+ {goos: "windows", goarch: "amd64", want: "windows-x64"},
+ {goos: "windows", goarch: "arm64", want: ""},
+ {goos: "android", goarch: "arm64", want: ""},
+ }
+ for _, test := range tests {
+ if got := publishedReleaseTarget(test.goos, test.goarch); got != test.want {
+ t.Errorf("publishedReleaseTarget(%q, %q) = %q, want %q", test.goos, test.goarch, got, test.want)
+ }
+ }
+}
+
func releaseForTarget(t *testing.T, tag string, goos string, goarch string) Release {
t.Helper()
check := assetCheckForTest(t, tag, goos, goarch)