From 07e26c04144057bbeb438c227a7b35d3f6ba5f5a Mon Sep 17 00:00:00 2001 From: pionxe Date: Fri, 17 Apr 2026 17:51:45 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(cli):=20=E6=94=AF=E6=8C=81=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E5=90=AF=E5=8A=A8=E9=9D=99=E9=BB=98=E6=A3=80=E6=B5=8B?= =?UTF-8?q?=E6=96=B0=E7=89=88=E6=9C=AC=E4=B8=8E=E5=B9=B3=E6=BB=91=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=8D=87=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入 `go-selfupdate`,支持检测 Github Releases 并自动替换二进制文件 - 新增 `internal/version` 包,配合 `.goreleaser.yaml` 注入构建版本号 - 在根命令 `PersistentPreRunE` 挂载异步静默版本检测(屏蔽 `url-dispatch`) - 优化 TUI 生命周期,利用提示缓冲实现 AltScreen 退出后的更新提醒 - 新增 `neocode update [--prerelease]` 手动升级命令 - fix(test): 修复 Windows 环境下 config 目录权限测试由于 `os.Chmod` 不兼容导致的误报 --- .goreleaser.yaml | 3 +- README.md | 1 + cmd/neocode/main.go | 3 + docs/guides/update.md | 26 +++ go.mod | 18 +- go.sum | 48 ++++ internal/cli/root.go | 41 +++- internal/cli/root_test.go | 65 ++++++ internal/cli/update_command.go | 60 +++++ internal/cli/update_command_test.go | 85 ++++++++ internal/cli/update_notice.go | 33 +++ internal/config/loader_test.go | 13 +- internal/updater/updater.go | 252 +++++++++++++++++++++ internal/updater/updater_test.go | 327 ++++++++++++++++++++++++++++ internal/version/version.go | 25 +++ internal/version/version_test.go | 41 ++++ 16 files changed, 1036 insertions(+), 5 deletions(-) create mode 100644 docs/guides/update.md create mode 100644 internal/cli/update_command.go create mode 100644 internal/cli/update_command_test.go create mode 100644 internal/cli/update_notice.go create mode 100644 internal/updater/updater.go create mode 100644 internal/updater/updater_test.go create mode 100644 internal/version/version.go create mode 100644 internal/version/version_test.go diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 9d95045a..a1919fba 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -11,6 +11,8 @@ before: builds: - env: - CGO_ENABLED=0 # 禁用 CGO,确保生成纯静态链接的二进制文件 + ldflags: + - -s -w -X 'neo-code/internal/version.Version={{.Version}}' goos: - linux - windows @@ -46,4 +48,3 @@ changelog: exclude: - '^docs:' - '^test:' - diff --git a/README.md b/README.md index cfc9ec7f..1e6bd26c 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,7 @@ go run ./cmd/neocode --workdir /path/to/workspace - [Context Compact 说明](docs/context-compact.md) - [Tools 与 TUI 集成](docs/tools-and-tui-integration.md) - [MCP 配置指南](docs/guides/mcp-configuration.md) +- [更新与升级](docs/guides/update.md) ## 如何参与 diff --git a/cmd/neocode/main.go b/cmd/neocode/main.go index 1926ff8e..dfaea80c 100644 --- a/cmd/neocode/main.go +++ b/cmd/neocode/main.go @@ -13,4 +13,7 @@ func main() { fmt.Fprintf(os.Stderr, "neocode: %v\n", err) os.Exit(1) } + if notice := cli.ConsumeUpdateNotice(); notice != "" { + fmt.Fprintln(os.Stdout, notice) + } } diff --git a/docs/guides/update.md b/docs/guides/update.md new file mode 100644 index 00000000..7bc0a5d2 --- /dev/null +++ b/docs/guides/update.md @@ -0,0 +1,26 @@ +# 更新与升级 + +## 自动检测 + +- `neocode` 启动时会在后台静默检测最新版本(默认 3 秒超时)。 +- 为避免干扰 Bubble Tea TUI 交互,更新提示会在应用退出、终端屏幕恢复后输出。 +- `url-dispatch` 子命令会跳过该检测流程。 + +## 手动升级 + +使用以下命令升级到最新稳定版: + +```bash +neocode update +``` + +如需包含预发布版本: + +```bash +neocode update --prerelease +``` + +## 版本来源 + +- 发布构建会通过 `ldflags` 注入版本号到 `internal/version.Version`。 +- 本地开发构建默认版本为 `dev`。 diff --git a/go.mod b/go.mod index 8d4b20dd..d5cfe3dd 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/glamour v1.0.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 + github.com/creativeprojects/go-selfupdate v1.5.2 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 golang.design/x/clipboard v0.7.1 @@ -16,6 +17,9 @@ require ( ) require ( + code.gitea.io/sdk/gitea v0.22.1 // indirect + github.com/42wim/httpsig v1.2.3 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/alecthomas/chroma/v2 v2.20.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect @@ -31,12 +35,19 @@ require ( github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/davidmz/go-pageant v1.0.2 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-fed/httpsig v1.1.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/google/go-github/v74 v74.0.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect github.com/gorilla/css v1.0.1 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/go-version v1.8.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -61,15 +72,20 @@ require ( github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/ulikunitz/xz v0.5.15 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.7.13 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect + gitlab.com/gitlab-org/api/client-go v1.9.1 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.49.0 // indirect golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476 // indirect golang.org/x/image v0.28.0 // indirect golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f // indirect + golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/term v0.41.0 // indirect golang.org/x/text v0.35.0 // indirect - google.golang.org/protobuf v1.36.8 // indirect + golang.org/x/time v0.14.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index 9feda321..1e41deec 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,11 @@ +code.gitea.io/sdk/gitea v0.22.1 h1:7K05KjRORyTcTYULQ/AwvlVS6pawLcWyXZcTr7gHFyA= +code.gitea.io/sdk/gitea v0.22.1/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= +github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs= +github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= @@ -47,8 +53,12 @@ github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3 github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creativeprojects/go-selfupdate v1.5.2 h1:3KR3JLrq70oplb9yZzbmJ89qRP78D1AN/9u+l3k0LJ4= +github.com/creativeprojects/go-selfupdate v1.5.2/go.mod h1:BCOuwIl1dRRCmPNRPH0amULeZqayhKyY2mH/h4va7Dk= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= +github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -59,13 +69,26 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= +github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-github/v74 v74.0.0 h1:yZcddTUn8DPbj11GxnMrNiAnXH14gNs559AsUpNpPgM= +github.com/google/go-github/v74 v74.0.0/go.mod h1:ubn/YdyftV80VPSI26nSJvaEsTOnsjrxG3o9kJhcyak= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= +github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -137,18 +160,27 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= +github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= +gitlab.com/gitlab-org/api/client-go v1.9.1 h1:tZm+URa36sVy8UCEHQyGGJ8COngV4YqMHpM6k9O5tK8= +gitlab.com/gitlab-org/api/client-go v1.9.1/go.mod h1:71yTJk1lnHCWcZLvM5kPAXzeJ2fn5GjaoV8gTOPd4ME= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.design/x/clipboard v0.7.1 h1:OEG3CmcYRBNnRwpDp7+uWLiZi3hrMRJpE9JkkkYtz2c= golang.design/x/clipboard v0.7.1/go.mod h1:i5SiIqj0wLFw9P/1D7vfILFK0KHMk7ydE72HRrUIgkg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476 h1:Wdx0vgH5Wgsw+lF//LJKmWOJBLWX6nprsMqnf99rYDE= @@ -157,18 +189,34 @@ golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE= golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY= golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f h1:/n+PL2HlfqeSiDCuhdBbRNlGS/g2fM4OHufalHaTVG8= golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f/go.mod h1:ESkJ836Z6LpG6mTVAhA48LpfW/8fNR0ifStlH2axyfg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/cli/root.go b/internal/cli/root.go index 98af9e8a..390f0840 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -3,18 +3,25 @@ package cli import ( "context" "errors" + "fmt" "strings" + "time" "github.com/spf13/cobra" "github.com/spf13/viper" "neo-code/internal/app" "neo-code/internal/config" + "neo-code/internal/updater" + "neo-code/internal/version" ) var launchRootProgram = defaultRootProgramLauncher var newRootProgram = app.NewProgram var runGlobalPreload = defaultGlobalPreload +var runSilentUpdateCheck = defaultSilentUpdateCheck + +const silentUpdateCheckTimeout = 3 * time.Second // GlobalFlags 描述 CLI 根命令当前支持的全局参数。 type GlobalFlags struct { @@ -24,6 +31,7 @@ type GlobalFlags struct { // Execute 负责执行 NeoCode 的 CLI 根命令。 func Execute(ctx context.Context) error { app.EnsureConsoleUTF8() + _ = ConsumeUpdateNotice() return NewRootCommand().ExecuteContext(ctx) } @@ -41,7 +49,11 @@ func NewRootCommand() *cobra.Command { if shouldSkipGlobalPreload(cmd) { return nil } - return runGlobalPreload(cmd.Context()) + if err := runGlobalPreload(cmd.Context()); err != nil { + return err + } + runSilentUpdateCheck(cmd.Context()) + return nil }, RunE: func(cmd *cobra.Command, args []string) error { flags.Workdir = strings.TrimSpace(settings.GetString("workdir")) @@ -56,6 +68,7 @@ func NewRootCommand() *cobra.Command { cmd.AddCommand( newGatewayCommand(), newURLDispatchCommand(), + newUpdateCommand(), ) return cmd @@ -92,6 +105,32 @@ func defaultGlobalPreload(ctx context.Context) error { return config.LoadPersistedEnv("") } +// defaultSilentUpdateCheck 在后台异步检查新版本并缓存退出后提示文案。 +func defaultSilentUpdateCheck(ctx context.Context) { + currentVersion := version.Current() + if !version.IsSemverRelease(currentVersion) { + return + } + parentCtx := context.WithoutCancel(ctx) + + go func(parent context.Context, currentVersion string) { + checkCtx, cancel := context.WithTimeout(parent, silentUpdateCheckTimeout) + defer cancel() + + result, err := updater.CheckLatest(checkCtx, updater.CheckOptions{ + CurrentVersion: currentVersion, + IncludePrerelease: false, + }) + if err != nil || !result.HasUpdate { + return + } + if strings.TrimSpace(result.LatestVersion) == "" { + return + } + setUpdateNotice(fmt.Sprintf("🚀 发现新版本: %s,运行 neocode update 即可升级", result.LatestVersion)) + }(parentCtx, currentVersion) +} + // shouldSkipGlobalPreload 判断当前命令是否应跳过全局预加载逻辑。 func shouldSkipGlobalPreload(cmd *cobra.Command) bool { if cmd == nil { diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index d6c514e5..30342402 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -21,6 +21,10 @@ import ( gatewayauth "neo-code/internal/gateway/auth" ) +func init() { + runSilentUpdateCheck = func(context.Context) {} +} + func TestNewRootCommandPassesWorkdirFlagToLauncher(t *testing.T) { originalLauncher := launchRootProgram t.Cleanup(func() { launchRootProgram = originalLauncher }) @@ -1192,6 +1196,67 @@ func TestShouldSkipGlobalPreload(t *testing.T) { } } +func TestRootCommandRunsSilentUpdateCheckAfterPreload(t *testing.T) { + originalLauncher := launchRootProgram + originalPreload := runGlobalPreload + originalSilentCheck := runSilentUpdateCheck + t.Cleanup(func() { launchRootProgram = originalLauncher }) + t.Cleanup(func() { runGlobalPreload = originalPreload }) + t.Cleanup(func() { runSilentUpdateCheck = originalSilentCheck }) + + events := make([]string, 0, 3) + runGlobalPreload = func(context.Context) error { + events = append(events, "preload") + return nil + } + runSilentUpdateCheck = func(context.Context) { + events = append(events, "check") + } + launchRootProgram = func(context.Context, app.BootstrapOptions) error { + events = append(events, "run") + return nil + } + + command := NewRootCommand() + command.SetArgs([]string{}) + if err := command.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext() error = %v", err) + } + want := []string{"preload", "check", "run"} + if len(events) != len(want) { + t.Fatalf("events = %v, want %v", events, want) + } + for i := range want { + if events[i] != want[i] { + t.Fatalf("events[%d] = %q, want %q", i, events[i], want[i]) + } + } +} + +func TestURLDispatchSkipsSilentUpdateCheck(t *testing.T) { + originalSilentCheck := runSilentUpdateCheck + originalRunner := runURLDispatchCommand + t.Cleanup(func() { runSilentUpdateCheck = originalSilentCheck }) + t.Cleanup(func() { runURLDispatchCommand = originalRunner }) + + var called bool + runSilentUpdateCheck = func(context.Context) { + called = true + } + runURLDispatchCommand = func(context.Context, urlDispatchCommandOptions) error { + return nil + } + + command := NewRootCommand() + command.SetArgs([]string{"url-dispatch", "--url", "neocode://review?path=README.md"}) + 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 url-dispatch") + } +} + func TestDefaultGlobalPreloadLoadsPersistedEnv(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) diff --git a/internal/cli/update_command.go b/internal/cli/update_command.go new file mode 100644 index 00000000..09ee762f --- /dev/null +++ b/internal/cli/update_command.go @@ -0,0 +1,60 @@ +package cli + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "neo-code/internal/updater" + "neo-code/internal/version" +) + +type updateCommandOptions struct { + IncludePrerelease bool +} + +var runUpdateCommand = defaultUpdateCommandRunner + +// newUpdateCommand 创建 update 子命令并绑定升级参数。 +func newUpdateCommand() *cobra.Command { + options := &updateCommandOptions{} + + cmd := &cobra.Command{ + Use: "update", + Short: "Update neocode to the latest release", + SilenceUsage: true, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + result, err := runUpdateCommand(cmd.Context(), *options) + if err != nil { + return err + } + + out := cmd.OutOrStdout() + if !result.Updated { + latest := strings.TrimSpace(result.LatestVersion) + if latest == "" { + latest = "unknown" + } + _, _ = fmt.Fprintf(out, "Already up-to-date (latest: %s).\n", latest) + return nil + } + + _, _ = fmt.Fprintf(out, "Updated successfully: %s -> %s\n", result.CurrentVersion, result.LatestVersion) + return nil + }, + } + + cmd.Flags().BoolVar(&options.IncludePrerelease, "prerelease", false, "include prerelease versions") + return cmd +} + +// defaultUpdateCommandRunner 执行手动升级流程并返回升级结果。 +func defaultUpdateCommandRunner(ctx context.Context, options updateCommandOptions) (updater.UpdateResult, error) { + return updater.DoUpdate(ctx, updater.UpdateOptions{ + CurrentVersion: version.Current(), + IncludePrerelease: options.IncludePrerelease, + }) +} diff --git a/internal/cli/update_command_test.go b/internal/cli/update_command_test.go new file mode 100644 index 00000000..13699be7 --- /dev/null +++ b/internal/cli/update_command_test.go @@ -0,0 +1,85 @@ +package cli + +import ( + "bytes" + "context" + "testing" + + "neo-code/internal/updater" +) + +func TestUpdateCommandPassesPrereleaseFlag(t *testing.T) { + originalRunner := runUpdateCommand + originalPreload := runGlobalPreload + originalSilentCheck := runSilentUpdateCheck + t.Cleanup(func() { runUpdateCommand = originalRunner }) + t.Cleanup(func() { runGlobalPreload = originalPreload }) + t.Cleanup(func() { runSilentUpdateCheck = originalSilentCheck }) + + runGlobalPreload = func(context.Context) error { return nil } + runSilentUpdateCheck = func(context.Context) {} + + var received updateCommandOptions + runUpdateCommand = func(_ context.Context, options updateCommandOptions) (updater.UpdateResult, error) { + received = options + return updater.UpdateResult{Updated: false, LatestVersion: "v0.2.1"}, nil + } + + command := NewRootCommand() + var stdout bytes.Buffer + command.SetOut(&stdout) + command.SetArgs([]string{"update", "--prerelease"}) + 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 got := stdout.String(); got == "" { + t.Fatal("expected update command output") + } +} + +func TestUpdateCommandShowsSuccessMessage(t *testing.T) { + originalRunner := runUpdateCommand + originalPreload := runGlobalPreload + originalSilentCheck := runSilentUpdateCheck + t.Cleanup(func() { runUpdateCommand = originalRunner }) + t.Cleanup(func() { runGlobalPreload = originalPreload }) + t.Cleanup(func() { runSilentUpdateCheck = originalSilentCheck }) + + runGlobalPreload = func(context.Context) error { return nil } + runSilentUpdateCheck = func(context.Context) {} + runUpdateCommand = func(context.Context, updateCommandOptions) (updater.UpdateResult, error) { + return updater.UpdateResult{ + CurrentVersion: "v0.1.0", + LatestVersion: "v0.2.1", + Updated: true, + }, nil + } + + command := NewRootCommand() + var stdout bytes.Buffer + command.SetOut(&stdout) + command.SetArgs([]string{"update"}) + if err := command.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext() error = %v", err) + } + + if got := stdout.String(); got == "" || !bytes.Contains(stdout.Bytes(), []byte("Updated successfully")) { + t.Fatalf("unexpected output: %q", got) + } +} + +func TestConsumeUpdateNoticeOnce(t *testing.T) { + _ = ConsumeUpdateNotice() + setUpdateNotice(" new version ") + + if got := ConsumeUpdateNotice(); got != "new version" { + t.Fatalf("ConsumeUpdateNotice() = %q, want %q", got, "new version") + } + if got := ConsumeUpdateNotice(); got != "" { + t.Fatalf("ConsumeUpdateNotice() second call = %q, want empty", got) + } +} diff --git a/internal/cli/update_notice.go b/internal/cli/update_notice.go new file mode 100644 index 00000000..8a645a33 --- /dev/null +++ b/internal/cli/update_notice.go @@ -0,0 +1,33 @@ +package cli + +import ( + "strings" + "sync" +) + +var ( + updateNoticeMu sync.Mutex + pendingUpdateNotice string +) + +// setUpdateNotice 保存待输出的更新提示,后写入会覆盖先前值。 +func setUpdateNotice(notice string) { + normalized := strings.TrimSpace(notice) + if normalized == "" { + return + } + + updateNoticeMu.Lock() + pendingUpdateNotice = normalized + updateNoticeMu.Unlock() +} + +// ConsumeUpdateNotice 读取并清空待输出的更新提示,确保只消费一次。 +func ConsumeUpdateNotice() string { + updateNoticeMu.Lock() + defer updateNoticeMu.Unlock() + + notice := pendingUpdateNotice + pendingUpdateNotice = "" + return notice +} diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index a93c3d0f..34efb42d 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "runtime" "strings" "testing" @@ -1044,11 +1045,19 @@ func TestDeleteCustomProviderRemovesProviderDir(t *testing.T) { func TestLoadCustomProvidersReadDirAndStatErrors(t *testing.T) { t.Run("providers dir read error", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows does not support chmod 000 for directories") + } + baseDir := t.TempDir() providersPath := filepath.Join(baseDir, providersDirName) - if err := os.WriteFile(providersPath, []byte("file"), 0o600); err != nil { - t.Fatalf("WriteFile() error = %v", err) + if err := os.MkdirAll(providersPath, 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.Chmod(providersPath, 0o000); err != nil { + t.Fatalf("Chmod() error = %v", err) } + defer func() { _ = os.Chmod(providersPath, 0o755) }() _, err := loadCustomProviders(baseDir) if err == nil { diff --git a/internal/updater/updater.go b/internal/updater/updater.go new file mode 100644 index 00000000..4166b643 --- /dev/null +++ b/internal/updater/updater.go @@ -0,0 +1,252 @@ +package updater + +import ( + "context" + "errors" + "fmt" + "regexp" + "runtime" + "strings" + + selfupdate "github.com/creativeprojects/go-selfupdate" + + "neo-code/internal/version" +) + +const ( + repositoryOwner = "1024XEngineer" + repositoryName = "neo-code" + checksumFilename = "checksums.txt" +) + +var ( + runtimeGOOS = runtime.GOOS + runtimeGOARCH = runtime.GOARCH +) + +var ( + newClient = func(config selfupdate.Config) (updateClient, error) { + updater, err := selfupdate.NewUpdater(config) + if err != nil { + return nil, err + } + return selfupdateClient{updater: updater}, nil + } + resolveExecutablePath = selfupdate.ExecutablePath +) + +type assetTarget struct { + OSToken string + ArchToken string + Ext string + AssetName string +} + +type releaseView interface { + Version() string + GreaterThan(other string) bool +} + +type updateClient interface { + DetectLatest(ctx context.Context, repository selfupdate.Repository) (releaseView, bool, error) + UpdateTo(ctx context.Context, rel releaseView, cmdPath string) error +} + +type selfupdateClient struct { + updater *selfupdate.Updater +} + +type selfupdateRelease struct { + release *selfupdate.Release +} + +// CheckOptions 描述静默检测新版本时的输入参数。 +type CheckOptions struct { + CurrentVersion string + IncludePrerelease bool +} + +// CheckResult 表示静默检测流程返回的版本信息。 +type CheckResult struct { + CurrentVersion string + LatestVersion string + HasUpdate bool +} + +// UpdateOptions 描述手动更新命令的输入参数。 +type UpdateOptions struct { + CurrentVersion string + IncludePrerelease bool +} + +// UpdateResult 表示手动更新流程的最终结果。 +type UpdateResult struct { + CurrentVersion string + LatestVersion string + Updated bool +} + +// CheckLatest 按当前平台资产规则检测最新版本,不做本地文件替换。 +func CheckLatest(ctx context.Context, opts CheckOptions) (CheckResult, error) { + currentVersion := normalizeCurrentVersion(opts.CurrentVersion) + target, err := resolveAssetTarget(runtimeGOOS, runtimeGOARCH) + if err != nil { + return CheckResult{CurrentVersion: currentVersion}, err + } + + client, err := newClient(buildSelfupdateConfig(target, opts.IncludePrerelease)) + if err != nil { + return CheckResult{CurrentVersion: currentVersion}, err + } + + repository := selfupdate.NewRepositorySlug(repositoryOwner, repositoryName) + release, found, err := client.DetectLatest(ctx, repository) + if err != nil { + return CheckResult{CurrentVersion: currentVersion}, err + } + + result := CheckResult{CurrentVersion: currentVersion} + if !found || release == nil { + return result, nil + } + + result.LatestVersion = strings.TrimSpace(release.Version()) + if result.LatestVersion == "" { + return result, nil + } + + if version.IsSemverRelease(currentVersion) { + result.HasUpdate = release.GreaterThan(currentVersion) + } + return result, nil +} + +// DoUpdate 下载并校验最新版本后原地替换当前可执行文件。 +func DoUpdate(ctx context.Context, opts UpdateOptions) (UpdateResult, error) { + currentVersion := normalizeCurrentVersion(opts.CurrentVersion) + target, err := resolveAssetTarget(runtimeGOOS, runtimeGOARCH) + if err != nil { + return UpdateResult{CurrentVersion: currentVersion}, err + } + + client, err := newClient(buildSelfupdateConfig(target, opts.IncludePrerelease)) + if err != nil { + return UpdateResult{CurrentVersion: currentVersion}, err + } + + repository := selfupdate.NewRepositorySlug(repositoryOwner, repositoryName) + release, found, err := client.DetectLatest(ctx, repository) + 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, + } + + if version.IsSemverRelease(currentVersion) && !release.GreaterThan(currentVersion) { + return result, nil + } + + executablePath, err := resolveExecutablePath() + if err != nil { + return result, err + } + + if err := client.UpdateTo(ctx, release, executablePath); err != nil { + return result, err + } + + result.Updated = true + 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) + if !ok || typed.release == nil { + return errors.New("updater: unsupported release type") + } + return c.updater.UpdateTo(ctx, typed.release, cmdPath) +} + +// Version 返回底层 release 的语义化版本字符串。 +func (r selfupdateRelease) Version() string { + return strings.TrimSpace(r.release.Version()) +} + +// GreaterThan 判断底层 release 是否高于指定版本。 +func (r selfupdateRelease) GreaterThan(other string) bool { + return r.release.GreaterThan(other) +} + +// normalizeCurrentVersion 归一化当前版本输入并处理空值回退。 +func normalizeCurrentVersion(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "dev" + } + return trimmed +} + +// buildSelfupdateConfig 构建严格资产匹配与 checksum 校验配置。 +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 产物命名约束生成当前平台目标资产名。 +func resolveAssetTarget(goos string, goarch string) (assetTarget, error) { + var osToken string + switch strings.ToLower(strings.TrimSpace(goos)) { + case "linux": + osToken = "Linux" + case "darwin": + osToken = "Darwin" + case "windows": + osToken = "Windows" + default: + return assetTarget{}, fmt.Errorf("updater: unsupported os %q", goos) + } + + var archToken string + switch strings.ToLower(strings.TrimSpace(goarch)) { + case "amd64": + archToken = "x86_64" + case "arm64": + archToken = "arm64" + default: + return assetTarget{}, fmt.Errorf("updater: unsupported arch %q", goarch) + } + + ext := "tar.gz" + if osToken == "Windows" { + ext = "zip" + } + + return assetTarget{ + OSToken: osToken, + ArchToken: archToken, + Ext: ext, + AssetName: fmt.Sprintf("neocode_%s_%s.%s", osToken, archToken, ext), + }, nil +} diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go new file mode 100644 index 00000000..2c724777 --- /dev/null +++ b/internal/updater/updater_test.go @@ -0,0 +1,327 @@ +package updater + +import ( + "context" + "errors" + "regexp" + "testing" + + selfupdate "github.com/creativeprojects/go-selfupdate" +) + +type fakeRelease struct { + version string + greaterFn func(string) bool +} + +func (r fakeRelease) Version() string { + return r.version +} + +func (r fakeRelease) GreaterThan(other string) bool { + if r.greaterFn != nil { + return r.greaterFn(other) + } + return false +} + +type fakeClient struct { + release releaseView + found bool + detectErr error + updateErr error + updateCalls int + lastUpdatePath string +} + +func (c *fakeClient) DetectLatest(context.Context, selfupdate.Repository) (releaseView, bool, error) { + return c.release, c.found, c.detectErr +} + +func (c *fakeClient) UpdateTo(_ context.Context, rel releaseView, cmdPath string) error { + _ = rel + c.updateCalls++ + c.lastUpdatePath = cmdPath + return c.updateErr +} + +func TestResolveAssetTarget(t *testing.T) { + tests := []struct { + name string + goos string + goarch string + wantOS string + wantArch string + wantExt string + wantAsset string + expectErrMsg string + }{ + { + name: "linux amd64", + goos: "linux", + goarch: "amd64", + wantOS: "Linux", + wantArch: "x86_64", + wantExt: "tar.gz", + wantAsset: "neocode_Linux_x86_64.tar.gz", + }, + { + name: "darwin arm64", + goos: "darwin", + goarch: "arm64", + wantOS: "Darwin", + wantArch: "arm64", + wantExt: "tar.gz", + wantAsset: "neocode_Darwin_arm64.tar.gz", + }, + { + name: "windows amd64", + goos: "windows", + goarch: "amd64", + wantOS: "Windows", + wantArch: "x86_64", + wantExt: "zip", + wantAsset: "neocode_Windows_x86_64.zip", + }, + { + name: "unsupported os", + goos: "freebsd", + goarch: "amd64", + expectErrMsg: "unsupported os", + }, + { + name: "unsupported arch", + goos: "linux", + goarch: "386", + expectErrMsg: "unsupported arch", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + target, err := resolveAssetTarget(tt.goos, tt.goarch) + if tt.expectErrMsg != "" { + if err == nil || !regexp.MustCompile(tt.expectErrMsg).MatchString(err.Error()) { + t.Fatalf("resolveAssetTarget() error = %v, want contains %q", err, tt.expectErrMsg) + } + return + } + if err != nil { + t.Fatalf("resolveAssetTarget() error = %v", err) + } + if target.OSToken != tt.wantOS { + t.Fatalf("OSToken = %q, want %q", target.OSToken, tt.wantOS) + } + if target.ArchToken != tt.wantArch { + t.Fatalf("ArchToken = %q, want %q", target.ArchToken, tt.wantArch) + } + if target.Ext != tt.wantExt { + t.Fatalf("Ext = %q, want %q", target.Ext, tt.wantExt) + } + if target.AssetName != tt.wantAsset { + t.Fatalf("AssetName = %q, want %q", target.AssetName, tt.wantAsset) + } + }) + } +} + +func TestBuildSelfupdateConfigUsesExactFilterAndChecksum(t *testing.T) { + target := assetTarget{ + OSToken: "Darwin", + ArchToken: "x86_64", + Ext: "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.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") + } + validator, ok := config.Validator.(*selfupdate.ChecksumValidator) + if !ok { + t.Fatalf("validator type = %T, want *selfupdate.ChecksumValidator", config.Validator) + } + if validator.UniqueFilename != checksumFilename { + t.Fatalf("UniqueFilename = %q, want %q", validator.UniqueFilename, checksumFilename) + } +} + +func TestCheckLatest(t *testing.T) { + originalNewClient := newClient + originalGOOS := runtimeGOOS + originalGOARCH := runtimeGOARCH + t.Cleanup(func() { + newClient = originalNewClient + runtimeGOOS = originalGOOS + runtimeGOARCH = originalGOARCH + }) + runtimeGOOS = "linux" + runtimeGOARCH = "amd64" + + client := &fakeClient{ + release: fakeRelease{ + version: "v1.2.0", + greaterFn: func(other string) bool { + return other == "v1.1.0" + }, + }, + found: true, + } + newClient = func(config selfupdate.Config) (updateClient, error) { + return client, nil + } + + result, err := CheckLatest(context.Background(), CheckOptions{ + CurrentVersion: "v1.1.0", + IncludePrerelease: false, + }) + if err != nil { + t.Fatalf("CheckLatest() error = %v", err) + } + if !result.HasUpdate { + t.Fatal("expected HasUpdate to be true") + } + if result.LatestVersion != "v1.2.0" { + t.Fatalf("LatestVersion = %q, want %q", result.LatestVersion, "v1.2.0") + } +} + +func TestDoUpdateSkipsWhenAlreadyLatestForSemver(t *testing.T) { + originalNewClient := newClient + originalGOOS := runtimeGOOS + originalGOARCH := runtimeGOARCH + t.Cleanup(func() { + newClient = originalNewClient + runtimeGOOS = originalGOOS + runtimeGOARCH = originalGOARCH + }) + runtimeGOOS = "linux" + runtimeGOARCH = "amd64" + + client := &fakeClient{ + release: fakeRelease{ + version: "v1.2.0", + greaterFn: func(other string) bool { + return false + }, + }, + found: true, + } + newClient = func(config selfupdate.Config) (updateClient, error) { + return client, nil + } + + result, err := DoUpdate(context.Background(), UpdateOptions{CurrentVersion: "v1.2.0"}) + if err != nil { + t.Fatalf("DoUpdate() error = %v", err) + } + if result.Updated { + t.Fatal("expected Updated to be false") + } + if client.updateCalls != 0 { + t.Fatalf("update calls = %d, want 0", client.updateCalls) + } +} + +func TestDoUpdateUsesUpdaterLibraryPathForWindows(t *testing.T) { + originalNewClient := newClient + originalExePath := resolveExecutablePath + originalGOOS := runtimeGOOS + originalGOARCH := runtimeGOARCH + t.Cleanup(func() { + newClient = originalNewClient + resolveExecutablePath = originalExePath + runtimeGOOS = originalGOOS + runtimeGOARCH = originalGOARCH + }) + runtimeGOOS = "windows" + runtimeGOARCH = "amd64" + + client := &fakeClient{ + release: fakeRelease{ + version: "v1.3.0", + greaterFn: func(other string) bool { + return false + }, + }, + found: true, + } + + var capturedConfig selfupdate.Config + newClient = func(config selfupdate.Config) (updateClient, error) { + capturedConfig = config + return client, nil + } + resolveExecutablePath = func() (string, error) { + return `C:\Tools\neocode.exe`, nil + } + + result, err := DoUpdate(context.Background(), UpdateOptions{CurrentVersion: "dev"}) + if err != nil { + t.Fatalf("DoUpdate() error = %v", err) + } + if !result.Updated { + t.Fatal("expected Updated to be true") + } + if client.updateCalls != 1 { + t.Fatalf("update calls = %d, want 1", client.updateCalls) + } + 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") + } +} + +func TestDoUpdatePropagatesUpdateError(t *testing.T) { + originalNewClient := newClient + originalExePath := resolveExecutablePath + originalGOOS := runtimeGOOS + originalGOARCH := runtimeGOARCH + t.Cleanup(func() { + newClient = originalNewClient + resolveExecutablePath = originalExePath + runtimeGOOS = originalGOOS + runtimeGOARCH = originalGOARCH + }) + runtimeGOOS = "linux" + runtimeGOARCH = "amd64" + + expected := errors.New("apply update failed") + client := &fakeClient{ + release: fakeRelease{ + version: "v1.3.0", + greaterFn: func(other string) bool { + return true + }, + }, + found: true, + updateErr: expected, + } + + newClient = func(config selfupdate.Config) (updateClient, error) { + return client, nil + } + resolveExecutablePath = func() (string, error) { + return "/usr/local/bin/neocode", nil + } + + _, err := DoUpdate(context.Background(), UpdateOptions{CurrentVersion: "v1.2.0"}) + if !errors.Is(err, expected) { + t.Fatalf("DoUpdate() error = %v, want %v", err, expected) + } +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 00000000..9f8ea7af --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,25 @@ +package version + +import ( + "regexp" + "strings" +) + +var semverPattern = regexp.MustCompile(`^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$`) + +// Version 表示当前构建注入的版本号;默认值用于本地开发构建。 +var Version = "dev" + +// Current 返回归一化后的当前版本;空值会回退为 dev。 +func Current() string { + value := strings.TrimSpace(Version) + if value == "" { + return "dev" + } + return value +} + +// IsSemverRelease 判断给定版本字符串是否为可比较的语义化版本。 +func IsSemverRelease(value string) bool { + return semverPattern.MatchString(strings.TrimSpace(value)) +} diff --git a/internal/version/version_test.go b/internal/version/version_test.go new file mode 100644 index 00000000..befdd017 --- /dev/null +++ b/internal/version/version_test.go @@ -0,0 +1,41 @@ +package version + +import "testing" + +func TestCurrentFallsBackToDev(t *testing.T) { + original := Version + t.Cleanup(func() { Version = original }) + + Version = " " + if got := Current(); got != "dev" { + t.Fatalf("Current() = %q, want %q", got, "dev") + } + + Version = " v1.2.3 " + if got := Current(); got != "v1.2.3" { + t.Fatalf("Current() = %q, want %q", got, "v1.2.3") + } +} + +func TestIsSemverRelease(t *testing.T) { + tests := []struct { + name string + value string + matched bool + }{ + {name: "with v prefix", value: "v1.2.3", matched: true}, + {name: "without v prefix", value: "1.2.3", matched: true}, + {name: "prerelease", value: "v1.2.3-rc.1", matched: true}, + {name: "build metadata", value: "v1.2.3+meta", matched: true}, + {name: "dev", value: "dev", matched: false}, + {name: "missing patch", value: "v1.2", matched: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsSemverRelease(tt.value); got != tt.matched { + t.Fatalf("IsSemverRelease(%q) = %v, want %v", tt.value, got, tt.matched) + } + }) + } +} From 4edc502261152ec735dd51d06f5a48acdded1894 Mon Sep 17 00:00:00 2001 From: pionxe Date: Fri, 17 Apr 2026 19:33:12 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(cli):=20=E4=BF=AE=E5=A4=8D=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=9B=B4=E6=96=B0=E5=B9=B6=E5=8F=91=E5=86=B2=E7=AA=81?= =?UTF-8?q?=E3=80=81=E7=BB=88=E7=AB=AF=E6=B3=A8=E5=85=A5=E9=A3=8E=E9=99=A9?= =?UTF-8?q?=E5=8F=8A=E7=BD=91=E7=BB=9C=E9=98=BB=E5=A1=9E=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 拦截 `update` 命令的静默检测逻辑,避免与手动升级流程产生并发冲突及输出过期提示 - 新增 `sanitizeVersionForTerminal`,剥离远端版本字符串中的 ANSI 控制序列,防止终端转义注入 - 为 `neocode update` 手动升级命令增加 5 分钟显式超时上下文,并优化网络超时错误提示 - 补全边界场景单元测试(涵盖跳过静默检测、字符串清洗及网络超时触发分支) --- internal/cli/root.go | 45 ++++++++-- internal/cli/root_test.go | 113 +++++++++++++++++++++++ internal/cli/update_command.go | 19 +++- internal/cli/update_command_test.go | 133 ++++++++++++++++++++++++++++ 4 files changed, 304 insertions(+), 6 deletions(-) diff --git a/internal/cli/root.go b/internal/cli/root.go index 390f0840..4041fc0b 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "regexp" "strings" "time" @@ -20,9 +21,13 @@ var launchRootProgram = defaultRootProgramLauncher var newRootProgram = app.NewProgram var runGlobalPreload = defaultGlobalPreload var runSilentUpdateCheck = defaultSilentUpdateCheck +var readCurrentVersion = version.Current +var checkLatestRelease = updater.CheckLatest const silentUpdateCheckTimeout = 3 * time.Second +var ansiEscapeSequencePattern = regexp.MustCompile(`\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\)|[@-Z\\-_])`) + // GlobalFlags 描述 CLI 根命令当前支持的全局参数。 type GlobalFlags struct { Workdir string @@ -52,7 +57,9 @@ func NewRootCommand() *cobra.Command { if err := runGlobalPreload(cmd.Context()); err != nil { return err } - runSilentUpdateCheck(cmd.Context()) + if !shouldSkipSilentUpdateCheck(cmd) { + runSilentUpdateCheck(cmd.Context()) + } return nil }, RunE: func(cmd *cobra.Command, args []string) error { @@ -107,7 +114,7 @@ func defaultGlobalPreload(ctx context.Context) error { // defaultSilentUpdateCheck 在后台异步检查新版本并缓存退出后提示文案。 func defaultSilentUpdateCheck(ctx context.Context) { - currentVersion := version.Current() + currentVersion := readCurrentVersion() if !version.IsSemverRelease(currentVersion) { return } @@ -117,17 +124,19 @@ func defaultSilentUpdateCheck(ctx context.Context) { checkCtx, cancel := context.WithTimeout(parent, silentUpdateCheckTimeout) defer cancel() - result, err := updater.CheckLatest(checkCtx, updater.CheckOptions{ + result, err := checkLatestRelease(checkCtx, updater.CheckOptions{ CurrentVersion: currentVersion, IncludePrerelease: false, }) if err != nil || !result.HasUpdate { return } - if strings.TrimSpace(result.LatestVersion) == "" { + + latestVersion := sanitizeVersionForTerminal(result.LatestVersion) + if latestVersion == "" { return } - setUpdateNotice(fmt.Sprintf("🚀 发现新版本: %s,运行 neocode update 即可升级", result.LatestVersion)) + setUpdateNotice(fmt.Sprintf("\u53d1\u73b0\u65b0\u7248\u672c: %s\uff0c\u8fd0\u884c neocode update \u5373\u53ef\u5347\u7ea7", latestVersion)) }(parentCtx, currentVersion) } @@ -138,3 +147,29 @@ func shouldSkipGlobalPreload(cmd *cobra.Command) bool { } return strings.EqualFold(strings.TrimSpace(cmd.Name()), "url-dispatch") } + +// shouldSkipSilentUpdateCheck 判断当前命令是否应跳过静默更新检测。 +func shouldSkipSilentUpdateCheck(cmd *cobra.Command) bool { + if cmd == nil { + return false + } + switch strings.ToLower(strings.TrimSpace(cmd.Name())) { + case "url-dispatch", "update": + return true + default: + return false + } +} + +// sanitizeVersionForTerminal 清洗远端版本字符串,避免 ANSI 控制序列或不可见字符污染终端输出。 +func sanitizeVersionForTerminal(version string) string { + cleaned := ansiEscapeSequencePattern.ReplaceAllString(version, "") + 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()) +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 30342402..432e7b28 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -10,6 +10,7 @@ import ( "path/filepath" "strings" "testing" + "time" tea "github.com/charmbracelet/bubbletea" "github.com/spf13/cobra" @@ -19,6 +20,7 @@ import ( "neo-code/internal/gateway" "neo-code/internal/gateway/adapters/urlscheme" gatewayauth "neo-code/internal/gateway/auth" + "neo-code/internal/updater" ) func init() { @@ -1196,6 +1198,21 @@ func TestShouldSkipGlobalPreload(t *testing.T) { } } +func TestShouldSkipSilentUpdateCheck(t *testing.T) { + if !shouldSkipSilentUpdateCheck(&cobra.Command{Use: "url-dispatch"}) { + t.Fatal("url-dispatch should skip silent update check") + } + if !shouldSkipSilentUpdateCheck(&cobra.Command{Use: "update"}) { + t.Fatal("update should skip silent update check") + } + if shouldSkipSilentUpdateCheck(&cobra.Command{Use: "gateway"}) { + t.Fatal("gateway should not skip silent update check") + } + if shouldSkipSilentUpdateCheck(nil) { + t.Fatal("nil command should not skip silent update check") + } +} + func TestRootCommandRunsSilentUpdateCheckAfterPreload(t *testing.T) { originalLauncher := launchRootProgram originalPreload := runGlobalPreload @@ -1257,6 +1274,102 @@ func TestURLDispatchSkipsSilentUpdateCheck(t *testing.T) { } } +func TestUpdateCommandSkipsSilentUpdateCheck(t *testing.T) { + originalSilentCheck := runSilentUpdateCheck + originalRunner := runUpdateCommand + t.Cleanup(func() { runSilentUpdateCheck = originalSilentCheck }) + t.Cleanup(func() { runUpdateCommand = originalRunner }) + + var called bool + runSilentUpdateCheck = func(context.Context) { + called = true + } + runUpdateCommand = func(context.Context, updateCommandOptions) (updater.UpdateResult, error) { + return updater.UpdateResult{Updated: false, LatestVersion: "v0.2.1"}, nil + } + + command := NewRootCommand() + command.SetArgs([]string{"update"}) + 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 update 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" { + t.Fatalf("sanitizeVersionForTerminal() = %q, want %q", got, "v0.2.1") + } +} + +func TestDefaultSilentUpdateCheckSkipsForNonReleaseVersion(t *testing.T) { + originalVersionReader := readCurrentVersion + originalCheckLatest := checkLatestRelease + t.Cleanup(func() { readCurrentVersion = originalVersionReader }) + t.Cleanup(func() { checkLatestRelease = originalCheckLatest }) + + readCurrentVersion = func() string { return "dev" } + + var called bool + checkLatestRelease = func(context.Context, updater.CheckOptions) (updater.CheckResult, error) { + called = true + return updater.CheckResult{}, nil + } + + defaultSilentUpdateCheck(context.Background()) + if called { + t.Fatal("expected release check to be skipped for non-semver version") + } +} + +func TestDefaultSilentUpdateCheckSetsSanitizedNotice(t *testing.T) { + _ = ConsumeUpdateNotice() + + originalVersionReader := readCurrentVersion + originalCheckLatest := checkLatestRelease + t.Cleanup(func() { readCurrentVersion = originalVersionReader }) + t.Cleanup(func() { checkLatestRelease = originalCheckLatest }) + + readCurrentVersion = func() string { return "v0.1.0" } + done := make(chan struct{}) + 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, + }, nil + } + + defaultSilentUpdateCheck(context.Background()) + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("expected silent update check goroutine to finish") + } + + deadline := time.Now().Add(200 * time.Millisecond) + for time.Now().Before(deadline) { + notice := ConsumeUpdateNotice() + if notice == "" { + time.Sleep(5 * time.Millisecond) + continue + } + if strings.Contains(notice, "\x1b") { + t.Fatalf("expected notice without ANSI sequence, got %q", notice) + } + if !strings.Contains(notice, "v0.2.1") { + t.Fatalf("expected sanitized version in notice, got %q", notice) + } + return + } + t.Fatal("expected update notice to be set") +} + func TestDefaultGlobalPreloadLoadsPersistedEnv(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) diff --git a/internal/cli/update_command.go b/internal/cli/update_command.go index 09ee762f..bc44cd98 100644 --- a/internal/cli/update_command.go +++ b/internal/cli/update_command.go @@ -2,8 +2,10 @@ package cli import ( "context" + "errors" "fmt" "strings" + "time" "github.com/spf13/cobra" @@ -16,6 +18,11 @@ type updateCommandOptions struct { } var runUpdateCommand = defaultUpdateCommandRunner +var doUpdate = updater.DoUpdate + +var updateCommandTimeout = 5 * time.Minute + +const updateTimeoutErrorTemplate = "\u66f4\u65b0\u8d85\u65f6\uff08%s\uff09\uff0c\u8bf7\u68c0\u67e5\u7f51\u7edc\u540e\u91cd\u8bd5" // newUpdateCommand 创建 update 子命令并绑定升级参数。 func newUpdateCommand() *cobra.Command { @@ -53,8 +60,18 @@ func newUpdateCommand() *cobra.Command { // defaultUpdateCommandRunner 执行手动升级流程并返回升级结果。 func defaultUpdateCommandRunner(ctx context.Context, options updateCommandOptions) (updater.UpdateResult, error) { - return updater.DoUpdate(ctx, updater.UpdateOptions{ + updateCtx, cancel := context.WithTimeout(ctx, updateCommandTimeout) + defer cancel() + + result, err := doUpdate(updateCtx, updater.UpdateOptions{ CurrentVersion: version.Current(), IncludePrerelease: options.IncludePrerelease, }) + if err != nil { + if errors.Is(updateCtx.Err(), context.DeadlineExceeded) { + return updater.UpdateResult{}, fmt.Errorf(updateTimeoutErrorTemplate, updateCommandTimeout) + } + return updater.UpdateResult{}, err + } + return result, nil } diff --git a/internal/cli/update_command_test.go b/internal/cli/update_command_test.go index 13699be7..c8a69d21 100644 --- a/internal/cli/update_command_test.go +++ b/internal/cli/update_command_test.go @@ -3,9 +3,13 @@ package cli import ( "bytes" "context" + "errors" + "strings" "testing" + "time" "neo-code/internal/updater" + "neo-code/internal/version" ) func TestUpdateCommandPassesPrereleaseFlag(t *testing.T) { @@ -72,6 +76,55 @@ func TestUpdateCommandShowsSuccessMessage(t *testing.T) { } } +func TestUpdateCommandShowsUnknownLatestWhenLatestVersionEmpty(t *testing.T) { + originalRunner := runUpdateCommand + originalPreload := runGlobalPreload + originalSilentCheck := runSilentUpdateCheck + t.Cleanup(func() { runUpdateCommand = originalRunner }) + t.Cleanup(func() { runGlobalPreload = originalPreload }) + t.Cleanup(func() { runSilentUpdateCheck = originalSilentCheck }) + + runGlobalPreload = func(context.Context) error { return nil } + runSilentUpdateCheck = func(context.Context) {} + runUpdateCommand = func(context.Context, updateCommandOptions) (updater.UpdateResult, error) { + return updater.UpdateResult{Updated: false, LatestVersion: " \t "}, nil + } + + command := NewRootCommand() + var stdout bytes.Buffer + command.SetOut(&stdout) + command.SetArgs([]string{"update"}) + if err := command.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext() error = %v", err) + } + if !strings.Contains(stdout.String(), "latest: unknown") { + t.Fatalf("unexpected output: %q", stdout.String()) + } +} + +func TestUpdateCommandReturnsRunnerError(t *testing.T) { + originalRunner := runUpdateCommand + originalPreload := runGlobalPreload + originalSilentCheck := runSilentUpdateCheck + t.Cleanup(func() { runUpdateCommand = originalRunner }) + t.Cleanup(func() { runGlobalPreload = originalPreload }) + t.Cleanup(func() { runSilentUpdateCheck = originalSilentCheck }) + + expected := errors.New("update failed") + runGlobalPreload = func(context.Context) error { return nil } + runSilentUpdateCheck = func(context.Context) {} + runUpdateCommand = func(context.Context, updateCommandOptions) (updater.UpdateResult, error) { + return updater.UpdateResult{}, expected + } + + command := NewRootCommand() + command.SetArgs([]string{"update"}) + err := command.ExecuteContext(context.Background()) + if !errors.Is(err, expected) { + t.Fatalf("expected runner error %v, got %v", expected, err) + } +} + func TestConsumeUpdateNoticeOnce(t *testing.T) { _ = ConsumeUpdateNotice() setUpdateNotice(" new version ") @@ -83,3 +136,83 @@ func TestConsumeUpdateNoticeOnce(t *testing.T) { t.Fatalf("ConsumeUpdateNotice() second call = %q, want empty", got) } } + +func TestSetUpdateNoticeIgnoresEmptyMessage(t *testing.T) { + _ = ConsumeUpdateNotice() + setUpdateNotice(" \n\t") + if got := ConsumeUpdateNotice(); got != "" { + t.Fatalf("ConsumeUpdateNotice() = %q, want empty", got) + } +} + +func TestDefaultUpdateCommandRunnerTimeout(t *testing.T) { + originalDoUpdate := doUpdate + originalTimeout := updateCommandTimeout + t.Cleanup(func() { doUpdate = originalDoUpdate }) + t.Cleanup(func() { updateCommandTimeout = originalTimeout }) + + updateCommandTimeout = 20 * time.Millisecond + doUpdate = func(ctx context.Context, options updater.UpdateOptions) (updater.UpdateResult, error) { + <-ctx.Done() + return updater.UpdateResult{}, ctx.Err() + } + + _, err := defaultUpdateCommandRunner(context.Background(), updateCommandOptions{}) + if err == nil { + t.Fatal("expected timeout error") + } + if !strings.Contains(err.Error(), "\u66f4\u65b0\u8d85\u65f6") { + t.Fatalf("expected friendly timeout message, got %v", err) + } +} + +func TestDefaultUpdateCommandRunnerPassesOptionsAndResult(t *testing.T) { + originalDoUpdate := doUpdate + originalTimeout := updateCommandTimeout + t.Cleanup(func() { doUpdate = originalDoUpdate }) + t.Cleanup(func() { updateCommandTimeout = originalTimeout }) + + updateCommandTimeout = time.Second + expected := updater.UpdateResult{ + CurrentVersion: "v0.1.0", + LatestVersion: "v0.2.0", + Updated: true, + } + var captured updater.UpdateOptions + doUpdate = func(ctx context.Context, options updater.UpdateOptions) (updater.UpdateResult, error) { + captured = options + return expected, nil + } + + result, err := defaultUpdateCommandRunner(context.Background(), updateCommandOptions{IncludePrerelease: true}) + if err != nil { + t.Fatalf("defaultUpdateCommandRunner() error = %v", err) + } + if result != expected { + t.Fatalf("result = %+v, want %+v", result, expected) + } + if !captured.IncludePrerelease { + t.Fatal("expected IncludePrerelease to be forwarded") + } + if captured.CurrentVersion != version.Current() { + t.Fatalf("CurrentVersion = %q, want %q", captured.CurrentVersion, version.Current()) + } +} + +func TestDefaultUpdateCommandRunnerReturnsUnderlyingError(t *testing.T) { + originalDoUpdate := doUpdate + originalTimeout := updateCommandTimeout + t.Cleanup(func() { doUpdate = originalDoUpdate }) + t.Cleanup(func() { updateCommandTimeout = originalTimeout }) + + updateCommandTimeout = time.Second + expected := errors.New("network failed") + doUpdate = func(context.Context, updater.UpdateOptions) (updater.UpdateResult, error) { + return updater.UpdateResult{}, expected + } + + _, err := defaultUpdateCommandRunner(context.Background(), updateCommandOptions{}) + if !errors.Is(err, expected) { + t.Fatalf("expected underlying error %v, got %v", expected, err) + } +} From fbc5f8a78316c787e6c541950f892f5cb0400325 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Fri, 17 Apr 2026 11:51:44 +0000 Subject: [PATCH 3/4] test: improve updater and workspace coverage Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: pionxe <148670367+pionxe@users.noreply.github.com> --- internal/security/workspace_test.go | 141 +++++++++++ internal/updater/updater_test.go | 376 ++++++++++++++++++++++++++++ 2 files changed, 517 insertions(+) diff --git a/internal/security/workspace_test.go b/internal/security/workspace_test.go index 37fb49fa..032e33d6 100644 --- a/internal/security/workspace_test.go +++ b/internal/security/workspace_test.go @@ -196,6 +196,19 @@ func TestWorkspaceSandboxCheckShortCircuits(t *testing.T) { } } +func TestWorkspaceSandboxCheckRejectsInvalidCapabilityToken(t *testing.T) { + t.Parallel() + + root := t.TempDir() + action := fileAction(ActionTypeRead, "filesystem_read_file", "read_file", root, "notes.txt") + action.Payload.CapabilityToken = &CapabilityToken{} + + _, err := NewWorkspaceSandbox().Check(context.Background(), action) + if err == nil || !strings.Contains(err.Error(), "capability token path not allowed") { + t.Fatalf("expected capability token path rejection, got %v", err) + } +} + func TestBuildWorkspacePlan(t *testing.T) { t.Parallel() @@ -263,6 +276,21 @@ func TestBuildWorkspacePlan(t *testing.T) { wantOK: true, wantTarget: ".", }, + { + name: "sandbox target type falls back to target type", + action: Action{ + Type: ActionTypeRead, + Payload: ActionPayload{ + ToolName: "filesystem_grep", + Resource: "filesystem_grep", + Workdir: root, + TargetType: TargetTypeDirectory, + Target: "docs", + }, + }, + wantOK: true, + wantTarget: "docs", + }, } for _, tt := range tests { @@ -291,6 +319,21 @@ func TestBuildWorkspacePlan(t *testing.T) { } } +func TestWorkspaceSandboxValidateWorkspacePlanErrors(t *testing.T) { + t.Parallel() + + sandbox := NewWorkspaceSandbox() + _, err := sandbox.validateWorkspacePlan(workspacePlan{ + root: filepath.Join(t.TempDir(), "missing"), + target: "notes.txt", + targetType: TargetTypePath, + actionType: ActionTypeRead, + }) + if err == nil || !strings.Contains(err.Error(), "resolve workspace root") { + t.Fatalf("expected resolve workspace root error, got %v", err) + } +} + func TestNeedsWorkspaceSandbox(t *testing.T) { t.Parallel() @@ -441,6 +484,13 @@ func TestCanonicalWorkspaceRoot(t *testing.T) { if _, ok := sandbox.canonicalRoots.Load(cleanedPathKey(existing)); !ok { t.Fatalf("expected canonical root cache entry for %q", existing) } + gotCached, err := sandbox.canonicalWorkspaceRoot(existing) + if err != nil { + t.Fatalf("canonicalWorkspaceRoot(cached) error: %v", err) + } + if !samePathKey(gotCached, got) { + t.Fatalf("canonicalWorkspaceRoot(cached) = %q, want %q", gotCached, got) + } missing := filepath.Join(t.TempDir(), "missing", "dir") _, err = sandbox.canonicalWorkspaceRoot(missing) @@ -776,6 +826,54 @@ func TestWorkspaceExecutionPlanValidateForExecution(t *testing.T) { t.Fatalf("expected valid plan, got %v", err) } }) + + t.Run("nearest existing path failure is returned", func(t *testing.T) { + t.Parallel() + + root := t.TempDir() + file := filepath.Join(root, "file.txt") + mustWriteWorkspaceFile(t, file, "x") + plan := &WorkspaceExecutionPlan{ + Root: root, + Target: filepath.Join(file, "child.txt"), + RequestedTarget: filepath.Join("file.txt", "child.txt"), + anchorPath: file, + anchorSnapshot: pathSnapshot{}, + } + err := plan.ValidateForExecution() + if err == nil || !strings.Contains(err.Error(), "inspect path") { + t.Fatalf("expected inspect path error, got %v", err) + } + }) + + t.Run("anchor path mismatch is rejected", func(t *testing.T) { + t.Parallel() + + root := t.TempDir() + targetA := filepath.Join(root, "a") + targetB := filepath.Join(root, "b") + if err := os.MkdirAll(targetA, 0o755); err != nil { + t.Fatalf("mkdir targetA: %v", err) + } + if err := os.MkdirAll(targetB, 0o755); err != nil { + t.Fatalf("mkdir targetB: %v", err) + } + snapshot, err := capturePathSnapshot(targetB) + if err != nil { + t.Fatalf("capturePathSnapshot(targetB): %v", err) + } + plan := &WorkspaceExecutionPlan{ + Root: root, + Target: targetA, + RequestedTarget: "a", + anchorPath: targetB, + anchorSnapshot: snapshot, + } + err = plan.ValidateForExecution() + if err == nil || !strings.Contains(err.Error(), "changed before execution") { + t.Fatalf("expected changed-before-execution error, got %v", err) + } + }) } func TestCapturePathSnapshotAndEqual(t *testing.T) { @@ -893,6 +991,28 @@ func TestNearestExistingPath(t *testing.T) { return cleanedPathKey(filepath.Join(root, "broken")) }, }, + { + name: "returns inspect error for non-not-exist lstat", + setup: func(t *testing.T) (string, string) { + t.Helper() + root := t.TempDir() + file := filepath.Join(root, "file.txt") + mustWriteWorkspaceFile(t, file, "x") + return root, filepath.Join(file, "child.txt") + }, + expectErr: "inspect path", + }, + { + name: "missing root path returns root", + setup: func(t *testing.T) (string, string) { + t.Helper() + root := filepath.Join(t.TempDir(), "missing-root") + return root, root + }, + expect: func(root string, target string) string { + return cleanedPathKey(root) + }, + }, } for _, tt := range tests { @@ -918,6 +1038,27 @@ func TestNearestExistingPath(t *testing.T) { } } +func TestEnsureNoSymlinkEscapeReturnsNearestPathError(t *testing.T) { + t.Parallel() + + root := t.TempDir() + file := filepath.Join(root, "file.txt") + mustWriteWorkspaceFile(t, file, "x") + + _, err := ensureNoSymlinkEscape(root, filepath.Join(file, "child.txt"), filepath.Join("file.txt", "child.txt")) + if err == nil || !strings.Contains(err.Error(), "inspect path") { + t.Fatalf("expected inspect path error, got %v", err) + } +} + +func TestValidateTargetVolumeNoVolumeShortCircuit(t *testing.T) { + t.Parallel() + + if err := validateTargetVolume(filepath.Join(t.TempDir(), "workspace"), filepath.Join(t.TempDir(), "target")); err != nil { + t.Fatalf("validateTargetVolume() error = %v, want nil on non-volume paths", err) + } +} + func TestSplitRelativePath(t *testing.T) { t.Parallel() diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go index 2c724777..b01f986c 100644 --- a/internal/updater/updater_test.go +++ b/internal/updater/updater_test.go @@ -1,10 +1,13 @@ package updater import ( + "bytes" "context" "errors" + "io" "regexp" "testing" + "time" selfupdate "github.com/creativeprojects/go-selfupdate" ) @@ -45,6 +48,53 @@ func (c *fakeClient) UpdateTo(_ context.Context, rel releaseView, cmdPath string return c.updateErr } +type stubSource struct { + releases []selfupdate.SourceRelease + listErr error +} + +func (s stubSource) ListReleases(context.Context, selfupdate.Repository) ([]selfupdate.SourceRelease, error) { + if s.listErr != nil { + return nil, s.listErr + } + return s.releases, nil +} + +func (s stubSource) DownloadReleaseAsset(context.Context, *selfupdate.Release, int64) (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(nil)), nil +} + +type stubSourceRelease struct { + id int64 + tagName string + draft bool + prerelease bool + assets []selfupdate.SourceAsset +} + +func (r stubSourceRelease) GetID() int64 { return r.id } +func (r stubSourceRelease) GetTagName() string { return r.tagName } +func (r stubSourceRelease) GetDraft() bool { return r.draft } +func (r stubSourceRelease) GetPrerelease() bool { return r.prerelease } +func (r stubSourceRelease) GetPublishedAt() time.Time { return time.Now() } +func (r stubSourceRelease) GetReleaseNotes() string { return "" } +func (r stubSourceRelease) GetName() string { return r.tagName } +func (r stubSourceRelease) GetURL() string { return "https://example.com/release" } +func (r stubSourceRelease) GetAssets() []selfupdate.SourceAsset { + return r.assets +} + +type stubSourceAsset struct { + id int64 + name string + size int +} + +func (a stubSourceAsset) GetID() int64 { return a.id } +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" } + func TestResolveAssetTarget(t *testing.T) { tests := []struct { name string @@ -199,6 +249,118 @@ func TestCheckLatest(t *testing.T) { } } +func TestCheckLatestErrorBranches(t *testing.T) { + originalNewClient := newClient + originalGOOS := runtimeGOOS + originalGOARCH := runtimeGOARCH + t.Cleanup(func() { + newClient = originalNewClient + runtimeGOOS = originalGOOS + runtimeGOARCH = originalGOARCH + }) + + t.Run("unsupported platform", func(t *testing.T) { + runtimeGOOS = "plan9" + runtimeGOARCH = "amd64" + + result, err := CheckLatest(context.Background(), CheckOptions{CurrentVersion: ""}) + if err == nil || !regexp.MustCompile(`unsupported os`).MatchString(err.Error()) { + t.Fatalf("CheckLatest() error = %v, want unsupported os", err) + } + if result.CurrentVersion != "dev" { + t.Fatalf("CurrentVersion = %q, want %q", result.CurrentVersion, "dev") + } + }) + + t.Run("new client failure", func(t *testing.T) { + runtimeGOOS = "linux" + runtimeGOARCH = "amd64" + newClient = func(selfupdate.Config) (updateClient, error) { + return nil, errors.New("new client failed") + } + + _, err := CheckLatest(context.Background(), CheckOptions{CurrentVersion: "v1.0.0"}) + if err == nil || err.Error() != "new client failed" { + t.Fatalf("CheckLatest() error = %v, want new client failed", err) + } + }) + + t.Run("detect latest failure", func(t *testing.T) { + runtimeGOOS = "linux" + runtimeGOARCH = "amd64" + newClient = func(selfupdate.Config) (updateClient, error) { + return &fakeClient{detectErr: errors.New("detect failed")}, nil + } + + _, err := CheckLatest(context.Background(), CheckOptions{CurrentVersion: "v1.0.0"}) + if err == nil || err.Error() != "detect failed" { + t.Fatalf("CheckLatest() error = %v, want detect failed", err) + } + }) + + t.Run("not found release", func(t *testing.T) { + runtimeGOOS = "linux" + runtimeGOARCH = "amd64" + newClient = func(selfupdate.Config) (updateClient, error) { + return &fakeClient{found: false}, nil + } + + result, err := CheckLatest(context.Background(), CheckOptions{CurrentVersion: " v1.0.0 "}) + if err != nil { + t.Fatalf("CheckLatest() error = %v", err) + } + if result.CurrentVersion != "v1.0.0" { + t.Fatalf("CurrentVersion = %q, want %q", result.CurrentVersion, "v1.0.0") + } + if result.HasUpdate { + t.Fatalf("HasUpdate = true, want false") + } + }) + + t.Run("empty latest version", func(t *testing.T) { + runtimeGOOS = "linux" + runtimeGOARCH = "amd64" + newClient = func(selfupdate.Config) (updateClient, error) { + return &fakeClient{ + release: fakeRelease{version: " "}, + found: true, + }, nil + } + + result, err := CheckLatest(context.Background(), CheckOptions{CurrentVersion: "v1.0.0"}) + if err != nil { + t.Fatalf("CheckLatest() error = %v", err) + } + if result.LatestVersion != "" || result.HasUpdate { + t.Fatalf("unexpected result: %+v", result) + } + }) + + t.Run("non semver current version never marks update", func(t *testing.T) { + runtimeGOOS = "linux" + runtimeGOARCH = "amd64" + newClient = func(selfupdate.Config) (updateClient, error) { + return &fakeClient{ + release: fakeRelease{ + version: "v9.9.9", + greaterFn: func(string) bool { + return true + }, + }, + found: true, + }, nil + } + + result, err := CheckLatest(context.Background(), CheckOptions{CurrentVersion: "dev"}) + if err != nil { + t.Fatalf("CheckLatest() error = %v", err) + } + if result.HasUpdate { + t.Fatalf("HasUpdate = true, want false for non-semver current version") + } + }) +} + func TestDoUpdateSkipsWhenAlreadyLatestForSemver(t *testing.T) { originalNewClient := newClient originalGOOS := runtimeGOOS @@ -325,3 +487,217 @@ func TestDoUpdatePropagatesUpdateError(t *testing.T) { t.Fatalf("DoUpdate() error = %v, want %v", err, expected) } } + +func TestDoUpdateErrorAndEdgeBranches(t *testing.T) { + originalNewClient := newClient + originalExePath := resolveExecutablePath + originalGOOS := runtimeGOOS + originalGOARCH := runtimeGOARCH + t.Cleanup(func() { + newClient = originalNewClient + resolveExecutablePath = originalExePath + runtimeGOOS = originalGOOS + runtimeGOARCH = originalGOARCH + }) + + t.Run("unsupported platform", func(t *testing.T) { + runtimeGOOS = "plan9" + runtimeGOARCH = "amd64" + + result, err := DoUpdate(context.Background(), UpdateOptions{CurrentVersion: ""}) + if err == nil || !regexp.MustCompile(`unsupported os`).MatchString(err.Error()) { + t.Fatalf("DoUpdate() error = %v, want unsupported os", err) + } + if result.CurrentVersion != "dev" { + t.Fatalf("CurrentVersion = %q, want %q", result.CurrentVersion, "dev") + } + }) + + t.Run("new client failure", func(t *testing.T) { + runtimeGOOS = "linux" + runtimeGOARCH = "amd64" + newClient = func(selfupdate.Config) (updateClient, error) { + return nil, errors.New("new client failed") + } + + _, err := DoUpdate(context.Background(), UpdateOptions{CurrentVersion: "v1.0.0"}) + if err == nil || err.Error() != "new client failed" { + t.Fatalf("DoUpdate() error = %v, want new client failed", err) + } + }) + + t.Run("detect latest failure", func(t *testing.T) { + runtimeGOOS = "linux" + runtimeGOARCH = "amd64" + newClient = func(selfupdate.Config) (updateClient, error) { + return &fakeClient{detectErr: errors.New("detect failed")}, nil + } + + _, err := DoUpdate(context.Background(), UpdateOptions{CurrentVersion: "v1.0.0"}) + if err == nil || err.Error() != "detect failed" { + t.Fatalf("DoUpdate() error = %v, want detect failed", err) + } + }) + + t.Run("release not found", func(t *testing.T) { + runtimeGOOS = "linux" + runtimeGOARCH = "amd64" + newClient = func(selfupdate.Config) (updateClient, error) { + return &fakeClient{found: false}, nil + } + + _, err := DoUpdate(context.Background(), UpdateOptions{CurrentVersion: "v1.0.0"}) + if err == nil || !regexp.MustCompile(`no release asset found`).MatchString(err.Error()) { + t.Fatalf("DoUpdate() error = %v, want no release asset found", err) + } + }) + + t.Run("resolve executable path failure", func(t *testing.T) { + runtimeGOOS = "linux" + runtimeGOARCH = "amd64" + newClient = func(selfupdate.Config) (updateClient, error) { + return &fakeClient{ + release: fakeRelease{ + version: "v1.3.0", + greaterFn: func(string) bool { + return true + }, + }, + found: true, + }, nil + } + resolveExecutablePath = func() (string, error) { + return "", errors.New("resolve exec failed") + } + + _, err := DoUpdate(context.Background(), UpdateOptions{CurrentVersion: "v1.2.0"}) + if err == nil || err.Error() != "resolve exec failed" { + t.Fatalf("DoUpdate() error = %v, want resolve exec failed", err) + } + }) + + t.Run("dev version updates without semver compare", func(t *testing.T) { + runtimeGOOS = "linux" + runtimeGOARCH = "amd64" + + client := &fakeClient{ + release: fakeRelease{ + version: "v1.3.0", + greaterFn: func(string) bool { + return false + }, + }, + found: true, + } + newClient = func(selfupdate.Config) (updateClient, error) { + return client, nil + } + resolveExecutablePath = func() (string, error) { + return "/tmp/neocode", nil + } + + result, err := DoUpdate(context.Background(), UpdateOptions{CurrentVersion: "dev"}) + if err != nil { + t.Fatalf("DoUpdate() error = %v", err) + } + if !result.Updated { + t.Fatalf("Updated = false, want true") + } + if client.updateCalls != 1 { + t.Fatalf("update calls = %d, want 1", client.updateCalls) + } + }) +} + +func TestSelfupdateClientDetectLatestAndUnsupportedUpdateType(t *testing.T) { + target := assetTarget{ + OSToken: "linux", + ArchToken: "amd64", + Ext: "tar.gz", + AssetName: "neocode_linux_amd64.tar.gz", + } + + source := stubSource{ + releases: []selfupdate.SourceRelease{ + stubSourceRelease{ + id: 1, + tagName: "v1.5.0", + assets: []selfupdate.SourceAsset{ + stubSourceAsset{id: 1, name: target.AssetName, size: 1}, + }, + }, + }, + } + updater, err := selfupdate.NewUpdater(selfupdate.Config{ + Source: source, + OS: target.OSToken, + Arch: target.ArchToken, + }) + if err != nil { + t.Fatalf("NewUpdater() error = %v", err) + } + + 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( + 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) + } + + err = client.UpdateTo(context.Background(), fakeRelease{version: "v1.0.0"}, "/tmp/neocode") + if err == nil || err.Error() != "updater: unsupported release type" { + t.Fatalf("UpdateTo() error = %v, want unsupported release type", err) + } + + err = client.UpdateTo(context.Background(), selfupdateRelease{}, "/tmp/neocode") + if err == nil || err.Error() != "updater: unsupported release type" { + t.Fatalf("UpdateTo() error = %v, want unsupported release type for nil release", err) + } + + if err := client.UpdateTo(context.Background(), rel, "/tmp/neocode"); err == nil { + t.Fatalf("expected UpdateTo() to fail with stub asset payload") + } +} + +func TestNewClientFactory(t *testing.T) { + _, err := newClient(selfupdate.Config{Filters: []string{"("}}) + if err == nil { + t.Fatalf("expected newClient to fail with invalid filter regex") + } + + client, err := newClient(selfupdate.Config{ + Source: stubSource{}, + OS: "linux", + Arch: "amd64", + }) + if err != nil { + t.Fatalf("newClient() unexpected error: %v", err) + } + if client == nil { + t.Fatalf("expected non-nil client") + } +} From 6c0338a4d95773e85a15845f60754e4ce82e8331 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Fri, 17 Apr 2026 12:13:56 +0000 Subject: [PATCH 4/4] 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> --- docs/guides/update.md | 2 +- internal/cli/root.go | 68 ++++++++++++++++++++++++----- internal/cli/root_test.go | 55 +++++++++++++++++++++++ internal/cli/update_command.go | 19 +++++--- internal/cli/update_command_test.go | 48 +++++++++++++++++++- 5 files changed, 172 insertions(+), 20 deletions(-) diff --git a/docs/guides/update.md b/docs/guides/update.md index 7bc0a5d2..5b52c67f 100644 --- a/docs/guides/update.md +++ b/docs/guides/update.md @@ -4,7 +4,7 @@ - `neocode` 启动时会在后台静默检测最新版本(默认 3 秒超时)。 - 为避免干扰 Bubble Tea TUI 交互,更新提示会在应用退出、终端屏幕恢复后输出。 -- `url-dispatch` 子命令会跳过该检测流程。 +- `url-dispatch` 与 `update` 子命令会跳过该检测流程。 ## 手动升级 diff --git a/internal/cli/root.go b/internal/cli/root.go index 4041fc0b..7ccc856e 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -6,6 +6,7 @@ import ( "fmt" "regexp" "strings" + "sync" "time" "github.com/spf13/cobra" @@ -25,9 +26,15 @@ var readCurrentVersion = version.Current var checkLatestRelease = updater.CheckLatest const silentUpdateCheckTimeout = 3 * time.Second +const silentUpdateCheckDrainTimeout = 300 * time.Millisecond var ansiEscapeSequencePattern = regexp.MustCompile(`\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\)|[@-Z\\-_])`) +var ( + silentUpdateCheckMu sync.Mutex + silentUpdateCheckDone <-chan struct{} +) + // GlobalFlags 描述 CLI 根命令当前支持的全局参数。 type GlobalFlags struct { Workdir string @@ -37,7 +44,11 @@ type GlobalFlags struct { func Execute(ctx context.Context) error { app.EnsureConsoleUTF8() _ = ConsumeUpdateNotice() - return NewRootCommand().ExecuteContext(ctx) + setSilentUpdateCheckDone(nil) + + err := NewRootCommand().ExecuteContext(ctx) + waitSilentUpdateCheckDone(silentUpdateCheckDrainTimeout) + return err } // NewRootCommand 创建 NeoCode 的 CLI 根命令。 @@ -116,11 +127,16 @@ func defaultGlobalPreload(ctx context.Context) error { func defaultSilentUpdateCheck(ctx context.Context) { currentVersion := readCurrentVersion() if !version.IsSemverRelease(currentVersion) { + setSilentUpdateCheckDone(nil) return } parentCtx := context.WithoutCancel(ctx) + done := make(chan struct{}) + setSilentUpdateCheckDone(done) + + go func(parent context.Context, currentVersion string, done chan struct{}) { + defer close(done) - go func(parent context.Context, currentVersion string) { checkCtx, cancel := context.WithTimeout(parent, silentUpdateCheckTimeout) defer cancel() @@ -137,23 +153,17 @@ func defaultSilentUpdateCheck(ctx context.Context) { return } setUpdateNotice(fmt.Sprintf("\u53d1\u73b0\u65b0\u7248\u672c: %s\uff0c\u8fd0\u884c neocode update \u5373\u53ef\u5347\u7ea7", latestVersion)) - }(parentCtx, currentVersion) + }(parentCtx, currentVersion, done) } // shouldSkipGlobalPreload 判断当前命令是否应跳过全局预加载逻辑。 func shouldSkipGlobalPreload(cmd *cobra.Command) bool { - if cmd == nil { - return false - } - return strings.EqualFold(strings.TrimSpace(cmd.Name()), "url-dispatch") + return normalizedCommandName(cmd) == "url-dispatch" } // shouldSkipSilentUpdateCheck 判断当前命令是否应跳过静默更新检测。 func shouldSkipSilentUpdateCheck(cmd *cobra.Command) bool { - if cmd == nil { - return false - } - switch strings.ToLower(strings.TrimSpace(cmd.Name())) { + switch normalizedCommandName(cmd) { case "url-dispatch", "update": return true default: @@ -173,3 +183,39 @@ func sanitizeVersionForTerminal(version string) string { } return strings.TrimSpace(builder.String()) } + +// normalizedCommandName 返回标准化后的命令名,统一处理空命令与大小写。 +func normalizedCommandName(cmd *cobra.Command) string { + if cmd == nil { + return "" + } + return strings.ToLower(strings.TrimSpace(cmd.Name())) +} + +// setSilentUpdateCheckDone 保存当前静默检测任务的完成信号通道。 +func setSilentUpdateCheckDone(done <-chan struct{}) { + silentUpdateCheckMu.Lock() + silentUpdateCheckDone = done + silentUpdateCheckMu.Unlock() +} + +// waitSilentUpdateCheckDone 在命令退出阶段等待静默检测短暂收口,降低提示丢失概率。 +func waitSilentUpdateCheckDone(timeout time.Duration) { + if timeout <= 0 { + return + } + + silentUpdateCheckMu.Lock() + done := silentUpdateCheckDone + silentUpdateCheckMu.Unlock() + if done == nil { + return + } + + timer := time.NewTimer(timeout) + defer timer.Stop() + select { + case <-done: + case <-timer.C: + } +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 432e7b28..ceac8d22 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -110,6 +110,52 @@ func TestExecuteUsesOSArgs(t *testing.T) { } } +func TestExecuteWaitsForSilentUpdateCheckCompletion(t *testing.T) { + originalLauncher := launchRootProgram + originalPreload := runGlobalPreload + originalSilentCheck := runSilentUpdateCheck + originalArgs := os.Args + t.Cleanup(func() { + launchRootProgram = originalLauncher + runGlobalPreload = originalPreload + runSilentUpdateCheck = originalSilentCheck + os.Args = originalArgs + }) + + _ = ConsumeUpdateNotice() + runGlobalPreload = func(context.Context) error { return nil } + launchRootProgram = func(context.Context, app.BootstrapOptions) error { return nil } + runSilentUpdateCheck = func(context.Context) { + done := make(chan struct{}) + setSilentUpdateCheckDone(done) + go func() { + time.Sleep(50 * time.Millisecond) + setUpdateNotice("发现新版本: v0.2.1") + close(done) + }() + } + os.Args = []string{"neocode"} + + if err := Execute(context.Background()); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if got := ConsumeUpdateNotice(); got == "" { + t.Fatal("expected update notice after Execute waits for silent check") + } +} + +func TestWaitSilentUpdateCheckDoneReturnsOnTimeout(t *testing.T) { + blocked := make(chan struct{}) + setSilentUpdateCheckDone(blocked) + t.Cleanup(func() { setSilentUpdateCheckDone(nil) }) + + start := time.Now() + waitSilentUpdateCheckDone(30 * time.Millisecond) + if elapsed := time.Since(start); elapsed < 20*time.Millisecond || elapsed > 150*time.Millisecond { + t.Fatalf("wait duration out of expected range, got %s", elapsed) + } +} + func TestDefaultRootProgramLauncherRunsProgram(t *testing.T) { originalNewProgram := newRootProgram t.Cleanup(func() { newRootProgram = originalNewProgram }) @@ -1198,6 +1244,15 @@ func TestShouldSkipGlobalPreload(t *testing.T) { } } +func TestNormalizedCommandName(t *testing.T) { + if got := normalizedCommandName(nil); got != "" { + t.Fatalf("normalizedCommandName(nil) = %q, want empty", got) + } + if got := normalizedCommandName(&cobra.Command{Use: "URL-Dispatch"}); got != "url-dispatch" { + t.Fatalf("normalizedCommandName() = %q, want %q", got, "url-dispatch") + } +} + func TestShouldSkipSilentUpdateCheck(t *testing.T) { if !shouldSkipSilentUpdateCheck(&cobra.Command{Use: "url-dispatch"}) { t.Fatal("url-dispatch should skip silent update check") diff --git a/internal/cli/update_command.go b/internal/cli/update_command.go index bc44cd98..d1992690 100644 --- a/internal/cli/update_command.go +++ b/internal/cli/update_command.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "strings" "time" "github.com/spf13/cobra" @@ -41,15 +40,14 @@ func newUpdateCommand() *cobra.Command { out := cmd.OutOrStdout() if !result.Updated { - latest := strings.TrimSpace(result.LatestVersion) - if latest == "" { - latest = "unknown" - } + latest := displayVersionForTerminal(result.LatestVersion) _, _ = fmt.Fprintf(out, "Already up-to-date (latest: %s).\n", latest) return nil } - _, _ = fmt.Fprintf(out, "Updated successfully: %s -> %s\n", result.CurrentVersion, result.LatestVersion) + current := displayVersionForTerminal(result.CurrentVersion) + latest := displayVersionForTerminal(result.LatestVersion) + _, _ = fmt.Fprintf(out, "Updated successfully: %s -> %s\n", current, latest) return nil }, } @@ -75,3 +73,12 @@ func defaultUpdateCommandRunner(ctx context.Context, options updateCommandOption } return result, nil } + +// displayVersionForTerminal 清洗版本字符串并为不可用值提供统一回退文案。 +func displayVersionForTerminal(raw string) string { + version := sanitizeVersionForTerminal(raw) + if version == "" { + return "unknown" + } + return version +} diff --git a/internal/cli/update_command_test.go b/internal/cli/update_command_test.go index c8a69d21..06a4d6ea 100644 --- a/internal/cli/update_command_test.go +++ b/internal/cli/update_command_test.go @@ -57,8 +57,8 @@ func TestUpdateCommandShowsSuccessMessage(t *testing.T) { runSilentUpdateCheck = func(context.Context) {} runUpdateCommand = func(context.Context, updateCommandOptions) (updater.UpdateResult, error) { return updater.UpdateResult{ - CurrentVersion: "v0.1.0", - LatestVersion: "v0.2.1", + CurrentVersion: "\x1b[31mv0.1.0\x1b[0m", + LatestVersion: "\x1b[32mv0.2.1\x1b[0m\t", Updated: true, }, nil } @@ -74,6 +74,12 @@ func TestUpdateCommandShowsSuccessMessage(t *testing.T) { if got := stdout.String(); got == "" || !bytes.Contains(stdout.Bytes(), []byte("Updated successfully")) { t.Fatalf("unexpected output: %q", got) } + if strings.Contains(stdout.String(), "\x1b") { + t.Fatalf("expected sanitized output without ANSI sequence, got %q", stdout.String()) + } + if !strings.Contains(stdout.String(), "v0.1.0 -> v0.2.1") { + t.Fatalf("unexpected output: %q", stdout.String()) + } } func TestUpdateCommandShowsUnknownLatestWhenLatestVersionEmpty(t *testing.T) { @@ -102,6 +108,35 @@ func TestUpdateCommandShowsUnknownLatestWhenLatestVersionEmpty(t *testing.T) { } } +func TestUpdateCommandSanitizesLatestVersionInUpToDateMessage(t *testing.T) { + originalRunner := runUpdateCommand + originalPreload := runGlobalPreload + originalSilentCheck := runSilentUpdateCheck + t.Cleanup(func() { runUpdateCommand = originalRunner }) + t.Cleanup(func() { runGlobalPreload = originalPreload }) + t.Cleanup(func() { runSilentUpdateCheck = originalSilentCheck }) + + runGlobalPreload = func(context.Context) error { return nil } + runSilentUpdateCheck = func(context.Context) {} + runUpdateCommand = func(context.Context, updateCommandOptions) (updater.UpdateResult, error) { + return updater.UpdateResult{Updated: false, LatestVersion: "\x1b[31mv0.2.1\x1b[0m\t\n"}, nil + } + + command := NewRootCommand() + var stdout bytes.Buffer + command.SetOut(&stdout) + command.SetArgs([]string{"update"}) + if err := command.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext() error = %v", err) + } + if strings.Contains(stdout.String(), "\x1b") { + t.Fatalf("expected sanitized output without ANSI sequence, got %q", stdout.String()) + } + if !strings.Contains(stdout.String(), "latest: v0.2.1") { + t.Fatalf("unexpected output: %q", stdout.String()) + } +} + func TestUpdateCommandReturnsRunnerError(t *testing.T) { originalRunner := runUpdateCommand originalPreload := runGlobalPreload @@ -216,3 +251,12 @@ func TestDefaultUpdateCommandRunnerReturnsUnderlyingError(t *testing.T) { t.Fatalf("expected underlying error %v, got %v", expected, err) } } + +func TestDisplayVersionForTerminal(t *testing.T) { + if got := displayVersionForTerminal("\x1b[31mv0.2.1\x1b[0m\t"); got != "v0.2.1" { + t.Fatalf("displayVersionForTerminal() = %q, want %q", got, "v0.2.1") + } + if got := displayVersionForTerminal(" \n\t"); got != "unknown" { + t.Fatalf("displayVersionForTerminal() empty = %q, want %q", got, "unknown") + } +}