From ae52238af07c0d584894f3c8b2ade736842c0878 Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Sat, 4 Jul 2026 15:16:24 +0200 Subject: [PATCH 01/15] docs(readme): document /new, zero upgrade, and add Discord link Add the /new slash command and zero upgrade command to the README's command references, expand docs/UPDATE.md to cover the --apply install flow, and link the project Discord server. --- README.md | 8 ++++++++ README_ZH.md | 5 +++++ docs/UPDATE.md | 30 +++++++++++++++++++++++++----- 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 594654a12..4fd3c797e 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ license Go 1.25+ 25+ providers + Discord
English | 中文

@@ -186,6 +187,7 @@ Common slash commands: | `/spec`, `/plan` | draft and review a plan before building | | `/image` | attach an image for vision-capable models | | `/resume`, `/rewind` | continue or roll back local sessions | +| `/new` | start a fresh session in place (previous session stays on disk) | | `/compact`, `/context` | manage context usage | | `/permissions`, `/tools` | inspect available tools and policy | | `/add-dir` | allow an extra write directory for this session | @@ -281,6 +283,7 @@ zero changes inspect and commit local git changes zero usage token usage and estimated cost zero cron scheduled agent jobs zero update check for newer releases +zero upgrade download, verify, and install the latest release ``` ## Appearance And Accessibility @@ -323,6 +326,11 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero - [Performance](docs/PERFORMANCE.md) - [Agent evals](docs/AGENT_EVALS.md) +## Community + +Join the [Discord server](https://discord.com/invite/N9CrWCH7b) for questions, +feedback, and discussion. + ## Contributing Contributions are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md), run the diff --git a/README_ZH.md b/README_ZH.md index 014697ef4..03f6a1cea 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -8,6 +8,7 @@ license Go 1.25+ 25+ providers + Discord
English | 中文

@@ -286,6 +287,10 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero - [性能](docs/PERFORMANCE.md) - [智能体评估](docs/AGENT_EVALS.md) +## 社区 + +加入 [Discord 服务器](https://discord.com/invite/N9CrWCH7b) 提问、反馈和讨论。 + ## 贡献 欢迎贡献。阅读 [CONTRIBUTING.md](CONTRIBUTING.md),运行相关测试,然后提交一个聚焦的拉取请求。 diff --git a/docs/UPDATE.md b/docs/UPDATE.md index 9447d6433..e2ac47a71 100644 --- a/docs/UPDATE.md +++ b/docs/UPDATE.md @@ -1,16 +1,24 @@ # Update Flow `zero update --check` checks the latest GitHub release and compares it with the -local CLI version. +local CLI version. `zero update --apply` (or its shorthand, `zero upgrade`) +downloads, verifies, and installs it. ```bash zero update --check zero update --check --json zero update --check --repo Gitlawb/zero zero update --check --target windows-x64 + +zero upgrade +zero update --apply +zero update --apply --json ``` -The command is intentionally check-only: +`--check` and `--apply` are mutually exclusive. `zero update` requires one of +them explicitly; `zero upgrade` is `zero update` with `--apply` implied. + +`--check` is check-only: - It does not replace the running binary. - It exits with code `0` when the check succeeds, even when an update is @@ -19,6 +27,18 @@ The command is intentionally check-only: - `--json` prints the same result in a machine-readable format for scripts and CI. +`--apply` installs the update in place: + +- npm installs delegate to `npm install -g @gitlawb/zero@latest`. +- 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. +- `--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. + Useful flags: | Flag | Purpose | @@ -26,7 +46,7 @@ Useful flags: | `--repo ` | Check another GitHub repository. | | `--endpoint ` | Check a specific release API URL or repository slug. | | `--timeout ` | Override the default release check timeout. | -| `--target ` | Validate release metadata for another supported target. | +| `--target ` | Validate release metadata for another supported target (`--check` only). | Supported targets are `linux-x64`, `linux-arm64`, `macos-x64`, `macos-arm64`, `windows-x64`, and `windows-arm64`. Without `--target`, Zero checks the current @@ -40,5 +60,5 @@ Endpoint resolution order: 4. `https://api.github.com/repos/Gitlawb/zero/releases/latest` Installer scripts download the matching release asset for the local platform and -verify its `.sha256` file. If Zero is already installed, run `zero update --check` -before reinstalling. +verify its `.sha256` file. If Zero is already installed, run `zero upgrade` +instead of reinstalling. From 4448ff7062fd9fc7bdf257119051dd8fe1676667 Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Sat, 4 Jul 2026 15:20:42 +0200 Subject: [PATCH 02/15] docs(update): escape pipe in --endpoint table cell CodeRabbit flagged the unescaped | in the --endpoint placeholder breaking the Markdown table layout. --- docs/UPDATE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/UPDATE.md b/docs/UPDATE.md index e2ac47a71..a4cd7df4f 100644 --- a/docs/UPDATE.md +++ b/docs/UPDATE.md @@ -44,7 +44,7 @@ Useful flags: | Flag | Purpose | |---|---| | `--repo ` | Check another GitHub repository. | -| `--endpoint ` | Check a specific release API URL or repository slug. | +| `--endpoint ` | Check a specific release API URL or repository slug. | | `--timeout ` | Override the default release check timeout. | | `--target ` | Validate release metadata for another supported target (`--check` only). | From 1eccdb93d90e1df515482099d30c91f1b5a50ea2 Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Sat, 4 Jul 2026 16:08:05 +0200 Subject: [PATCH 03/15] docs(readme-zh): bring /new and zero upgrade in line with README.md README_ZH.md had picked up the Discord link but was still missing the /new slash command and zero upgrade entries added to README.md, letting the translation drift out of sync. --- README_ZH.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README_ZH.md b/README_ZH.md index 03f6a1cea..c0dd35cf3 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -160,6 +160,7 @@ zero | `/spec`、`/plan` | 在构建之前起草和审查计划 | | `/image` | 为视觉模型附加图片 | | `/resume`、`/rewind` | 继续或回滚本地会话 | +| `/new` | 原地开始一个新会话(之前的会话仍保留在磁盘上) | | `/compact`、`/context` | 管理上下文使用 | | `/permissions`、`/tools` | 检查可用工具和策略 | | `/add-dir` | 为此会话授予额外的写入目录 | @@ -246,6 +247,7 @@ zero changes 检查和提交本地 Git 变更 zero usage Token 使用量和估算成本 zero cron 定时智能体任务 zero update 检查更新版本 +zero upgrade 下载、验证并安装最新版本 ``` ## 外观和无障碍 From 169c7c243a814c212b4833f67ce29b01ac835ed8 Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Sun, 5 Jul 2026 11:38:10 +0200 Subject: [PATCH 04/15] cli: add upgrade to root help output --- internal/cli/app.go | 1 + internal/cli/app_test.go | 1 + 2 files changed, 2 insertions(+) diff --git a/internal/cli/app.go b/internal/cli/app.go index 7ab5aadf6..40c673929 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -1061,6 +1061,7 @@ Commands: auth Log in to model providers via OAuth sandbox Inspect sandbox policy and persistent grants update Check for Zero CLI updates + upgrade Apply available Zero CLI updates worktrees Prepare isolated git worktrees verify Detect and run local verification checks eval Validate offline agent eval suites diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index 054148adf..d55002594 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -1504,6 +1504,7 @@ func assertHelpOutput(t *testing.T, args []string) { "mcp", "sandbox", "update", + "upgrade", "worktrees", "verify", "serve", From 852ff1855171cb3cee115dce1e9e86add9ed9454 Mon Sep 17 00:00:00 2001 From: PierrunoYT <95778421+PierrunoYT@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:43:27 +0200 Subject: [PATCH 05/15] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- internal/cli/app.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cli/app.go b/internal/cli/app.go index 40c673929..34e9efb98 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -1060,7 +1060,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 Zero CLI updates + update Check for or apply Zero CLI updates upgrade Apply available Zero CLI updates worktrees Prepare isolated git worktrees verify Detect and run local verification checks From 8c29600b7bdfea80ef51c71e327a0c25d9c2f57a Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Sun, 5 Jul 2026 16:55:00 +0200 Subject: [PATCH 06/15] docs: make required update flag explicit in common commands zero update errors without --check or --apply, so the bare form in the common-commands examples was misleading. Addresses the Copilot finding from the PR #489 review. --- README.md | 2 +- README_ZH.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4fd3c797e..2807f22e5 100644 --- a/README.md +++ b/README.md @@ -282,7 +282,7 @@ zero verify detect and run local verification checks zero changes inspect and commit local git changes zero usage token usage and estimated cost zero cron scheduled agent jobs -zero update check for newer releases +zero update --check check for newer releases zero upgrade download, verify, and install the latest release ``` diff --git a/README_ZH.md b/README_ZH.md index c0dd35cf3..12f87b6c6 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -246,7 +246,7 @@ zero verify 检测和运行本地验证检查 zero changes 检查和提交本地 Git 变更 zero usage Token 使用量和估算成本 zero cron 定时智能体任务 -zero update 检查更新版本 +zero update --check 检查更新版本 zero upgrade 下载、验证并安装最新版本 ``` From f475e22ea74f2c231bd91a88ec14adcd8b0460f9 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sun, 5 Jul 2026 22:27:10 +0530 Subject: [PATCH 07/15] docs: use a permanent (non-expiring) Discord invite in the READMEs --- README.md | 4 ++-- README_ZH.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2807f22e5..826d3376f 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ license Go 1.25+ 25+ providers - Discord + Discord
English | 中文

@@ -328,7 +328,7 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero ## Community -Join the [Discord server](https://discord.com/invite/N9CrWCH7b) for questions, +Join the [Discord server](https://discord.com/invite/CaQDS6wdFn) for questions, feedback, and discussion. ## Contributing diff --git a/README_ZH.md b/README_ZH.md index 12f87b6c6..64f1a7402 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -8,7 +8,7 @@ license Go 1.25+ 25+ providers - Discord + Discord
English | 中文

@@ -291,7 +291,7 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero ## 社区 -加入 [Discord 服务器](https://discord.com/invite/N9CrWCH7b) 提问、反馈和讨论。 +加入 [Discord 服务器](https://discord.com/invite/CaQDS6wdFn) 提问、反馈和讨论。 ## 贡献 From 4b3f1608815dad8316225019ee25e5ad3691ed5a Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Mon, 6 Jul 2026 14:15:31 +0200 Subject: [PATCH 08/15] docs(cli): match upgrade help summary to download/verify/install wording Copilot flagged the root zero --help summary for upgrade as less specific than the download/verify/install phrasing already used in README.md and docs/UPDATE.md for the same command. Co-Authored-By: Claude Sonnet 5 --- internal/cli/app.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cli/app.go b/internal/cli/app.go index b86606260..d8fd892b2 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -1093,7 +1093,7 @@ Commands: auth Log in to model providers via OAuth sandbox Inspect sandbox policy and persistent grants update Check for or apply Zero CLI updates - upgrade Apply available Zero CLI updates + upgrade Download, verify, and install available Zero CLI updates worktrees Prepare isolated git worktrees verify Detect and run local verification checks eval Validate offline agent eval suites From 66ccae01c3bd56017eec71688e7472bae363ae1d Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Mon, 6 Jul 2026 14:17:27 +0200 Subject: [PATCH 09/15] docs: use discord.gg short link for the community invite discord.com/invite/ 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 @@ license Go 1.25+ 25+ providers - Discord + Discord
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 @@ license Go 1.25+ 25+ providers - Discord + Discord
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)