From 9f78b506af9b6e18042b9c7f46f066cbf4f2e01a Mon Sep 17 00:00:00 2001 From: pionxe Date: Thu, 23 Apr 2026 21:58:45 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(cli,updater):=20=E6=96=B0=E5=A2=9E=20v?= =?UTF-8?q?ersion=20=E5=AD=90=E5=91=BD=E4=BB=A4=E5=B9=B6=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20update=20=E8=B7=A8=E5=B9=B3=E5=8F=B0=E8=B5=84=E4=BA=A7?= =?UTF-8?q?=E6=8E=A2=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 updater 从“精确资产名过滤”调整为基于 os/arch/ext 的语义匹配 - 兼容资产命名大小写、分隔符与扩展名变体,避免“有资产却命不中” - 在无候选/多候选场景输出可诊断错误信息(os、arch、expected-pattern、available-assets-count、candidate-assets) - 新增 neocode version 命令,输出当前版本、远端版本与比较结果 - 默认仅比较稳定版,--prerelease 才纳入预发布版本比较 - 抽取统一版本探测入口,version 命令跳过静默检查,避免二次探测 - 补充 updater/cli 回归测试,并更新更新指南与 README 说明 --- README.md | 7 + docs/guides/update.md | 39 ++- internal/cli/root.go | 12 +- internal/cli/root_test.go | 31 ++ internal/cli/version_command.go | 108 +++++++ internal/cli/version_command_test.go | 220 +++++++++++++++ internal/cli/version_probe.go | 31 ++ internal/cli/version_probe_test.go | 56 ++++ internal/updater/updater.go | 408 +++++++++++++++++++++++++-- internal/updater/updater_test.go | 333 ++++++++++++++++++++-- 10 files changed, 1172 insertions(+), 73 deletions(-) create mode 100644 internal/cli/version_command.go create mode 100644 internal/cli/version_command_test.go create mode 100644 internal/cli/version_probe.go create mode 100644 internal/cli/version_probe_test.go diff --git a/README.md b/README.md index c3b440ce..a94027ad 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,13 @@ Skill 内部调用脚本 `scripts/create_issue.sh` 创建 issue。你也可以 - `wake.openUrl`:处理 `neocode://` 唤醒请求 - `gateway.event`:网关推送通知事件(notification) +## 版本与升级命令 + +- `neocode version`:输出当前版本并检查最新稳定版。 +- `neocode version --prerelease`:检查时包含预发布版本。 +- `neocode update`:执行升级到当前通道的最新版本。 +- `neocode update --prerelease`:执行升级并允许预发布版本。 + ## License MIT diff --git a/docs/guides/update.md b/docs/guides/update.md index 5b52c67f..31748759 100644 --- a/docs/guides/update.md +++ b/docs/guides/update.md @@ -1,25 +1,50 @@ -# 更新与升级 +# 更新与版本检查 -## 自动检测 - -- `neocode` 启动时会在后台静默检测最新版本(默认 3 秒超时)。 +## 自动检查 +- `neocode` 启动时会在后台静默检查最新稳定版本(默认 3 秒超时)。 - 为避免干扰 Bubble Tea TUI 交互,更新提示会在应用退出、终端屏幕恢复后输出。 -- `url-dispatch` 与 `update` 子命令会跳过该检测流程。 +- `url-dispatch`、`update`、`version` 子命令会跳过该静默检查,避免重复探测。 + +## 查询版本 + +查看当前版本并探测远端最新版本: + +```bash +neocode version +``` + +包含预发布版本一起比较: + +```bash +neocode version --prerelease +``` + +行为说明: +- 始终输出当前版本。 +- 探测成功时输出“最新版本 + 比较结果”。 +- 探测失败时输出失败原因,但命令仍返回成功退出码,方便脚本场景继续执行。 ## 手动升级 -使用以下命令升级到最新稳定版: +升级到最新稳定版本: ```bash neocode update ``` -如需包含预发布版本: +包含预发布版本: ```bash neocode update --prerelease ``` +更新命令在平台资产匹配失败时会输出可诊断信息,例如: +- `os` +- `arch` +- `expected-pattern` +- `available-assets-count` +- `candidate-assets`(最多展示前 10 个,单项最长 120 字符) + ## 版本来源 - 发布构建会通过 `ldflags` 注入版本号到 `internal/version.Version`。 diff --git a/internal/cli/root.go b/internal/cli/root.go index fc8f291a..cbf3d657 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -21,6 +21,7 @@ var launchRootProgram = defaultRootProgramLauncher var newRootProgram = app.NewProgram var runGlobalPreload = defaultGlobalPreload var runSilentUpdateCheck = defaultSilentUpdateCheck +var runReleaseProbe = defaultReleaseProbe var readCurrentVersion = version.Current var checkLatestRelease = updater.CheckLatest @@ -87,6 +88,7 @@ func NewRootCommand() *cobra.Command { newGatewayCommand(), newMigrateCommand(), newURLDispatchCommand(), + newVersionCommand(), newUpdateCommand(), ) @@ -138,13 +140,7 @@ func defaultSilentUpdateCheck(ctx context.Context) { go func(parent context.Context, currentVersion string, done chan struct{}) { defer close(done) - checkCtx, cancel := context.WithTimeout(parent, silentUpdateCheckTimeout) - defer cancel() - - result, err := checkLatestRelease(checkCtx, updater.CheckOptions{ - CurrentVersion: currentVersion, - IncludePrerelease: false, - }) + result, err := runReleaseProbe(parent, currentVersion, false, silentUpdateCheckTimeout) if err != nil || !result.HasUpdate { return } @@ -165,7 +161,7 @@ func shouldSkipGlobalPreload(cmd *cobra.Command) bool { // shouldSkipSilentUpdateCheck 判断当前子命令是否跳过静默更新检查。 func shouldSkipSilentUpdateCheck(cmd *cobra.Command) bool { switch normalizedCommandName(cmd) { - case "url-dispatch", "update": + case "url-dispatch", "update", "version": return true default: return commandAnnotationEnabled(cmd, commandAnnotationSkipSilentUpdateCheck) diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 69c3e01c..7e79dcc0 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -1339,6 +1339,9 @@ func TestShouldSkipSilentUpdateCheck(t *testing.T) { if !shouldSkipSilentUpdateCheck(&cobra.Command{Use: "update"}) { t.Fatal("update should skip silent update check") } + if !shouldSkipSilentUpdateCheck(&cobra.Command{Use: "version"}) { + t.Fatal("version should skip silent update check") + } if shouldSkipSilentUpdateCheck(&cobra.Command{Use: "gateway"}) { t.Fatal("gateway should not skip silent update check") } @@ -1432,6 +1435,34 @@ func TestUpdateCommandSkipsSilentUpdateCheck(t *testing.T) { } } +func TestVersionCommandSkipsSilentUpdateCheck(t *testing.T) { + originalSilentCheck := runSilentUpdateCheck + originalRunner := runVersionCommand + t.Cleanup(func() { runSilentUpdateCheck = originalSilentCheck }) + t.Cleanup(func() { runVersionCommand = originalRunner }) + + var called bool + runSilentUpdateCheck = func(context.Context) { + called = true + } + runVersionCommand = func(context.Context, versionCommandOptions) (versionCommandResult, error) { + return versionCommandResult{ + CurrentVersion: "v1.0.0", + LatestVersion: "v1.0.0", + Comparable: true, + }, nil + } + + command := NewRootCommand() + command.SetArgs([]string{"version"}) + if err := command.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext() error = %v", err) + } + if called { + t.Fatal("expected silent update check to be skipped for version command") + } +} + func TestSanitizeVersionForTerminal(t *testing.T) { dirty := "\x1b[31mv0.2.1\x1b[0m\t\n\r\x00" if got := sanitizeVersionForTerminal(dirty); got != "v0.2.1" { diff --git a/internal/cli/version_command.go b/internal/cli/version_command.go new file mode 100644 index 00000000..4f5cdb16 --- /dev/null +++ b/internal/cli/version_command.go @@ -0,0 +1,108 @@ +package cli + +import ( + "context" + "fmt" + "io" + "strings" + "time" + + "github.com/spf13/cobra" + + "neo-code/internal/version" +) + +type versionCommandOptions struct { + IncludePrerelease bool +} + +type versionCommandResult struct { + CurrentVersion string + LatestVersion string + HasUpdate bool + Comparable bool + IncludePrerelease bool + CheckErr error +} + +var runVersionCommand = defaultVersionCommandRunner + +var versionProbeTimeout = 3 * time.Second + +// newVersionCommand 创建 version 子命令并绑定探测参数。 +func newVersionCommand() *cobra.Command { + options := &versionCommandOptions{} + + cmd := &cobra.Command{ + Use: "version", + Short: "Show current version and check for updates", + SilenceUsage: true, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + result, err := runVersionCommand(cmd.Context(), *options) + if err != nil { + return err + } + printVersionCommandResult(cmd.OutOrStdout(), result) + return nil + }, + } + + cmd.Flags().BoolVar(&options.IncludePrerelease, "prerelease", false, "include prerelease versions") + return cmd +} + +// defaultVersionCommandRunner 执行版本探测并构造可展示结果,探测失败不返回执行错误。 +func defaultVersionCommandRunner(ctx context.Context, options versionCommandOptions) (versionCommandResult, error) { + currentVersion := readCurrentVersion() + result := versionCommandResult{ + CurrentVersion: currentVersion, + Comparable: version.IsSemverRelease(currentVersion), + IncludePrerelease: options.IncludePrerelease, + } + + probe, err := runReleaseProbe(ctx, currentVersion, options.IncludePrerelease, versionProbeTimeout) + if err != nil { + result.CheckErr = err + return result, nil + } + + result.LatestVersion = strings.TrimSpace(probe.LatestVersion) + if result.Comparable { + result.HasUpdate = probe.HasUpdate + } + return result, nil +} + +// printVersionCommandResult 渲染 version 命令的输出文案,保证故障场景退出码仍为 0。 +func printVersionCommandResult(out io.Writer, result versionCommandResult) { + current := displayVersionForTerminal(result.CurrentVersion) + _, _ = fmt.Fprintf(out, "Current version: %s\n", current) + + label := "Latest stable version" + if result.IncludePrerelease { + label = "Latest version (including prerelease)" + } + + if result.CheckErr != nil { + _, _ = fmt.Fprintf(out, "%s: check failed (%v)\n", label, result.CheckErr) + return + } + + latest := displayVersionForTerminal(result.LatestVersion) + _, _ = fmt.Fprintf(out, "%s: %s\n", label, latest) + + if !result.Comparable { + _, _ = fmt.Fprintln(out, "Comparison skipped: current build is non-semver.") + return + } + if latest == "unknown" { + _, _ = fmt.Fprintln(out, "Update status: unknown (latest version unavailable).") + return + } + if result.HasUpdate { + _, _ = fmt.Fprintln(out, "Update available: run neocode update") + return + } + _, _ = fmt.Fprintln(out, "You are on the latest version.") +} diff --git a/internal/cli/version_command_test.go b/internal/cli/version_command_test.go new file mode 100644 index 00000000..cbbe5206 --- /dev/null +++ b/internal/cli/version_command_test.go @@ -0,0 +1,220 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + "time" + + "neo-code/internal/updater" +) + +func TestVersionCommandPassesPrereleaseFlag(t *testing.T) { + originalRunner := runVersionCommand + originalPreload := runGlobalPreload + originalSilentCheck := runSilentUpdateCheck + t.Cleanup(func() { runVersionCommand = originalRunner }) + t.Cleanup(func() { runGlobalPreload = originalPreload }) + t.Cleanup(func() { runSilentUpdateCheck = originalSilentCheck }) + + runGlobalPreload = func(context.Context) error { return nil } + runSilentUpdateCheck = func(context.Context) {} + + var received versionCommandOptions + runVersionCommand = func(_ context.Context, options versionCommandOptions) (versionCommandResult, error) { + received = options + return versionCommandResult{ + CurrentVersion: "v1.0.0", + LatestVersion: "v1.0.0", + Comparable: true, + HasUpdate: false, + }, nil + } + + command := NewRootCommand() + command.SetArgs([]string{"version", "--prerelease"}) + var stdout bytes.Buffer + command.SetOut(&stdout) + if err := command.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext() error = %v", err) + } + if !received.IncludePrerelease { + t.Fatal("expected IncludePrerelease to be true") + } + if stdout.Len() == 0 { + t.Fatal("expected output") + } +} + +func TestVersionCommandShowsUpdateAvailable(t *testing.T) { + originalRunner := runVersionCommand + originalPreload := runGlobalPreload + originalSilentCheck := runSilentUpdateCheck + t.Cleanup(func() { runVersionCommand = originalRunner }) + t.Cleanup(func() { runGlobalPreload = originalPreload }) + t.Cleanup(func() { runSilentUpdateCheck = originalSilentCheck }) + + runGlobalPreload = func(context.Context) error { return nil } + 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, + }, nil + } + + command := NewRootCommand() + command.SetArgs([]string{"version"}) + var stdout bytes.Buffer + command.SetOut(&stdout) + if err := command.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext() error = %v", err) + } + output := stdout.String() + if !strings.Contains(output, "Current version: v1.0.0") { + t.Fatalf("output = %q, want current version", output) + } + if !strings.Contains(output, "Latest stable version: v1.2.0") { + t.Fatalf("output = %q, want latest version", output) + } + if !strings.Contains(output, "Update available: run neocode update") { + t.Fatalf("output = %q, want update guidance", output) + } +} + +func TestVersionCommandShowsUpToDate(t *testing.T) { + originalRunner := runVersionCommand + originalPreload := runGlobalPreload + originalSilentCheck := runSilentUpdateCheck + t.Cleanup(func() { runVersionCommand = originalRunner }) + t.Cleanup(func() { runGlobalPreload = originalPreload }) + t.Cleanup(func() { runSilentUpdateCheck = originalSilentCheck }) + + runGlobalPreload = func(context.Context) error { return nil } + 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, + }, nil + } + + command := NewRootCommand() + command.SetArgs([]string{"version"}) + var stdout bytes.Buffer + command.SetOut(&stdout) + if err := command.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext() error = %v", err) + } + if !strings.Contains(stdout.String(), "You are on the latest version.") { + t.Fatalf("output = %q, want up-to-date message", stdout.String()) + } +} + +func TestVersionCommandCheckFailureDoesNotFailCommand(t *testing.T) { + originalRunner := runVersionCommand + originalPreload := runGlobalPreload + originalSilentCheck := runSilentUpdateCheck + t.Cleanup(func() { runVersionCommand = originalRunner }) + t.Cleanup(func() { runGlobalPreload = originalPreload }) + t.Cleanup(func() { runSilentUpdateCheck = originalSilentCheck }) + + runGlobalPreload = func(context.Context) error { return nil } + runSilentUpdateCheck = func(context.Context) {} + runVersionCommand = func(context.Context, versionCommandOptions) (versionCommandResult, error) { + return versionCommandResult{ + CurrentVersion: "v1.2.0", + CheckErr: errors.New("network down"), + }, nil + } + + command := NewRootCommand() + command.SetArgs([]string{"version"}) + var stdout bytes.Buffer + command.SetOut(&stdout) + if err := command.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext() error = %v", err) + } + if !strings.Contains(stdout.String(), "check failed") { + t.Fatalf("output = %q, want check failure", stdout.String()) + } +} + +func TestVersionCommandSkipsComparisonForNonSemver(t *testing.T) { + originalRunner := runVersionCommand + originalPreload := runGlobalPreload + originalSilentCheck := runSilentUpdateCheck + t.Cleanup(func() { runVersionCommand = originalRunner }) + t.Cleanup(func() { runGlobalPreload = originalPreload }) + t.Cleanup(func() { runSilentUpdateCheck = originalSilentCheck }) + + runGlobalPreload = func(context.Context) error { return nil } + runSilentUpdateCheck = func(context.Context) {} + runVersionCommand = func(context.Context, versionCommandOptions) (versionCommandResult, error) { + return versionCommandResult{ + CurrentVersion: "dev", + LatestVersion: "v1.2.0", + Comparable: false, + }, nil + } + + command := NewRootCommand() + command.SetArgs([]string{"version"}) + var stdout bytes.Buffer + command.SetOut(&stdout) + if err := command.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext() error = %v", err) + } + if !strings.Contains(stdout.String(), "Comparison skipped: current build is non-semver.") { + t.Fatalf("output = %q, want non-semver message", stdout.String()) + } +} + +func TestDefaultVersionCommandRunnerUsesProbeOptions(t *testing.T) { + originalProbe := runReleaseProbe + originalReader := readCurrentVersion + originalTimeout := versionProbeTimeout + t.Cleanup(func() { runReleaseProbe = originalProbe }) + t.Cleanup(func() { readCurrentVersion = originalReader }) + t.Cleanup(func() { versionProbeTimeout = originalTimeout }) + + readCurrentVersion = func() string { return "v1.0.0" } + versionProbeTimeout = 2 * time.Second + + var capturedCurrent string + var capturedIncludePrerelease bool + var capturedTimeout time.Duration + runReleaseProbe = func(ctx context.Context, currentVersion string, includePrerelease bool, timeout time.Duration) (updater.CheckResult, error) { + capturedCurrent = currentVersion + capturedIncludePrerelease = includePrerelease + capturedTimeout = timeout + return updater.CheckResult{ + CurrentVersion: "v1.0.0", + LatestVersion: "v1.1.0", + HasUpdate: true, + }, nil + } + + result, err := defaultVersionCommandRunner(context.Background(), versionCommandOptions{IncludePrerelease: true}) + if err != nil { + t.Fatalf("defaultVersionCommandRunner() error = %v", err) + } + if capturedCurrent != "v1.0.0" { + t.Fatalf("captured current version = %q, want %q", capturedCurrent, "v1.0.0") + } + if !capturedIncludePrerelease { + t.Fatal("expected include prerelease to be forwarded") + } + if capturedTimeout != 2*time.Second { + t.Fatalf("captured timeout = %s, want %s", capturedTimeout, 2*time.Second) + } + if !result.HasUpdate || result.LatestVersion != "v1.1.0" { + t.Fatalf("unexpected result: %+v", result) + } +} diff --git a/internal/cli/version_probe.go b/internal/cli/version_probe.go new file mode 100644 index 00000000..4daf823e --- /dev/null +++ b/internal/cli/version_probe.go @@ -0,0 +1,31 @@ +package cli + +import ( + "context" + "time" + + "neo-code/internal/updater" +) + +// defaultReleaseProbe 统一封装版本探测的超时控制与参数透传。 +func defaultReleaseProbe( + ctx context.Context, + currentVersion string, + includePrerelease bool, + timeout time.Duration, +) (updater.CheckResult, error) { + if timeout <= 0 { + return checkLatestRelease(ctx, updater.CheckOptions{ + CurrentVersion: currentVersion, + IncludePrerelease: includePrerelease, + }) + } + + checkCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + return checkLatestRelease(checkCtx, updater.CheckOptions{ + CurrentVersion: currentVersion, + IncludePrerelease: includePrerelease, + }) +} diff --git a/internal/cli/version_probe_test.go b/internal/cli/version_probe_test.go new file mode 100644 index 00000000..45222dec --- /dev/null +++ b/internal/cli/version_probe_test.go @@ -0,0 +1,56 @@ +package cli + +import ( + "context" + "errors" + "testing" + "time" + + "neo-code/internal/updater" +) + +func TestDefaultReleaseProbePassesOptions(t *testing.T) { + originalCheckLatest := checkLatestRelease + t.Cleanup(func() { checkLatestRelease = originalCheckLatest }) + + var capturedCurrent string + var capturedIncludePrerelease bool + checkLatestRelease = func(ctx context.Context, options updater.CheckOptions) (updater.CheckResult, error) { + capturedCurrent = options.CurrentVersion + capturedIncludePrerelease = options.IncludePrerelease + return updater.CheckResult{ + CurrentVersion: options.CurrentVersion, + LatestVersion: "v1.2.0", + HasUpdate: true, + }, nil + } + + result, err := defaultReleaseProbe(context.Background(), "v1.0.0", true, time.Second) + if err != nil { + t.Fatalf("defaultReleaseProbe() error = %v", err) + } + if capturedCurrent != "v1.0.0" { + t.Fatalf("captured current version = %q, want %q", capturedCurrent, "v1.0.0") + } + if !capturedIncludePrerelease { + t.Fatal("expected include prerelease flag to be forwarded") + } + if !result.HasUpdate || result.LatestVersion != "v1.2.0" { + t.Fatalf("unexpected result: %+v", result) + } +} + +func TestDefaultReleaseProbeReturnsContextTimeoutError(t *testing.T) { + originalCheckLatest := checkLatestRelease + t.Cleanup(func() { checkLatestRelease = originalCheckLatest }) + + checkLatestRelease = func(ctx context.Context, options updater.CheckOptions) (updater.CheckResult, error) { + <-ctx.Done() + return updater.CheckResult{}, ctx.Err() + } + + _, err := defaultReleaseProbe(context.Background(), "v1.0.0", false, 10*time.Millisecond) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("defaultReleaseProbe() error = %v, want context deadline exceeded", err) + } +} diff --git a/internal/updater/updater.go b/internal/updater/updater.go index 4166b643..2321b71f 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -6,8 +6,10 @@ import ( "fmt" "regexp" "runtime" + "sort" "strings" + "github.com/Masterminds/semver/v3" selfupdate "github.com/creativeprojects/go-selfupdate" "neo-code/internal/version" @@ -17,6 +19,9 @@ const ( repositoryOwner = "1024XEngineer" repositoryName = "neo-code" checksumFilename = "checksums.txt" + + maxDiagnosticCandidateAssets = 10 + maxDiagnosticAssetNameLength = 120 ) var ( @@ -26,15 +31,31 @@ var ( var ( newClient = func(config selfupdate.Config) (updateClient, error) { + source := config.Source + if source == nil { + created, err := selfupdate.NewGitHubSource(selfupdate.GitHubConfig{}) + if err != nil { + return nil, err + } + source = created + } + config.Source = source + updater, err := selfupdate.NewUpdater(config) if err != nil { return nil, err } - return selfupdateClient{updater: updater}, nil + return selfupdateClient{ + updater: updater, + source: source, + config: config, + }, nil } resolveExecutablePath = selfupdate.ExecutablePath ) +var semverTagPattern = regexp.MustCompile(`\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?`) + type assetTarget struct { OSToken string ArchToken string @@ -42,31 +63,56 @@ type assetTarget struct { AssetName string } +type probeStatus uint8 + +const ( + probeStatusMatched probeStatus = iota + 1 + probeStatusNoCandidate + probeStatusAmbiguous +) + +type probeResult struct { + Status probeStatus + Release releaseView + LatestVersion string + ExpectedPattern string + AvailableAssetsCount int + CandidateAssets []string +} + type releaseView interface { Version() string GreaterThan(other string) bool } type updateClient interface { - DetectLatest(ctx context.Context, repository selfupdate.Repository) (releaseView, bool, error) + ProbeLatest(ctx context.Context, repository selfupdate.Repository, target assetTarget) (probeResult, error) UpdateTo(ctx context.Context, rel releaseView, cmdPath string) error } type selfupdateClient struct { updater *selfupdate.Updater + source selfupdate.Source + config selfupdate.Config } type selfupdateRelease struct { release *selfupdate.Release } -// CheckOptions 描述静默检测新版本时的输入参数。 +type releaseSnapshot struct { + Release selfupdate.SourceRelease + Version *semver.Version + MatchedAssets []selfupdate.SourceAsset +} + +// CheckOptions 描述静默探测最新版本时的输入参数。 type CheckOptions struct { CurrentVersion string IncludePrerelease bool } -// CheckResult 表示静默检测流程返回的版本信息。 +// CheckResult 表示静默探测流程返回的版本信息。 type CheckResult struct { CurrentVersion string LatestVersion string @@ -86,7 +132,7 @@ type UpdateResult struct { Updated bool } -// CheckLatest 按当前平台资产规则检测最新版本,不做本地文件替换。 +// CheckLatest 按当前平台语义规则探测最新版本,不做本地文件替换。 func CheckLatest(ctx context.Context, opts CheckOptions) (CheckResult, error) { currentVersion := normalizeCurrentVersion(opts.CurrentVersion) target, err := resolveAssetTarget(runtimeGOOS, runtimeGOARCH) @@ -100,23 +146,24 @@ func CheckLatest(ctx context.Context, opts CheckOptions) (CheckResult, error) { } repository := selfupdate.NewRepositorySlug(repositoryOwner, repositoryName) - release, found, err := client.DetectLatest(ctx, repository) + probe, err := client.ProbeLatest(ctx, repository, target) if err != nil { return CheckResult{CurrentVersion: currentVersion}, err } - result := CheckResult{CurrentVersion: currentVersion} - if !found || release == nil { - return result, nil + result := CheckResult{ + CurrentVersion: currentVersion, + LatestVersion: strings.TrimSpace(probe.LatestVersion), } - - result.LatestVersion = strings.TrimSpace(release.Version()) if result.LatestVersion == "" { return result, nil } + if probe.Status != probeStatusMatched || probe.Release == nil { + return result, nil + } if version.IsSemverRelease(currentVersion) { - result.HasUpdate = release.GreaterThan(currentVersion) + result.HasUpdate = probe.Release.GreaterThan(currentVersion) } return result, nil } @@ -135,21 +182,30 @@ func DoUpdate(ctx context.Context, opts UpdateOptions) (UpdateResult, error) { } repository := selfupdate.NewRepositorySlug(repositoryOwner, repositoryName) - release, found, err := client.DetectLatest(ctx, repository) + probe, err := client.ProbeLatest(ctx, repository, target) if err != nil { return UpdateResult{CurrentVersion: currentVersion}, err } - if !found || release == nil { - return UpdateResult{CurrentVersion: currentVersion}, errors.New("updater: no release asset found for current platform") - } - latestVersion := strings.TrimSpace(release.Version()) result := UpdateResult{ CurrentVersion: currentVersion, - LatestVersion: latestVersion, + LatestVersion: strings.TrimSpace(probe.LatestVersion), + } + + switch probe.Status { + case probeStatusNoCandidate: + return result, newAssetDiagnosticError("updater: no release asset found for current platform", target, probe) + case probeStatusAmbiguous: + return result, newAssetDiagnosticError("updater: multiple release assets matched current platform", target, probe) + case probeStatusMatched: + if probe.Release == nil { + return result, newAssetDiagnosticError("updater: no release asset found for current platform", target, probe) + } + default: + return result, newAssetDiagnosticError("updater: no release asset found for current platform", target, probe) } - if version.IsSemverRelease(currentVersion) && !release.GreaterThan(currentVersion) { + if version.IsSemverRelease(currentVersion) && !probe.Release.GreaterThan(currentVersion) { return result, nil } @@ -158,7 +214,7 @@ func DoUpdate(ctx context.Context, opts UpdateOptions) (UpdateResult, error) { return result, err } - if err := client.UpdateTo(ctx, release, executablePath); err != nil { + if err := client.UpdateTo(ctx, probe.Release, executablePath); err != nil { return result, err } @@ -166,6 +222,85 @@ func DoUpdate(ctx context.Context, opts UpdateOptions) (UpdateResult, error) { return result, nil } +// ProbeLatest 以平台语义匹配策略探测最新可用资产,并输出可诊断元数据。 +func (c selfupdateClient) ProbeLatest( + ctx context.Context, + repository selfupdate.Repository, + target assetTarget, +) (probeResult, error) { + result := probeResult{ + Status: probeStatusNoCandidate, + ExpectedPattern: buildExpectedPattern(target), + } + + releases, err := c.source.ListReleases(ctx, repository) + if err != nil { + return result, err + } + + matcher := regexp.MustCompile(result.ExpectedPattern) + var latestEligible *releaseSnapshot + var latestMatched *releaseSnapshot + + for _, rel := range releases { + snapshot, ok := buildReleaseSnapshot(rel, c.config.Prerelease, matcher) + if !ok { + continue + } + if latestEligible == nil || snapshot.Version.GreaterThan(latestEligible.Version) { + latestEligible = snapshot + } + if len(snapshot.MatchedAssets) == 0 { + continue + } + if latestMatched == nil || snapshot.Version.GreaterThan(latestMatched.Version) { + latestMatched = snapshot + } + } + + if latestEligible != nil { + result.LatestVersion = latestEligible.Version.String() + } + if latestMatched == nil { + if latestEligible != nil { + allAssets := collectAssetNames(latestEligible.Release.GetAssets()) + result.AvailableAssetsCount = len(allAssets) + result.CandidateAssets = sampleAssetsForDiagnostic(allAssets) + } + return result, nil + } + + result.LatestVersion = latestMatched.Version.String() + result.AvailableAssetsCount = len(latestMatched.Release.GetAssets()) + + matchedNames := collectAssetNames(latestMatched.MatchedAssets) + result.CandidateAssets = sampleAssetsForDiagnostic(matchedNames) + if len(latestMatched.MatchedAssets) > 1 { + result.Status = probeStatusAmbiguous + return result, nil + } + + chosenAsset := firstNonEmptyAssetName(latestMatched.MatchedAssets) + release, found, err := c.detectReleaseByTagAndAsset( + ctx, + repository, + latestMatched.Release.GetTagName(), + chosenAsset, + target, + ) + if err != nil { + return result, err + } + if !found || release == nil { + return result, nil + } + + result.Status = probeStatusMatched + result.Release = release + result.LatestVersion = strings.TrimSpace(release.Version()) + return result, nil +} + // DetectLatest 调用底层 go-selfupdate 客户端获取最新版本信息。 func (c selfupdateClient) DetectLatest(ctx context.Context, repository selfupdate.Repository) (releaseView, bool, error) { release, found, err := c.updater.DetectLatest(ctx, repository) @@ -184,6 +319,44 @@ func (c selfupdateClient) UpdateTo(ctx context.Context, rel releaseView, cmdPath return c.updater.UpdateTo(ctx, typed.release, cmdPath) } +// detectReleaseByTagAndAsset 在已确定 tag 和资产名后,按精确资产过滤拿到可下载 release。 +func (c selfupdateClient) detectReleaseByTagAndAsset( + ctx context.Context, + repository selfupdate.Repository, + tagName string, + assetName string, + target assetTarget, +) (releaseView, bool, error) { + cleanTag := strings.TrimSpace(tagName) + cleanAsset := strings.ToLower(strings.TrimSpace(assetName)) + if cleanTag == "" || cleanAsset == "" { + return nil, false, nil + } + + config := selfupdate.Config{ + Source: c.source, + Validator: c.config.Validator, + Filters: []string{"^" + regexp.QuoteMeta(cleanAsset) + "$"}, + OS: target.OSToken, + Arch: target.ArchToken, + Arm: c.config.Arm, + UniversalArch: c.config.UniversalArch, + Draft: c.config.Draft, + Prerelease: c.config.Prerelease, + OldSavePath: c.config.OldSavePath, + } + updater, err := selfupdate.NewUpdater(config) + if err != nil { + return nil, false, err + } + + release, found, err := updater.DetectVersion(ctx, repository, cleanTag) + if err != nil || !found || release == nil { + return nil, found, err + } + return selfupdateRelease{release: release}, true, nil +} + // Version 返回底层 release 的语义化版本字符串。 func (r selfupdateRelease) Version() string { return strings.TrimSpace(r.release.Version()) @@ -203,27 +376,26 @@ func normalizeCurrentVersion(value string) string { return trimmed } -// buildSelfupdateConfig 构建严格资产匹配与 checksum 校验配置。 +// buildSelfupdateConfig 构建更新客户端配置,默认按平台语义匹配,不绑定精确资产名。 func buildSelfupdateConfig(target assetTarget, includePrerelease bool) selfupdate.Config { return selfupdate.Config{ OS: target.OSToken, Arch: target.ArchToken, - Filters: []string{"^" + regexp.QuoteMeta(target.AssetName) + "$"}, Validator: &selfupdate.ChecksumValidator{UniqueFilename: checksumFilename}, Prerelease: includePrerelease, } } -// resolveAssetTarget 按 GoReleaser 产物命名约束生成当前平台目标资产名。 +// resolveAssetTarget 按平台信息归一化出资产语义匹配目标。 func resolveAssetTarget(goos string, goarch string) (assetTarget, error) { var osToken string switch strings.ToLower(strings.TrimSpace(goos)) { case "linux": - osToken = "Linux" + osToken = "linux" case "darwin": - osToken = "Darwin" + osToken = "darwin" case "windows": - osToken = "Windows" + osToken = "windows" default: return assetTarget{}, fmt.Errorf("updater: unsupported os %q", goos) } @@ -239,7 +411,7 @@ func resolveAssetTarget(goos string, goarch string) (assetTarget, error) { } ext := "tar.gz" - if osToken == "Windows" { + if osToken == "windows" { ext = "zip" } @@ -250,3 +422,185 @@ func resolveAssetTarget(goos string, goarch string) (assetTarget, error) { AssetName: fmt.Sprintf("neocode_%s_%s.%s", osToken, archToken, ext), }, nil } + +// buildReleaseSnapshot 将 source release 归一化为可比较快照,并过滤不可用发布。 +func buildReleaseSnapshot( + release selfupdate.SourceRelease, + includePrerelease bool, + matcher *regexp.Regexp, +) (*releaseSnapshot, bool) { + if release == nil || release.GetDraft() { + return nil, false + } + if release.GetPrerelease() && !includePrerelease { + return nil, false + } + + parsedVersion, ok := parseReleaseVersion(release.GetTagName()) + if !ok { + return nil, false + } + + matched := make([]selfupdate.SourceAsset, 0, len(release.GetAssets())) + for _, asset := range release.GetAssets() { + name := strings.ToLower(strings.TrimSpace(assetName(asset))) + if name == "" || !matcher.MatchString(name) { + continue + } + matched = append(matched, asset) + } + + return &releaseSnapshot{ + Release: release, + Version: parsedVersion, + MatchedAssets: matched, + }, true +} + +// parseReleaseVersion 从 tag 中提取可比较语义化版本,兼容前缀字符。 +func parseReleaseVersion(tag string) (*semver.Version, bool) { + trimmed := strings.TrimSpace(tag) + if trimmed == "" { + return nil, false + } + raw := semverTagPattern.FindString(trimmed) + if raw == "" { + return nil, false + } + parsed, err := semver.NewVersion(raw) + if err != nil { + return nil, false + } + return parsed, true +} + +// buildExpectedPattern 构建平台语义匹配模式,允许大小写和分隔符变体。 +func buildExpectedPattern(target assetTarget) string { + osPattern := platformAliasPattern(target.OSToken) + archPattern := archAliasPattern(target.ArchToken) + extPattern := extAliasPattern(target.Ext) + return fmt.Sprintf( + `^neocode[-_]%s[-_]%s(?:[-_.][0-9a-z]+)*(?:\.exe)?(?:%s)$`, + osPattern, + archPattern, + extPattern, + ) +} + +// platformAliasPattern 返回平台别名匹配表达式。 +func platformAliasPattern(osToken string) string { + switch strings.ToLower(strings.TrimSpace(osToken)) { + case "windows": + return `(?:windows|win)` + case "darwin": + return `(?:darwin|macos)` + default: + return regexp.QuoteMeta(strings.ToLower(strings.TrimSpace(osToken))) + } +} + +// archAliasPattern 返回架构别名匹配表达式。 +func archAliasPattern(arch string) string { + switch strings.ToLower(strings.TrimSpace(arch)) { + case "x86_64": + return `(?:x86_64|x86-64|amd64)` + case "arm64": + return `(?:arm64|aarch64)` + default: + return regexp.QuoteMeta(strings.ToLower(strings.TrimSpace(arch))) + } +} + +// extAliasPattern 返回归档扩展名匹配表达式。 +func extAliasPattern(ext string) string { + switch strings.ToLower(strings.TrimSpace(ext)) { + case "tar.gz": + return `(?:\.tar\.gz|\.tgz)` + default: + cleaned := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(ext)), ".") + return `(?:\.` + regexp.QuoteMeta(cleaned) + `)` + } +} + +// newAssetDiagnosticError 生成包含平台与候选信息的可执行诊断错误。 +func newAssetDiagnosticError(message string, target assetTarget, probe probeResult) error { + return fmt.Errorf( + `%s (os=%s arch=%s expected-pattern="%s" available-assets-count=%d candidate-assets=%v)`, + message, + target.OSToken, + target.ArchToken, + probe.ExpectedPattern, + probe.AvailableAssetsCount, + probe.CandidateAssets, + ) +} + +// collectAssetNames 提取并排序资产名,便于稳定输出诊断信息。 +func collectAssetNames(assets []selfupdate.SourceAsset) []string { + names := make([]string, 0, len(assets)) + for _, asset := range assets { + name := strings.TrimSpace(assetName(asset)) + if name == "" { + continue + } + names = append(names, name) + } + sort.Strings(names) + return names +} + +// sampleAssetsForDiagnostic 按上限截断候选资产,防止诊断输出失控。 +func sampleAssetsForDiagnostic(names []string) []string { + sampled := make([]string, 0, minInt(len(names), maxDiagnosticCandidateAssets)) + for _, name := range names { + if len(sampled) >= maxDiagnosticCandidateAssets { + break + } + sampled = append(sampled, trimDiagnosticAssetName(name)) + } + return sampled +} + +// trimDiagnosticAssetName 对候选资产名按长度截断,控制日志噪声。 +func trimDiagnosticAssetName(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return trimmed + } + runes := []rune(trimmed) + if len(runes) <= maxDiagnosticAssetNameLength { + return trimmed + } + return string(runes[:maxDiagnosticAssetNameLength]) + "..." +} + +// firstNonEmptyAssetName 返回第一个可用资产名,用于二次精确探测。 +func firstNonEmptyAssetName(assets []selfupdate.SourceAsset) string { + for _, asset := range assets { + name := strings.TrimSpace(assetName(asset)) + if name != "" { + return name + } + } + return "" +} + +// assetName 统一提取资产展示名,优先使用资产名,缺失时回退下载 URL。 +func assetName(asset selfupdate.SourceAsset) string { + if asset == nil { + return "" + } + name := strings.TrimSpace(asset.GetName()) + if name != "" { + return name + } + return strings.TrimSpace(asset.GetBrowserDownloadURL()) +} + +// minInt 返回两个整数中的较小值。 +func minInt(a int, b int) int { + if a < b { + return a + } + return b +} diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go index b01f986c..3c44e626 100644 --- a/internal/updater/updater_test.go +++ b/internal/updater/updater_test.go @@ -4,8 +4,10 @@ import ( "bytes" "context" "errors" + "fmt" "io" "regexp" + "strings" "testing" "time" @@ -29,16 +31,61 @@ func (r fakeRelease) GreaterThan(other string) bool { } type fakeClient struct { - release releaseView - found bool - detectErr error - updateErr error - updateCalls int - lastUpdatePath string + release releaseView + found bool + detectErr error + updateErr error + updateCalls int + lastUpdatePath string + probeStatus probeStatus + probeLatestVersion string + probeExpectedPattern string + probeAvailableAssetSize int + probeCandidates []string } -func (c *fakeClient) DetectLatest(context.Context, selfupdate.Repository) (releaseView, bool, error) { - return c.release, c.found, c.detectErr +func (c *fakeClient) ProbeLatest(_ context.Context, _ selfupdate.Repository, target assetTarget) (probeResult, error) { + if c.detectErr != nil { + return probeResult{}, c.detectErr + } + + expectedPattern := c.probeExpectedPattern + if strings.TrimSpace(expectedPattern) == "" { + expectedPattern = buildExpectedPattern(target) + } + latest := strings.TrimSpace(c.probeLatestVersion) + if latest == "" && c.release != nil { + latest = strings.TrimSpace(c.release.Version()) + } + + if c.probeStatus != 0 { + return probeResult{ + Status: c.probeStatus, + Release: c.release, + LatestVersion: latest, + ExpectedPattern: expectedPattern, + AvailableAssetsCount: c.probeAvailableAssetSize, + CandidateAssets: append([]string(nil), c.probeCandidates...), + }, nil + } + if !c.found || c.release == nil { + return probeResult{ + Status: probeStatusNoCandidate, + LatestVersion: latest, + ExpectedPattern: expectedPattern, + AvailableAssetsCount: c.probeAvailableAssetSize, + CandidateAssets: append([]string(nil), c.probeCandidates...), + }, nil + } + + return probeResult{ + Status: probeStatusMatched, + Release: c.release, + LatestVersion: latest, + ExpectedPattern: expectedPattern, + AvailableAssetsCount: c.probeAvailableAssetSize, + CandidateAssets: append([]string(nil), c.probeCandidates...), + }, nil } func (c *fakeClient) UpdateTo(_ context.Context, rel releaseView, cmdPath string) error { @@ -110,28 +157,28 @@ func TestResolveAssetTarget(t *testing.T) { name: "linux amd64", goos: "linux", goarch: "amd64", - wantOS: "Linux", + wantOS: "linux", wantArch: "x86_64", wantExt: "tar.gz", - wantAsset: "neocode_Linux_x86_64.tar.gz", + wantAsset: "neocode_linux_x86_64.tar.gz", }, { name: "darwin arm64", goos: "darwin", goarch: "arm64", - wantOS: "Darwin", + wantOS: "darwin", wantArch: "arm64", wantExt: "tar.gz", - wantAsset: "neocode_Darwin_arm64.tar.gz", + wantAsset: "neocode_darwin_arm64.tar.gz", }, { name: "windows amd64", goos: "windows", goarch: "amd64", - wantOS: "Windows", + wantOS: "windows", wantArch: "x86_64", wantExt: "zip", - wantAsset: "neocode_Windows_x86_64.zip", + wantAsset: "neocode_windows_x86_64.zip", }, { name: "unsupported os", @@ -175,30 +222,22 @@ func TestResolveAssetTarget(t *testing.T) { } } -func TestBuildSelfupdateConfigUsesExactFilterAndChecksum(t *testing.T) { +func TestBuildSelfupdateConfigUsesSemanticConfigAndChecksum(t *testing.T) { target := assetTarget{ - OSToken: "Darwin", + OSToken: "darwin", ArchToken: "x86_64", Ext: "tar.gz", - AssetName: "neocode_Darwin_x86_64.tar.gz", + AssetName: "neocode_darwin_x86_64.tar.gz", } config := buildSelfupdateConfig(target, true) - if config.OS != "Darwin" || config.Arch != "x86_64" { - t.Fatalf("OS/Arch = %q/%q, want %q/%q", config.OS, config.Arch, "Darwin", "x86_64") + if config.OS != "darwin" || config.Arch != "x86_64" { + t.Fatalf("OS/Arch = %q/%q, want %q/%q", config.OS, config.Arch, "darwin", "x86_64") } if !config.Prerelease { t.Fatal("expected prerelease to be enabled") } - if len(config.Filters) != 1 { - t.Fatalf("len(Filters) = %d, want 1", len(config.Filters)) - } - exactFilter := config.Filters[0] - re := regexp.MustCompile(exactFilter) - if !re.MatchString("neocode_Darwin_x86_64.tar.gz") { - t.Fatal("exact filter should match target asset") - } - if re.MatchString("neocode_Darwin_x86_64.tar.gz.sig") { - t.Fatal("exact filter should not match similar asset names") + if len(config.Filters) != 0 { + t.Fatalf("len(Filters) = %d, want 0", len(config.Filters)) } validator, ok := config.Validator.(*selfupdate.ChecksumValidator) if !ok { @@ -444,8 +483,8 @@ func TestDoUpdateUsesUpdaterLibraryPathForWindows(t *testing.T) { if client.lastUpdatePath != `C:\Tools\neocode.exe` { t.Fatalf("last update path = %q, want %q", client.lastUpdatePath, `C:\Tools\neocode.exe`) } - if capturedConfig.OS != "Windows" || capturedConfig.Arch != "x86_64" { - t.Fatalf("config OS/Arch = %q/%q, want %q/%q", capturedConfig.OS, capturedConfig.Arch, "Windows", "x86_64") + if capturedConfig.OS != "windows" || capturedConfig.Arch != "x86_64" { + t.Fatalf("config OS/Arch = %q/%q, want %q/%q", capturedConfig.OS, capturedConfig.Arch, "windows", "x86_64") } } @@ -609,6 +648,238 @@ func TestDoUpdateErrorAndEdgeBranches(t *testing.T) { }) } +func TestDoUpdateReturnsDiagnosticWhenNoCandidate(t *testing.T) { + originalNewClient := newClient + originalGOOS := runtimeGOOS + originalGOARCH := runtimeGOARCH + t.Cleanup(func() { + newClient = originalNewClient + runtimeGOOS = originalGOOS + runtimeGOARCH = originalGOARCH + }) + runtimeGOOS = "windows" + runtimeGOARCH = "amd64" + + newClient = func(selfupdate.Config) (updateClient, error) { + return &fakeClient{ + found: false, + probeLatestVersion: "v1.4.0", + probeAvailableAssetSize: 3, + probeCandidates: []string{ + "neocode_Windows_x86_64.zip", + "checksums.txt", + "neocode_Darwin_arm64.tar.gz", + }, + }, nil + } + + _, err := DoUpdate(context.Background(), UpdateOptions{CurrentVersion: "v1.3.0"}) + if err == nil { + t.Fatal("expected diagnostic error") + } + text := err.Error() + if !strings.Contains(text, "no release asset found for current platform") { + t.Fatalf("error = %v, want no release asset found", err) + } + if !strings.Contains(text, "os=windows") || !strings.Contains(text, "arch=x86_64") { + t.Fatalf("error = %v, want os/arch fields", err) + } + if !strings.Contains(text, "expected-pattern=") || !strings.Contains(text, "available-assets-count=3") { + t.Fatalf("error = %v, want diagnostic fields", err) + } + if !strings.Contains(text, "candidate-assets=[neocode_Windows_x86_64.zip checksums.txt") { + t.Fatalf("error = %v, want candidate assets", err) + } +} + +func TestDoUpdateReturnsDiagnosticWhenAmbiguous(t *testing.T) { + originalNewClient := newClient + originalGOOS := runtimeGOOS + originalGOARCH := runtimeGOARCH + t.Cleanup(func() { + newClient = originalNewClient + runtimeGOOS = originalGOOS + runtimeGOARCH = originalGOARCH + }) + runtimeGOOS = "linux" + runtimeGOARCH = "amd64" + + newClient = func(selfupdate.Config) (updateClient, error) { + return &fakeClient{ + release: fakeRelease{ + version: "v1.4.0", + }, + probeStatus: probeStatusAmbiguous, + probeLatestVersion: "v1.4.0", + probeAvailableAssetSize: 4, + probeCandidates: []string{ + "neocode_linux_x86_64.tar.gz", + "neocode-linux-amd64.tgz", + }, + }, nil + } + + _, err := DoUpdate(context.Background(), UpdateOptions{CurrentVersion: "v1.3.0"}) + if err == nil { + t.Fatal("expected diagnostic error") + } + text := err.Error() + if !strings.Contains(text, "multiple release assets matched current platform") { + t.Fatalf("error = %v, want ambiguous message", err) + } + if !strings.Contains(text, "available-assets-count=4") { + t.Fatalf("error = %v, want available assets count", err) + } + if !strings.Contains(text, "candidate-assets=[neocode_linux_x86_64.tar.gz neocode-linux-amd64.tgz]") { + t.Fatalf("error = %v, want candidate assets", err) + } +} + +func TestBuildExpectedPatternMatchesNamingVariants(t *testing.T) { + target := assetTarget{ + OSToken: "windows", + ArchToken: "x86_64", + Ext: "zip", + } + matcher := regexp.MustCompile(buildExpectedPattern(target)) + cases := []struct { + name string + asset string + match bool + }{ + {name: "underscore", asset: "neocode_Windows_x86_64.zip", match: true}, + {name: "hyphen amd64", asset: "neocode-windows-amd64.zip", match: true}, + {name: "extra suffix", asset: "neocode_win_x86-64_rc1.zip", match: true}, + {name: "invalid arch", asset: "neocode_windows_arm64.zip", match: false}, + {name: "invalid ext", asset: "neocode_windows_x86_64.tar.gz", match: false}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + got := matcher.MatchString(strings.ToLower(tt.asset)) + if got != tt.match { + t.Fatalf("MatchString(%q) = %v, want %v", tt.asset, got, tt.match) + } + }) + } +} + +func TestSampleAssetsForDiagnosticTruncatesByCountAndLength(t *testing.T) { + longName := "neocode_windows_x86_64_" + strings.Repeat("a", 200) + ".zip" + input := make([]string, 0, 12) + input = append(input, longName) + for i := 0; i < 11; i++ { + input = append(input, fmt.Sprintf("asset-%02d", i)) + } + + sampled := sampleAssetsForDiagnostic(input) + if len(sampled) != maxDiagnosticCandidateAssets { + t.Fatalf("len(sampled) = %d, want %d", len(sampled), maxDiagnosticCandidateAssets) + } + if !strings.HasSuffix(sampled[0], "...") { + t.Fatalf("sampled[0] = %q, want truncated suffix", sampled[0]) + } +} + +func TestSelfupdateClientProbeLatestForNamingVariantsAndAmbiguity(t *testing.T) { + t.Run("match naming variant", func(t *testing.T) { + source := stubSource{ + releases: []selfupdate.SourceRelease{ + stubSourceRelease{ + id: 1, + tagName: "v1.5.0", + assets: []selfupdate.SourceAsset{ + stubSourceAsset{id: 1, name: "neocode-Windows-amd64.zip", size: 1}, + }, + }, + }, + } + updater, err := selfupdate.NewUpdater(selfupdate.Config{ + Source: source, + OS: "windows", + Arch: "x86_64", + }) + if err != nil { + t.Fatalf("NewUpdater() error = %v", err) + } + + client := selfupdateClient{ + updater: updater, + source: source, + config: selfupdate.Config{ + Source: source, + OS: "windows", + Arch: "x86_64", + }, + } + target := assetTarget{ + OSToken: "windows", + ArchToken: "x86_64", + Ext: "zip", + } + 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("probe status = %v, want matched", probe.Status) + } + if probe.Release == nil { + t.Fatal("expected probe release") + } + if probe.LatestVersion == "" { + t.Fatal("expected latest version") + } + }) + + t.Run("ambiguous assets", func(t *testing.T) { + source := stubSource{ + releases: []selfupdate.SourceRelease{ + stubSourceRelease{ + id: 1, + tagName: "v1.5.0", + assets: []selfupdate.SourceAsset{ + stubSourceAsset{id: 1, name: "neocode_windows_x86_64.zip", size: 1}, + stubSourceAsset{id: 2, name: "neocode-windows-amd64.zip", size: 1}, + }, + }, + }, + } + updater, err := selfupdate.NewUpdater(selfupdate.Config{ + Source: source, + OS: "windows", + Arch: "x86_64", + }) + if err != nil { + t.Fatalf("NewUpdater() error = %v", err) + } + + client := selfupdateClient{ + updater: updater, + source: source, + config: selfupdate.Config{ + Source: source, + OS: "windows", + Arch: "x86_64", + }, + } + target := assetTarget{ + OSToken: "windows", + ArchToken: "x86_64", + Ext: "zip", + } + probe, err := client.ProbeLatest(context.Background(), selfupdate.NewRepositorySlug(repositoryOwner, repositoryName), target) + if err != nil { + t.Fatalf("ProbeLatest() error = %v", err) + } + if probe.Status != probeStatusAmbiguous { + t.Fatalf("probe status = %v, want ambiguous", probe.Status) + } + if len(probe.CandidateAssets) != 2 { + t.Fatalf("len(candidate assets) = %d, want 2", len(probe.CandidateAssets)) + } + }) +} + func TestSelfupdateClientDetectLatestAndUnsupportedUpdateType(t *testing.T) { target := assetTarget{ OSToken: "linux", From 5886b0fd286e33e7f2481a4caa593efef4e4d346 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Thu, 23 Apr 2026 14:24:46 +0000 Subject: [PATCH 2/6] test: improve patch coverage for update/version branches Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: pionxe <148670367+pionxe@users.noreply.github.com> --- internal/cli/version_command_test.go | 99 +++++++++++ internal/cli/version_probe_test.go | 19 +++ internal/updater/updater_test.go | 241 +++++++++++++++++++++++++++ 3 files changed, 359 insertions(+) diff --git a/internal/cli/version_command_test.go b/internal/cli/version_command_test.go index cbbe5206..d77dc9ab 100644 --- a/internal/cli/version_command_test.go +++ b/internal/cli/version_command_test.go @@ -176,6 +176,28 @@ func TestVersionCommandSkipsComparisonForNonSemver(t *testing.T) { } } +func TestVersionCommandPropagatesRunnerError(t *testing.T) { + originalRunner := runVersionCommand + originalPreload := runGlobalPreload + originalSilentCheck := runSilentUpdateCheck + t.Cleanup(func() { runVersionCommand = originalRunner }) + t.Cleanup(func() { runGlobalPreload = originalPreload }) + t.Cleanup(func() { runSilentUpdateCheck = originalSilentCheck }) + + runGlobalPreload = func(context.Context) error { return nil } + runSilentUpdateCheck = func(context.Context) {} + runVersionCommand = func(context.Context, versionCommandOptions) (versionCommandResult, error) { + return versionCommandResult{}, errors.New("runner failed") + } + + command := NewRootCommand() + command.SetArgs([]string{"version"}) + err := command.ExecuteContext(context.Background()) + if err == nil || err.Error() != "runner failed" { + t.Fatalf("ExecuteContext() error = %v, want runner failed", err) + } +} + func TestDefaultVersionCommandRunnerUsesProbeOptions(t *testing.T) { originalProbe := runReleaseProbe originalReader := readCurrentVersion @@ -218,3 +240,80 @@ func TestDefaultVersionCommandRunnerUsesProbeOptions(t *testing.T) { t.Fatalf("unexpected result: %+v", result) } } + +func TestDefaultVersionCommandRunnerCheckFailureReturnsResultWithoutError(t *testing.T) { + originalProbe := runReleaseProbe + originalReader := readCurrentVersion + t.Cleanup(func() { runReleaseProbe = originalProbe }) + t.Cleanup(func() { readCurrentVersion = originalReader }) + + readCurrentVersion = func() string { return "v1.0.0" } + runReleaseProbe = func(context.Context, string, bool, time.Duration) (updater.CheckResult, error) { + return updater.CheckResult{}, errors.New("probe failed") + } + + result, err := defaultVersionCommandRunner(context.Background(), versionCommandOptions{}) + if err != nil { + t.Fatalf("defaultVersionCommandRunner() error = %v", err) + } + if result.CheckErr == nil || result.CheckErr.Error() != "probe failed" { + t.Fatalf("unexpected CheckErr: %v", result.CheckErr) + } +} + +func TestDefaultVersionCommandRunnerTrimsLatestVersionAndSkipsNonSemverCompare(t *testing.T) { + originalProbe := runReleaseProbe + originalReader := readCurrentVersion + t.Cleanup(func() { runReleaseProbe = originalProbe }) + t.Cleanup(func() { readCurrentVersion = originalReader }) + + 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, + }, nil + } + + result, err := defaultVersionCommandRunner(context.Background(), versionCommandOptions{}) + if err != nil { + t.Fatalf("defaultVersionCommandRunner() error = %v", err) + } + if result.LatestVersion != "v1.2.0" { + t.Fatalf("LatestVersion = %q, want %q", result.LatestVersion, "v1.2.0") + } + if result.HasUpdate { + t.Fatalf("HasUpdate = true, want false for non-semver current version") + } +} + +func TestPrintVersionCommandResultBranches(t *testing.T) { + t.Run("prerelease label and unknown latest", func(t *testing.T) { + var out bytes.Buffer + printVersionCommandResult(&out, versionCommandResult{ + CurrentVersion: "v1.0.0", + LatestVersion: "", + Comparable: true, + IncludePrerelease: true, + }) + text := out.String() + if !strings.Contains(text, "Latest version (including prerelease): unknown") { + t.Fatalf("output = %q, want prerelease latest label", text) + } + if !strings.Contains(text, "Update status: unknown (latest version unavailable).") { + t.Fatalf("output = %q, want unknown update status", text) + } + }) + + t.Run("check error uses prerelease label", func(t *testing.T) { + var out bytes.Buffer + printVersionCommandResult(&out, versionCommandResult{ + CurrentVersion: "v1.0.0", + CheckErr: errors.New("network"), + IncludePrerelease: true, + }) + if !strings.Contains(out.String(), "Latest version (including prerelease): check failed (network)") { + t.Fatalf("output = %q, want prerelease check failure", out.String()) + } + }) +} diff --git a/internal/cli/version_probe_test.go b/internal/cli/version_probe_test.go index 45222dec..6b69285a 100644 --- a/internal/cli/version_probe_test.go +++ b/internal/cli/version_probe_test.go @@ -54,3 +54,22 @@ func TestDefaultReleaseProbeReturnsContextTimeoutError(t *testing.T) { t.Fatalf("defaultReleaseProbe() error = %v, want context deadline exceeded", err) } } + +func TestDefaultReleaseProbeWithoutTimeoutUsesOriginalContext(t *testing.T) { + originalCheckLatest := checkLatestRelease + t.Cleanup(func() { checkLatestRelease = originalCheckLatest }) + + var hasDeadline bool + checkLatestRelease = func(ctx context.Context, options updater.CheckOptions) (updater.CheckResult, error) { + _, hasDeadline = ctx.Deadline() + return updater.CheckResult{CurrentVersion: options.CurrentVersion}, nil + } + + _, err := defaultReleaseProbe(context.Background(), "v1.0.0", false, 0) + if err != nil { + t.Fatalf("defaultReleaseProbe() error = %v", err) + } + if hasDeadline { + t.Fatal("expected original context without deadline when timeout <= 0") + } +} diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go index 3c44e626..c256c7be 100644 --- a/internal/updater/updater_test.go +++ b/internal/updater/updater_test.go @@ -142,6 +142,13 @@ func (a stubSourceAsset) GetName() string { return a.name } func (a stubSourceAsset) GetSize() int { return a.size } func (a stubSourceAsset) GetBrowserDownloadURL() string { return "https://example.com/asset" } +type blankSourceAsset struct{} + +func (blankSourceAsset) GetID() int64 { return 0 } +func (blankSourceAsset) GetName() string { return " " } +func (blankSourceAsset) GetSize() int { return 0 } +func (blankSourceAsset) GetBrowserDownloadURL() string { return " " } + func TestResolveAssetTarget(t *testing.T) { tests := []struct { name string @@ -972,3 +979,237 @@ func TestNewClientFactory(t *testing.T) { t.Fatalf("expected non-nil client") } } + +func TestParseReleaseVersionBranches(t *testing.T) { + tests := []struct { + name string + tag string + ok bool + }{ + {name: "empty", tag: " ", ok: false}, + {name: "no semver", tag: "release-latest", ok: false}, + {name: "with prefix", tag: "release/v1.2.3", ok: true}, + {name: "prerelease build", tag: "v1.2.3-rc.1+build.7", ok: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ver, ok := parseReleaseVersion(tt.tag) + if ok != tt.ok { + t.Fatalf("parseReleaseVersion(%q) ok = %v, want %v", tt.tag, ok, tt.ok) + } + if ok && ver == nil { + t.Fatalf("parseReleaseVersion(%q) returned nil version", tt.tag) + } + }) + } +} + +func TestAliasPatternHelpers(t *testing.T) { + if got := platformAliasPattern("windows"); got != `(?:windows|win)` { + t.Fatalf("platformAliasPattern(windows) = %q", got) + } + if got := platformAliasPattern("darwin"); got != `(?:darwin|macos)` { + t.Fatalf("platformAliasPattern(darwin) = %q", got) + } + if got := platformAliasPattern(" Linux "); got != `linux` { + t.Fatalf("platformAliasPattern(linux) = %q, want linux", got) + } + + if got := archAliasPattern("x86_64"); got != `(?:x86_64|x86-64|amd64)` { + t.Fatalf("archAliasPattern(x86_64) = %q", got) + } + if got := archAliasPattern("arm64"); got != `(?:arm64|aarch64)` { + t.Fatalf("archAliasPattern(arm64) = %q", got) + } + if got := archAliasPattern(" riscv64 "); got != `riscv64` { + t.Fatalf("archAliasPattern(riscv64) = %q, want riscv64", got) + } +} + +func TestBuildReleaseSnapshotFilters(t *testing.T) { + matcher := regexp.MustCompile(`^neocode[-_]linux[-_](?:x86_64|amd64)(?:\.tar\.gz|\.tgz)$`) + + if _, ok := buildReleaseSnapshot(nil, false, matcher); ok { + t.Fatal("expected nil release to be filtered") + } + + draft := stubSourceRelease{draft: true, tagName: "v1.2.3"} + if _, ok := buildReleaseSnapshot(draft, false, matcher); ok { + t.Fatal("expected draft release to be filtered") + } + + pre := stubSourceRelease{prerelease: true, tagName: "v1.2.3"} + if _, ok := buildReleaseSnapshot(pre, false, matcher); ok { + t.Fatal("expected prerelease to be filtered when includePrerelease=false") + } + + invalidTag := stubSourceRelease{tagName: "latest"} + if _, ok := buildReleaseSnapshot(invalidTag, true, matcher); ok { + t.Fatal("expected non-semver tag to be filtered") + } + + release := stubSourceRelease{ + tagName: "v1.2.3", + assets: []selfupdate.SourceAsset{ + stubSourceAsset{name: "neocode_linux_amd64.tar.gz"}, + stubSourceAsset{name: "checksums.txt"}, + }, + } + snapshot, ok := buildReleaseSnapshot(release, true, matcher) + if !ok || snapshot == nil { + t.Fatal("expected valid snapshot") + } + if len(snapshot.MatchedAssets) != 1 { + t.Fatalf("len(MatchedAssets) = %d, want 1", len(snapshot.MatchedAssets)) + } +} + +func TestSelfupdateClientProbeLatestNoMatchedAssetReturnsEligibleDiagnostic(t *testing.T) { + source := stubSource{ + releases: []selfupdate.SourceRelease{ + stubSourceRelease{ + id: 1, + tagName: "v1.8.0", + assets: []selfupdate.SourceAsset{ + stubSourceAsset{id: 1, name: "checksums.txt", size: 1}, + stubSourceAsset{id: 2, name: "readme.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 != probeStatusNoCandidate { + t.Fatalf("Status = %v, want no-candidate", probe.Status) + } + if probe.LatestVersion != "1.8.0" { + t.Fatalf("LatestVersion = %q, want %q", probe.LatestVersion, "1.8.0") + } + if probe.AvailableAssetsCount != 2 { + t.Fatalf("AvailableAssetsCount = %d, want 2", probe.AvailableAssetsCount) + } + if len(probe.CandidateAssets) != 2 { + t.Fatalf("len(CandidateAssets) = %d, want 2", len(probe.CandidateAssets)) + } +} + +func TestSelfupdateClientProbeLatestListReleasesError(t *testing.T) { + client := selfupdateClient{ + source: stubSource{listErr: errors.New("list failed")}, + config: selfupdate.Config{OS: "linux", Arch: "x86_64"}, + } + + target := assetTarget{OSToken: "linux", ArchToken: "x86_64", Ext: "tar.gz"} + _, err := client.ProbeLatest(context.Background(), selfupdate.NewRepositorySlug(repositoryOwner, repositoryName), target) + if err == nil || err.Error() != "list failed" { + t.Fatalf("ProbeLatest() error = %v, want list failed", err) + } +} + +func TestDetectReleaseByTagAndAssetBranches(t *testing.T) { + source := stubSource{ + releases: []selfupdate.SourceRelease{ + stubSourceRelease{ + id: 1, + tagName: "v1.0.0", + assets: []selfupdate.SourceAsset{ + stubSourceAsset{id: 1, name: "neocode_linux_x86_64.tar.gz", size: 1}, + }, + }, + }, + } + client := selfupdateClient{ + source: source, + config: selfupdate.Config{ + Source: source, + OS: "linux", + Arch: "x86_64", + }, + } + + repository := selfupdate.NewRepositorySlug(repositoryOwner, repositoryName) + target := assetTarget{OSToken: "linux", ArchToken: "x86_64", Ext: "tar.gz"} + + rel, found, err := client.detectReleaseByTagAndAsset(context.Background(), repository, " ", "asset", target) + if err != nil || found || rel != nil { + t.Fatalf("empty tag result = (%v, %v, %v), want (nil, false, nil)", rel, found, err) + } + + rel, found, err = client.detectReleaseByTagAndAsset(context.Background(), repository, "v1.0.0", " ", target) + if err != nil || found || rel != nil { + t.Fatalf("empty asset result = (%v, %v, %v), want (nil, false, nil)", rel, found, err) + } + + errClient := selfupdateClient{ + source: stubSource{listErr: errors.New("list failed")}, + config: selfupdate.Config{ + Source: stubSource{listErr: errors.New("list failed")}, + OS: "linux", + Arch: "x86_64", + }, + } + _, _, err = errClient.detectReleaseByTagAndAsset( + context.Background(), + repository, + "v1.0.0", + "neocode_linux_x86_64.tar.gz", + target, + ) + if err == nil || err.Error() != "list failed" { + t.Fatalf("detectReleaseByTagAndAsset() error = %v, want list failed", err) + } +} + +func TestAssetDiagnosticHelperBranches(t *testing.T) { + names := collectAssetNames([]selfupdate.SourceAsset{ + stubSourceAsset{name: "z-last"}, + blankSourceAsset{}, + stubSourceAsset{name: "a-first"}, + }) + if len(names) != 2 || names[0] != "a-first" || names[1] != "z-last" { + t.Fatalf("collectAssetNames() = %v", names) + } + + if got := trimDiagnosticAssetName(" "); got != "" { + t.Fatalf("trimDiagnosticAssetName(blank) = %q, want empty", got) + } + + if got := firstNonEmptyAssetName([]selfupdate.SourceAsset{blankSourceAsset{}}); got != "" { + t.Fatalf("firstNonEmptyAssetName() = %q, want empty", got) + } + + if got := assetName(nil); got != "" { + t.Fatalf("assetName(nil) = %q, want empty", got) + } + if got := assetName(blankSourceAsset{}); got != "" { + t.Fatalf("assetName(blankSourceAsset) = %q, want empty", got) + } +} From a8562753f3b44e5d1daa9ed3ae4148680aaf3e17 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Fri, 24 Apr 2026 05:02:30 +0000 Subject: [PATCH 3/6] fix(updater,cli): resolve review issues in version probe and diagnostics Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: pionxe <148670367+pionxe@users.noreply.github.com> --- internal/cli/version_command.go | 6 ++ internal/cli/version_command_test.go | 52 +++++++++++------ internal/updater/updater.go | 77 +++++++++++++++--------- internal/updater/updater_test.go | 87 +++++++++++++++++++--------- 4 files changed, 150 insertions(+), 72 deletions(-) diff --git a/internal/cli/version_command.go b/internal/cli/version_command.go index 4f5cdb16..9cc5177c 100644 --- a/internal/cli/version_command.go +++ b/internal/cli/version_command.go @@ -21,6 +21,7 @@ type versionCommandResult struct { LatestVersion string HasUpdate bool Comparable bool + ComparableLatest bool IncludePrerelease bool CheckErr error } @@ -68,6 +69,7 @@ func defaultVersionCommandRunner(ctx context.Context, options versionCommandOpti } result.LatestVersion = strings.TrimSpace(probe.LatestVersion) + result.ComparableLatest = probe.ComparableLatest if result.Comparable { result.HasUpdate = probe.HasUpdate } @@ -100,6 +102,10 @@ func printVersionCommandResult(out io.Writer, result versionCommandResult) { _, _ = fmt.Fprintln(out, "Update status: unknown (latest version unavailable).") return } + if !result.ComparableLatest { + _, _ = fmt.Fprintln(out, "Update status: unknown (latest release has no installable asset for current platform).") + return + } if result.HasUpdate { _, _ = fmt.Fprintln(out, "Update available: run neocode update") return diff --git a/internal/cli/version_command_test.go b/internal/cli/version_command_test.go index d77dc9ab..38f4e94e 100644 --- a/internal/cli/version_command_test.go +++ b/internal/cli/version_command_test.go @@ -26,10 +26,11 @@ 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, + CurrentVersion: "v1.0.0", + LatestVersion: "v1.0.0", + Comparable: true, + HasUpdate: false, + ComparableLatest: true, }, nil } @@ -60,10 +61,11 @@ 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, + CurrentVersion: "v1.0.0", + LatestVersion: "v1.2.0", + Comparable: true, + HasUpdate: true, + ComparableLatest: true, }, nil } @@ -98,10 +100,11 @@ 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, + CurrentVersion: "v1.2.0", + LatestVersion: "v1.2.0", + Comparable: true, + HasUpdate: false, + ComparableLatest: true, }, nil } @@ -217,9 +220,10 @@ func TestDefaultVersionCommandRunnerUsesProbeOptions(t *testing.T) { capturedIncludePrerelease = includePrerelease capturedTimeout = timeout return updater.CheckResult{ - CurrentVersion: "v1.0.0", - LatestVersion: "v1.1.0", - HasUpdate: true, + CurrentVersion: "v1.0.0", + LatestVersion: "v1.1.0", + HasUpdate: true, + ComparableLatest: true, }, nil } @@ -270,8 +274,9 @@ 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, + LatestVersion: " v1.2.0 ", + HasUpdate: true, + ComparableLatest: true, }, nil } @@ -316,4 +321,17 @@ func TestPrintVersionCommandResultBranches(t *testing.T) { t.Fatalf("output = %q, want prerelease check failure", out.String()) } }) + + 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, + }) + 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()) + } + }) } diff --git a/internal/updater/updater.go b/internal/updater/updater.go index 2321b71f..275b0309 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -54,7 +54,8 @@ var ( resolveExecutablePath = selfupdate.ExecutablePath ) -var semverTagPattern = regexp.MustCompile(`\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?`) +var semverTagPattern = regexp.MustCompile(`^(?:v|V)?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$`) +var diagnosticANSIPattern = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`) type assetTarget struct { OSToken string @@ -114,9 +115,10 @@ type CheckOptions struct { // CheckResult 表示静默探测流程返回的版本信息。 type CheckResult struct { - CurrentVersion string - LatestVersion string - HasUpdate bool + CurrentVersion string + LatestVersion string + HasUpdate bool + ComparableLatest bool } // UpdateOptions 描述手动更新命令的输入参数。 @@ -158,7 +160,8 @@ func CheckLatest(ctx context.Context, opts CheckOptions) (CheckResult, error) { if result.LatestVersion == "" { return result, nil } - if probe.Status != probeStatusMatched || probe.Release == nil { + result.ComparableLatest = probe.Status == probeStatusMatched && probe.Release != nil + if !result.ComparableLatest { return result, nil } @@ -301,15 +304,6 @@ func (c selfupdateClient) ProbeLatest( return result, nil } -// DetectLatest 调用底层 go-selfupdate 客户端获取最新版本信息。 -func (c selfupdateClient) DetectLatest(ctx context.Context, repository selfupdate.Repository) (releaseView, bool, error) { - release, found, err := c.updater.DetectLatest(ctx, repository) - if err != nil || !found || release == nil { - return nil, found, err - } - return selfupdateRelease{release: release}, true, nil -} - // UpdateTo 委托 go-selfupdate 完成原地替换流程,不追加平台分支逻辑。 func (c selfupdateClient) UpdateTo(ctx context.Context, rel releaseView, cmdPath string) error { typed, ok := rel.(selfupdateRelease) @@ -457,17 +451,16 @@ func buildReleaseSnapshot( }, true } -// parseReleaseVersion 从 tag 中提取可比较语义化版本,兼容前缀字符。 +// parseReleaseVersion 解析严格语义化版本标签,仅接受完整的 vX.Y.Z(含可选先行/构建元数据)格式。 func parseReleaseVersion(tag string) (*semver.Version, bool) { trimmed := strings.TrimSpace(tag) if trimmed == "" { return nil, false } - raw := semverTagPattern.FindString(trimmed) - if raw == "" { + if !semverTagPattern.MatchString(trimmed) { return nil, false } - parsed, err := semver.NewVersion(raw) + parsed, err := semver.NewVersion(trimmed) if err != nil { return nil, false } @@ -524,14 +517,15 @@ func extAliasPattern(ext string) string { // newAssetDiagnosticError 生成包含平台与候选信息的可执行诊断错误。 func newAssetDiagnosticError(message string, target assetTarget, probe probeResult) error { + candidateAssets := sanitizeDiagnosticAssets(probe.CandidateAssets) return fmt.Errorf( `%s (os=%s arch=%s expected-pattern="%s" available-assets-count=%d candidate-assets=%v)`, message, - target.OSToken, - target.ArchToken, - probe.ExpectedPattern, + sanitizeDiagnosticText(target.OSToken), + sanitizeDiagnosticText(target.ArchToken), + sanitizeDiagnosticText(probe.ExpectedPattern), probe.AvailableAssetsCount, - probe.CandidateAssets, + candidateAssets, ) } @@ -563,17 +557,46 @@ func sampleAssetsForDiagnostic(names []string) []string { // trimDiagnosticAssetName 对候选资产名按长度截断,控制日志噪声。 func trimDiagnosticAssetName(value string) string { - trimmed := strings.TrimSpace(value) - if trimmed == "" { - return trimmed + sanitized := sanitizeDiagnosticText(value) + if sanitized == "" { + return sanitized } - runes := []rune(trimmed) + runes := []rune(sanitized) if len(runes) <= maxDiagnosticAssetNameLength { - return trimmed + return sanitized } return string(runes[:maxDiagnosticAssetNameLength]) + "..." } +// sanitizeDiagnosticAssets 清洗候选资产名列表,避免终端输出被控制字符污染。 +func sanitizeDiagnosticAssets(values []string) []string { + if len(values) == 0 { + return nil + } + sanitized := make([]string, 0, len(values)) + for _, value := range values { + cleaned := trimDiagnosticAssetName(value) + if cleaned == "" { + continue + } + sanitized = append(sanitized, cleaned) + } + return sanitized +} + +// sanitizeDiagnosticText 去除 ANSI 序列和不可打印字符,仅保留可见字符用于诊断输出。 +func sanitizeDiagnosticText(value string) string { + cleaned := diagnosticANSIPattern.ReplaceAllString(value, "") + var builder strings.Builder + builder.Grow(len(cleaned)) + for _, ch := range cleaned { + if ch >= 0x20 && ch <= 0x7e { + builder.WriteRune(ch) + } + } + return strings.TrimSpace(builder.String()) +} + // firstNonEmptyAssetName 返回第一个可用资产名,用于二次精确探测。 func firstNonEmptyAssetName(assets []selfupdate.SourceAsset) string { for _, asset := range assets { diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go index c256c7be..76131d42 100644 --- a/internal/updater/updater_test.go +++ b/internal/updater/updater_test.go @@ -290,6 +290,9 @@ func TestCheckLatest(t *testing.T) { if !result.HasUpdate { t.Fatal("expected HasUpdate to be true") } + if !result.ComparableLatest { + t.Fatal("expected ComparableLatest to be true") + } if result.LatestVersion != "v1.2.0" { t.Fatalf("LatestVersion = %q, want %q", result.LatestVersion, "v1.2.0") } @@ -361,6 +364,9 @@ func TestCheckLatestErrorBranches(t *testing.T) { if result.HasUpdate { t.Fatalf("HasUpdate = true, want false") } + if result.ComparableLatest { + t.Fatalf("ComparableLatest = true, want false") + } }) t.Run("empty latest version", func(t *testing.T) { @@ -380,6 +386,9 @@ func TestCheckLatestErrorBranches(t *testing.T) { if result.LatestVersion != "" || result.HasUpdate { t.Fatalf("unexpected result: %+v", result) } + if result.ComparableLatest { + t.Fatalf("ComparableLatest = true, want false") + } }) t.Run("non semver current version never marks update", func(t *testing.T) { @@ -404,6 +413,34 @@ func TestCheckLatestErrorBranches(t *testing.T) { if result.HasUpdate { t.Fatalf("HasUpdate = true, want false for non-semver current version") } + if !result.ComparableLatest { + t.Fatalf("ComparableLatest = false, want true when release is installable") + } + }) + + t.Run("latest version exists but current platform not installable", func(t *testing.T) { + runtimeGOOS = "linux" + runtimeGOARCH = "amd64" + newClient = func(selfupdate.Config) (updateClient, error) { + return &fakeClient{ + probeStatus: probeStatusNoCandidate, + probeLatestVersion: "v2.0.0", + }, nil + } + + result, err := CheckLatest(context.Background(), CheckOptions{CurrentVersion: "v1.0.0"}) + if err != nil { + t.Fatalf("CheckLatest() error = %v", err) + } + 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.ComparableLatest { + t.Fatalf("ComparableLatest = true, want false when latest is not installable") + } }) } @@ -887,7 +924,7 @@ func TestSelfupdateClientProbeLatestForNamingVariantsAndAmbiguity(t *testing.T) }) } -func TestSelfupdateClientDetectLatestAndUnsupportedUpdateType(t *testing.T) { +func TestSelfupdateClientUpdateToUnsupportedType(t *testing.T) { target := assetTarget{ OSToken: "linux", ArchToken: "amd64", @@ -916,35 +953,18 @@ func TestSelfupdateClientDetectLatestAndUnsupportedUpdateType(t *testing.T) { } client := selfupdateClient{updater: updater} - rel, found, err := client.DetectLatest(context.Background(), selfupdate.NewRepositorySlug(repositoryOwner, repositoryName)) - if err != nil { - t.Fatalf("DetectLatest() error = %v", err) - } - if !found || rel == nil { - t.Fatalf("expected release found, got found=%v rel=%v", found, rel) - } - if rel.Version() == "" { - t.Fatalf("expected non-empty release version") - } - if !rel.GreaterThan("1.0.0") { - t.Fatalf("expected release to be greater than 1.0.0") - } - - noReleaseUpdater, err := selfupdate.NewUpdater(selfupdate.Config{ - Source: stubSource{releases: nil}, - OS: target.OSToken, - Arch: target.ArchToken, - }) - if err != nil { - t.Fatalf("NewUpdater(no release) error = %v", err) - } - noReleaseClient := selfupdateClient{updater: noReleaseUpdater} - if gotRel, gotFound, gotErr := noReleaseClient.DetectLatest( + release, found, err := updater.DetectVersion( context.Background(), selfupdate.NewRepositorySlug(repositoryOwner, repositoryName), - ); gotErr != nil || gotFound || gotRel != nil { - t.Fatalf("DetectLatest(no release) = (%v, %v, %v), want (nil, false, nil)", gotRel, gotFound, gotErr) + "v1.5.0", + ) + if err != nil { + t.Fatalf("DetectVersion() error = %v", err) } + if !found || release == nil { + t.Fatalf("expected release found, got found=%v release=%v", found, release) + } + rel := selfupdateRelease{release: release} err = client.UpdateTo(context.Background(), fakeRelease{version: "v1.0.0"}, "/tmp/neocode") if err == nil || err.Error() != "updater: unsupported release type" { @@ -988,7 +1008,9 @@ func TestParseReleaseVersionBranches(t *testing.T) { }{ {name: "empty", tag: " ", ok: false}, {name: "no semver", tag: "release-latest", ok: false}, - {name: "with prefix", tag: "release/v1.2.3", ok: true}, + {name: "with v prefix", tag: "v1.2.3", ok: true}, + {name: "with uppercase v prefix", tag: "V1.2.3", ok: false}, + {name: "embedded semver text should be rejected", tag: "release/v1.2.3", ok: false}, {name: "prerelease build", tag: "v1.2.3-rc.1+build.7", ok: true}, } @@ -1201,6 +1223,15 @@ func TestAssetDiagnosticHelperBranches(t *testing.T) { if got := trimDiagnosticAssetName(" "); got != "" { t.Fatalf("trimDiagnosticAssetName(blank) = %q, want empty", got) } + if got := trimDiagnosticAssetName("bad-\x1b[31masset\x1b[0m-\nname"); got != "bad-asset-name" { + t.Fatalf("trimDiagnosticAssetName(ansi) = %q, want %q", got, "bad-asset-name") + } + if got := sanitizeDiagnosticText("\x1b[31masset\x1b[0m\t\nok"); got != "assetok" { + t.Fatalf("sanitizeDiagnosticText() = %q, want %q", got, "assetok") + } + if got := sanitizeDiagnosticAssets([]string{"\x1b[31mone\x1b[0m", " ", "two"}); len(got) != 2 || got[0] != "one" || got[1] != "two" { + t.Fatalf("sanitizeDiagnosticAssets() = %v, want [one two]", got) + } if got := firstNonEmptyAssetName([]selfupdate.SourceAsset{blankSourceAsset{}}); got != "" { t.Fatalf("firstNonEmptyAssetName() = %q, want empty", got) From 43b2bf3d84950e7f312ba9835a8e79b42a741d77 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Fri, 24 Apr 2026 12:17:57 +0000 Subject: [PATCH 4/6] docs: resolve merge conflicts for version/update docs Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: pionxe <148670367+pionxe@users.noreply.github.com> --- README.md | 14 ++++++++++++++ docs/guides/update.md | 31 +++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/README.md b/README.md index a94027ad..2f0b1e68 100644 --- a/README.md +++ b/README.md @@ -276,6 +276,20 @@ Skill 内部调用脚本 `scripts/create_issue.sh` 创建 issue。你也可以 - `neocode update`:执行升级到当前通道的最新版本。 - `neocode update --prerelease`:执行升级并允许预发布版本。 +## 双产物与启动兼容(RFC#420) + +- 发布产物: + - `neocode`(完整客户端,含 `gateway` 子命令) + - `neocode-gateway`(Gateway-Only 入口) +- `url-dispatch` 网关不可达时的拉起优先级固定为: + - `NEOCODE_GATEWAY_BIN` + - `PATH` 中 `neocode-gateway` + - `neocode gateway` +- 第三方接入与协议文档见: + - [`docs/guides/gateway-integration-guide.md`](docs/guides/gateway-integration-guide.md) + - [`docs/gateway-rpc-api.md`](docs/gateway-rpc-api.md) + - [`docs/gateway-error-catalog.md`](docs/gateway-error-catalog.md) + ## License MIT diff --git a/docs/guides/update.md b/docs/guides/update.md index 31748759..ea07a73f 100644 --- a/docs/guides/update.md +++ b/docs/guides/update.md @@ -45,6 +45,37 @@ neocode update --prerelease - `available-assets-count` - `candidate-assets`(最多展示前 10 个,单项最长 120 字符) +## 双产物安装建议 + +1. Full 模式:安装 `neocode`。 +2. Gateway 模式:安装 `neocode-gateway`。 + +安装脚本支持 flavor: + +```bash +bash ./scripts/install.sh --flavor full +bash ./scripts/install.sh --flavor gateway +``` + +```powershell +.\scripts\install.ps1 -Flavor full +.\scripts\install.ps1 -Flavor gateway +``` + +## 升级后验证(推荐) + +1. `GET /healthz` 返回 200。 +2. `/rpc` 未鉴权请求返回预期失败(`gateway_code=unauthorized`)。 +3. 必要时执行一次最小 `gateway.run` 冒烟。 + +## 回滚步骤 + +1. 停止当前网关进程。 +2. 回退到上一版已验证二进制。 +3. 重新启动并执行“升级后验证(推荐)”步骤。 + +若回滚后仍异常,优先检查配置文件兼容性与 token 文件状态。 + ## 版本来源 - 发布构建会通过 `ldflags` 注入版本号到 `internal/version.Version`。 From ad0e94114aedfb5fc0c917e768c3021d734c04a3 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Fri, 24 Apr 2026 13:26:49 +0000 Subject: [PATCH 5/6] chore: resolve PR merge conflicts with main Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: pionxe <148670367+pionxe@users.noreply.github.com> --- README.md | 25 +++++++++++++--------- docs/guides/update.md | 50 +++++++++---------------------------------- 2 files changed, 25 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 2f0b1e68..dbde5b99 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ NeoCode 是一个在终端中运行的 AI 编码助手,采用 ReAct(Reason-A ## 有什么能力? - 终端原生 TUI 交互体验(Bubble Tea) - Agent 可调用内置工具完成文件与命令相关任务 -- 支持 Provider/Model 切换(内建 `openai`、`gemini`、`openll`、`qiniu`) +- 支持 Provider/Model 切换(内建 `openai`、`gemini`、`openll`、`qiniu`、`modelscope`) - 支持上下文压缩(`/compact`),帮助长会话保持可用 - 支持工作区隔离(`--workdir`、`/cwd`) - 会话持久化与恢复,降低重复沟通成本 @@ -35,7 +35,7 @@ NeoCode 是一个在终端中运行的 AI 编码助手,采用 ReAct(Reason-A ### 1) 环境要求 - Go `1.25+` -- 可用的 API Key(如 OpenAI、Gemini、OpenLL、Qiniu) +- 可用的 API Key(如 OpenAI、Gemini、OpenLL、Qiniu、ModelScope) ### 2) 一键安装 macOS / Linux: @@ -97,6 +97,7 @@ export OPENAI_API_KEY="your_key_here" export GEMINI_API_KEY="your_key_here" export AI_API_KEY="your_key_here" export QINIU_API_KEY="your_key_here" +export MODELSCOPE_API_KEY="your_key_here" ``` Windows PowerShell: @@ -105,6 +106,7 @@ $env:OPENAI_API_KEY = "your_key_here" $env:GEMINI_API_KEY = "your_key_here" $env:AI_API_KEY = "your_key_here" $env:QINIU_API_KEY = "your_key_here" +$env:MODELSCOPE_API_KEY = "your_key_here" ``` 按工作区启动(仅当前进程生效): @@ -140,6 +142,13 @@ Gateway 转发与自动拉起说明: 帮我在 internal/runtime 下定位与 tool result 回灌相关逻辑 ``` +## 版本与升级命令 + +- `neocode version`:输出当前版本并检查最新稳定版。 +- `neocode version --prerelease`:检查时包含预发布版本。 +- `neocode update`:执行升级到当前通道的最新版本。 +- `neocode update --prerelease`:执行升级并允许预发布版本。 + ## 配置入口 - 主配置文件:`~/.neocode/config.yaml` @@ -155,7 +164,8 @@ Gateway 转发与自动拉起说明: ## 内部结构补充 -- `internal/context`:负责主会话 system prompt 的 section 组装、动态上下文注入与消息裁剪。 +- `internal/context`:负责消费仓库/运行时事实并组装主会话 system prompt、动态上下文注入与消息裁剪。 +- `internal/repository`:负责仓库级事实发现与裁剪,统一提供 repo summary、changed-files context 与 targeted retrieval。 - `internal/runtime`:负责 ReAct 主循环、tool 调用编排、compact 触发与 reminder 注入时机。 - `internal/subagent`:负责子代理角色策略、执行约束与输出契约。 - `internal/promptasset`:负责受版本管理的静态 prompt 模板资产,使用 `go:embed` 编译进程序,供 `context`、`runtime`、`subagent` 读取。 @@ -167,9 +177,11 @@ Gateway 转发与自动拉起说明: - [Runtime/Provider 事件流](docs/runtime-provider-event-flow.md) - [Session 持久化设计](docs/session-persistence-design.md) - [Context Compact 说明](docs/context-compact.md) +- [Repository 模块设计](docs/repository-design.md) - [Tools 与 TUI 集成](docs/tools-and-tui-integration.md) - [Skills 设计与使用](docs/skills-system-design.md) - [MCP 配置指南](docs/guides/mcp-configuration.md) +- [ModelScope 半引导配置](docs/guides/modelscope-provider-setup.md) - [更新与升级](docs/guides/update.md) ## 如何参与 @@ -269,13 +281,6 @@ Skill 内部调用脚本 `scripts/create_issue.sh` 创建 issue。你也可以 - `wake.openUrl`:处理 `neocode://` 唤醒请求 - `gateway.event`:网关推送通知事件(notification) -## 版本与升级命令 - -- `neocode version`:输出当前版本并检查最新稳定版。 -- `neocode version --prerelease`:检查时包含预发布版本。 -- `neocode update`:执行升级到当前通道的最新版本。 -- `neocode update --prerelease`:执行升级并允许预发布版本。 - ## 双产物与启动兼容(RFC#420) - 发布产物: diff --git a/docs/guides/update.md b/docs/guides/update.md index ea07a73f..e3552987 100644 --- a/docs/guides/update.md +++ b/docs/guides/update.md @@ -1,30 +1,12 @@ -# 更新与版本检查 +# 更新与回滚指南 -## 自动检查 -- `neocode` 启动时会在后台静默检查最新稳定版本(默认 3 秒超时)。 -- 为避免干扰 Bubble Tea TUI 交互,更新提示会在应用退出、终端屏幕恢复后输出。 -- `url-dispatch`、`update`、`version` 子命令会跳过该静默检查,避免重复探测。 +## 1. 自动更新检测 -## 查询版本 +1. `neocode` 启动时会后台检测新版本(默认 3 秒超时)。 +2. 为避免干扰 TUI,提示在程序退出后展示。 +3. `url-dispatch` 与 `update` 子命令默认跳过静默检测。 -查看当前版本并探测远端最新版本: - -```bash -neocode version -``` - -包含预发布版本一起比较: - -```bash -neocode version --prerelease -``` - -行为说明: -- 始终输出当前版本。 -- 探测成功时输出“最新版本 + 比较结果”。 -- 探测失败时输出失败原因,但命令仍返回成功退出码,方便脚本场景继续执行。 - -## 手动升级 +## 2. 手动升级 升级到最新稳定版本: @@ -38,14 +20,7 @@ neocode update neocode update --prerelease ``` -更新命令在平台资产匹配失败时会输出可诊断信息,例如: -- `os` -- `arch` -- `expected-pattern` -- `available-assets-count` -- `candidate-assets`(最多展示前 10 个,单项最长 120 字符) - -## 双产物安装建议 +## 3. 双产物安装建议 1. Full 模式:安装 `neocode`。 2. Gateway 模式:安装 `neocode-gateway`。 @@ -62,21 +37,16 @@ bash ./scripts/install.sh --flavor gateway .\scripts\install.ps1 -Flavor gateway ``` -## 升级后验证(推荐) +## 4. 升级后验证(推荐) 1. `GET /healthz` 返回 200。 2. `/rpc` 未鉴权请求返回预期失败(`gateway_code=unauthorized`)。 3. 必要时执行一次最小 `gateway.run` 冒烟。 -## 回滚步骤 +## 5. 回滚步骤 1. 停止当前网关进程。 2. 回退到上一版已验证二进制。 -3. 重新启动并执行“升级后验证(推荐)”步骤。 +3. 重新启动并执行第 4 节验证步骤。 若回滚后仍异常,优先检查配置文件兼容性与 token 文件状态。 - -## 版本来源 - -- 发布构建会通过 `ldflags` 注入版本号到 `internal/version.Version`。 -- 本地开发构建默认版本为 `dev`。 From 2eabbce4a4dd893eb6a685e888211718b236618a Mon Sep 17 00:00:00 2001 From: xgopilot Date: Fri, 24 Apr 2026 14:28:28 +0000 Subject: [PATCH 6/6] 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")},