Files
larksuite-cli/internal/registry/remote.go

332 lines
8.5 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registry
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"sync"
"time"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
const (
defaultMetaTTL = 86400 // seconds (24h)
maxResponseSize = 10 << 20 // 10 MB
fetchTimeout = 5 * time.Second
)
// CacheMeta holds metadata about the cached remote_meta.json file.
type CacheMeta struct {
LastCheckAt int64 `json:"last_check_at"`
Version string `json:"version,omitempty"`
Brand string `json:"brand,omitempty"`
}
// MergedRegistry is the top-level structure of remote_meta.json. Services are
// decoded straight into the typed meta model so embedded, cached, and remote
// data share one representation (no map intermediary, no re-parse).
type MergedRegistry struct {
Version string `json:"version"`
Services []meta.Service `json:"services"`
}
// remoteResponse is the envelope returned by the remote API. Data stays raw:
// the cache must store the server payload verbatim — a typed re-marshal would
// strip every field the model doesn't (yet) declare and starve future binaries.
type remoteResponse struct {
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
}
// configuredBrand is set by InitWithBrand and determines which API host to use.
var configuredBrand core.LarkBrand
// --- configuration helpers ---
// enableRemoteMeta controls whether remote API meta fetching is active.
// Flip to true when ready to roll out.
var enableRemoteMeta = true
func remoteEnabled() bool {
if !enableRemoteMeta {
return false
}
return os.Getenv("LARKSUITE_CLI_REMOTE_META") != "off"
}
// testMetaURL overrides the remote meta URL for testing.
var testMetaURL string
func remoteMetaURL(version string) string {
if testMetaURL != "" {
return testMetaURL
}
var base string
switch configuredBrand {
case core.BrandLark:
base = "https://open.larksuite.com/api/tools/open/api_definition"
default:
base = "https://open.feishu.cn/api/tools/open/api_definition"
}
q := "protocol=meta&client_version=" + url.QueryEscape(build.Version)
if version != "" {
q += "&data_version=" + url.QueryEscape(version)
}
return base + "?" + q
}
func metaTTL() time.Duration {
if s := os.Getenv("LARKSUITE_CLI_META_TTL"); s != "" {
if n, err := strconv.Atoi(s); err == nil && n >= 0 {
return time.Duration(n) * time.Second
}
}
return defaultMetaTTL * time.Second
}
// --- cache path helpers ---
func cacheDir() string {
return filepath.Join(core.GetConfigDir(), "cache")
}
func cachePath() string {
return filepath.Join(cacheDir(), "remote_meta.json")
}
func cacheMetaPath() string {
return filepath.Join(cacheDir(), "remote_meta.meta.json")
}
// cacheWritable checks if the cache directory is writable.
// Returns false if the directory cannot be created or written to.
func cacheWritable() bool {
dir := cacheDir()
if err := vfs.MkdirAll(dir, 0700); err != nil {
return false
}
probe := filepath.Join(dir, ".probe")
if err := vfs.WriteFile(probe, []byte{}, 0644); err != nil {
return false
}
vfs.Remove(probe)
return true
}
// --- cache I/O ---
func loadCacheMeta() (CacheMeta, error) {
var cm CacheMeta
data, err := vfs.ReadFile(cacheMetaPath())
if err != nil {
return cm, err
}
if err = json.Unmarshal(data, &cm); err != nil {
return cm, err
}
return cm, nil
}
func saveCacheMeta(cm CacheMeta) error {
if err := vfs.MkdirAll(cacheDir(), 0700); err != nil {
return err
}
data, err := json.Marshal(cm)
if err != nil {
return err
}
return validate.AtomicWrite(cacheMetaPath(), data, 0644)
}
func loadCachedMerged() (*MergedRegistry, error) {
path := cachePath()
data, err := vfs.ReadFile(path)
if err != nil {
return nil, err
}
var reg MergedRegistry
if err := json.Unmarshal(data, &reg); err != nil {
// Cache corrupted — remove it so next run triggers a fresh fetch
vfs.Remove(path)
vfs.Remove(cacheMetaPath())
return nil, err
}
return &reg, nil
}
// saveCachedMerged writes the cache file. data must be the server's data
// payload verbatim (see remoteResponse) — never a typed re-marshal.
func saveCachedMerged(data []byte, cm CacheMeta) error {
if err := vfs.MkdirAll(cacheDir(), 0700); err != nil {
return err
}
if err := validate.AtomicWrite(cachePath(), data, 0644); err != nil {
return err
}
return saveCacheMeta(cm)
}
// --- HTTP fetch ---
// fetchRemoteMerged fetches the remote API definition.
// localVersion is sent as data_version query param for server-side version comparison.
// Returns (data, reg, err). A nil reg means the version is unchanged (not modified).
func fetchRemoteMerged(localVersion string) (data []byte, reg *MergedRegistry, err error) {
// Route through the shared proxy-plugin-aware transport so remote API
// definition fetches honor proxy plugin mode instead of bypassing it.
client := transport.NewHTTPClient(fetchTimeout)
req, err := http.NewRequest("GET", remoteMetaURL(localVersion), nil)
if err != nil {
return nil, nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, nil, &httpError{StatusCode: resp.StatusCode}
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize))
if err != nil {
return nil, nil, err
}
// Parse the envelope response
var envelope remoteResponse
if err := json.Unmarshal(body, &envelope); err != nil {
return nil, nil, err
}
if envelope.Msg != "succeeded" {
return nil, nil, fmt.Errorf("remote meta: unexpected msg %q", envelope.Msg)
}
var parsed MergedRegistry
if len(envelope.Data) > 0 {
if err := json.Unmarshal(envelope.Data, &parsed); err != nil {
return nil, nil, fmt.Errorf("remote meta: parse data: %w", err)
}
}
// If data.services is nil, the version is up-to-date (not modified)
if parsed.Services == nil {
return nil, nil, nil
}
// Cache the data portion verbatim (see remoteResponse for why not a typed
// re-marshal).
return envelope.Data, &parsed, nil
}
type httpError struct {
StatusCode int
}
func (e *httpError) Error() string {
return "remote meta: HTTP " + strconv.Itoa(e.StatusCode)
}
// --- sync fetch (no embedded, no cache) ---
// doSyncFetch performs a blocking fetch for first-run without embedded data.
func doSyncFetch() {
fmt.Fprintf(os.Stderr, "Fetching API metadata...\n")
data, reg, err := fetchRemoteMerged(embeddedVersion)
if err != nil || reg == nil {
// Write meta even on failure so we don't retry every invocation within TTL
_ = saveCacheMeta(CacheMeta{
LastCheckAt: time.Now().Unix(),
Brand: string(configuredBrand),
})
return
}
cm := CacheMeta{
LastCheckAt: time.Now().Unix(),
Version: reg.Version,
Brand: string(configuredBrand),
}
_ = saveCachedMerged(data, cm)
overlayMergedServices(reg)
}
// --- background refresh ---
var (
refreshOnce sync.Once
bgRefreshInFlight sync.WaitGroup // tracks doBackgroundRefresh goroutines for test teardown (resetInit)
)
func triggerBackgroundRefresh() {
refreshOnce.Do(func() {
bgRefreshInFlight.Add(1)
go func() {
defer bgRefreshInFlight.Done()
doBackgroundRefresh()
}()
})
}
func doBackgroundRefresh() {
defer func() { _ = recover() }()
cm, _ := loadCacheMeta()
version := cm.Version
if version == "" {
version = embeddedVersion
}
data, reg, err := fetchRemoteMerged(version)
if err != nil {
// On error, update last_check_at to avoid retrying every invocation
cm.LastCheckAt = time.Now().Unix()
_ = saveCacheMeta(cm)
return
}
if reg == nil {
// Version unchanged — just update check time
cm.LastCheckAt = time.Now().Unix()
_ = saveCacheMeta(cm)
return
}
newMeta := CacheMeta{
LastCheckAt: time.Now().Unix(),
Version: reg.Version,
Brand: string(configuredBrand),
}
_ = saveCachedMerged(data, newMeta)
}
// shouldRefresh returns true if the cache TTL has expired.
func shouldRefresh(cm CacheMeta) bool {
if cm.LastCheckAt == 0 {
return true
}
return time.Since(time.Unix(cm.LastCheckAt, 0)) > metaTTL()
}
// overlayMergedServices merges remote services into the in-memory map.
// Remote entries override embedded entries with the same name.
func overlayMergedServices(reg *MergedRegistry) {
for _, svc := range reg.Services {
if svc.Name == "" {
continue
}
mergedServices[svc.Name] = svc
}
}