Skip to content

Commit 6c0338a

Browse files
xgopilotpionxe
andcommitted
fix(cli): harden update notice/output and simplify logic
Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: pionxe <148670367+pionxe@users.noreply.github.com>
1 parent fbc5f8a commit 6c0338a

5 files changed

Lines changed: 172 additions & 20 deletions

File tree

docs/guides/update.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
- `neocode` 启动时会在后台静默检测最新版本(默认 3 秒超时)。
66
- 为避免干扰 Bubble Tea TUI 交互,更新提示会在应用退出、终端屏幕恢复后输出。
7-
- `url-dispatch` 子命令会跳过该检测流程。
7+
- `url-dispatch` `update` 子命令会跳过该检测流程。
88

99
## 手动升级
1010

internal/cli/root.go

Lines changed: 57 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"regexp"
88
"strings"
9+
"sync"
910
"time"
1011

1112
"github.com/spf13/cobra"
@@ -25,9 +26,15 @@ var readCurrentVersion = version.Current
2526
var checkLatestRelease = updater.CheckLatest
2627

2728
const silentUpdateCheckTimeout = 3 * time.Second
29+
const silentUpdateCheckDrainTimeout = 300 * time.Millisecond
2830

2931
var ansiEscapeSequencePattern = regexp.MustCompile(`\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\)|[@-Z\\-_])`)
3032

