mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
6 Commits
v1.0.55
...
feat/start
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b6aa7dc6a | ||
|
|
ed63e12725 | ||
|
|
761aa55cbf | ||
|
|
2098c3c412 | ||
|
|
4215ad9908 | ||
|
|
0b305a2248 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -36,6 +36,7 @@ tests/mail/reports/
|
||||
.hammer/
|
||||
.lark-slides/
|
||||
internal/registry/meta_data.json
|
||||
internal/registry/metastatic/meta_data_gen.go
|
||||
cmd/api/download.bin
|
||||
app.log
|
||||
/sidecar-server-demo
|
||||
|
||||
@@ -2,6 +2,8 @@ version: 2
|
||||
|
||||
before:
|
||||
hooks:
|
||||
# fetch_meta.py also regenerates the static Go registry (meta_data_gen.go),
|
||||
# the sole source of the embedded command tree.
|
||||
- python3 scripts/fetch_meta.py
|
||||
|
||||
builds:
|
||||
|
||||
3
Makefile
3
Makefile
@@ -12,6 +12,9 @@ PREFIX ?= /usr/local
|
||||
|
||||
all: test
|
||||
|
||||
# fetch_meta fetches meta_data.json AND regenerates the static Go registry
|
||||
# (internal/registry/metastatic/meta_data_gen.go) — the sole build-time source
|
||||
# of the embedded command tree. Both are gitignored; build/vet/test depend on it.
|
||||
fetch_meta:
|
||||
python3 scripts/fetch_meta.py
|
||||
|
||||
|
||||
@@ -72,10 +72,12 @@ to generate QR codes (supports ASCII and PNG formats).`,
|
||||
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend")
|
||||
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
|
||||
// Brand only — never decrypt the app secret just to build help text
|
||||
// (avoids a keychain read on every `auth login --help` / completion).
|
||||
var helpBrand core.LarkBrand
|
||||
if f != nil && f.Config != nil {
|
||||
if cfg, err := f.Config(); err == nil && cfg != nil {
|
||||
helpBrand = cfg.Brand
|
||||
if f != nil && f.ConfigBrand != nil {
|
||||
if b, ok := f.ConfigBrand(); ok {
|
||||
helpBrand = b
|
||||
}
|
||||
}
|
||||
available := sortedKnownDomains(helpBrand)
|
||||
|
||||
87
cmd/command_tree_dump_test.go
Normal file
87
cmd/command_tree_dump_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Tree-dump tool: dumps the full command tree (paths, flags, descriptions,
|
||||
// annotations) in a canonical, line-stable form so two builds can be diffed
|
||||
// byte-for-byte (e.g. before/after a registry change). Set LARK_TREE_DUMP=<path>
|
||||
// to write the dump; otherwise the test is a no-op. Not a committed golden — the
|
||||
// meta data is fetched/gitignored and drifts.
|
||||
package cmd_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/cmd"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
func esc(s string) string {
|
||||
s = strings.ReplaceAll(s, "\\", "\\\\")
|
||||
s = strings.ReplaceAll(s, "\n", "\\n")
|
||||
s = strings.ReplaceAll(s, "\t", "\\t")
|
||||
s = strings.ReplaceAll(s, "\r", "\\r")
|
||||
return s
|
||||
}
|
||||
|
||||
func dumpCommandTree(root *cobra.Command) string {
|
||||
var lines []string
|
||||
var walk func(c *cobra.Command)
|
||||
walk = func(c *cobra.Command) {
|
||||
path := strings.TrimSpace(strings.TrimPrefix(c.CommandPath(), "lark-cli"))
|
||||
head := fmt.Sprintf("CMD %q use=%q short=%q long=%q runnable=%t hidden=%t",
|
||||
path, esc(c.Use), esc(c.Short), esc(c.Long), c.Runnable(), c.Hidden)
|
||||
lines = append(lines, head)
|
||||
|
||||
if len(c.Annotations) > 0 {
|
||||
keys := make([]string, 0, len(c.Annotations))
|
||||
for k := range c.Annotations {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
lines = append(lines, fmt.Sprintf(" ann %s=%q", k, esc(c.Annotations[k])))
|
||||
}
|
||||
}
|
||||
|
||||
var flags []string
|
||||
c.Flags().VisitAll(func(f *pflag.Flag) {
|
||||
flags = append(flags, fmt.Sprintf(" flag --%s -%s type=%s def=%q usage=%q",
|
||||
f.Name, f.Shorthand, f.Value.Type(), esc(f.DefValue), esc(f.Usage)))
|
||||
})
|
||||
sort.Strings(flags)
|
||||
lines = append(lines, flags...)
|
||||
|
||||
subs := c.Commands()
|
||||
sort.Slice(subs, func(i, j int) bool { return subs[i].Name() < subs[j].Name() })
|
||||
for _, sub := range subs {
|
||||
walk(sub)
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
return strings.Join(lines, "\n") + "\n"
|
||||
}
|
||||
|
||||
func TestDumpCommandTree(t *testing.T) {
|
||||
out := os.Getenv("LARK_TREE_DUMP")
|
||||
if out == "" {
|
||||
t.Skip("set LARK_TREE_DUMP=<path> to dump the command tree")
|
||||
}
|
||||
// Deterministic: embedded meta only (no remote cache), empty config dir so
|
||||
// strict-mode/plugins/policy cannot reshape the tree.
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
root := cmd.Build(context.Background(), cmdutil.InvocationContext{})
|
||||
dump := dumpCommandTree(root)
|
||||
if err := os.WriteFile(out, []byte(dump), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("wrote %d bytes, %d lines to %s", len(dump), strings.Count(dump, "\n"), out)
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/registry/metaschema"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
@@ -30,74 +31,56 @@ func RegisterServiceCommands(parent *cobra.Command, f *cmdutil.Factory) {
|
||||
}
|
||||
|
||||
func RegisterServiceCommandsWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
|
||||
for _, project := range registry.ListFromMetaProjects() {
|
||||
spec := registry.LoadFromMeta(project)
|
||||
if spec == nil {
|
||||
for _, spec := range registry.TypedServices() {
|
||||
if spec.Name == "" || spec.ServicePath == "" || len(spec.Resources) == 0 {
|
||||
continue
|
||||
}
|
||||
specName := registry.GetStrFromMap(spec, "name")
|
||||
servicePath := registry.GetStrFromMap(spec, "servicePath")
|
||||
if specName == "" || servicePath == "" {
|
||||
continue
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
if resources == nil {
|
||||
continue
|
||||
}
|
||||
registerServiceWithContext(ctx, parent, spec, resources, f)
|
||||
registerServiceWithContext(ctx, parent, spec, f)
|
||||
}
|
||||
}
|
||||
|
||||
func registerService(parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
|
||||
registerServiceWithContext(context.Background(), parent, spec, resources, f)
|
||||
svc := registry.MapToService(spec)
|
||||
svc.Resources = registry.MapToResources(resources)
|
||||
registerServiceWithContext(context.Background(), parent, svc, f)
|
||||
}
|
||||
|
||||
func registerServiceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
|
||||
specName := registry.GetStrFromMap(spec, "name")
|
||||
specDesc := registry.GetServiceDescription(specName, "en")
|
||||
func registerServiceWithContext(ctx context.Context, parent *cobra.Command, spec metaschema.Service, f *cmdutil.Factory) {
|
||||
specDesc := registry.GetServiceDescription(spec.Name, "en")
|
||||
if specDesc == "" {
|
||||
specDesc = registry.GetStrFromMap(spec, "description")
|
||||
specDesc = spec.Description
|
||||
}
|
||||
|
||||
// Find existing service command or create one
|
||||
var svc *cobra.Command
|
||||
for _, c := range parent.Commands() {
|
||||
if c.Name() == specName {
|
||||
if c.Name() == spec.Name {
|
||||
svc = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if svc == nil {
|
||||
svc = &cobra.Command{
|
||||
Use: specName,
|
||||
Use: spec.Name,
|
||||
Short: specDesc,
|
||||
}
|
||||
parent.AddCommand(svc)
|
||||
}
|
||||
|
||||
for resName, resource := range resources {
|
||||
resMap, _ := resource.(map[string]interface{})
|
||||
if resMap == nil {
|
||||
continue
|
||||
}
|
||||
registerResourceWithContext(ctx, svc, spec, resName, resMap, f)
|
||||
for _, resource := range spec.Resources {
|
||||
registerResourceWithContext(ctx, svc, spec, resource, f)
|
||||
}
|
||||
}
|
||||
|
||||
func registerResourceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, name string, resource map[string]interface{}, f *cmdutil.Factory) {
|
||||
func registerResourceWithContext(ctx context.Context, parent *cobra.Command, spec metaschema.Service, resource metaschema.Resource, f *cmdutil.Factory) {
|
||||
res := &cobra.Command{
|
||||
Use: name,
|
||||
Short: name + " operations",
|
||||
Use: resource.Name,
|
||||
Short: resource.Name + " operations",
|
||||
}
|
||||
parent.AddCommand(res)
|
||||
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
for methodName, method := range methods {
|
||||
methodMap, _ := method.(map[string]interface{})
|
||||
if methodMap == nil {
|
||||
continue
|
||||
}
|
||||
registerMethodWithContext(ctx, res, spec, methodMap, methodName, name, f)
|
||||
for _, method := range resource.Methods {
|
||||
registerMethodWithContext(ctx, res, spec, method, method.Name, resource.Name, f)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,31 +108,36 @@ type ServiceMethodOptions struct {
|
||||
FileFields []string // auto-detected file field names from metadata
|
||||
}
|
||||
|
||||
// detectFileFields delegates to the shared cmdutil.DetectFileFields helper.
|
||||
func detectFileFields(method map[string]interface{}) []string {
|
||||
return cmdutil.DetectFileFields(method)
|
||||
// detectFileFieldsTyped returns the names of file-type fields in the method's
|
||||
// request body (used to decide whether to register --file).
|
||||
func detectFileFieldsTyped(m metaschema.Method) []string {
|
||||
var fields []string
|
||||
for _, fld := range m.RequestBody {
|
||||
if fld.Type == "file" {
|
||||
fields = append(fields, fld.Name)
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func registerMethodWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, method map[string]interface{}, name string, resName string, f *cmdutil.Factory) {
|
||||
func registerMethodWithContext(ctx context.Context, parent *cobra.Command, spec metaschema.Service, method metaschema.Method, name string, resName string, f *cmdutil.Factory) {
|
||||
parent.AddCommand(NewCmdServiceMethodWithContext(ctx, f, spec, method, name, resName, nil))
|
||||
}
|
||||
|
||||
// NewCmdServiceMethod creates a command for a dynamically registered service method.
|
||||
// NewCmdServiceMethod creates a command for a dynamically registered service
|
||||
// method from map specs (kept for tests; converts to typed internally).
|
||||
func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
|
||||
return NewCmdServiceMethodWithContext(context.Background(), f, spec, method, name, resName, runF)
|
||||
return NewCmdServiceMethodWithContext(context.Background(), f, registry.MapToService(spec), registry.MapToMethod(name, method), name, resName, runF)
|
||||
}
|
||||
|
||||
func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
|
||||
desc := registry.GetStrFromMap(method, "description")
|
||||
httpMethod := registry.GetStrFromMap(method, "httpMethod")
|
||||
risk := registry.GetStrFromMap(method, "risk")
|
||||
specName := registry.GetStrFromMap(spec, "name")
|
||||
schemaPath := fmt.Sprintf("%s.%s.%s", specName, resName, name)
|
||||
func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spec metaschema.Service, method metaschema.Method, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
|
||||
desc := method.Description
|
||||
httpMethod := method.HTTPMethod
|
||||
risk := method.Risk
|
||||
schemaPath := fmt.Sprintf("%s.%s.%s", spec.Name, resName, name)
|
||||
|
||||
opts := &ServiceMethodOptions{
|
||||
Factory: f,
|
||||
Spec: spec,
|
||||
Method: method,
|
||||
SchemaPath: schemaPath,
|
||||
}
|
||||
var asStr string
|
||||
@@ -159,6 +147,10 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
|
||||
Short: desc,
|
||||
Long: fmt.Sprintf("%s\n\nView parameter definitions before calling:\n lark-cli schema %s", desc, schemaPath),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Materialize the maps the execution path still reads lazily — only
|
||||
// when THIS command actually runs, never at startup.
|
||||
opts.Spec = registry.ServiceToMap(spec)
|
||||
opts.Method = registry.MethodToMap(method)
|
||||
opts.Cmd = cmd
|
||||
opts.Ctx = cmd.Context()
|
||||
opts.As = core.Identity(asStr)
|
||||
@@ -188,7 +180,7 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
|
||||
}
|
||||
|
||||
// Conditionally register --file for methods with file-type fields.
|
||||
fileFields := detectFileFields(method)
|
||||
fileFields := detectFileFieldsTyped(method)
|
||||
opts.FileFields = fileFields
|
||||
if len(fileFields) > 0 {
|
||||
switch httpMethod {
|
||||
@@ -200,10 +192,15 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
|
||||
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
cmdutil.SetTips(cmd, registry.GetStrSliceFromMap(method, "tips"))
|
||||
// meta_data.json carries no per-method tips; SetTips(nil) matches prior behavior.
|
||||
cmdutil.SetTips(cmd, nil)
|
||||
cmdutil.SetRisk(cmd, risk)
|
||||
if tokens, ok := method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
|
||||
cmdutil.SetSupportedIdentities(cmd, cmdutil.AccessTokensToIdentities(tokens))
|
||||
if len(method.AccessTokens) > 0 {
|
||||
toks := make([]interface{}, len(method.AccessTokens))
|
||||
for i, t := range method.AccessTokens {
|
||||
toks[i] = t
|
||||
}
|
||||
cmdutil.SetSupportedIdentities(cmd, cmdutil.AccessTokensToIdentities(toks))
|
||||
}
|
||||
|
||||
return cmd
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -752,7 +753,7 @@ func TestDetectFileFields(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := detectFileFields(tt.method)
|
||||
got := detectFileFieldsTyped(registry.MapToMethod("", tt.method))
|
||||
if len(got) != len(tt.want) {
|
||||
t.Errorf("detectFileFields() = %v, want %v", got, tt.want)
|
||||
return
|
||||
|
||||
@@ -30,10 +30,11 @@ type InvocationContext struct {
|
||||
}
|
||||
|
||||
type Factory struct {
|
||||
Config func() (*core.CliConfig, error) // lazily loads app config from Credential
|
||||
HttpClient func() (*http.Client, error) // HTTP client for non-Lark API calls (with retry and security headers)
|
||||
LarkClient func() (*lark.Client, error) // Lark SDK client for all Open API calls
|
||||
IOStreams *IOStreams // stdin/stdout/stderr streams
|
||||
Config func() (*core.CliConfig, error) // lazily loads app config from Credential
|
||||
ConfigBrand func() (core.LarkBrand, bool) // brand only, no secret decryption — for startup help/registration (avoids keychain)
|
||||
HttpClient func() (*http.Client, error) // HTTP client for non-Lark API calls (with retry and security headers)
|
||||
LarkClient func() (*lark.Client, error) // Lark SDK client for all Open API calls
|
||||
IOStreams *IOStreams // stdin/stdout/stderr streams
|
||||
|
||||
Invocation InvocationContext // Immutable call context; do not mutate after Factory construction.
|
||||
Keychain keychain.KeychainAccess // secret storage (real keychain in prod, mock in tests)
|
||||
@@ -151,11 +152,14 @@ func (f *Factory) ResolveStrictMode(ctx context.Context) core.StrictMode {
|
||||
if f.Credential == nil {
|
||||
return core.StrictModeOff
|
||||
}
|
||||
acct, err := f.Credential.ResolveAccount(ctx)
|
||||
if err != nil || acct == nil {
|
||||
// Strict mode is plain config metadata; resolve it WITHOUT decrypting the
|
||||
// app secret so identity-flag registration at startup never touches the
|
||||
// keychain (ResolveStrictMode is called per command during Build).
|
||||
_, supported, ok := f.Credential.ResolveMeta(ctx)
|
||||
if !ok {
|
||||
return core.StrictModeOff
|
||||
}
|
||||
ids := extcred.IdentitySupport(acct.SupportedIdentities)
|
||||
ids := extcred.IdentitySupport(supported)
|
||||
switch {
|
||||
case ids.BotOnly():
|
||||
return core.StrictModeBot
|
||||
|
||||
@@ -78,6 +78,18 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
||||
return cfg, nil
|
||||
})
|
||||
|
||||
// ConfigBrand resolves just the brand without decrypting the app secret, so
|
||||
// brand-aware help and shortcut registration at startup do not touch the
|
||||
// keychain. It still initializes the registry with the resolved brand — the
|
||||
// same side effect Config has, minus the secret.
|
||||
f.ConfigBrand = sync.OnceValues(func() (core.LarkBrand, bool) {
|
||||
brand, _, ok := f.Credential.ResolveMeta(context.Background())
|
||||
if ok {
|
||||
registry.InitWithBrand(brand)
|
||||
}
|
||||
return brand, ok
|
||||
})
|
||||
|
||||
// Phase 4: LarkClient from Credential (placeholder AppSecret)
|
||||
f.LarkClient = cachedLarkClientFunc(f)
|
||||
|
||||
|
||||
@@ -65,7 +65,13 @@ func TestFactory(t *testing.T, config *core.CliConfig) (*Factory, *bytes.Buffer,
|
||||
)
|
||||
|
||||
f := &Factory{
|
||||
Config: func() (*core.CliConfig, error) { return config, nil },
|
||||
Config: func() (*core.CliConfig, error) { return config, nil },
|
||||
ConfigBrand: func() (core.LarkBrand, bool) {
|
||||
if config != nil {
|
||||
return config.Brand, true
|
||||
}
|
||||
return "", false
|
||||
},
|
||||
HttpClient: func() (*http.Client, error) { return mockClient, nil },
|
||||
LarkClient: func() (*lark.Client, error) { return testLarkClient, nil },
|
||||
IOStreams: &IOStreams{In: nil, Out: stdoutBuf, ErrOut: stderrBuf},
|
||||
|
||||
@@ -21,6 +21,14 @@ type DefaultAccountResolver interface {
|
||||
ResolveAccount(ctx context.Context) (*Account, error)
|
||||
}
|
||||
|
||||
// metaResolver is an optional capability: resolve config metadata (brand +
|
||||
// strict-mode identity support) without resolving the app secret (no keychain
|
||||
// access). Providers that don't implement it fall back to ResolveAccount inside
|
||||
// CredentialProvider.ResolveMeta.
|
||||
type metaResolver interface {
|
||||
ResolveMeta(ctx context.Context) (core.LarkBrand, uint8, bool)
|
||||
}
|
||||
|
||||
// DefaultTokenResolver is implemented by the default token provider.
|
||||
type DefaultTokenResolver interface {
|
||||
ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error)
|
||||
@@ -141,6 +149,11 @@ type CredentialProvider struct {
|
||||
accountErr error
|
||||
selectedSource credentialSource
|
||||
|
||||
metaOnce sync.Once
|
||||
metaBrand core.LarkBrand
|
||||
metaIdents uint8
|
||||
metaOK bool
|
||||
|
||||
hintOnce sync.Once
|
||||
hint *IdentityHint
|
||||
hintErr error
|
||||
@@ -172,6 +185,44 @@ func (p *CredentialProvider) ResolveAccount(ctx context.Context) (*Account, erro
|
||||
return p.account, p.accountErr
|
||||
}
|
||||
|
||||
// ResolveMeta resolves config metadata — brand and strict-mode identity support
|
||||
// — cheaply, WITHOUT decrypting the app secret for the default
|
||||
// (config.json/keychain) provider. It mirrors doResolveAccount's provider
|
||||
// selection: external providers (env/sidecar) are asked first via ResolveAccount
|
||||
// (they do not touch the keychain), then the default provider's keychain-free
|
||||
// metaResolver path. Cached after first call. Best-effort: returns ok=false when
|
||||
// nothing is configured, so callers keep their defaults. Used for brand-aware
|
||||
// help text, shortcut registration, and strict-mode checks at startup, where
|
||||
// decrypting the secret would be wasteful.
|
||||
func (p *CredentialProvider) ResolveMeta(ctx context.Context) (core.LarkBrand, uint8, bool) {
|
||||
p.metaOnce.Do(func() {
|
||||
p.metaBrand, p.metaIdents, p.metaOK = p.doResolveMeta(ctx)
|
||||
})
|
||||
return p.metaBrand, p.metaIdents, p.metaOK
|
||||
}
|
||||
|
||||
func (p *CredentialProvider) doResolveMeta(ctx context.Context) (core.LarkBrand, uint8, bool) {
|
||||
for _, prov := range p.providers {
|
||||
acct, err := prov.ResolveAccount(ctx)
|
||||
if err != nil {
|
||||
return "", 0, false
|
||||
}
|
||||
if acct != nil {
|
||||
internal := convertAccount(acct)
|
||||
return internal.Brand, internal.SupportedIdentities, true
|
||||
}
|
||||
}
|
||||
if p.defaultAcct != nil {
|
||||
if mr, ok := p.defaultAcct.(metaResolver); ok {
|
||||
return mr.ResolveMeta(ctx)
|
||||
}
|
||||
if acct, err := p.defaultAcct.ResolveAccount(ctx); err == nil && acct != nil {
|
||||
return acct.Brand, acct.SupportedIdentities, true
|
||||
}
|
||||
}
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
func (p *CredentialProvider) doResolveAccount(ctx context.Context) (*Account, error) {
|
||||
for _, prov := range p.providers {
|
||||
acct, err := prov.ResolveAccount(ctx)
|
||||
|
||||
@@ -76,6 +76,23 @@ func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account,
|
||||
return AccountFromCliConfig(cfg), nil
|
||||
}
|
||||
|
||||
// ResolveMeta returns config metadata — brand and the strict-mode identity
|
||||
// support — from config.json WITHOUT resolving the app secret (no keychain
|
||||
// access). Both are plain config fields, so brand-aware help, shortcut
|
||||
// registration, and strict-mode checks at startup need not decrypt the secret.
|
||||
// Returns ok=false when no config exists, so callers keep their defaults.
|
||||
func (p *DefaultAccountProvider) ResolveMeta(_ context.Context) (core.LarkBrand, uint8, bool) {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
return "", 0, false
|
||||
}
|
||||
app := multi.CurrentAppConfig(p.profile)
|
||||
if app == nil {
|
||||
return "", 0, false
|
||||
}
|
||||
return app.Brand, strictModeToIdentitySupport(multi, p.profile), true
|
||||
}
|
||||
|
||||
// strictModeToIdentitySupport maps the config-level strict mode to
|
||||
// the SupportedIdentities bitflag using an already-loaded MultiAppConfig.
|
||||
func strictModeToIdentitySupport(multi *core.MultiAppConfig, profileOverride string) uint8 {
|
||||
|
||||
@@ -19,72 +19,32 @@ import (
|
||||
//go:embed scope_priorities.json scope_overrides.json
|
||||
var registryFS embed.FS
|
||||
|
||||
// embeddedMetaJSON is set by loader_embedded.go when meta_data.json is compiled in.
|
||||
var embeddedMetaJSON []byte
|
||||
|
||||
// EmbeddedMetaJSON returns the raw embedded meta_data.json bytes for callers
|
||||
// that need to parse key order or other JSON-level structure not exposed by
|
||||
// LoadFromMeta (which loses map insertion order).
|
||||
func EmbeddedMetaJSON() []byte {
|
||||
return embeddedMetaJSON
|
||||
}
|
||||
|
||||
var (
|
||||
embeddedServicesMap map[string]map[string]interface{} // service name -> spec
|
||||
embeddedServiceNames []string // sorted
|
||||
embeddedParseOnce sync.Once
|
||||
)
|
||||
|
||||
// parseEmbeddedServices parses embeddedMetaJSON into a service name → spec map
|
||||
// without touching mergedServices. Safe to call multiple times (sync.Once).
|
||||
func parseEmbeddedServices() {
|
||||
embeddedParseOnce.Do(func() {
|
||||
embeddedServicesMap = make(map[string]map[string]interface{})
|
||||
if len(embeddedMetaJSON) == 0 {
|
||||
return
|
||||
}
|
||||
var wrapper struct {
|
||||
Services []map[string]interface{} `json:"services"`
|
||||
}
|
||||
if err := json.Unmarshal(embeddedMetaJSON, &wrapper); err != nil {
|
||||
return
|
||||
}
|
||||
for _, svc := range wrapper.Services {
|
||||
name, _ := svc["name"].(string)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
embeddedServicesMap[name] = svc
|
||||
}
|
||||
embeddedServiceNames = make([]string, 0, len(embeddedServicesMap))
|
||||
for name := range embeddedServicesMap {
|
||||
embeddedServiceNames = append(embeddedServiceNames, name)
|
||||
}
|
||||
sort.Strings(embeddedServiceNames)
|
||||
})
|
||||
}
|
||||
|
||||
// EmbeddedSpec returns the embedded spec for one service, or nil if unknown.
|
||||
// Bypasses remote overlay — used for deterministic envelope output.
|
||||
// EmbeddedSpec returns the embedded baseline spec for one service as a map, or
|
||||
// nil if the service is unknown. It reads the static compile-time registry
|
||||
// (metastatic.Registry) and bypasses the remote overlay, so envelope output is
|
||||
// deterministic across machines.
|
||||
func EmbeddedSpec(serviceName string) map[string]interface{} {
|
||||
parseEmbeddedServices()
|
||||
return embeddedServicesMap[serviceName]
|
||||
if svc, ok := baselineServiceByName(serviceName); ok {
|
||||
return ServiceToMap(svc)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EmbeddedServiceNames returns sorted embedded service names (no overlay).
|
||||
// Returns a defensive copy — callers must not mutate the package-level slice.
|
||||
// EmbeddedServiceNames returns the embedded baseline service names, sorted
|
||||
// (no remote overlay).
|
||||
func EmbeddedServiceNames() []string {
|
||||
parseEmbeddedServices()
|
||||
out := make([]string, len(embeddedServiceNames))
|
||||
copy(out, embeddedServiceNames)
|
||||
svcs := baselineServices()
|
||||
out := make([]string, 0, len(svcs))
|
||||
for _, s := range svcs {
|
||||
out = append(out, s.Name)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
var (
|
||||
mergedServices = make(map[string]map[string]interface{}) // project name → parsed spec
|
||||
mergedProjectList []string // sorted project names
|
||||
embeddedVersion string // version from embedded meta_data.json
|
||||
initOnce sync.Once
|
||||
embeddedVersion string // baseline data version (from the static registry)
|
||||
initOnce sync.Once
|
||||
)
|
||||
|
||||
// Init initializes the registry with default brand (feishu).
|
||||
@@ -101,55 +61,27 @@ func Init() {
|
||||
func InitWithBrand(brand core.LarkBrand) {
|
||||
initOnce.Do(func() {
|
||||
configuredBrand = brand
|
||||
// 1. Load embedded meta_data.json as baseline (no-op if not compiled in)
|
||||
loadEmbeddedIntoMerged()
|
||||
// 2. Remote overlay
|
||||
// 1. Baseline version: the static compile-time registry (metastatic).
|
||||
embeddedVersion = baselineVersion()
|
||||
// 2. Remote overlay — still fetched/refreshed at runtime, decoded into
|
||||
// the same typed shape and merged over the baseline.
|
||||
if remoteEnabled() && cacheWritable() {
|
||||
// Check if brand changed since last cache
|
||||
meta, metaErr := loadCacheMeta()
|
||||
brandChanged := metaErr == nil && meta.Brand != "" && meta.Brand != string(brand)
|
||||
|
||||
if !brandChanged {
|
||||
if cached, err := loadCachedMerged(); err == nil {
|
||||
overlayMergedServices(cached)
|
||||
}
|
||||
_ = loadCachedTyped()
|
||||
}
|
||||
if len(mergedServices) == 0 || brandChanged {
|
||||
// No data at all or brand changed — must sync fetch
|
||||
if !hasTypedData() || brandChanged {
|
||||
// No data at all (e.g. stub build, no cache) or brand changed.
|
||||
doSyncFetch()
|
||||
} else if shouldRefresh(meta) || metaErr != nil {
|
||||
// Have embedded/cached data; refresh in background if TTL expired or first run
|
||||
triggerBackgroundRefresh()
|
||||
}
|
||||
}
|
||||
// 3. Build sorted project list
|
||||
rebuildProjectList()
|
||||
})
|
||||
}
|
||||
|
||||
// loadEmbeddedIntoMerged parses the embedded meta_data.json and populates
|
||||
// mergedServices. No-op if meta_data.json is not compiled in.
|
||||
func loadEmbeddedIntoMerged() {
|
||||
if len(embeddedMetaJSON) == 0 {
|
||||
return
|
||||
}
|
||||
var reg MergedRegistry
|
||||
if err := json.Unmarshal(embeddedMetaJSON, ®); err != nil {
|
||||
return
|
||||
}
|
||||
embeddedVersion = reg.Version
|
||||
overlayMergedServices(®)
|
||||
}
|
||||
|
||||
// rebuildProjectList rebuilds the sorted list of project names from mergedServices.
|
||||
func rebuildProjectList() {
|
||||
mergedProjectList = make([]string, 0, len(mergedServices))
|
||||
for name := range mergedServices {
|
||||
mergedProjectList = append(mergedProjectList, name)
|
||||
}
|
||||
sort.Strings(mergedProjectList)
|
||||
}
|
||||
|
||||
var cachedAllScopes map[string][]string
|
||||
|
||||
// CollectAllScopesFromMeta collects all unique scopes from from_meta/*.json
|
||||
@@ -226,7 +158,11 @@ func CollectAllScopesFromMeta(identity string) []string {
|
||||
// It returns data from the merged registry (embedded + cached remote overlay).
|
||||
func LoadFromMeta(project string) map[string]interface{} {
|
||||
Init()
|
||||
return mergedServices[project]
|
||||
svc, ok := typedServiceByName(project)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return ServiceToMap(svc)
|
||||
}
|
||||
|
||||
// ListFromMetaProjects lists available service project names (sorted).
|
||||
@@ -234,7 +170,7 @@ func LoadFromMeta(project string) map[string]interface{} {
|
||||
//go:noinline
|
||||
func ListFromMetaProjects() []string {
|
||||
Init()
|
||||
return mergedProjectList
|
||||
return typedServiceNames()
|
||||
}
|
||||
|
||||
// DefaultScopeScore is the score assigned to scopes not in the priorities table.
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package registry
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed meta_data*.json
|
||||
var metaFS embed.FS
|
||||
|
||||
//go:embed meta_data_default.json
|
||||
var embeddedMetaDataDefaultJSON []byte
|
||||
|
||||
func init() {
|
||||
if data, err := metaFS.ReadFile("meta_data.json"); err == nil && len(data) > 0 {
|
||||
embeddedMetaJSON = data
|
||||
} else {
|
||||
embeddedMetaJSON = embeddedMetaDataDefaultJSON
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"version":"0.0.0","services":[]}
|
||||
99
internal/registry/metaschema/types.go
Normal file
99
internal/registry/metaschema/types.go
Normal file
@@ -0,0 +1,99 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package metaschema defines the typed shape of the command-spec registry
|
||||
// (meta_data.json). The embedded baseline is emitted as static Go data in
|
||||
// package metastatic (no runtime JSON parse, no startup allocation); the remote
|
||||
// overlay is decoded into these same types at runtime.
|
||||
//
|
||||
// All container fields are slices (never maps): a package-level slice literal is
|
||||
// laid out in the binary's data section and costs zero heap allocation at
|
||||
// startup, whereas a map literal builds an hmap at init time. Map keys from the
|
||||
// JSON (resource/method/field names) are preserved in the Name field.
|
||||
package metaschema
|
||||
|
||||
// Registry is the top level of meta_data.json: {version, services:[...]}.
|
||||
type Registry struct {
|
||||
Version string
|
||||
Services []Service
|
||||
}
|
||||
|
||||
// Service is one API domain (e.g. "im", "calendar").
|
||||
type Service struct {
|
||||
Name string
|
||||
Version string
|
||||
Title string
|
||||
Description string
|
||||
ServicePath string
|
||||
Resources []Resource // JSON "resources" map, keyed by Resource.Name
|
||||
}
|
||||
|
||||
// Resource groups methods under a service (e.g. "messages").
|
||||
type Resource struct {
|
||||
Name string
|
||||
Methods []Method // JSON "methods" map, keyed by Method.Name
|
||||
}
|
||||
|
||||
// Method is a single API call.
|
||||
type Method struct {
|
||||
Name string // JSON map key
|
||||
ID string
|
||||
Path string
|
||||
HTTPMethod string
|
||||
Description string
|
||||
Risk string
|
||||
DocURL string
|
||||
Danger bool
|
||||
Scopes []string
|
||||
AccessTokens []string
|
||||
ParameterOrder []string
|
||||
RequiredScopes []string
|
||||
Parameters []Field // JSON "parameters" map, keyed by Field.Name
|
||||
RequestBody []Field // JSON "requestBody" map
|
||||
ResponseBody []Field // JSON "responseBody" map
|
||||
Affordance *Affordance // optional AI-facing usage overlay; nil on most methods
|
||||
}
|
||||
|
||||
// Field is one parameter / request-body / response-body entry. Nested object
|
||||
// fields recurse via Properties.
|
||||
type Field struct {
|
||||
Name string // JSON map key
|
||||
Type string
|
||||
Location string
|
||||
Description string
|
||||
Default string
|
||||
Example string
|
||||
EnumName string
|
||||
Min string
|
||||
Max string
|
||||
Ref string
|
||||
Required bool
|
||||
Options []Option
|
||||
Enum []string
|
||||
Annotations []string
|
||||
Properties []Field
|
||||
}
|
||||
|
||||
// Option is one allowed value for a field with an enum-like option list.
|
||||
type Option struct {
|
||||
Value string
|
||||
Description string
|
||||
}
|
||||
|
||||
// Affordance is the optional AI-facing usage overlay for a method, surfaced in
|
||||
// the schema envelope as _meta.affordance. Absent (nil) on most methods; it is
|
||||
// authored upstream in registry-config.yaml and merged into meta_data.json.
|
||||
type Affordance struct {
|
||||
UseWhen []string
|
||||
DoNotUseWhen []string
|
||||
Prerequisites []string
|
||||
Examples []AffordanceExample
|
||||
Related []string
|
||||
}
|
||||
|
||||
// AffordanceExample is one ready-to-run example: a one-line description plus a
|
||||
// complete lark-cli command string.
|
||||
type AffordanceExample struct {
|
||||
Description string
|
||||
Command string
|
||||
}
|
||||
255
internal/registry/metastatic/gen.go
Normal file
255
internal/registry/metastatic/gen.go
Normal file
@@ -0,0 +1,255 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build ignore
|
||||
|
||||
// Command gen reads internal/registry/meta_data.json and emits
|
||||
// meta_data_gen.go: the embedded command spec as a single static
|
||||
// metaschema.Registry literal (zero runtime JSON parse, zero startup heap
|
||||
// allocation). Run via: go run internal/registry/metastatic/gen.go
|
||||
//
|
||||
// Maps in the JSON (resources/methods/fields) are emitted as slices sorted by
|
||||
// key so generation is deterministic.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"go/format"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
inPath = "internal/registry/meta_data.json"
|
||||
outPath = "internal/registry/metastatic/meta_data_gen.go"
|
||||
)
|
||||
|
||||
func gs(m map[string]any, k string) string {
|
||||
if v, ok := m[k].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func gb(m map[string]any, k string) bool {
|
||||
if v, ok := m[k].(bool); ok {
|
||||
return v
|
||||
}
|
||||
return false
|
||||
}
|
||||
func gss(m map[string]any, k string) []string {
|
||||
raw, _ := m[k].([]any)
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, e := range raw {
|
||||
if s, ok := e.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func gm(m map[string]any, k string) map[string]any {
|
||||
if v, ok := m[k].(map[string]any); ok {
|
||||
return v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func sortedKeys(m map[string]any) []string {
|
||||
ks := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
ks = append(ks, k)
|
||||
}
|
||||
sort.Strings(ks)
|
||||
return ks
|
||||
}
|
||||
|
||||
func emitStrSlice(b *strings.Builder, name string, vs []string) {
|
||||
if len(vs) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(b, "%s: []string{", name)
|
||||
for _, v := range vs {
|
||||
fmt.Fprintf(b, "%q, ", v)
|
||||
}
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
|
||||
func emitOptions(b *strings.Builder, raw []any) {
|
||||
if len(raw) == 0 {
|
||||
return
|
||||
}
|
||||
b.WriteString("Options: []metaschema.Option{")
|
||||
for _, e := range raw {
|
||||
o, _ := e.(map[string]any)
|
||||
fmt.Fprintf(b, "{Value: %q, Description: %q}, ", gs(o, "value"), gs(o, "description"))
|
||||
}
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
|
||||
// emitFields emits a metaschema.Field slice from a JSON map[fieldName]fieldSpec.
|
||||
func emitFields(b *strings.Builder, label string, fm map[string]any) {
|
||||
if len(fm) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(b, "%s: []metaschema.Field{\n", label)
|
||||
for _, name := range sortedKeys(fm) {
|
||||
f, _ := fm[name].(map[string]any)
|
||||
if f == nil {
|
||||
continue
|
||||
}
|
||||
b.WriteString("{")
|
||||
fmt.Fprintf(b, "Name: %q, ", name)
|
||||
for _, kv := range []struct{ k, field string }{
|
||||
{"type", "Type"}, {"location", "Location"}, {"description", "Description"},
|
||||
{"default", "Default"}, {"example", "Example"}, {"enumName", "EnumName"},
|
||||
{"min", "Min"}, {"max", "Max"}, {"ref", "Ref"},
|
||||
} {
|
||||
if v := gs(f, kv.k); v != "" {
|
||||
fmt.Fprintf(b, "%s: %q, ", kv.field, v)
|
||||
}
|
||||
}
|
||||
if gb(f, "required") {
|
||||
b.WriteString("Required: true, ")
|
||||
}
|
||||
emitStrSlice(b, "Enum", gss(f, "enum"))
|
||||
emitStrSlice(b, "Annotations", gss(f, "annotations"))
|
||||
if opts, ok := f["options"].([]any); ok {
|
||||
emitOptions(b, opts)
|
||||
}
|
||||
if props := gm(f, "properties"); props != nil {
|
||||
emitFields(b, "Properties", props)
|
||||
}
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
|
||||
// emitAffordance emits a metaschema.Affordance literal from a method's
|
||||
// "affordance" JSON object, or nothing when absent/empty.
|
||||
func emitAffordance(b *strings.Builder, raw map[string]any) {
|
||||
if raw == nil {
|
||||
return
|
||||
}
|
||||
useWhen := gss(raw, "use_when")
|
||||
doNot := gss(raw, "do_not_use_when")
|
||||
prereq := gss(raw, "prerequisites")
|
||||
related := gss(raw, "related")
|
||||
examples, _ := raw["examples"].([]any)
|
||||
if len(useWhen) == 0 && len(doNot) == 0 && len(prereq) == 0 && len(related) == 0 && len(examples) == 0 {
|
||||
return
|
||||
}
|
||||
b.WriteString("Affordance: &metaschema.Affordance{")
|
||||
emitStrSlice(b, "UseWhen", useWhen)
|
||||
emitStrSlice(b, "DoNotUseWhen", doNot)
|
||||
emitStrSlice(b, "Prerequisites", prereq)
|
||||
if len(examples) > 0 {
|
||||
b.WriteString("Examples: []metaschema.AffordanceExample{")
|
||||
for _, e := range examples {
|
||||
ex, _ := e.(map[string]any)
|
||||
fmt.Fprintf(b, "{Description: %q, Command: %q}, ", gs(ex, "description"), gs(ex, "command"))
|
||||
}
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
emitStrSlice(b, "Related", related)
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
|
||||
func emitMethods(b *strings.Builder, mm map[string]any) {
|
||||
b.WriteString("Methods: []metaschema.Method{\n")
|
||||
for _, name := range sortedKeys(mm) {
|
||||
m, _ := mm[name].(map[string]any)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
b.WriteString("{")
|
||||
fmt.Fprintf(b, "Name: %q, ID: %q, Path: %q, HTTPMethod: %q, Description: %q, ",
|
||||
name, gs(m, "id"), gs(m, "path"), gs(m, "httpMethod"), gs(m, "description"))
|
||||
if v := gs(m, "risk"); v != "" {
|
||||
fmt.Fprintf(b, "Risk: %q, ", v)
|
||||
}
|
||||
if v := gs(m, "docUrl"); v != "" {
|
||||
fmt.Fprintf(b, "DocURL: %q, ", v)
|
||||
}
|
||||
if gb(m, "danger") {
|
||||
b.WriteString("Danger: true, ")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
emitStrSlice(b, "Scopes", gss(m, "scopes"))
|
||||
emitStrSlice(b, "AccessTokens", gss(m, "accessTokens"))
|
||||
emitStrSlice(b, "ParameterOrder", gss(m, "parameterOrder"))
|
||||
emitStrSlice(b, "RequiredScopes", gss(m, "requiredScopes"))
|
||||
emitFields(b, "Parameters", gm(m, "parameters"))
|
||||
emitFields(b, "RequestBody", gm(m, "requestBody"))
|
||||
emitFields(b, "ResponseBody", gm(m, "responseBody"))
|
||||
emitAffordance(b, gm(m, "affordance"))
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
|
||||
func main() {
|
||||
data, err := os.ReadFile(inPath)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "read:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
var reg map[string]any
|
||||
if err := json.Unmarshal(data, ®); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "unmarshal:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("// Code generated from meta_data.json by gen.go. DO NOT EDIT.\n")
|
||||
b.WriteString("// Gitignored; produced at build time by `make fetch_meta`.\n\n")
|
||||
b.WriteString("package metastatic\n\n")
|
||||
b.WriteString("import \"github.com/larksuite/cli/internal/registry/metaschema\"\n\n")
|
||||
b.WriteString("// registryData holds the command spec as static Go data. It is a\n")
|
||||
b.WriteString("// package-level var, so its backing arrays live in the binary's static\n")
|
||||
b.WriteString("// section (zero heap alloc on read). init() wires it into the Registry\n")
|
||||
b.WriteString("// declared by stub.go with a single struct-header copy. No build tag is\n")
|
||||
b.WriteString("// needed: when this generated file is absent (fresh checkout) stub.go's\n")
|
||||
b.WriteString("// empty Registry stands alone; when present, init() augments it.\n")
|
||||
b.WriteString("var registryData = metaschema.Registry{\n")
|
||||
fmt.Fprintf(&b, "Version: %q,\n", gs(reg, "version"))
|
||||
b.WriteString("Services: []metaschema.Service{\n")
|
||||
svcs, _ := reg["services"].([]any)
|
||||
for _, sv := range svcs {
|
||||
s, _ := sv.(map[string]any)
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
b.WriteString("{")
|
||||
fmt.Fprintf(&b, "Name: %q, Version: %q, Title: %q, Description: %q, ServicePath: %q,\n",
|
||||
gs(s, "name"), gs(s, "version"), gs(s, "title"), gs(s, "description"), gs(s, "servicePath"))
|
||||
b.WriteString("Resources: []metaschema.Resource{\n")
|
||||
res := gm(s, "resources")
|
||||
for _, rname := range sortedKeys(res) {
|
||||
r, _ := res[rname].(map[string]any)
|
||||
if r == nil {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, "{Name: %q,\n", rname)
|
||||
emitMethods(&b, gm(r, "methods"))
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
b.WriteString("},\n") // Resources
|
||||
b.WriteString("},\n") // Service
|
||||
}
|
||||
b.WriteString("},\n") // Services
|
||||
b.WriteString("}\n\n") // registryData literal
|
||||
b.WriteString("func init() { Registry = registryData }\n")
|
||||
|
||||
src, err := format.Source([]byte(b.String()))
|
||||
if err != nil {
|
||||
// Write unformatted for debugging, then fail.
|
||||
_ = os.WriteFile(outPath+".broken", []byte(b.String()), 0644)
|
||||
fmt.Fprintln(os.Stderr, "gofmt:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := os.WriteFile(outPath, src, 0644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "write:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("wrote %s (%d services, %d bytes)\n", outPath, len(svcs), len(src))
|
||||
}
|
||||
15
internal/registry/metastatic/stub.go
Normal file
15
internal/registry/metastatic/stub.go
Normal file
@@ -0,0 +1,15 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package metastatic
|
||||
|
||||
import "github.com/larksuite/cli/internal/registry/metaschema"
|
||||
|
||||
// Registry is the command spec as static Go data. It is declared here (zero
|
||||
// value) so the package always compiles, and populated by meta_data_gen.go's
|
||||
// init() when that generated file is present. On a fresh checkout the generated
|
||||
// file is absent — it is gitignored and produced at build time by
|
||||
// `make gen_meta` — so Registry stays empty. This keeps the "heavy spec is
|
||||
// never committed, only generated" model, now without a build tag: the
|
||||
// generated file augments this one rather than replacing it under a tag.
|
||||
var Registry = metaschema.Registry{}
|
||||
90
internal/registry/metastatic_validate_test.go
Normal file
90
internal/registry/metastatic_validate_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Validation for the static-meta registry: the generated metastatic.Registry is
|
||||
// the sole embedded baseline (no JSON parsed at runtime), and a deep read of it
|
||||
// allocates nothing. The data is generated from meta_data.json at build time
|
||||
// (`make fetch_meta`) and is gitignored, so these tests skip on a bare checkout
|
||||
// where it has not been generated yet.
|
||||
package registry
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/registry/metaschema"
|
||||
"github.com/larksuite/cli/internal/registry/metastatic"
|
||||
)
|
||||
|
||||
func countFieldsStatic(fs []metaschema.Field) int {
|
||||
n := 0
|
||||
for _, f := range fs {
|
||||
n++
|
||||
n += countFieldsStatic(f.Properties)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func countStatic() (svc, res, meth, fld int) {
|
||||
svc = len(metastatic.Registry.Services)
|
||||
for _, s := range metastatic.Registry.Services {
|
||||
for _, r := range s.Resources {
|
||||
res++
|
||||
for _, m := range r.Methods {
|
||||
meth++
|
||||
fld += countFieldsStatic(m.Parameters) + countFieldsStatic(m.RequestBody) + countFieldsStatic(m.ResponseBody)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// TestStaticRegistryPopulated checks the generated registry carries data. It
|
||||
// skips on a bare checkout where meta_data_gen.go has not been generated yet.
|
||||
func TestStaticRegistryPopulated(t *testing.T) {
|
||||
if len(metastatic.Registry.Services) == 0 {
|
||||
t.Skip("static registry empty; run `make fetch_meta` to generate it")
|
||||
}
|
||||
svc, res, meth, fld := countStatic()
|
||||
t.Logf("static: services=%d resources=%d methods=%d fields=%d", svc, res, meth, fld)
|
||||
if svc == 0 || res == 0 || meth == 0 || fld == 0 {
|
||||
t.Fatalf("static registry incomplete: svc=%d res=%d meth=%d fld=%d", svc, res, meth, fld)
|
||||
}
|
||||
if metastatic.Registry.Version == "" {
|
||||
t.Error("static registry has empty Version")
|
||||
}
|
||||
}
|
||||
|
||||
var sinkInt int
|
||||
|
||||
// --- zero-alloc: a deep read of the static registry must allocate nothing ---
|
||||
|
||||
func deepReadStatic() int {
|
||||
n := 0
|
||||
for _, s := range metastatic.Registry.Services {
|
||||
n += len(s.Name)
|
||||
for _, r := range s.Resources {
|
||||
for _, m := range r.Methods {
|
||||
n += len(m.ID) + len(m.Scopes) + countFieldsStatic(m.Parameters) + countFieldsStatic(m.ResponseBody)
|
||||
}
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestStaticReadZeroAlloc(t *testing.T) {
|
||||
if len(metastatic.Registry.Services) == 0 {
|
||||
t.Skip("static registry empty; run `make fetch_meta` to generate it")
|
||||
}
|
||||
avg := testing.AllocsPerRun(50, func() { sinkInt = deepReadStatic() })
|
||||
t.Logf("static deep-read: %.1f allocs/op", avg)
|
||||
if avg > 0 {
|
||||
t.Errorf("static read allocates %.1f/op, want 0 (data should be in the binary, not heap)", avg)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkReadStaticRegistry(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
sinkInt = deepReadStatic()
|
||||
}
|
||||
}
|
||||
@@ -147,22 +147,6 @@ func saveCacheMeta(meta CacheMeta) error {
|
||||
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, ®); err != nil {
|
||||
// Cache corrupted — remove it so next run triggers a fresh fetch
|
||||
vfs.Remove(path)
|
||||
vfs.Remove(cacheMetaPath())
|
||||
return nil, err
|
||||
}
|
||||
return ®, nil
|
||||
}
|
||||
|
||||
func saveCachedMerged(data []byte, meta CacheMeta) error {
|
||||
if err := vfs.MkdirAll(cacheDir(), 0700); err != nil {
|
||||
return err
|
||||
@@ -253,7 +237,7 @@ func doSyncFetch() {
|
||||
Brand: string(configuredBrand),
|
||||
}
|
||||
_ = saveCachedMerged(data, meta)
|
||||
overlayMergedServices(reg)
|
||||
_ = loadCachedTyped()
|
||||
}
|
||||
|
||||
// --- background refresh ---
|
||||
@@ -308,15 +292,3 @@ func shouldRefresh(meta CacheMeta) bool {
|
||||
}
|
||||
return time.Since(time.Unix(meta.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 {
|
||||
name, ok := svc["name"].(string)
|
||||
if !ok || name == "" {
|
||||
continue
|
||||
}
|
||||
mergedServices[name] = svc
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/registry/metaschema"
|
||||
"github.com/larksuite/cli/internal/registry/metastatic"
|
||||
)
|
||||
|
||||
// waitBackgroundRefresh blocks until any in-flight background refresh started by
|
||||
@@ -30,8 +32,7 @@ func resetInit() {
|
||||
// reads globals this function mutates (see CI race: TestComputeMinimumScopeSet → Tenant).
|
||||
waitBackgroundRefresh()
|
||||
initOnce = sync.Once{}
|
||||
mergedServices = make(map[string]map[string]interface{})
|
||||
mergedProjectList = nil
|
||||
resetTyped()
|
||||
embeddedVersion = ""
|
||||
cachedAllScopes = nil
|
||||
cachedScopePriorities = nil
|
||||
@@ -55,16 +56,10 @@ func TestResetInitClearsEmbeddedVersion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// hasEmbeddedServices returns true if meta_data.json with real services is compiled in.
|
||||
// hasEmbeddedServices returns true if the static registry has services compiled
|
||||
// in (generated from meta_data.json at build time).
|
||||
func hasEmbeddedServices() bool {
|
||||
if len(embeddedMetaJSON) == 0 {
|
||||
return false
|
||||
}
|
||||
var reg MergedRegistry
|
||||
if err := json.Unmarshal(embeddedMetaJSON, ®); err != nil {
|
||||
return false
|
||||
}
|
||||
return len(reg.Services) > 0
|
||||
return len(metastatic.Registry.Services) > 0
|
||||
}
|
||||
|
||||
// testRegistry returns a minimal MergedRegistry with one service.
|
||||
@@ -302,50 +297,36 @@ func TestMetaTTL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayMergedServices(t *testing.T) {
|
||||
func TestRemoteOverlayTyped(t *testing.T) {
|
||||
resetInit()
|
||||
mergedServices = make(map[string]map[string]interface{})
|
||||
mergedServices["existing"] = map[string]interface{}{"name": "existing", "version": "v1"}
|
||||
setRemoteOverrides([]metaschema.Service{
|
||||
{Name: "existing", Version: "v2"},
|
||||
{Name: "brand_new", Version: "v1"},
|
||||
})
|
||||
|
||||
reg := &MergedRegistry{
|
||||
Services: []map[string]interface{}{
|
||||
{"name": "existing", "version": "v2"},
|
||||
{"name": "brand_new", "version": "v1"},
|
||||
},
|
||||
// override present
|
||||
if s, ok := typedServiceByName("existing"); !ok || s.Version != "v2" {
|
||||
t.Errorf("expected existing override v2, got %+v ok=%v", s, ok)
|
||||
}
|
||||
overlayMergedServices(reg)
|
||||
|
||||
// existing should be overridden
|
||||
if v := mergedServices["existing"]["version"].(string); v != "v2" {
|
||||
t.Errorf("expected existing to be overridden to v2, got %s", v)
|
||||
}
|
||||
// brand_new should be added
|
||||
if _, ok := mergedServices["brand_new"]; !ok {
|
||||
// new service added
|
||||
if _, ok := typedServiceByName("brand_new"); !ok {
|
||||
t.Error("expected brand_new to be added")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayMergedServicesDoesNotPolluteFollowingInit(t *testing.T) {
|
||||
func TestRemoteOverlayDoesNotPolluteFollowingInit(t *testing.T) {
|
||||
resetInit()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
|
||||
|
||||
const leakedExisting = "test_isolation_existing_sentinel"
|
||||
const leakedOverlay = "test_isolation_overlay_sentinel"
|
||||
|
||||
mergedServices = map[string]map[string]interface{}{
|
||||
leakedExisting: {"name": leakedExisting, "version": "v1"},
|
||||
}
|
||||
overlayMergedServices(&MergedRegistry{Services: []map[string]interface{}{{"name": leakedOverlay, "version": "v1"}}})
|
||||
const leaked = "test_isolation_overlay_sentinel"
|
||||
setRemoteOverrides([]metaschema.Service{{Name: leaked, Version: "v1"}})
|
||||
|
||||
resetInit()
|
||||
Init()
|
||||
|
||||
if spec := LoadFromMeta(leakedExisting); spec != nil {
|
||||
t.Fatalf("polluted service %q survived resetInit", leakedExisting)
|
||||
}
|
||||
if spec := LoadFromMeta(leakedOverlay); spec != nil {
|
||||
t.Fatalf("polluted service %q survived resetInit", leakedOverlay)
|
||||
if spec := LoadFromMeta(leaked); spec != nil {
|
||||
t.Fatalf("polluted service %q survived resetInit", leaked)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,8 +406,8 @@ func TestCorruptedCache_SelfHeals(t *testing.T) {
|
||||
metaData, _ := json.Marshal(meta)
|
||||
os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), metaData, 0644)
|
||||
|
||||
// loadCachedMerged should fail and remove the corrupted files
|
||||
_, err := loadCachedMerged()
|
||||
// loadCachedTyped should fail and remove the corrupted files
|
||||
err := loadCachedTyped()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for corrupted cache")
|
||||
}
|
||||
|
||||
579
internal/registry/typed.go
Normal file
579
internal/registry/typed.go
Normal file
@@ -0,0 +1,579 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package registry
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/internal/registry/metaschema"
|
||||
"github.com/larksuite/cli/internal/registry/metastatic"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// This file is the typed registry layer for the static-meta migration.
|
||||
//
|
||||
// - The embedded baseline is metastatic.Registry: static Go data laid out in
|
||||
// the binary at compile time (zero startup cost). It is empty on a fresh
|
||||
// checkout (stub.go) until the generated meta_data_gen.go is produced by
|
||||
// `make fetch_meta`; no build tag is involved.
|
||||
// - The remote overlay (~/.lark-cli/cache/remote_meta.json) is still fetched
|
||||
// and refreshed at runtime, decoded into the same typed shape, and merged
|
||||
// over the baseline as per-service overrides.
|
||||
//
|
||||
// Startup (command-tree build) reads these typed structs directly. Execution-
|
||||
// path consumers that still expect map[string]interface{} go through
|
||||
// ServiceToMap, which rebuilds one service's map lazily, on demand — never the
|
||||
// whole spec at startup.
|
||||
|
||||
var (
|
||||
typedMu sync.RWMutex
|
||||
remoteOverrides map[string]metaschema.Service // service name -> remote override
|
||||
typedNamesCache []string
|
||||
)
|
||||
|
||||
// resetTyped clears the typed overlay state (test/teardown helper).
|
||||
func resetTyped() {
|
||||
typedMu.Lock()
|
||||
defer typedMu.Unlock()
|
||||
remoteOverrides = nil
|
||||
typedNamesCache = nil
|
||||
}
|
||||
|
||||
// baselineServices returns the embedded baseline service specs: the static
|
||||
// compile-time data in metastatic.Registry (zero parse, zero alloc). It is
|
||||
// empty only on a fresh checkout where meta_data_gen.go has not been generated
|
||||
// yet (see stub.go).
|
||||
var (
|
||||
baselineOnce sync.Once
|
||||
baselineSvcs []metaschema.Service
|
||||
baselineVer string
|
||||
)
|
||||
|
||||
func loadBaseline() {
|
||||
baselineOnce.Do(func() {
|
||||
baselineSvcs = metastatic.Registry.Services
|
||||
baselineVer = metastatic.Registry.Version
|
||||
})
|
||||
}
|
||||
|
||||
func baselineServices() []metaschema.Service {
|
||||
loadBaseline()
|
||||
return baselineSvcs
|
||||
}
|
||||
|
||||
func baselineVersion() string {
|
||||
loadBaseline()
|
||||
return baselineVer
|
||||
}
|
||||
|
||||
// baselineServiceByName returns the embedded baseline service spec by name.
|
||||
func baselineServiceByName(name string) (metaschema.Service, bool) {
|
||||
svcs := baselineServices()
|
||||
for i := range svcs {
|
||||
if svcs[i].Name == name {
|
||||
return svcs[i], true
|
||||
}
|
||||
}
|
||||
return metaschema.Service{}, false
|
||||
}
|
||||
|
||||
// typedServiceByName returns the effective typed spec for a service: the remote
|
||||
// override if present, otherwise the static baseline.
|
||||
func typedServiceByName(name string) (metaschema.Service, bool) {
|
||||
typedMu.RLock()
|
||||
if s, ok := remoteOverrides[name]; ok {
|
||||
typedMu.RUnlock()
|
||||
return s, true
|
||||
}
|
||||
typedMu.RUnlock()
|
||||
return baselineServiceByName(name)
|
||||
}
|
||||
|
||||
// typedServiceNames returns all effective service names (baseline + remote
|
||||
// additions), sorted. Cached until the overlay changes.
|
||||
func typedServiceNames() []string {
|
||||
typedMu.RLock()
|
||||
if typedNamesCache != nil {
|
||||
out := typedNamesCache
|
||||
typedMu.RUnlock()
|
||||
return out
|
||||
}
|
||||
typedMu.RUnlock()
|
||||
|
||||
seen := make(map[string]bool)
|
||||
for _, s := range baselineServices() {
|
||||
seen[s.Name] = true
|
||||
}
|
||||
typedMu.RLock()
|
||||
for name := range remoteOverrides {
|
||||
seen[name] = true
|
||||
}
|
||||
typedMu.RUnlock()
|
||||
|
||||
names := make([]string, 0, len(seen))
|
||||
for n := range seen {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
typedMu.Lock()
|
||||
typedNamesCache = names
|
||||
typedMu.Unlock()
|
||||
return names
|
||||
}
|
||||
|
||||
// setRemoteOverrides installs the parsed remote overlay (called from Init).
|
||||
func setRemoteOverrides(svcs []metaschema.Service) {
|
||||
typedMu.Lock()
|
||||
defer typedMu.Unlock()
|
||||
if remoteOverrides == nil {
|
||||
remoteOverrides = make(map[string]metaschema.Service, len(svcs))
|
||||
}
|
||||
for _, s := range svcs {
|
||||
remoteOverrides[s.Name] = s
|
||||
}
|
||||
typedNamesCache = nil
|
||||
}
|
||||
|
||||
// TypedService returns the effective typed spec for a service (remote override
|
||||
// or static baseline). Public accessor for the command-tree builder.
|
||||
func TypedService(name string) (metaschema.Service, bool) {
|
||||
Init()
|
||||
return typedServiceByName(name)
|
||||
}
|
||||
|
||||
// TypedServices returns all effective service specs, sorted by name. Reading
|
||||
// these builds nothing on the heap (static data); the remote overlay, if any,
|
||||
// was allocated once at Init.
|
||||
func TypedServices() []metaschema.Service {
|
||||
Init()
|
||||
names := typedServiceNames()
|
||||
out := make([]metaschema.Service, 0, len(names))
|
||||
for _, n := range names {
|
||||
if s, ok := typedServiceByName(n); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// hasTypedData reports whether any typed spec is available (static baseline or
|
||||
// remote overlay). False only when the static registry has not been generated
|
||||
// (fresh checkout) and there is no cache.
|
||||
func hasTypedData() bool {
|
||||
if len(baselineServices()) > 0 {
|
||||
return true
|
||||
}
|
||||
typedMu.RLock()
|
||||
defer typedMu.RUnlock()
|
||||
return len(remoteOverrides) > 0
|
||||
}
|
||||
|
||||
// loadCachedTyped reads the on-disk remote cache, decodes it into the typed
|
||||
// shape, and installs it as the remote overlay (typed replacement for the old
|
||||
// map-based loadCachedMerged + overlay).
|
||||
func loadCachedTyped() error {
|
||||
data, err := vfs.ReadFile(cachePath())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var reg wireRegistry
|
||||
if err := json.Unmarshal(data, ®); err != nil {
|
||||
// Cache corrupted — remove it so the next run triggers a fresh fetch.
|
||||
_ = vfs.Remove(cachePath())
|
||||
_ = vfs.Remove(cacheMetaPath())
|
||||
return err
|
||||
}
|
||||
svcs := make([]metaschema.Service, 0, len(reg.Services))
|
||||
for _, ws := range reg.Services {
|
||||
svcs = append(svcs, wireToService(ws))
|
||||
}
|
||||
setRemoteOverrides(svcs)
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- typed -> map[string]interface{} shim (lazy, per service, execution-path) ---
|
||||
|
||||
func strList(ss []string) []interface{} {
|
||||
if len(ss) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]interface{}, len(ss))
|
||||
for i, s := range ss {
|
||||
out[i] = s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func fieldToMap(f metaschema.Field) map[string]interface{} {
|
||||
m := map[string]interface{}{}
|
||||
put := func(k, v string) {
|
||||
if v != "" {
|
||||
m[k] = v
|
||||
}
|
||||
}
|
||||
put("type", f.Type)
|
||||
put("location", f.Location)
|
||||
put("description", f.Description)
|
||||
put("default", f.Default)
|
||||
put("example", f.Example)
|
||||
put("enumName", f.EnumName)
|
||||
put("min", f.Min)
|
||||
put("max", f.Max)
|
||||
put("ref", f.Ref)
|
||||
if f.Required {
|
||||
m["required"] = true
|
||||
}
|
||||
if v := strList(f.Enum); v != nil {
|
||||
m["enum"] = v
|
||||
}
|
||||
if v := strList(f.Annotations); v != nil {
|
||||
m["annotations"] = v
|
||||
}
|
||||
if len(f.Options) > 0 {
|
||||
opts := make([]interface{}, len(f.Options))
|
||||
for i, o := range f.Options {
|
||||
opts[i] = map[string]interface{}{"value": o.Value, "description": o.Description}
|
||||
}
|
||||
m["options"] = opts
|
||||
}
|
||||
if len(f.Properties) > 0 {
|
||||
m["properties"] = fieldsToMap(f.Properties)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func fieldsToMap(fs []metaschema.Field) map[string]interface{} {
|
||||
if len(fs) == 0 {
|
||||
return nil
|
||||
}
|
||||
m := make(map[string]interface{}, len(fs))
|
||||
for _, f := range fs {
|
||||
m[f.Name] = fieldToMap(f)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// affordanceToMap rebuilds the JSON-shaped affordance object (snake_case keys)
|
||||
// so the schema assembler's parseAffordance(method["affordance"]) keeps working
|
||||
// through the typed registry. Returns nil when the overlay carries nothing.
|
||||
func affordanceToMap(a *metaschema.Affordance) map[string]interface{} {
|
||||
m := map[string]interface{}{}
|
||||
if v := strList(a.UseWhen); v != nil {
|
||||
m["use_when"] = v
|
||||
}
|
||||
if v := strList(a.DoNotUseWhen); v != nil {
|
||||
m["do_not_use_when"] = v
|
||||
}
|
||||
if v := strList(a.Prerequisites); v != nil {
|
||||
m["prerequisites"] = v
|
||||
}
|
||||
if len(a.Examples) > 0 {
|
||||
ex := make([]interface{}, len(a.Examples))
|
||||
for i, e := range a.Examples {
|
||||
ex[i] = map[string]interface{}{"description": e.Description, "command": e.Command}
|
||||
}
|
||||
m["examples"] = ex
|
||||
}
|
||||
if v := strList(a.Related); v != nil {
|
||||
m["related"] = v
|
||||
}
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func MethodToMap(mth metaschema.Method) map[string]interface{} {
|
||||
m := map[string]interface{}{
|
||||
"id": mth.ID,
|
||||
"path": mth.Path,
|
||||
"httpMethod": mth.HTTPMethod,
|
||||
"description": mth.Description,
|
||||
}
|
||||
if mth.Risk != "" {
|
||||
m["risk"] = mth.Risk
|
||||
}
|
||||
if mth.DocURL != "" {
|
||||
m["docUrl"] = mth.DocURL
|
||||
}
|
||||
if mth.Danger {
|
||||
m["danger"] = true
|
||||
}
|
||||
if v := strList(mth.Scopes); v != nil {
|
||||
m["scopes"] = v
|
||||
}
|
||||
if v := strList(mth.AccessTokens); v != nil {
|
||||
m["accessTokens"] = v
|
||||
}
|
||||
if v := strList(mth.ParameterOrder); v != nil {
|
||||
m["parameterOrder"] = v
|
||||
}
|
||||
if v := strList(mth.RequiredScopes); v != nil {
|
||||
m["requiredScopes"] = v
|
||||
}
|
||||
if v := fieldsToMap(mth.Parameters); v != nil {
|
||||
m["parameters"] = v
|
||||
}
|
||||
if v := fieldsToMap(mth.RequestBody); v != nil {
|
||||
m["requestBody"] = v
|
||||
}
|
||||
if v := fieldsToMap(mth.ResponseBody); v != nil {
|
||||
m["responseBody"] = v
|
||||
}
|
||||
if mth.Affordance != nil {
|
||||
if am := affordanceToMap(mth.Affordance); am != nil {
|
||||
m["affordance"] = am
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// ServiceToMap rebuilds the JSON-shaped map[string]interface{} for one service,
|
||||
// so execution-path consumers (and method RunE) keep working unchanged.
|
||||
func ServiceToMap(s metaschema.Service) map[string]interface{} {
|
||||
resources := make(map[string]interface{}, len(s.Resources))
|
||||
for _, r := range s.Resources {
|
||||
methods := make(map[string]interface{}, len(r.Methods))
|
||||
for _, mth := range r.Methods {
|
||||
methods[mth.Name] = MethodToMap(mth)
|
||||
}
|
||||
resources[r.Name] = map[string]interface{}{"methods": methods}
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"name": s.Name,
|
||||
"version": s.Version,
|
||||
"title": s.Title,
|
||||
"description": s.Description,
|
||||
"servicePath": s.ServicePath,
|
||||
"resources": resources,
|
||||
}
|
||||
}
|
||||
|
||||
// --- map[string]interface{} -> typed (for the map-based wrappers still used by
|
||||
// tests; production builds from typed directly) ---
|
||||
|
||||
func ifaceStrs(v interface{}) []string {
|
||||
raw, _ := v.([]interface{})
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, e := range raw {
|
||||
if s, ok := e.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedMapKeys(m map[string]interface{}) []string {
|
||||
ks := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
ks = append(ks, k)
|
||||
}
|
||||
sort.Strings(ks)
|
||||
return ks
|
||||
}
|
||||
|
||||
func mapToField(name string, m map[string]interface{}) metaschema.Field {
|
||||
f := metaschema.Field{
|
||||
Name: name, Type: GetStrFromMap(m, "type"), Location: GetStrFromMap(m, "location"),
|
||||
Description: GetStrFromMap(m, "description"), Default: GetStrFromMap(m, "default"),
|
||||
Example: GetStrFromMap(m, "example"), EnumName: GetStrFromMap(m, "enumName"),
|
||||
Min: GetStrFromMap(m, "min"), Max: GetStrFromMap(m, "max"), Ref: GetStrFromMap(m, "ref"),
|
||||
Enum: ifaceStrs(m["enum"]), Annotations: ifaceStrs(m["annotations"]),
|
||||
}
|
||||
if b, ok := m["required"].(bool); ok {
|
||||
f.Required = b
|
||||
}
|
||||
if opts, ok := m["options"].([]interface{}); ok {
|
||||
for _, o := range opts {
|
||||
om, _ := o.(map[string]interface{})
|
||||
f.Options = append(f.Options, metaschema.Option{Value: GetStrFromMap(om, "value"), Description: GetStrFromMap(om, "description")})
|
||||
}
|
||||
}
|
||||
f.Properties = mapToFields(m["properties"])
|
||||
return f
|
||||
}
|
||||
|
||||
func mapToFields(v interface{}) []metaschema.Field {
|
||||
fm, _ := v.(map[string]interface{})
|
||||
if len(fm) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]metaschema.Field, 0, len(fm))
|
||||
for _, k := range sortedMapKeys(fm) {
|
||||
em, _ := fm[k].(map[string]interface{})
|
||||
out = append(out, mapToField(k, em))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func MapToMethod(name string, m map[string]interface{}) metaschema.Method {
|
||||
return metaschema.Method{
|
||||
Name: name, ID: GetStrFromMap(m, "id"), Path: GetStrFromMap(m, "path"),
|
||||
HTTPMethod: GetStrFromMap(m, "httpMethod"), Description: GetStrFromMap(m, "description"),
|
||||
Risk: GetStrFromMap(m, "risk"), DocURL: GetStrFromMap(m, "docUrl"),
|
||||
Danger: boolFromMap(m, "danger"),
|
||||
Scopes: ifaceStrs(m["scopes"]),
|
||||
AccessTokens: ifaceStrs(m["accessTokens"]),
|
||||
ParameterOrder: ifaceStrs(m["parameterOrder"]),
|
||||
RequiredScopes: ifaceStrs(m["requiredScopes"]),
|
||||
Parameters: mapToFields(m["parameters"]),
|
||||
RequestBody: mapToFields(m["requestBody"]),
|
||||
ResponseBody: mapToFields(m["responseBody"]),
|
||||
}
|
||||
}
|
||||
|
||||
func boolFromMap(m map[string]interface{}, k string) bool {
|
||||
b, _ := m[k].(bool)
|
||||
return b
|
||||
}
|
||||
|
||||
func MapToResources(v interface{}) []metaschema.Resource {
|
||||
rm, _ := v.(map[string]interface{})
|
||||
if len(rm) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]metaschema.Resource, 0, len(rm))
|
||||
for _, rk := range sortedMapKeys(rm) {
|
||||
res, _ := rm[rk].(map[string]interface{})
|
||||
mm, _ := res["methods"].(map[string]interface{})
|
||||
methods := make([]metaschema.Method, 0, len(mm))
|
||||
for _, mk := range sortedMapKeys(mm) {
|
||||
methodMap, _ := mm[mk].(map[string]interface{})
|
||||
methods = append(methods, MapToMethod(mk, methodMap))
|
||||
}
|
||||
out = append(out, metaschema.Resource{Name: rk, Methods: methods})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MapToService converts a JSON-shaped service spec (with embedded "resources")
|
||||
// into the typed form.
|
||||
func MapToService(spec map[string]interface{}) metaschema.Service {
|
||||
return metaschema.Service{
|
||||
Name: GetStrFromMap(spec, "name"), Version: GetStrFromMap(spec, "version"),
|
||||
Title: GetStrFromMap(spec, "title"), Description: GetStrFromMap(spec, "description"),
|
||||
ServicePath: GetStrFromMap(spec, "servicePath"), Resources: MapToResources(spec["resources"]),
|
||||
}
|
||||
}
|
||||
|
||||
// --- remote JSON (wire) -> typed ---
|
||||
|
||||
type wireRegistry struct {
|
||||
Version string `json:"version"`
|
||||
Services []wireService `json:"services"`
|
||||
}
|
||||
|
||||
type wireService struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
ServicePath string `json:"servicePath"`
|
||||
Resources map[string]wireResource `json:"resources"`
|
||||
}
|
||||
|
||||
type wireResource struct {
|
||||
Methods map[string]wireMethod `json:"methods"`
|
||||
}
|
||||
|
||||
type wireMethod struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
HTTPMethod string `json:"httpMethod"`
|
||||
Description string `json:"description"`
|
||||
Risk string `json:"risk"`
|
||||
DocURL string `json:"docUrl"`
|
||||
Danger bool `json:"danger"`
|
||||
Scopes []string `json:"scopes"`
|
||||
AccessTokens []string `json:"accessTokens"`
|
||||
ParameterOrder []string `json:"parameterOrder"`
|
||||
RequiredScopes []string `json:"requiredScopes"`
|
||||
Parameters map[string]wireField `json:"parameters"`
|
||||
RequestBody map[string]wireField `json:"requestBody"`
|
||||
ResponseBody map[string]wireField `json:"responseBody"`
|
||||
}
|
||||
|
||||
type wireField struct {
|
||||
Type string `json:"type"`
|
||||
Location string `json:"location"`
|
||||
Description string `json:"description"`
|
||||
Default string `json:"default"`
|
||||
Example string `json:"example"`
|
||||
EnumName string `json:"enumName"`
|
||||
Min string `json:"min"`
|
||||
Max string `json:"max"`
|
||||
Ref string `json:"ref"`
|
||||
Required bool `json:"required"`
|
||||
Options []metaschema.Option `json:"options"`
|
||||
Enum []string `json:"enum"`
|
||||
Annotations []string `json:"annotations"`
|
||||
Properties map[string]wireField `json:"properties"`
|
||||
}
|
||||
|
||||
func sortedFieldKeys(m map[string]wireField) []string {
|
||||
ks := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
ks = append(ks, k)
|
||||
}
|
||||
sort.Strings(ks)
|
||||
return ks
|
||||
}
|
||||
|
||||
func wireFields(m map[string]wireField) []metaschema.Field {
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]metaschema.Field, 0, len(m))
|
||||
for _, name := range sortedFieldKeys(m) {
|
||||
wf := m[name]
|
||||
out = append(out, metaschema.Field{
|
||||
Name: name, Type: wf.Type, Location: wf.Location, Description: wf.Description,
|
||||
Default: wf.Default, Example: wf.Example, EnumName: wf.EnumName,
|
||||
Min: wf.Min, Max: wf.Max, Ref: wf.Ref, Required: wf.Required,
|
||||
Options: wf.Options, Enum: wf.Enum, Annotations: wf.Annotations,
|
||||
Properties: wireFields(wf.Properties),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func wireToService(ws wireService) metaschema.Service {
|
||||
resKeys := make([]string, 0, len(ws.Resources))
|
||||
for k := range ws.Resources {
|
||||
resKeys = append(resKeys, k)
|
||||
}
|
||||
sort.Strings(resKeys)
|
||||
resources := make([]metaschema.Resource, 0, len(resKeys))
|
||||
for _, rk := range resKeys {
|
||||
wr := ws.Resources[rk]
|
||||
methKeys := make([]string, 0, len(wr.Methods))
|
||||
for k := range wr.Methods {
|
||||
methKeys = append(methKeys, k)
|
||||
}
|
||||
sort.Strings(methKeys)
|
||||
methods := make([]metaschema.Method, 0, len(methKeys))
|
||||
for _, mk := range methKeys {
|
||||
wm := wr.Methods[mk]
|
||||
methods = append(methods, metaschema.Method{
|
||||
Name: mk, ID: wm.ID, Path: wm.Path, HTTPMethod: wm.HTTPMethod,
|
||||
Description: wm.Description, Risk: wm.Risk, DocURL: wm.DocURL, Danger: wm.Danger,
|
||||
Scopes: wm.Scopes, AccessTokens: wm.AccessTokens,
|
||||
ParameterOrder: wm.ParameterOrder, RequiredScopes: wm.RequiredScopes,
|
||||
Parameters: wireFields(wm.Parameters), RequestBody: wireFields(wm.RequestBody),
|
||||
ResponseBody: wireFields(wm.ResponseBody),
|
||||
})
|
||||
}
|
||||
resources = append(resources, metaschema.Resource{Name: rk, Methods: methods})
|
||||
}
|
||||
return metaschema.Service{
|
||||
Name: ws.Name, Version: ws.Version, Title: ws.Title,
|
||||
Description: ws.Description, ServicePath: ws.ServicePath, Resources: resources,
|
||||
}
|
||||
}
|
||||
@@ -4,290 +4,14 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
|
||||
// MethodKeyOrder records the natural meta_data.json key order for one method's
|
||||
// parameters / requestBody / responseBody. Nested object key orders are stored
|
||||
// under NestedKeys, keyed by dotted path from the method root
|
||||
// (e.g. "responseBody.items.properties").
|
||||
type MethodKeyOrder struct {
|
||||
Parameters []string
|
||||
RequestBody []string
|
||||
ResponseBody []string
|
||||
NestedKeys map[string][]string
|
||||
}
|
||||
|
||||
var (
|
||||
keyOrderIndex map[string]*MethodKeyOrder // dottedPath -> order
|
||||
keyOrderInitOnce sync.Once
|
||||
)
|
||||
|
||||
// lookupKeyOrder returns the key-order record for service.resourcePath.method,
|
||||
// or nil if the method is not in the embedded data (e.g. remote-cached).
|
||||
func lookupKeyOrder(service string, resourcePath []string, method string) *MethodKeyOrder {
|
||||
keyOrderInitOnce.Do(buildKeyOrderIndex)
|
||||
if keyOrderIndex == nil {
|
||||
return nil
|
||||
}
|
||||
dotted := dottedPath(service, resourcePath, method)
|
||||
return keyOrderIndex[dotted]
|
||||
}
|
||||
|
||||
func dottedPath(service string, resourcePath []string, method string) string {
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString(service)
|
||||
for _, r := range resourcePath {
|
||||
buf.WriteByte('.')
|
||||
buf.WriteString(r)
|
||||
}
|
||||
buf.WriteByte('.')
|
||||
buf.WriteString(method)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// buildKeyOrderIndex parses the embedded meta_data.json bytes once at init,
|
||||
// walking services -> resources -> methods -> {parameters,requestBody,responseBody}
|
||||
// and recording each map's key insertion order via json.Decoder.Token().
|
||||
func buildKeyOrderIndex() {
|
||||
raw := registry.EmbeddedMetaJSON()
|
||||
if len(raw) == 0 {
|
||||
return
|
||||
}
|
||||
keyOrderIndex = make(map[string]*MethodKeyOrder)
|
||||
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
// Top-level: { "services": [...], "version": "..." }
|
||||
if !expectDelim(dec, '{') {
|
||||
return
|
||||
}
|
||||
for dec.More() {
|
||||
key, _ := readKey(dec)
|
||||
if key != "services" {
|
||||
skipValue(dec)
|
||||
continue
|
||||
}
|
||||
if !expectDelim(dec, '[') {
|
||||
return
|
||||
}
|
||||
for dec.More() {
|
||||
parseService(dec)
|
||||
}
|
||||
// closing ]
|
||||
_, _ = dec.Token()
|
||||
}
|
||||
}
|
||||
|
||||
// parseService consumes one service object inside services[].
|
||||
// meta_data.json may emit "resources" before "name", so we first capture both
|
||||
// raw fields, then walk resources with the resolved service name.
|
||||
func parseService(dec *json.Decoder) {
|
||||
if !expectDelim(dec, '{') {
|
||||
return
|
||||
}
|
||||
var serviceName string
|
||||
var resourcesRaw json.RawMessage
|
||||
for dec.More() {
|
||||
key, _ := readKey(dec)
|
||||
switch key {
|
||||
case "name":
|
||||
tok, _ := dec.Token()
|
||||
if s, ok := tok.(string); ok {
|
||||
serviceName = s
|
||||
}
|
||||
case "resources":
|
||||
if err := dec.Decode(&resourcesRaw); err != nil {
|
||||
skipValue(dec)
|
||||
}
|
||||
default:
|
||||
skipValue(dec)
|
||||
}
|
||||
}
|
||||
_, _ = dec.Token() // closing }
|
||||
if serviceName != "" && len(resourcesRaw) > 0 {
|
||||
subDec := json.NewDecoder(bytes.NewReader(resourcesRaw))
|
||||
parseResources(subDec, serviceName, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// parseResources walks a resources map (resName -> resource object).
|
||||
// resourcePath is the accumulated path of parent resources (for nested resources).
|
||||
func parseResources(dec *json.Decoder, service string, resourcePath []string) {
|
||||
if !expectDelim(dec, '{') {
|
||||
return
|
||||
}
|
||||
for dec.More() {
|
||||
resName, _ := readKey(dec)
|
||||
parseResourceObj(dec, service, append(resourcePath, resName))
|
||||
}
|
||||
_, _ = dec.Token()
|
||||
}
|
||||
|
||||
// parseResourceObj consumes one resource value: { methods: {...}, ... } and may
|
||||
// recurse into nested resources via "resources" key if present.
|
||||
func parseResourceObj(dec *json.Decoder, service string, resourcePath []string) {
|
||||
if !expectDelim(dec, '{') {
|
||||
return
|
||||
}
|
||||
for dec.More() {
|
||||
key, _ := readKey(dec)
|
||||
switch key {
|
||||
case "methods":
|
||||
parseMethods(dec, service, resourcePath)
|
||||
case "resources":
|
||||
parseResources(dec, service, resourcePath)
|
||||
default:
|
||||
skipValue(dec)
|
||||
}
|
||||
}
|
||||
_, _ = dec.Token()
|
||||
}
|
||||
|
||||
// parseMethods consumes the methods map (methodName -> method object).
|
||||
func parseMethods(dec *json.Decoder, service string, resourcePath []string) {
|
||||
if !expectDelim(dec, '{') {
|
||||
return
|
||||
}
|
||||
for dec.More() {
|
||||
methodName, _ := readKey(dec)
|
||||
mko := parseMethod(dec)
|
||||
dotted := dottedPath(service, resourcePath, methodName)
|
||||
keyOrderIndex[dotted] = mko
|
||||
}
|
||||
_, _ = dec.Token()
|
||||
}
|
||||
|
||||
// parseMethod consumes one method object and records key orders.
|
||||
func parseMethod(dec *json.Decoder) *MethodKeyOrder {
|
||||
mko := &MethodKeyOrder{NestedKeys: make(map[string][]string)}
|
||||
if !expectDelim(dec, '{') {
|
||||
return mko
|
||||
}
|
||||
for dec.More() {
|
||||
key, _ := readKey(dec)
|
||||
switch key {
|
||||
case "parameters":
|
||||
mko.Parameters = recordObjectKeysRecursive(dec, "parameters", mko.NestedKeys)
|
||||
case "requestBody":
|
||||
mko.RequestBody = recordObjectKeysRecursive(dec, "requestBody", mko.NestedKeys)
|
||||
case "responseBody":
|
||||
mko.ResponseBody = recordObjectKeysRecursive(dec, "responseBody", mko.NestedKeys)
|
||||
default:
|
||||
skipValue(dec)
|
||||
}
|
||||
}
|
||||
_, _ = dec.Token()
|
||||
return mko
|
||||
}
|
||||
|
||||
// recordObjectKeysRecursive consumes an object and records the top-level key
|
||||
// order. It also recurses into each child's "properties" submap, recording
|
||||
// nested orders under prefix.subpath in nestedKeys. Returns the top-level keys
|
||||
// in order.
|
||||
func recordObjectKeysRecursive(dec *json.Decoder, prefix string, nestedKeys map[string][]string) []string {
|
||||
if !expectDelim(dec, '{') {
|
||||
return nil
|
||||
}
|
||||
var order []string
|
||||
for dec.More() {
|
||||
key, _ := readKey(dec)
|
||||
order = append(order, key)
|
||||
// Each child value is itself an object; we want its nested "properties" order if present.
|
||||
consumeFieldRecursive(dec, prefix+"."+key, nestedKeys)
|
||||
}
|
||||
_, _ = dec.Token()
|
||||
if prefix != "" && len(order) > 0 {
|
||||
nestedKeys[prefix] = order
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
// consumeFieldRecursive consumes a field object (e.g. one parameter spec) and,
|
||||
// if it contains "properties": {...}, recursively records that submap's order.
|
||||
func consumeFieldRecursive(dec *json.Decoder, path string, nestedKeys map[string][]string) {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
delim, ok := tok.(json.Delim)
|
||||
if !ok || delim != '{' {
|
||||
// Not an object — skip the rest of the value
|
||||
skipValueAfterToken(dec, tok)
|
||||
return
|
||||
}
|
||||
for dec.More() {
|
||||
fieldKey, _ := readKey(dec)
|
||||
if fieldKey == "properties" {
|
||||
recordObjectKeysRecursive(dec, path+".properties", nestedKeys)
|
||||
} else {
|
||||
skipValue(dec)
|
||||
}
|
||||
}
|
||||
_, _ = dec.Token()
|
||||
}
|
||||
|
||||
// --- json.Decoder helpers ---
|
||||
|
||||
func expectDelim(dec *json.Decoder, want json.Delim) bool {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
delim, ok := tok.(json.Delim)
|
||||
return ok && delim == want
|
||||
}
|
||||
|
||||
func readKey(dec *json.Decoder) (string, error) {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s, _ := tok.(string)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// skipValue consumes the next complete value (scalar, object, or array).
|
||||
func skipValue(dec *json.Decoder) {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
skipValueAfterToken(dec, tok)
|
||||
}
|
||||
|
||||
func skipValueAfterToken(dec *json.Decoder, tok json.Token) {
|
||||
delim, ok := tok.(json.Delim)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// We started inside a container of type `delim` ({ or [) and must eat
|
||||
// tokens until that container closes, tracking nested containers of any
|
||||
// kind. depth counts how many open containers we are currently inside.
|
||||
_ = delim
|
||||
depth := 1
|
||||
for depth > 0 {
|
||||
t, err := dec.Token()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if d, ok := t.(json.Delim); ok {
|
||||
switch d {
|
||||
case '{', '[':
|
||||
depth++
|
||||
case '}', ']':
|
||||
depth--
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// coerceLiteral converts a meta_data literal (default / enum / example) to
|
||||
// the JSON Schema type declared by the field (integer/number/boolean/string).
|
||||
// meta_data stores every literal as a string, so without coercion an
|
||||
@@ -501,10 +225,6 @@ func buildOrderedProps(raw map[string]interface{}, nestedPath string) (*OrderedP
|
||||
return op, required
|
||||
}
|
||||
|
||||
// currentMethodOrder is the per-method key-order context used by orderedKeys.
|
||||
// It is set inside AssembleEnvelope (under assembleMu) and reset on return.
|
||||
var currentMethodOrder *MethodKeyOrder
|
||||
|
||||
// parseAffordance lifts the affordance overlay from a method's raw meta_data.json
|
||||
// entry into a typed *Affordance. Returns nil when the field is absent, malformed,
|
||||
// or carries no populated subfields.
|
||||
@@ -611,8 +331,6 @@ func buildMeta(method map[string]interface{}) *Meta {
|
||||
// The params / data wrapping mirrors the CLI's actual flag layout:
|
||||
// path+query → --params JSON, body → --data JSON, file → --file. AI consumers
|
||||
// can pluck inputSchema.properties.params and pass it verbatim to --params.
|
||||
//
|
||||
// Caller must set currentMethodOrder for property-order preservation.
|
||||
func buildInputSchema(method map[string]interface{}) *InputSchema {
|
||||
is := &InputSchema{
|
||||
Type: "object",
|
||||
@@ -738,27 +456,11 @@ func buildOutputSchema(method map[string]interface{}) *OutputSchema {
|
||||
return os
|
||||
}
|
||||
|
||||
// assembleMu serializes AssembleEnvelope calls so that the package-level
|
||||
// currentMethodOrder pointer is safe for concurrent callers.
|
||||
var assembleMu sync.Mutex
|
||||
|
||||
// AssembleEnvelope is the main entry point: takes a service / resource path /
|
||||
// method name plus its meta_data spec, and produces a fully assembled MCP
|
||||
// envelope. Output is fully determined by inputs (same arguments → same
|
||||
// envelope), but assembly briefly publishes the per-method key-order context
|
||||
// through the package-level currentMethodOrder so orderedKeys can reach it
|
||||
// without threading it through every helper. assembleMu serializes that
|
||||
// publish, which is why concurrent callers are still safe — they queue
|
||||
// rather than run in parallel.
|
||||
//
|
||||
// If parallelism becomes a bottleneck, replace currentMethodOrder with an
|
||||
// assembler struct or pass *MethodKeyOrder explicitly down the call chain.
|
||||
// envelope).
|
||||
func AssembleEnvelope(serviceName string, resourcePath []string, methodName string, method map[string]interface{}) Envelope {
|
||||
assembleMu.Lock()
|
||||
defer assembleMu.Unlock()
|
||||
currentMethodOrder = lookupKeyOrder(serviceName, resourcePath, methodName)
|
||||
defer func() { currentMethodOrder = nil }()
|
||||
|
||||
name := serviceName
|
||||
for _, r := range resourcePath {
|
||||
name += " " + r
|
||||
@@ -836,35 +538,10 @@ func walkMethods(resources map[string]interface{}, parentPath []string,
|
||||
}
|
||||
}
|
||||
|
||||
// orderedKeys returns the keys of raw in their meta_data natural order if
|
||||
// the current per-method key-order context has them recorded; otherwise
|
||||
// alphabetical fallback.
|
||||
func orderedKeys(raw map[string]interface{}, nestedPath string) []string {
|
||||
if currentMethodOrder != nil && nestedPath != "" {
|
||||
if order, ok := currentMethodOrder.NestedKeys[nestedPath]; ok {
|
||||
// Filter to keys that actually exist in raw (defensive)
|
||||
out := make([]string, 0, len(order))
|
||||
seen := make(map[string]bool)
|
||||
for _, k := range order {
|
||||
if _, ok := raw[k]; ok {
|
||||
out = append(out, k)
|
||||
seen[k] = true
|
||||
}
|
||||
}
|
||||
// Append any keys present in raw but missing from order (defensive),
|
||||
// alphabetically for determinism.
|
||||
var extra []string
|
||||
for k := range raw {
|
||||
if !seen[k] {
|
||||
extra = append(extra, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(extra)
|
||||
out = append(out, extra...)
|
||||
return out
|
||||
}
|
||||
}
|
||||
// Fallback: alphabetical
|
||||
// orderedKeys returns the keys of raw in alphabetical order. Field display
|
||||
// order is not preserved: the schema envelope is consumed as a JSON Schema (MCP
|
||||
// tool spec), where object property order carries no meaning.
|
||||
func orderedKeys(raw map[string]interface{}, _ string) []string {
|
||||
keys := make([]string, 0, len(raw))
|
||||
for k := range raw {
|
||||
keys = append(keys, k)
|
||||
|
||||
@@ -7,10 +7,12 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/registry/metaschema"
|
||||
)
|
||||
|
||||
// TestMain isolates registry-backed tests from any host ~/.lark-cli cache so
|
||||
@@ -35,58 +37,6 @@ func TestMain(m *testing.M) {
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func TestKeyOrderIndex_ImReactionsList(t *testing.T) {
|
||||
// We only assert key-set membership, not absolute order — the upstream
|
||||
// meta_data API does not guarantee a stable JSON key sequence across
|
||||
// fetches, so hard-coding the order makes CI flaky. Order preservation
|
||||
// from input to output is tested separately in TestBuildInputSchema_*.
|
||||
order := lookupKeyOrder("im", []string{"reactions"}, "list")
|
||||
if order == nil {
|
||||
t.Fatal("expected key order for im.reactions.list, got nil")
|
||||
}
|
||||
wantParams := map[string]bool{
|
||||
"message_id": true, "reaction_type": true, "page_token": true,
|
||||
"page_size": true, "user_id_type": true,
|
||||
}
|
||||
if got, want := len(order.Parameters), len(wantParams); got != want {
|
||||
t.Errorf("parameters count = %d, want %d (got %v)", got, want, order.Parameters)
|
||||
}
|
||||
for _, k := range order.Parameters {
|
||||
if !wantParams[k] {
|
||||
t.Errorf("unexpected parameter key %q", k)
|
||||
}
|
||||
}
|
||||
// im.reactions.list 是 GET,没有 requestBody
|
||||
if len(order.RequestBody) != 0 {
|
||||
t.Errorf("expected empty RequestBody, got %v", order.RequestBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyOrderIndex_ImImagesCreate(t *testing.T) {
|
||||
// Membership-only assertion; see comment on TestKeyOrderIndex_ImReactionsList.
|
||||
order := lookupKeyOrder("im", []string{"images"}, "create")
|
||||
if order == nil {
|
||||
t.Fatal("expected key order for im.images.create, got nil")
|
||||
}
|
||||
wantBody := map[string]bool{"image_type": true, "image": true}
|
||||
if got, want := len(order.RequestBody), len(wantBody); got != want {
|
||||
t.Errorf("requestBody count = %d, want %d (got %v)", got, want, order.RequestBody)
|
||||
}
|
||||
for _, k := range order.RequestBody {
|
||||
if !wantBody[k] {
|
||||
t.Errorf("unexpected requestBody key %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyOrderIndex_UnknownPath(t *testing.T) {
|
||||
// 远端缓存的命令(不在 embedded 内)查不到 key order,返回 nil 走字母序兜底
|
||||
order := lookupKeyOrder("nonexistent_service", []string{"foo"}, "bar")
|
||||
if order != nil {
|
||||
t.Errorf("expected nil for unknown path, got %+v", order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_BasicTypes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -288,9 +238,6 @@ func TestConvertProperty_DescriptionDefaultExample(t *testing.T) {
|
||||
|
||||
func TestBuildInputSchema_ReactionsList(t *testing.T) {
|
||||
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
|
||||
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
|
||||
currentMethodOrder = mko
|
||||
defer func() { currentMethodOrder = nil }()
|
||||
|
||||
is := buildInputSchema(method)
|
||||
|
||||
@@ -313,16 +260,15 @@ func TestBuildInputSchema_ReactionsList(t *testing.T) {
|
||||
if !reflect.DeepEqual(params.Required, []string{"message_id"}) {
|
||||
t.Errorf("params.Required = %v, want [message_id]", params.Required)
|
||||
}
|
||||
if !reflect.DeepEqual(params.Properties.Order, mko.Parameters) {
|
||||
t.Errorf("params.properties order = %v, want (from key index) %v",
|
||||
params.Properties.Order, mko.Parameters)
|
||||
// Property order is alphabetical now: the envelope is a JSON Schema (MCP
|
||||
// tool spec) where object property order carries no meaning.
|
||||
if !sort.StringsAreSorted(params.Properties.Order) {
|
||||
t.Errorf("params.properties order not alphabetical: %v", params.Properties.Order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInputSchema_ImagesCreate_FileAndBody(t *testing.T) {
|
||||
method := loadMethodFromRegistry(t, "im", []string{"images"}, "create")
|
||||
currentMethodOrder = lookupKeyOrder("im", []string{"images"}, "create")
|
||||
defer func() { currentMethodOrder = nil }()
|
||||
|
||||
is := buildInputSchema(method)
|
||||
|
||||
@@ -382,9 +328,6 @@ func TestBuildInputSchema_HighRiskWriteInjectsYes(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
currentMethodOrder = nil
|
||||
defer func() { currentMethodOrder = nil }()
|
||||
|
||||
is := buildInputSchema(method)
|
||||
|
||||
// yes lives at inputSchema.properties.yes (sibling of params/data)
|
||||
@@ -413,9 +356,6 @@ func TestBuildInputSchema_HighRiskWriteInjectsYes(t *testing.T) {
|
||||
|
||||
func TestBuildInputSchema_NoYesForReadRisk(t *testing.T) {
|
||||
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
|
||||
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
|
||||
currentMethodOrder = mko
|
||||
defer func() { currentMethodOrder = nil }()
|
||||
|
||||
is := buildInputSchema(method)
|
||||
if _, ok := is.Properties.Map["yes"]; ok {
|
||||
@@ -425,9 +365,6 @@ func TestBuildInputSchema_NoYesForReadRisk(t *testing.T) {
|
||||
|
||||
func TestBuildOutputSchema_ReactionsList(t *testing.T) {
|
||||
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
|
||||
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
|
||||
currentMethodOrder = mko
|
||||
defer func() { currentMethodOrder = nil }()
|
||||
|
||||
os := buildOutputSchema(method)
|
||||
|
||||
@@ -613,6 +550,45 @@ func TestBuildMeta_AffordanceFromMethod(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildMeta_AffordanceThroughTypedRegistry guards the static-registry path:
|
||||
// a method's affordance must survive metaschema.Method -> registry.MethodToMap
|
||||
// -> buildMeta, so `schema --format json` keeps emitting _meta.affordance after
|
||||
// the embedded-JSON-to-typed-registry migration. Without typed-side support the
|
||||
// overlay is silently stripped whenever meta_data.json carries affordance.
|
||||
func TestBuildMeta_AffordanceThroughTypedRegistry(t *testing.T) {
|
||||
mth := metaschema.Method{
|
||||
Name: "primary",
|
||||
Affordance: &metaschema.Affordance{
|
||||
UseWhen: []string{"用户想拿到自己默认日历的 ID"},
|
||||
DoNotUseWhen: []string{"已经知道某个具体日历的 ID"},
|
||||
Prerequisites: []string{"user 身份登录"},
|
||||
Examples: []metaschema.AffordanceExample{
|
||||
{Description: "取主日历", Command: "lark-cli calendar calendars primary"},
|
||||
},
|
||||
Related: []string{"calendars.list", "calendars.get"},
|
||||
},
|
||||
}
|
||||
method := registry.MethodToMap(mth)
|
||||
m := buildMeta(method)
|
||||
if m.Affordance == nil {
|
||||
t.Fatal("affordance dropped through the typed registry (MethodToMap -> buildMeta)")
|
||||
}
|
||||
a := m.Affordance
|
||||
if len(a.UseWhen) != 1 || a.UseWhen[0] != "用户想拿到自己默认日历的 ID" {
|
||||
t.Errorf("UseWhen = %v", a.UseWhen)
|
||||
}
|
||||
if len(a.DoNotUseWhen) != 1 || len(a.Prerequisites) != 1 {
|
||||
t.Errorf("DoNotUseWhen=%v Prerequisites=%v", a.DoNotUseWhen, a.Prerequisites)
|
||||
}
|
||||
if len(a.Examples) != 1 || a.Examples[0].Description != "取主日历" ||
|
||||
a.Examples[0].Command != "lark-cli calendar calendars primary" {
|
||||
t.Errorf("Examples = %+v", a.Examples)
|
||||
}
|
||||
if len(a.Related) != 2 {
|
||||
t.Errorf("Related = %v", a.Related)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeta_MissingDocURLOmitted(t *testing.T) {
|
||||
method := map[string]interface{}{
|
||||
"scopes": []interface{}{"x"},
|
||||
@@ -634,7 +610,6 @@ func TestBuildMeta_MissingDocURLOmitted(t *testing.T) {
|
||||
func TestBuildOutputSchema_EmptyResponseBody(t *testing.T) {
|
||||
// 装配器对空 responseBody 应生成 properties = {} (不 nil)
|
||||
method := map[string]interface{}{}
|
||||
currentMethodOrder = nil
|
||||
os := buildOutputSchema(method)
|
||||
if os.Type != "object" {
|
||||
t.Errorf("Type = %q, want \"object\"", os.Type)
|
||||
|
||||
@@ -83,9 +83,13 @@ type AffordanceCase struct {
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
// OrderedProps is map[string]Property with preserved key order on MarshalJSON.
|
||||
// It is used wherever JSON output must reflect meta_data.json's natural field
|
||||
// order rather than Go's default alphabetical map encoding.
|
||||
// OrderedProps is map[string]Property that emits its keys in Order on
|
||||
// MarshalJSON. Order is now populated alphabetically (see orderedKeys): the
|
||||
// schema envelope is an MCP tool spec / JSON Schema, where object property
|
||||
// order carries no meaning. The machinery that once preserved meta_data.json's
|
||||
// natural field order was removed with the static-registry migration; Order is
|
||||
// retained so MarshalJSON has one stable key sequence (and callers that leave
|
||||
// it empty fall back to alphabetical over Map).
|
||||
type OrderedProps struct {
|
||||
Order []string
|
||||
Map map[string]Property
|
||||
|
||||
@@ -6,6 +6,7 @@ OUT_DIR="$ROOT_DIR/.pkg-pr-new"
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
# fetch_meta.py also regenerates the static Go registry (meta_data_gen.go).
|
||||
python3 scripts/fetch_meta.py
|
||||
|
||||
rm -rf "$OUT_DIR"
|
||||
|
||||
@@ -63,6 +63,19 @@ def fetch_remote(brand):
|
||||
return data
|
||||
|
||||
|
||||
def run_gen():
|
||||
"""Regenerate the static Go registry (metastatic/meta_data_gen.go) from
|
||||
meta_data.json. Run after every fetch so any caller that fetches also
|
||||
produces the sole build-time source of the embedded command tree — no build
|
||||
tag, no JSON embedded in the binary. Output is gitignored."""
|
||||
print("fetch-meta: generating static Go registry (metastatic/meta_data_gen.go)", file=sys.stderr)
|
||||
subprocess.run(
|
||||
["go", "run", "internal/registry/metastatic/gen.go"],
|
||||
cwd=ROOT,
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Fetch meta_data.json for build-time embedding")
|
||||
parser.add_argument("--brand", default="feishu", choices=["feishu", "lark"],
|
||||
@@ -71,27 +84,29 @@ def main():
|
||||
help="force refresh from remote even if local file exists")
|
||||
args = parser.parse_args()
|
||||
|
||||
if os.path.exists(OUT_PATH) and not args.force:
|
||||
if os.path.isfile(OUT_PATH):
|
||||
try:
|
||||
with open(OUT_PATH, "r", encoding="utf-8") as fp:
|
||||
local = json.load(fp)
|
||||
if local.get("services"):
|
||||
print(f"fetch-meta: {OUT_PATH} already exists, skipping (use --force to re-fetch)", file=sys.stderr)
|
||||
return
|
||||
print(f"fetch-meta: {OUT_PATH} has no services, re-fetching", file=sys.stderr)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
print(f"fetch-meta: {OUT_PATH} is invalid JSON, re-fetching", file=sys.stderr)
|
||||
else:
|
||||
print(f"fetch-meta: {OUT_PATH} is not a file, re-fetching", file=sys.stderr)
|
||||
have_valid = False
|
||||
if os.path.isfile(OUT_PATH) and not args.force:
|
||||
try:
|
||||
with open(OUT_PATH, "r", encoding="utf-8") as fp:
|
||||
local = json.load(fp)
|
||||
have_valid = bool(local.get("services"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
have_valid = False
|
||||
|
||||
data = fetch_remote(args.brand)
|
||||
count = len(data.get("services", []))
|
||||
print(f"fetch-meta: OK, {count} services from remote API", file=sys.stderr)
|
||||
if have_valid:
|
||||
print(f"fetch-meta: {OUT_PATH} already exists, skipping fetch (use --force to re-fetch)", file=sys.stderr)
|
||||
else:
|
||||
data = fetch_remote(args.brand)
|
||||
count = len(data.get("services", []))
|
||||
print(f"fetch-meta: OK, {count} services from remote API", file=sys.stderr)
|
||||
with open(OUT_PATH, "w") as fp:
|
||||
json.dump(data, fp, ensure_ascii=False, indent=2)
|
||||
fp.write("\n")
|
||||
|
||||
with open(OUT_PATH, "w") as fp:
|
||||
json.dump(data, fp, ensure_ascii=False, indent=2)
|
||||
fp.write("\n")
|
||||
# Always (re)generate the static Go registry so every fetch also produces
|
||||
# the embedded command tree — the build-time replacement for the old
|
||||
# embedded meta_data.json.
|
||||
run_gen()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -97,11 +97,13 @@ func RegisterShortcuts(program *cobra.Command, f *cmdutil.Factory) {
|
||||
}
|
||||
|
||||
func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f *cmdutil.Factory) {
|
||||
// Factory.Config may be nil in tests that pass a zero-value factory.
|
||||
// Brand only — never decrypt the app secret at registration time (avoids a
|
||||
// keychain read on every invocation). ConfigBrand may be nil in tests that
|
||||
// pass a zero-value factory.
|
||||
var brand core.LarkBrand
|
||||
if f != nil && f.Config != nil {
|
||||
if cfg, err := f.Config(); err == nil && cfg != nil {
|
||||
brand = cfg.Brand
|
||||
if f != nil && f.ConfigBrand != nil {
|
||||
if b, ok := f.ConfigBrand(); ok {
|
||||
brand = b
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@ func newFactoryWithBrand(brand core.LarkBrand) *cmdutil.Factory {
|
||||
Config: func() (*core.CliConfig, error) {
|
||||
return &core.CliConfig{Brand: brand}, nil
|
||||
},
|
||||
// Registration reads the brand via ConfigBrand (no secret decryption).
|
||||
ConfigBrand: func() (core.LarkBrand, bool) {
|
||||
return brand, true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user