Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/commandhost"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/distribution"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/keychain"
internalplatform "github.com/larksuite/cli/internal/platform"
Expand Down Expand Up @@ -225,6 +226,7 @@ func buildInternalWithConfig(ctx context.Context, inv cmdutil.InvocationContext,
if cfg == nil {
cfg = &buildConfig{}
}
ctx = distribution.CaptureSource(ctx)
registeredShortcuts, commandSetErr := resolveShortcutSnapshot(cfg.commandSets)
// Default streams when WithIO is not supplied so the root command's
// SetIn/Out/Err calls below don't deref nil. NewDefault also normalizes
Expand Down
16 changes: 8 additions & 8 deletions cmd/doctor/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ func doctorRun(opts *DoctorOptions, projector *recovery.Projector) error {
// ── 0. CLI version & update check ──
checks = append(checks, pass("cli_version", build.Version))
if !opts.Offline && projector.CanReference(recovery.TargetUpdate) {
checks = append(checks, checkCLIUpdate()...)
checks = append(checks, checkCLIUpdate(opts.Ctx)...)
}

// ── 1. Config file ──
Expand Down Expand Up @@ -241,24 +241,24 @@ func probeEndpoint(ctx context.Context, client *http.Client, url string) error {
return nil
}

// checkCLIUpdate actively queries the npm registry for the latest version.
// checkCLIUpdate actively queries the configured source for its target 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 := fetchLatestForDoctor()
func checkCLIUpdate(ctx context.Context) []checkResult {
target, err := fetchLatestForDoctor(ctx)
if err != nil {
return []checkResult{warn("cli_update", "check failed: "+err.Error(), "")}
}
current := build.Version
if update.IsNewer(latest, current) {
if target.Available(current) {
return []checkResult{warn("cli_update",
fmt.Sprintf("%s → %s available", current, latest),
fmt.Sprintf("%s → %s available", current, target.Version),
"run: lark-cli update")}
}
return []checkResult{pass("cli_update", latest+" (up to date)")}
return []checkResult{pass("cli_update", target.Version+" (up to date)")}
}

var fetchLatestForDoctor = update.FetchLatest
var fetchLatestForDoctor = update.FetchTarget

func finishDoctor(f *cmdutil.Factory, checks []checkResult) error {
allOK := true
Expand Down
45 changes: 43 additions & 2 deletions cmd/doctor/doctor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,61 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/spf13/cobra"

extcred "github.com/larksuite/cli/extension/credential"
exttransport "github.com/larksuite/cli/extension/transport"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/distribution"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/surface"
"github.com/larksuite/cli/internal/update"
)

type doctorManifestProvider struct{ manifestURL string }

func (doctorManifestProvider) Name() string { return "doctor-manifest-test" }
func (doctorManifestProvider) ResolveInterceptor(context.Context) exttransport.Interceptor {
return nil
}
func (p doctorManifestProvider) ResolveManifestURL(context.Context) string {
return p.manifestURL
}

func TestCheckCLIUpdateReportsDifferentOpaqueManifestTarget(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = fmt.Fprintf(w, `{"schema":1,"version":"older-channel","artifacts":{"skills":{"url":"https://distribution.example/skills.zip","checksum":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},%q:{"url":"https://distribution.example/cli.zip","checksum":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}}`, distribution.CurrentPlatformKey())
}))
defer server.Close()
previousProvider := exttransport.GetProvider()
previousFetch := fetchLatestForDoctor
previousClient := distribution.DefaultClient
previousVersion := build.Version
exttransport.Register(doctorManifestProvider{manifestURL: server.URL})
distribution.DefaultClient = server.Client()
fetchLatestForDoctor = update.FetchTarget
build.Version = "newer-channel"
t.Cleanup(func() {
exttransport.Register(previousProvider)
fetchLatestForDoctor = previousFetch
distribution.DefaultClient = previousClient
build.Version = previousVersion
})
checks := checkCLIUpdate(context.Background())
if len(checks) != 1 || checks[0].Status != "warn" || !strings.Contains(checks[0].Message, "older-channel") {
t.Fatalf("checks = %#v", checks)
}
}

