Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ Gateway 转发与自动拉起说明:
- `neocode version --prerelease`:检查时包含预发布版本。
- `neocode update`:执行升级到当前通道的最新版本。
- `neocode update --prerelease`:执行升级并允许预发布版本。
- 当远端“语义最新版本”在当前平台不可安装时,`version` 会同时给出“可安装的最高版本”升级提示,并提示远端资产异常状态。

## 配置入口

Expand Down
33 changes: 28 additions & 5 deletions docs/guides/update.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 手动升级

升级到最新稳定版本:

Expand All @@ -20,7 +43,7 @@ neocode update
neocode update --prerelease
```

## 3. 双产物安装建议
## 4. 双产物安装建议

1. Full 模式:安装 `neocode`。
2. Gateway 模式:安装 `neocode-gateway`。
Expand All @@ -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. 回退到上一版已验证二进制。
Expand Down
9 changes: 6 additions & 3 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
7 changes: 4 additions & 3 deletions internal/cli/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
29 changes: 21 additions & 8 deletions internal/cli/version_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.")
Expand All @@ -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 {
Expand Down
91 changes: 63 additions & 28 deletions internal/cli/version_command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}

Expand All @@ -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) {
Expand Down Expand Up @@ -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
}

Expand All @@ -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")
}
Expand Down Expand Up @@ -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)
}
})
}
29 changes: 17 additions & 12 deletions internal/updater/updater.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ type probeResult struct {
Status probeStatus
Release releaseView
LatestVersion string
InstallableVersion string
LatestInstallable bool
ExpectedPattern string
AvailableAssetsCount int
CandidateAssets []string
Expand Down Expand Up @@ -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 描述手动更新命令的输入参数。
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
}

Expand Down
Loading
Loading