diff --git a/README.md b/README.md index ee9276551..9718ab2a4 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ license Go 1.26.5+ 25+ providers + Discord
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 @@ license Go 1.26.5+ 25+ providers + Discord
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)