mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 10:08:02 +08:00
Compare commits
6 Commits
feat/batch
...
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/
|
.hammer/
|
||||||
.lark-slides/
|
.lark-slides/
|
||||||
internal/registry/meta_data.json
|
internal/registry/meta_data.json
|
||||||
|
internal/registry/metastatic/meta_data_gen.go
|
||||||
cmd/api/download.bin
|
cmd/api/download.bin
|
||||||
app.log
|
app.log
|
||||||
/sidecar-server-demo
|
/sidecar-server-demo
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ version: 2
|
|||||||
|
|
||||||
before:
|
before:
|
||||||
hooks:
|
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
|
- python3 scripts/fetch_meta.py
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
|
|||||||
3
Makefile
3
Makefile
@@ -12,6 +12,9 @@ PREFIX ?= /usr/local
|
|||||||
|
|
||||||
all: test
|
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:
|
fetch_meta:
|
||||||
python3 scripts/fetch_meta.py
|
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().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")
|
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
|
var helpBrand core.LarkBrand
|
||||||
if f != nil && f.Config != nil {
|
if f != nil && f.ConfigBrand != nil {
|
||||||
if cfg, err := f.Config(); err == nil && cfg != nil {
|
if b, ok := f.ConfigBrand(); ok {
|
||||||
helpBrand = cfg.Brand
|
helpBrand = b
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
available := sortedKnownDomains(helpBrand)
|
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/errclass"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/internal/registry"
|
"github.com/larksuite/cli/internal/registry"
|
||||||
|
"github.com/larksuite/cli/internal/registry/metaschema"
|
||||||
"github.com/larksuite/cli/internal/util"
|
"github.com/larksuite/cli/internal/util"
|
||||||
"github.com/larksuite/cli/internal/validate"
|
"github.com/larksuite/cli/internal/validate"
|
||||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
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) {
|
func RegisterServiceCommandsWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
|
||||||
for _, project := range registry.ListFromMetaProjects() {
|
for _, spec := range registry.TypedServices() {
|
||||||
spec := registry.LoadFromMeta(project)
|
if spec.Name == "" || spec.ServicePath == "" || len(spec.Resources) == 0 {
|
||||||
if spec == nil {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
specName := registry.GetStrFromMap(spec, "name")
|
registerServiceWithContext(ctx, parent, spec, f)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerService(parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
|
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) {
|
func registerServiceWithContext(ctx context.Context, parent *cobra.Command, spec metaschema.Service, f *cmdutil.Factory) {
|
||||||
specName := registry.GetStrFromMap(spec, "name")
|
specDesc := registry.GetServiceDescription(spec.Name, "en")
|
||||||
specDesc := registry.GetServiceDescription(specName, "en")
|
|
||||||
if specDesc == "" {
|
if specDesc == "" {
|
||||||
specDesc = registry.GetStrFromMap(spec, "description")
|
specDesc = spec.Description
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find existing service command or create one
|
// Find existing service command or create one
|
||||||
var svc *cobra.Command
|
var svc *cobra.Command
|
||||||
for _, c := range parent.Commands() {
|
for _, c := range parent.Commands() {
|
||||||
if c.Name() == specName {
|
if c.Name() == spec.Name {
|
||||||
svc = c
|
svc = c
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if svc == nil {
|
if svc == nil {
|
||||||
svc = &cobra.Command{
|
svc = &cobra.Command{
|
||||||
Use: specName,
|
Use: spec.Name,
|
||||||
Short: specDesc,
|
Short: specDesc,
|
||||||
}
|
}
|
||||||
parent.AddCommand(svc)
|
parent.AddCommand(svc)
|
||||||
}
|
}
|
||||||
|
|
||||||
for resName, resource := range resources {
|
for _, resource := range spec.Resources {
|
||||||
resMap, _ := resource.(map[string]interface{})
|
registerResourceWithContext(ctx, svc, spec, resource, f)
|
||||||
if resMap == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
registerResourceWithContext(ctx, svc, spec, resName, resMap, 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{
|
res := &cobra.Command{
|
||||||
Use: name,
|
Use: resource.Name,
|
||||||
Short: name + " operations",
|
Short: resource.Name + " operations",
|
||||||
}
|
}
|
||||||
parent.AddCommand(res)
|
parent.AddCommand(res)
|
||||||
|
|
||||||
methods, _ := resource["methods"].(map[string]interface{})
|
for _, method := range resource.Methods {
|
||||||
for methodName, method := range methods {
|
registerMethodWithContext(ctx, res, spec, method, method.Name, resource.Name, f)
|
||||||
methodMap, _ := method.(map[string]interface{})
|
|
||||||
if methodMap == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
registerMethodWithContext(ctx, res, spec, methodMap, methodName, name, f)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,31 +108,36 @@ type ServiceMethodOptions struct {
|
|||||||
FileFields []string // auto-detected file field names from metadata
|
FileFields []string // auto-detected file field names from metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectFileFields delegates to the shared cmdutil.DetectFileFields helper.
|
// detectFileFieldsTyped returns the names of file-type fields in the method's
|
||||||
func detectFileFields(method map[string]interface{}) []string {
|
// request body (used to decide whether to register --file).
|
||||||
return cmdutil.DetectFileFields(method)
|
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))
|
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 {
|
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 {
|
func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spec metaschema.Service, method metaschema.Method, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
|
||||||
desc := registry.GetStrFromMap(method, "description")
|
desc := method.Description
|
||||||
httpMethod := registry.GetStrFromMap(method, "httpMethod")
|
httpMethod := method.HTTPMethod
|
||||||
risk := registry.GetStrFromMap(method, "risk")
|
risk := method.Risk
|
||||||
specName := registry.GetStrFromMap(spec, "name")
|
schemaPath := fmt.Sprintf("%s.%s.%s", spec.Name, resName, name)
|
||||||
schemaPath := fmt.Sprintf("%s.%s.%s", specName, resName, name)
|
|
||||||
|
|
||||||
opts := &ServiceMethodOptions{
|
opts := &ServiceMethodOptions{
|
||||||
Factory: f,
|
Factory: f,
|
||||||
Spec: spec,
|
|
||||||
Method: method,
|
|
||||||
SchemaPath: schemaPath,
|
SchemaPath: schemaPath,
|
||||||
}
|
}
|
||||||
var asStr string
|
var asStr string
|
||||||
@@ -159,6 +147,10 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
|
|||||||
Short: desc,
|
Short: desc,
|
||||||
Long: fmt.Sprintf("%s\n\nView parameter definitions before calling:\n lark-cli schema %s", desc, schemaPath),
|
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 {
|
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.Cmd = cmd
|
||||||
opts.Ctx = cmd.Context()
|
opts.Ctx = cmd.Context()
|
||||||
opts.As = core.Identity(asStr)
|
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.
|
// Conditionally register --file for methods with file-type fields.
|
||||||
fileFields := detectFileFields(method)
|
fileFields := detectFileFieldsTyped(method)
|
||||||
opts.FileFields = fileFields
|
opts.FileFields = fileFields
|
||||||
if len(fileFields) > 0 {
|
if len(fileFields) > 0 {
|
||||||
switch httpMethod {
|
switch httpMethod {
|
||||||
@@ -200,10 +192,15 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
|
|||||||
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
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)
|
cmdutil.SetRisk(cmd, risk)
|
||||||
if tokens, ok := method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
|
if len(method.AccessTokens) > 0 {
|
||||||
cmdutil.SetSupportedIdentities(cmd, cmdutil.AccessTokensToIdentities(tokens))
|
toks := make([]interface{}, len(method.AccessTokens))
|
||||||
|
for i, t := range method.AccessTokens {
|
||||||
|
toks[i] = t
|
||||||
|
}
|
||||||
|
cmdutil.SetSupportedIdentities(cmd, cmdutil.AccessTokensToIdentities(toks))
|
||||||
}
|
}
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/httpmock"
|
"github.com/larksuite/cli/internal/httpmock"
|
||||||
|
"github.com/larksuite/cli/internal/registry"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -752,7 +753,7 @@ func TestDetectFileFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got := detectFileFields(tt.method)
|
got := detectFileFieldsTyped(registry.MapToMethod("", tt.method))
|
||||||
if len(got) != len(tt.want) {
|
if len(got) != len(tt.want) {
|
||||||
t.Errorf("detectFileFields() = %v, want %v", got, tt.want)
|
t.Errorf("detectFileFields() = %v, want %v", got, tt.want)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -30,10 +30,11 @@ type InvocationContext struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Factory struct {
|
type Factory struct {
|
||||||
Config func() (*core.CliConfig, error) // lazily loads app config from Credential
|
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)
|
ConfigBrand func() (core.LarkBrand, bool) // brand only, no secret decryption — for startup help/registration (avoids keychain)
|
||||||
LarkClient func() (*lark.Client, error) // Lark SDK client for all Open API calls
|
HttpClient func() (*http.Client, error) // HTTP client for non-Lark API calls (with retry and security headers)
|
||||||
IOStreams *IOStreams // stdin/stdout/stderr streams
|
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.
|
Invocation InvocationContext // Immutable call context; do not mutate after Factory construction.
|
||||||
Keychain keychain.KeychainAccess // secret storage (real keychain in prod, mock in tests)
|
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 {
|
if f.Credential == nil {
|
||||||
return core.StrictModeOff
|
return core.StrictModeOff
|
||||||
}
|
}
|
||||||
acct, err := f.Credential.ResolveAccount(ctx)
|
// Strict mode is plain config metadata; resolve it WITHOUT decrypting the
|
||||||
if err != nil || acct == nil {
|
// 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
|
return core.StrictModeOff
|
||||||
}
|
}
|
||||||
ids := extcred.IdentitySupport(acct.SupportedIdentities)
|
ids := extcred.IdentitySupport(supported)
|
||||||
switch {
|
switch {
|
||||||
case ids.BotOnly():
|
case ids.BotOnly():
|
||||||
return core.StrictModeBot
|
return core.StrictModeBot
|
||||||
|
|||||||
@@ -78,6 +78,18 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
|||||||
return cfg, nil
|
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)
|
// Phase 4: LarkClient from Credential (placeholder AppSecret)
|
||||||
f.LarkClient = cachedLarkClientFunc(f)
|
f.LarkClient = cachedLarkClientFunc(f)
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,13 @@ func TestFactory(t *testing.T, config *core.CliConfig) (*Factory, *bytes.Buffer,
|
|||||||
)
|
)
|
||||||
|
|
||||||
f := &Factory{
|
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 },
|
HttpClient: func() (*http.Client, error) { return mockClient, nil },
|
||||||
LarkClient: func() (*lark.Client, error) { return testLarkClient, nil },
|
LarkClient: func() (*lark.Client, error) { return testLarkClient, nil },
|
||||||
IOStreams: &IOStreams{In: nil, Out: stdoutBuf, ErrOut: stderrBuf},
|
IOStreams: &IOStreams{In: nil, Out: stdoutBuf, ErrOut: stderrBuf},
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ type DefaultAccountResolver interface {
|
|||||||
ResolveAccount(ctx context.Context) (*Account, error)
|
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.
|
// DefaultTokenResolver is implemented by the default token provider.
|
||||||
type DefaultTokenResolver interface {
|
type DefaultTokenResolver interface {
|
||||||
ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error)
|
ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error)
|
||||||
@@ -141,6 +149,11 @@ type CredentialProvider struct {
|
|||||||
accountErr error
|
accountErr error
|
||||||
selectedSource credentialSource
|
selectedSource credentialSource
|
||||||
|
|
||||||
|
metaOnce sync.Once
|
||||||
|
metaBrand core.LarkBrand
|
||||||
|
metaIdents uint8
|
||||||
|
metaOK bool
|
||||||
|
|
||||||
hintOnce sync.Once
|
hintOnce sync.Once
|
||||||
hint *IdentityHint
|
hint *IdentityHint
|
||||||
hintErr error
|
hintErr error
|
||||||
@@ -172,6 +185,44 @@ func (p *CredentialProvider) ResolveAccount(ctx context.Context) (*Account, erro
|
|||||||
return p.account, p.accountErr
|
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) {
|
func (p *CredentialProvider) doResolveAccount(ctx context.Context) (*Account, error) {
|
||||||
for _, prov := range p.providers {
|
for _, prov := range p.providers {
|
||||||
acct, err := prov.ResolveAccount(ctx)
|
acct, err := prov.ResolveAccount(ctx)
|
||||||
|
|||||||
@@ -76,6 +76,23 @@ func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account,
|
|||||||
return AccountFromCliConfig(cfg), nil
|
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
|
// strictModeToIdentitySupport maps the config-level strict mode to
|
||||||
// the SupportedIdentities bitflag using an already-loaded MultiAppConfig.
|
// the SupportedIdentities bitflag using an already-loaded MultiAppConfig.
|
||||||
func strictModeToIdentitySupport(multi *core.MultiAppConfig, profileOverride string) uint8 {
|
func strictModeToIdentitySupport(multi *core.MultiAppConfig, profileOverride string) uint8 {
|
||||||
|
|||||||
@@ -19,72 +19,32 @@ import (
|
|||||||
//go:embed scope_priorities.json scope_overrides.json
|
//go:embed scope_priorities.json scope_overrides.json
|
||||||
var registryFS embed.FS
|
var registryFS embed.FS
|
||||||
|
|
||||||
// embeddedMetaJSON is set by loader_embedded.go when meta_data.json is compiled in.
|
// EmbeddedSpec returns the embedded baseline spec for one service as a map, or
|
||||||
var embeddedMetaJSON []byte
|
// nil if the service is unknown. It reads the static compile-time registry
|
||||||
|
// (metastatic.Registry) and bypasses the remote overlay, so envelope output is
|
||||||
// EmbeddedMetaJSON returns the raw embedded meta_data.json bytes for callers
|
// deterministic across machines.
|
||||||
// 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.
|
|
||||||
func EmbeddedSpec(serviceName string) map[string]interface{} {
|
func EmbeddedSpec(serviceName string) map[string]interface{} {
|
||||||
parseEmbeddedServices()
|
if svc, ok := baselineServiceByName(serviceName); ok {
|
||||||
return embeddedServicesMap[serviceName]
|
return ServiceToMap(svc)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmbeddedServiceNames returns sorted embedded service names (no overlay).
|
// EmbeddedServiceNames returns the embedded baseline service names, sorted
|
||||||
// Returns a defensive copy — callers must not mutate the package-level slice.
|
// (no remote overlay).
|
||||||
func EmbeddedServiceNames() []string {
|
func EmbeddedServiceNames() []string {
|
||||||
parseEmbeddedServices()
|
svcs := baselineServices()
|
||||||
out := make([]string, len(embeddedServiceNames))
|
out := make([]string, 0, len(svcs))
|
||||||
copy(out, embeddedServiceNames)
|
for _, s := range svcs {
|
||||||
|
out = append(out, s.Name)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
mergedServices = make(map[string]map[string]interface{}) // project name → parsed spec
|
embeddedVersion string // baseline data version (from the static registry)
|
||||||
mergedProjectList []string // sorted project names
|
initOnce sync.Once
|
||||||
embeddedVersion string // version from embedded meta_data.json
|
|
||||||
initOnce sync.Once
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Init initializes the registry with default brand (feishu).
|
// Init initializes the registry with default brand (feishu).
|
||||||
@@ -101,55 +61,27 @@ func Init() {
|
|||||||
func InitWithBrand(brand core.LarkBrand) {
|
func InitWithBrand(brand core.LarkBrand) {
|
||||||
initOnce.Do(func() {
|
initOnce.Do(func() {
|
||||||
configuredBrand = brand
|
configuredBrand = brand
|
||||||
// 1. Load embedded meta_data.json as baseline (no-op if not compiled in)
|
// 1. Baseline version: the static compile-time registry (metastatic).
|
||||||
loadEmbeddedIntoMerged()
|
embeddedVersion = baselineVersion()
|
||||||
// 2. Remote overlay
|
// 2. Remote overlay — still fetched/refreshed at runtime, decoded into
|
||||||
|
// the same typed shape and merged over the baseline.
|
||||||
if remoteEnabled() && cacheWritable() {
|
if remoteEnabled() && cacheWritable() {
|
||||||
// Check if brand changed since last cache
|
|
||||||
meta, metaErr := loadCacheMeta()
|
meta, metaErr := loadCacheMeta()
|
||||||
brandChanged := metaErr == nil && meta.Brand != "" && meta.Brand != string(brand)
|
brandChanged := metaErr == nil && meta.Brand != "" && meta.Brand != string(brand)
|
||||||
|
|
||||||
if !brandChanged {
|
if !brandChanged {
|
||||||
if cached, err := loadCachedMerged(); err == nil {
|
_ = loadCachedTyped()
|
||||||
overlayMergedServices(cached)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if len(mergedServices) == 0 || brandChanged {
|
if !hasTypedData() || brandChanged {
|
||||||
// No data at all or brand changed — must sync fetch
|
// No data at all (e.g. stub build, no cache) or brand changed.
|
||||||
doSyncFetch()
|
doSyncFetch()
|
||||||
} else if shouldRefresh(meta) || metaErr != nil {
|
} else if shouldRefresh(meta) || metaErr != nil {
|
||||||
// Have embedded/cached data; refresh in background if TTL expired or first run
|
|
||||||
triggerBackgroundRefresh()
|
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
|
var cachedAllScopes map[string][]string
|
||||||
|
|
||||||
// CollectAllScopesFromMeta collects all unique scopes from from_meta/*.json
|
// 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).
|
// It returns data from the merged registry (embedded + cached remote overlay).
|
||||||
func LoadFromMeta(project string) map[string]interface{} {
|
func LoadFromMeta(project string) map[string]interface{} {
|
||||||
Init()
|
Init()
|
||||||
return mergedServices[project]
|
svc, ok := typedServiceByName(project)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ServiceToMap(svc)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListFromMetaProjects lists available service project names (sorted).
|
// ListFromMetaProjects lists available service project names (sorted).
|
||||||
@@ -234,7 +170,7 @@ func LoadFromMeta(project string) map[string]interface{} {
|
|||||||
//go:noinline
|
//go:noinline
|
||||||
func ListFromMetaProjects() []string {
|
func ListFromMetaProjects() []string {
|
||||||
Init()
|
Init()
|
||||||
return mergedProjectList
|
return typedServiceNames()
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultScopeScore is the score assigned to scopes not in the priorities table.
|
// 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)
|
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 {
|
func saveCachedMerged(data []byte, meta CacheMeta) error {
|
||||||
if err := vfs.MkdirAll(cacheDir(), 0700); err != nil {
|
if err := vfs.MkdirAll(cacheDir(), 0700); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -253,7 +237,7 @@ func doSyncFetch() {
|
|||||||
Brand: string(configuredBrand),
|
Brand: string(configuredBrand),
|
||||||
}
|
}
|
||||||
_ = saveCachedMerged(data, meta)
|
_ = saveCachedMerged(data, meta)
|
||||||
overlayMergedServices(reg)
|
_ = loadCachedTyped()
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- background refresh ---
|
// --- background refresh ---
|
||||||
@@ -308,15 +292,3 @@ func shouldRefresh(meta CacheMeta) bool {
|
|||||||
}
|
}
|
||||||
return time.Since(time.Unix(meta.LastCheckAt, 0)) > metaTTL()
|
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"
|
"time"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/core"
|
"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
|
// 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).
|
// reads globals this function mutates (see CI race: TestComputeMinimumScopeSet → Tenant).
|
||||||
waitBackgroundRefresh()
|
waitBackgroundRefresh()
|
||||||
initOnce = sync.Once{}
|
initOnce = sync.Once{}
|
||||||
mergedServices = make(map[string]map[string]interface{})
|
resetTyped()
|
||||||
mergedProjectList = nil
|
|
||||||
embeddedVersion = ""
|
embeddedVersion = ""
|
||||||
cachedAllScopes = nil
|
cachedAllScopes = nil
|
||||||
cachedScopePriorities = 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 {
|
func hasEmbeddedServices() bool {
|
||||||
if len(embeddedMetaJSON) == 0 {
|
return len(metastatic.Registry.Services) > 0
|
||||||
return false
|
|
||||||
}
|
|
||||||
var reg MergedRegistry
|
|
||||||
if err := json.Unmarshal(embeddedMetaJSON, ®); err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return len(reg.Services) > 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// testRegistry returns a minimal MergedRegistry with one service.
|
// 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()
|
resetInit()
|
||||||
mergedServices = make(map[string]map[string]interface{})
|
setRemoteOverrides([]metaschema.Service{
|
||||||
mergedServices["existing"] = map[string]interface{}{"name": "existing", "version": "v1"}
|
{Name: "existing", Version: "v2"},
|
||||||
|
{Name: "brand_new", Version: "v1"},
|
||||||
|
})
|
||||||
|
|
||||||
reg := &MergedRegistry{
|
// override present
|
||||||
Services: []map[string]interface{}{
|
if s, ok := typedServiceByName("existing"); !ok || s.Version != "v2" {
|
||||||
{"name": "existing", "version": "v2"},
|
t.Errorf("expected existing override v2, got %+v ok=%v", s, ok)
|
||||||
{"name": "brand_new", "version": "v1"},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
overlayMergedServices(reg)
|
// new service added
|
||||||
|
if _, ok := typedServiceByName("brand_new"); !ok {
|
||||||
// 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 {
|
|
||||||
t.Error("expected brand_new to be added")
|
t.Error("expected brand_new to be added")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOverlayMergedServicesDoesNotPolluteFollowingInit(t *testing.T) {
|
func TestRemoteOverlayDoesNotPolluteFollowingInit(t *testing.T) {
|
||||||
resetInit()
|
resetInit()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
|
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
|
||||||
|
|
||||||
const leakedExisting = "test_isolation_existing_sentinel"
|
const leaked = "test_isolation_overlay_sentinel"
|
||||||
const leakedOverlay = "test_isolation_overlay_sentinel"
|
setRemoteOverrides([]metaschema.Service{{Name: leaked, Version: "v1"}})
|
||||||
|
|
||||||
mergedServices = map[string]map[string]interface{}{
|
|
||||||
leakedExisting: {"name": leakedExisting, "version": "v1"},
|
|
||||||
}
|
|
||||||
overlayMergedServices(&MergedRegistry{Services: []map[string]interface{}{{"name": leakedOverlay, "version": "v1"}}})
|
|
||||||
|
|
||||||
resetInit()
|
resetInit()
|
||||||
Init()
|
Init()
|
||||||
|
|
||||||
if spec := LoadFromMeta(leakedExisting); spec != nil {
|
if spec := LoadFromMeta(leaked); spec != nil {
|
||||||
t.Fatalf("polluted service %q survived resetInit", leakedExisting)
|
t.Fatalf("polluted service %q survived resetInit", leaked)
|
||||||
}
|
|
||||||
if spec := LoadFromMeta(leakedOverlay); spec != nil {
|
|
||||||
t.Fatalf("polluted service %q survived resetInit", leakedOverlay)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,8 +406,8 @@ func TestCorruptedCache_SelfHeals(t *testing.T) {
|
|||||||
metaData, _ := json.Marshal(meta)
|
metaData, _ := json.Marshal(meta)
|
||||||
os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), metaData, 0644)
|
os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), metaData, 0644)
|
||||||
|
|
||||||
// loadCachedMerged should fail and remove the corrupted files
|
// loadCachedTyped should fail and remove the corrupted files
|
||||||
_, err := loadCachedMerged()
|
err := loadCachedTyped()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for corrupted cache")
|
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
|
package schema
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/registry"
|
"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
|
// coerceLiteral converts a meta_data literal (default / enum / example) to
|
||||||
// the JSON Schema type declared by the field (integer/number/boolean/string).
|
// the JSON Schema type declared by the field (integer/number/boolean/string).
|
||||||
// meta_data stores every literal as a string, so without coercion an
|
// 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
|
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
|
// 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,
|
// entry into a typed *Affordance. Returns nil when the field is absent, malformed,
|
||||||
// or carries no populated subfields.
|
// 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:
|
// The params / data wrapping mirrors the CLI's actual flag layout:
|
||||||
// path+query → --params JSON, body → --data JSON, file → --file. AI consumers
|
// path+query → --params JSON, body → --data JSON, file → --file. AI consumers
|
||||||
// can pluck inputSchema.properties.params and pass it verbatim to --params.
|
// 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 {
|
func buildInputSchema(method map[string]interface{}) *InputSchema {
|
||||||
is := &InputSchema{
|
is := &InputSchema{
|
||||||
Type: "object",
|
Type: "object",
|
||||||
@@ -738,27 +456,11 @@ func buildOutputSchema(method map[string]interface{}) *OutputSchema {
|
|||||||
return os
|
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 /
|
// AssembleEnvelope is the main entry point: takes a service / resource path /
|
||||||
// method name plus its meta_data spec, and produces a fully assembled MCP
|
// method name plus its meta_data spec, and produces a fully assembled MCP
|
||||||
// envelope. Output is fully determined by inputs (same arguments → same
|
// envelope. Output is fully determined by inputs (same arguments → same
|
||||||
// envelope), but assembly briefly publishes the per-method key-order context
|
// envelope).
|
||||||
// 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.
|
|
||||||
func AssembleEnvelope(serviceName string, resourcePath []string, methodName string, method map[string]interface{}) 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
|
name := serviceName
|
||||||
for _, r := range resourcePath {
|
for _, r := range resourcePath {
|
||||||
name += " " + r
|
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
|
// orderedKeys returns the keys of raw in alphabetical order. Field display
|
||||||
// the current per-method key-order context has them recorded; otherwise
|
// order is not preserved: the schema envelope is consumed as a JSON Schema (MCP
|
||||||
// alphabetical fallback.
|
// tool spec), where object property order carries no meaning.
|
||||||
func orderedKeys(raw map[string]interface{}, nestedPath string) []string {
|
func orderedKeys(raw map[string]interface{}, _ 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
|
|
||||||
keys := make([]string, 0, len(raw))
|
keys := make([]string, 0, len(raw))
|
||||||
for k := range raw {
|
for k := range raw {
|
||||||
keys = append(keys, k)
|
keys = append(keys, k)
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/registry"
|
"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
|
// TestMain isolates registry-backed tests from any host ~/.lark-cli cache so
|
||||||
@@ -35,58 +37,6 @@ func TestMain(m *testing.M) {
|
|||||||
os.Exit(code)
|
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) {
|
func TestConvertProperty_BasicTypes(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -288,9 +238,6 @@ func TestConvertProperty_DescriptionDefaultExample(t *testing.T) {
|
|||||||
|
|
||||||
func TestBuildInputSchema_ReactionsList(t *testing.T) {
|
func TestBuildInputSchema_ReactionsList(t *testing.T) {
|
||||||
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
|
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
|
||||||
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
|
|
||||||
currentMethodOrder = mko
|
|
||||||
defer func() { currentMethodOrder = nil }()
|
|
||||||
|
|
||||||
is := buildInputSchema(method)
|
is := buildInputSchema(method)
|
||||||
|
|
||||||
@@ -313,16 +260,15 @@ func TestBuildInputSchema_ReactionsList(t *testing.T) {
|
|||||||
if !reflect.DeepEqual(params.Required, []string{"message_id"}) {
|
if !reflect.DeepEqual(params.Required, []string{"message_id"}) {
|
||||||
t.Errorf("params.Required = %v, want [message_id]", params.Required)
|
t.Errorf("params.Required = %v, want [message_id]", params.Required)
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(params.Properties.Order, mko.Parameters) {
|
// Property order is alphabetical now: the envelope is a JSON Schema (MCP
|
||||||
t.Errorf("params.properties order = %v, want (from key index) %v",
|
// tool spec) where object property order carries no meaning.
|
||||||
params.Properties.Order, mko.Parameters)
|
if !sort.StringsAreSorted(params.Properties.Order) {
|
||||||
|
t.Errorf("params.properties order not alphabetical: %v", params.Properties.Order)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildInputSchema_ImagesCreate_FileAndBody(t *testing.T) {
|
func TestBuildInputSchema_ImagesCreate_FileAndBody(t *testing.T) {
|
||||||
method := loadMethodFromRegistry(t, "im", []string{"images"}, "create")
|
method := loadMethodFromRegistry(t, "im", []string{"images"}, "create")
|
||||||
currentMethodOrder = lookupKeyOrder("im", []string{"images"}, "create")
|
|
||||||
defer func() { currentMethodOrder = nil }()
|
|
||||||
|
|
||||||
is := buildInputSchema(method)
|
is := buildInputSchema(method)
|
||||||
|
|
||||||
@@ -382,9 +328,6 @@ func TestBuildInputSchema_HighRiskWriteInjectsYes(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
currentMethodOrder = nil
|
|
||||||
defer func() { currentMethodOrder = nil }()
|
|
||||||
|
|
||||||
is := buildInputSchema(method)
|
is := buildInputSchema(method)
|
||||||
|
|
||||||
// yes lives at inputSchema.properties.yes (sibling of params/data)
|
// 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) {
|
func TestBuildInputSchema_NoYesForReadRisk(t *testing.T) {
|
||||||
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
|
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
|
||||||
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
|
|
||||||
currentMethodOrder = mko
|
|
||||||
defer func() { currentMethodOrder = nil }()
|
|
||||||
|
|
||||||
is := buildInputSchema(method)
|
is := buildInputSchema(method)
|
||||||
if _, ok := is.Properties.Map["yes"]; ok {
|
if _, ok := is.Properties.Map["yes"]; ok {
|
||||||
@@ -425,9 +365,6 @@ func TestBuildInputSchema_NoYesForReadRisk(t *testing.T) {
|
|||||||
|
|
||||||
func TestBuildOutputSchema_ReactionsList(t *testing.T) {
|
func TestBuildOutputSchema_ReactionsList(t *testing.T) {
|
||||||
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
|
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
|
||||||
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
|
|
||||||
currentMethodOrder = mko
|
|
||||||
defer func() { currentMethodOrder = nil }()
|
|
||||||
|
|
||||||
os := buildOutputSchema(method)
|
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) {
|
func TestBuildMeta_MissingDocURLOmitted(t *testing.T) {
|
||||||
method := map[string]interface{}{
|
method := map[string]interface{}{
|
||||||
"scopes": []interface{}{"x"},
|
"scopes": []interface{}{"x"},
|
||||||
@@ -634,7 +610,6 @@ func TestBuildMeta_MissingDocURLOmitted(t *testing.T) {
|
|||||||
func TestBuildOutputSchema_EmptyResponseBody(t *testing.T) {
|
func TestBuildOutputSchema_EmptyResponseBody(t *testing.T) {
|
||||||
// 装配器对空 responseBody 应生成 properties = {} (不 nil)
|
// 装配器对空 responseBody 应生成 properties = {} (不 nil)
|
||||||
method := map[string]interface{}{}
|
method := map[string]interface{}{}
|
||||||
currentMethodOrder = nil
|
|
||||||
os := buildOutputSchema(method)
|
os := buildOutputSchema(method)
|
||||||
if os.Type != "object" {
|
if os.Type != "object" {
|
||||||
t.Errorf("Type = %q, want \"object\"", os.Type)
|
t.Errorf("Type = %q, want \"object\"", os.Type)
|
||||||
|
|||||||
@@ -83,9 +83,13 @@ type AffordanceCase struct {
|
|||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OrderedProps is map[string]Property with preserved key order on MarshalJSON.
|
// OrderedProps is map[string]Property that emits its keys in Order on
|
||||||
// It is used wherever JSON output must reflect meta_data.json's natural field
|
// MarshalJSON. Order is now populated alphabetically (see orderedKeys): the
|
||||||
// order rather than Go's default alphabetical map encoding.
|
// 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 {
|
type OrderedProps struct {
|
||||||
Order []string
|
Order []string
|
||||||
Map map[string]Property
|
Map map[string]Property
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ OUT_DIR="$ROOT_DIR/.pkg-pr-new"
|
|||||||
|
|
||||||
cd "$ROOT_DIR"
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
# fetch_meta.py also regenerates the static Go registry (meta_data_gen.go).
|
||||||
python3 scripts/fetch_meta.py
|
python3 scripts/fetch_meta.py
|
||||||
|
|
||||||
rm -rf "$OUT_DIR"
|
rm -rf "$OUT_DIR"
|
||||||
|
|||||||
@@ -63,6 +63,19 @@ def fetch_remote(brand):
|
|||||||
return data
|
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():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="Fetch meta_data.json for build-time embedding")
|
parser = argparse.ArgumentParser(description="Fetch meta_data.json for build-time embedding")
|
||||||
parser.add_argument("--brand", default="feishu", choices=["feishu", "lark"],
|
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")
|
help="force refresh from remote even if local file exists")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if os.path.exists(OUT_PATH) and not args.force:
|
have_valid = False
|
||||||
if os.path.isfile(OUT_PATH):
|
if os.path.isfile(OUT_PATH) and not args.force:
|
||||||
try:
|
try:
|
||||||
with open(OUT_PATH, "r", encoding="utf-8") as fp:
|
with open(OUT_PATH, "r", encoding="utf-8") as fp:
|
||||||
local = json.load(fp)
|
local = json.load(fp)
|
||||||
if local.get("services"):
|
have_valid = bool(local.get("services"))
|
||||||
print(f"fetch-meta: {OUT_PATH} already exists, skipping (use --force to re-fetch)", file=sys.stderr)
|
except (OSError, json.JSONDecodeError):
|
||||||
return
|
have_valid = False
|
||||||
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)
|
|
||||||
|
|
||||||
data = fetch_remote(args.brand)
|
if have_valid:
|
||||||
count = len(data.get("services", []))
|
print(f"fetch-meta: {OUT_PATH} already exists, skipping fetch (use --force to re-fetch)", file=sys.stderr)
|
||||||
print(f"fetch-meta: OK, {count} services from remote API", 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:
|
# Always (re)generate the static Go registry so every fetch also produces
|
||||||
json.dump(data, fp, ensure_ascii=False, indent=2)
|
# the embedded command tree — the build-time replacement for the old
|
||||||
fp.write("\n")
|
# embedded meta_data.json.
|
||||||
|
run_gen()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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) {
|
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
|
var brand core.LarkBrand
|
||||||
if f != nil && f.Config != nil {
|
if f != nil && f.ConfigBrand != nil {
|
||||||
if cfg, err := f.Config(); err == nil && cfg != nil {
|
if b, ok := f.ConfigBrand(); ok {
|
||||||
brand = cfg.Brand
|
brand = b
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ func newFactoryWithBrand(brand core.LarkBrand) *cmdutil.Factory {
|
|||||||
Config: func() (*core.CliConfig, error) {
|
Config: func() (*core.CliConfig, error) {
|
||||||
return &core.CliConfig{Brand: brand}, nil
|
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