Files
larksuite-cli/internal/update/update.go
liangshuo-1 4710a294f5 refactor(transport): own all HTTP transport in internal/transport, fix util layering inversion (#1213)
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).
2026-06-02 16:10:35 +08:00

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)
}
}