mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 06:29:52 +08:00
Users who install or upgrade lark-cli via make install, go install, or
direct binary download end up with a binary but no AI agent skills,
degrading agent UX. This PR adds a startup-time skills version drift
notice (injected into JSON envelope _notice.skills, mirroring the
existing _notice.update pattern) and unifies lark-cli update's skills
sync across all three branches (npm / manual / already-latest) with
stamp-based dedup, so any explicit update invocation keeps skills in
sync regardless of how the binary was installed.
Changes:
- new internal/skillscheck package: notice (StaleNotice + atomic
pending), stamp (~/.lark-cli/skills.stamp), skip (CI / DEV /
non-release / LARKSUITE_CLI_NO_SKILLS_NOTIFIER opt-out), check
(synchronous Init)
- cmd/root.go: rename setupUpdateNotice -> setupNotices, compose
output.PendingNotice returning {update?, skills?}; capture
build.Version locally before spawning the async update goroutine
- cmd/update/update.go: add runSkillsAndStamp helper with stamp-based
dedup; rewire the three branches through shared applySkillsResult /
emitSkillsTextHints helpers; add skills_status block to --check JSON
output as a pure report (no side effects)
- internal/update: export IsRelease(version) bool / IsCIEnv() bool
for cross-package reuse; refresh UpdateInfo.Message to append
', run: lark-cli update' so both notices recommend the same fix
- AGENTS.md: add Notification Opt-Outs section documenting
LARKSUITE_CLI_NO_UPDATE_NOTIFIER and LARKSUITE_CLI_NO_SKILLS_NOTIFIER
- internal/binding/types.go: bump default exec-provider timeout from
5s to 10s (out-of-scope flake fix for TestResolveExecRef_JSONResponse
under heavy parallel test load)
278 lines
7.6 KiB
Go
278 lines
7.6 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package update
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// roundTripFunc adapts a function to http.RoundTripper.
|
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
|
|
|
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
|
|
|
|
// clearSkipEnv unsets all env vars that shouldSkip checks,
|
|
// preventing the host environment (e.g. CI=true) from polluting test results.
|
|
func clearSkipEnv(t *testing.T) {
|
|
t.Helper()
|
|
for _, key := range []string{"LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "CI", "BUILD_NUMBER", "RUN_ID"} {
|
|
t.Setenv(key, "")
|
|
os.Unsetenv(key)
|
|
}
|
|
}
|
|
|
|
func mustParseURL(raw string) *url.URL {
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return u
|
|
}
|
|
|
|
func TestIsNewer(t *testing.T) {
|
|
tests := []struct {
|
|
a, b string
|
|
want bool
|
|
}{
|
|
{"1.1.0", "1.0.0", true},
|
|
{"1.0.0", "1.0.0", false},
|
|
{"1.0.0", "1.1.0", false},
|
|
{"2.0.0", "1.9.9", true},
|
|
{"1.0.1", "1.0.0", true},
|
|
{"v1.1.0", "1.0.0", true},
|
|
{"1.1.0", "v1.0.0", true},
|
|
{"0.0.1", "0.0.0", true},
|
|
{"DEV", "1.0.0", false}, // unparseable remote → false
|
|
{"1.0.0", "DEV", true}, // unparseable local → assume outdated
|
|
{"1.0.0", "9b933f1", true}, // bare commit hash → assume outdated
|
|
{"", "1.0.0", false}, // empty remote → false
|
|
{"1.1.0", "v1.0.0-12-g9b933f1-dirty", true}, // git describe: 1.1.0 > 1.0.0
|
|
{"1.0.0", "1.0.0-rc.1", true}, // stable release > prerelease
|
|
{"1.0.0-rc.2", "1.0.0-rc.1", true}, // prerelease identifiers are ordered
|
|
{"1.0.0-rc.1", "1.0.0", false}, // prerelease < stable release
|
|
}
|
|
for _, tt := range tests {
|
|
got := IsNewer(tt.a, tt.b)
|
|
if got != tt.want {
|
|
t.Errorf("IsNewer(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseVersion(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want []int
|
|
}{
|
|
{"1.2.3", []int{1, 2, 3}},
|
|
{"v1.2.3", []int{1, 2, 3}},
|
|
{"0.0.1", []int{0, 0, 1}},
|
|
{"1.0.0-beta.1", []int{1, 0, 0}},
|
|
{"1.0.0-rc.1", []int{1, 0, 0}},
|
|
{"1.0.0-0", []int{1, 0, 0}},
|
|
{"1.0.0+build.123", []int{1, 0, 0}},
|
|
{"1.0.0-beta.1+build", []int{1, 0, 0}},
|
|
{"1.0.0-", nil}, // empty pre-release
|
|
{"1.0.0-01", nil}, // leading zero in numeric pre-release
|
|
{"1.0.0-beta..1", nil}, // empty identifier between dots
|
|
{"01.0.0", nil}, // leading zero in major
|
|
{"1.00.0", nil}, // leading zero in minor
|
|
{"1.0.00", nil}, // leading zero in patch
|
|
{"DEV", nil},
|
|
{"", nil},
|
|
{"1.2", nil},
|
|
}
|
|
for _, tt := range tests {
|
|
got := ParseVersion(tt.input)
|
|
if tt.want == nil {
|
|
if got != nil {
|
|
t.Errorf("ParseVersion(%q) = %v, want nil", tt.input, got)
|
|
}
|
|
continue
|
|
}
|
|
if got == nil || got[0] != tt.want[0] || got[1] != tt.want[1] || got[2] != tt.want[2] {
|
|
t.Errorf("ParseVersion(%q) = %v, want %v", tt.input, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestShouldSkip(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
version string
|
|
env map[string]string
|
|
want bool
|
|
}{
|
|
{"DEV", "DEV", nil, true},
|
|
{"dev_lower", "dev", nil, true},
|
|
{"empty", "", nil, true},
|
|
{"CI", "1.0.0", map[string]string{"CI": "true"}, true},
|
|
{"BUILD_NUMBER", "1.0.0", map[string]string{"BUILD_NUMBER": "42"}, true},
|
|
{"RUN_ID", "1.0.0", map[string]string{"RUN_ID": "123"}, true},
|
|
{"notifier_off", "1.0.0", map[string]string{"LARKSUITE_CLI_NO_UPDATE_NOTIFIER": "1"}, true},
|
|
{"git_describe", "v1.0.0-12-g9b933f1", nil, true},
|
|
{"git_dirty", "v1.0.0-12-g9b933f1-dirty", nil, true},
|
|
{"commit_hash", "9b933f1", nil, true},
|
|
{"clean_semver", "1.0.0", nil, false},
|
|
{"clean_semver_v", "v1.0.0", nil, false},
|
|
{"prerelease_beta", "1.0.0-beta.1", nil, false},
|
|
{"prerelease_rc", "2.0.0-rc.1", nil, false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
clearSkipEnv(t)
|
|
for k, v := range tt.env {
|
|
t.Setenv(k, v)
|
|
}
|
|
got := shouldSkip(tt.version)
|
|
if got != tt.want {
|
|
t.Errorf("shouldSkip(%q) = %v, want %v", tt.version, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsRelease(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
ver string
|
|
want bool
|
|
}{
|
|
{"clean_semver", "1.0.0", true},
|
|
{"v_prefix", "v1.0.0", true},
|
|
{"prerelease", "1.0.0-beta.1", true},
|
|
{"rc", "1.0.0-rc.1", true},
|
|
{"alpha_prerelease", "2.0.0-alpha.0", true},
|
|
{"git_describe_dirty", "1.0.0-12-g9b933f1-dirty", false},
|
|
{"git_describe_clean", "1.0.0-12-g9b933f1", false},
|
|
{"bare_commit_hash", "9b933f1", false},
|
|
{"dev_marker", "DEV", false},
|
|
{"incomplete_semver", "1.0", false},
|
|
{"empty", "", false},
|
|
{"invalid", "not-a-version", false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := IsRelease(tt.ver); got != tt.want {
|
|
t.Errorf("IsRelease(%q) = %v, want %v", tt.ver, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUpdateInfoMethods(t *testing.T) {
|
|
info := &UpdateInfo{Current: "1.0.0", Latest: "2.0.0"}
|
|
got := info.Message()
|
|
want := "lark-cli 2.0.0 available, current 1.0.0, run: lark-cli update"
|
|
if got != want {
|
|
t.Errorf("Message() = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestCheckCached(t *testing.T) {
|
|
clearSkipEnv(t)
|
|
tmp := t.TempDir()
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
|
|
|
// No cache → nil
|
|
info := CheckCached("1.0.0")
|
|
if info != nil {
|
|
t.Errorf("expected nil with no cache, got %+v", info)
|
|
}
|
|
|
|
// Write cache with newer version
|
|
state := &updateState{LatestVersion: "2.0.0", CheckedAt: time.Now().Unix()}
|
|
data, _ := json.Marshal(state)
|
|
os.WriteFile(filepath.Join(tmp, stateFile), data, 0644)
|
|
|
|
info = CheckCached("1.0.0")
|
|
if info == nil {
|
|
t.Fatal("expected update info, got nil")
|
|
}
|
|
if info.Latest != "2.0.0" || info.Current != "1.0.0" {
|
|
t.Errorf("unexpected info: %+v", info)
|
|
}
|
|
|
|
// Same version → nil
|
|
info = CheckCached("2.0.0")
|
|
if info != nil {
|
|
t.Errorf("expected nil when versions match, got %+v", info)
|
|
}
|
|
}
|
|
|
|
func TestRefreshCache(t *testing.T) {
|
|
clearSkipEnv(t)
|
|
tmp := t.TempDir()
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
|
|
|
// Set up mock npm registry via DefaultClient
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
json.NewEncoder(w).Encode(npmLatestResponse{Version: "3.0.0"})
|
|
}))
|
|
defer srv.Close()
|
|
|
|
// Redirect all requests to the mock server.
|
|
DefaultClient = srv.Client()
|
|
DefaultClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
req.URL = mustParseURL(srv.URL + req.URL.Path)
|
|
return http.DefaultTransport.RoundTrip(req)
|
|
})
|
|
defer func() { DefaultClient = nil }()
|
|
|
|
RefreshCache("1.0.0")
|
|
|
|
// Verify cache was written
|
|
info := CheckCached("1.0.0")
|
|
if info == nil {
|
|
t.Fatal("expected update info after refresh, got nil")
|
|
}
|
|
if info.Latest != "3.0.0" {
|
|
t.Errorf("expected latest 3.0.0, got %s", info.Latest)
|
|
}
|
|
|
|
// Second refresh should be no-op (cache is fresh) — won't hit network.
|
|
RefreshCache("1.0.0")
|
|
}
|
|
|
|
func TestPendingAtomicAccess(t *testing.T) {
|
|
// Initially nil
|
|
if got := GetPending(); got != nil {
|
|
t.Errorf("expected nil, got %+v", got)
|
|
}
|
|
|
|
info := &UpdateInfo{Current: "1.0.0", Latest: "2.0.0"}
|
|
SetPending(info)
|
|
|
|
got := GetPending()
|
|
if got == nil || got.Current != "1.0.0" || got.Latest != "2.0.0" {
|
|
t.Errorf("unexpected pending: %+v", got)
|
|
}
|
|
|
|
// Clean up for other tests
|
|
SetPending(nil)
|
|
}
|
|
|
|
func TestIsCIEnv(t *testing.T) {
|
|
clearSkipEnv(t)
|
|
if IsCIEnv() {
|
|
t.Fatal("IsCIEnv() = true after clearSkipEnv, want false")
|
|
}
|
|
for _, key := range []string{"CI", "BUILD_NUMBER", "RUN_ID"} {
|
|
t.Run(key, func(t *testing.T) {
|
|
clearSkipEnv(t)
|
|
t.Setenv(key, "1")
|
|
if !IsCIEnv() {
|
|
t.Errorf("IsCIEnv() = false with %s=1, want true", key)
|
|
}
|
|
})
|
|
}
|
|
}
|