diff --git a/cmd/schema/schema.go b/cmd/schema/schema.go index 64d5dc9a3..3b27967e0 100644 --- a/cmd/schema/schema.go +++ b/cmd/schema/schema.go @@ -65,13 +65,13 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co return cmd } -// completeSchemaPath is a thin adapter over the embedded catalog's Complete. -// It uses the embedded source so completion candidates match what `schema` -// execution can resolve (both overlay-free). +// completeSchemaPath is a thin adapter over the schema catalog's Complete. +// It uses the same source as schema execution so completion candidates match +// what `schema` can resolve. func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { mode := f.ResolveStrictMode(cmd.Context()) - completions, noSpace := registry.EmbeddedCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode)) + completions, noSpace := registry.SchemaCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode)) directive := cobra.ShellCompDirectiveNoFileComp if noSpace { directive |= cobra.ShellCompDirectiveNoSpace @@ -86,13 +86,19 @@ func schemaRun(opts *SchemaOptions) error { return runSchema(out, apicatalog.ParsePath(opts.Args), mode) } -// runSchema resolves the path through the embedded catalog and renders the +// runSchema resolves the path through the schema catalog and renders the // matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and // schema owns rendering (Envelope/Envelopes); this adapter only chooses the // output shape — a single resolved method renders as one envelope object, // anything broader as an array — and maps resolve failures to hints. func runSchema(out io.Writer, parts []string, mode core.StrictMode) error { - catalog := registry.EmbeddedCatalog() + catalog := registry.SchemaCatalog() + if len(catalog.Services()) == 0 { + // No embedded metadata and the runtime fallback is empty too: offline + // with a cold cache, remote meta off, or an unwritable cache dir. + return errs.NewValidationError(errs.SubtypeFailedPrecondition, "No API metadata available"). + WithHint("this binary has no embedded API metadata; run any command with network access to the open platform once so metadata can be fetched and cached") + } target, err := catalog.Resolve(parts) if err != nil { return resolveError(err) diff --git a/internal/affordance/affordance.go b/internal/affordance/affordance.go index b0e49836d..6ad46cd59 100644 --- a/internal/affordance/affordance.go +++ b/internal/affordance/affordance.go @@ -77,14 +77,10 @@ func loadService(service string) map[string]json.RawMessage { // space→dot fallback covers domains where the two already coincide. func commandFormResolver(service string) func(string) string { byForm := map[string]string{} - for _, svc := range registry.EmbeddedServicesTyped() { - if svc.Name != service { - continue - } + if svc, ok := registry.SchemaCatalog().Service(service); ok { for _, ref := range apicatalog.ServiceMethods(svc, nil) { byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID } - break } return func(h string) string { h = strings.TrimSpace(h) diff --git a/internal/registry/catalog.go b/internal/registry/catalog.go index 30a0af8c8..58c458ab9 100644 --- a/internal/registry/catalog.go +++ b/internal/registry/catalog.go @@ -6,8 +6,7 @@ package registry import "github.com/larksuite/cli/internal/apicatalog" // EmbeddedCatalog returns a navigation catalog over the embedded (overlay-free) -// metadata — deterministic across machines, for `lark-cli schema`, golden tests -// and schema lint. +// metadata — deterministic across machines, for golden tests and schema lint. func EmbeddedCatalog() apicatalog.Catalog { return apicatalog.New(apicatalog.SourceEmbedded, EmbeddedServicesTyped()) } @@ -18,3 +17,14 @@ func EmbeddedCatalog() apicatalog.Catalog { func RuntimeCatalog() apicatalog.Catalog { return apicatalog.New(apicatalog.SourceRuntime, ServicesTyped()) } + +// SchemaCatalog returns the embedded catalog when metadata is compiled in, +// otherwise the merged runtime catalog. Binaries built from the bare Go module +// embed only the empty meta_data_default.json stub, so the embedded view has +// nothing to resolve; the merged view is the only data such binaries have. +func SchemaCatalog() apicatalog.Catalog { + if len(EmbeddedServicesTyped()) > 0 { + return EmbeddedCatalog() + } + return RuntimeCatalog() +} diff --git a/internal/registry/catalog_test.go b/internal/registry/catalog_test.go new file mode 100644 index 000000000..d4f2d4a12 --- /dev/null +++ b/internal/registry/catalog_test.go @@ -0,0 +1,67 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package registry + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/larksuite/cli/internal/apicatalog" +) + +// swapEmbeddedMeta replaces the compiled-in metadata bytes for one test and +// restores them (with a full state reset) on cleanup. +func swapEmbeddedMeta(t *testing.T, data []byte) { + t.Helper() + resetInit() + orig := embeddedMetaJSON + embeddedMetaJSON = data + t.Cleanup(func() { + waitBackgroundRefresh() + embeddedMetaJSON = orig + resetInit() + }) +} + +func TestSchemaCatalog_EmbeddedWhenCompiledIn(t *testing.T) { + swapEmbeddedMeta(t, testCacheJSON("embedded_svc")) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_REMOTE_META", "off") + + c := SchemaCatalog() + + if c.Source() != apicatalog.SourceEmbedded { + t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceEmbedded) + } + if _, ok := c.Service("embedded_svc"); !ok { + t.Fatal("expected embedded_svc from embedded metadata") + } +} + +// TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded simulates a binary built +// from the bare Go module (plugin builds): only the empty meta_data_default.json +// stub is compiled in, so SchemaCatalog must serve the merged runtime view that +// Init seeds via sync fetch. +func TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded(t *testing.T) { + swapEmbeddedMeta(t, embeddedMetaDataDefaultJSON) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_REMOTE_META", "on") + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + w.Write(testEnvelopeJSON("remote_svc")) + })) + defer ts.Close() + testMetaURL = ts.URL + + c := SchemaCatalog() + + if c.Source() != apicatalog.SourceRuntime { + t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceRuntime) + } + if _, ok := c.Service("remote_svc"); !ok { + t.Fatal("expected remote_svc from runtime fallback") + } +} diff --git a/internal/registry/loader.go b/internal/registry/loader.go index 813526f46..4233a4c78 100644 --- a/internal/registry/loader.go +++ b/internal/registry/loader.go @@ -15,6 +15,7 @@ import ( "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/meta" + "github.com/larksuite/cli/internal/update" ) //go:embed scope_priorities.json scope_overrides.json @@ -85,7 +86,9 @@ func InitWithBrand(brand core.LarkBrand) { brandChanged := metaErr == nil && cm.Brand != "" && cm.Brand != string(brand) if !brandChanged { - if cached, err := loadCachedMerged(); err == nil { + // After a CLI upgrade the embedded data can be fresher than an old + // cache; an equal/older cache must not shadow it. + if cached, err := loadCachedMerged(); err == nil && update.IsNewer(cached.Version, embeddedVersion) { overlayMergedServices(cached) } } diff --git a/internal/registry/loader_test.go b/internal/registry/loader_test.go new file mode 100644 index 000000000..d37bb1a86 --- /dev/null +++ b/internal/registry/loader_test.go @@ -0,0 +1,102 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package registry + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/meta" +) + +// seedCache writes a cache file + cache meta for one service whose Title is +// marker, tagged with the given top-level data version and brand. +func seedCache(t *testing.T, dir, name, marker, version, brand string) { + t.Helper() + cDir := filepath.Join(dir, "cache") + if err := os.MkdirAll(cDir, 0700); err != nil { + t.Fatal(err) + } + reg := MergedRegistry{ + Version: version, + Services: []meta.Service{{Name: name, Version: "cache", Title: marker}}, + } + data, _ := json.Marshal(reg) + if err := os.WriteFile(filepath.Join(cDir, "remote_meta.json"), data, 0644); err != nil { + t.Fatal(err) + } + cm := CacheMeta{LastCheckAt: time.Now().Unix(), Version: version, Brand: brand} + mData, _ := json.Marshal(cm) + if err := os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), mData, 0644); err != nil { + t.Fatal(err) + } +} + +// initWithCache runs a fresh feishu-brand init with remote on, a high TTL and a +// recent LastCheckAt (so no refresh fires), embedded meta at embeddedVer and a +// pre-seeded cache at cacheVer — the overlay version gate is the only variable. +func initWithCache(t *testing.T, embeddedVer, cacheVer string) { + t.Helper() + embedded, _ := json.Marshal(MergedRegistry{ + Version: embeddedVer, + Services: []meta.Service{{Name: "svc", Version: "embedded", Title: "EMBEDDED"}}, + }) + swapEmbeddedMeta(t, embedded) + tmp := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp) + t.Setenv("LARKSUITE_CLI_REMOTE_META", "on") + t.Setenv("LARKSUITE_CLI_META_TTL", "3600") + seedCache(t, tmp, "svc", "CACHE", cacheVer, "feishu") + InitWithBrand(core.BrandFeishu) +} + +func titleOf(t *testing.T, name string) string { + t.Helper() + svc, ok := ServiceTyped(name) + if !ok { + t.Fatalf("service %q not loaded", name) + } + return svc.Title +} + +func TestOverlayGate_EqualVersion_UsesEmbedded(t *testing.T) { + initWithCache(t, "1.0.0", "1.0.0") + if got := titleOf(t, "svc"); got != "EMBEDDED" { + t.Errorf("equal version: got %q, want EMBEDDED (cache must not overlay)", got) + } +} + +func TestOverlayGate_OlderCache_UsesEmbedded(t *testing.T) { + initWithCache(t, "2.0.0", "1.0.0") + if got := titleOf(t, "svc"); got != "EMBEDDED" { + t.Errorf("older cache: got %q, want EMBEDDED", got) + } +} + +func TestOverlayGate_NewerCache_OverlaysCache(t *testing.T) { + initWithCache(t, "1.0.0", "2.0.0") + if got := titleOf(t, "svc"); got != "CACHE" { + t.Errorf("newer cache: got %q, want CACHE", got) + } +} + +func TestOverlayGate_UnparseableCacheVersion_UsesEmbedded(t *testing.T) { + initWithCache(t, "1.0.0", "not-a-semver") + if got := titleOf(t, "svc"); got != "EMBEDDED" { + t.Errorf("unparseable cache version: got %q, want EMBEDDED", got) + } +} + +func TestOverlayGate_StubEmbedded_OverlaysRealCache(t *testing.T) { + // The bare-module stub baseline is "0.0.0"; a real cache version must win so + // plugin builds without compiled meta_data.json still get remote data. + initWithCache(t, "0.0.0", "1.0.0") + if got := titleOf(t, "svc"); got != "CACHE" { + t.Errorf("stub-embedded baseline: got %q, want CACHE", got) + } +} diff --git a/internal/registry/remote_test.go b/internal/registry/remote_test.go index 98c2407df..15c7281c7 100644 --- a/internal/registry/remote_test.go +++ b/internal/registry/remote_test.go @@ -72,9 +72,11 @@ func hasEmbeddedServices() bool { } // testRegistry returns a minimal MergedRegistry with one service. +// The version is a real semver newer than the embedded stub baseline ("0.0.0") +// so cache overlay passes the version gate in InitWithBrand. func testRegistry(name string) MergedRegistry { return MergedRegistry{ - Version: "test-1.0", + Version: "1.0.0", Services: []meta.Service{ { Name: name, @@ -160,7 +162,7 @@ func TestRemoteOff_SkipsRemoteLogic(t *testing.T) { } func TestCacheHit_WithinTTL(t *testing.T) { - resetInit() + swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta tmp := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp) t.Setenv("LARKSUITE_CLI_REMOTE_META", "on") @@ -197,7 +199,7 @@ func TestCacheHit_WithinTTL(t *testing.T) { } func TestNetworkError_SilentDegradation(t *testing.T) { - resetInit() + swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta tmp := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp) t.Setenv("LARKSUITE_CLI_REMOTE_META", "on") @@ -371,8 +373,8 @@ func TestFetchRemoteMerged_200(t *testing.T) { if data == nil { t.Fatal("expected non-nil data") } - if reg.Version != "test-1.0" { - t.Errorf("expected version test-1.0, got %s", reg.Version) + if reg.Version != "1.0.0" { + t.Errorf("expected version 1.0.0, got %s", reg.Version) } }