Skip to content
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,14 @@ Gateway 转发与自动拉起说明:
帮我在 internal/runtime 下定位与 tool result 回灌相关逻辑
```

## 版本与升级命令

- `neocode version`:输出当前版本并检查最新稳定版。
- `neocode version --prerelease`:检查时包含预发布版本。
- `neocode update`:执行升级到当前通道的最新版本。
- `neocode update --prerelease`:执行升级并允许预发布版本。
- 当远端“语义最新版本”在当前平台不可安装时,`version` 会同时给出“可安装的最高版本”升级提示,并提示远端资产异常状态。

## 配置入口

- 主配置文件:`~/.neocode/config.yaml`
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
21 changes: 10 additions & 11 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -87,6 +88,7 @@ func NewRootCommand() *cobra.Command {
newGatewayCommand(),
newMigrateCommand(),
newURLDispatchCommand(),
newVersionCommand(),
newUpdateCommand(),
)

Expand Down Expand Up @@ -138,22 +140,19 @@ 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
}

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 All @@ -165,7 +164,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)
Expand Down
38 changes: 35 additions & 3 deletions internal/cli/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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" {
Expand Down Expand Up @@ -1472,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
127 changes: 127 additions & 0 deletions internal/cli/version_command.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
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
InstallableVersion string
HasUpdate bool
Comparable bool
ComparableLatest 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)
result.InstallableVersion = strings.TrimSpace(probe.InstallableVersion)
result.ComparableLatest = probe.ComparableLatest
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)
installable := displayVersionForTerminal(result.InstallableVersion)

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.ComparableLatest {
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 {
_, _ = fmt.Fprintln(out, "Update available: run neocode update")
return
}
_, _ = fmt.Fprintln(out, "You are on the latest version.")
}
Loading
Loading