diff --git a/cmd/doctor/doctor.go b/cmd/doctor/doctor.go index 6edde0a59..67d959894 100644 --- a/cmd/doctor/doctor.go +++ b/cmd/doctor/doctor.go @@ -14,9 +14,11 @@ import ( "github.com/spf13/cobra" larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/build" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/update" ) // DoctorOptions holds inputs for the doctor command. @@ -60,6 +62,10 @@ func fail(name, msg, hint string) checkResult { return checkResult{Name: name, Status: "fail", Message: msg, Hint: hint} } +func warn(name, msg, hint string) checkResult { + return checkResult{Name: name, Status: "warn", Message: msg, Hint: hint} +} + func skip(name, msg string) checkResult { return checkResult{Name: name, Status: "skip", Message: msg} } @@ -68,6 +74,12 @@ func doctorRun(opts *DoctorOptions) error { f := opts.Factory var checks []checkResult + // ── 0. CLI version & update check ── + checks = append(checks, pass("cli_version", build.Version)) + if !opts.Offline { + checks = append(checks, checkCLIUpdate()...) + } + // ── 1. Config file ── _, err := core.LoadMultiAppConfig() if err != nil { @@ -214,6 +226,23 @@ func mustHTTPClient(f *cmdutil.Factory) *http.Client { return c } +// checkCLIUpdate actively queries the npm registry for the latest version. +// Unlike the root-level async check, this does a synchronous fetch with timeout +// and works regardless of build version (dev builds included). +func checkCLIUpdate() []checkResult { + latest, err := update.FetchLatest() + if err != nil { + return []checkResult{warn("cli_update", "check failed: "+err.Error(), "")} + } + current := build.Version + if update.IsNewer(latest, current) { + return []checkResult{warn("cli_update", + fmt.Sprintf("%s → %s available", current, latest), + "run: npm update -g @larksuite/cli")} + } + return []checkResult{pass("cli_update", latest+" (up to date)")} +} + func finishDoctor(f *cmdutil.Factory, checks []checkResult) error { allOK := true for _, c := range checks { diff --git a/cmd/root.go b/cmd/root.go index f02ddfcca..4a6daf9f6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "net/url" + "os" "strconv" "github.com/larksuite/cli/cmd/api" @@ -24,6 +25,7 @@ import ( "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/registry" + "github.com/larksuite/cli/internal/update" "github.com/larksuite/cli/shortcuts" "github.com/spf13/cobra" ) @@ -65,7 +67,7 @@ AI AGENT SKILLS: teach the agent Lark API patterns, best practices, and workflows. Install all skills: - npx skills add larksuite/cli --all -y + npx skills add larksuite/cli -g -y Or pick specific domains: npx skills add larksuite/cli -s lark-calendar -y @@ -105,12 +107,68 @@ func Execute() int { service.RegisterServiceCommands(rootCmd, f) shortcuts.RegisterShortcuts(rootCmd, f) + // --- Update check (non-blocking) --- + if !isCompletionCommand(os.Args) { + setupUpdateNotice() + } + if err := rootCmd.Execute(); err != nil { return handleRootError(f, err) } return 0 } +// setupUpdateNotice starts an async update check and wires the output decorator. +func setupUpdateNotice() { + // Sync: check cache immediately (no network, fast). + if info := update.CheckCached(build.Version); info != nil { + update.SetPending(info) + } + + // Async: refresh cache for this run (and future runs). + go func() { + defer func() { + if r := recover(); r != nil { + fmt.Fprintf(os.Stderr, "update check panic: %v\n", r) + } + }() + update.RefreshCache(build.Version) + // If cache was just populated for the first time, set pending now. + if update.GetPending() == nil { + if info := update.CheckCached(build.Version); info != nil { + update.SetPending(info) + } + } + }() + + // Wire the output decorator so JSON envelopes include "_notice". + output.PendingNotice = func() map[string]interface{} { + info := update.GetPending() + if info == nil { + return nil + } + return map[string]interface{}{ + "update": map[string]interface{}{ + "current": info.Current, + "latest": info.Latest, + "message": info.Message(), + }, + } + } +} + +// isCompletionCommand returns true if args indicate a shell completion request. +// Update notifications must be suppressed for these to avoid corrupting +// machine-parseable completion output. +func isCompletionCommand(args []string) bool { + for _, arg := range args { + if arg == "completion" || arg == "__complete" { + return true + } + } + return false +} + // handleRootError dispatches a command error to the appropriate handler // and returns the process exit code. func handleRootError(f *cmdutil.Factory, err error) int { diff --git a/internal/output/envelope.go b/internal/output/envelope.go index 21caefabb..e76b6d5c0 100644 --- a/internal/output/envelope.go +++ b/internal/output/envelope.go @@ -5,18 +5,20 @@ package output // Envelope is the standard success response wrapper. type Envelope struct { - OK bool `json:"ok"` - Identity string `json:"identity,omitempty"` - Data interface{} `json:"data,omitempty"` - Meta *Meta `json:"meta,omitempty"` + OK bool `json:"ok"` + Identity string `json:"identity,omitempty"` + Data interface{} `json:"data,omitempty"` + Meta *Meta `json:"meta,omitempty"` + Notice map[string]interface{} `json:"_notice,omitempty"` } // ErrorEnvelope is the standard error response wrapper. type ErrorEnvelope struct { - OK bool `json:"ok"` - Identity string `json:"identity,omitempty"` - Error *ErrDetail `json:"error"` - Meta *Meta `json:"meta,omitempty"` + OK bool `json:"ok"` + Identity string `json:"identity,omitempty"` + Error *ErrDetail `json:"error"` + Meta *Meta `json:"meta,omitempty"` + Notice map[string]interface{} `json:"_notice,omitempty"` } // ErrDetail describes a structured error. @@ -34,3 +36,17 @@ type Meta struct { Count int `json:"count,omitempty"` Rollback string `json:"rollback,omitempty"` } + +// PendingNotice, if set, returns system-level notices to inject as the +// "_notice" field in JSON output envelopes. Set by cmd/root.go. +// Returns nil when there is nothing to report. +var PendingNotice func() map[string]interface{} + +// GetNotice returns the current pending notice for struct-based callers. +// Returns nil when there is nothing to report. +func GetNotice() map[string]interface{} { + if PendingNotice == nil { + return nil + } + return PendingNotice() +} diff --git a/internal/output/errors.go b/internal/output/errors.go index e61c9b27e..0a9099232 100644 --- a/internal/output/errors.go +++ b/internal/output/errors.go @@ -40,10 +40,11 @@ func WriteErrorEnvelope(w io.Writer, err *ExitError, identity string) { if err.Detail == nil { return } - env := ErrorEnvelope{ + env := &ErrorEnvelope{ OK: false, Identity: identity, Error: err.Detail, + Notice: GetNotice(), } var buf bytes.Buffer enc := json.NewEncoder(&buf) diff --git a/internal/output/errors_test.go b/internal/output/errors_test.go index 2cc3d1f2d..30662dd05 100644 --- a/internal/output/errors_test.go +++ b/internal/output/errors_test.go @@ -4,6 +4,8 @@ package output import ( + "bytes" + "encoding/json" "fmt" "testing" ) @@ -37,3 +39,112 @@ func TestMarkRaw_Nil(t *testing.T) { t.Error("expected MarkRaw(nil) to return nil") } } + +func TestWriteErrorEnvelope_WithNotice(t *testing.T) { + // Set up PendingNotice + origNotice := PendingNotice + PendingNotice = func() map[string]interface{} { + return map[string]interface{}{ + "update": map[string]interface{}{ + "current": "1.0.0", + "latest": "2.0.0", + }, + } + } + defer func() { PendingNotice = origNotice }() + + exitErr := &ExitError{ + Code: 1, + Detail: &ErrDetail{Type: "api_error", Message: "something failed"}, + } + + var buf bytes.Buffer + WriteErrorEnvelope(&buf, exitErr, "user") + + var env map[string]interface{} + if err := json.Unmarshal(buf.Bytes(), &env); err != nil { + t.Fatalf("failed to parse output: %v", err) + } + + // Verify _notice is present + notice, ok := env["_notice"].(map[string]interface{}) + if !ok { + t.Fatal("expected _notice field in output") + } + update, ok := notice["update"].(map[string]interface{}) + if !ok { + t.Fatal("expected _notice.update field") + } + if update["latest"] != "2.0.0" { + t.Errorf("expected latest=2.0.0, got %v", update["latest"]) + } + + // Verify standard fields + if env["ok"] != false { + t.Error("expected ok=false") + } + if env["identity"] != "user" { + t.Errorf("expected identity=user, got %v", env["identity"]) + } +} + +func TestWriteErrorEnvelope_WithoutNotice(t *testing.T) { + // Ensure PendingNotice is nil + origNotice := PendingNotice + PendingNotice = nil + defer func() { PendingNotice = origNotice }() + + exitErr := &ExitError{ + Code: 1, + Detail: &ErrDetail{Type: "api_error", Message: "something failed"}, + } + + var buf bytes.Buffer + WriteErrorEnvelope(&buf, exitErr, "bot") + + var env map[string]interface{} + if err := json.Unmarshal(buf.Bytes(), &env); err != nil { + t.Fatalf("failed to parse output: %v", err) + } + + if _, ok := env["_notice"]; ok { + t.Error("expected no _notice field when PendingNotice is nil") + } +} + +func TestWriteErrorEnvelope_NilDetail(t *testing.T) { + exitErr := &ExitError{Code: 1} + + var buf bytes.Buffer + WriteErrorEnvelope(&buf, exitErr, "user") + + if buf.Len() != 0 { + t.Errorf("expected no output for nil Detail, got: %s", buf.String()) + } +} + +func TestGetNotice(t *testing.T) { + // Nil PendingNotice → nil + origNotice := PendingNotice + PendingNotice = nil + if got := GetNotice(); got != nil { + t.Errorf("expected nil, got %v", got) + } + + // With PendingNotice → returns value + PendingNotice = func() map[string]interface{} { + return map[string]interface{}{"update": "test"} + } + got := GetNotice() + if got == nil || got["update"] != "test" { + t.Errorf("expected {update: test}, got %v", got) + } + + // PendingNotice returns nil → nil + PendingNotice = func() map[string]interface{} { return nil } + if got := GetNotice(); got != nil { + t.Errorf("expected nil, got %v", got) + } + + PendingNotice = origNotice +} diff --git a/internal/output/print.go b/internal/output/print.go index e26e5117c..c26c2edbd 100644 --- a/internal/output/print.go +++ b/internal/output/print.go @@ -14,6 +14,7 @@ import ( // PrintJson prints data as formatted JSON to w. func PrintJson(w io.Writer, data interface{}) { + injectNotice(data) b, err := json.MarshalIndent(data, "", " ") if err != nil { fmt.Fprintf(os.Stderr, "json marshal error: %v\n", err) @@ -22,6 +23,31 @@ func PrintJson(w io.Writer, data interface{}) { fmt.Fprintln(w, string(b)) } +// injectNotice adds a "_notice" field into CLI envelope maps. +// Only modifies map[string]interface{} values that have an "ok" key +// (e.g. doctor, auth, config commands that build map envelopes directly). +// +// Struct-based envelopes (Envelope, ErrorEnvelope) are NOT handled here — +// callers must set the Notice field explicitly via GetNotice(). +// See: shortcuts/common/runner.go Out(), output/errors.go WriteErrorEnvelope(). +func injectNotice(data interface{}) { + if PendingNotice == nil { + return + } + m, ok := data.(map[string]interface{}) + if !ok { + return + } + if _, isEnvelope := m["ok"]; !isEnvelope { + return + } + notice := PendingNotice() + if notice == nil { + return + } + m["_notice"] = notice +} + // PrintNdjson prints data as NDJSON (Newline Delimited JSON) to w. func PrintNdjson(w io.Writer, data interface{}) { emit := func(item interface{}) { diff --git a/internal/output/print_test.go b/internal/output/print_test.go new file mode 100644 index 000000000..46c13f93b --- /dev/null +++ b/internal/output/print_test.go @@ -0,0 +1,101 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +import ( + "bytes" + "encoding/json" + "testing" +) + +func TestPrintJson_InjectNotice_Map(t *testing.T) { + origNotice := PendingNotice + PendingNotice = func() map[string]interface{} { + return map[string]interface{}{"update": "available"} + } + defer func() { PendingNotice = origNotice }() + + data := map[string]interface{}{"ok": true, "data": "test"} + var buf bytes.Buffer + PrintJson(&buf, data) + + var got map[string]interface{} + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("failed to parse: %v", err) + } + notice, ok := got["_notice"].(map[string]interface{}) + if !ok { + t.Fatal("expected _notice in map-based envelope") + } + if notice["update"] != "available" { + t.Errorf("expected update=available, got %v", notice["update"]) + } +} + +func TestPrintJson_InjectNotice_SkipsNonEnvelope(t *testing.T) { + origNotice := PendingNotice + PendingNotice = func() map[string]interface{} { + return map[string]interface{}{"update": "available"} + } + defer func() { PendingNotice = origNotice }() + + // Map without "ok" key should not get _notice + data := map[string]interface{}{"name": "test"} + var buf bytes.Buffer + PrintJson(&buf, data) + + var got map[string]interface{} + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("failed to parse: %v", err) + } + if _, ok := got["_notice"]; ok { + t.Error("expected no _notice for non-envelope map") + } +} + +func TestPrintJson_Struct_PreservesNotice(t *testing.T) { + origNotice := PendingNotice + PendingNotice = nil // no global notice + defer func() { PendingNotice = origNotice }() + + // Struct with Notice already set should preserve it + env := &Envelope{ + OK: true, + Identity: "user", + Data: "hello", + Notice: map[string]interface{}{"update": "set-by-caller"}, + } + var buf bytes.Buffer + PrintJson(&buf, env) + + var got map[string]interface{} + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("failed to parse: %v", err) + } + notice, ok := got["_notice"].(map[string]interface{}) + if !ok { + t.Fatal("expected _notice from struct field") + } + if notice["update"] != "set-by-caller" { + t.Errorf("expected update=set-by-caller, got %v", notice["update"]) + } +} + +func TestPrintJson_NoNotice(t *testing.T) { + origNotice := PendingNotice + PendingNotice = nil + defer func() { PendingNotice = origNotice }() + + data := map[string]interface{}{"ok": true, "data": "test"} + var buf bytes.Buffer + PrintJson(&buf, data) + + var got map[string]interface{} + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("failed to parse: %v", err) + } + if _, ok := got["_notice"]; ok { + t.Error("expected no _notice when PendingNotice is nil") + } +} diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 000000000..68e0a265c --- /dev/null +++ b/internal/update/update.go @@ -0,0 +1,255 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package update + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync/atomic" + "time" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/validate" +) + +const ( + registryURL = "https://registry.npmjs.org/@larksuite/cli/latest" + cacheTTL = 24 * time.Hour + fetchTimeout = 5 * time.Second + stateFile = "update-state.json" + maxBody = 256 << 10 // 256 KB + +) + +// UpdateInfo holds version update information. +type UpdateInfo struct { + Current string `json:"current"` + Latest string `json:"latest"` +} + +// Message returns a concise update notification. +func (u *UpdateInfo) Message() string { + return fmt.Sprintf("lark-cli %s available, current %s", u.Latest, u.Current) +} + +// pending stores the latest update info for the current process. +var pending atomic.Pointer[UpdateInfo] + +// SetPending stores the update info for consumption by output decorators. +func SetPending(info *UpdateInfo) { pending.Store(info) } + +// GetPending returns the pending update info, or nil. +func GetPending() *UpdateInfo { return pending.Load() } + +// DefaultClient is the HTTP client used for npm registry requests. +// Override in tests with an httptest server client. +var DefaultClient *http.Client + +func httpClient() *http.Client { + if DefaultClient != nil { + return DefaultClient + } + return &http.Client{Timeout: fetchTimeout} +} + +// updateState is persisted to disk for caching. +type updateState struct { + LatestVersion string `json:"latest_version"` + CheckedAt int64 `json:"checked_at"` +} + +// CheckCached checks the local cache only (no network). Always fast. +func CheckCached(currentVersion string) *UpdateInfo { + if shouldSkip(currentVersion) { + return nil + } + state, _ := loadState() + if state == nil || state.LatestVersion == "" { + return nil + } + if !IsNewer(state.LatestVersion, currentVersion) { + return nil + } + return &UpdateInfo{Current: currentVersion, Latest: state.LatestVersion} +} + +// RefreshCache fetches the latest version from npm and updates the local cache. +// No-op if the cache is still fresh (< 24h). Safe to call from a goroutine. +func RefreshCache(currentVersion string) { + if shouldSkip(currentVersion) { + return + } + state, _ := loadState() + if state != nil && time.Since(time.Unix(state.CheckedAt, 0)) < cacheTTL { + return // cache is fresh + } + latest, err := fetchLatestVersion() + if err != nil { + return + } + _ = saveState(&updateState{ + LatestVersion: latest, + CheckedAt: time.Now().Unix(), + }) +} + +func shouldSkip(version string) bool { + if os.Getenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER") != "" { + return true + } + // Suppress in CI environments. + for _, key := range []string{"CI", "BUILD_NUMBER", "RUN_ID"} { + if os.Getenv(key) != "" { + return true + } + } + // No version info at all — can't compare. + if version == "DEV" || version == "dev" || version == "" { + return true + } + // Skip local dev builds (e.g. v1.0.0-12-g9b933f1-dirty from git describe). + // Only released versions (clean X.Y.Z) should check for updates. + if !isRelease(version) { + return true + } + return false +} + +// isRelease returns true for published versions: clean semver (1.0.0) +// and npm prerelease (1.0.0-beta.1, 1.0.0-rc.1). +// Returns false for git describe dev builds (v1.0.0-12-g9b933f1-dirty). +var gitDescribePattern = regexp.MustCompile(`-\d+-g[0-9a-f]{7,}`) + +func isRelease(version string) bool { + v := strings.TrimPrefix(version, "v") + if ParseVersion(v) == nil { + return false + } + return !gitDescribePattern.MatchString(v) +} + +// --- state file I/O --- + +func statePath() string { + return filepath.Join(core.GetConfigDir(), stateFile) +} + +func loadState() (*updateState, error) { + data, err := os.ReadFile(statePath()) + if err != nil { + return nil, err + } + var s updateState + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + return &s, nil +} + +func saveState(s *updateState) error { + dir := core.GetConfigDir() + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + data, err := json.Marshal(s) + if err != nil { + return err + } + return validate.AtomicWrite(statePath(), data, 0644) +} + +// FetchLatest queries the npm registry and returns the latest published version. +// This is a synchronous call with timeout, intended for diagnostic commands (doctor). +func FetchLatest() (string, error) { + return fetchLatestVersion() +} + +// --- npm registry --- + +type npmLatestResponse struct { + Version string `json:"version"` +} + +func fetchLatestVersion() (string, error) { + resp, err := httpClient().Get(registryURL) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("npm registry: HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody)) + if err != nil { + return "", err + } + + var result npmLatestResponse + if err := json.Unmarshal(body, &result); err != nil { + return "", err + } + if result.Version == "" { + return "", fmt.Errorf("npm registry: empty version") + } + return result.Version, nil +} + +// --- semver helpers --- + +// IsNewer returns true if version a should be considered an update over b. +// +// When both parse as semver, standard comparison applies. +// When b cannot be parsed (e.g. bare commit hash "9b933f1"), any valid a +// is considered newer — an unparseable local version is assumed outdated. +// When a cannot be parsed, returns false (can't confirm it's newer). +func IsNewer(a, b string) bool { + ap := ParseVersion(a) + bp := ParseVersion(b) + if ap == nil { + return false // can't confirm remote is newer + } + if bp == nil { + return true // local version unparseable → assume outdated + } + for i := 0; i < 3; i++ { + if ap[i] > bp[i] { + return true + } + if ap[i] < bp[i] { + return false + } + } + return false +} + +// ParseVersion parses "X.Y.Z" (with optional "v" prefix and pre-release suffix) +// into [major, minor, patch]. Returns nil on invalid input. +func ParseVersion(v string) []int { + v = strings.TrimPrefix(v, "v") + parts := strings.SplitN(v, ".", 3) + if len(parts) != 3 { + return nil + } + nums := make([]int, 3) + for i, p := range parts { + if idx := strings.IndexAny(p, "-+"); idx >= 0 { + p = p[:idx] + } + n, err := strconv.Atoi(p) + if err != nil { + return nil + } + nums[i] = n + } + return nums +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go new file mode 100644 index 000000000..a56a99676 --- /dev/null +++ b/internal/update/update_test.go @@ -0,0 +1,253 @@ +// 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" + "strings" + "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 + } + 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}}, + {"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 { + version string + want bool + }{ + {"1.0.0", true}, + {"v1.0.0", true}, + {"0.1.0", true}, + {"1.0.0-beta.1", true}, + {"1.0.0-rc.1", true}, + {"2.0.0-alpha.0", true}, + {"v1.0.0-12-g9b933f1", false}, // git describe + {"v1.0.0-12-g9b933f1-dirty", false}, // git describe dirty + {"v2.1.0-3-gabcdef0", false}, // git describe short + {"9b933f1", false}, // bare commit hash + {"DEV", false}, // dev marker + {"", false}, // empty + {"1.0", false}, // incomplete semver + } + for _, tt := range tests { + t.Run(tt.version, func(t *testing.T) { + got := isRelease(tt.version) + if got != tt.want { + t.Errorf("isRelease(%q) = %v, want %v", tt.version, got, tt.want) + } + }) + } +} + +func TestUpdateInfoMethods(t *testing.T) { + info := &UpdateInfo{Current: "1.0.0", Latest: "2.0.0"} + + msg := info.Message() + if !strings.Contains(msg, "2.0.0") { + t.Errorf("Message() missing latest version: %s", msg) + } + if !strings.Contains(msg, "1.0.0") { + t.Errorf("Message() missing current version: %s", msg) + } +} + +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) +} diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 1f1df8bce..5a6e2ba49 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -418,7 +418,7 @@ func (ctx *RuntimeContext) IO() *cmdutil.IOStreams { // Out prints a success JSON envelope to stdout. func (ctx *RuntimeContext) Out(data interface{}, meta *output.Meta) { - env := output.Envelope{OK: true, Identity: string(ctx.As()), Data: data, Meta: meta} + env := output.Envelope{OK: true, Identity: string(ctx.As()), Data: data, Meta: meta, Notice: output.GetNotice()} b, _ := json.MarshalIndent(env, "", " ") fmt.Fprintln(ctx.IO().Out, string(b)) } diff --git a/skills/lark-shared/SKILL.md b/skills/lark-shared/SKILL.md index c21bca742..139b4c1e6 100644 --- a/skills/lark-shared/SKILL.md +++ b/skills/lark-shared/SKILL.md @@ -73,6 +73,21 @@ lark-cli auth login --scope "calendar:calendar:readonly" ``` +## 更新检查 + +lark-cli 命令执行后,如果检测到新版本,JSON 输出中会包含 `_notice.update` 字段(含 `message`、`command` 等)。 + +**当你在输出中看到 `_notice.update` 时,完成用户当前请求后,主动提议帮用户更新**: + +1. 告知用户当前版本和最新版本号 +2. 提议执行更新(CLI 和 Skills 需要同时更新): + ```bash + npm update -g @larksuite/cli && npx skills add larksuite/cli -g -y + ``` +3. 更新完成后提醒用户:**退出并重新打开 AI Agent**以加载最新 Skills + +**规则**:不要静默忽略更新提示。即使当前任务与更新无关,也应在完成用户请求后补充告知。 + ## 安全规则 - **禁止输出密钥**(appSecret、accessToken)到终端明文。