func TestNewCmdDoctor_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
Expand Down Expand Up @@ -109,9 +150,9 @@ func TestDoctorRunDoesNotFetchUpdateWhenCommandIsConcealed(t *testing.T) {
t.Cleanup(func() { fetchLatestForDoctor = oldFetch })

fetches := 0
fetchLatestForDoctor = func() (string, error) {
fetchLatestForDoctor = func(context.Context) (update.Target, error) {
fetches++
return "9.9.9", nil
return update.Target{Version: "9.9.9"}, nil
}
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandUpdate: surface.CommandConcealed,
Expand Down
14 changes: 14 additions & 0 deletions cmd/notice_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,22 @@ import (

"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/update"
)

func TestComposePendingNoticeUsesManifestTarget(t *testing.T) {
update.SetPending(&update.UpdateInfo{Current: "newer", Latest: "older", Source: "manifest"})
t.Cleanup(func() { update.SetPending(nil) })

entry := composePendingNotice(nil)["update"].(map[string]interface{})
if entry["target"] != "older" || entry["source"] != "manifest" {
t.Fatalf("manifest update notice = %#v", entry)
}
if _, exists := entry["latest"]; exists {
t.Fatalf("manifest target was labeled latest: %#v", entry)
}
}

// composePendingNotice must surface a deprecated-command alias under the
// "deprecated_command" key, with the migration target and a skill-update hint,
// so the JSON "_notice" envelope reaches users who run pre-refactor commands
Expand Down
8 changes: 4 additions & 4 deletions cmd/presentation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -595,14 +595,14 @@ func TestSetupNoticesDoesNoProviderWorkWhenUpdateIsConcealed(t *testing.T) {
})

var checks, refreshes, skillChecks int
checkCachedUpdate = func(string) *update.UpdateInfo {
checkCachedUpdate = func(context.Context, string) *update.UpdateInfo {
checks++
return nil
}
refreshUpdateCache = func(string) { refreshes++ }
initializeSkillsCheck = func(string) { skillChecks++ }
refreshUpdateCache = func(context.Context, string) { refreshes++ }
initializeSkillsCheck = func(context.Context, string) { skillChecks++ }

setupNotices(surface.NewPlan(map[surface.CommandID]surface.CommandState{
setupNotices(context.Background(), surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandUpdate: surface.CommandConcealed,
}))
if checks != 0 || refreshes != 0 || skillChecks != 0 {
Expand Down
31 changes: 22 additions & 9 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/distribution"
"github.com/larksuite/cli/internal/flagalias"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
Expand Down Expand Up @@ -106,7 +107,7 @@ func executeWithOptions(opts []BuildOption) int {

// --- Notices (non-blocking) ---
if !isCompletionCommand(os.Args) {
setupNotices(runtime.surface)
setupNotices(rootCmd.Context(), runtime.surface)
}

runErr := rootCmd.Execute()
Expand Down Expand Up @@ -142,17 +143,23 @@ func isDeferredBootstrapProfileError(err error) bool {
var (
checkCachedUpdate = update.CheckCached
refreshUpdateCache = update.RefreshCache
initializeSkillsCheck = skillscheck.Init
initializeSkillsCheck = func(ctx context.Context, version string) {
if src, err := distribution.ResolveSource(ctx); err == nil && src.ManifestMode() {
skillscheck.InitForSource(version, src.Identity(), true)
return
}
skillscheck.Init(version)
}
)

// setupNotices wires both the binary update notice and the skills
// staleness notice into output.PendingNotice as a composed function.
// Each provider populates an independent key under _notice; either
// or both may be present in any given envelope.
func setupNotices(plan *surface.Plan) {
func setupNotices(ctx context.Context, plan *surface.Plan) {
if plan.CanReference(surface.CommandUpdate) {
// Binary update — synchronous cache check + async refresh.
if info := checkCachedUpdate(build.Version); info != nil {
if info := checkCachedUpdate(ctx, build.Version); info != nil {
update.SetPending(info)
}
ver := build.Version
Expand All @@ -162,17 +169,17 @@ func setupNotices(plan *surface.Plan) {
fmt.Fprintf(os.Stderr, "update check panic: %v\n", r)
}
}()
refreshUpdateCache(ver)
refreshUpdateCache(ctx, ver)
if update.GetPending() == nil {
if info := checkCachedUpdate(ver); info != nil {
if info := checkCachedUpdate(ctx, ver); info != nil {
update.SetPending(info)
}
}
}()

// Skills drift has only one recovery action: lark-cli update. Do not
// even inspect local drift state when that action is absent.
initializeSkillsCheck(build.Version)
initializeSkillsCheck(ctx, build.Version)
}

// Capture this build's immutable plan; never consult another Build's state.
Expand All @@ -192,12 +199,18 @@ func composePendingNotice(plan *surface.Plan) map[string]interface{} {
// both exist solely to steer the caller to `lark-cli update`.
if canUpdate {
if info := update.GetPending(); info != nil {
notice["update"] = map[string]interface{}{
entry := map[string]interface{}{
"current": info.Current,
"latest": info.Latest,
"message": info.Message(),
"command": "lark-cli update",
}
if info.Source == "manifest" {
entry["source"] = "manifest"
entry["target"] = info.Latest
} else {
entry["latest"] = info.Latest
}
notice["update"] = entry
}
if stale := skillscheck.GetPending(); stale != nil {
entry := map[string]interface{}{
Expand Down
8 changes: 4 additions & 4 deletions cmd/root_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -510,7 +510,7 @@ func TestSetupNotices_ColdStart_NoNotice(t *testing.T) {
output.PendingNotice = nil
})

setupNotices(nil)
setupNotices(context.Background(), nil)

notice := output.GetNotice()
if notice == nil {
Expand Down Expand Up @@ -544,7 +544,7 @@ func TestSetupNotices_InSync(t *testing.T) {
output.PendingNotice = nil
})

setupNotices(nil)
setupNotices(context.Background(), nil)

notice := output.GetNotice()
if notice != nil {
Expand Down Expand Up @@ -577,7 +577,7 @@ func TestSetupNotices_Drift(t *testing.T) {
output.PendingNotice = nil
})

setupNotices(nil)
setupNotices(context.Background(), nil)

notice := output.GetNotice()
if notice == nil {
Expand Down Expand Up @@ -626,7 +626,7 @@ func TestSetupNotices_BothUpdateAndSkills(t *testing.T) {
output.PendingNotice = nil
})

setupNotices(nil)
setupNotices(context.Background(), nil)

// After setupNotices, skills pending is set (drift). Manually populate
// the update side so the composed envelope has both keys — the update
Expand Down
2 changes: 1 addition & 1 deletion cmd/root_upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command, projector *recover
// Gate 4: cached newer version. CheckCached applies opt-out (shouldSkip)
// and the IsNewer/semver validation chain; it reads the on-disk cache that
// the 24h-throttled RefreshCache maintains (CheckCached itself has no TTL).
info := checkRootCachedUpdate(build.Version)
info := checkRootCachedUpdate(cmd.Context(), build.Version)
if info == nil {
return
}
Expand Down
3 changes: 2 additions & 1 deletion cmd/root_upgrade_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package cmd

import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
Expand Down Expand Up @@ -154,7 +155,7 @@ func TestOfferRootUpgradeDoesNotReadCacheWhenUpdateIsConcealed(t *testing.T) {
t.Cleanup(func() { checkRootCachedUpdate = oldCheck })

cacheReads := 0
checkRootCachedUpdate = func(string) *update.UpdateInfo {
checkRootCachedUpdate = func(context.Context, string) *update.UpdateInfo {
cacheReads++
return &update.UpdateInfo{Current: "1.0.0", Latest: "2.0.0"}
}
Expand Down
Loading
Loading