33+
var (
34+
silentUpdateCheckMu sync.Mutex
35+
silentUpdateCheckDone <-chan struct{}
36+
)
37+
3138
// GlobalFlags 描述 CLI 根命令当前支持的全局参数。
3239
type GlobalFlags struct {
3340
Workdir string
@@ -37,7 +44,11 @@ type GlobalFlags struct {
3744
func Execute(ctx context.Context) error {
3845
app.EnsureConsoleUTF8()
3946
_ = ConsumeUpdateNotice()
40-
return NewRootCommand().ExecuteContext(ctx)
47+
setSilentUpdateCheckDone(nil)
48+
49+
err := NewRootCommand().ExecuteContext(ctx)
50+
waitSilentUpdateCheckDone(silentUpdateCheckDrainTimeout)
51+
return err
4152
}
4253

4354
// NewRootCommand 创建 NeoCode 的 CLI 根命令。
@@ -116,11 +127,16 @@ func defaultGlobalPreload(ctx context.Context) error {
116127
func defaultSilentUpdateCheck(ctx context.Context) {
117128
currentVersion := readCurrentVersion()
118129
if !version.IsSemverRelease(currentVersion) {
130+
setSilentUpdateCheckDone(nil)
119131
return
120132
}
121133
parentCtx := context.WithoutCancel(ctx)
134+
done := make(chan struct{})
135+
setSilentUpdateCheckDone(done)
136+
137+
go func(parent context.Context, currentVersion string, done chan struct{}) {
138+
defer close(done)
122139

123-
go func(parent context.Context, currentVersion string) {
124140
checkCtx, cancel := context.WithTimeout(parent, silentUpdateCheckTimeout)
125141
defer cancel()
126142

@@ -137,23 +153,17 @@ func defaultSilentUpdateCheck(ctx context.Context) {
137153
return
138154
}
139155
setUpdateNotice(fmt.Sprintf("\u53d1\u73b0\u65b0\u7248\u672c: %s\uff0c\u8fd0\u884c neocode update \u5373\u53ef\u5347\u7ea7", latestVersion))
140-
}(parentCtx, currentVersion)
156+
}(parentCtx, currentVersion, done)
141157
}
142158

143159
// shouldSkipGlobalPreload 判断当前命令是否应跳过全局预加载逻辑。
144160
func shouldSkipGlobalPreload(cmd *cobra.Command) bool {
145-
if cmd == nil {
146-
return false
147-
}
148-
return strings.EqualFold(strings.TrimSpace(cmd.Name()), "url-dispatch")
161+
return normalizedCommandName(cmd) == "url-dispatch"
149162
}
150163

151164
// shouldSkipSilentUpdateCheck 判断当前命令是否应跳过静默更新检测。
152165
func shouldSkipSilentUpdateCheck(cmd *cobra.Command) bool {
153-
if cmd == nil {
154-
return false
155-
}
156-
switch strings.ToLower(strings.TrimSpace(cmd.Name())) {
166+
switch normalizedCommandName(cmd) {
157167
case "url-dispatch", "update":
158168
return true
159169
default:
@@ -173,3 +183,39 @@ func sanitizeVersionForTerminal(version string) string {
173183
}
174184
return strings.TrimSpace(builder.String())
175185
}
186+
187+
// normalizedCommandName 返回标准化后的命令名,统一处理空命令与大小写。
188+
func normalizedCommandName(cmd *cobra.Command) string {
189+
if cmd == nil {
190+
return ""
191+
}
192+
return strings.ToLower(strings.TrimSpace(cmd.Name()))
193+
}
194+
195+
// setSilentUpdateCheckDone 保存当前静默检测任务的完成信号通道。
196+
func setSilentUpdateCheckDone(done <-chan struct{}) {
197+
silentUpdateCheckMu.Lock()
198+
silentUpdateCheckDone = done
199+
silentUpdateCheckMu.Unlock()
200+
}
201+
202+
// waitSilentUpdateCheckDone 在命令退出阶段等待静默检测短暂收口,降低提示丢失概率。
203+
func waitSilentUpdateCheckDone(timeout time.Duration) {
204+
if timeout <= 0 {
205+
return
206+
}
207+
208+
silentUpdateCheckMu.Lock()
209+
done := silentUpdateCheckDone
210+
silentUpdateCheckMu.Unlock()
211+
if done == nil {
212+
return
213+
}
214+
215+
timer := time.NewTimer(timeout)
216+
defer timer.Stop()
217+
select {
218+
case <-done:
219+
case <-timer.C:
220+
}
221+
}

internal/cli/root_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,52 @@ func TestExecuteUsesOSArgs(t *testing.T) {
110110
}
111111
}
112112

113+
func TestExecuteWaitsForSilentUpdateCheckCompletion(t *testing.T) {
114+
originalLauncher := launchRootProgram
115+
originalPreload := runGlobalPreload
116+
originalSilentCheck := runSilentUpdateCheck
117+
originalArgs := os.Args
118+
t.Cleanup(func() {
119+
launchRootProgram = originalLauncher
120+
runGlobalPreload = originalPreload
121+
runSilentUpdateCheck = originalSilentCheck
122+
os.Args = originalArgs
123+
})
124+
125+
_ = ConsumeUpdateNotice()
126+
runGlobalPreload = func(context.Context) error { return nil }
127+
launchRootProgram = func(context.Context, app.BootstrapOptions) error { return nil }
128+
runSilentUpdateCheck = func(context.Context) {
129+
done := make(chan struct{})
130+
setSilentUpdateCheckDone(done)
131+
go func() {
132+
time.Sleep(50 * time.Millisecond)
133+
setUpdateNotice("发现新版本: v0.2.1")
134+
close(done)
135+
}()
136+
}
137+
os.Args = []string{"neocode"}
138+
139+
if err := Execute(context.Background()); err != nil {
140+
t.Fatalf("Execute() error = %v", err)
141+
}
142+
if got := ConsumeUpdateNotice(); got == "" {
143+
t.Fatal("expected update notice after Execute waits for silent check")
144+
}
145+
}
146+
147+
func TestWaitSilentUpdateCheckDoneReturnsOnTimeout(t *testing.T) {
148+
blocked := make(chan struct{})
149+
setSilentUpdateCheckDone(blocked)
150+
t.Cleanup(func() { setSilentUpdateCheckDone(nil) })
151+
152+
start := time.Now()
153+
waitSilentUpdateCheckDone(30 * time.Millisecond)
154+
if elapsed := time.Since(start); elapsed < 20*time.Millisecond || elapsed > 150*time.Millisecond {
155+
t.Fatalf("wait duration out of expected range, got %s", elapsed)
156+
}
157+
}
158+
113159
func TestDefaultRootProgramLauncherRunsProgram(t *testing.T) {
114160
originalNewProgram := newRootProgram
115161
t.Cleanup(func() { newRootProgram = originalNewProgram })
@@ -1198,6 +1244,15 @@ func TestShouldSkipGlobalPreload(t *testing.T) {
11981244
}
11991245
}
12001246

1247+
func TestNormalizedCommandName(t *testing.T) {
1248+
if got := normalizedCommandName(nil); got != "" {
1249+
t.Fatalf("normalizedCommandName(nil) = %q, want empty", got)
1250+
}
1251+
if got := normalizedCommandName(&cobra.Command{Use: "URL-Dispatch"}); got != "url-dispatch" {
1252+
t.Fatalf("normalizedCommandName() = %q, want %q", got, "url-dispatch")
1253+
}
1254+
}
1255+
12011256
func TestShouldSkipSilentUpdateCheck(t *testing.T) {
12021257
if !shouldSkipSilentUpdateCheck(&cobra.Command{Use: "url-dispatch"}) {
12031258
t.Fatal("url-dispatch should skip silent update check")

internal/cli/update_command.go

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import (
44
"context"
55
"errors"
66
"fmt"
7-
"strings"
87
"time"
98

109
"github.com/spf13/cobra"
@@ -41,15 +40,14 @@ func newUpdateCommand() *cobra.Command {
4140

4241
out := cmd.OutOrStdout()
4342
if !result.Updated {
44-
latest := strings.TrimSpace(result.LatestVersion)
45-
if latest == "" {
46-
latest = "unknown"
47-
}
43+
latest := displayVersionForTerminal(result.LatestVersion)
4844
_, _ = fmt.Fprintf(out, "Already up-to-date (latest: %s).\n", latest)
4945
return nil
5046
}
5147

52-
_, _ = fmt.Fprintf(out, "Updated successfully: %s -> %s\n", result.CurrentVersion, result.LatestVersion)
48+
current := displayVersionForTerminal(result.CurrentVersion)
49+
latest := displayVersionForTerminal(result.LatestVersion)
50+
_, _ = fmt.Fprintf(out, "Updated successfully: %s -> %s\n", current, latest)
5351
return nil
5452
},
5553
}
@@ -75,3 +73,12 @@ func defaultUpdateCommandRunner(ctx context.Context, options updateCommandOption
7573
}
7674
return result, nil
7775
}
76+
77+
// displayVersionForTerminal 清洗版本字符串并为不可用值提供统一回退文案。
78+
func displayVersionForTerminal(raw string) string {
79+
version := sanitizeVersionForTerminal(raw)
80+
if version == "" {
81+
return "unknown"
82+
}
83+
return version
84+
}

internal/cli/update_command_test.go

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,8 @@ func TestUpdateCommandShowsSuccessMessage(t *testing.T) {
5757
runSilentUpdateCheck = func(context.Context) {}
5858
runUpdateCommand = func(context.Context, updateCommandOptions) (updater.UpdateResult, error) {
5959
return updater.UpdateResult{
60-
CurrentVersion: "v0.1.0",
61-
LatestVersion: "v0.2.1",
60+
CurrentVersion: "\x1b[31mv0.1.0\x1b[0m",
61+
LatestVersion: "\x1b[32mv0.2.1\x1b[0m\t",
6262
Updated: true,
6363
}, nil
6464
}
@@ -74,6 +74,12 @@ func TestUpdateCommandShowsSuccessMessage(t *testing.T) {
7474
if got := stdout.String(); got == "" || !bytes.Contains(stdout.Bytes(), []byte("Updated successfully")) {
7575
t.Fatalf("unexpected output: %q", got)
7676
}
77+
if strings.Contains(stdout.String(), "\x1b") {
78+
t.Fatalf("expected sanitized output without ANSI sequence, got %q", stdout.String())
79+
}
80+
if !strings.Contains(stdout.String(), "v0.1.0 -> v0.2.1") {
81+
t.Fatalf("unexpected output: %q", stdout.String())
82+
}
7783
}
7884

7985
func TestUpdateCommandShowsUnknownLatestWhenLatestVersionEmpty(t *testing.T) {
@@ -102,6 +108,35 @@ func TestUpdateCommandShowsUnknownLatestWhenLatestVersionEmpty(t *testing.T) {
102108
}
103109
}
104110

111+
func TestUpdateCommandSanitizesLatestVersionInUpToDateMessage(t *testing.T) {
112+
originalRunner := runUpdateCommand
113+
originalPreload := runGlobalPreload
114+
originalSilentCheck := runSilentUpdateCheck
115+
t.Cleanup(func() { runUpdateCommand = originalRunner })
116+
t.Cleanup(func() { runGlobalPreload = originalPreload })
117+
t.Cleanup(func() { runSilentUpdateCheck = originalSilentCheck })
118+
119+
runGlobalPreload = func(context.Context) error { return nil }
120+
runSilentUpdateCheck = func(context.Context) {}
121+
runUpdateCommand = func(context.Context, updateCommandOptions) (updater.UpdateResult, error) {
122+
return updater.UpdateResult{Updated: false, LatestVersion: "\x1b[31mv0.2.1\x1b[0m\t\n"}, nil
123+
}
124+
125+
command := NewRootCommand()
126+
var stdout bytes.Buffer
127+
command.SetOut(&stdout)
128+
command.SetArgs([]string{"update"})
129+
if err := command.ExecuteContext(context.Background()); err != nil {
130+
t.Fatalf("ExecuteContext() error = %v", err)
131+
}
132+
if strings.Contains(stdout.String(), "\x1b") {
133+
t.Fatalf("expected sanitized output without ANSI sequence, got %q", stdout.String())
134+
}
135+
if !strings.Contains(stdout.String(), "latest: v0.2.1") {
136+
t.Fatalf("unexpected output: %q", stdout.String())
137+
}
138+
}
139+
105140
func TestUpdateCommandReturnsRunnerError(t *testing.T) {
106141
originalRunner := runUpdateCommand
107142
originalPreload := runGlobalPreload
@@ -216,3 +251,12 @@ func TestDefaultUpdateCommandRunnerReturnsUnderlyingError(t *testing.T) {
216251
t.Fatalf("expected underlying error %v, got %v", expected, err)
217252
}
218253
}
254+
255+
func TestDisplayVersionForTerminal(t *testing.T) {
256+
if got := displayVersionForTerminal("\x1b[31mv0.2.1\x1b[0m\t"); got != "v0.2.1" {
257+
t.Fatalf("displayVersionForTerminal() = %q, want %q", got, "v0.2.1")
258+
}
259+
if got := displayVersionForTerminal(" \n\t"); got != "unknown" {
260+
t.Fatalf("displayVersionForTerminal() empty = %q, want %q", got, "unknown")
261+
}
262+
}

0 commit comments

Comments
 (0)