chore: import upstream snapshot with attribution
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:22:54 +08:00
commit bf9395e022
2349 changed files with 588574 additions and 0 deletions
+361
View File
@@ -0,0 +1,361 @@
// 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/transport"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
const (
registryURL = "https://registry.npmjs.org/@larksuite/cli/latest"
cacheTTL = 24 * time.Hour
fetchTimeout = 15 * 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 including the canonical
// fix command. Aligned with skillscheck.StaleNotice.Message style so
// AI agents can parse a unified "run: lark-cli update" hint across
// both notice types.
func (u *UpdateInfo) Message() string {
return fmt.Sprintf("lark-cli %s available, current %s, run: lark-cli update", 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,
Transport: transport.Shared(),
}
}
// 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.
if IsCIEnv() {
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)
}
// IsRelease reports whether version looks like a clean published release
// (semver "1.0.0", or npm prerelease "1.0.0-beta.1") and not a git-describe
// dev build like "1.0.0-12-g9b933f1-dirty". Exported so internal/skillscheck
// can apply the same release-only gating without duplicating the regex.
func IsRelease(version string) bool { return isRelease(version) }
// IsCIEnv returns true when any of the standard CI environment variables
// is set. Exported for internal/skillscheck so its skip rules track the
// same CI-suppression behavior as the update notifier.
func IsCIEnv() bool {
for _, key := range []string{"CI", "BUILD_NUMBER", "RUN_ID"} {
if os.Getenv(key) != "" {
return true
}
}
return false
}
// --- state file I/O ---
func statePath() string {
return filepath.Join(core.GetConfigDir(), stateFile)
}
func loadState() (*updateState, error) {
data, err := vfs.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 := vfs.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 := parseVersionDetail(a)
bp := parseVersionDetail(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.core[i] > bp.core[i] {
return true
}
if ap.core[i] < bp.core[i] {
return false
}
}
return comparePrerelease(ap.prerelease, bp.prerelease) > 0
}
// 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 {
parsed := parseVersionDetail(v)
if parsed == nil {
return nil
}
return []int{parsed.core[0], parsed.core[1], parsed.core[2]}
}
type parsedVersion struct {
core [3]int
prerelease string
}
// validPrerelease matches semver pre-release identifiers (dot-separated).
// Each identifier is either: "0", a non-zero-leading numeric, or alphanumeric with at least one letter/hyphen.
// Rejects empty identifiers ("1.0.0-"), leading-zero numerics ("1.0.0-01"), etc.
var validPrerelease = regexp.MustCompile(
`^(?:0|[1-9]\d*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)` +
`(?:\.(?:0|[1-9]\d*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*))*$`)
func parseVersionDetail(v string) *parsedVersion {
v = strings.TrimPrefix(v, "v")
if idx := strings.Index(v, "+"); idx >= 0 {
v = v[:idx]
}
prerelease := ""
if idx := strings.Index(v, "-"); idx >= 0 {
prerelease = v[idx+1:]
v = v[:idx]
if prerelease == "" || !validPrerelease.MatchString(prerelease) {
return nil
}
}
parts := strings.SplitN(v, ".", 3)
if len(parts) != 3 {
return nil
}
var nums [3]int
for i, p := range parts {
if len(p) > 1 && p[0] == '0' {
return nil // leading zero in core part (e.g. "01.0.0")
}
n, err := strconv.Atoi(p)
if err != nil {
return nil
}
nums[i] = n
}
return &parsedVersion{core: nums, prerelease: prerelease}
}
func comparePrerelease(a, b string) int {
if a == "" && b == "" {
return 0
}
if a == "" {
return 1
}
if b == "" {
return -1
}
ap := strings.Split(a, ".")
bp := strings.Split(b, ".")
for i := 0; i < len(ap) && i < len(bp); i++ {
cmp := comparePrereleaseIdentifier(ap[i], bp[i])
if cmp != 0 {
return cmp
}
}
switch {
case len(ap) > len(bp):
return 1
case len(ap) < len(bp):
return -1
default:
return 0
}
}
func comparePrereleaseIdentifier(a, b string) int {
an, aErr := strconv.Atoi(a)
bn, bErr := strconv.Atoi(b)
aNumeric := aErr == nil
bNumeric := bErr == nil
switch {
case aNumeric && bNumeric:
if an > bn {
return 1
}
if an < bn {
return -1
}
return 0
case aNumeric:
return -1
case bNumeric:
return 1
default:
return strings.Compare(a, b)
}
}
+277
View File
@@ -0,0 +1,277 @@
// 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)
}
})
}
}