mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
internal/util imported internal/proxyplugin (SharedTransport, FallbackTransport, NewHTTPClient, and WarnIfProxied via proxyPluginStatus), so a foundational util package depended up into a feature package, pulling binding/core/vfs into the transitive cone of every util importer. Move internal/proxyplugin -> internal/transport and make it the single owner of outbound transport: fold the two SharedTransport functions into one Shared() (proxy-plugin override -> LARK_CLI_NO_PROXY -> http.DefaultTransport), and move Fallback/NewHTTPClient/WarnIfProxied/DetectProxyEnv/noProxyTransport out of the now-deleted internal/util/proxy.go into the new package. The proxy-plugin probe is demoted to a private pluginTransport(); the duplicate redactProxyURL collapses to one. internal/util keeps no proxy code and is a leaf again. Re-point all consumers (registry, doctor, config, auth, cmdutil, update) to internal/transport. Behavior-preserving: package move + symbol rename + dedup. Two new tests lock the fail-closed contract (plugin overrides NO_PROXY; malformed config never falls through to direct egress).
362 lines
9.0 KiB
Go
362 lines
9.0 KiB
Go
// 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 = 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 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)
|
|
}
|
|
}
|