diff --git a/README.md b/README.md
index ee9276551..9718ab2a4 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,7 @@
+
English | 中文
@@ -205,6 +206,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) |
| `/btw [question]` | ask in an isolated fork without adding the side conversation to the main session |
| `/loop` | repeat a prompt or custom `/command` on an interval (`/loop 5m /babysit-prs`) or self-paced |
| `/compact`, `/context` | manage context usage |
@@ -301,7 +303,8 @@ 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
```
## Extending Zero
@@ -408,6 +411,8 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
## Community
+Real-time chat happens on the [Discord server](https://discord.gg/CaQDS6wdFn).
+
Questions, setup help, ideas, and sharing all live in
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
diff --git a/README_ZH.md b/README_ZH.md
index e959dbba1..f8c09cdad 100644
--- a/README_ZH.md
+++ b/README_ZH.md
@@ -8,6 +8,7 @@
+
English | 中文
@@ -141,6 +142,7 @@ zero
| `/spec`、`/plan` | 在构建之前起草和审查计划 |
| `/image` | 为视觉模型附加图片 |
| `/resume`、`/rewind` | 继续或回滚本地会话 |
+| `/new` | 原地开始一个新会话(之前的会话仍保留在磁盘上) |
| `/compact`、`/context` | 管理上下文使用 |
| `/permissions`、`/tools` | 检查可用工具和策略 |
| `/add-dir` | 为此会话授予额外的写入目录 |
@@ -226,7 +228,8 @@ zero verify 检测和运行本地验证检查
zero changes 检查和提交本地 Git 变更
zero usage Token 使用量和估算成本
zero cron 定时智能体任务
-zero update 检查更新版本
+zero update --check 检查更新版本
+zero upgrade 下载、验证并安装最新版本
```
## 外观和无障碍
@@ -269,6 +272,20 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
- [性能](docs/PERFORMANCE.md)
- [智能体评估](docs/AGENT_EVALS.md)
+## 社区
+
+实时交流在 [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) | 维护者发布的版本和项目动态 |
+
## 贡献
欢迎贡献。阅读 [CONTRIBUTING.md](CONTRIBUTING.md),运行相关测试,然后提交一个聚焦的拉取请求。
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 9447d6433..ae6de4841 100644
--- a/docs/UPDATE.md
+++ b/docs/UPDATE.md
@@ -1,16 +1,23 @@
# 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
```
-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,14 +26,35 @@ 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 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.
+- `--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
+ produce a single parseable JSON document. Use `--check --json` for
+ machine-readable automation.
+
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`. 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. |
+| `--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 +68,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.
diff --git a/internal/cli/app.go b/internal/cli/app.go
index 80854beb4..df0d4dd6a 100644
--- a/internal/cli/app.go
+++ b/internal/cli/app.go
@@ -1196,7 +1196,8 @@ 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 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
eval Validate offline agent eval suites
diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go
index bbcd20ad8..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
@@ -1660,6 +1674,7 @@ func assertHelpOutput(t *testing.T, args []string) {
"mcp",
"sandbox",
"update",
+ "upgrade",
"worktrees",
"verify",
"serve",
diff --git a/internal/cli/update.go b/internal/cli/update.go
index 8d2c5d0c9..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 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; 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 b4b11d1f0..ea68afd77 100644
--- a/internal/update/update.go
+++ b/internal/update/update.go
@@ -37,6 +37,17 @@ 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"`
+ // 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 {
@@ -165,14 +176,46 @@ 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,
TagName: release.TagName,
ReleaseAsset: assetCheck,
UpdateAvailable: compareSemverParts(latestParts, currentParts) > 0,
- }, nil
+ SourceFlag: upgradeSourceFlag(options),
+ }
+ 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
+// 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 " + shellQuote(endpoint)
+ }
+ if strings.TrimSpace(os.Getenv("ZERO_UPDATE_RELEASE_URL")) != "" {
+ return ""
+ }
+ if repository := strings.TrimSpace(options.Repository); repository != "" && repository != DefaultRepository {
+ return "--repo " + repository
+ }
+ 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 {
@@ -182,11 +225,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, upgradeGuidance(result.ReleaseAsset, result.SourceFlag, result.installMethod))
return strings.Join(lines, "\n")
}
lines := []string{
@@ -218,6 +257,69 @@ 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 {
+ return publishedReleaseTarget(runtime.GOOS, runtime.GOARCH)
+}
+
+func publishedReleaseTarget(goos, goarch string) string {
+ platform, err := releasePlatform(goos)
+ if err != nil {
+ return ""
+ }
+ arch, err := releaseArch(goarch)
+ if err != nil {
+ return ""
+ }
+ target := platform + "-" + arch
+ if target == "windows-arm64" {
+ return ""
+ }
+ return target
+}
+
+// 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).
+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`
+ // 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."
+ }
+ if sourceFlag != "" {
+ if installMethod == InstallMethodNpm {
+ return "This npm-managed installation can be updated with `npm install -g " + npmPackageName + "@latest`, which installs the official npm package. The custom `" + sourceFlag + "` source only affects the release check and update gating, not the npm install source."
+ }
+ 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."
+}
+
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 1341d8c4b..692be2faa 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"
@@ -436,13 +437,29 @@ 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, "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",
@@ -459,6 +476,186 @@ 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?channel=$preview"}, want: "zero upgrade --endpoint 'https://example.test/releases/latest?channel=$preview'"},
+ } {
+ 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, "bare `zero upgrade` does not repeat") {
+ t.Fatalf("output = %q, want it to distinguish a bare upgrade", 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")
+ 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 != "--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)
+ }
+}
+
+// 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 = "darwin", "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 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)