From 2eabbce4a4dd893eb6a685e888211718b236618a Mon Sep 17 00:00:00 2001 From: xgopilot Date: Fri, 24 Apr 2026 14:28:28 +0000 Subject: [PATCH] fix(cli,updater): separate eligible/installable latest versions and align docs Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: pionxe <148670367+pionxe@users.noreply.github.com> --- README.md | 1 + docs/guides/update.md | 33 ++++++++-- internal/cli/root.go | 9 ++- internal/cli/root_test.go | 7 +- internal/cli/version_command.go | 29 ++++++--- internal/cli/version_command_test.go | 91 ++++++++++++++++++-------- internal/updater/updater.go | 29 +++++---- internal/updater/updater_test.go | 97 ++++++++++++++++++++++++++-- 8 files changed, 233 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index dbde5b99..5e7cda83 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,7 @@ Gateway 转发与自动拉起说明: - `neocode version --prerelease`:检查时包含预发布版本。 - `neocode update`:执行升级到当前通道的最新版本。 - `neocode update --prerelease`:执行升级并允许预发布版本。 +- 当远端“语义最新版本”在当前平台不可安装时,`version` 会同时给出“可安装的最高版本”升级提示,并提示远端资产异常状态。 ## 配置入口 diff --git a/docs/guides/update.md b/docs/guides/update.md index e3552987..dae04cf0 100644 --- a/docs/guides/update.md +++ b/docs/guides/update.md @@ -4,9 +4,32 @@ 1. `neocode` 启动时会后台检测新版本(默认 3 秒超时)。 2. 为避免干扰 TUI,提示在程序退出后展示。 -3. `url-dispatch` 与 `update` 子命令默认跳过静默检测。 +3. `url-dispatch`、`update` 与 `version` 子命令默认跳过静默检测(避免同次命令重复探测)。 -## 2. 手动升级 +## 2. 版本查询 + +查看当前版本并探测远端最新稳定版本: + +```bash +neocode version +``` + +探测时包含预发布版本: + +```bash +neocode version --prerelease +``` + +输出语义说明: + +1. `Current version`:当前本地二进制版本。 +2. `Latest stable version` / `Latest version (including prerelease)`:远端语义上的最新版本。 +3. 若远端最新版本对当前平台不可安装,但存在更低可安装版本: + - 会提示可执行升级(目标为当前平台可安装的最高版本)。 + - 同时提示远端存在“更新但当前平台暂不可安装”的状态,避免误判为“已是最新”。 +4. 版本探测失败时命令仍返回成功退出码(0),并输出 `check failed` 诊断信息,便于脚本集成。 + +## 3. 手动升级 升级到最新稳定版本: @@ -20,7 +43,7 @@ neocode update neocode update --prerelease ``` -## 3. 双产物安装建议 +## 4. 双产物安装建议 1. Full 模式:安装 `neocode`。 2. Gateway 模式:安装 `neocode-gateway`。 @@ -37,13 +60,13 @@ bash ./scripts/install.sh --flavor gateway .\scripts\install.ps1 -Flavor gateway ``` -## 4. 升级后验证(推荐) +## 5. 升级后验证(推荐) 1. `GET /healthz` 返回 200。 2. `/rpc` 未鉴权请求返回预期失败(`gateway_code=unauthorized`)。 3. 必要时执行一次最小 `gateway.run` 冒烟。 -## 5. 回滚步骤 +## 6. 回滚步骤 1. 停止当前网关进程。 2. 回退到上一版已验证二进制。 diff --git a/internal/cli/root.go b/internal/cli/root.go index cbf3d657..db83cd08 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -145,11 +145,14 @@ func defaultSilentUpdateCheck(ctx context.Context) { return } - latestVersion := sanitizeVersionForTerminal(result.LatestVersion) - if latestVersion == "" { + installableVersion := sanitizeVersionForTerminal(result.InstallableVersion) + if installableVersion == "" { + installableVersion = sanitizeVersionForTerminal(result.LatestVersion) + } + if installableVersion == "" { return } - setUpdateNotice(fmt.Sprintf("\u53d1\u73b0\u65b0\u7248\u672c: %s\uff0c\u8fd0\u884c neocode update \u5373\u53ef\u5347\u7ea7", latestVersion)) + setUpdateNotice(fmt.Sprintf("\u53d1\u73b0\u65b0\u7248\u672c: %s\uff0c\u8fd0\u884c neocode update \u5373\u53ef\u5347\u7ea7", installableVersion)) }(parentCtx, currentVersion, done) } diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 7e79dcc0..5cf1dc66 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -1503,9 +1503,10 @@ func TestDefaultSilentUpdateCheckSetsSanitizedNotice(t *testing.T) { checkLatestRelease = func(context.Context, updater.CheckOptions) (updater.CheckResult, error) { close(done) return updater.CheckResult{ - CurrentVersion: "v0.1.0", - LatestVersion: "\x1b[31mv0.2.1\x1b[0m\t\n\r", - HasUpdate: true, + CurrentVersion: "v0.1.0", + LatestVersion: "\x1b[31mv9.9.9\x1b[0m\t\n\r", + InstallableVersion: "\x1b[31mv0.2.1\x1b[0m\t\n\r", + HasUpdate: true, }, nil } diff --git a/internal/cli/version_command.go b/internal/cli/version_command.go index 9cc5177c..4c539bfd 100644 --- a/internal/cli/version_command.go +++ b/internal/cli/version_command.go @@ -17,13 +17,14 @@ type versionCommandOptions struct { } type versionCommandResult struct { - CurrentVersion string - LatestVersion string - HasUpdate bool - Comparable bool - ComparableLatest bool - IncludePrerelease bool - CheckErr error + CurrentVersion string + LatestVersion string + InstallableVersion string + HasUpdate bool + Comparable bool + ComparableLatest bool + IncludePrerelease bool + CheckErr error } var runVersionCommand = defaultVersionCommandRunner @@ -69,6 +70,7 @@ func defaultVersionCommandRunner(ctx context.Context, options versionCommandOpti } result.LatestVersion = strings.TrimSpace(probe.LatestVersion) + result.InstallableVersion = strings.TrimSpace(probe.InstallableVersion) result.ComparableLatest = probe.ComparableLatest if result.Comparable { result.HasUpdate = probe.HasUpdate @@ -93,6 +95,7 @@ func printVersionCommandResult(out io.Writer, result versionCommandResult) { latest := displayVersionForTerminal(result.LatestVersion) _, _ = fmt.Fprintf(out, "%s: %s\n", label, latest) + installable := displayVersionForTerminal(result.InstallableVersion) if !result.Comparable { _, _ = fmt.Fprintln(out, "Comparison skipped: current build is non-semver.") @@ -103,7 +106,17 @@ func printVersionCommandResult(out io.Writer, result versionCommandResult) { return } if !result.ComparableLatest { - _, _ = fmt.Fprintln(out, "Update status: unknown (latest release has no installable asset for current platform).") + if result.HasUpdate { + if installable != "unknown" { + _, _ = fmt.Fprintf(out, "Update available for this platform: run neocode update (target: %s)\n", installable) + } else { + _, _ = fmt.Fprintln(out, "Update available for this platform: run neocode update") + } + _, _ = fmt.Fprintln(out, "Remote notice: a newer release exists but is currently not installable on this platform.") + return + } + _, _ = fmt.Fprintln(out, "You are on the latest installable version for this platform.") + _, _ = fmt.Fprintln(out, "Remote notice: a newer release exists but is currently not installable on this platform.") return } if result.HasUpdate { diff --git a/internal/cli/version_command_test.go b/internal/cli/version_command_test.go index 38f4e94e..54001b6f 100644 --- a/internal/cli/version_command_test.go +++ b/internal/cli/version_command_test.go @@ -26,11 +26,12 @@ func TestVersionCommandPassesPrereleaseFlag(t *testing.T) { runVersionCommand = func(_ context.Context, options versionCommandOptions) (versionCommandResult, error) { received = options return versionCommandResult{ - CurrentVersion: "v1.0.0", - LatestVersion: "v1.0.0", - Comparable: true, - HasUpdate: false, - ComparableLatest: true, + CurrentVersion: "v1.0.0", + LatestVersion: "v1.0.0", + InstallableVersion: "v1.0.0", + Comparable: true, + HasUpdate: false, + ComparableLatest: true, }, nil } @@ -61,11 +62,12 @@ func TestVersionCommandShowsUpdateAvailable(t *testing.T) { runSilentUpdateCheck = func(context.Context) {} runVersionCommand = func(context.Context, versionCommandOptions) (versionCommandResult, error) { return versionCommandResult{ - CurrentVersion: "v1.0.0", - LatestVersion: "v1.2.0", - Comparable: true, - HasUpdate: true, - ComparableLatest: true, + CurrentVersion: "v1.0.0", + LatestVersion: "v1.2.0", + InstallableVersion: "v1.2.0", + Comparable: true, + HasUpdate: true, + ComparableLatest: true, }, nil } @@ -100,11 +102,12 @@ func TestVersionCommandShowsUpToDate(t *testing.T) { runSilentUpdateCheck = func(context.Context) {} runVersionCommand = func(context.Context, versionCommandOptions) (versionCommandResult, error) { return versionCommandResult{ - CurrentVersion: "v1.2.0", - LatestVersion: "v1.2.0", - Comparable: true, - HasUpdate: false, - ComparableLatest: true, + CurrentVersion: "v1.2.0", + LatestVersion: "v1.2.0", + InstallableVersion: "v1.2.0", + Comparable: true, + HasUpdate: false, + ComparableLatest: true, }, nil } @@ -220,10 +223,11 @@ func TestDefaultVersionCommandRunnerUsesProbeOptions(t *testing.T) { capturedIncludePrerelease = includePrerelease capturedTimeout = timeout return updater.CheckResult{ - CurrentVersion: "v1.0.0", - LatestVersion: "v1.1.0", - HasUpdate: true, - ComparableLatest: true, + CurrentVersion: "v1.0.0", + LatestVersion: "v1.1.0", + InstallableVersion: "v1.1.0", + HasUpdate: true, + ComparableLatest: true, }, nil } @@ -243,6 +247,9 @@ func TestDefaultVersionCommandRunnerUsesProbeOptions(t *testing.T) { if !result.HasUpdate || result.LatestVersion != "v1.1.0" { t.Fatalf("unexpected result: %+v", result) } + if result.InstallableVersion != "v1.1.0" { + t.Fatalf("InstallableVersion = %q, want %q", result.InstallableVersion, "v1.1.0") + } } func TestDefaultVersionCommandRunnerCheckFailureReturnsResultWithoutError(t *testing.T) { @@ -274,9 +281,10 @@ func TestDefaultVersionCommandRunnerTrimsLatestVersionAndSkipsNonSemverCompare(t readCurrentVersion = func() string { return "dev" } runReleaseProbe = func(context.Context, string, bool, time.Duration) (updater.CheckResult, error) { return updater.CheckResult{ - LatestVersion: " v1.2.0 ", - HasUpdate: true, - ComparableLatest: true, + LatestVersion: " v1.2.0 ", + InstallableVersion: " v1.2.0 ", + HasUpdate: true, + ComparableLatest: true, }, nil } @@ -287,6 +295,9 @@ func TestDefaultVersionCommandRunnerTrimsLatestVersionAndSkipsNonSemverCompare(t if result.LatestVersion != "v1.2.0" { t.Fatalf("LatestVersion = %q, want %q", result.LatestVersion, "v1.2.0") } + if result.InstallableVersion != "v1.2.0" { + t.Fatalf("InstallableVersion = %q, want %q", result.InstallableVersion, "v1.2.0") + } if result.HasUpdate { t.Fatalf("HasUpdate = true, want false for non-semver current version") } @@ -325,13 +336,37 @@ func TestPrintVersionCommandResultBranches(t *testing.T) { t.Run("latest exists but no installable asset", func(t *testing.T) { var out bytes.Buffer printVersionCommandResult(&out, versionCommandResult{ - CurrentVersion: "v1.0.0", - LatestVersion: "v2.0.0", - Comparable: true, - ComparableLatest: false, + CurrentVersion: "v1.0.0", + LatestVersion: "v2.0.0", + InstallableVersion: "v1.9.0", + Comparable: true, + HasUpdate: true, + ComparableLatest: false, + }) + text := out.String() + if !strings.Contains(text, "Update available for this platform: run neocode update (target: v1.9.0)") { + t.Fatalf("output = %q, want installable update guidance", text) + } + if !strings.Contains(text, "Remote notice: a newer release exists but is currently not installable on this platform.") { + t.Fatalf("output = %q, want remote notice", text) + } + }) + + t.Run("latest exists but current already on latest installable", func(t *testing.T) { + var out bytes.Buffer + printVersionCommandResult(&out, versionCommandResult{ + CurrentVersion: "v1.9.0", + LatestVersion: "v2.0.0", + InstallableVersion: "v1.9.0", + Comparable: true, + ComparableLatest: false, }) - if !strings.Contains(out.String(), "Update status: unknown (latest release has no installable asset for current platform).") { - t.Fatalf("output = %q, want no-installable-asset message", out.String()) + text := out.String() + if !strings.Contains(text, "You are on the latest installable version for this platform.") { + t.Fatalf("output = %q, want latest installable message", text) + } + if !strings.Contains(text, "Remote notice: a newer release exists but is currently not installable on this platform.") { + t.Fatalf("output = %q, want remote notice", text) } }) } diff --git a/internal/updater/updater.go b/internal/updater/updater.go index 275b0309..b3e63301 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -76,6 +76,8 @@ type probeResult struct { Status probeStatus Release releaseView LatestVersion string + InstallableVersion string + LatestInstallable bool ExpectedPattern string AvailableAssetsCount int CandidateAssets []string @@ -115,10 +117,11 @@ type CheckOptions struct { // CheckResult 表示静默探测流程返回的版本信息。 type CheckResult struct { - CurrentVersion string - LatestVersion string - HasUpdate bool - ComparableLatest bool + CurrentVersion string + LatestVersion string + InstallableVersion string + HasUpdate bool + ComparableLatest bool } // UpdateOptions 描述手动更新命令的输入参数。 @@ -154,18 +157,19 @@ func CheckLatest(ctx context.Context, opts CheckOptions) (CheckResult, error) { } result := CheckResult{ - CurrentVersion: currentVersion, - LatestVersion: strings.TrimSpace(probe.LatestVersion), + CurrentVersion: currentVersion, + LatestVersion: strings.TrimSpace(probe.LatestVersion), + InstallableVersion: strings.TrimSpace(probe.InstallableVersion), } if result.LatestVersion == "" { return result, nil } - result.ComparableLatest = probe.Status == probeStatusMatched && probe.Release != nil - if !result.ComparableLatest { - return result, nil + result.ComparableLatest = probe.LatestInstallable && probe.Status == probeStatusMatched && probe.Release != nil + if result.InstallableVersion == "" && probe.Release != nil { + result.InstallableVersion = strings.TrimSpace(probe.Release.Version()) } - if version.IsSemverRelease(currentVersion) { + if version.IsSemverRelease(currentVersion) && probe.Release != nil { result.HasUpdate = probe.Release.GreaterThan(currentVersion) } return result, nil @@ -273,7 +277,8 @@ func (c selfupdateClient) ProbeLatest( return result, nil } - result.LatestVersion = latestMatched.Version.String() + result.InstallableVersion = latestMatched.Version.String() + result.LatestInstallable = latestEligible != nil && latestEligible.Version.Equal(latestMatched.Version) result.AvailableAssetsCount = len(latestMatched.Release.GetAssets()) matchedNames := collectAssetNames(latestMatched.MatchedAssets) @@ -300,7 +305,7 @@ func (c selfupdateClient) ProbeLatest( result.Status = probeStatusMatched result.Release = release - result.LatestVersion = strings.TrimSpace(release.Version()) + result.InstallableVersion = strings.TrimSpace(release.Version()) return result, nil } diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go index 76131d42..d9ea39dd 100644 --- a/internal/updater/updater_test.go +++ b/internal/updater/updater_test.go @@ -39,6 +39,8 @@ type fakeClient struct { lastUpdatePath string probeStatus probeStatus probeLatestVersion string + probeInstallableVersion string + probeLatestInstallable bool probeExpectedPattern string probeAvailableAssetSize int probeCandidates []string @@ -57,12 +59,18 @@ func (c *fakeClient) ProbeLatest(_ context.Context, _ selfupdate.Repository, tar if latest == "" && c.release != nil { latest = strings.TrimSpace(c.release.Version()) } + installable := strings.TrimSpace(c.probeInstallableVersion) + if installable == "" && c.release != nil { + installable = strings.TrimSpace(c.release.Version()) + } if c.probeStatus != 0 { return probeResult{ Status: c.probeStatus, Release: c.release, LatestVersion: latest, + InstallableVersion: installable, + LatestInstallable: c.probeLatestInstallable, ExpectedPattern: expectedPattern, AvailableAssetsCount: c.probeAvailableAssetSize, CandidateAssets: append([]string(nil), c.probeCandidates...), @@ -72,6 +80,8 @@ func (c *fakeClient) ProbeLatest(_ context.Context, _ selfupdate.Repository, tar return probeResult{ Status: probeStatusNoCandidate, LatestVersion: latest, + InstallableVersion: installable, + LatestInstallable: c.probeLatestInstallable, ExpectedPattern: expectedPattern, AvailableAssetsCount: c.probeAvailableAssetSize, CandidateAssets: append([]string(nil), c.probeCandidates...), @@ -82,6 +92,8 @@ func (c *fakeClient) ProbeLatest(_ context.Context, _ selfupdate.Repository, tar Status: probeStatusMatched, Release: c.release, LatestVersion: latest, + InstallableVersion: installable, + LatestInstallable: c.probeLatestInstallable || latest == installable, ExpectedPattern: expectedPattern, AvailableAssetsCount: c.probeAvailableAssetSize, CandidateAssets: append([]string(nil), c.probeCandidates...), @@ -296,6 +308,9 @@ func TestCheckLatest(t *testing.T) { if result.LatestVersion != "v1.2.0" { t.Fatalf("LatestVersion = %q, want %q", result.LatestVersion, "v1.2.0") } + if result.InstallableVersion != "v1.2.0" { + t.Fatalf("InstallableVersion = %q, want %q", result.InstallableVersion, "v1.2.0") + } } func TestCheckLatestErrorBranches(t *testing.T) { @@ -423,8 +438,16 @@ func TestCheckLatestErrorBranches(t *testing.T) { runtimeGOARCH = "amd64" newClient = func(selfupdate.Config) (updateClient, error) { return &fakeClient{ - probeStatus: probeStatusNoCandidate, - probeLatestVersion: "v2.0.0", + probeStatus: probeStatusMatched, + probeLatestVersion: "v2.0.0", + probeInstallableVersion: "v1.9.0", + release: fakeRelease{ + version: "v1.9.0", + greaterFn: func(other string) bool { + return other == "v1.0.0" + }, + }, + found: true, }, nil } @@ -435,8 +458,11 @@ func TestCheckLatestErrorBranches(t *testing.T) { if result.LatestVersion != "v2.0.0" { t.Fatalf("LatestVersion = %q, want %q", result.LatestVersion, "v2.0.0") } - if result.HasUpdate { - t.Fatalf("HasUpdate = true, want false when latest is not installable") + if result.InstallableVersion != "v1.9.0" { + t.Fatalf("InstallableVersion = %q, want %q", result.InstallableVersion, "v1.9.0") + } + if !result.HasUpdate { + t.Fatalf("HasUpdate = false, want true when installable version is newer") } if result.ComparableLatest { t.Fatalf("ComparableLatest = true, want false when latest is not installable") @@ -1143,6 +1169,69 @@ func TestSelfupdateClientProbeLatestNoMatchedAssetReturnsEligibleDiagnostic(t *t } } +func TestSelfupdateClientProbeLatestKeepsEligibleLatestAndInstallableLatest(t *testing.T) { + source := stubSource{ + releases: []selfupdate.SourceRelease{ + stubSourceRelease{ + id: 2, + tagName: "v2.0.0", + assets: []selfupdate.SourceAsset{ + stubSourceAsset{id: 20, name: "checksums.txt", size: 1}, + }, + }, + stubSourceRelease{ + id: 1, + tagName: "v1.9.0", + assets: []selfupdate.SourceAsset{ + stubSourceAsset{id: 10, name: "neocode_linux_x86_64.tar.gz", size: 1}, + stubSourceAsset{id: 11, name: "checksums.txt", size: 1}, + }, + }, + }, + } + + updater, err := selfupdate.NewUpdater(selfupdate.Config{ + Source: source, + OS: "linux", + Arch: "x86_64", + }) + if err != nil { + t.Fatalf("NewUpdater() error = %v", err) + } + + client := selfupdateClient{ + updater: updater, + source: source, + config: selfupdate.Config{ + Source: source, + OS: "linux", + Arch: "x86_64", + }, + } + target := assetTarget{ + OSToken: "linux", + ArchToken: "x86_64", + Ext: "tar.gz", + } + + probe, err := client.ProbeLatest(context.Background(), selfupdate.NewRepositorySlug(repositoryOwner, repositoryName), target) + if err != nil { + t.Fatalf("ProbeLatest() error = %v", err) + } + if probe.Status != probeStatusMatched { + t.Fatalf("Status = %v, want matched", probe.Status) + } + if probe.LatestVersion != "2.0.0" { + t.Fatalf("LatestVersion = %q, want %q", probe.LatestVersion, "2.0.0") + } + if probe.InstallableVersion != "1.9.0" { + t.Fatalf("InstallableVersion = %q, want %q", probe.InstallableVersion, "1.9.0") + } + if probe.LatestInstallable { + t.Fatalf("LatestInstallable = true, want false") + } +} + func TestSelfupdateClientProbeLatestListReleasesError(t *testing.T) { client := selfupdateClient{ source: stubSource{listErr: errors.New("list failed")},