Compare commits

...

22 Commits

Author SHA1 Message Date
fangshuyu
6bc6d30eeb docs: mention markdown for lark-doc read understanding 2026-06-15 19:25:53 +08:00
search_zhuhao
72c294712c feat: 【larksuite/cli】【drive 搜索支持 original_creator_ids】 M-7074213537 (#1046)
sa: none

fg: none

cfg: none

doc: none

test: ppe
Change-Id: I88bedd02a5daa3307b05c9b6f94748e1544d279a
2026-06-15 14:18:45 +08:00
sammi-bytedance
37f4f899b2 docs(lark-im): document @mention format per message type (text/post/card) (#1419)
Split the send/reply @Mention sections by message type:
- text: <at user_id="ou_xxx">name</at> (inner name optional), @all
- post: inline form in text/md elements, or a dedicated {"tag":"at"} node
- interactive card: card-native <at id=>, <at ids=>, <at email=>
2026-06-15 14:08:50 +08:00
AlbertSun
c0730b46bf feat: simplify proxy plugin warning and gate on tty (#1448) 2026-06-13 20:32:16 +08:00
hhang
751092c8ef fix(vfs): reject Windows absolute paths cross-platform (#1401)
* fix(vfs): reject Windows absolute paths cross-platform

* test(vfs): cover input Windows absolute paths
2026-06-13 18:56:13 +08:00
liangshuo-1
deb0bd9dd6 refactor: converge command pipelines onto a typed metadata model + catalog (#1191) 2026-06-13 18:02:50 +08:00
raistlin042
0fbfe68726 docs: drop Miaoda brand word from apps command help text (#1399) 2026-06-13 14:00:30 +08:00
liangshuo-1
e1af7e3018 chore: release v1.0.53 (#1443)
]
2026-06-12 20:03:08 +08:00
bubbmon233
693e299589 docs(mail): clarify message read shortcuts (#1261)
* docs(mail): clarify message read shortcuts

Update mail read shortcut help, docs, and triage guidance so single-message and multi-message reads are routed to the right commands.

Add focused tests for help text, dry-run copy, triage stderr hints, and batch_get chunking behavior.

sprint: S1

* docs(mail): align batch_get limit with gateway config

* docs(mail): use shell-safe batch message id examples

* docs(mail): trim batch_get pagination wording

* docs(mail): use placeholder style for message ids

* docs(mail): hide batch_get internals from help
2026-06-12 19:52:36 +08:00
Yuxuan Zhao
69f335be7c test(calendar): drop flaky calendar list e2e checks (#1441) 2026-06-12 19:00:09 +08:00
JackZhao10086
d1a0926dd6 feat/revoke token (#1434) 2026-06-12 17:49:33 +08:00
syh-cpdsss
008bdda861 docs(whiteboard): optimize whiteboard skill (#1371)
* docs(whiteboard): optimize whiteboard skill

Change-Id: Iabcbe9f4e309ae9f467ceec265320cea6cdfa81b

* fix: PR issue

Change-Id: I96d99037b3ba74a3ea9964991b67cdf15fb985be
2026-06-12 17:46:55 +08:00
syh-cpdsss
f1da8c274b docs(okr): optimize okr skill (#1368)
Change-Id: I095a3a7a935e4f84459d1be24015f59cd9e324a6
2026-06-12 17:46:27 +08:00
AlbertSun
842be3fdc5 feat(token): mint TAT via unified OAuth v3 Token Endpoint (#1408) 2026-06-12 17:44:07 +08:00
raistlin042
1cd7a88597 fix: read release error_logs from data.error_logs in apps +release-get (#1436) 2026-06-12 16:58:47 +08:00
max
7c64e63b9d feat(note): clarify note ownership with dedicated detail and transcript flows (#1435)
* feat: split note domain

* fix: address note transcript review comments

* fix: stabilize empty note detail detection
2026-06-12 16:30:41 +08:00
luozhixiong01
8e60f01474 feat(im): unify sort flags into --sort field and --order direction (#1302)
The 4 im query commands had three inconsistent sort conventions and leaked upstream API jargon (ByCreateTimeAsc, member_count_desc) directly to users. This PR unifies them on a single rule — --sort selects a field, --order selects a direction, both from fixed enums — so an agent only ever picks from an enum, never constructs a string. Old flags (--sort-type, --sort-by, and --sort on messages/threads) are kept as hidden silent aliases (no deprecation warning), so existing scripts keep working byte-for-byte.
2026-06-12 15:27:54 +08:00
JackZhao10086
465c789f7c feat: add --json flag support to auth subcommands (#1431)
* feat: add --json flag support to auth subcommands

* feat(auth/logout): add json output support for logout command

* feat(auth/list): add json output support for auth list command
2026-06-12 15:04:14 +08:00
Yuxuan Zhao
2a7e9c7d0d test(drive): retry duplicate-remote push in live E2E (#1403) 2026-06-12 13:48:19 +08:00
liangshuo-1
76ba6fad4f chore: add CODEOWNERS for internal/ and new skills domains (#1420) 2026-06-12 11:19:25 +08:00
liangshuo-1
510545f1e5 refactor(vc): consolidate note handling back into the vc domain (#1417) 2026-06-12 00:44:35 +08:00
max
c11cf3b716 feat: split note domain (#1345)
Add note shortcuts for note detail and unified transcript retrieval, route vc note detail parsing through the note domain, and update note/vc/minutes skill guidance for normal versus unified transcript handling.

Includes dry-run E2E coverage for the new note shortcuts and documents the remaining live E2E fixture gap.
2026-06-11 22:38:29 +08:00
189 changed files with 9070 additions and 3783 deletions

30
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1,30 @@
/internal/ @liangshuo-1
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
/skills/ @liangshuo-1
/skills/lark-approval/
/skills/lark-apps/
/skills/lark-attendance/
/skills/lark-base/
/skills/lark-calendar/
/skills/lark-contact/
/skills/lark-doc/
/skills/lark-drive/
/skills/lark-event/
/skills/lark-im/
/skills/lark-mail/
/skills/lark-markdown/
/skills/lark-minutes/
/skills/lark-okr/
/skills/lark-openapi-explorer/
/skills/lark-shared/
/skills/lark-sheets/
/skills/lark-skill-maker/
/skills/lark-slides/
/skills/lark-task/
/skills/lark-vc/
/skills/lark-vc-agent/
/skills/lark-whiteboard/
/skills/lark-wiki/
/skills/lark-workflow-meeting-summary/
/skills/lark-workflow-standup-report/

2
.gitignore vendored
View File

@@ -35,6 +35,8 @@ tests/mail/reports/
# Generated / test artifacts
.hammer/
.lark-slides/
/notes/
/minutes/
internal/registry/meta_data.json
cmd/api/download.bin
app.log

View File

@@ -2,6 +2,25 @@
All notable changes to this project will be documented in this file.
## [v1.0.53] - 2026-06-12
### Features
- **auth**: Revoke user tokens server-side on `auth logout` (#1434)
- **auth**: Add `--json` flag support to auth subcommands (#1431)
- **token**: Mint TAT via unified OAuth v3 Token Endpoint (#1408)
- **note**: Split note into a dedicated domain with `+detail` and `+transcript` flows (#1345, #1417, #1435)
- **im**: Unify sort flags into `--sort` field and `--order` direction (#1302)
### Bug Fixes
- **apps**: Read release error_logs from `data.error_logs` in `+release-get` (#1436)
### Documentation
- **skills**: Optimize whiteboard skill (#1371)
- **skills**: Optimize okr skill (#1368)
## [v1.0.52] - 2026-06-11
### Features
@@ -1130,6 +1149,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.53]: https://github.com/larksuite/cli/releases/tag/v1.0.53
[v1.0.52]: https://github.com/larksuite/cli/releases/tag/v1.0.52
[v1.0.51]: https://github.com/larksuite/cli/releases/tag/v1.0.51
[v1.0.50]: https://github.com/larksuite/cli/releases/tag/v1.0.50

View File

@@ -66,6 +66,24 @@ func TestApiCmd_DryRun(t *testing.T) {
}
}
// Regression: --params null parses to a nil map; writing page_size onto it must
// not panic. Symmetric to the typed-flag overlay path in cmd/service — both
// write into the map ParseJSONMap returns.
func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("--params null with --page-size should not error, got: %v", err)
}
if out := stdout.String(); !strings.Contains(out, "page_size") {
t.Errorf("expected page_size applied over null --params, got:\n%s", out)
}
}
func TestApiCmd_BotMode(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,

View File

@@ -91,6 +91,29 @@ func TestAuthCheckCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthCheckCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *CheckOptions
cmd := NewCmdAuthCheck(f, func(opts *CheckOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--scope", "calendar:calendar:read", "--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Fatal("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
@@ -109,6 +132,27 @@ func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthLogoutCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *LogoutOptions
cmd := NewCmdAuthLogout(f, func(opts *LogoutOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Fatal("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthListCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
@@ -126,6 +170,27 @@ func TestAuthListCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthListCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *ListOptions
cmd := NewCmdAuthList(f, func(opts *ListOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Error("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthStatusCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -145,6 +210,29 @@ func TestAuthStatusCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthStatusCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *StatusOptions
cmd := NewCmdAuthStatus(f, func(opts *StatusOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Error("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthStatusCmd_VerifyFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -267,6 +355,32 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *ScopesOptions
cmd := NewCmdAuthScopes(f, func(opts *ScopesOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--format", "pretty", "--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Fatal("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
if gotOpts.Format != "json" {
t.Errorf("expected format json, got %s", gotOpts.Format)
}
}
func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,

View File

@@ -19,6 +19,7 @@ import (
type CheckOptions struct {
Factory *cmdutil.Factory
Scope string
JSON bool
}
// NewCmdAuthCheck creates the auth check subcommand.
@@ -37,6 +38,7 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
}
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to check (space-separated)")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmd.MarkFlagRequired("scope")
cmdutil.SetRisk(cmd, "read")

View File

@@ -18,6 +18,7 @@ import (
// ListOptions holds all inputs for auth list.
type ListOptions struct {
Factory *cmdutil.Factory
JSON bool
}
// NewCmdAuthList creates the auth list subcommand.
@@ -34,6 +35,7 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co
return authListRun(opts)
},
}
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "read")
return cmd
@@ -44,6 +46,14 @@ func authListRun(opts *ListOptions) error {
multi, _ := core.LoadMultiAppConfig()
if multi == nil || len(multi.Apps) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"users": []map[string]interface{}{},
"reason": "not_configured",
})
return nil
}
// auth list is a read-only probe; the "configured but no users"
// branch below already returns exit 0 with a stderr hint, so we
// keep the same contract here. We still want the hint to be
@@ -61,6 +71,14 @@ func authListRun(opts *ListOptions) error {
app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil || len(app.Users) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"users": []map[string]interface{}{},
"reason": "not_logged_in",
})
return nil
}
fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.")
return nil
}

View File

@@ -4,6 +4,7 @@
package auth
import (
"encoding/json"
"strings"
"testing"
@@ -34,6 +35,33 @@ func TestAuthListRun_NotConfigured_ReturnsExitZero(t *testing.T) {
}
}
func TestAuthListRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("auth list should succeed when not configured (exit 0); got: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
users, ok := payload["users"].([]any)
if !ok || len(users) != 0 {
t.Errorf("stdout.users = %v, want empty array", payload["users"])
}
if payload["reason"] != "not_configured" {
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
// TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp covers the
// reason this hint exists workspace-aware in the first place: an AI agent
// in OpenClaw / Hermes that probes auth list before binding gets routed to
@@ -57,3 +85,48 @@ func TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp(t *testing.T)
t.Errorf("agent hint must not mention config init: %s", out)
}
}
func TestAuthListRun_JSONMode_NoLoggedInUsers_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, nil)
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
users, ok := payload["users"].([]any)
if !ok || len(users) != 0 {
t.Errorf("stdout.users = %v, want empty array", payload["users"])
}
if payload["reason"] != "not_logged_in" {
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthListRun_DefaultMode_NoLoggedInUsers_KeepsTextOutput(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, nil)
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authListRun(&ListOptions{Factory: f}); err != nil {
t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err)
}
if stdout.Len() != 0 {
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
}
if !strings.Contains(stderr.String(), "No logged-in users") {
t.Errorf("stderr = %q, want no-users hint", stderr.String())
}
}

View File

@@ -92,16 +92,11 @@ func buildDomainMeta(name, lang string) domainMeta {
Description: desc,
}
}
// Fallback: read from from_meta spec (legacy)
meta := registry.LoadFromMeta(name)
// Fallback: read from the typed service spec (legacy)
dm := domainMeta{Name: name}
if meta != nil {
if t, ok := meta["title"].(string); ok {
dm.Title = t
}
if d, ok := meta["description"].(string); ok {
dm.Description = d
}
if svc, ok := registry.ServiceTyped(name); ok {
dm.Title = svc.Title
dm.Description = svc.Description
}
return dm
}

View File

@@ -128,5 +128,5 @@ func getLoginMsg(lang i18n.Lang) *loginMsg {
// (not backed by from_meta service specs). Descriptions are now centralized in
// service_descriptions.json.
func getShortcutOnlyDomainNames() []string {
return []string{"base", "contact", "docs", "markdown", "apps"}
return []string{"base", "contact", "docs", "markdown", "apps", "note"}
}

View File

@@ -9,6 +9,7 @@ import (
"errors"
"io"
"net/http"
"slices"
"sort"
"strings"
"testing"
@@ -214,6 +215,12 @@ func TestGetShortcutOnlyDomainNames_HaveDescriptions(t *testing.T) {
}
}
func TestGetShortcutOnlyDomainNames_IncludesNote(t *testing.T) {
if !slices.Contains(getShortcutOnlyDomainNames(), "note") {
t.Fatal("shortcut-only domains must include note so auth login can select vc:note:read")
}
}
func TestCollectScopesForDomains(t *testing.T) {
projects := registry.ListFromMetaProjects()
if len(projects) == 0 {

View File

@@ -18,6 +18,7 @@ import (
// LogoutOptions holds all inputs for auth logout.
type LogoutOptions struct {
Factory *cmdutil.Factory
JSON bool
}
// NewCmdAuthLogout creates the auth logout subcommand.
@@ -34,6 +35,7 @@ func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobr
return authLogoutRun(opts)
},
}
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "write")
return cmd
@@ -44,25 +46,65 @@ func authLogoutRun(opts *LogoutOptions) error {
multi, _ := core.LoadMultiAppConfig()
if multi == nil || len(multi.Apps) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"loggedOut": false,
"reason": "not_configured",
})
return nil
}
fmt.Fprintln(f.IOStreams.ErrOut, "No configuration found.")
return nil
}
app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil || len(app.Users) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"loggedOut": false,
"reason": "not_logged_in",
})
return nil
}
fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.")
return nil
}
httpClient, httpErr := f.HttpClient()
appSecret, secretErr := core.ResolveSecretInput(app.AppSecret, f.Keychain)
for _, user := range app.Users {
if httpErr == nil && secretErr == nil {
if token := larkauth.GetStoredToken(app.AppId, user.UserOpenId); token != nil {
revokeToken := token.RefreshToken
tokenTypeHint := "refresh_token"
if revokeToken == "" {
revokeToken = token.AccessToken
tokenTypeHint = "access_token"
}
if revokeToken != "" {
_ = larkauth.RevokeToken(httpClient, app.AppId, appSecret, app.Brand, revokeToken, tokenTypeHint)
}
}
}
if err := larkauth.RemoveStoredToken(app.AppId, user.UserOpenId); err != nil {
fmt.Fprintf(f.IOStreams.ErrOut, "Warning: failed to remove token for %s: %v\n", user.UserOpenId, err)
}
}
app.Users = []core.AppUser{}
if err := core.SaveMultiAppConfig(multi); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"loggedOut": true,
})
return nil
}
output.PrintSuccess(f.IOStreams.ErrOut, "Logged out")
return nil
}

356
cmd/auth/logout_test.go Normal file
View File

@@ -0,0 +1,356 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/json"
"net/url"
"strings"
"testing"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/zalando/go-keyring"
)
func writeLogoutConfig(t *testing.T, users []core.AppUser) {
t.Helper()
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: "test-app",
Apps: []core.AppConfig{
{
AppId: "test-app",
AppSecret: core.PlainSecret("test-secret"),
Brand: core.BrandFeishu,
Users: users,
},
},
}); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
}
func TestAuthLogoutRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
if payload["loggedOut"] != false {
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
}
if payload["reason"] != "not_configured" {
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthLogoutRun_JSONMode_NotLoggedIn_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, nil)
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
if payload["loggedOut"] != false {
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
}
if payload["reason"] != "not_logged_in" {
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthLogoutRun_JSONMode_Success_WritesStdoutOnly(t *testing.T) {
keyring.MockInit()
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "test-app",
UserOpenId: "ou_user",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
if payload["loggedOut"] != true {
t.Errorf("stdout.loggedOut = %v, want true", payload["loggedOut"])
}
if _, hasReason := payload["reason"]; hasReason {
t.Errorf("stdout.reason must be absent on success, got %v", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthLogoutRun_DefaultMode_KeepsTextOutput(t *testing.T) {
keyring.MockInit()
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "test-app",
UserOpenId: "ou_user",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
if stdout.Len() != 0 {
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
}
if !strings.Contains(stderr.String(), "Logged out") {
t.Errorf("stderr = %q, want success text", stderr.String())
}
}
func TestAuthLogoutRun_RevokesTokenAndClearsLocalState(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "cli_test",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "cli_test",
UserOpenId: "ou_user",
AccessToken: "user-access-token",
RefreshToken: "user-refresh-token",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathOAuthRevoke,
Body: map[string]interface{}{"code": 0},
BodyFilter: func(body []byte) bool {
values, err := url.ParseQuery(string(body))
if err != nil {
return false
}
return values.Get("client_id") == "cli_test" &&
values.Get("client_secret") == "secret" &&
values.Get("token") == "user-refresh-token" &&
values.Get("token_type_hint") == "refresh_token"
},
})
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
if got := stderr.String(); !strings.Contains(got, "Logged out") {
t.Fatalf("stderr = %q, want Logged out", got)
}
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
t.Fatalf("expected stored token removed, got %#v", got)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
t.Fatalf("expected users cleared, got %#v", saved.Apps)
}
}
func TestAuthLogoutRun_FallsBackToAccessTokenWhenRefreshTokenMissing(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "cli_test",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "cli_test",
UserOpenId: "ou_user",
AccessToken: "user-access-token",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathOAuthRevoke,
Body: map[string]interface{}{"code": 0},
BodyFilter: func(body []byte) bool {
values, err := url.ParseQuery(string(body))
if err != nil {
return false
}
return values.Get("client_id") == "cli_test" &&
values.Get("client_secret") == "secret" &&
values.Get("token") == "user-access-token" &&
values.Get("token_type_hint") == "access_token"
},
})
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
if got := stderr.String(); !strings.Contains(got, "Logged out") {
t.Fatalf("stderr = %q, want Logged out", got)
}
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
t.Fatalf("expected stored token removed, got %#v", got)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
t.Fatalf("expected users cleared, got %#v", saved.Apps)
}
}
func TestAuthLogoutRun_RevokeFailureStillClearsLocalState(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "cli_test",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "cli_test",
UserOpenId: "ou_user",
AccessToken: "user-access-token",
RefreshToken: "user-refresh-token",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathOAuthRevoke,
Status: 500,
Body: map[string]interface{}{"error": "server_error"},
})
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
gotErr := stderr.String()
if strings.Contains(gotErr, "failed to revoke token for ou_user") {
t.Fatalf("stderr = %q, want no revoke warning", gotErr)
}
if !strings.Contains(gotErr, "Logged out") {
t.Fatalf("stderr = %q, want Logged out", gotErr)
}
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
t.Fatalf("expected stored token removed, got %#v", got)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
t.Fatalf("expected users cleared, got %#v", saved.Apps)
}
}

View File

@@ -19,6 +19,7 @@ type ScopesOptions struct {
Factory *cmdutil.Factory
Ctx context.Context
Format string
JSON bool
}
// NewCmdAuthScopes creates the auth scopes subcommand.
@@ -30,6 +31,9 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
Short: "Query scopes enabled for the app",
RunE: func(cmd *cobra.Command, args []string) error {
opts.Ctx = cmd.Context()
if opts.JSON {
opts.Format = "json"
}
if runF != nil {
return runF(opts)
}
@@ -38,6 +42,7 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
}
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "read")
return cmd

View File

@@ -17,6 +17,7 @@ import (
type StatusOptions struct {
Factory *cmdutil.Factory
Verify bool
JSON bool
}
// NewCmdAuthStatus creates the auth status subcommand.
@@ -35,6 +36,7 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
}
cmd.Flags().BoolVar(&opts.Verify, "verify", false, "verify token against server (requires network)")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "read")
return cmd

View File

@@ -0,0 +1,52 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"reflect"
"testing"
"github.com/spf13/cobra"
)
// TestCommandCatalogPath pins that the auth-hint path reconstruction inverts the
// service command tree for any depth — flat dotted resources AND genuinely
// nested resources — so it round-trips through apicatalog.Resolve instead of
// assuming a fixed root->service->resource->method shape.
func TestCommandCatalogPath(t *testing.T) {
chain := func(names ...string) *cobra.Command {
var parent, leaf *cobra.Command
for _, n := range names {
c := &cobra.Command{Use: n}
if parent != nil {
parent.AddCommand(c)
}
parent = c
leaf = c
}
return leaf
}
tests := []struct {
name string
leaf *cobra.Command
want []string
}{
{"flat dotted resource", chain("lark-cli", "im", "chat.members", "create"), []string{"im", "chat.members", "create"}},
{"nested resources", chain("lark-cli", "im", "spaces", "items", "get"), []string{"im", "spaces", "items", "get"}},
{"service level", chain("lark-cli", "im"), []string{"im"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := commandCatalogPath(tt.leaf); !reflect.DeepEqual(got, tt.want) {
t.Errorf("commandCatalogPath = %v, want %v", got, tt.want)
}
})
}
// The root command (no parent) has no catalog path.
if got := commandCatalogPath(&cobra.Command{Use: "lark-cli"}); len(got) != 0 {
t.Errorf("root path = %v, want empty", got)
}
}

View File

@@ -33,15 +33,16 @@ const probeTimeout = 3 * time.Second
//
// 1. A TAT request using the just-saved credentials. credential.FetchTAT
// returns a typed errs.* error (via the shared classifyTATResponseCode)
// only when the server deterministically rejected the credentials — a
// non-zero TAT body code, classified as CategoryConfig / SubtypeInvalidClient
// (10003 / 10014) or whatever codemeta maps. That typed error is propagated
// so the root dispatcher renders the canonical envelope and `config init`
// exits non-zero — identical to how every other token-resolving command
// reports the same bad credentials. Ambiguous failures (transport errors,
// HTTP non-200, JSON parse errors, timeouts) come back as raw untyped
// errors and are swallowed (return nil), so valid configurations are never
// disturbed by upstream noise. errs.IsTyped is the discriminator.
// only when the unified Token Endpoint deterministically rejected the
// credentials — an OAuth2 invalid_client / unauthorized_client classified as
// CategoryConfig / SubtypeInvalidClient, or whatever codemeta maps. That
// typed error is propagated so the root dispatcher renders the canonical
// envelope and `config init` exits non-zero — identical to how every other
// token-resolving command reports the same bad credentials. Ambiguous
// failures (transport errors, transient 5xx/server_error, JSON parse errors,
// timeouts) come back as raw untyped errors and are swallowed (return nil),
// so valid configurations are never disturbed by upstream noise.
// errs.IsTyped is the discriminator.
//
// 2. If TAT succeeded, a POST to the probe endpoint is fired. The outcome of
// that call (success, server error, timeout, parse failure) is always

View File

@@ -31,10 +31,10 @@ type fakeRT struct {
func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) {
switch {
case strings.HasSuffix(req.URL.Path, "/auth/v3/tenant_access_token/internal"):
case strings.HasSuffix(req.URL.Path, "/oauth/v3/token"):
f.tatCalls++
if f.tatHandler == nil {
return jsonResp(200, `{"code":0,"tenant_access_token":"t-ok"}`), nil
return jsonResp(200, `{"code":0,"access_token":"t-ok","token_type":"Bearer"}`), nil
}
return f.tatHandler(req)
case strings.HasSuffix(req.URL.Path, "/application/v6/larksuite_cli_app/probe"):
@@ -84,14 +84,15 @@ func fakeFactory(t *testing.T, rt http.RoundTripper) (*cmdutil.Factory, *bytes.B
}
// assertConfigRejection asserts runProbe propagated a deterministic credential
// rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient) with
// the expected upstream code. This is the same typed error every other
// token-resolving command returns for the same bad credentials, and nothing is
// written to stderr (the root dispatcher renders the envelope).
func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer, wantCode int) {
// rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient). This
// is the same typed error every other token-resolving command returns for the
// same bad credentials, and nothing is written to stderr (the root dispatcher
// renders the envelope). The numeric code is not asserted: the unified v3 Token
// Endpoint reports invalid_client via the OAuth2 error string, not a Lark code.
func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer) {
t.Helper()
if err == nil {
t.Fatalf("expected *errs.ConfigError (code %d), got nil", wantCode)
t.Fatal("expected *errs.ConfigError, got nil")
}
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
@@ -103,9 +104,6 @@ func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer, wantCo
if cfgErr.Subtype != errs.SubtypeInvalidClient {
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
}
if cfgErr.Code != wantCode {
t.Errorf("Code = %d, want %d", cfgErr.Code, wantCode)
}
if errBuf.Len() != 0 {
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
}
@@ -123,11 +121,13 @@ func assertSilent(t *testing.T, err error, errBuf *bytes.Buffer) {
}
}
// 10003 (bad / non-existent app_id) → ConfigError/InvalidClient, propagated.
func TestRunProbe_TATCode10003_ReturnsConfigError(t *testing.T) {
// invalid_client (bad / non-existent app_id or wrong secret) → the v3 Token
// Endpoint returns HTTP 400 with the OAuth2 error → ConfigError/InvalidClient,
// propagated. The probe endpoint must not be called when TAT fails.
func TestRunProbe_TATInvalidClient_ReturnsConfigError(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(200, `{"code":10003,"msg":"invalid param"}`), nil
return jsonResp(400, `{"error":"invalid_client","error_description":"The client secret is invalid.","code":20002}`), nil
},
}
f, errBuf := fakeFactory(t, rt)
@@ -137,28 +137,27 @@ func TestRunProbe_TATCode10003_ReturnsConfigError(t *testing.T) {
if rt.probeCalls != 0 {
t.Error("probe endpoint must not be called when TAT fails")
}
assertConfigRejection(t, err, errBuf, 10003)
assertConfigRejection(t, err, errBuf)
}
// 10014 (real app_id + wrong secret) → ConfigError/InvalidClient via codemeta —
// the most common real-world rejection, propagated.
func TestRunProbe_TATCode10014_ReturnsConfigError(t *testing.T) {
// unauthorized_client is treated as the same credential rejection, propagated.
func TestRunProbe_TATUnauthorizedClient_ReturnsConfigError(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(200, `{"code":10014,"msg":"app secret invalid"}`), nil
return jsonResp(401, `{"error":"unauthorized_client","error_description":"client not authorized"}`), nil
},
}
f, errBuf := fakeFactory(t, rt)
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf, 10014)
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
}
// Any non-zero body code is a deterministic rejection and propagates (typed).
// An unrecognized code falls back to *errs.APIError via BuildAPIError — still
// typed, so the probe still surfaces it rather than swallowing.
func TestRunProbe_TATUnknownBodyCode_Propagates(t *testing.T) {
// Any other deterministic client-side OAuth error (e.g. invalid_scope) falls
// back to *errs.APIError via BuildAPIError — still typed, so the probe surfaces
// it rather than swallowing — but is not a credential (ConfigError) rejection.
func TestRunProbe_TATOtherClientError_Propagates(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(200, `{"code":99999,"msg":"future-unknown"}`), nil
return jsonResp(400, `{"code":20068,"error":"invalid_scope","error_description":"unauthorized scope"}`), nil
},
}
f, errBuf := fakeFactory(t, rt)

View File

@@ -11,6 +11,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/apicatalog"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -118,38 +119,37 @@ func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string
}
// resolveDeclaredServiceMethodScopes returns the scopes declared by a
// service/resource/method command from the embedded from_meta registry.
// service/resource/method command. It reconstructs the catalog path from the
// command ancestry and resolves it through the same navigation Module the
// command tree is built from (apicatalog), so it stays correct for nested
// resources instead of hard-coding a root->service->resource->method depth.
// Non-method commands (services, resources, shortcuts) resolve to a non-method
// target and yield no scopes.
func resolveDeclaredServiceMethodScopes(cmd *cobra.Command, identity string) []string {
// Service-method scope lookup only applies to commands mounted as
// root -> service -> resource -> method. Non-resource/method commands
// intentionally return no scopes here so auth-hint enrichment does not
// change runtime semantics for other command shapes.
if cmd == nil || cmd.Parent() == nil || cmd.Parent().Parent() == nil || cmd.Parent().Parent().Parent() == nil {
if cmd == nil || strings.HasPrefix(cmd.Name(), "+") {
return nil
}
if strings.HasPrefix(cmd.Name(), "+") {
path := commandCatalogPath(cmd)
if len(path) == 0 {
return nil
}
target, err := registry.RuntimeCatalog().Resolve(path)
if err != nil || target.Kind != apicatalog.TargetMethod {
return nil
}
return registry.DeclaredScopesForMethod(target.Method.Method, identity)
}
service := cmd.Parent().Parent().Name()
resource := cmd.Parent().Name()
method := cmd.Name()
spec := registry.LoadFromMeta(service)
if spec == nil {
return nil
// commandCatalogPath reconstructs the catalog path [service, resource..., method]
// from a command's ancestry, excluding the root command. It is the inverse of
// the service command tree's construction, so any depth (flat or nested)
// round-trips through apicatalog.Resolve.
func commandCatalogPath(cmd *cobra.Command) []string {
var path []string
for c := cmd; c != nil && c.Parent() != nil; c = c.Parent() {
path = append([]string{c.Name()}, path...)
}
resources, _ := spec["resources"].(map[string]interface{})
resMap, _ := resources[resource].(map[string]interface{})
if resMap == nil {
return nil
}
methods, _ := resMap["methods"].(map[string]interface{})
methodMap, _ := methods[method].(map[string]interface{})
if methodMap == nil {
return nil
}
return registry.DeclaredScopesForMethod(methodMap, identity)
return path
}
// shortcutSupportsIdentity reports whether a shortcut supports the requested

View File

@@ -36,7 +36,7 @@ const rootLong = `lark-cli — Lark/Feishu CLI tool.
USAGE:
lark-cli <command> [subcommand] [method] [options]
lark-cli api <method> <path> [--params <json>] [--data <json>]
lark-cli schema <service.resource.method> [--format pretty]
lark-cli schema <service.resource.method>
EXAMPLES:
# View upcoming events

View File

@@ -5,17 +5,17 @@ package schema
import (
"context"
"fmt"
"errors"
"io"
"sort"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/schema"
"github.com/larksuite/cli/internal/util"
"github.com/spf13/cobra"
)
@@ -24,336 +24,10 @@ type SchemaOptions struct {
Factory *cmdutil.Factory
Ctx context.Context
// Positional args
Path string // first positional, when only one is given
ExtraArgs []string // 2nd+ positional args (space-separated form)
// Flags
Format string
}
func printServices(w io.Writer) {
services := registry.ListFromMetaProjects()
fmt.Fprintf(w, "%sAvailable services:%s\n\n", output.Bold, output.Reset)
for _, s := range services {
spec := registry.LoadFromMeta(s)
title := registry.GetStrFromMap(spec, "title")
if title == "" {
title = registry.GetStrFromMap(spec, "description")
}
fmt.Fprintf(w, " %s%s%s %s%s%s\n", output.Cyan, s, output.Reset, output.Dim, title, output.Reset)
}
fmt.Fprintf(w, "\n%sUsage: lark-cli schema <service>.<resource>.<method>%s\n", output.Dim, output.Reset)
}
func printResourceList(w io.Writer, spec map[string]interface{}, mode core.StrictMode) {
name := registry.GetStrFromMap(spec, "name")
version := registry.GetStrFromMap(spec, "version")
title := registry.GetStrFromMap(spec, "title")
if title == "" {
title = registry.GetStrFromMap(spec, "description")
}
servicePath := registry.GetStrFromMap(spec, "servicePath")
fmt.Fprintf(w, "%s%s%s (%s) — %s\n\n", output.Bold, name, output.Reset, version, title)
fmt.Fprintf(w, "%sBase path: %s%s\n\n", output.Dim, servicePath, output.Reset)
resources, _ := spec["resources"].(map[string]interface{})
for _, resName := range sortedKeys(resources) {
resMap, _ := resources[resName].(map[string]interface{})
methods, _ := resMap["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
if len(methods) == 0 {
continue
}
fmt.Fprintf(w, " %s%s%s\n", output.Cyan, resName, output.Reset)
for _, methodName := range sortedKeys(methods) {
m, _ := methods[methodName].(map[string]interface{})
httpMethod := registry.GetStrFromMap(m, "httpMethod")
desc := registry.GetStrFromMap(m, "description")
danger := ""
if d, _ := m["danger"].(bool); d {
danger = fmt.Sprintf(" %s[danger]%s", output.Red, output.Reset)
}
fmt.Fprintf(w, " %-7s %s%s%s %s%s%s%s\n", httpMethod, output.Bold, methodName, output.Reset, output.Dim, desc, output.Reset, danger)
}
fmt.Fprintln(w)
}
fmt.Fprintf(w, "%sUsage: lark-cli schema %s.<resource>.<method>%s\n", output.Dim, name, output.Reset)
}
// hasFileFields returns true if any requestBody field has type "file".
func hasFileFields(method map[string]interface{}) (bool, []string) {
names := cmdutil.DetectFileFields(method)
return len(names) > 0, names
}
func printMethodDetail(w io.Writer, spec map[string]interface{}, resName, methodName string, method map[string]interface{}) {
servicePath := registry.GetStrFromMap(spec, "servicePath")
specName := registry.GetStrFromMap(spec, "name")
methodPath := registry.GetStrFromMap(method, "path")
fullPath := servicePath + "/" + methodPath
httpMethod := registry.GetStrFromMap(method, "httpMethod")
desc := registry.GetStrFromMap(method, "description")
isFileUpload, fileFieldNames := hasFileFields(method)
fmt.Fprintf(w, "%s%s.%s.%s%s\n\n", output.Bold, specName, resName, methodName, output.Reset)
httpColor := output.Yellow
if httpMethod == "GET" {
httpColor = output.Green
} else if httpMethod == "DELETE" {
httpColor = output.Red
}
fmt.Fprintf(w, " %s%s%s %s\n", httpColor, httpMethod, output.Reset, fullPath)
if desc != "" {
fmt.Fprintf(w, " %s\n", desc)
}
fmt.Fprintln(w)
// Parameters
params, _ := method["parameters"].(map[string]interface{})
if len(params) > 0 {
fmt.Fprintf(w, "%sParameters:%s\n\n", output.Bold, output.Reset)
fmt.Fprintf(w, " %s--params%s <json> %soptional%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
for _, paramName := range sortedParamKeys(params) {
p, _ := params[paramName].(map[string]interface{})
pType := registry.GetStrFromMap(p, "type")
if pType == "" {
pType = "string"
}
location := registry.GetStrFromMap(p, "location")
required, _ := p["required"].(bool)
reqStr := fmt.Sprintf("%soptional%s", output.Dim, output.Reset)
if required {
reqStr = fmt.Sprintf("%srequired%s", output.Red, output.Reset)
}
locColor := output.Dim
if location == "path" {
locColor = output.Yellow
}
// Options (enum values)
optStr := formatOptions(p)
fmt.Fprintf(w, " - %s%s%s (%s, %s%s%s, %s)%s\n", output.Cyan, paramName, output.Reset, pType, locColor, location, output.Reset, reqStr, optStr)
if pdesc := registry.GetStrFromMap(p, "description"); pdesc != "" {
pdesc = util.TruncateStrWithEllipsis(pdesc, 100)
fmt.Fprintf(w, " %s%s%s\n", output.Dim, pdesc, output.Reset)
}
if ex := registry.GetStrFromMap(p, "example"); ex != "" {
fmt.Fprintf(w, " %se.g. %s%s\n", output.Dim, ex, output.Reset)
}
if rangeStr := formatRange(p); rangeStr != "" {
fmt.Fprintf(w, " %srange: %s%s\n", output.Dim, rangeStr, output.Reset)
}
}
fmt.Fprintln(w)
}
// --data for write methods
if httpMethod == "POST" || httpMethod == "PUT" || httpMethod == "PATCH" || httpMethod == "DELETE" {
if len(params) == 0 {
fmt.Fprintf(w, "%sParameters:%s\n\n", output.Bold, output.Reset)
}
fileUploadTag := ""
if isFileUpload {
fileUploadTag = fmt.Sprintf(" %s[file upload]%s", output.Yellow, output.Reset)
}
fmt.Fprintf(w, " %s--data%s <json> %soptional%s%s\n", output.Cyan, output.Reset, output.Dim, output.Reset, fileUploadTag)
requestBody, _ := method["requestBody"].(map[string]interface{})
if len(requestBody) > 0 {
printNestedFields(w, requestBody, " ", "")
}
if isFileUpload {
if len(fileFieldNames) == 1 {
fmt.Fprintf(w, "\n %s--file%s <[field=]path> %sfile upload%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
fmt.Fprintf(w, " Upload file as multipart/form-data. Default field: %q\n", fileFieldNames[0])
} else {
fmt.Fprintf(w, "\n %s--file%s <field=path> %sfile upload%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
fmt.Fprintf(w, " Upload file as multipart/form-data. Fields: %s\n", strings.Join(fileFieldNames, ", "))
}
}
fmt.Fprintln(w)
}
// Response
responseBody, _ := method["responseBody"].(map[string]interface{})
if len(responseBody) > 0 {
fmt.Fprintf(w, "%sResponse:%s\n\n", output.Bold, output.Reset)
printNestedFields(w, responseBody, " ", "")
fmt.Fprintln(w)
}
// Identity
if tokens, ok := method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
var identities []string
for _, t := range tokens {
if s, ok := t.(string); ok {
switch s {
case "user":
identities = append(identities, "user")
case "tenant":
identities = append(identities, "bot")
}
}
}
if len(identities) > 0 {
fmt.Fprintf(w, "%sIdentity:%s %s\n", output.Bold, output.Reset, strings.Join(identities, ", "))
}
}
// Scopes (all)
if scopes, ok := method["scopes"].([]interface{}); ok && len(scopes) > 0 {
var scopeStrs []string
for _, s := range scopes {
if str, ok := s.(string); ok {
scopeStrs = append(scopeStrs, str)
}
}
fmt.Fprintf(w, "%sScopes:%s %s\n", output.Bold, output.Reset, strings.Join(scopeStrs, ", "))
}
// CLI example
if isFileUpload && len(fileFieldNames) == 1 {
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s --file <path>\n", output.Bold, output.Reset, specName, resName, methodName)
} else if isFileUpload {
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s --file <field=path>\n", output.Bold, output.Reset, specName, resName, methodName)
} else {
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s\n", output.Bold, output.Reset, specName, resName, methodName)
}
// Docs
if docUrl := registry.GetStrFromMap(method, "docUrl"); docUrl != "" {
fmt.Fprintf(w, "%sDocs:%s %s\n", output.Bold, output.Reset, docUrl)
}
}
func printNestedFields(w io.Writer, fields map[string]interface{}, indent, prefix string) {
for _, fieldName := range sortedFieldKeys(fields) {
f, _ := fields[fieldName].(map[string]interface{})
fullName := fieldName
if prefix != "" {
fullName = prefix + "." + fieldName
}
fType := registry.GetStrFromMap(f, "type")
required, _ := f["required"].(bool)
reqStr := fmt.Sprintf("%soptional%s", output.Dim, output.Reset)
if required {
reqStr = fmt.Sprintf("%srequired%s", output.Red, output.Reset)
}
optStr := formatOptions(f)
fmt.Fprintf(w, "%s- %s%s%s (%s, %s)%s\n", indent, output.Cyan, fullName, output.Reset, fType, reqStr, optStr)
desc := registry.GetStrFromMap(f, "description")
if desc != "" {
desc = util.TruncateStrWithEllipsis(desc, 100)
fmt.Fprintf(w, "%s %s%s%s\n", indent, output.Dim, desc, output.Reset)
}
if ex := registry.GetStrFromMap(f, "example"); ex != "" {
fmt.Fprintf(w, "%s %se.g. %s%s\n", indent, output.Dim, ex, output.Reset)
}
if rangeStr := formatRange(f); rangeStr != "" {
fmt.Fprintf(w, "%s %srange: %s%s\n", indent, output.Dim, rangeStr, output.Reset)
}
if props, ok := f["properties"].(map[string]interface{}); ok && len(props) > 0 {
printNestedFields(w, props, indent+" ", fullName)
}
}
}
// formatOptions returns " — val1 | val2 | ..." if field has options, else "".
func formatOptions(f map[string]interface{}) string {
opts, ok := f["options"].([]interface{})
if !ok || len(opts) == 0 {
return ""
}
var vals []string
for _, o := range opts {
if om, ok := o.(map[string]interface{}); ok {
if v := registry.GetStrFromMap(om, "value"); v != "" {
vals = append(vals, v)
}
}
}
if len(vals) == 0 {
return ""
}
return fmt.Sprintf(" %s— %s%s", output.Dim, strings.Join(vals, " | "), output.Reset)
}
// formatRange returns "min..max" if field has min/max, else "".
func formatRange(f map[string]interface{}) string {
minVal := registry.GetStrFromMap(f, "min")
maxVal := registry.GetStrFromMap(f, "max")
if minVal == "" && maxVal == "" {
return ""
}
if minVal != "" && maxVal != "" {
return minVal + ".." + maxVal
}
if minVal != "" {
return ">=" + minVal
}
return "<=" + maxVal
}
// sortedKeys returns map keys in alphabetical order.
func sortedKeys(m map[string]interface{}) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// sortedParamKeys returns parameter keys sorted: required first, then alphabetical.
func sortedParamKeys(params map[string]interface{}) []string {
keys := make([]string, 0, len(params))
for k := range params {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
pi, _ := params[keys[i]].(map[string]interface{})
pj, _ := params[keys[j]].(map[string]interface{})
ri, _ := pi["required"].(bool)
rj, _ := pj["required"].(bool)
if ri != rj {
return ri
}
return keys[i] < keys[j]
})
return keys
}
// sortedFieldKeys returns field keys sorted: required first, then alphabetical.
func sortedFieldKeys(fields map[string]interface{}) []string {
keys := make([]string, 0, len(fields))
for k := range fields {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
fi, _ := fields[keys[i]].(map[string]interface{})
fj, _ := fields[keys[j]].(map[string]interface{})
ri, _ := fi["required"].(bool)
rj, _ := fj["required"].(bool)
if ri != rj {
return ri
}
return keys[i] < keys[j]
})
return keys
}
func findResourceByPath(resources map[string]interface{}, parts []string) (map[string]interface{}, string, []string) {
for i := len(parts); i >= 1; i-- {
candidateName := strings.Join(parts[:i], ".")
if res, ok := resources[candidateName]; ok {
if resMap, ok := res.(map[string]interface{}); ok {
return resMap, candidateName, parts[i:]
}
}
}
return nil, "", nil
// Args are the positional path segments, in either the dotted single-arg
// form ("im.messages.reply") or the space-separated form ("im messages
// reply"); apicatalog.ParsePath normalizes both.
Args []string
}
// NewCmdSchema creates the schema command. If runF is non-nil it is called instead of schemaRun (test hook).
@@ -365,12 +39,7 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
Short: "View API method parameters, types, and scopes",
Args: cobra.MaximumNArgs(8),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
opts.Path = args[0]
}
if len(args) > 1 {
opts.ExtraArgs = args[1:]
}
opts.Args = append([]string(nil), args...)
opts.Ctx = cmd.Context()
if runF != nil {
return runF(opts)
@@ -380,433 +49,89 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
}
cmdutil.DisableAuthCheck(cmd)
// Tolerated for agent compatibility; ignored — schema only emits the JSON
// envelope, and its output is identity-independent (strict-mode filtering
// comes from ResolveStrictMode, never from --as).
cmd.Flags().String("format", "json", "")
cmd.Flags().Bool("json", true, "")
cmd.Flags().String("as", "", "")
_ = cmd.Flags().MarkHidden("format")
_ = cmd.Flags().MarkHidden("json")
_ = cmd.Flags().MarkHidden("as")
cmd.ValidArgsFunction = completeSchemaPath(f)
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp
})
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
return cmd
}
// completeSchemaPath provides tab-completion for the schema path argument.
// It handles both legacy dotted resource names (e.g. app.table.fields) and the
// newer space-separated form (e.g. `schema im messages reply`).
// completeSchemaPath is a thin adapter over the embedded catalog's Complete.
// It uses the embedded source so completion candidates match what `schema`
// execution can resolve (both overlay-free).
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
mode := f.ResolveStrictMode(cmd.Context())
// Case 1: legacy "single dotted arg" path — no previous args yet
if len(args) == 0 {
parts := strings.Split(toComplete, ".")
if len(parts) <= 1 {
var completions []string
for _, s := range registry.ListFromMetaProjects() {
if strings.HasPrefix(s, toComplete) {
completions = append(completions, s+".")
}
}
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
}
serviceName := parts[0]
spec := registry.LoadFromMeta(serviceName)
if spec == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
spec = filterSpecByStrictMode(spec, mode)
resources, _ := spec["resources"].(map[string]interface{})
if resources == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
afterService := strings.Join(parts[1:], ".")
completions := completeSchemaPathForSpec(serviceName, resources, afterService)
allTrailingDot := len(completions) > 0
for _, c := range completions {
if !strings.HasSuffix(c, ".") {
allTrailingDot = false
break
}
}
directive := cobra.ShellCompDirectiveNoFileComp
if allTrailingDot {
directive |= cobra.ShellCompDirectiveNoSpace
}
return completions, directive
completions, noSpace := registry.EmbeddedCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
directive := cobra.ShellCompDirectiveNoFileComp
if noSpace {
directive |= cobra.ShellCompDirectiveNoSpace
}
// Case 2: space-form, args already has segments
// Walk down service -> resource(s) -> method based on existing args
serviceName := args[0]
spec := registry.LoadFromMeta(serviceName)
if spec == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
spec = filterSpecByStrictMode(spec, mode)
resources, _ := spec["resources"].(map[string]interface{})
if resources == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
// args[1:] are resource path segments (possibly partial); current
// toComplete is the next segment under cursor.
consumed := args[1:]
resource, _, remaining := findResourceByPath(resources, consumed)
if resource == nil {
// Suggest top-level resource names that match toComplete
var completions []string
for resName := range resources {
if strings.HasPrefix(resName, toComplete) {
completions = append(completions, resName)
}
}
sort.Strings(completions)
return completions, cobra.ShellCompDirectiveNoFileComp
}
if len(remaining) > 0 {
// Already typed past the resource — suggest methods
methods, _ := resource["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
var completions []string
for mName := range methods {
if strings.HasPrefix(mName, toComplete) {
completions = append(completions, mName)
}
}
sort.Strings(completions)
return completions, cobra.ShellCompDirectiveNoFileComp
}
// Resource matched exactly, suggest methods
methods, _ := resource["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
var completions []string
for mName := range methods {
if strings.HasPrefix(mName, toComplete) {
completions = append(completions, mName)
}
}
sort.Strings(completions)
return completions, cobra.ShellCompDirectiveNoFileComp
return completions, directive
}
}
func completeSchemaPathForSpec(serviceName string, resources map[string]interface{}, afterService string) []string {
var completions []string
for resName, resVal := range resources {
if strings.HasPrefix(resName, afterService) {
completions = append(completions, serviceName+"."+resName+".")
continue
}
if !strings.HasPrefix(afterService, resName+".") {
continue
}
methodPrefix := afterService[len(resName)+1:]
resMap, _ := resVal.(map[string]interface{})
if resMap == nil {
continue
}
methods, _ := resMap["methods"].(map[string]interface{})
for methodName := range methods {
if strings.HasPrefix(methodName, methodPrefix) {
completions = append(completions, serviceName+"."+resName+"."+methodName)
}
}
}
sort.Strings(completions)
return completions
}
func schemaRun(opts *SchemaOptions) error {
out := opts.Factory.IOStreams.Out
mode := opts.Factory.ResolveStrictMode(opts.Ctx)
// args may have arrived as a single string (legacy single-arg path) or
// split into multiple — normalize to a single args slice.
var rawArgs []string
if opts.Path != "" {
rawArgs = []string{opts.Path}
}
if len(opts.ExtraArgs) > 0 {
if opts.Path != "" {
rawArgs = append([]string{opts.Path}, opts.ExtraArgs...)
} else {
rawArgs = append([]string(nil), opts.ExtraArgs...)
}
}
parts := schema.ParsePath(rawArgs)
if opts.Format == "pretty" {
return runPrettyMode(out, parts, mode)
}
return runJSONMode(out, parts, mode)
return runSchema(out, apicatalog.ParsePath(opts.Args), mode)
}
// runJSONMode dispatches list/single envelope output based on parts.
// JSON mode uses embedded data only (bypasses remote overlay) so envelope
// output is deterministic across machines.
func runJSONMode(out io.Writer, parts []string, mode core.StrictMode) error {
filter := strictModeFilter(mode)
switch len(parts) {
case 0:
envs := schema.AssembleAll(filter)
output.PrintJson(out, envs)
return nil
case 1:
spec := registry.EmbeddedSpec(parts[0])
if spec == nil {
return errUnknownEmbeddedService(parts[0])
// runSchema resolves the path through the embedded catalog and renders the
// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and
// schema owns rendering (Envelope/Envelopes); this adapter only chooses the
// output shape — a single resolved method renders as one envelope object,
// anything broader as an array — and maps resolve failures to hints.
func runSchema(out io.Writer, parts []string, mode core.StrictMode) error {
catalog := registry.EmbeddedCatalog()
target, err := catalog.Resolve(parts)
if err != nil {
return resolveError(err)
}
refs := catalog.MethodRefs(target, registry.FilterForStrictMode(mode))
if target.Kind == apicatalog.TargetMethod {
if len(refs) == 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"Method %s not available in current identity mode", target.Method.SchemaPath()).
WithHint("strict mode hides methods the active account identity cannot call; it is shown for an identity (user or bot) that has the required access token")
}
envs := schema.AssembleService(parts[0], spec, filter)
output.PrintJson(out, envs)
return nil
default:
return runJSONForPath(out, parts, filter)
}
}
// runJSONForPath handles len(parts) >= 2: try resource match first, fallback
// to single-method match. Uses embedded data only.
func runJSONForPath(out io.Writer, parts []string, filter schema.MethodFilter) error {
serviceName := parts[0]
spec := registry.EmbeddedSpec(serviceName)
if spec == nil {
return errUnknownEmbeddedService(serviceName)
}
resources, _ := spec["resources"].(map[string]interface{})
resource, resName, remaining := findResourceByPath(resources, parts[1:])
if resource == nil {
var names []string
for k := range resources {
names = append(names, k)
}
sort.Strings(names)
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")),
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
}
if len(remaining) == 0 {
// Resource-scoped envelope array
envs := assembleResource(serviceName, resName, resource, filter)
output.PrintJson(out, envs)
output.PrintJson(out, schema.EnvelopeOf(refs[0]))
return nil
}
methodName := remaining[0]
methods, _ := resource["methods"].(map[string]interface{})
method, ok := methods[methodName].(map[string]interface{})
if !ok {
var names []string
for k := range methods {
names = append(names, k)
}
sort.Strings(names)
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName),
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
}
if len(remaining) > 1 {
// Method exists but caller appended extra segments — reject so they
// don't silently get this method's schema when they typo'd the path.
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown path: %s.%s.%s",
serviceName, resName, strings.Join(remaining, ".")),
fmt.Sprintf("Method %q exists but the trailing segments %q do not resolve",
methodName, strings.Join(remaining[1:], ".")))
}
if filter != nil && !filter(method) {
// Method exists in spec but filtered out by strict mode
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Method %s.%s.%s not available in current identity mode", serviceName, resName, methodName),
"Use --as user / --as bot to switch")
}
env := schema.AssembleEnvelope(serviceName, []string{resName}, methodName, method)
output.PrintJson(out, env)
output.PrintJson(out, schema.Envelopes(refs))
return nil
}
func assembleResource(serviceName, resName string, resource map[string]interface{}, filter schema.MethodFilter) []schema.Envelope {
methods, _ := resource["methods"].(map[string]interface{})
resourcePath := []string{resName}
var envs []schema.Envelope
for methodName, raw := range methods {
method, ok := raw.(map[string]interface{})
if !ok {
continue
}
if filter != nil && !filter(method) {
continue
}
envs = append(envs, schema.AssembleEnvelope(serviceName, resourcePath, methodName, method))
// resolveError maps a catalog *ResolveError to a typed *errs.ValidationError
// (CategoryValidation drives the exit code; Hint promotes to the envelope),
// preserving the historical message + hint text.
func resolveError(err error) error {
var re *apicatalog.ResolveError
if !errors.As(err, &re) {
return err
}
sort.Slice(envs, func(i, j int) bool { return envs[i].Name < envs[j].Name })
return envs
}
// runPrettyMode preserves the existing legacy pretty rendering verbatim.
// All printServices/printResourceList/printMethodDetail calls stay unchanged.
func runPrettyMode(out io.Writer, parts []string, mode core.StrictMode) error {
if len(parts) == 0 {
printServices(out)
return nil
}
serviceName := parts[0]
spec := registry.LoadFromMeta(serviceName)
if spec == nil {
return errUnknownService(serviceName)
}
if len(parts) == 1 {
printResourceList(out, spec, mode)
return nil
}
resources, _ := spec["resources"].(map[string]interface{})
resource, resName, remaining := findResourceByPath(resources, parts[1:])
if resource == nil {
var names []string
for k := range resources {
names = append(names, k)
}
sort.Strings(names)
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")),
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
}
if len(remaining) == 0 {
fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset)
methods, _ := resource["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
for _, mName := range sortedKeys(methods) {
m, _ := methods[mName].(map[string]interface{})
httpMethod := registry.GetStrFromMap(m, "httpMethod")
desc := registry.GetStrFromMap(m, "description")
fmt.Fprintf(out, " %-7s %s%s%s %s%s%s\n", httpMethod, output.Bold, mName, output.Reset, output.Dim, desc, output.Reset)
}
fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.<method>%s\n", output.Dim, serviceName, resName, output.Reset)
return nil
}
methodName := remaining[0]
methods, _ := resource["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
method, ok := methods[methodName].(map[string]interface{})
if !ok {
var names []string
for k := range methods {
names = append(names, k)
}
sort.Strings(names)
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName),
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
}
if len(remaining) > 1 {
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown path: %s.%s.%s",
serviceName, resName, strings.Join(remaining, ".")),
fmt.Sprintf("Method %q exists but the trailing segments %q do not resolve",
methodName, strings.Join(remaining[1:], ".")))
}
printMethodDetail(out, spec, resName, methodName, method)
return nil
}
// strictModeFilter adapts core.StrictMode into a schema.MethodFilter, or returns
// nil if strict mode is not active.
func strictModeFilter(mode core.StrictMode) schema.MethodFilter {
if !mode.IsActive() {
return nil
}
token := registry.IdentityToAccessToken(string(mode.ForcedIdentity()))
return func(method map[string]interface{}) bool {
tokens, _ := method["accessTokens"].([]interface{})
if tokens == nil {
return true // permissive when meta_data lacks accessTokens
}
for _, t := range tokens {
if s, _ := t.(string); s == token {
return true
}
}
return false
}
}
func errUnknownService(name string) error {
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown service: %s", name),
fmt.Sprintf("Available: %s", strings.Join(registry.ListFromMetaProjects(), ", ")))
}
// errUnknownEmbeddedService is the JSON-mode variant: it lists only embedded
// services (no overlay) because JSON mode itself bypasses overlay; suggesting
// overlay-only services would mislead callers when those services subsequently
// fail to resolve in envelope output.
func errUnknownEmbeddedService(name string) error {
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown service: %s", name),
fmt.Sprintf("Available: %s", strings.Join(registry.EmbeddedServiceNames(), ", ")))
}
// filterSpecByStrictMode returns a shallow copy of spec with each resource's methods
// filtered by strict mode. Returns the original spec when strict mode is off.
func filterSpecByStrictMode(spec map[string]interface{}, mode core.StrictMode) map[string]interface{} {
if !mode.IsActive() {
return spec
}
result := make(map[string]interface{}, len(spec))
for k, v := range spec {
result[k] = v
}
resources, _ := spec["resources"].(map[string]interface{})
if resources == nil {
return result
}
filteredRes := make(map[string]interface{}, len(resources))
for resName, resVal := range resources {
resMap, ok := resVal.(map[string]interface{})
if !ok {
continue
}
methods, _ := resMap["methods"].(map[string]interface{})
filtered := filterMethodsByStrictMode(methods, mode)
if len(filtered) == 0 {
continue
}
resCopy := make(map[string]interface{}, len(resMap))
for k, v := range resMap {
resCopy[k] = v
}
resCopy["methods"] = filtered
filteredRes[resName] = resCopy
}
result["resources"] = filteredRes
return result
}
// filterMethodsByStrictMode removes methods incompatible with the active strict mode.
// Returns the original map unmodified when strict mode is off.
func filterMethodsByStrictMode(methods map[string]interface{}, mode core.StrictMode) map[string]interface{} {
if !mode.IsActive() || methods == nil {
return methods
}
token := registry.IdentityToAccessToken(string(mode.ForcedIdentity()))
filtered := make(map[string]interface{}, len(methods))
for name, val := range methods {
m, ok := val.(map[string]interface{})
if !ok {
continue
}
tokens, _ := m["accessTokens"].([]interface{})
if tokens == nil {
filtered[name] = val
continue
}
for _, t := range tokens {
if ts, ok := t.(string); ok && ts == token {
filtered[name] = val
break
}
}
}
return filtered
switch re.Kind {
case apicatalog.ErrService:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown service: %s", re.Subject).
WithHint("Available: %s", strings.Join(re.Candidates, ", "))
case apicatalog.ErrResource:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown resource: %s", re.Subject).
WithHint("Available: %s", strings.Join(re.Candidates, ", "))
case apicatalog.ErrMethod:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown method: %s", re.Subject).
WithHint("Available: %s", strings.Join(re.Candidates, ", "))
case apicatalog.ErrPath:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown path: %s", re.Subject).
WithHint("Method %q exists but the trailing segments %q do not resolve", re.Method, re.Trailing)
}
return err
}

View File

@@ -4,7 +4,6 @@
package schema
import (
"bytes"
"encoding/json"
"strings"
"testing"
@@ -21,29 +20,46 @@ func TestSchemaCmd_FlagParsing(t *testing.T) {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"calendar.events.list", "--format", "pretty"})
cmd.SetArgs([]string{"calendar.events.list"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.Path != "calendar.events.list" {
t.Errorf("expected path calendar.events.list, got %s", gotOpts.Path)
}
if gotOpts.Format != "pretty" {
t.Errorf("expected Format=pretty, got %s", gotOpts.Format)
if len(gotOpts.Args) != 1 || gotOpts.Args[0] != "calendar.events.list" {
t.Errorf("expected args [calendar.events.list], got %v", gotOpts.Args)
}
}
func TestSchemaCmd_NoArgs_Pretty(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{"--format", "pretty"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
func TestSchemaCmd_OutputFlagsAcceptedForCompat(t *testing.T) {
// Agents are habituated to --format/--json/--as from api/service commands.
// schema must accept them without erroring and always emit the JSON envelope —
// its output is structured JSON and identity-independent, so the values have
// no effect.
argSets := [][]string{
{"--format", "json"},
{"--format", "pretty"},
{"--format", "table"}, // no table rendering for a nested schema -> JSON
{"--format", "csv"},
{"--json"},
{"--json", "--format", "ndjson"},
{"--as", "user"},
{"--as", "bot"},
{"--as", "user", "--json"},
}
if !strings.Contains(stdout.String(), "Available services") {
t.Error("expected service list in pretty mode")
for _, extra := range argSets {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdSchema(f, nil)
cmd.SetArgs(append([]string{"im.images.create"}, extra...))
if err := cmd.Execute(); err != nil {
t.Fatalf("args %v should be accepted, got error: %v", extra, err)
}
var env map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("args %v: output is not a JSON envelope: %v\n%s", extra, err, stdout.String())
}
if env["name"] != "im images create" {
t.Errorf("args %v: expected the im images create envelope, got name=%v", extra, env["name"])
}
}
}
@@ -51,7 +67,7 @@ func TestSchemaCmd_NoArgs_JSON_IsArray(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{}) // default --format json
cmd.SetArgs([]string{})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -76,7 +92,7 @@ func TestSchemaCmd_JSONIsEnvelope(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{"im.images.create", "--format", "json"})
cmd.SetArgs([]string{"im.images.create"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -179,23 +195,6 @@ func TestSchemaCmd_NoYesForReadRisk(t *testing.T) {
}
}
func TestSchemaCmd_PrettyUnchanged_KeyTextPresent(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{"im.images.create", "--format", "pretty"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
// Existing pretty rendering surfaces these markers — they must still appear
for _, want := range []string{"Parameters:", "Response:", "Identity:", "Scopes:", "CLI:"} {
if !strings.Contains(out, want) {
t.Errorf("pretty output missing marker %q", want)
}
}
}
func TestSchemaCmd_UnknownService(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -212,168 +211,6 @@ func TestSchemaCmd_UnknownService(t *testing.T) {
}
}
func TestPrintMethodDetail_FileUpload(t *testing.T) {
spec := map[string]interface{}{
"name": "im",
"servicePath": "/open-apis/im/v1",
}
method := map[string]interface{}{
"path": "images",
"httpMethod": "POST",
"description": "Upload an image",
"requestBody": map[string]interface{}{
"image_type": map[string]interface{}{
"type": "string",
"required": true,
},
"image": map[string]interface{}{
"type": "file",
"required": true,
},
},
"accessTokens": []interface{}{"user", "tenant"},
}
var buf bytes.Buffer
printMethodDetail(&buf, spec, "images", "create", method)
out := buf.String()
if !strings.Contains(out, "file upload") {
t.Errorf("expected 'file upload' marker in output, got:\n%s", out)
}
if !strings.Contains(out, "--file") {
t.Errorf("expected '--file' in output, got:\n%s", out)
}
if !strings.Contains(out, `"image"`) {
t.Errorf("expected default field name 'image' in output, got:\n%s", out)
}
if !strings.Contains(out, "--file <path>") {
t.Errorf("expected CLI example with --file <path>, got:\n%s", out)
}
}
func TestPrintMethodDetail_NoFileUpload(t *testing.T) {
spec := map[string]interface{}{
"name": "calendar",
"servicePath": "/open-apis/calendar/v4",
}
method := map[string]interface{}{
"path": "events",
"httpMethod": "POST",
"description": "Create an event",
"requestBody": map[string]interface{}{
"summary": map[string]interface{}{
"type": "string",
"required": true,
},
},
}
var buf bytes.Buffer
printMethodDetail(&buf, spec, "events", "create", method)
out := buf.String()
if strings.Contains(out, "file upload") {
t.Errorf("did not expect 'file upload' marker for non-file method, got:\n%s", out)
}
if strings.Contains(out, "--file") {
t.Errorf("did not expect '--file' for non-file method, got:\n%s", out)
}
}
func TestHasFileFields(t *testing.T) {
tests := []struct {
name string
method map[string]interface{}
wantBool bool
wantFields []string
}{
{
name: "has file field",
method: map[string]interface{}{
"requestBody": map[string]interface{}{
"image": map[string]interface{}{"type": "file"},
"name": map[string]interface{}{"type": "string"},
},
},
wantBool: true,
wantFields: []string{"image"},
},
{
name: "no file field",
method: map[string]interface{}{
"requestBody": map[string]interface{}{
"name": map[string]interface{}{"type": "string"},
},
},
wantBool: false,
wantFields: nil,
},
{
name: "no requestBody",
method: map[string]interface{}{},
wantBool: false,
wantFields: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, names := hasFileFields(tt.method)
if got != tt.wantBool {
t.Errorf("hasFileFields() = %v, want %v", got, tt.wantBool)
}
if tt.wantFields == nil && names != nil {
t.Errorf("expected nil names, got %v", names)
}
if tt.wantFields != nil && len(names) != len(tt.wantFields) {
t.Errorf("expected %d field names, got %d", len(tt.wantFields), len(names))
}
})
}
}
func TestCompleteSchemaPathForSpec(t *testing.T) {
resources := map[string]interface{}{
"records": map[string]interface{}{
"methods": map[string]interface{}{
"create": map[string]interface{}{},
"list": map[string]interface{}{},
},
},
"record_permissions": map[string]interface{}{
"methods": map[string]interface{}{
"get": map[string]interface{}{},
},
},
}
got := completeSchemaPathForSpec("base", resources, "records.cr")
if len(got) != 1 || got[0] != "base.records.create" {
t.Fatalf("completions = %v, want [base.records.create]", got)
}
got = completeSchemaPathForSpec("base", resources, "record")
if len(got) != 2 || got[0] != "base.record_permissions." || got[1] != "base.records." {
t.Fatalf("resource completions = %v", got)
}
}
func TestFilterSpecByStrictMode_RemovesIncompatibleMethodsFromCompletionSource(t *testing.T) {
spec := map[string]interface{}{
"resources": map[string]interface{}{
"records": map[string]interface{}{
"methods": map[string]interface{}{
"list": map[string]interface{}{"accessTokens": []interface{}{"tenant"}},
"create": map[string]interface{}{"accessTokens": []interface{}{"user"}},
},
},
},
}
filtered := filterSpecByStrictMode(spec, core.StrictModeBot)
resources, _ := filtered["resources"].(map[string]interface{})
got := completeSchemaPathForSpec("base", resources, "records.")
if len(got) != 1 || got[0] != "base.records.list" {
t.Fatalf("filtered completions = %v, want [base.records.list]", got)
}
}
// Completion candidate generation (dotted + space forms, strict-mode filtering,
// dotted-resource handling) now lives in internal/apicatalog and is covered by
// apicatalog's TestComplete. cmd/schema only adapts catalog.Complete to cobra.

80
cmd/service/affordance.go Normal file
View File

@@ -0,0 +1,80 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/meta"
)
// methodLong composes a method command's long help in one place: the
// description, the affordance guidance block (when the method has one), the
// pointer to the full schema, and the params-only addendum (params whose flag
// name is taken — paramFlagBinder.paramsOnlyHelp, "" when none). Affordance
// sits near the top so an agent sees when-to-use and few-shot examples before
// the flag list.
func methodLong(description, affordance, schemaPath, paramsOnly string) string {
var b strings.Builder
b.WriteString(description)
if affordance != "" {
b.WriteString("\n\n")
b.WriteString(affordance)
}
fmt.Fprintf(&b, "\n\nView parameter definitions before calling:\n lark-cli schema %s", schemaPath)
b.WriteString(paramsOnly)
return b.String()
}
// renderAffordance renders a method's affordance as a help block — when to use,
// prerequisites, and (most importantly for agents) few-shot Examples — or "" when
// the method carries no affordance. It reads the single typed model
// (meta.Method.ParsedAffordance) so the help and the envelope agree on shape.
func renderAffordance(m meta.Method) string {
a, ok := m.ParsedAffordance()
if !ok {
return ""
}
var b strings.Builder
bullets := func(title string, items []string) {
var nonEmpty []string
for _, it := range items {
if strings.TrimSpace(it) != "" {
nonEmpty = append(nonEmpty, it)
}
}
if len(nonEmpty) == 0 {
return
}
fmt.Fprintf(&b, "%s:\n", title)
for _, it := range nonEmpty {
fmt.Fprintf(&b, " • %s\n", it)
}
}
bullets("When to use", a.UseWhen)
bullets("Avoid when", a.DoNotUseWhen)
bullets("Prerequisites", a.Prerequisites)
if len(a.Examples) > 0 {
var lines []string
for _, ex := range a.Examples {
if ex.Command == "" {
continue
}
if ex.Description != "" {
lines = append(lines, fmt.Sprintf(" • %s\n %s", ex.Description, ex.Command))
} else {
lines = append(lines, fmt.Sprintf(" • %s", ex.Command))
}
}
if len(lines) > 0 {
fmt.Fprintf(&b, "Examples:\n%s\n", strings.Join(lines, "\n"))
}
}
bullets("Related", a.Related)
return strings.TrimRight(b.String(), "\n")
}

View File

@@ -0,0 +1,72 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/meta"
)
func TestRenderAffordance(t *testing.T) {
raw := json.RawMessage(`{
"use_when": ["发送文本消息"],
"do_not_use_when": ["群已解散"],
"prerequisites": ["已获取 chat_id"],
"examples": [
{"description":"发一条文本","command":"lark-cli im messages create --params '{...}'"},
{"command":"lark-cli im messages list"},
{"description":"no command, skipped","command":""}
],
"related": ["im.messages.list"]
}`)
out := renderAffordance(meta.Method{Affordance: raw})
for _, want := range []string{
"When to use:", "发送文本消息",
"Avoid when:", "群已解散",
"Prerequisites:", "已获取 chat_id",
"Examples:", "发一条文本", "lark-cli im messages create --params '{...}'",
"lark-cli im messages list", // example with no description -> bare command line
"Related:", "im.messages.list",
} {
if !strings.Contains(out, want) {
t.Errorf("renderAffordance missing %q in:\n%s", want, out)
}
}
if strings.Contains(out, "no command, skipped") {
t.Errorf("example with empty command should be skipped:\n%s", out)
}
// Absent or empty affordance renders nothing (so methods without an overlay
// add nothing to their help).
if renderAffordance(meta.Method{}) != "" || renderAffordance(meta.Method{Affordance: json.RawMessage(`{}`)}) != "" {
t.Error("empty affordance should render nothing")
}
}
func TestServiceMethod_AffordanceInLong(t *testing.T) {
withAff := map[string]interface{}{
"path": "messages", "httpMethod": "POST", "description": "发送消息",
"affordance": map[string]interface{}{
"examples": []interface{}{
map[string]interface{}{"description": "发文本", "command": "lark-cli im messages create ..."},
},
},
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(withAff), "create", "messages", nil)
if !strings.Contains(cmd.Long, "Examples:") || !strings.Contains(cmd.Long, "lark-cli im messages create ...") {
t.Errorf("affordance examples not in command Long:\n%s", cmd.Long)
}
// A method with no affordance adds no guidance block.
plain := map[string]interface{}{"path": "x", "httpMethod": "GET", "description": "d"}
cmd2 := NewCmdServiceMethod(f, imSpec(), meta.FromMap(plain), "list", "x", nil)
if strings.Contains(cmd2.Long, "Examples:") {
t.Errorf("no-affordance method should have no Examples in Long:\n%s", cmd2.Long)
}
}

211
cmd/service/flaggroups.go Normal file
View File

@@ -0,0 +1,211 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// Flag annotations the grouped service-method help renderer reads.
const (
flagGroupAnnotation = "lark_flag_group" // display group key
flagSubAnnotation = "lark_flag_sub" // "required" | "optional" within API Parameters
flagNoteAnnotation = "lark_flag_note" // extra lines shown indented under a flag
groupParams = "params" // typed path/query flags
groupBody = "body" // --data, --file
groupRaw = "raw" // --params
groupExecution = "execution" // --as/--dry-run/--page-*/--yes
groupOutput = "output" // --output/--format/--jq
subRequired = "required"
subOptional = "optional"
)
// serviceFlagGroupOrder is the display order + titles of the flag groups. API
// Parameters carries only typed path/query flags; raw --params, request body and
// execution/output controls each get their own group so an agent can tell the
// distinct input kinds apart.
var serviceFlagGroupOrder = []struct{ key, title string }{
{groupParams, "API Parameters"},
{groupBody, "Request Body"},
{groupRaw, "Raw Parameter Input"},
{groupExecution, "Execution"},
{groupOutput, "Output"},
}
// applyGroupedUsage installs the grouped usage renderer on a service method
// cmd: local flags via the grouped renderer instead of cobra's flat Flags:
// list; global (inherited) flags and the Risk/Tips sections appended by the
// root help func are unaffected. Rendered by hand rather than via
// cmd.SetUsageTemplate: cobra lazy-links text/template on the first
// SetUsageTemplate call, whose executor reaches reflect.Value.MethodByName —
// that disables the linker's method-level dead-code elimination and costs
// ~19 MB of binary size.
func applyGroupedUsage(cmd *cobra.Command) {
cmd.SetUsageFunc(func(c *cobra.Command) error {
w := c.OutOrStderr()
fmt.Fprintf(w, "Usage:\n %s\n", c.UseLine())
if c.HasAvailableLocalFlags() {
fmt.Fprintf(w, "\n%s\n", renderServiceFlagGroups(c))
}
if c.HasAvailableInheritedFlags() {
fmt.Fprintf(w, "\nGlobal Flags:\n%s\n", strings.TrimRight(c.InheritedFlags().FlagUsages(), " \t\n"))
}
return nil
})
}
func annotate(f *pflag.Flag, key string, vals []string) {
if f.Annotations == nil {
f.Annotations = map[string][]string{}
}
f.Annotations[key] = vals
}
// tagFlagGroup records a flag's display group (no-op if the flag is absent).
func tagFlagGroup(fs *pflag.FlagSet, name, group string) {
if f := fs.Lookup(name); f != nil {
annotate(f, flagGroupAnnotation, []string{group})
}
}
func annotationOf(f *pflag.Flag, key string) []string {
if f.Annotations != nil {
return f.Annotations[key]
}
return nil
}
func flagGroupOf(f *pflag.Flag) string {
if v := annotationOf(f, flagGroupAnnotation); len(v) > 0 {
return v[0]
}
return ""
}
func flagSubOf(f *pflag.Flag) string {
if v := annotationOf(f, flagSubAnnotation); len(v) > 0 {
return v[0]
}
return ""
}
// renderServiceFlagGroups renders the command's local flags into ordered,
// titled groups; the API Parameters group is further split into Required /
// Optional. It is the body of the usage func applyGroupedUsage installs.
func renderServiceFlagGroups(cmd *cobra.Command) string {
var b strings.Builder
seen := map[*pflag.Flag]bool{}
for _, g := range serviceFlagGroupOrder {
flags := groupFlags(cmd, g.key, seen)
if len(flags) == 0 {
continue
}
fmt.Fprintf(&b, "%s:\n", g.title)
if g.key == groupParams {
writeSection(&b, " Required:", subFlags(flags, subRequired))
writeSection(&b, " Optional:", subFlags(flags, subOptional))
} else {
writeSection(&b, "", flags)
}
fmt.Fprintln(&b)
}
// Anything untagged (e.g. -h/--help) goes last under "Other".
var other []*pflag.Flag
cmd.LocalFlags().VisitAll(func(f *pflag.Flag) {
if f.Hidden || seen[f] {
return
}
other = append(other, f)
})
if len(other) > 0 {
fmt.Fprintln(&b, "Other:")
writeSection(&b, "", other)
}
return strings.TrimRight(b.String(), "\n")
}
// groupFlags returns the visible local flags tagged with group key, marking them
// seen so the trailing "Other" bucket only catches genuinely untagged flags.
func groupFlags(cmd *cobra.Command, key string, seen map[*pflag.Flag]bool) []*pflag.Flag {
var flags []*pflag.Flag
cmd.LocalFlags().VisitAll(func(f *pflag.Flag) {
if f.Hidden || flagGroupOf(f) != key {
return
}
flags = append(flags, f)
seen[f] = true
})
return flags
}
func subFlags(flags []*pflag.Flag, sub string) []*pflag.Flag {
var out []*pflag.Flag
for _, f := range flags {
s := flagSubOf(f)
// Untagged subgroup defaults to Optional so nothing is dropped.
if s == sub || (s == "" && sub == subOptional) {
out = append(out, f)
}
}
return out
}
// writeSection prints an optional (sub)header and the flags, aligned in a
// column, each flag row followed by its note lines indented under the usage.
func writeSection(b *strings.Builder, header string, flags []*pflag.Flag) {
if len(flags) == 0 {
return
}
if header != "" {
fmt.Fprintf(b, "%s\n", header)
}
specs := make([]string, len(flags))
maxSpec := 0
for i, f := range flags {
specs[i] = flagSpec(f)
if len(specs[i]) > maxSpec {
maxSpec = len(specs[i])
}
}
for i, f := range flags {
_, usage := pflag.UnquoteUsage(f)
if showsDefault(f) {
usage += fmt.Sprintf(" (default %s)", f.DefValue)
}
fmt.Fprintf(b, "%-*s %s\n", maxSpec, specs[i], strings.TrimSpace(usage))
for _, note := range annotationOf(f, flagNoteAnnotation) {
fmt.Fprintf(b, "%*s%s\n", maxSpec+3+4, "", note)
}
}
}
// flagSpec is pflag's " --name type" / " -x, --name type" left column.
func flagSpec(f *pflag.Flag) string {
typeName, _ := pflag.UnquoteUsage(f)
spec := " --" + f.Name
if f.Shorthand != "" && f.ShorthandDeprecated == "" {
spec = " -" + f.Shorthand + ", --" + f.Name
}
if typeName != "" {
spec += " " + typeName
}
return spec
}
// showsDefault mirrors pflag's "non-zero default" rule for the flag types these
// commands use, so the grouped rendering shows the same "(default x)" hints as
// cobra's flat list.
func showsDefault(f *pflag.Flag) bool {
switch f.DefValue {
case "", "0", "false", "[]":
return false
}
return true
}

View File

@@ -0,0 +1,115 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/meta"
)
func TestServiceFlagGroups_AgentContract(t *testing.T) {
method := map[string]interface{}{
"path": "chats/:chat_id/members",
"httpMethod": "POST",
"parameters": map[string]interface{}{
"chat_id": map[string]interface{}{"type": "string", "location": "path", "required": true},
"member_id_type": map[string]interface{}{
"type": "string", "location": "query",
"options": []interface{}{
map[string]interface{}{"value": "open_id", "description": "以 open_id 标识用户"},
map[string]interface{}{"value": "user_id", "description": "以 user_id 标识用户"},
},
},
},
// Documented body field -> --data belongs under Request Body.
"requestBody": map[string]interface{}{
"id_list": map[string]interface{}{"type": "list", "required": true},
},
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "create", "chat.members", nil)
out := renderServiceFlagGroups(cmd)
idx := func(s string) int { return strings.Index(out, s) }
// Section order: API Parameters → Request Body → Raw Parameter Input → Execution → Output.
iParams, iBody, iRaw, iExec, iOut := idx("API Parameters:"), idx("Request Body:"), idx("Raw Parameter Input:"), idx("Execution:"), idx("Output:")
for name, i := range map[string]int{"API Parameters": iParams, "Request Body": iBody, "Raw Parameter Input": iRaw, "Execution": iExec, "Output": iOut} {
if i < 0 {
t.Fatalf("missing section %q in:\n%s", name, out)
}
}
if !(iParams < iBody && iBody < iRaw && iRaw < iExec && iExec < iOut) {
t.Errorf("section order wrong:\n%s", out)
}
// Required/Optional subsections under API Parameters.
if i := idx(" Required:"); i < iParams || i > iBody {
t.Errorf("Required subsection misplaced:\n%s", out)
}
if i := idx(" Optional:"); i < iParams || i > iBody {
t.Errorf("Optional subsection misplaced:\n%s", out)
}
// Typed flags are API Parameters; required path flag under Required, enum
// flag under Optional with an inline "enum: ..." (not multi-line meanings).
if i := idx("--chat-id"); i < iParams || i > iBody {
t.Errorf("--chat-id not under API Parameters:\n%s", out)
}
if !strings.Contains(out, "chat_id, required") {
t.Errorf("typed flag help format wrong:\n%s", out)
}
if !strings.Contains(out, "enum: open_id=以 open_id 标识用户|user_id=以 user_id 标识用户") {
t.Errorf("expected compact enum value=meaning inline:\n%s", out)
}
// --data is Request Body; --params is Raw Parameter Input (NOT API Parameters)
// and carries the precedence rule.
if i := idx("--data"); i < iBody || i > iRaw {
t.Errorf("--data not under Request Body:\n%s", out)
}
if i := idx("--params"); i < iRaw || i > iExec {
t.Errorf("--params not under Raw Parameter Input:\n%s", out)
}
if !strings.Contains(out, "typed flags override matching keys in --params") {
t.Errorf("missing --params precedence rule:\n%s", out)
}
// Control flags land in Execution/Output.
if i := idx("--dry-run"); i < iExec || i > iOut {
t.Errorf("--dry-run not under Execution:\n%s", out)
}
if idx("--format") < iOut {
t.Errorf("--format not under Output:\n%s", out)
}
// The usage template is wired to the grouped renderer (no flat Flags: list).
if u := cmd.UsageString(); !strings.Contains(u, "API Parameters:") || strings.Contains(u, "\nFlags:\n") {
t.Errorf("usage template not grouped:\n%s", u)
}
}
// TestServiceFlagGroups_UndocumentedBodyIsRaw: a POST with no documented body
// fields still offers --data (escape hatch) but must NOT imply a declared body —
// it goes under Raw Parameter Input, not "Request Body".
func TestServiceFlagGroups_UndocumentedBodyIsRaw(t *testing.T) {
method := map[string]interface{}{"path": "things/do", "httpMethod": "POST"} // POST, no requestBody, no params
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "do", "things", nil)
out := renderServiceFlagGroups(cmd)
if strings.Contains(out, "Request Body:") {
t.Errorf("undocumented body must not render a Request Body section:\n%s", out)
}
iRaw, iData := strings.Index(out, "Raw Parameter Input:"), strings.Index(out, "--data")
if iRaw < 0 || iData < iRaw {
t.Errorf("--data not under Raw Parameter Input:\n%s", out)
}
if !strings.Contains(out, "no documented fields") {
t.Errorf("--data should be labeled a raw escape hatch:\n%s", out)
}
}

166
cmd/service/paramflags.go Normal file
View File

@@ -0,0 +1,166 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/meta"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
type boundParamFlag struct {
field meta.Field
read func() interface{}
}
// paramsOnlyField is a path/query parameter that got no typed flag because its
// kebab name is already taken by another flag (a standard flag like --format, or
// a root persistent flag). It stays reachable via --params; the binder keeps it,
// with the flag that claimed the name, so --help can show the exact --params form
// and steer the reader off the wrong flag.
type paramsOnlyField struct {
field meta.Field
claimed *pflag.Flag
}
// paramFlagBinder owns one service method's generated typed param flags: it
// registers them (kind, help, enum completion, reserved-name skip) and applies
// the --params overlay, where a changed typed flag overrides its key in the
// --params JSON. Holding the field<->flag binding here keeps the request builder
// from re-deriving which flags map to which param keys.
type paramFlagBinder struct {
bound []boundParamFlag
paramsOnly []paramsOnlyField
}
// newParamFlagBinder registers one typed kebab flag per path/query parameter on
// cmd and returns a binder for the --params overlay. A name already taken by
// another flag is skipped — pflag panics on a local duplicate and a generated
// flag would silently shadow a persistent one — and recorded as paramsOnly so
// the parameter stays reachable (and discoverable) via --params. The taken set
// is derived, not hand-listed: local flags (the standard set, registered before
// this runs) via cmd, the lazily-added --help materialized here, and the root's
// persistent flags via reserved (nil for direct callers that have no root).
func newParamFlagBinder(cmd *cobra.Command, params []meta.Field, reserved *pflag.FlagSet) *paramFlagBinder {
cmd.InitDefaultHelpFlag() // materialize --help/-h so the local guard below sees it
b := &paramFlagBinder{}
for _, f := range params {
name := f.FlagName()
if claimed := flagClaiming(cmd, reserved, name); claimed != nil {
b.paramsOnly = append(b.paramsOnly, paramsOnlyField{field: f, claimed: claimed})
continue
}
read := registerTypedFlag(cmd.Flags(), name, f.CanonicalType(), paramFlagUsage(f))
if values := enumStrings(f.EnumValues()); len(values) > 0 {
cmdutil.RegisterFlagCompletion(cmd, name, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return values, cobra.ShellCompDirectiveNoFileComp
})
}
// Group as an API parameter and mark required/optional for the
// Required/Optional subsections of the grouped --help renderer.
if fl := cmd.Flags().Lookup(name); fl != nil {
annotate(fl, flagGroupAnnotation, []string{groupParams})
sub := subOptional
if f.Required {
sub = subRequired
}
annotate(fl, flagSubAnnotation, []string{sub})
}
b.bound = append(b.bound, boundParamFlag{field: f, read: read})
}
return b
}
// flagClaiming returns the flag already occupying name (so a typed param flag
// would collide), or nil when the name is free. It checks the command's own
// flags (the standard set + the materialized --help) and the root's persistent
// flags — so the reserved set is whatever is actually registered, never a
// hand-kept list that drifts when a global flag is added.
func flagClaiming(cmd *cobra.Command, reserved *pflag.FlagSet, name string) *pflag.Flag {
if fl := cmd.Flags().Lookup(name); fl != nil {
return fl
}
if reserved != nil {
return reserved.Lookup(name)
}
return nil
}
// paramsOnlyHelp renders the --help addendum for parameters that have no typed
// flag, or "" when there are none. Per field: a copy-pasteable --params form,
// the same fieldFacts a typed flag would show on its usage line, and what the
// colliding flag actually does — so neither a human nor an agent sets the
// wrong one (e.g. --format, which is the output format, not the API parameter).
func (b *paramFlagBinder) paramsOnlyHelp() string {
if len(b.paramsOnly) == 0 {
return ""
}
var sb strings.Builder
sb.WriteString("\nParameters set via --params (no typed flag; the name is taken by another flag):\n")
for _, p := range b.paramsOnly {
name := p.field.Name
fmt.Fprintf(&sb, " %s: --params '{%q: %s}'\n", name, name, paramExample(p.field))
for _, fact := range fieldFacts(p.field) {
fmt.Fprintf(&sb, " %s\n", fact)
}
if p.claimed != nil {
fmt.Fprintf(&sb, " do not use --%s (%s)\n", p.claimed.Name, p.claimed.Usage)
}
}
return sb.String()
}
// hasTypedFlag reports whether the binder registered a typed flag for the
// param named name. False for params-only fields — a flag with the same kebab
// name may exist (that's the collision), but it is not this param's input.
// Nil-safe for direct buildServiceRequest callers that have no binder.
func (b *paramFlagBinder) hasTypedFlag(name string) bool {
if b == nil {
return false
}
for _, pf := range b.bound {
if pf.field.Name == name {
return true
}
}
return false
}
// overlay lets an explicit typed flag override the same key in --params
// (--params is the base). Only changed flags apply, so the --params-only path is
// unchanged. A nil binder or cmd is a no-op.
func (b *paramFlagBinder) overlay(cmd *cobra.Command, params map[string]interface{}) {
if b == nil || cmd == nil {
return
}
for _, pf := range b.bound {
if cmd.Flags().Changed(pf.field.FlagName()) {
params[pf.field.Name] = pf.read()
}
}
}
// registerTypedFlag registers one flag of the given canonical JSON-Schema kind
// and returns a reader for its parsed value; the kind→pflag-type switch lives
// only here.
func registerTypedFlag(fs *pflag.FlagSet, name, kind, usage string) func() interface{} {
switch kind {
case "integer":
return flagReader(fs.Int(name, 0, usage))
case "boolean":
return flagReader(fs.Bool(name, false, usage))
case "array":
return flagReader(fs.StringArray(name, nil, usage))
default:
return flagReader(fs.String(name, "", usage))
}
}
func flagReader[T any](p *T) func() interface{} {
return func() interface{} { return *p }
}

View File

@@ -0,0 +1,626 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/meta"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// imChatMembersCreate: POST chats/{chat_id}/members with one path param and one
// optional enum query param — the canonical case from the screenshot feedback.
func imChatMembersCreate() meta.Method {
return meta.FromMap(map[string]interface{}{
"path": "chats/{chat_id}/members",
"httpMethod": "POST",
"parameters": map[string]interface{}{
"chat_id": map[string]interface{}{
"type": "string", "location": "path", "required": true,
},
"member_id_type": map[string]interface{}{
"type": "string", "location": "query", "required": false,
"options": []interface{}{
map[string]interface{}{"value": "open_id"},
map[string]interface{}{"value": "user_id"},
},
},
},
})
}
func TestServiceMethod_TypedFlagRegistered(t *testing.T) {
f := &cmdutil.Factory{}
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
if cmd.Flags().Lookup("chat-id") == nil {
t.Error("expected generated --chat-id flag for path param chat_id")
}
if cmd.Flags().Lookup("member-id-type") == nil {
t.Error("expected generated --member-id-type flag for query param member_id_type")
}
}
// A query param literally named "format" kebab-collides with the global
// --format flag. Generation must skip it (never re-register, never panic) and
// leave the standard --format flag intact.
func TestServiceMethod_TypedFlagReservedCollisionSkipped(t *testing.T) {
method := map[string]interface{}{
"path": "messages",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"format": map[string]interface{}{"type": "string", "location": "query"},
},
}
var cmd *cobra.Command
func() {
defer func() {
if r := recover(); r != nil {
t.Fatalf("flag generation panicked on reserved-name collision: %v", r)
}
}()
cmd = NewCmdServiceMethod(&cmdutil.Factory{}, imSpec(), meta.FromMap(method), "list", "messages", nil)
}()
fl := cmd.Flags().Lookup("format")
if fl == nil || fl.DefValue != "json" {
t.Fatalf("standard --format flag must be preserved, got %+v", fl)
}
}
func TestServiceMethod_TypedFlag_DrivesPathParam(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
cmd.SetArgs([]string{"--chat-id", "oc_abc123", "--data", `{"id_list":["ou_x"]}`, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "chats/oc_abc123/members") {
t.Errorf("expected URL with chat_id substituted from --chat-id, got:\n%s", stdout.String())
}
}
func TestServiceMethod_TypedFlag_DrivesQueryParam(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
cmd.SetArgs([]string{"--chat-id", "oc_abc123", "--member-id-type", "open_id", "--data", `{}`, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "member_id_type") || !strings.Contains(out, "open_id") {
t.Errorf("expected query param member_id_type=open_id from flag, got:\n%s", out)
}
}
func TestServiceMethod_TypedFlag_AgreesWithParams(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
cmd.SetArgs([]string{"--chat-id", "oc_abc123", "--params", `{"chat_id":"oc_abc123"}`, "--data", `{}`, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("same value via flag and --params should be accepted, got: %v", err)
}
if !strings.Contains(stdout.String(), "chats/oc_abc123/members") {
t.Errorf("expected URL with chat_id, got:\n%s", stdout.String())
}
}
// --params is the base; an explicit typed flag overrides the same key.
func TestServiceMethod_TypedFlag_OverridesParams(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
cmd.SetArgs([]string{"--chat-id", "oc_flag", "--params", `{"chat_id":"oc_params"}`, "--data", `{}`, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "chats/oc_flag/members") {
t.Errorf("expected --chat-id to override --params chat_id, got:\n%s", out)
}
if strings.Contains(out, "oc_params") {
t.Errorf("--params value should have been overridden by the flag, got:\n%s", out)
}
}
// Override works for a non-string (integer) param too, exercising the int
// register/read path end to end.
func TestServiceMethod_TypedFlag_IntegerOverridesParams(t *testing.T) {
method := map[string]interface{}{
"path": "messages",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"page_size": map[string]interface{}{"type": "integer", "location": "query"},
},
}
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "list", "messages", nil)
cmd.SetArgs([]string{"--page-size", "100", "--params", `{"page_size":5}`, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "page_size") || !strings.Contains(out, "100") {
t.Errorf("expected --page-size 100 to override --params page_size=5, got:\n%s", out)
}
}
// Regression: with no typed flags passed, behavior is byte-identical to today.
func TestServiceMethod_TypedFlag_OnlyParamsStillWorks(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
cmd.SetArgs([]string{"--params", `{"chat_id":"oc_abc123"}`, "--data", `{}`, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "chats/oc_abc123/members") {
t.Errorf("expected URL with chat_id from --params, got:\n%s", stdout.String())
}
}
// Regression: --params null is valid JSON that unmarshals to a nil map. A typed
// flag overlaying onto it must not panic (assignment to a nil map) — null is
// treated as "no base params", with the flag value applied on top.
func TestServiceMethod_TypedFlag_OverridesNullParams(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
cmd.SetArgs([]string{"--chat-id", "oc_abc123", "--params", "null", "--data", `{}`, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("--params null with a typed flag should not error, got: %v", err)
}
if !strings.Contains(stdout.String(), "chats/oc_abc123/members") {
t.Errorf("expected chat_id from --chat-id over null --params, got:\n%s", stdout.String())
}
}
// Startup smoke test: registering every embedded method must not panic on a
// generated-flag name collision (pflag panics on duplicate registration, which
// would crash the whole CLI at startup), and a known path param must surface as
// a typed flag end to end.
func TestRegisterServiceCommands_GeneratesFlagsNoPanic(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
f := &cmdutil.Factory{}
defer func() {
if r := recover(); r != nil {
t.Fatalf("registering all service commands panicked: %v", r)
}
}()
RegisterServiceCommands(root, f)
create, _, err := root.Find([]string{"im", "chat.members", "create"})
if err != nil {
t.Fatalf("im chat.members create not registered: %v", err)
}
if create.Flags().Lookup("chat-id") == nil {
t.Error("expected generated --chat-id flag on im chat.members create")
}
}
// Locks the boolean and array branches of bindParamFlag end to end (string and
// integer are covered above): a bool flag yields true and a repeatable array
// flag yields all its elements in the request.
func TestServiceMethod_TypedFlag_BoolAndArrayKinds(t *testing.T) {
method := map[string]interface{}{
"path": "items",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"with_deleted": map[string]interface{}{"type": "boolean", "location": "query"},
"ids": map[string]interface{}{"type": "list", "location": "query"},
},
}
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "list", "items", nil)
cmd.SetArgs([]string{"--with-deleted", "--ids", "a", "--ids", "b", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{"with_deleted", "true", "ids", "\"a\"", "\"b\""} {
if !strings.Contains(out, want) {
t.Errorf("expected dry-run output to contain %q, got:\n%s", want, out)
}
}
}
// Override (--params base, typed flag wins) is covered for string and integer
// above; this locks the same semantics for the boolean and array kinds.
func TestServiceMethod_TypedFlag_BoolAndArrayOverrideParams(t *testing.T) {
method := map[string]interface{}{
"path": "items",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"with_deleted": map[string]interface{}{"type": "boolean", "location": "query"},
"ids": map[string]interface{}{"type": "list", "location": "query"},
},
}
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "list", "items", nil)
cmd.SetArgs([]string{
"--params", `{"with_deleted":false,"ids":["from_params"]}`,
"--with-deleted", "--ids", "a", "--ids", "b",
"--dry-run",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{"with_deleted", "true", "\"a\"", "\"b\""} {
if !strings.Contains(out, want) {
t.Errorf("expected flag to override --params (want %q), got:\n%s", want, out)
}
}
if strings.Contains(out, "from_params") {
t.Errorf("--params array value should have been overridden by --ids, got:\n%s", out)
}
}
// A param whose kebab name collides with a global flag (here "format" vs the
// global --format) gets no typed flag, but the collision is no longer silent:
// non-colliding params still get flags, the global --format is untouched, and
// --help shows the exact --params form and steers the reader off --format.
func TestServiceMethod_ParamsOnly_HelpSteersToParams(t *testing.T) {
method := map[string]interface{}{
"path": "things/{thing_id}",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"thing_id": map[string]interface{}{"type": "string", "location": "path", "required": true},
"format": map[string]interface{}{"type": "string", "location": "query", "min": "1", "max": "64", "description": "返回的消息体格式。", "options": []interface{}{
map[string]interface{}{"value": "full"},
map[string]interface{}{"value": "metadata"},
}},
},
}
cmd := NewCmdServiceMethod(&cmdutil.Factory{}, imSpec(), meta.FromMap(method), "get", "things", nil)
if cmd.Flags().Lookup("thing-id") == nil {
t.Error("non-colliding param should still get a typed --thing-id flag")
}
if fl := cmd.Flags().Lookup("format"); fl == nil || fl.DefValue != "json" {
t.Fatalf("global --format must be preserved (not shadowed), got %+v", fl)
}
for _, want := range []string{`--params '{"format"`, "返回的消息体格式", "full", "metadata", "min: 1, max: 64", "do not use --format"} {
if !strings.Contains(cmd.Long, want) {
t.Errorf("help should contain %q so the reader uses --params, not --format; got:\n%s", want, cmd.Long)
}
}
}
// The collision guard derives reserved names from the actual flag sets — local
// flags plus the root's persistent flags passed in — so a future persistent
// flag is covered with no hand-maintained list. Here a param named "profile"
// (a root persistent flag) is skipped while a normal param is bound.
func TestParamFlagBinder_PersistentFlagReserved(t *testing.T) {
cmd := &cobra.Command{Use: "x"}
reserved := pflag.NewFlagSet("root", pflag.ContinueOnError)
reserved.String("profile", "", "use a specific profile")
m := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
"profile": map[string]interface{}{"type": "string", "location": "query"},
"id": map[string]interface{}{"type": "string", "location": "path"},
}})
b := newParamFlagBinder(cmd, m.Params(), reserved)
if cmd.Flags().Lookup("id") == nil {
t.Error("non-colliding param should get a typed flag")
}
if cmd.Flags().Lookup("profile") != nil {
t.Error("param colliding with a reserved persistent flag must not be registered")
}
found := false
for _, p := range b.paramsOnly {
if p.field.Name == "profile" {
found = true
}
}
if !found {
t.Error("colliding param should be recorded for the --params help note")
}
}
// boolIntQueryMethod is the fixture for the zero-value semantics tests: one
// boolean and one integer query param, where false and 0 are meaningful values.
func boolIntQueryMethod(required bool) meta.Method {
return meta.FromMap(map[string]interface{}{
"path": "items",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"with_deleted": map[string]interface{}{"type": "boolean", "location": "query", "required": required},
"page_size": map[string]interface{}{"type": "integer", "location": "query"},
},
})
}
// Presence is intent: a typed flag is only overlaid when explicitly Changed,
// so --flag=false / --flag 0 are real values and must be sent — not silently
// dropped as "empty", which would let the API default win over an explicit
// user choice.
func TestServiceMethod_TypedFlag_ExplicitFalseAndZeroAreSent(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), boolIntQueryMethod(false), "list", "items", nil)
cmd.SetArgs([]string{"--with-deleted=false", "--page-size", "0", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{`"with_deleted": false`, `"page_size": 0`} {
if !strings.Contains(out, want) {
t.Errorf("explicit zero value must be sent (want %s), got:\n%s", want, out)
}
}
}
// An explicitly provided false satisfies a required query parameter — the
// pre-flight must not report "missing" for a value the user just set.
func TestServiceMethod_TypedFlag_ExplicitFalseSatisfiesRequired(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), boolIntQueryMethod(true), "list", "items", nil)
cmd.SetArgs([]string{"--with-deleted=false", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("required param explicitly set to false must pass pre-flight, got: %v", err)
}
if !strings.Contains(stdout.String(), `"with_deleted": false`) {
t.Errorf("explicit false must be sent, got:\n%s", stdout.String())
}
}
// The same presence-is-intent rule applies to the --params JSON base: a key
// deliberately written as false/0 is sent. (Zero values used to be silently
// dropped; this locks the corrected semantics as the contract.)
func TestServiceMethod_Params_JSONZeroValuesAreSent(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), boolIntQueryMethod(false), "list", "items", nil)
cmd.SetArgs([]string{"--params", `{"with_deleted":false,"page_size":0}`, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{`"with_deleted": false`, `"page_size": 0`} {
if !strings.Contains(out, want) {
t.Errorf("--params zero value must be sent (want %s), got:\n%s", want, out)
}
}
}
// "" stays unusable: a required parameter fed an empty-string placeholder is
// still caught by the friendly pre-flight error, not sent as an empty value.
func TestServiceMethod_Params_EmptyStringStillMissing(t *testing.T) {
method := meta.FromMap(map[string]interface{}{
"path": "items",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"user_id_type": map[string]interface{}{"type": "string", "location": "query", "required": true},
},
})
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), method, "list", "items", nil)
cmd.SetArgs([]string{"--params", `{"user_id_type":""}`, "--dry-run"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "missing required query parameter") {
t.Fatalf("empty string for a required param should still pre-flight error, got: %v", err)
}
}
// A declared optional query param fed "" is dropped (unusable value), not sent
// as an empty query value — the declared-param loop owns the decision and the
// undeclared passthrough must not resurrect it. Undeclared keys stay the
// verbatim raw escape hatch.
func TestServiceMethod_Params_EmptyOptionalDroppedUndeclaredKept(t *testing.T) {
method := meta.FromMap(map[string]interface{}{
"path": "items",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"user_id_type": map[string]interface{}{"type": "string", "location": "query"},
},
})
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), method, "list", "items", nil)
cmd.SetArgs([]string{"--params", `{"user_id_type":"","custom_key":"v1"}`, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if strings.Contains(out, "user_id_type") {
t.Errorf("declared optional param with empty value must be dropped, got:\n%s", out)
}
if !strings.Contains(out, `"custom_key": "v1"`) {
t.Errorf("undeclared key must pass through verbatim, got:\n%s", out)
}
}
// min/max from the metadata surface on the typed flag's help line, in the same
// vocabulary as the envelope's minimum/maximum.
func TestParamFlagUsage_Bounds(t *testing.T) {
cases := []struct{ name, min, max, want string }{
{"both", "1", "100", "min: 1, max: 100"},
{"min only", "1", "", "min: 1"},
{"max only", "", "64", "max: 64"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
"page_size": map[string]interface{}{"type": "integer", "location": "query", "min": tc.min, "max": tc.max},
}}).Params()
if usage := paramFlagUsage(fields[0]); !strings.Contains(usage, tc.want) {
t.Errorf("usage = %q, want contains %q", usage, tc.want)
}
})
}
t.Run("no bounds, no clause", func(t *testing.T) {
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
"page_token": map[string]interface{}{"type": "string", "location": "query"},
}}).Params()
if usage := paramFlagUsage(fields[0]); strings.Contains(usage, "min:") || strings.Contains(usage, "max:") {
t.Errorf("usage without bounds should not mention min/max, got %q", usage)
}
})
}
// The sanitized field description rides the help line — a bare name like
// user_mailbox_id carries no meaning. The cut is at note separators (;), NOT
// at sentence ends (。): the later sentence often holds the key affordance.
func TestParamFlagUsage_Description(t *testing.T) {
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
"user_mailbox_id": map[string]interface{}{
"type": "string", "location": "path", "required": true,
"description": `用户邮箱地址。当使用用户身份访问时,可以输入"me"代表当前调用接口用户;后续补充说明不该出现`,
},
}}).Params()
usage := paramFlagUsage(fields[0])
if !strings.Contains(usage, `可以输入"me"代表当前调用接口用户`) {
t.Errorf("description must keep full sentences up to the note separator, got %q", usage)
}
if strings.Contains(usage, "补充说明") {
t.Errorf("text after the note separator must be cut, got %q", usage)
}
t.Run("long description truncated", func(t *testing.T) {
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
"x": map[string]interface{}{
"type": "string", "location": "query",
"description": strings.Repeat("长", 80),
},
}}).Params()
usage := paramFlagUsage(fields[0])
if !strings.Contains(usage, "...") {
t.Errorf("long description should be truncated with ellipsis, got %q", usage)
}
if strings.Contains(usage, strings.Repeat("长", 61)) {
t.Errorf("description should not exceed the cap, got %q", usage)
}
})
t.Run("trailing sentence punctuation trimmed", func(t *testing.T) {
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
"x": map[string]interface{}{
"type": "string", "location": "query", "description": "返回格式。",
},
}}).Params()
if usage := paramFlagUsage(fields[0]); strings.Contains(usage, "。.") {
t.Errorf("clause join must not double the punctuation, got %q", usage)
}
})
}
// Pins the convergence contract: the params-only addendum renders the SAME
// fieldFacts list the typed flag's usage line joins inline — a fact added to
// fieldFacts reaches both surfaces, and neither can drift over what a param's
// help says (the addendum once rendered values-only enums and silently lacked
// the API default).
func TestParamHelp_BothSurfacesRenderFieldFacts(t *testing.T) {
f := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
"mode": map[string]interface{}{
"type": "string", "location": "query",
"description": "模式选择。",
"default": "fast",
"min": "1", "max": "8",
"options": []interface{}{
map[string]interface{}{"value": "fast", "description": "快速"},
map[string]interface{}{"value": "full"},
},
},
}}).Params()[0]
facts := fieldFacts(f)
if len(facts) != 4 { // description, enum, bounds, API default
t.Fatalf("fieldFacts = %v, want 4 facts", facts)
}
usage := paramFlagUsage(f)
help := (&paramFlagBinder{paramsOnly: []paramsOnlyField{{field: f}}}).paramsOnlyHelp()
for _, fact := range facts {
if !strings.Contains(usage, fact) {
t.Errorf("usage line missing fact %q: %q", fact, usage)
}
if !strings.Contains(help, fact) {
t.Errorf("params-only addendum missing fact %q:\n%s", fact, help)
}
}
}
// Bounds reach the registered flag's help end to end.
func TestServiceMethod_TypedFlag_HelpShowsBounds(t *testing.T) {
method := meta.FromMap(map[string]interface{}{
"path": "items",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"page_size": map[string]interface{}{"type": "integer", "location": "query", "min": "1", "max": "100", "default": "20"},
},
})
cmd := NewCmdServiceMethod(&cmdutil.Factory{}, imSpec(), method, "list", "items", nil)
fl := cmd.Flags().Lookup("page-size")
if fl == nil {
t.Fatal("expected generated --page-size flag")
}
if !strings.Contains(fl.Usage, "min: 1, max: 100") {
t.Errorf("flag usage should carry bounds, got %q", fl.Usage)
}
}
// The missing-required hint must name both recovery paths — the typed flag and
// the --params fallback — so a reader who only knows one input style can
// proceed without a round-trip through schema.
func TestServiceMethod_MissingRequired_HintNamesFlagAndParams(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
cmd.SetArgs([]string{"--data", `{"id_list":["ou_x"]}`, "--dry-run"})
err := cmd.Execute()
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
for _, want := range []string{"--chat-id", `--params '{"chat_id": "<value>"}'`, "lark-cli schema im.chat.members.create"} {
if !strings.Contains(ve.Hint, want) {
t.Errorf("hint %q should contain %q", ve.Hint, want)
}
}
}
// A params-only required field (kebab name claimed by the standard --format
// flag) has no typed flag to offer: the hint must give only the --params form,
// never steer the reader to the colliding flag.
func TestServiceMethod_MissingRequired_ParamsOnlyHintSkipsFlag(t *testing.T) {
method := meta.FromMap(map[string]interface{}{
"path": "messages",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"format": map[string]interface{}{"type": "string", "location": "query", "required": true},
},
})
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), method, "list", "messages", nil)
cmd.SetArgs([]string{"--dry-run"})
err := cmd.Execute()
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if !strings.Contains(ve.Hint, `--params '{"format": "<value>"}'`) {
t.Errorf("hint %q should carry the --params form", ve.Hint)
}
if strings.Contains(ve.Hint, "set --format") {
t.Errorf("hint %q must not steer to the colliding --format flag", ve.Hint)
}
}

162
cmd/service/paramhelp.go Normal file
View File

@@ -0,0 +1,162 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Help rendering for generated param flags. fieldFacts is the single list of
// agent-relevant facts a param exposes; every help surface (the typed flag's
// usage line, the params-only --params addendum) renders that one list, so the
// surfaces cannot drift over which facts exist. Values come from the
// meta.Field accessors, so nothing here depends on internal/schema.
package service
import (
"fmt"
"regexp"
"strconv"
"strings"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/util"
)
// fieldFacts returns a param field's facts in display order, each as a compact
// one-line clause: the sanitized description, the allowed enum values (with
// meanings), the min/max constraint, and the API default. This is the ONE
// place that decides what a param's help says — add a fact here (e.g. a future
// deprecation marker) and every surface shows it. Unabridged prose and
// per-option detail stay in `lark-cli schema`.
func fieldFacts(f meta.Field) []string {
var facts []string
if d := sanitizeFieldDesc(f.Description); d != "" {
facts = append(facts, d)
}
if opts := f.EnumOptions(); len(opts) > 0 {
facts = append(facts, "enum: "+formatEnumInline(opts))
}
if b := formatBoundsInline(f); b != "" {
facts = append(facts, b)
}
if s := literalStr(f.CoercedDefault()); s != "" {
facts = append(facts, "API default: "+s)
}
return facts
}
// paramFlagUsage renders the typed param flag's help line:
//
// <param_name>, required|optional[. <fact>]...
//
// It leads with the canonical underscore param name (the key this flag
// overrides in --params) and required/optional, then joins the field's facts
// inline.
func paramFlagUsage(f meta.Field) string {
req := "optional"
if f.Required {
req = "required"
}
parts := append([]string{fmt.Sprintf("%s, %s", f.Name, req)}, fieldFacts(f)...)
return strings.Join(parts, ". ") + "."
}
// paramExample picks a concrete sample for a params-only field's --help snippet:
// its first allowed enum value, else its example, else a placeholder.
func paramExample(f meta.Field) string {
if vals := enumStrings(f.EnumValues()); len(vals) > 0 {
return fmt.Sprintf("%q", vals[0])
}
if s := literalStr(f.CoercedExample()); s != "" {
return fmt.Sprintf("%q", s)
}
return `"<value>"`
}
var markdownLinkRe = regexp.MustCompile(`\[([^\]]*)\]\([^)]*\)`)
// inlineClause compresses metadata prose into one help clause: markdown links
// keep their text, the clause cuts at the first rune in stops, whitespace
// collapses, trailing punctuation goes — sentence enders (the clause join adds
// its own) and connectors a cut can strand, like a colon introducing a list the
// newline cut dropped — and the result caps at max runes. The two policies
// below differ only in where they cut and how much they keep.
func inlineClause(s, stops string, max int) string {
if s == "" {
return ""
}
s = markdownLinkRe.ReplaceAllString(s, "$1")
// Backquotes must go: pflag's UnquoteUsage treats a backquoted word in a
// flag's usage string as the flag's metavar, so a description like wiki
// space_id's "可替换为`my_library`" would render the flag as
// "--space-id my_library" instead of "--space-id string".
s = strings.ReplaceAll(s, "`", "")
if i := strings.IndexAny(s, stops); i >= 0 {
s = s[:i]
}
s = strings.Join(strings.Fields(s), " ")
s = strings.TrimRight(s, "。.:,、")
return util.TruncateStrWithEllipsis(s, max)
}
// sanitizeOptionDesc is the enum-option policy: many values share one line, so
// keep only the first clause (cut at 。 too) and stay ultra-compact.
func sanitizeOptionDesc(s string) string { return inlineClause(s, "。;;\n\r", 40) }
// sanitizeFieldDesc is the field-description policy: one line per field, so
// keep full sentences and cut only at note separators (meta_data appends
// bullet notes after ;/) — the later sentence often carries the key
// affordance, e.g. user_mailbox_id's `可以输入"me"`.
func sanitizeFieldDesc(s string) string { return inlineClause(s, ";\n\r", 60) }
// formatEnumInline renders allowed values for the help line: "v=meaning" when
// the value carries a (sanitized, truncated) description — so opaque numeric
// enums like succeed_type read as "0=…|1=…|2=…" — else just "v". Full meanings
// live in the envelope's enumDescriptions / `lark-cli schema`.
func formatEnumInline(opts []meta.EnumOption) string {
items := make([]string, len(opts))
for i, o := range opts {
if d := sanitizeOptionDesc(o.Description); d != "" {
items[i] = fmt.Sprintf("%v=%s", o.Value, d)
} else {
items[i] = fmt.Sprintf("%v", o.Value)
}
}
return strings.Join(items, "|")
}
// formatBoundsInline renders the field's min/max constraint ("min: 1, max:
// 100", or the single declared side), or "" when the field declares neither.
// The vocabulary matches the envelope's minimum/maximum, so help and `lark-cli
// schema` state the same constraint.
func formatBoundsInline(f meta.Field) string {
min, max := f.MinBound(), f.MaxBound()
switch {
case min != nil && max != nil:
return fmt.Sprintf("min: %s, max: %s", formatBound(*min), formatBound(*max))
case min != nil:
return "min: " + formatBound(*min)
case max != nil:
return "max: " + formatBound(*max)
}
return ""
}
// formatBound renders a bound without a float artifact (100 not 100.000000).
func formatBound(v float64) string {
return strconv.FormatFloat(v, 'f', -1, 64)
}
// literalStr renders a coerced literal (default/example) for flag help,
// returning "" for a nil or empty value so the caller can omit the clause.
func literalStr(v interface{}) string {
if v == nil {
return ""
}
return fmt.Sprintf("%v", v)
}
func enumStrings(enum []interface{}) []string {
out := make([]string, 0, len(enum))
for _, e := range enum {
out = append(out, fmt.Sprintf("%v", e))
}
return out
}

View File

@@ -0,0 +1,61 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"strings"
"testing"
)
func TestSanitizeOptionDesc(t *testing.T) {
cases := map[string]string{
"": "",
"以 open_id 标识用户": "以 open_id 标识用户",
"中文。English second clause": "中文", // first clause only (。)
"headtail": "head", // first clause ()
"line one\nline two": "line one", // first clause (newline)
" spaced out ": "spaced out", // whitespace collapsed
"see [飞书后台](https://x/admin) 详情": "see 飞书后台 详情", // markdown link -> text, url dropped
}
for in, want := range cases {
if got := sanitizeOptionDesc(in); got != want {
t.Errorf("sanitizeOptionDesc(%q) = %q, want %q", in, got, want)
}
}
// Truncation: a long single clause is cut to 40 runes with an ellipsis,
// rune-safe (no split mid-character).
long := strings.Repeat("文", 60)
got := sanitizeOptionDesc(long)
if r := []rune(got); len(r) != 40 || !strings.HasSuffix(got, "...") {
t.Errorf("truncation = %q (%d runes), want 40 runes ending in ...", got, len(r))
}
}
func TestSanitizeFieldDesc_TrimsDanglingPunctuation(t *testing.T) {
// A clause cut can strand a connector (e.g. a colon introducing a list the
// newline cut drops, as in im.reactions.list's message_id); the help line
// joiner then renders "…获取方式:." — so dangling punctuation must go too.
cases := map[string]string{
"待查询的消息ID。ID 获取方式:\n- 调用接口获取": "待查询的消息ID。ID 获取方式",
"see the list below:\nitem": "see the list below",
"逗号结尾,\n下一行": "逗号结尾",
}
for in, want := range cases {
if got := sanitizeFieldDesc(in); got != want {
t.Errorf("sanitizeFieldDesc(%q) = %q, want %q", in, got, want)
}
}
}
func TestSanitizeFieldDesc_StripsBackquotes(t *testing.T) {
// pflag's UnquoteUsage takes a backquoted word in a flag's usage string as
// the flag's metavar: wiki space_id's description rendered the flag as
// "--space-id my_library" instead of "--space-id string".
in := "[知识空间id](https://x/wiki),如果查询我的文档库可替换为`my_library`"
want := "知识空间id如果查询我的文档库可替换为my_library"
if got := sanitizeFieldDesc(in); got != want {
t.Errorf("sanitizeFieldDesc(%q) = %q, want %q", in, got, want)
}
}

View File

@@ -10,18 +10,20 @@ import (
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/validate"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// RegisterServiceCommands registers all service commands from from_meta specs.
@@ -30,85 +32,74 @@ func RegisterServiceCommands(parent *cobra.Command, f *cmdutil.Factory) {
}
func RegisterServiceCommandsWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
for _, project := range registry.ListFromMetaProjects() {
spec := registry.LoadFromMeta(project)
if spec == nil {
// Drive the service list from the same navigation catalog the method walk
// uses — RuntimeCatalog().Services() is the deterministic, sorted view of the
// merged metadata — so registration is catalog-sourced end to end. Kept as a
// per-service loop rather than a flat WalkMethods(nil) drive precisely so a
// service with no methods still gets its bare command (WalkMethods yields one
// ref per method, so empty services would vanish).
for _, svc := range registry.RuntimeCatalog().Services() {
if svc.Name == "" || svc.ServicePath == "" {
continue
}
specName := registry.GetStrFromMap(spec, "name")
servicePath := registry.GetStrFromMap(spec, "servicePath")
if specName == "" || servicePath == "" {
continue
}
resources, _ := spec["resources"].(map[string]interface{})
if resources == nil {
continue
}
registerServiceWithContext(ctx, parent, spec, resources, f)
registerServiceWithContext(ctx, parent, svc, f)
}
}
func registerService(parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
registerServiceWithContext(context.Background(), parent, spec, resources, f)
func registerService(parent *cobra.Command, svc meta.Service, f *cmdutil.Factory) {
registerServiceWithContext(context.Background(), parent, svc, f)
}
func registerServiceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
specName := registry.GetStrFromMap(spec, "name")
specDesc := registry.GetServiceDescription(specName, "en")
if specDesc == "" {
specDesc = registry.GetStrFromMap(spec, "description")
}
func registerServiceWithContext(ctx context.Context, parent *cobra.Command, svc meta.Service, f *cmdutil.Factory) {
svcCmd := ensureChildCommand(parent, svc.Name, serviceShort(svc))
// Find existing service command or create one
var svc *cobra.Command
// Build the service's subtree from the catalog's method walk
// (apicatalog.ServiceMethods recurses nested resources), so the command tree
// is sourced from the same navigation Module as schema/scope rather than a
// hand-rolled resource/method walk. Each ref's ResourcePath becomes the
// resource-command chain — one level for a flat dotted resource like
// "chat.members", deeper for genuinely nested resources. A service with no
// methods keeps its bare command (svcCmd is created above regardless).
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
resCmd := svcCmd
for _, seg := range ref.ResourcePath {
resCmd = ensureChildCommand(resCmd, seg, seg+" operations")
}
resCmd.AddCommand(buildMethodCommand(ctx, f, newMethodCommandSpec(ref), nil, parent.PersistentFlags()))
}
}
// serviceShort is the service command's help summary: the localized description
// from the registry, falling back to the metadata's own description.
func serviceShort(svc meta.Service) string {
if d := registry.GetServiceDescription(svc.Name, "en"); d != "" {
return d
}
return svc.Description
}
// ensureChildCommand returns the child of parent named name, creating it (with
// short) when absent — so re-registration merges into an existing command tree
// instead of duplicating a level.
func ensureChildCommand(parent *cobra.Command, name, short string) *cobra.Command {
for _, c := range parent.Commands() {
if c.Name() == specName {
svc = c
break
if c.Name() == name {
return c
}
}
if svc == nil {
svc = &cobra.Command{
Use: specName,
Short: specDesc,
}
parent.AddCommand(svc)
}
for resName, resource := range resources {
resMap, _ := resource.(map[string]interface{})
if resMap == nil {
continue
}
registerResourceWithContext(ctx, svc, spec, resName, resMap, f)
}
}
func registerResourceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, name string, resource map[string]interface{}, f *cmdutil.Factory) {
res := &cobra.Command{
Use: name,
Short: name + " operations",
}
parent.AddCommand(res)
methods, _ := resource["methods"].(map[string]interface{})
for methodName, method := range methods {
methodMap, _ := method.(map[string]interface{})
if methodMap == nil {
continue
}
registerMethodWithContext(ctx, res, spec, methodMap, methodName, name, f)
}
cmd := &cobra.Command{Use: name, Short: short}
parent.AddCommand(cmd)
return cmd
}
// ServiceMethodOptions holds all inputs for a dynamically registered service method command.
type ServiceMethodOptions struct {
Factory *cmdutil.Factory
Cmd *cobra.Command
Ctx context.Context
Spec map[string]interface{}
Method map[string]interface{}
SchemaPath string
Factory *cmdutil.Factory
Cmd *cobra.Command
Ctx context.Context
ServicePath string
Method meta.Method
SchemaPath string
// Flags
Params string
@@ -123,41 +114,113 @@ type ServiceMethodOptions struct {
DryRun bool
File string // --file flag value
FileFields []string // auto-detected file field names from metadata
// binder owns the generated typed param flags — registration and the
// --params overlay — replacing the raw paramFlags side-channel.
binder *paramFlagBinder
}
// detectFileFields delegates to the shared cmdutil.DetectFileFields helper.
func detectFileFields(method map[string]interface{}) []string {
return cmdutil.DetectFileFields(method)
}
func registerMethodWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, method map[string]interface{}, name string, resName string, f *cmdutil.Factory) {
parent.AddCommand(NewCmdServiceMethodWithContext(ctx, f, spec, method, name, resName, nil))
// detectFileFields returns the request-body file-upload field names.
func detectFileFields(m meta.Method) []string {
files := m.Files()
if len(files) == 0 {
return nil
}
names := make([]string, len(files))
for i, f := range files {
names[i] = f.Name
}
return names
}
// NewCmdServiceMethod creates a command for a dynamically registered service method.
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)
func NewCmdServiceMethod(f *cmdutil.Factory, svc meta.Service, m meta.Method, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
return NewCmdServiceMethodWithContext(context.Background(), f, svc, m, name, resName, runF)
}
func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
desc := registry.GetStrFromMap(method, "description")
httpMethod := registry.GetStrFromMap(method, "httpMethod")
risk := registry.GetStrFromMap(method, "risk")
specName := registry.GetStrFromMap(spec, "name")
schemaPath := fmt.Sprintf("%s.%s.%s", specName, resName, name)
// NewCmdServiceMethodWithContext builds the command for one service method from
// its (service, resource, method) coordinates, deriving the methodCommandSpec
// via an apicatalog.MethodRef so direct callers and the catalog-driven
// registration assemble the command identically.
func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, svc meta.Service, m meta.Method, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
m.Name = name
ref := apicatalog.MethodRef{Service: svc, ResourcePath: []string{resName}, Method: m}
// No root in scope here; persistent-flag collisions don't apply to a
// standalone command, and local/standard-flag collisions are still caught.
return buildMethodCommand(ctx, f, newMethodCommandSpec(ref), runF, nil)
}
// methodCommandSpec is the static description of one generated service method
// command, read off an apicatalog.MethodRef — the single place command
// construction gets the method's facts (schema path, HTTP base path, risk,
// identities, params, file fields, request-body support), so the cobra command
// is assembled from a typed spec rather than recomputing paths/flags inline.
type methodCommandSpec struct {
method meta.Method
schemaPath string // "service.resource.method", for the --help hint
servicePath string // service HTTP base path
risk string // RiskRead | RiskWrite | RiskHighRiskWrite
restricts bool // method declares accessTokens (identity-restricted)
identities []string // permitted --as values; empty when unrestricted
params []meta.Field // path/query params -> typed flags
fileFields []string // request-body file-upload field names
// acceptsBody is whether the HTTP method allows a request body at all (so
// --data is offered as a raw escape hatch). declaresBody is whether the
// metadata documents body fields (data or file). They differ for e.g. a POST
// with no documented requestBody: --data still works, but help must not imply
// the API declares a body.
acceptsBody bool
declaresBody bool
affordance string // rendered hand-authored usage guidance (when-to-use, examples); "" if none
}
func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
m := ref.Method
return methodCommandSpec{
method: m,
schemaPath: ref.SchemaPath(),
servicePath: ref.Service.ServicePath,
risk: m.Risk,
restricts: m.RestrictsIdentity(),
identities: m.Identities(),
params: m.Params(),
fileFields: detectFileFields(m),
acceptsBody: methodTakesBody(m.HTTPMethod),
declaresBody: len(m.Data()) > 0 || len(m.Files()) > 0,
affordance: renderAffordance(m),
}
}
// methodTakesBody reports whether the HTTP method allows a request body, i.e.
// whether --data applies (as a raw escape hatch even when no body is declared).
func methodTakesBody(httpMethod string) bool {
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
return true
}
return false
}
// buildMethodCommand assembles the cobra command for a service method from its
// static spec: the standard flags, the conditional --data/--file/--yes flags,
// the generated typed param flags (via paramFlagBinder), and the risk/identity
// policy annotations.
func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodCommandSpec, runF func(*ServiceMethodOptions) error, reserved *pflag.FlagSet) *cobra.Command {
m := spec.method
opts := &ServiceMethodOptions{
Factory: f,
Spec: spec,
Method: method,
SchemaPath: schemaPath,
Factory: f,
ServicePath: spec.servicePath,
Method: m,
SchemaPath: spec.schemaPath,
FileFields: spec.fileFields,
}
var asStr string
cmd := &cobra.Command{
Use: name,
Short: desc,
Long: fmt.Sprintf("%s\n\nView parameter definitions before calling:\n lark-cli schema %s", desc, schemaPath),
Use: m.Name,
Short: m.Description,
// Long is assembled below, once the binder knows which params got no
// typed flag.
RunE: func(cmd *cobra.Command, args []string) error {
opts.Cmd = cmd
opts.Ctx = cmd.Context()
@@ -169,10 +232,15 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
},
}
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON (supports - for stdin, @file for file input)")
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin, @file for file input)")
cmd.Flags().StringVar(&opts.Params, "params", "", "Raw URL/query params JSON. Supports - and @file.")
if spec.acceptsBody {
dataUsage := "JSON request body. Supports - and @file."
if !spec.declaresBody {
// POST/etc. with no documented body fields: --data is a raw escape
// hatch, not a declared body — say so rather than imply structure.
dataUsage = "Raw JSON request body (no documented fields; see schema). Supports - and @file."
}
cmd.Flags().StringVar(&opts.Data, "data", "", dataUsage)
}
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
@@ -183,27 +251,61 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
cmd.Flags().Bool("json", false, "shorthand for --format json")
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
if risk == "high-risk-write" {
if spec.risk == cmdutil.RiskHighRiskWrite {
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
}
// Conditionally register --file for methods with file-type fields.
fileFields := detectFileFields(method)
opts.FileFields = fileFields
if len(fileFields) > 0 {
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload ([field=]path, supports - for stdin)")
}
// --file only for body methods that actually declare file-type fields.
if len(spec.fileFields) > 0 && spec.acceptsBody {
cmd.Flags().StringVar(&opts.File, "file", "", "File upload [field=]path. Supports - and stdin.")
}
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
})
cmdutil.SetTips(cmd, registry.GetStrSliceFromMap(method, "tips"))
cmdutil.SetRisk(cmd, risk)
if tokens, ok := method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
cmdutil.SetSupportedIdentities(cmd, cmdutil.AccessTokensToIdentities(tokens))
// Registered last so the collision guard sees the standard flags above.
opts.binder = newParamFlagBinder(cmd, spec.params, reserved)
// Single composition point for Long: description, affordance, schema
// pointer, and the binder's params-only addendum (params whose flag name is
// taken, reachable via --params only).
cmd.Long = methodLong(m.Description, spec.affordance, spec.schemaPath, opts.binder.paramsOnlyHelp())
// Group flags for the grouped --help renderer (typed param flags are grouped
// as API Parameters by the binder). tagFlagGroup is a no-op for flags not
// registered above (e.g. --data/--file/--yes only exist for some methods).
// --data sits under Request Body only when the metadata documents body
// fields; otherwise it's a raw escape hatch, grouped with --params so help
// doesn't imply a declared body the API doesn't have.
if fl := cmd.Flags().Lookup("data"); fl != nil {
if spec.declaresBody {
annotate(fl, flagGroupAnnotation, []string{groupBody})
} else {
annotate(fl, flagGroupAnnotation, []string{groupRaw})
}
}
tagFlagGroup(cmd.Flags(), "file", groupBody)
if fl := cmd.Flags().Lookup("params"); fl != nil {
annotate(fl, flagGroupAnnotation, []string{groupRaw})
// State the precedence rule where the agent reads it: --params is the
// base, typed flags override. Only meaningful when typed flags exist.
if len(spec.params) > 0 {
annotate(fl, flagNoteAnnotation, []string{
"Typed API parameter flags above are preferred.",
"If both are set, typed flags override matching keys in --params.",
})
}
}
for _, name := range []string{"as", "dry-run", "page-all", "page-limit", "page-delay", "yes"} {
tagFlagGroup(cmd.Flags(), name, groupExecution)
}
for _, name := range []string{"output", "format", "jq"} {
tagFlagGroup(cmd.Flags(), name, groupOutput)
}
applyGroupedUsage(cmd)
cmdutil.SetTips(cmd, m.Tips)
cmdutil.SetRisk(cmd, spec.risk)
if spec.restricts {
cmdutil.SetSupportedIdentities(cmd, spec.identities)
}
return cmd
@@ -218,8 +320,8 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
}
// Check if this API method supports the resolved identity.
if tokens, ok := opts.Method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
if err := f.CheckIdentity(opts.As, cmdutil.AccessTokensToIdentities(tokens)); err != nil {
if opts.Method.RestrictsIdentity() {
if err := f.CheckIdentity(opts.As, opts.Method.Identities()); err != nil {
return err
}
}
@@ -235,12 +337,10 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
if err != nil {
return err
}
// Identity info is now included in the JSON envelope; skip stderr printing.
// cmdutil.PrintIdentity(f.IOStreams.ErrOut, opts.As, config, f.IdentityAutoDetected)
// Identity is not printed to stderr here: it is part of the JSON envelope.
scopes, _ := opts.Method["scopes"].([]interface{})
if !opts.As.IsBot() {
if err := checkServiceScopes(opts.Ctx, f.Credential, opts.As, config, opts.Method, scopes); err != nil {
if err := checkServiceScopes(opts.Ctx, f.Credential, opts.As, config, opts.Method); err != nil {
return err
}
}
@@ -257,7 +357,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
return serviceDryRun(f, request, config, opts.Format)
}
if registry.GetStrFromMap(opts.Method, "risk") == "high-risk-write" {
if opts.Method.Risk == cmdutil.RiskHighRiskWrite {
if yes, _ := opts.Cmd.Flags().GetBool("yes"); !yes {
return cmdutil.RequireConfirmation(opts.SchemaPath)
}
@@ -302,7 +402,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
}
// checkServiceScopes pre-checks user scopes before making the API call.
func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider, identity core.Identity, config *core.CliConfig, method map[string]interface{}, scopes []interface{}) error {
func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider, identity core.Identity, config *core.CliConfig, method meta.Method) error {
if ctx.Err() != nil {
return ctx.Err()
}
@@ -311,23 +411,15 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider
return nil //nolint:nilerr // skip scope check when token resolution fails or has no scopes
}
requiredScopes, hasRequired := method["requiredScopes"].([]interface{})
if hasRequired && len(requiredScopes) > 0 {
if len(method.RequiredScopes) > 0 {
// Strict: ALL requiredScopes must be present
required := make([]string, 0, len(requiredScopes))
for _, s := range requiredScopes {
if str, ok := s.(string); ok {
required = append(required, str)
}
}
if missing := auth.MissingScopes(result.Scopes, required); len(missing) > 0 {
if missing := auth.MissingScopes(result.Scopes, method.RequiredScopes); len(missing) > 0 {
return newPreflightMissingScopeError(string(config.Brand), config.AppID, string(identity), missing)
}
return nil
}
if len(scopes) == 0 {
if len(method.Scopes) == 0 {
return nil
}
@@ -336,12 +428,12 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider
for _, s := range strings.Fields(result.Scopes) {
grantedSet[s] = true
}
for _, s := range scopes {
if str, ok := s.(string); ok && grantedSet[str] {
for _, s := range method.Scopes {
if grantedSet[s] {
return nil
}
}
recommended := registry.SelectRecommendedScope(scopes, "user")
recommended := registry.SelectRecommendedScopeFromStrings(method.Scopes, "user")
return newPreflightMissingScopeError(string(config.Brand), config.AppID, string(identity), []string{recommended})
}
@@ -362,14 +454,44 @@ func newPreflightMissingScopeError(brand, appID, identity string, missing []stri
WithIdentity(identity)
}
// unusableParamValue reports whether a provided path/query parameter value
// cannot form a usable request value: nil or an empty string. A key's presence
// in params is the intent signal — a typed flag is overlaid only when
// explicitly Changed, and a --params JSON key is deliberately written — so
// false and 0 are real values and must not be conflated with "unset"
// (reflect.IsZero would drop an explicit --with-deleted=false or --foo 0).
// Only nil/"" stay treated as missing: that keeps the friendly pre-flight
// error when a required param is fed an empty placeholder, and never emits a
// declared param as an empty path segment or query value. Undeclared keys are
// not judged by this rule — they pass through verbatim as the raw escape hatch.
func unusableParamValue(v interface{}) bool {
if v == nil {
return true
}
s, ok := v.(string)
return ok && s == ""
}
// missingParamHint is the recovery hint for a missing required parameter. It
// names both input paths — the typed flag when the binder registered one, and
// the --params fallback — plus the schema pointer. A params-only field gets
// only the --params form: a flag with its kebab name exists but belongs to
// something else (e.g. the output --format), and the hint must not steer
// there. Asking the binder, not cmd.Flags(), is what tells those apart.
func missingParamHint(opts *ServiceMethodOptions, f meta.Field) string {
paramsForm := fmt.Sprintf("--params '{%q: \"<value>\"}'", f.Name)
if opts.binder.hasTypedFlag(f.Name) {
return fmt.Sprintf("set --%s <value> (or %s); see: lark-cli schema %s", f.FlagName(), paramsForm, opts.SchemaPath)
}
return fmt.Sprintf("set %s; see: lark-cli schema %s", paramsForm, opts.SchemaPath)
}
// buildServiceRequest parses flags, builds the URL with path/query params, and returns a RawApiRequest.
// When dryRun is true and a file is provided, file reading is skipped and
// FileUploadMeta is returned instead so the caller can render dry-run output.
func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmdutil.FileUploadMeta, error) {
spec := opts.Spec
method := opts.Method
schemaPath := opts.SchemaPath
httpMethod := registry.GetStrFromMap(method, "httpMethod")
httpMethod := method.HTTPMethod
// stdin is an io.Reader consumed at most once. Only one of --params/--data
// may use "-" (stdin); the conflict check below prevents silent data loss.
@@ -387,53 +509,55 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
if err != nil {
return client.RawApiRequest{}, nil, err
}
opts.binder.overlay(opts.Cmd, params)
url := registry.GetStrFromMap(spec, "servicePath") + "/" + registry.GetStrFromMap(method, "path")
url := opts.ServicePath + "/" + method.Path
parameters, _ := method["parameters"].(map[string]interface{})
for name, param := range parameters {
p, _ := param.(map[string]interface{})
if registry.GetStrFromMap(p, "location") != "path" {
specs := method.Params()
for _, s := range specs {
if s.Location != "path" {
continue
}
val, ok := params[name]
if !ok || util.IsEmptyValue(val) {
val, ok := params[s.Name]
if !ok || unusableParamValue(val) {
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"missing required path parameter: %s", name).
WithHint("lark-cli schema %s", schemaPath).
WithParam(name)
"missing required path parameter: %s", s.Name).
WithHint("%s", missingParamHint(opts, s)).
WithParam(s.Name)
}
valStr := fmt.Sprintf("%v", val)
if err := validate.ResourceName(valStr, name); err != nil {
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(name).WithCause(err)
if err := validate.ResourceName(valStr, s.Name); err != nil {
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(s.Name).WithCause(err)
}
url = strings.Replace(url, "{"+name+"}", validate.EncodePathSegment(valStr), 1)
delete(params, name)
url = strings.Replace(url, "{"+s.Name+"}", validate.EncodePathSegment(valStr), 1)
delete(params, s.Name)
}
queryParams := map[string]interface{}{}
for name, param := range parameters {
p, _ := param.(map[string]interface{})
if registry.GetStrFromMap(p, "location") != "query" {
for _, s := range specs {
if s.Location != "query" {
continue
}
value, exists := params[name]
required, _ := p["required"].(bool)
isPaginationParam := opts.PageAll && (name == "page_token" || name == "page_size")
if required && !isPaginationParam && (!exists || util.IsEmptyValue(value)) {
value, exists := params[s.Name]
isPaginationParam := opts.PageAll && (s.Name == "page_token" || s.Name == "page_size")
if s.Required && !isPaginationParam && (!exists || unusableParamValue(value)) {
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"missing required query parameter: %s", name).
WithHint("lark-cli schema %s", schemaPath).
WithParam(name)
"missing required query parameter: %s", s.Name).
WithHint("%s", missingParamHint(opts, s)).
WithParam(s.Name)
}
if exists && !util.IsEmptyValue(value) {
queryParams[name] = value
if exists && !unusableParamValue(value) {
queryParams[s.Name] = value
}
// This loop owns declared query params: consume the key so the
// passthrough below can't resurrect a value the gate dropped (an
// unusable "" would otherwise be sent as an empty query value).
delete(params, s.Name)
}
// Whatever remains is undeclared — the raw escape hatch for params the
// metadata doesn't (yet) describe; passed through verbatim, no filtering.
for name, value := range params {
if _, ok := queryParams[name]; !ok {
queryParams[name] = value
}
queryParams[name] = value
}
request := client.RawApiRequest{

View File

@@ -8,13 +8,14 @@ import (
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/meta"
)
// highRiskDeleteMethod mirrors a simple DELETE API with a required path
// parameter and risk metadata. The returned map is what service registration
// reads; the test exercises --yes registration and the gate behavior.
func highRiskDeleteMethod() map[string]interface{} {
return map[string]interface{}{
// parameter and risk metadata. The test exercises --yes registration and the
// gate behavior.
func highRiskDeleteMethod() meta.Method {
return meta.FromMap(map[string]interface{}{
"path": "files/{file_token}",
"httpMethod": "DELETE",
"risk": "high-risk-write",
@@ -23,11 +24,11 @@ func highRiskDeleteMethod() map[string]interface{} {
"type": "string", "location": "path", "required": true,
},
},
}
})
}
func writeMethodNoRisk() map[string]interface{} {
return map[string]interface{}{
func writeMethodNoRisk() meta.Method {
return meta.FromMap(map[string]interface{}{
"path": "files/{file_token}",
"httpMethod": "DELETE",
"parameters": map[string]interface{}{
@@ -35,7 +36,7 @@ func writeMethodNoRisk() map[string]interface{} {
"type": "string", "location": "path", "required": true,
},
},
}
})
}
func TestServiceMethod_YesFlagRegisteredForHighRisk(t *testing.T) {

View File

@@ -11,6 +11,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/meta"
"github.com/spf13/cobra"
)
@@ -20,14 +21,14 @@ var testConfig = &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
func driveSpec() map[string]interface{} {
return map[string]interface{}{
func driveSpec() meta.Service {
return meta.ServiceFromMap(map[string]interface{}{
"name": "drive",
"servicePath": "/open-apis/drive/v1",
}
})
}
func driveMethod(httpMethod string, params map[string]interface{}) map[string]interface{} {
func driveMethod(httpMethod string, params map[string]interface{}) meta.Method {
m := map[string]interface{}{
"path": "files/{file_token}/copy",
"httpMethod": httpMethod,
@@ -41,7 +42,7 @@ func driveMethod(httpMethod string, params map[string]interface{}) map[string]in
},
}
}
return m
return meta.FromMap(m)
}
// ── registerService ──
@@ -49,23 +50,23 @@ func driveMethod(httpMethod string, params map[string]interface{}) map[string]in
func TestRegisterService(t *testing.T) {
parent := &cobra.Command{Use: "root"}
f := &cmdutil.Factory{}
spec := map[string]interface{}{
base := meta.ServiceFromMap(map[string]interface{}{
"name": "base",
"description": "Base API",
"servicePath": "/open-apis/base/v3",
}
resources := map[string]interface{}{
"tables": map[string]interface{}{
"methods": map[string]interface{}{
"list": map[string]interface{}{
"description": "List tables",
"httpMethod": "GET",
"resources": map[string]interface{}{
"tables": map[string]interface{}{
"methods": map[string]interface{}{
"list": map[string]interface{}{
"description": "List tables",
"httpMethod": "GET",
},
},
},
},
}
})
registerService(parent, spec, resources, f)
registerService(parent, base, f)
// service command exists
svc, _, err := parent.Find([]string{"base"})
@@ -90,18 +91,18 @@ func TestRegisterService_MergesExistingCommand(t *testing.T) {
parent.AddCommand(existing)
f := &cmdutil.Factory{}
spec := map[string]interface{}{
svc := meta.ServiceFromMap(map[string]interface{}{
"name": "base", "description": "Base API", "servicePath": "/open-apis/base/v3",
}
resources := map[string]interface{}{
"tables": map[string]interface{}{
"methods": map[string]interface{}{
"list": map[string]interface{}{"description": "List", "httpMethod": "GET"},
"resources": map[string]interface{}{
"tables": map[string]interface{}{
"methods": map[string]interface{}{
"list": map[string]interface{}{"description": "List", "httpMethod": "GET"},
},
},
},
}
})
registerService(parent, spec, resources, f)
registerService(parent, svc, f)
// Should reuse existing, not duplicate
count := 0
@@ -143,7 +144,7 @@ func TestNewCmdServiceMethod_StrictModeHidesAsFlag(t *testing.T) {
func TestNewCmdServiceMethod_GETHasNoDataFlag(t *testing.T) {
f := &cmdutil.Factory{}
cmd := NewCmdServiceMethod(f, driveSpec(),
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files", nil)
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files", nil)
if cmd.Flags().Lookup("data") != nil {
t.Error("GET method should not have --data flag")
@@ -159,7 +160,7 @@ func TestNewCmdServiceMethod_GETHasNoDataFlag(t *testing.T) {
func TestNewCmdServiceMethod_POSTHasDataFlag(t *testing.T) {
f := &cmdutil.Factory{}
cmd := NewCmdServiceMethod(f, driveSpec(),
map[string]interface{}{"description": "desc", "httpMethod": "POST"}, "create", "files", nil)
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "POST"}), "create", "files", nil)
if cmd.Flags().Lookup("data") == nil {
t.Error("POST method should have --data flag")
@@ -171,7 +172,7 @@ func TestNewCmdServiceMethod_RunFCallback(t *testing.T) {
var captured *ServiceMethodOptions
cmd := NewCmdServiceMethod(f, driveSpec(),
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files",
func(opts *ServiceMethodOptions) error {
captured = opts
return nil
@@ -268,15 +269,15 @@ func TestServiceMethod_MissingPathParam(t *testing.T) {
}
func TestServiceMethod_MissingRequiredQueryParam(t *testing.T) {
spec := map[string]interface{}{
spec := meta.ServiceFromMap(map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{
})
method := meta.FromMap(map[string]interface{}{
"path": "items", "httpMethod": "GET",
"parameters": map[string]interface{}{
"q": map[string]interface{}{"location": "query", "required": true},
},
}
})
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--params", `{}`, "--dry-run"})
@@ -291,15 +292,15 @@ func TestServiceMethod_MissingRequiredQueryParam(t *testing.T) {
}
func TestServiceMethod_PaginationParamSkippedWithPageAll(t *testing.T) {
spec := map[string]interface{}{
spec := meta.ServiceFromMap(map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{
})
method := meta.FromMap(map[string]interface{}{
"path": "items", "httpMethod": "GET",
"parameters": map[string]interface{}{
"page_size": map[string]interface{}{"location": "query", "required": true},
},
}
})
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--params", `{}`, "--page-all", "--dry-run"})
@@ -315,10 +316,10 @@ func TestServiceMethod_PaginationParamSkippedWithPageAll(t *testing.T) {
func TestServiceMethod_InvalidParamsJSON(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := map[string]interface{}{
spec := meta.ServiceFromMap(map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--params", "{bad", "--dry-run"})
@@ -333,10 +334,10 @@ func TestServiceMethod_InvalidParamsJSON(t *testing.T) {
func TestServiceMethod_InvalidDataJSON(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := map[string]interface{}{
spec := meta.ServiceFromMap(map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}}
})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "create", "items", nil)
cmd.SetArgs([]string{"--data", "{bad", "--dry-run"})
@@ -351,10 +352,10 @@ func TestServiceMethod_InvalidDataJSON(t *testing.T) {
func TestServiceMethod_ParamsAndDataBothStdinConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := map[string]interface{}{
spec := meta.ServiceFromMap(map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}}
})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "create", "items", nil)
cmd.SetArgs([]string{"--params", "-", "--data", "-", "--dry-run"})
@@ -369,10 +370,10 @@ func TestServiceMethod_ParamsAndDataBothStdinConflict(t *testing.T) {
func TestServiceMethod_OutputAndPageAllConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := map[string]interface{}{
spec := meta.ServiceFromMap(map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--page-all", "--output", "file.bin", "--as", "bot"})
@@ -398,8 +399,8 @@ func TestServiceMethod_BotMode_Success(t *testing.T) {
},
})
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot"})
@@ -427,8 +428,8 @@ func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) {
},
})
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--page-all"})
@@ -450,8 +451,8 @@ func TestServiceMethod_UnknownFormat_Warning(t *testing.T) {
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
})
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--format", "unknown"})
@@ -470,7 +471,7 @@ func TestNewCmdServiceMethod_JqFlag(t *testing.T) {
var captured *ServiceMethodOptions
cmd := NewCmdServiceMethod(f, driveSpec(),
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files",
func(opts *ServiceMethodOptions) error {
captured = opts
return nil
@@ -492,7 +493,7 @@ func TestNewCmdServiceMethod_JqShortForm(t *testing.T) {
var captured *ServiceMethodOptions
cmd := NewCmdServiceMethod(f, driveSpec(),
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files",
func(opts *ServiceMethodOptions) error {
captured = opts
return nil
@@ -508,10 +509,10 @@ func TestNewCmdServiceMethod_JqShortForm(t *testing.T) {
func TestServiceMethod_JqAndOutputConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := map[string]interface{}{
spec := meta.ServiceFromMap(map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--jq", ".data", "--output", "file.bin", "--as", "bot"})
@@ -542,8 +543,8 @@ func TestServiceMethod_JqFilter_AppliesExpression(t *testing.T) {
},
})
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--jq", ".data.items[].name"})
@@ -561,10 +562,10 @@ func TestServiceMethod_JqFilter_AppliesExpression(t *testing.T) {
func TestServiceMethod_JqAndFormatConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := map[string]interface{}{
spec := meta.ServiceFromMap(map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--jq", ".data", "--format", "ndjson", "--as", "bot"})
@@ -579,10 +580,10 @@ func TestServiceMethod_JqAndFormatConflict(t *testing.T) {
func TestServiceMethod_JqInvalidExpression(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := map[string]interface{}{
spec := meta.ServiceFromMap(map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--jq", "invalid[", "--as", "bot"})
@@ -611,8 +612,8 @@ func TestServiceMethod_PageAll_WithJq(t *testing.T) {
},
})
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--page-all", "--jq", ".data.items[].id"})
@@ -630,8 +631,8 @@ func TestServiceMethod_PageAll_WithJq(t *testing.T) {
// ── file upload ──
func imImageMethod() map[string]interface{} {
return map[string]interface{}{
func imImageMethod() meta.Method {
return meta.FromMap(map[string]interface{}{
"path": "images",
"httpMethod": "POST",
"requestBody": map[string]interface{}{
@@ -645,14 +646,14 @@ func imImageMethod() map[string]interface{} {
},
},
"accessTokens": []interface{}{"user", "tenant"},
}
})
}
func imSpec() map[string]interface{} {
return map[string]interface{}{
func imSpec() meta.Service {
return meta.ServiceFromMap(map[string]interface{}{
"name": "im",
"servicePath": "/open-apis/im/v1",
}
})
}
func TestServiceMethod_FileFlagRegistered(t *testing.T) {
@@ -684,7 +685,7 @@ func TestServiceMethod_FileFlagNotRegisteredForGET(t *testing.T) {
},
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), getMethod, "get", "images", nil)
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(getMethod), "get", "images", nil)
flag := cmd.Flags().Lookup("file")
if flag != nil {
t.Fatal("expected --file flag NOT to be registered for GET method")
@@ -752,7 +753,7 @@ func TestDetectFileFields(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := detectFileFields(tt.method)
got := detectFileFields(meta.FromMap(tt.method))
if len(got) != len(tt.want) {
t.Errorf("detectFileFields() = %v, want %v", got, tt.want)
return
@@ -771,7 +772,7 @@ func TestServiceMethod_JsonFlag_Accepted(t *testing.T) {
var captured *ServiceMethodOptions
cmd := NewCmdServiceMethod(f, driveSpec(),
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files",
func(opts *ServiceMethodOptions) error {
captured = opts
return nil

View File

@@ -0,0 +1,396 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package apicatalog is the single navigation Module over the API metadata. It
// owns every "which services/resources/methods exist and how does a path
// resolve" question that was previously duplicated across cmd/schema,
// cmd/service, internal/schema and internal/registry. It depends only on
// internal/meta; registry is the source Adapter (EmbeddedCatalog/RuntimeCatalog),
// so apicatalog never imports registry.
package apicatalog
import (
"sort"
"strings"
"github.com/larksuite/cli/internal/meta"
)
// Source records whether a catalog includes the remote overlay. It is carried
// so callers (and tests) can assert determinism instead of guessing.
type Source string
const (
SourceEmbedded Source = "embedded" // compiled-in metadata only; deterministic
SourceRuntime Source = "runtime" // embedded + remote overlay
)
// MethodFilter optionally drops methods (e.g. by identity in strict mode).
// A nil filter includes everything.
type MethodFilter func(meta.Method) bool
// Catalog is a navigation view over services with a name index. It owns its
// ordering — New sorts by name — so WalkMethods/Resolve/Complete are
// deterministic regardless of how the source adapter ordered its input.
type Catalog struct {
source Source
services []meta.Service
byName map[string]meta.Service
}
// New builds a Catalog over the given services, owning its navigation order:
// the slice is copied and sorted by name so callers may pass any order and the
// ordering contract is not delegated to the adapter. The copy is shallow —
// meta.Service values share their Resources maps, which are treated as
// read-only.
func New(source Source, services []meta.Service) Catalog {
sorted := append([]meta.Service(nil), services...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
byName := make(map[string]meta.Service, len(sorted))
for _, s := range sorted {
byName[s.Name] = s
}
return Catalog{source: source, services: sorted, byName: byName}
}
// Source reports embedded vs runtime.
func (c Catalog) Source() Source { return c.source }
// Services returns the services in name order. Treat the result as read-only:
// it is the Catalog's own ordered slice and its element Resources maps are
// shared.
func (c Catalog) Services() []meta.Service { return c.services }
// Service looks up one service by name.
func (c Catalog) Service(name string) (meta.Service, bool) {
s, ok := c.byName[name]
return s, ok
}
// Resolve maps a path (already split into segments) to a Target. An empty path
// is TargetAll. Failures return a *ResolveError carrying the available
// candidates so the command layer can render a hint.
func (c Catalog) Resolve(parts []string) (Target, error) {
if len(parts) == 0 {
return Target{Kind: TargetAll}, nil
}
svc, ok := c.byName[parts[0]]
if !ok {
return Target{}, &ResolveError{Kind: ErrService, Subject: parts[0], Candidates: c.serviceNames()}
}
if len(parts) == 1 {
return Target{Kind: TargetService, Service: svc}, nil
}
res, path, remaining, ok := findResource(svc, parts[1:])
if !ok {
return Target{}, &ResolveError{
Kind: ErrResource,
Subject: svc.Name + "." + strings.Join(parts[1:], "."),
Candidates: resourceNames(svc),
}
}
resPath := strings.Join(path, ".")
if len(remaining) == 0 {
return Target{Kind: TargetResource, Service: svc, Resource: &ResourceRef{Service: svc, Resource: res, Path: path}}, nil
}
methodName := remaining[0]
m, ok := res.Method(methodName)
if !ok {
return Target{}, &ResolveError{
Kind: ErrMethod,
Subject: svc.Name + "." + resPath + "." + methodName,
Candidates: methodNames(res),
}
}
if len(remaining) > 1 {
// Method exists but trailing segments don't resolve — reject so a typo
// doesn't silently return this method's schema.
return Target{}, &ResolveError{
Kind: ErrPath,
Subject: svc.Name + "." + resPath + "." + strings.Join(remaining, "."),
Method: methodName,
Trailing: strings.Join(remaining[1:], "."),
}
}
return Target{Kind: TargetMethod, Service: svc, Method: &MethodRef{Service: svc, Resource: res, ResourcePath: path, Method: m}}, nil
}
// MethodRefs returns the method refs selected by a resolved Target, filtered:
// TargetAll -> every method, TargetService / TargetResource -> that subtree,
// TargetMethod -> the single method if it passes the filter (else empty). It
// unifies WalkMethods/ServiceMethods/ResourceMethods so the command layer maps a
// Target to refs in one call instead of re-deciding the walker per Kind.
func (c Catalog) MethodRefs(target Target, filter MethodFilter) []MethodRef {
switch target.Kind {
case TargetService:
return ServiceMethods(target.Service, filter)
case TargetResource:
return ResourceMethods(*target.Resource, filter)
case TargetMethod:
if filter != nil && !filter(target.Method.Method) {
return nil
}
return []MethodRef{*target.Method}
case TargetAll:
return c.WalkMethods(filter)
default:
// Unknown / zero-value Kind: return nothing rather than silently
// dumping every method (the safe direction for an invalid Target).
return nil
}
}
// WalkMethods returns one MethodRef per method across all services (optionally
// filtered), recursing nested resources, in a deterministic order: services by
// name, resources by name, methods by name.
func (c Catalog) WalkMethods(filter MethodFilter) []MethodRef {
var out []MethodRef
for _, svc := range c.services {
out = append(out, ServiceMethods(svc, filter)...)
}
return out
}
// ServiceMethods returns the method refs of one service (filtered), recursing
// nested resources, in deterministic resource/method name order.
func ServiceMethods(svc meta.Service, filter MethodFilter) []MethodRef {
var out []MethodRef
walkResources(svc, svc.ResourceList(), nil, filter, &out)
return out
}
// ResourceMethods returns the method refs under one resource (filtered), using
// the resource's resolved path as the base and recursing nested resources.
func ResourceMethods(r ResourceRef, filter MethodFilter) []MethodRef {
var out []MethodRef
for _, m := range r.Resource.MethodList() {
if filter == nil || filter(m) {
out = append(out, MethodRef{Service: r.Service, Resource: r.Resource, ResourcePath: r.Path, Method: m})
}
}
walkResources(r.Service, r.Resource.SubResources(), r.Path, filter, &out)
return out
}
func walkResources(svc meta.Service, resources []meta.Resource, parentPath []string, filter MethodFilter, out *[]MethodRef) {
for _, res := range resources {
path := append(append([]string(nil), parentPath...), res.Name)
for _, m := range res.MethodList() {
if filter == nil || filter(m) {
*out = append(*out, MethodRef{Service: svc, Resource: res, ResourcePath: path, Method: m})
}
}
walkResources(svc, res.SubResources(), path, filter, out)
}
}
// Complete returns shell-completion candidates for the schema path argument,
// supporting both the legacy single dotted arg ("im.reac") and the
// space-separated form ("im reactions"). noSpace mirrors cobra's
// ShellCompDirectiveNoSpace (so "service." / "service.resource." stay open for
// the next segment). Filtering uses the caller's MethodFilter so strict-mode
// unavailable methods are hidden.
func (c Catalog) Complete(args []string, toComplete string, filter MethodFilter) (completions []string, noSpace bool) {
// Case 1: legacy single dotted arg — no resolved args yet.
if len(args) == 0 {
parts := strings.Split(toComplete, ".")
if len(parts) <= 1 {
for _, name := range c.serviceNames() {
if strings.HasPrefix(name, toComplete) {
completions = append(completions, name+".")
}
}
return completions, true
}
svc, ok := c.byName[parts[0]]
if !ok {
return nil, false
}
completions = c.completeDotted(svc, strings.Join(parts[1:], "."), filter)
allTrailingDot := len(completions) > 0
for _, comp := range completions {
if !strings.HasSuffix(comp, ".") {
allTrailingDot = false
break
}
}
return completions, allTrailingDot
}
// Case 2: space-separated form — args holds resolved segments.
svc, ok := c.byName[args[0]]
if !ok {
return nil, false
}
resource, _, _, ok := findResource(svc, args[1:])
if !ok {
// No resource matched yet — suggest top-level resources reachable in the
// current identity mode.
return completeChildren(svc.ResourceList(), nil, toComplete, filter), false
}
// Positioned in a resource — offer its methods and its sub-resources, so the
// next segment can drill deeper, symmetric to findResource's descent.
return completeChildren(resource.SubResources(), resource.MethodList(), toComplete, filter), false
}
// completeDotted suggests dotted completions for the text after the service
// segment. It descends fully-typed "resource." segments (longest match per
// level, so flat dotted keys like "chat.members" and genuinely nested resources
// both resolve), then offers the reachable sub-resources (as "…name.") and the
// methods (as "…name") of the level it lands in whose names extend the trailing
// partial token. This descent is symmetric to findResource, so completion can
// reach every method Resolve can.
func (c Catalog) completeDotted(svc meta.Service, afterService string, filter MethodFilter) []string {
subs := svc.ResourceList()
base := svc.Name
rest := afterService
var here *meta.Resource // resource we're positioned in; nil at the service root
for {
matched, n, ok := longestResourceFollowedByDot(subs, rest)
if !ok {
break
}
base += "." + matched.Name
rest = rest[n:]
r := matched
here = &r
subs = matched.SubResources()
}
var out []string
for _, sub := range subs {
if strings.HasPrefix(sub.Name, rest) && resourceReachable(sub, filter) {
out = append(out, base+"."+sub.Name+".")
}
}
if here != nil {
for _, m := range here.MethodList() {
if (filter == nil || filter(m)) && strings.HasPrefix(m.Name, rest) {
out = append(out, base+"."+m.Name)
}
}
}
sort.Strings(out)
return out
}
// completeChildren returns the sorted next-segment candidates at one level: the
// (filtered) methods and the reachable sub-resources whose names extend prefix.
// Methods are terminal; sub-resources are bare names the caller drills into on
// the next segment.
func completeChildren(subResources []meta.Resource, methods []meta.Method, prefix string, filter MethodFilter) []string {
var out []string
for _, m := range methods {
if (filter == nil || filter(m)) && strings.HasPrefix(m.Name, prefix) {
out = append(out, m.Name)
}
}
for _, sub := range subResources {
if strings.HasPrefix(sub.Name, prefix) && resourceReachable(sub, filter) {
out = append(out, sub.Name)
}
}
sort.Strings(out)
return out
}
// longestResourceFollowedByDot finds the longest resource in resources whose
// name is a fully-typed segment of text (text begins with "name."), returning
// it, the byte length consumed (incl. the dot), and whether one matched.
func longestResourceFollowedByDot(resources []meta.Resource, text string) (meta.Resource, int, bool) {
best := meta.Resource{}
bestLen := -1
for _, r := range resources {
if len(r.Name) > bestLen && strings.HasPrefix(text, r.Name+".") {
best = r
bestLen = len(r.Name)
}
}
if bestLen < 0 {
return meta.Resource{}, 0, false
}
return best, len(best.Name) + 1, true
}
// findResource resolves a resource path against a service, descending nested
// resources. At each level it consumes the longest leading run of parts that
// names a resource at that level, so both flat dotted keys ("chat.members")
// and genuinely nested resources ("spaces" > "items") resolve. This descent is
// symmetric to walkResources, which guarantees every path WalkMethods emits
// resolves back (the round-trip contract). Returns the deepest matched resource
// (Name injected), its path segments, the unconsumed remainder, and whether
// anything matched.
//
// Descent is greedy and resource-first: the one ambiguous case is a resource
// that has BOTH a method and a sub-resource of the same name — the sub-resource
// wins and shadows the method, so Resolve can never reach that method. Real
// metadata never collides the two, so this is theoretical.
func findResource(svc meta.Service, parts []string) (res meta.Resource, path []string, remaining []string, ok bool) {
level := svc.Resources
remaining = parts
for len(remaining) > 0 {
matched, name, n := longestResourcePrefix(level, remaining)
if n == 0 {
break
}
matched.Name = name
res = matched
path = append(path, name)
remaining = remaining[n:]
level = matched.Resources
ok = true
}
return res, path, remaining, ok
}
// longestResourcePrefix finds the longest leading run of segs (joined by ".")
// that names a resource in level, returning the resource, its dotted name, and
// the number of segments consumed (0 if none match). Longest-first lets a flat
// dotted key win over its single leading segment when present.
func longestResourcePrefix(level map[string]meta.Resource, segs []string) (meta.Resource, string, int) {
for i := len(segs); i >= 1; i-- {
name := strings.Join(segs[:i], ".")
if r, ok := level[name]; ok {
return r, name, i
}
}
return meta.Resource{}, "", 0
}
// resourceReachable reports whether a resource exposes a method reachable under
// the filter — directly or in any nested sub-resource (a nil filter accepts any
// method). A resource whose methods are all filtered out but which contains a
// reachable nested method is still offerable, so completion can drill into it.
func resourceReachable(res meta.Resource, filter MethodFilter) bool {
for _, m := range res.MethodList() {
if filter == nil || filter(m) {
return true
}
}
for _, sub := range res.SubResources() {
if resourceReachable(sub, filter) {
return true
}
}
return false
}
func (c Catalog) serviceNames() []string {
names := make([]string, len(c.services))
for i, s := range c.services {
names[i] = s.Name
}
return names // c.services is already name-sorted
}
func resourceNames(svc meta.Service) []string { return sortedKeys(svc.Resources) }
func methodNames(res meta.Resource) []string { return sortedKeys(res.Methods) }
func sortedKeys[V any](m map[string]V) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}

View File

@@ -0,0 +1,340 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apicatalog_test
import (
"errors"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/meta"
)
// testCatalog builds a small embedded catalog: services drive (no resources)
// and im with a dotted resource (chat.members), a multi-method resource
// (reactions, where list is user-only), and images.
func testCatalog() apicatalog.Catalog {
im := meta.ServiceFromMap(map[string]interface{}{
"name": "im",
"resources": map[string]interface{}{
"chat.members": map[string]interface{}{
"methods": map[string]interface{}{"create": map[string]interface{}{}},
},
"reactions": map[string]interface{}{
"methods": map[string]interface{}{
"create": map[string]interface{}{},
"list": map[string]interface{}{"accessTokens": []interface{}{"user"}},
},
},
"images": map[string]interface{}{
"methods": map[string]interface{}{"create": map[string]interface{}{}},
},
},
})
drive := meta.ServiceFromMap(map[string]interface{}{"name": "drive"})
return apicatalog.New(apicatalog.SourceEmbedded, []meta.Service{drive, im}) // already name-sorted
}
func TestNew_PreservesOrderAndLookup(t *testing.T) {
c := testCatalog()
if c.Source() != apicatalog.SourceEmbedded {
t.Fatalf("source = %q", c.Source())
}
names := []string{}
for _, s := range c.Services() {
names = append(names, s.Name)
}
if !reflect.DeepEqual(names, []string{"drive", "im"}) {
t.Errorf("Services order = %v, want [drive im]", names)
}
if _, ok := c.Service("im"); !ok {
t.Error("Service(im) not found")
}
if _, ok := c.Service("nope"); ok {
t.Error("Service(nope) should not be found")
}
}
// TestNew_SortsAndIsolatesInput pins the ordering contract New owns: it sorts
// arbitrary input by service name and shallow-copies the slice so later caller
// mutation can't reorder the Catalog.
func TestNew_SortsAndIsolatesInput(t *testing.T) {
in := []meta.Service{
meta.ServiceFromMap(map[string]interface{}{"name": "zeta"}),
meta.ServiceFromMap(map[string]interface{}{"name": "alpha"}),
}
c := apicatalog.New(apicatalog.SourceEmbedded, in)
names := func() []string {
var out []string
for _, s := range c.Services() {
out = append(out, s.Name)
}
return out
}
if got := names(); !reflect.DeepEqual(got, []string{"alpha", "zeta"}) {
t.Errorf("New did not sort unsorted input: %v", got)
}
// Mutating the caller's slice afterward must not reorder the Catalog.
in[0] = meta.ServiceFromMap(map[string]interface{}{"name": "MUTATED"})
if got := names(); !reflect.DeepEqual(got, []string{"alpha", "zeta"}) {
t.Errorf("Catalog order changed after caller mutated its input slice: %v", got)
}
}
func TestWalkMethods_AllAndFiltered(t *testing.T) {
c := testCatalog()
all := c.WalkMethods(nil)
got := map[string]bool{}
for _, r := range all {
got[r.SchemaPath()] = true
}
want := []string{
"im.chat.members.create",
"im.images.create",
"im.reactions.create",
"im.reactions.list",
}
if len(all) != len(want) {
t.Fatalf("WalkMethods(nil) = %d refs, want %d (%v)", len(all), len(want), got)
}
for _, w := range want {
if !got[w] {
t.Errorf("WalkMethods(nil) missing %q", w)
}
}
// Deterministic order: services by name, resources by name, methods by name.
var order []string
for _, r := range all {
order = append(order, r.SchemaPath())
}
if !reflect.DeepEqual(order, want) {
t.Errorf("WalkMethods order = %v, want %v", order, want)
}
// Filter to bot-only ("tenant"): reactions.list (user-only) drops; methods
// with no accessTokens are permissive and stay.
botOnly := func(m meta.Method) bool {
if m.AccessTokens == nil {
return true
}
for _, tok := range m.AccessTokens {
if tok == "tenant" {
return true
}
}
return false
}
filtered := c.WalkMethods(botOnly)
for _, r := range filtered {
if r.SchemaPath() == "im.reactions.list" {
t.Error("filtered walk should drop user-only im.reactions.list")
}
}
if len(filtered) != len(all)-1 {
t.Errorf("filtered walk = %d, want %d", len(filtered), len(all)-1)
}
}
func TestMethodRef_Paths_DottedResourceStaysOneSegment(t *testing.T) {
c := testCatalog()
target, err := c.Resolve([]string{"im", "chat.members", "create"})
if err != nil {
t.Fatalf("resolve: %v", err)
}
if target.Kind != apicatalog.TargetMethod {
t.Fatalf("kind = %v", target.Kind)
}
m := target.Method
if m.SchemaPath() != "im.chat.members.create" {
t.Errorf("SchemaPath = %q", m.SchemaPath())
}
if !reflect.DeepEqual(m.CommandPath(), []string{"im", "chat.members", "create"}) {
t.Errorf("CommandPath = %v", m.CommandPath())
}
if m.ResourceName() != "chat.members" {
t.Errorf("ResourceName = %q, want chat.members (one segment)", m.ResourceName())
}
if m.Method.Name != "create" {
t.Errorf("Method.Name not injected: %q", m.Method.Name)
}
}
func TestResolve_DottedAndSplitFormsEquivalent(t *testing.T) {
c := testCatalog()
// schema.ParsePath splits both "im.chat.members.create" and
// "im chat.members create" into segments; findResource's longest-prefix
// must resolve the dotted resource either way.
a, errA := c.Resolve([]string{"im", "chat", "members", "create"}) // fully split
b, errB := c.Resolve([]string{"im", "chat.members", "create"}) // resource as one segment
if errA != nil || errB != nil {
t.Fatalf("errA=%v errB=%v", errA, errB)
}
if a.Method.SchemaPath() != b.Method.SchemaPath() || a.Method.SchemaPath() != "im.chat.members.create" {
t.Errorf("forms diverged: %q vs %q", a.Method.SchemaPath(), b.Method.SchemaPath())
}
}
func TestResolve_Targets(t *testing.T) {
c := testCatalog()
if tg, _ := c.Resolve(nil); tg.Kind != apicatalog.TargetAll {
t.Errorf("empty -> %v, want all", tg.Kind)
}
if tg, _ := c.Resolve([]string{"im"}); tg.Kind != apicatalog.TargetService || tg.Service.Name != "im" {
t.Errorf("[im] -> %v/%q", tg.Kind, tg.Service.Name)
}
if tg, _ := c.Resolve([]string{"im", "reactions"}); tg.Kind != apicatalog.TargetResource || tg.Resource.SchemaPath() != "im.reactions" {
t.Errorf("[im reactions] -> %v", tg.Kind)
}
}
func TestResolve_Errors(t *testing.T) {
c := testCatalog()
cases := []struct {
parts []string
kind apicatalog.ResolveErrorKind
}{
{[]string{"nope"}, apicatalog.ErrService},
{[]string{"im", "nope"}, apicatalog.ErrResource},
{[]string{"im", "reactions", "nope"}, apicatalog.ErrMethod},
{[]string{"im", "reactions", "list", "extra"}, apicatalog.ErrPath},
}
for _, tc := range cases {
_, err := c.Resolve(tc.parts)
var re *apicatalog.ResolveError
if !errors.As(err, &re) {
t.Errorf("%v -> err %v, want *ResolveError", tc.parts, err)
continue
}
if re.Kind != tc.kind {
t.Errorf("%v -> kind %q, want %q", tc.parts, re.Kind, tc.kind)
}
if tc.kind != apicatalog.ErrPath && len(re.Candidates) == 0 {
t.Errorf("%v -> expected candidates", tc.parts)
}
}
}
// nestedCatalog adds a genuinely nested resource (spaces > items) on top of a
// flat dotted resource (chat.members), so the round-trip contract is exercised
// for real nesting — not just flat dotted keys.
func nestedCatalog() apicatalog.Catalog {
im := meta.ServiceFromMap(map[string]interface{}{
"name": "im",
"resources": map[string]interface{}{
"chat.members": map[string]interface{}{
"methods": map[string]interface{}{"create": map[string]interface{}{}},
},
"spaces": map[string]interface{}{
"methods": map[string]interface{}{"create": map[string]interface{}{}},
"resources": map[string]interface{}{
"items": map[string]interface{}{
"methods": map[string]interface{}{"get": map[string]interface{}{}},
},
},
},
},
})
return apicatalog.New(apicatalog.SourceEmbedded, []meta.Service{im})
}
// TestResolve_WalkMethodsRoundTrip is the core catalog contract: every method
// WalkMethods emits must Resolve back to the same method — both from its dotted
// SchemaPath (fully split) and from its CommandPath (resource as one segment).
// This pins findResource's nested-resource descent symmetric to walkResources,
// so "traversable" implies "resolvable".
func TestResolve_WalkMethodsRoundTrip(t *testing.T) {
for _, c := range []apicatalog.Catalog{testCatalog(), nestedCatalog()} {
for _, ref := range c.WalkMethods(nil) {
want := ref.SchemaPath()
for _, parts := range [][]string{
strings.Split(want, "."), // fully-split dotted form
ref.CommandPath(), // command form (resource stays one segment)
} {
tg, err := c.Resolve(parts)
if err != nil {
t.Errorf("round-trip %v: %v", parts, err)
continue
}
if tg.Kind != apicatalog.TargetMethod {
t.Errorf("round-trip %v: kind=%v, want method", parts, tg.Kind)
continue
}
if tg.Method.SchemaPath() != want {
t.Errorf("round-trip %v: resolved to %q, want %q", parts, tg.Method.SchemaPath(), want)
}
}
}
}
}
// TestComplete_Nested pins completion closure for genuinely nested resources:
// both the dotted and space forms must reach a nested method, symmetric to
// Resolve (findResource descends, so completion must too).
func TestComplete_Nested(t *testing.T) {
c := nestedCatalog()
// dotted: under a resource, offer its methods AND its sub-resources
if comps, ns := c.Complete(nil, "im.spaces.", nil); !reflect.DeepEqual(comps, []string{"im.spaces.create", "im.spaces.items."}) || ns {
t.Errorf("Complete([], im.spaces.) = %v noSpace=%v, want [im.spaces.create im.spaces.items.] false", comps, ns)
}
// dotted: drill into the nested sub-resource's method
if comps, ns := c.Complete(nil, "im.spaces.items.", nil); !reflect.DeepEqual(comps, []string{"im.spaces.items.get"}) || ns {
t.Errorf("Complete([], im.spaces.items.) = %v noSpace=%v, want [im.spaces.items.get] false", comps, ns)
}
// dotted: partial sub-resource name -> the sub-resource (NoSpace, more to type)
if comps, ns := c.Complete(nil, "im.spaces.it", nil); !reflect.DeepEqual(comps, []string{"im.spaces.items."}) || !ns {
t.Errorf("Complete([], im.spaces.it) = %v noSpace=%v, want [im.spaces.items.] true", comps, ns)
}
// space form: under a resource, offer methods AND sub-resources
if comps, _ := c.Complete([]string{"im", "spaces"}, "", nil); !reflect.DeepEqual(comps, []string{"create", "items"}) {
t.Errorf("Complete([im spaces], '') = %v, want [create items]", comps)
}
// space form: drill into the nested sub-resource's methods
if comps, _ := c.Complete([]string{"im", "spaces", "items"}, "", nil); !reflect.DeepEqual(comps, []string{"get"}) {
t.Errorf("Complete([im spaces items], '') = %v, want [get]", comps)
}
}
func TestComplete(t *testing.T) {
c := testCatalog()
// dotted: service prefix -> "im." (NoSpace)
if comps, ns := c.Complete(nil, "i", nil); !reflect.DeepEqual(comps, []string{"im."}) || !ns {
t.Errorf("Complete([], i) = %v noSpace=%v", comps, ns)
}
// dotted: resource prefix -> "im.reactions." (NoSpace)
if comps, _ := c.Complete(nil, "im.rea", nil); !reflect.DeepEqual(comps, []string{"im.reactions."}) {
t.Errorf("Complete([], im.rea) = %v", comps)
}
// space form: resource candidates under im (deterministic order)
comps, ns := c.Complete([]string{"im"}, "", nil)
if !reflect.DeepEqual(comps, []string{"chat.members", "images", "reactions"}) || ns {
t.Errorf("Complete([im], '') = %v noSpace=%v", comps, ns)
}
// space form: method candidates under reactions
if comps, _ := c.Complete([]string{"im", "reactions"}, "", nil); !reflect.DeepEqual(comps, []string{"create", "list"}) {
t.Errorf("Complete([im reactions], '') = %v", comps)
}
// filter applied: bot-only hides user-only list
botOnly := func(m meta.Method) bool {
if m.AccessTokens == nil {
return true
}
for _, tok := range m.AccessTokens {
if tok == "tenant" {
return true
}
}
return false
}
if comps, _ := c.Complete([]string{"im", "reactions"}, "", botOnly); !reflect.DeepEqual(comps, []string{"create"}) {
t.Errorf("Complete with bot filter = %v, want [create]", comps)
}
}

View File

@@ -0,0 +1,75 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apicatalog
import (
"strings"
"github.com/larksuite/cli/internal/meta"
)
// TargetKind classifies what a schema/command path resolves to.
type TargetKind string
const (
TargetAll TargetKind = "all" // empty path: every method
TargetService TargetKind = "service" // <service>
TargetResource TargetKind = "resource" // <service> <resource...>
TargetMethod TargetKind = "method" // <service> <resource...> <method>
)
// Target is the result of Catalog.Resolve. Resource and Method are populated
// only for TargetResource and TargetMethod respectively.
type Target struct {
Kind TargetKind
Service meta.Service
Resource *ResourceRef
Method *MethodRef
}
// ResourceRef identifies one resource within a service. Path holds the resource
// path segments (one element for the common flat dotted resource like
// "chat.members"; multiple for genuinely nested resources).
type ResourceRef struct {
Service meta.Service
Resource meta.Resource
Path []string
}
// MethodRef identifies one method, carrying the full navigation context so the
// command path and schema path can be derived without re-walking the catalog.
type MethodRef struct {
Service meta.Service
Resource meta.Resource
ResourcePath []string
Method meta.Method
}
// SchemaPath is the dotted "service.resource" identifier.
func (r ResourceRef) SchemaPath() string {
return r.Service.Name + "." + strings.Join(r.Path, ".")
}
// ServiceName returns the owning service name.
func (r MethodRef) ServiceName() string { return r.Service.Name }
// ResourceName is the dotted resource path, e.g. "chat.members".
func (r MethodRef) ResourceName() string { return strings.Join(r.ResourcePath, ".") }
// MethodName returns the method's own name.
func (r MethodRef) MethodName() string { return r.Method.Name }
// SchemaPath is the dotted "service.resource.method" identifier, e.g.
// "im.chat.members.create".
func (r MethodRef) SchemaPath() string {
return r.Service.Name + "." + strings.Join(r.ResourcePath, ".") + "." + r.Method.Name
}
// CommandPath is the CLI argv segments, e.g. ["im", "chat.members", "create"].
func (r MethodRef) CommandPath() []string {
out := make([]string, 0, len(r.ResourcePath)+2)
out = append(out, r.Service.Name)
out = append(out, r.ResourcePath...)
return append(out, r.Method.Name)
}

View File

@@ -0,0 +1,31 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apicatalog
import "strings"
// ParsePath normalizes positional command arguments into the path segments
// Resolve consumes. It accepts two equivalent forms:
//
// im.messages.reply -> single arg, split on "."
// im messages reply -> multiple args, used as-is
//
// "im chat.members bots" as a single quoted arg is NOT supported; quote
// arguments individually if your shell needs it. A resource keeps its internal
// dots when passed as one segment (e.g. "chat.members"); findResource's
// longest-prefix descent resolves both the split and the one-segment forms to
// the same target. Returns nil for zero args (bare invocation -> TargetAll).
func ParsePath(args []string) []string {
switch len(args) {
case 0:
return nil
case 1:
if strings.Contains(args[0], ".") {
return strings.Split(args[0], ".")
}
return []string{args[0]}
default:
return args
}
}

View File

@@ -1,11 +1,13 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package schema
package apicatalog_test
import (
"reflect"
"testing"
"github.com/larksuite/cli/internal/apicatalog"
)
func TestParsePath(t *testing.T) {
@@ -25,7 +27,7 @@ func TestParsePath(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ParsePath(tt.args)
got := apicatalog.ParsePath(tt.args)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("ParsePath(%v) = %v, want %v", tt.args, got, tt.want)
}

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apicatalog
// ResolveErrorKind classifies a Resolve failure so the command layer can render
// the right hint without re-deriving what was being looked up.
type ResolveErrorKind string
const (
ErrService ResolveErrorKind = "service"
ErrResource ResolveErrorKind = "resource"
ErrMethod ResolveErrorKind = "method"
ErrPath ResolveErrorKind = "path" // method exists but trailing segments don't resolve
)
// ResolveError is returned by Catalog.Resolve. Subject is the dotted thing that
// failed to resolve; Candidates lists the available names at that level (nil for
// ErrPath, which instead carries the matched Method and the unresolved Trailing).
type ResolveError struct {
Kind ResolveErrorKind
Subject string
Candidates []string
Method string
Trailing string
}
func (e *ResolveError) Error() string {
return "unknown " + string(e.Kind) + ": " + e.Subject
}

View File

@@ -47,6 +47,7 @@ type DeviceFlowResult struct {
// OAuthEndpoints contains the OAuth endpoint URLs.
type OAuthEndpoints struct {
DeviceAuthorization string
Revoke string
Token string
}
@@ -55,6 +56,7 @@ func ResolveOAuthEndpoints(brand core.LarkBrand) OAuthEndpoints {
ep := core.ResolveEndpoints(brand)
return OAuthEndpoints{
DeviceAuthorization: ep.Accounts + PathDeviceAuthorization,
Revoke: ep.Accounts + PathOAuthRevoke,
Token: ep.Open + PathOAuthTokenV2,
}
}

View File

@@ -31,6 +31,9 @@ func TestResolveOAuthEndpoints_Feishu(t *testing.T) {
if ep.DeviceAuthorization != "https://accounts.feishu.cn/oauth/v1/device_authorization" {
t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization)
}
if ep.Revoke != "https://accounts.feishu.cn/oauth/v1/revoke" {
t.Errorf("Revoke = %q", ep.Revoke)
}
if ep.Token != "https://open.feishu.cn/open-apis/authen/v2/oauth/token" {
t.Errorf("Token = %q", ep.Token)
}
@@ -42,6 +45,9 @@ func TestResolveOAuthEndpoints_Lark(t *testing.T) {
if ep.DeviceAuthorization != "https://accounts.larksuite.com/oauth/v1/device_authorization" {
t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization)
}
if ep.Revoke != "https://accounts.larksuite.com/oauth/v1/revoke" {
t.Errorf("Revoke = %q", ep.Revoke)
}
if ep.Token != "https://open.larksuite.com/open-apis/authen/v2/oauth/token" {
t.Errorf("Token = %q", ep.Token)
}

View File

@@ -7,6 +7,8 @@ package auth
const (
// PathDeviceAuthorization is the endpoint for device authorization.
PathDeviceAuthorization = "/oauth/v1/device_authorization"
// PathOAuthRevoke is the endpoint for revoking an OAuth token.
PathOAuthRevoke = "/oauth/v1/revoke"
// PathAppRegistration is the endpoint for application registration.
PathAppRegistration = "/oauth/v1/app/registration"
// PathOAuthTokenV2 is the endpoint for requesting an OAuth token (v2).

131
internal/auth/revoke.go Normal file
View File

@@ -0,0 +1,131 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
)
// RevokeToken revokes a previously issued OAuth token.
func RevokeToken(httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, token, tokenTypeHint string) error {
endpoints := ResolveOAuthEndpoints(brand)
form := url.Values{}
form.Set("client_id", appId)
form.Set("client_secret", appSecret)
form.Set("token", token)
if tokenTypeHint != "" {
form.Set("token_type_hint", tokenTypeHint)
}
req, err := http.NewRequest(http.MethodPost, endpoints.Revoke, strings.NewReader(form.Encode()))
if err != nil {
return errs.NewInternalError(errs.SubtypeUnknown, "token revoke request creation failed: %v", err).WithCause(err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "token revoke transport error: %v", err).WithCause(err)
}
defer resp.Body.Close()
logHTTPResponse(resp)
body, err := io.ReadAll(resp.Body)
if err != nil {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "token revoke read error: %v", err).WithCause(err)
}
if resp.StatusCode >= 400 {
return revokeHTTPStatusError(resp.StatusCode, body)
}
if len(body) == 0 {
return nil
}
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return nil
}
if code := getInt(data, "code", 0); code != 0 {
msg := getStr(data, "msg")
if msg == "" {
msg = getStr(data, "message")
}
if msg == "" {
msg = "unknown error"
}
return errs.NewAPIError(errs.SubtypeUnknown, "token revoke failed [%d]: %s", code, msg).
WithCode(code).
WithCause(errors.New(msg))
}
if errStr := getStr(data, "error"); errStr != "" {
msg := getStr(data, "error_description")
if msg == "" {
msg = errStr
}
return errs.NewAPIError(errs.SubtypeUnknown, "token revoke failed: %s", msg).
WithCause(errors.New(msg))
}
return nil
}
func revokeHTTPStatusError(status int, body []byte) error {
msg := formatOAuthErrorBody(body)
cause := errors.New(strings.TrimSpace(string(body)))
if strings.TrimSpace(string(body)) == "" {
cause = errors.New(msg)
}
if status >= http.StatusInternalServerError {
return errs.NewNetworkError(errs.SubtypeNetworkServer, "token revoke failed: HTTP %d: %s", status, msg).
WithCode(status).
WithRetryable().
WithCause(cause)
}
subtype := errs.SubtypeUnknown
if status == http.StatusNotFound {
subtype = errs.SubtypeNotFound
}
return errs.NewAPIError(subtype, "token revoke failed: HTTP %d: %s", status, msg).
WithCode(status).
WithCause(cause)
}
func formatOAuthErrorBody(body []byte) string {
trimmed := strings.TrimSpace(string(body))
if trimmed == "" {
return "empty response"
}
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return trimmed
}
if msg := getStr(data, "error_description"); msg != "" {
return msg
}
if msg := getStr(data, "msg"); msg != "" {
return msg
}
if msg := getStr(data, "message"); msg != "" {
return msg
}
if msg := getStr(data, "error"); msg != "" {
return msg
}
return trimmed
}

View File

@@ -0,0 +1,207 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"errors"
"net/http"
"net/url"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
)
type revokeRoundTripFunc func(*http.Request) (*http.Response, error)
func (fn revokeRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req)
}
type errReadCloser struct {
err error
}
func (r errReadCloser) Read(_ []byte) (int, error) {
return 0, r.err
}
func (r errReadCloser) Close() error {
return nil
}
func TestRevokeToken_PostsExpectedForm(t *testing.T) {
reg := &httpmock.Registry{}
t.Cleanup(func() { reg.Verify(t) })
stub := &httpmock.Stub{
Method: "POST",
URL: PathOAuthRevoke,
Body: map[string]interface{}{"code": 0},
BodyFilter: func(body []byte) bool {
values, err := url.ParseQuery(string(body))
if err != nil {
return false
}
return values.Get("client_id") == "cli_a" &&
values.Get("client_secret") == "secret_b" &&
values.Get("token") == "user-access-token" &&
values.Get("token_type_hint") == "access_token"
},
}
reg.Register(stub)
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
if err != nil {
t.Fatalf("RevokeToken() error = %v", err)
}
if got := stub.CapturedHeaders.Get("Content-Type"); got != "application/x-www-form-urlencoded" {
t.Fatalf("Content-Type = %q", got)
}
}
func TestRevokeToken_DoFailureReturnsTypedNetworkError(t *testing.T) {
sentinel := errors.New("transport down")
httpClient := &http.Client{
Transport: revokeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, sentinel
}),
}
err := RevokeToken(httpClient, "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
if err == nil {
t.Fatal("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkTransport {
t.Fatalf("problem = %#v, want network/transport", p)
}
if !errors.Is(err, sentinel) {
t.Fatalf("expected cause %v to be preserved, got %v", sentinel, err)
}
}
func TestRevokeToken_ReportsHTTPError(t *testing.T) {
reg := &httpmock.Registry{}
t.Cleanup(func() { reg.Verify(t) })
reg.Register(&httpmock.Stub{
Method: "POST",
URL: PathOAuthRevoke,
Status: 400,
Body: map[string]interface{}{"error": "invalid_token"},
})
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
if err == nil {
t.Fatal("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if p.Category != errs.CategoryAPI || p.Code != 400 {
t.Fatalf("problem = %#v, want api error with HTTP 400", p)
}
if !strings.Contains(err.Error(), "invalid_token") {
t.Fatalf("expected invalid_token error, got %v", err)
}
}
func TestRevokeToken_ReportsOAuthCodeErrorAsTypedAPIError(t *testing.T) {
reg := &httpmock.Registry{}
t.Cleanup(func() { reg.Verify(t) })
reg.Register(&httpmock.Stub{
Method: "POST",
URL: PathOAuthRevoke,
Body: map[string]interface{}{
"code": 12345,
"msg": "invalid revoke state",
},
})
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
if err == nil {
t.Fatal("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if p.Category != errs.CategoryAPI || p.Code != 12345 {
t.Fatalf("problem = %#v, want api error with code 12345", p)
}
if !strings.Contains(err.Error(), "invalid revoke state") {
t.Fatalf("expected oauth error message, got %v", err)
}
}
func TestRevokeToken_ReportsOAuthErrorFieldAsTypedAPIError(t *testing.T) {
reg := &httpmock.Registry{}
t.Cleanup(func() { reg.Verify(t) })
reg.Register(&httpmock.Stub{
Method: "POST",
URL: PathOAuthRevoke,
Body: map[string]interface{}{
"error": "invalid_token",
"error_description": "token already expired",
},
})
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
if err == nil {
t.Fatal("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if p.Category != errs.CategoryAPI {
t.Fatalf("problem = %#v, want api error", p)
}
if !strings.Contains(err.Error(), "token already expired") {
t.Fatalf("expected oauth error_description, got %v", err)
}
}
func TestRevokeToken_ReadFailureReturnsTypedInternalError(t *testing.T) {
sentinel := errors.New("read failed")
httpClient := &http.Client{
Transport: revokeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: errReadCloser{err: sentinel},
Header: make(http.Header),
}, nil
}),
}
err := RevokeToken(httpClient, "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
if err == nil {
t.Fatal("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("problem = %#v, want internal/invalid_response", p)
}
if !errors.Is(err, sentinel) {
t.Fatalf("expected cause %v to be preserved, got %v", sentinel, err)
}
if !strings.Contains(err.Error(), "token revoke read error") {
t.Fatalf("expected read error message, got %v", err)
}
if _, ok := err.(*errs.InternalError); !ok {
t.Fatalf("expected *errs.InternalError, got %T", err)
}
}

View File

@@ -100,9 +100,19 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
return nil
}
// warnIfProxied is a test seam for the proxy-warning gate. Production wires it
// to transport.WarnIfProxied; tests swap in a spy to count invocations. It is
// needed because the real function is guarded by an internal sync.Once, so
// calling it directly would only fire on the first test (see
// factory_proxy_warn_test.go). The terminal check is the IOStreams
// .StderrIsTerminal field, which tests set directly.
var warnIfProxied = transport.WarnIfProxied
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
return sync.OnceValues(func() (*http.Client, error) {
transport.WarnIfProxied(f.IOStreams.ErrOut)
if f.IOStreams.StderrIsTerminal {
warnIfProxied(f.IOStreams.ErrOut)
}
var rt http.RoundTripper = transport.Shared()
rt = &RetryTransport{Base: rt}
@@ -129,7 +139,9 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
lark.WithLogLevel(larkcore.LogLevelError),
lark.WithHeaders(BaseSecurityHeaders()),
}
transport.WarnIfProxied(f.IOStreams.ErrOut)
if f.IOStreams.StderrIsTerminal {
warnIfProxied(f.IOStreams.ErrOut)
}
opts = append(opts, lark.WithHttpClient(&http.Client{
Transport: buildSDKTransport(),
CheckRedirect: safeRedirectPolicy,

View File

@@ -0,0 +1,85 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"io"
"testing"
_ "github.com/larksuite/cli/extension/credential/env" // registers the env-backed account provider
"github.com/larksuite/cli/internal/envvars"
)
// installProxyWarnSpy replaces warnIfProxied with a counter for one test and
// restores it on cleanup. Returns a pointer to the call count so the caller can
// assert how many times the warning fired. The terminal state is controlled via
// the IOStreams.StderrIsTerminal field, not a seam.
func installProxyWarnSpy(t *testing.T) *int {
t.Helper()
prevWarn := warnIfProxied
t.Cleanup(func() { warnIfProxied = prevWarn })
calls := 0
warnIfProxied = func(io.Writer) { calls++ }
return &calls
}
var proxyWarnGateCases = []struct {
name string
terminal bool
want int
}{
{"terminal stderr warns once", true, 1},
{"non-terminal stderr stays silent", false, 0},
}
// TestCachedHttpClientFunc_ProxyWarnGate verifies the http-client init path
// invokes WarnIfProxied only when stderr is an interactive terminal.
func TestCachedHttpClientFunc_ProxyWarnGate(t *testing.T) {
for _, tc := range proxyWarnGateCases {
t.Run(tc.name, func(t *testing.T) {
calls := installProxyWarnSpy(t)
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{
ErrOut: io.Discard, StderrIsTerminal: tc.terminal,
}})
if _, err := fn(); err != nil {
t.Fatalf("http client init: %v", err)
}
if *calls != tc.want {
t.Errorf("WarnIfProxied calls = %d, want %d", *calls, tc.want)
}
})
}
}
// TestCachedLarkClientFunc_ProxyWarnGate verifies the lark-client init path
// invokes WarnIfProxied only when stderr is an interactive terminal. The gate
// runs after ResolveAccount, so an env-backed credential is wired up to let
// account resolution succeed without network or config files.
func TestCachedLarkClientFunc_ProxyWarnGate(t *testing.T) {
for _, tc := range proxyWarnGateCases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv(envvars.CliAppID, "env-app")
t.Setenv(envvars.CliAppSecret, "env-secret")
t.Setenv(envvars.CliDefaultAs, "")
t.Setenv(envvars.CliUserAccessToken, "")
t.Setenv(envvars.CliTenantAccessToken, "")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
calls := installProxyWarnSpy(t)
// normalizeStreams copies the struct (out := *s), so the
// StderrIsTerminal field survives into f.IOStreams.
f := NewDefault(&IOStreams{ErrOut: io.Discard, StderrIsTerminal: tc.terminal}, InvocationContext{})
if _, err := cachedLarkClientFunc(f)(); err != nil {
t.Fatalf("lark client init: %v", err)
}
if *calls != tc.want {
t.Errorf("WarnIfProxied calls = %d, want %d", *calls, tc.want)
}
})
}
}

View File

@@ -12,23 +12,9 @@ import (
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// DetectFileFields returns field names with type "file" in the method's requestBody.
func DetectFileFields(method map[string]interface{}) []string {
rb, _ := method["requestBody"].(map[string]interface{})
var fields []string
for name, field := range rb {
f, _ := field.(map[string]interface{})
if registry.GetStrFromMap(f, "type") == "file" {
fields = append(fields, name)
}
}
return fields
}
// ParseFileFlag parses a --file flag value into its components.
// The format is either "path" or "field=path". When no explicit "field="
// prefix is present, defaultField is used as the field name.

View File

@@ -10,22 +10,6 @@ import (
"github.com/larksuite/cli/internal/core"
)
// AccessTokensToIdentities converts from_meta accessTokens (e.g. ["tenant", "user"])
// to CLI identity names (e.g. ["bot", "user"]).
func AccessTokensToIdentities(tokens []interface{}) []string {
var identities []string
for _, t := range tokens {
if ts, ok := t.(string); ok {
if ts == "tenant" {
identities = append(identities, "bot")
} else {
identities = append(identities, ts)
}
}
}
return identities
}
// PrintIdentity outputs the current identity to stderr so callers (including AI agents)
// can see which identity is being used for the API call.
func PrintIdentity(w io.Writer, as core.Identity, config *core.CliConfig, autoDetected bool) {

View File

@@ -11,54 +11,6 @@ import (
"github.com/larksuite/cli/internal/core"
)
func TestAccessTokensToIdentities(t *testing.T) {
tests := []struct {
name string
tokens []interface{}
want []string
}{
{
name: "tenant becomes bot",
tokens: []interface{}{"tenant"},
want: []string{"bot"},
},
{
name: "user stays user",
tokens: []interface{}{"user"},
want: []string{"user"},
},
{
name: "tenant and user",
tokens: []interface{}{"tenant", "user"},
want: []string{"bot", "user"},
},
{
name: "empty list",
tokens: []interface{}{},
want: nil,
},
{
name: "non-string values skipped",
tokens: []interface{}{"tenant", 42, "user"},
want: []string{"bot", "user"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := AccessTokensToIdentities(tt.tokens)
if len(got) != len(tt.want) {
t.Fatalf("len: want %d, got %d (%v)", len(tt.want), len(got), got)
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("[%d] want %s, got %s", i, tt.want[i], got[i])
}
}
})
}
}
func TestPrintIdentity_BotExplicit(t *testing.T) {
var buf bytes.Buffer
PrintIdentity(&buf, core.AsBot, nil, false)

View File

@@ -18,17 +18,28 @@ type IOStreams struct {
Out io.Writer
ErrOut io.Writer
IsTerminal bool
// StderrIsTerminal reports whether ErrOut is an interactive terminal.
// Advisory warnings written to stderr (e.g. the proxy notice) gate on this
// so they stay out of non-interactive output (pipes, CI, agent runs).
// Computed once in NewIOStreams, mirroring IsTerminal; tests assign it
// directly like cmd/config/bind_test.go does for IsTerminal.
StderrIsTerminal bool
}
// NewIOStreams builds an IOStreams from arbitrary readers/writers.
// IsTerminal is derived from in's underlying *os.File, if any; non-file
// readers (bytes.Buffer, strings.Reader, …) yield IsTerminal=false.
// IsTerminal / StderrIsTerminal are derived from in's / errOut's underlying
// *os.File, if any; non-file streams (bytes.Buffer, strings.Reader, …) yield
// false.
func NewIOStreams(in io.Reader, out, errOut io.Writer) *IOStreams {
isTerminal := false
if f, ok := in.(*os.File); ok {
isTerminal = term.IsTerminal(int(f.Fd()))
}
return &IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal}
stderrIsTerminal := false
if f, ok := errOut.(*os.File); ok {
stderrIsTerminal = term.IsTerminal(int(f.Fd()))
}
return &IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal, StderrIsTerminal: stderrIsTerminal}
}
// SystemIO creates an IOStreams wired to the process's standard file descriptors.

View File

@@ -34,7 +34,9 @@ func ParseOptionalBody(httpMethod, data string, stdin io.Reader, fileIO fileio.F
return body, nil
}
// ParseJSONMap parses a JSON string into a map. Returns an empty map if input is empty.
// ParseJSONMap parses a JSON string into a map. Returns an empty (never nil) map
// for empty input or the JSON literal null, so callers can always overlay onto
// the result without a nil-map panic.
// Supports stdin (-), @file, @@-escape, and single-quote stripping via ResolveInput.
func ParseJSONMap(input, label string, stdin io.Reader, fileIO fileio.FileIO) (map[string]any, error) {
resolved, err := ResolveInput(input, stdin, fileIO)
@@ -48,5 +50,10 @@ func ParseJSONMap(input, label string, stdin io.Reader, fileIO fileio.FileIO) (m
if err := json.Unmarshal([]byte(resolved), &result); err != nil {
return nil, output.ErrValidation("%s invalid format, expected JSON object", label)
}
if result == nil {
// `null` unmarshals into a nil map without error; normalize it so the
// returned map is always writable, matching the empty-input case.
return map[string]any{}, nil
}
return result, nil
}

View File

@@ -47,6 +47,7 @@ func TestParseJSONMap(t *testing.T) {
wantErr bool
}{
{"empty input", "", "--params", 0, false},
{"json null", "null", "--params", 0, false},
{"valid json", `{"a":"1","b":"2"}`, "--params", 2, false},
{"invalid json", `{bad}`, "--params", 0, true},
{"json array", `[1,2]`, "--data", 0, true},
@@ -61,6 +62,12 @@ func TestParseJSONMap(t *testing.T) {
if !tt.wantErr && len(got) != tt.wantLen {
t.Errorf("ParseJSONMap() returned map with %d keys, want %d", len(got), tt.wantLen)
}
// A successful parse must yield a non-nil, writable map: callers
// overlay onto it (params[k]=v), so `null` — which unmarshals to a
// nil map without error — must normalize to {} like empty input.
if !tt.wantErr && got == nil {
t.Error("ParseJSONMap() = nil map on success, want non-nil")
}
})
}
}

View File

@@ -3,17 +3,20 @@
package cmdutil
import "github.com/spf13/cobra"
import (
"github.com/larksuite/cli/internal/core"
"github.com/spf13/cobra"
)
const riskLevelAnnotationKey = "risk_level"
// Risk level constants — the three-tier convention used across the CLI.
// Use these in place of string literals so the typo radius is one place,
// not every call site.
// Risk level constants — aliases of the canonical core.Risk* values, re-exported
// here so command code gets the risk vocabulary and the SetRisk/GetRisk helpers
// from one package. core is the single source of truth.
const (
RiskRead = "read"
RiskWrite = "write"
RiskHighRiskWrite = "high-risk-write"
RiskRead = core.RiskRead
RiskWrite = core.RiskWrite
RiskHighRiskWrite = core.RiskHighRiskWrite
)
// SetRisk stores a command's static risk level on cobra annotations so the

15
internal/core/risk.go Normal file
View File

@@ -0,0 +1,15 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
// Risk levels — the three-tier convention used across the CLI. They live here,
// at the leaf, so the envelope renderer (internal/schema) and the command
// toolkit (internal/cmdutil) share one vocabulary without a renderer depending
// on command utilities. Framework confirmation gating acts only on
// RiskHighRiskWrite.
const (
RiskRead = "read"
RiskWrite = "write"
RiskHighRiskWrite = "high-risk-write"
)

View File

@@ -22,6 +22,12 @@ func ParseBrand(value string) LarkBrand {
return BrandFeishu
}
// OAuthTokenV3Path is the unified OAuth 2.0 Token Endpoint path on the accounts
// domain. It serves every grant type (client_credentials for TAT,
// authorization_code / device_code / refresh_token for UAT) and replaces the
// legacy per-token endpoints (e.g. /open-apis/auth/v3/tenant_access_token/internal).
const OAuthTokenV3Path = "/oauth/v3/token"
// Endpoints holds resolved endpoint URLs for different Lark services.
type Endpoints struct {
Open string // e.g. "https://open.feishu.cn"

View File

@@ -42,6 +42,11 @@ func TestResolveEndpoints_EmptyDefaultsToFeishu(t *testing.T) {
if ep.Open != "https://open.feishu.cn" {
t.Errorf("Open = %q, want feishu.cn for empty brand", ep.Open)
}
// The unified OAuth v3 Token Endpoint mints TAT on the accounts domain;
// pin the default-brand host so a stray non-production domain revert is caught.
if ep.Accounts != "https://accounts.feishu.cn" {
t.Errorf("Accounts = %q, want accounts.feishu.cn for empty brand", ep.Accounts)
}
}
func TestResolveOpenBaseURL(t *testing.T) {

View File

@@ -19,33 +19,44 @@ import (
extcred "github.com/larksuite/cli/extension/credential"
)
// classifyTATResponseCode wraps a non-zero TAT endpoint response code into the
// canonical typed error. The TAT mint endpoint reports invalid credentials
// with two distinct codes:
// classifyTATResponseCode wraps a deterministic (non-transient) failure from the
// unified Token Endpoint into the canonical typed errs.* error. The v3 endpoint
// reports failures using the OAuth 2.0 model — an `error` string plus an
// optional numeric `code` — instead of the legacy `{code, msg}` shape.
//
// - 10003: bad app_id format or non-existent app_id ("invalid param")
// - 10014: invalid app_secret ("app secret invalid")
//
// Both surface as CategoryConfig/InvalidClient from the user's perspective —
// the configured credentials cannot mint a tenant access token. 10014 is
// globally mapped in codemeta (TAT-mint-specific variant of OAuth 99991543).
// 10003 is NOT globally mapped because in other Lark endpoints it carries
// unrelated semantics (e.g. task API uses 10003 for permission denied), so
// the override stays local to this TAT call site instead of leaking into the
// shared codemeta table.
func classifyTATResponseCode(code int, msg, brand, appID string) error {
if code == 10003 {
// invalid_client / unauthorized_client mean the configured app_id/app_secret
// cannot mint a token; from the user's perspective that is the same actionable
// CategoryConfig/InvalidClient failure the legacy 10003/10014 codes produced.
// Every other deterministic error falls through to BuildAPIError, which still
// yields a typed error so probe callers (errs.IsTyped) surface it rather than
// swallowing it. Transient/server-side failures (5xx / server_error) are
// filtered out by FetchTAT before this is called, so they stay untyped.
func classifyTATResponseCode(code int, oauthErr, errDesc, brand, appID string) error {
msg := errDesc
if msg == "" {
msg = oauthErr
}
switch oauthErr {
case "invalid_client", "unauthorized_client":
return errs.NewConfigError(errs.SubtypeInvalidClient, "%s", msg).
WithCode(code).
WithHint("%s", errclass.ConfigHint(errs.SubtypeInvalidClient))
}
return errclass.BuildAPIError(map[string]any{
if err := errclass.BuildAPIError(map[string]any{
"code": code,
"msg": msg,
}, errclass.ClassifyContext{
Brand: brand,
AppID: appID,
})
}); err != nil {
return err
}
// BuildAPIError returns nil for code 0 (Feishu's success convention), but this
// function is only reached once FetchTAT has ruled out success — a non-credential
// OAuth error (e.g. invalid_scope) can arrive with code 0 and is still a
// deterministic rejection. Back it with a typed APIError so callers never receive
// the ("", nil) "empty token, no error" pair.
return errs.NewAPIError(errs.SubtypeUnknown, "%s", msg).WithCode(code)
}
// DefaultAccountProvider resolves account from config.json via keychain.
@@ -146,8 +157,8 @@ func (p *DefaultTokenProvider) resolveUAT(ctx context.Context) (*TokenResult, er
return &TokenResult{Token: token, Scopes: scopes}, nil
}
// resolveTAT resolves a tenant access token. Result is cached after first call.
// NOTE: Uses sync.Once — only the context from the first call is used.
// resolveTAT resolves a tenant access token. The result is cached after the first
// call via sync.Once — only the context from the first call is used.
func (p *DefaultTokenProvider) resolveTAT(ctx context.Context) (*TokenResult, error) {
p.tatOnce.Do(func() {
p.tatResult, p.tatErr = p.doResolveTAT(ctx)

View File

@@ -19,18 +19,16 @@ func TestDefaultAccountProvider_Implements(t *testing.T) {
var _ DefaultAccountResolver = &DefaultAccountProvider{}
}
// TestClassifyTATResponseCode_10003_MapsToInvalidClient pins that the TAT
// endpoint's "invalid param" code surfaces as CategoryConfig/InvalidClient.
// Reason: a bad or non-existent app_id triggers 10003 on the TAT mint endpoint,
// which from the user's perspective is the same actionable failure as 10014
// ("app secret invalid") — both mean the configured credentials cannot mint a
// tenant access token. The global codemeta intentionally does not map 10003
// because in other Lark endpoints 10003 carries unrelated semantics (e.g. task
// API uses it for permission denied), so the override is local to this site.
func TestClassifyTATResponseCode_10003_MapsToInvalidClient(t *testing.T) {
err := classifyTATResponseCode(10003, "invalid param", "feishu", "cli_app_x")
// TestClassifyTATResponseCode_InvalidClient_MapsToInvalidClient pins that the
// unified Token Endpoint's OAuth2 invalid_client error surfaces as
// CategoryConfig/InvalidClient — the configured app_id/app_secret cannot mint a
// tenant access token, the same actionable failure the legacy 10003/10014 codes
// produced. The numeric code is intentionally not asserted: the v3 endpoint may
// return invalid_client with no Lark code (code defaults to 0).
func TestClassifyTATResponseCode_InvalidClient_MapsToInvalidClient(t *testing.T) {
err := classifyTATResponseCode(0, "invalid_client", "client authentication failed", "feishu", "cli_app_x")
if err == nil {
t.Fatal("expected non-nil error for code=10003")
t.Fatal("expected non-nil error for invalid_client")
}
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
@@ -42,22 +40,16 @@ func TestClassifyTATResponseCode_10003_MapsToInvalidClient(t *testing.T) {
if cfgErr.Subtype != errs.SubtypeInvalidClient {
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
}
if cfgErr.Code != 10003 {
t.Errorf("Code = %d, want 10003", cfgErr.Code)
}
if cfgErr.Hint == "" {
t.Error("Hint must be non-empty so the user gets a recovery action")
}
}
// TestClassifyTATResponseCode_10014_RoutesViaCodeMeta pins that 10014 still
// goes through the global BuildAPIError path (codemeta entry) so the override
// for 10003 does not regress the existing mapping.
func TestClassifyTATResponseCode_10014_RoutesViaCodeMeta(t *testing.T) {
err := classifyTATResponseCode(10014, "app secret invalid", "feishu", "cli_app_x")
if err == nil {
t.Fatal("expected non-nil error for code=10014")
}
// TestClassifyTATResponseCode_UnauthorizedClient_MapsToInvalidClient pins that
// unauthorized_client is treated as the same credential failure as
// invalid_client.
func TestClassifyTATResponseCode_UnauthorizedClient_MapsToInvalidClient(t *testing.T) {
err := classifyTATResponseCode(0, "unauthorized_client", "client not authorized", "feishu", "cli_app_x")
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err)
@@ -65,21 +57,38 @@ func TestClassifyTATResponseCode_10014_RoutesViaCodeMeta(t *testing.T) {
if cfgErr.Subtype != errs.SubtypeInvalidClient {
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
}
if cfgErr.Code != 10014 {
t.Errorf("Code = %d, want 10014", cfgErr.Code)
}
}
// TestClassifyTATResponseCode_UnknownCodeFallsThrough pins that codes outside
// the credential set fall through to the generic BuildAPIError fallback
// (CategoryAPI/SubtypeUnknown) — the override is narrow and intentional.
func TestClassifyTATResponseCode_UnknownCodeFallsThrough(t *testing.T) {
err := classifyTATResponseCode(99999999, "some unknown failure", "feishu", "cli_app_x")
// TestClassifyTATResponseCode_OtherErrorFallsThrough pins that OAuth errors
// outside the credential set fall through to the generic BuildAPIError fallback
// — still typed, but not a ConfigError. The mapping is narrow and intentional.
func TestClassifyTATResponseCode_OtherErrorFallsThrough(t *testing.T) {
err := classifyTATResponseCode(20068, "invalid_scope", "unauthorized scope", "feishu", "cli_app_x")
if err == nil {
t.Fatal("expected non-nil error for unmapped code")
t.Fatal("expected non-nil error for invalid_scope")
}
var cfgErr *errs.ConfigError
if errors.As(err, &cfgErr) {
t.Fatalf("unmapped code must not be classified as ConfigError, got %T", err)
t.Fatalf("invalid_scope must not be classified as ConfigError, got %T", err)
}
}
// TestClassifyTATResponseCode_CodeZeroOtherError_StillTyped pins the code-0
// backstop: a non-credential OAuth error (e.g. invalid_scope) that arrives with no
// numeric code (code 0) must still produce a non-nil typed error. BuildAPIError
// returns nil for code 0 (Feishu's success convention); without the backstop,
// FetchTAT would surface this deterministic rejection as ("", nil) — an empty token
// with no error.
func TestClassifyTATResponseCode_CodeZeroOtherError_StillTyped(t *testing.T) {
err := classifyTATResponseCode(0, "invalid_scope", "the requested scope is not granted", "feishu", "cli_app_x")
if err == nil {
t.Fatal("expected non-nil error for code-0 invalid_scope (must not be swallowed as success)")
}
if !errs.IsTyped(err) {
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
}
var cfgErr *errs.ConfigError
if errors.As(err, &cfgErr) {
t.Fatalf("code-0 invalid_scope must not be a ConfigError, got %T", err)
}
}

View File

@@ -4,46 +4,47 @@
package credential
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/larksuite/cli/internal/core"
)
// FetchTAT performs a single HTTP POST to mint a tenant access token with the
// given credentials. It does not read configuration or keychain, so callers
// that already hold plaintext credentials (e.g. the post-`config init` probe)
// can validate them without a second keychain round-trip.
// FetchTAT performs a single HTTP POST to mint a tenant access token via the
// unified OAuth 2.0 Token Endpoint ({accounts}/oauth/v3/token) using the
// client_credentials grant with client_secret_post authentication. It does not
// read configuration or keychain, so callers that already hold plaintext
// credentials (e.g. the post-`config init` probe) can validate them without a
// second keychain round-trip.
//
// A non-zero TAT response code means the server inspected the payload and
// rejected the credentials; FetchTAT returns the canonical typed error from
// classifyTATResponseCode — the SAME classification doResolveTAT (and thus
// every token-resolving command) produces, so callers see one consistent
// envelope (CategoryConfig / SubtypeInvalidClient for 10003 / 10014, etc.).
// Transport, HTTP-status and JSON-parse failures are returned raw (untyped),
// leaving them ambiguous; a caller can use errs.IsTyped to tell a deterministic
// credential rejection apart from upstream/transport noise.
// A deterministic client-side rejection (e.g. invalid_client) returns the
// canonical typed error from classifyTATResponseCode — the SAME classification
// doResolveTAT (and thus every token-resolving command) produces, so callers
// see one consistent envelope. Transport failures, unreadable/unparseable
// bodies, and transient server-side failures (5xx / server_error) are returned
// raw (untyped), leaving them ambiguous; a caller can use errs.IsTyped to tell a
// deterministic credential rejection apart from upstream/transport noise.
//
// The caller owns the context timeout.
func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, appID, appSecret string) (string, error) {
ep := core.ResolveEndpoints(brand)
url := ep.Open + "/open-apis/auth/v3/tenant_access_token/internal"
endpoint := ep.Accounts + core.OAuthTokenV3Path
body, err := json.Marshal(map[string]string{
"app_id": appID,
"app_secret": appSecret,
})
if err != nil {
return "", fmt.Errorf("failed to marshal TAT request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
form := url.Values{}
form.Set("grant_type", "client_credentials")
form.Set("client_id", appID)
form.Set("client_secret", appSecret)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
@@ -51,20 +52,51 @@ func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("TAT API returned HTTP %d", resp.StatusCode)
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", fmt.Errorf("failed to read TAT response: %w", err)
}
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
TenantAccessToken string `json:"tenant_access_token"`
Code int `json:"code"`
AccessToken string `json:"access_token"`
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
Msg string `json:"msg"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("failed to parse TAT response: %w", err)
if err := json.Unmarshal(body, &result); err != nil {
// An unparseable body is ambiguous (covers non-JSON error pages and
// truncated payloads); stay untyped so probe callers treat it as noise.
return "", fmt.Errorf("failed to parse TAT response (HTTP %d): %w", resp.StatusCode, err)
}
if result.Code != 0 {
return "", classifyTATResponseCode(result.Code, result.Msg, string(brand), appID)
if result.Code == 0 && result.AccessToken != "" {
return result.AccessToken, nil
}
return result.TenantAccessToken, nil
// Transient/server-side failures stay untyped so probe callers stay silent and
// retryers can back off; only deterministic client rejections are typed. Covers
// 5xx, HTTP 429 rate-limit, and the OAuth transient error strings (server_error,
// temporarily_unavailable, slow_down) — matching the legacy "non-2xx is noise"
// behavior so a rate-limited probe is not surfaced as a hard credential error.
if resp.StatusCode >= 500 || resp.StatusCode == http.StatusTooManyRequests ||
result.Error == "server_error" || result.Error == "temporarily_unavailable" ||
result.Error == "slow_down" {
return "", fmt.Errorf("TAT endpoint transient failure (HTTP %d, code=%d, error=%q): %s",
resp.StatusCode, result.Code, result.Error, result.ErrorDescription)
}
// A 2xx with neither token nor error is a malformed success — ambiguous, untyped.
if result.Code == 0 && result.Error == "" {
return "", fmt.Errorf("TAT response missing access_token (HTTP %d)", resp.StatusCode)
}
// Prefer the OAuth error_description; fall back to the legacy Lark `msg` so a
// gateway-level {code, msg} response (carrying no OAuth fields) still yields a
// non-empty typed message instead of a bare "API error: [code]".
desc := result.ErrorDescription
if desc == "" {
desc = result.Msg
}
return "", classifyTATResponseCode(result.Code, result.Error, desc, string(brand), appID)
}

View File

@@ -44,7 +44,7 @@ func (s *stubRoundTripper) RoundTrip(req *http.Request) (*http.Response, error)
func TestFetchTAT_Success(t *testing.T) {
rt := &stubRoundTripper{
respCode: 200,
respBody: `{"code":0,"tenant_access_token":"t-abc","msg":"ok"}`,
respBody: `{"code":0,"access_token":"t-abc","token_type":"Bearer","expires_in":7200}`,
}
hc := &http.Client{Transport: rt}
@@ -55,24 +55,33 @@ func TestFetchTAT_Success(t *testing.T) {
if token != "t-abc" {
t.Errorf("token = %q, want t-abc", token)
}
if rt.gotReq.URL.String() != "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" {
if rt.gotReq.URL.String() != "https://accounts.feishu.cn/oauth/v3/token" {
t.Errorf("url = %s", rt.gotReq.URL.String())
}
if !strings.Contains(rt.gotBody, `"app_id":"cli_app"`) || !strings.Contains(rt.gotBody, `"app_secret":"secret_x"`) {
t.Errorf("request body missing credentials: %s", rt.gotBody)
if ct := rt.gotReq.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" {
t.Errorf("Content-Type = %q, want application/x-www-form-urlencoded", ct)
}
// client_secret_post: grant_type + client_id + client_secret in the form body.
for _, want := range []string{"grant_type=client_credentials", "client_id=cli_app", "client_secret=secret_x"} {
if !strings.Contains(rt.gotBody, want) {
t.Errorf("request body missing %q: %s", want, rt.gotBody)
}
}
}
// 10003 (bad / non-existent app_id, "invalid param") is classified locally by
// invalid_client (wrong app_id/app_secret on the client_credentials grant) is a
// deterministic client-side rejection that FetchTAT routes to
// classifyTATResponseCode as CategoryConfig / SubtypeInvalidClient — the same
// typed error doResolveTAT (and thus every token-resolving command) returns.
func TestFetchTAT_Code10003_ConfigInvalidClient(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10003,"msg":"invalid param"}`}
// The v3 endpoint reports it as HTTP 400 with the OAuth2 error body (wrong
// secret → code 20002, unknown app → code 20048).
func TestFetchTAT_InvalidClient_ConfigInvalidClient(t *testing.T) {
rt := &stubRoundTripper{respCode: 400, respBody: `{"error":"invalid_client","error_description":"The client secret is invalid.","code":20002}`}
hc := &http.Client{Transport: rt}
token, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil {
t.Fatal("expected error for code 10003")
t.Fatal("expected error for invalid_client")
}
if token != "" {
t.Errorf("token = %q, want empty", token)
@@ -87,52 +96,115 @@ func TestFetchTAT_Code10003_ConfigInvalidClient(t *testing.T) {
if cfgErr.Subtype != errs.SubtypeInvalidClient {
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
}
if cfgErr.Code != 10003 {
t.Errorf("Code = %d, want 10003", cfgErr.Code)
}
}
// 10014 ("app secret invalid") — the most common real-world rejection (real
// app_id + wrong secret) — is globally mapped in codemeta to
// CategoryConfig / SubtypeInvalidClient via BuildAPIError.
func TestFetchTAT_Code10014_ConfigInvalidClient(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10014,"msg":"app secret invalid"}`}
hc := &http.Client{Transport: rt}
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error not *errs.ConfigError: %T %v", err, err)
}
if cfgErr.Subtype != errs.SubtypeInvalidClient || cfgErr.Code != 10014 {
t.Errorf("got Subtype=%q Code=%d, want invalid_client/10014", cfgErr.Subtype, cfgErr.Code)
}
}
// Any non-zero body code is a deterministic server-side rejection, so it
// always yields a typed error (errs.IsTyped). An unrecognized code falls back
// to CategoryAPI / SubtypeUnknown via BuildAPIError — still typed, so a probe
// caller still surfaces it rather than silently swallowing.
func TestFetchTAT_UnknownBodyCode_Typed(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":99999,"msg":"future-unknown"}`}
// Any other deterministic client-side OAuth error (e.g. invalid_scope) still
// yields a typed error (errs.IsTyped) via BuildAPIError — so a probe caller
// surfaces it rather than silently swallowing it — but is NOT classified as a
// credential (invalid_client) problem.
func TestFetchTAT_OtherClientError_Typed(t *testing.T) {
rt := &stubRoundTripper{respCode: 400, respBody: `{"code":20068,"error":"invalid_scope","error_description":"unauthorized scope"}`}
hc := &http.Client{Transport: rt}
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil {
t.Fatal("expected error for code 99999")
t.Fatal("expected error for invalid_scope")
}
if !errs.IsTyped(err) {
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Errorf("unknown code should fall back to *errs.APIError, got %T", err)
var cfgErr *errs.ConfigError
if errors.As(err, &cfgErr) {
t.Errorf("invalid_scope must not be classified as ConfigError/InvalidClient, got %T", err)
}
}
// Non-2xx HTTP is ambiguous (not a payload-level credential rejection) — it
// must stay UNTYPED so a probe caller treats it as upstream noise and stays
// silent.
// A deterministic OAuth error that arrives WITHOUT a numeric code (code defaults to
// 0) must still surface as a non-nil typed error — never the ("", nil) success pair.
// Guards the code-0 backstop in classifyTATResponseCode: BuildAPIError returns nil
// for code 0, which would otherwise swallow this rejection into an empty-token success.
func TestFetchTAT_OtherClientError_CodeZero_Typed(t *testing.T) {
rt := &stubRoundTripper{respCode: 400, respBody: `{"error":"invalid_scope","error_description":"the requested scope is not granted"}`}
hc := &http.Client{Transport: rt}
tok, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil {
t.Fatal("expected non-nil error for code-0 invalid_scope (must not return empty token + nil error)")
}
if tok != "" {
t.Errorf("token = %q, want empty", tok)
}
if !errs.IsTyped(err) {
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
}
}
// A gateway-style {code, msg} error (no OAuth error / error_description fields)
// must still surface its msg on the typed error, not degrade to a generic
// "API error: [code]". Guards the legacy-msg fallback in FetchTAT.
func TestFetchTAT_LarkStyleMsg_FallsBackOnTypedError(t *testing.T) {
rt := &stubRoundTripper{respCode: 400, respBody: `{"code":99999,"msg":"app ticket invalid"}`}
hc := &http.Client{Transport: rt}
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil {
t.Fatal("expected error for {code, msg} response")
}
if !errs.IsTyped(err) {
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
}
if !strings.Contains(err.Error(), "app ticket invalid") {
t.Errorf("typed error must carry the Lark msg, got: %v", err)
}
}
// Transient server-side failures (5xx / server_error) are NOT deterministic
// credential rejections — they must stay UNTYPED so a probe caller treats them
// as upstream noise and stays silent (and retryers can back off).
func TestFetchTAT_ServerError_Untyped(t *testing.T) {
rt := &stubRoundTripper{respCode: 500, respBody: `{"code":20050,"error":"server_error","error_description":"please retry"}`}
hc := &http.Client{Transport: rt}
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil {
t.Fatal("expected error for server_error")
}
if errs.IsTyped(err) {
t.Errorf("server_error must be UNTYPED (transient), got typed %T %v", err, err)
}
}
// Rate-limiting is transient, not a deterministic credential rejection — an HTTP
// 429 (even with a parseable OAuth body) and the OAuth slow_down error must both
// stay UNTYPED so a rate-limited probe stays silent and retryers can back off.
func TestFetchTAT_RateLimit_Untyped(t *testing.T) {
cases := []struct {
name string
code int
body string
}{
{"http 429", 429, `{"code":99991400,"error":"too_many_requests","error_description":"rate limit exceeded"}`},
{"oauth slow_down", 200, `{"error":"slow_down","error_description":"polling too fast"}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rt := &stubRoundTripper{respCode: tc.code, respBody: tc.body}
hc := &http.Client{Transport: rt}
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil {
t.Fatal("expected error for rate-limit")
}
if errs.IsTyped(err) {
t.Errorf("rate-limit must be UNTYPED (transient), got typed %T %v", err, err)
}
})
}
}
// Non-2xx HTTP with a non-JSON body is ambiguous (not a structured OAuth
// rejection) — it must stay UNTYPED so a probe caller treats it as upstream
// noise and stays silent.
func TestFetchTAT_HTTPNon200_Untyped(t *testing.T) {
for _, code := range []int{401, 403, 500, 503} {
rt := &stubRoundTripper{respCode: code, respBody: `whatever`}
@@ -182,12 +254,12 @@ func TestFetchTAT_BrandRouting(t *testing.T) {
brand core.LarkBrand
wantURL string
}{
{core.BrandFeishu, "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"},
{core.BrandLark, "https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal"},
{core.BrandFeishu, "https://accounts.feishu.cn/oauth/v3/token"},
{core.BrandLark, "https://accounts.larksuite.com/oauth/v3/token"},
}
for _, tc := range tests {
t.Run(string(tc.brand), func(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":0,"tenant_access_token":"t"}`}
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":0,"access_token":"t","token_type":"Bearer"}`}
hc := &http.Client{Transport: rt}
if _, err := FetchTAT(context.Background(), hc, tc.brand, "a", "b"); err != nil {
t.Fatal(err)

View File

@@ -65,7 +65,7 @@ var codeMeta = map[int]CodeMeta{
// CategoryConfig
99991543: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // RFC 6749 §5.2 — app_id / app_secret incorrect (Open API)
10014: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // TAT endpoint — "app secret invalid" (TAT-mint variant of 99991543)
10014: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // legacy TAT endpoint — "app secret invalid" (pre-v3 variant of 99991543; CLI now reports invalid_client)
// CategoryPolicy
21000: {Category: errs.CategoryPolicy, Subtype: errs.SubtypeChallengeRequired},

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package meta
import "encoding/json"
// Affordance is the hand-authored usage guidance overlaid on a method: when to
// use it, when not to, prerequisites, few-shot examples, and related methods.
// It is the single typed model of the affordance shape; the envelope renderer
// and the command help both parse through ParsedAffordance so the vocabulary
// is defined once. The JSON tags double as the envelope's wire shape.
type Affordance struct {
UseWhen []string `json:"use_when,omitempty"`
DoNotUseWhen []string `json:"do_not_use_when,omitempty"`
Prerequisites []string `json:"prerequisites,omitempty"`
Examples []AffordanceCase `json:"examples,omitempty"`
Related []string `json:"related,omitempty"`
}
// AffordanceCase is one few-shot example: a one-line description and a
// ready-to-run command.
type AffordanceCase struct {
Description string `json:"description"`
Command string `json:"command"`
}
// ParsedAffordance decodes the method's raw affordance overlay into the typed
// Affordance. ok is false when the method carries no affordance, the JSON is
// malformed, or every section is empty — so callers can treat "no guidance"
// uniformly.
func (m Method) ParsedAffordance() (Affordance, bool) {
if len(m.Affordance) == 0 {
return Affordance{}, false
}
var a Affordance
if json.Unmarshal(m.Affordance, &a) != nil {
return Affordance{}, false
}
if len(a.UseWhen) == 0 && len(a.DoNotUseWhen) == 0 && len(a.Prerequisites) == 0 && len(a.Examples) == 0 && len(a.Related) == 0 {
return Affordance{}, false
}
return a, true
}

View File

@@ -0,0 +1,56 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package meta
import (
"encoding/json"
"testing"
)
func TestMethod_ParsedAffordance(t *testing.T) {
// absent / empty / malformed all resolve to ok=false.
t.Run("nil affordance", func(t *testing.T) {
if _, ok := (Method{}).ParsedAffordance(); ok {
t.Error("ParsedAffordance on a method without affordance ok=true, want false")
}
})
notOK := map[string]string{
"empty payload": ``,
"empty object": `{}`,
"all empty arrays": `{"use_when":[],"do_not_use_when":[],"prerequisites":[],"examples":[],"related":[]}`,
"malformed string": `"not an object"`,
"malformed number": `42`,
"nested type mismatch": `{"examples":"should be a list"}`,
}
for name, raw := range notOK {
t.Run(name, func(t *testing.T) {
if _, ok := (Method{Affordance: json.RawMessage(raw)}).ParsedAffordance(); ok {
t.Errorf("ParsedAffordance(%s) ok=true, want false", raw)
}
})
}
// Populated affordance parses with all fields.
raw := `{
"use_when": ["需要拿到当前用户的主日历 ID"],
"do_not_use_when": ["已知具体 calendar_id"],
"prerequisites": ["user 身份登录"],
"examples": [{"description":"获取主日历","command":"lark-cli calendar calendars primary"}],
"related": ["calendars.list"]
}`
a, ok := (Method{Affordance: json.RawMessage(raw)}).ParsedAffordance()
if !ok {
t.Fatal("ParsedAffordance ok=false, want populated")
}
if len(a.UseWhen) != 1 || a.UseWhen[0] != "需要拿到当前用户的主日历 ID" {
t.Errorf("UseWhen = %v", a.UseWhen)
}
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) != 1 || a.Related[0] != "calendars.list" {
t.Errorf("Related = %v", a.Related)
}
}

94
internal/meta/identity.go Normal file
View File

@@ -0,0 +1,94 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package meta
import "sort"
// Token is the metadata accessTokens vocabulary: which token kind a method
// accepts. It is a distinct type so the two directions of the token<->identity
// mapping below cannot be swapped silently — a bare string compiles on either
// side of a string/string signature, a Token does not. The CLI identity
// vocabulary ("bot"/"user") already has a home in internal/core (core.Identity);
// meta is a leaf and must not import core, so the identity side stays a plain
// string here and is typed at the core boundary.
type Token string
const (
TokenTenant Token = "tenant" // bot calls use tenant_access_token
TokenUser Token = "user"
)
// IdentityForToken maps a metadata access token to the CLI identity (--as
// value) that uses it: tenant -> "bot", user -> "user". ok is false for
// unrecognized tokens. This is the single source of truth for the
// token<->identity vocabulary; schema, registry and command code all go
// through it instead of re-spelling the mapping.
func IdentityForToken(token Token) (string, bool) {
switch token {
case TokenTenant:
return "bot", true
case TokenUser:
return "user", true
}
return "", false
}
// TokenForIdentity is the inverse of IdentityForToken: "bot" -> TokenTenant;
// everything else (notably "user") maps to itself.
func TokenForIdentity(identity string) Token {
if identity == "bot" {
return TokenTenant
}
return Token(identity)
}
// RestrictsIdentity reports whether the method limits which identities may call
// it: true exactly when it declares one or more accessTokens. nil OR an empty
// slice means unrestricted (any identity). This is the single rule that both
// the strict-mode predicate (SupportsToken) and command identity gates use, so
// nil and [] never diverge across schema/scope and execution.
func (m Method) RestrictsIdentity() bool {
return len(m.AccessTokens) > 0
}
// SupportsToken reports whether this method is reachable with the given access
// token (see TokenForIdentity). An unrestricted method (RestrictsIdentity ==
// false, i.e. nil or empty accessTokens) is reachable by any token. This is
// the single source of truth for the predicate; registry scope policy and
// command identity checks build on it.
func (m Method) SupportsToken(token Token) bool {
if !m.RestrictsIdentity() {
return true
}
for _, t := range m.AccessTokens {
if t == token {
return true
}
}
return false
}
// Identities returns the CLI identities (--as values) that can call this
// method, derived from its metadata accessTokens: tenant -> "bot", user
// stays "user"; unrecognized tokens are dropped; the result is deduped and
// name-sorted. The slice is always non-nil so callers rendering it (e.g. the
// envelope's access_tokens) emit [] rather than null.
//
// An empty result does NOT imply unrestricted — use RestrictsIdentity() for
// that. Identities() lists only CLI-known identities, so a method restricted
// solely to unrecognized tokens returns empty yet RestrictsIdentity() is true.
func (m Method) Identities() []string {
seen := make(map[string]bool, len(m.AccessTokens))
for _, t := range m.AccessTokens {
if id, ok := IdentityForToken(t); ok {
seen[id] = true
}
}
out := make([]string, 0, len(seen))
for id := range seen {
out = append(out, id)
}
sort.Strings(out)
return out
}

View File

@@ -0,0 +1,85 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package meta
import (
"reflect"
"testing"
)
func TestIdentityTokenBijection(t *testing.T) {
if got := TokenForIdentity("bot"); got != "tenant" {
t.Errorf("TokenForIdentity(bot) = %q, want tenant", got)
}
if got := TokenForIdentity("user"); got != "user" {
t.Errorf("TokenForIdentity(user) = %q, want user", got)
}
if id, ok := IdentityForToken("tenant"); id != "bot" || !ok {
t.Errorf("IdentityForToken(tenant) = %q,%v want bot,true", id, ok)
}
if id, ok := IdentityForToken("user"); id != "user" || !ok {
t.Errorf("IdentityForToken(user) = %q,%v want user,true", id, ok)
}
if _, ok := IdentityForToken("weird"); ok {
t.Error("IdentityForToken(weird) ok=true, want false")
}
}
func TestMethod_RestrictsIdentity(t *testing.T) {
// nil and empty both mean "unrestricted"; only a populated list restricts.
if (Method{}).RestrictsIdentity() {
t.Error("nil accessTokens must be unrestricted")
}
if (Method{AccessTokens: []Token{}}).RestrictsIdentity() {
t.Error("empty accessTokens must be unrestricted (same as nil)")
}
if !(Method{AccessTokens: []Token{"tenant"}}).RestrictsIdentity() {
t.Error("populated accessTokens must restrict identity")
}
}
func TestMethod_SupportsToken(t *testing.T) {
// unrestricted (nil OR empty) -> permissive for any token; the two must not
// diverge, else strict/scope and the command gate disagree.
for _, m := range []Method{{}, {AccessTokens: []Token{}}} {
if !m.SupportsToken("tenant") || !m.SupportsToken("user") {
t.Errorf("unrestricted method %#v should support any token", m.AccessTokens)
}
}
// restricted: only the declared tokens are reachable
m := Method{AccessTokens: []Token{"tenant"}}
if !m.SupportsToken("tenant") {
t.Error("tenant-declared method should support tenant")
}
if m.SupportsToken("user") {
t.Error("tenant-only method must NOT support user")
}
}
func TestMethod_Identities(t *testing.T) {
// tenant->bot, user stays; deduped + name-sorted (so order-independent);
// unrecognized dropped; absent tokens -> empty but NON-nil so the envelope
// renders [] not null.
tests := []struct {
name string
tokens []Token
want []string
}{
{"tenant only", []Token{"tenant"}, []string{"bot"}},
{"user only", []Token{"user"}, []string{"user"}},
{"tenant then user", []Token{"tenant", "user"}, []string{"bot", "user"}},
{"user then tenant", []Token{"user", "tenant"}, []string{"bot", "user"}},
{"deduped", []Token{"tenant", "tenant", "user"}, []string{"bot", "user"}},
{"empty", []Token{}, []string{}},
{"nil", nil, []string{}},
{"unknown skipped", []Token{"user", "admin"}, []string{"user"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := (Method{AccessTokens: tt.tokens}).Identities(); !reflect.DeepEqual(got, tt.want) {
t.Errorf("Identities(%v) = %#v, want %#v", tt.tokens, got, tt.want)
}
})
}
}

215
internal/meta/meta.go Normal file
View File

@@ -0,0 +1,215 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package meta is the typed model of the API metadata registry and the single
// place that parses it. The metadata is a fixed, regular vocabulary, so a plain
// typed json.Unmarshal replaces hand-rolled map[string]interface{} walking. Map
// key order is not preserved (Go maps are unordered); callers that need a
// deterministic sequence get fields/methods/resources sorted by name via the
// list accessors below.
package meta
import (
"encoding/json"
"sort"
"strings"
)
// Option is one enum option of a field. Value is `any` (not string) so a
// metadata value that arrives as a JSON number — rather than the usual quoted
// string — coerces like Field.Enum / EnumOption.Value instead of failing the
// whole registry unmarshal and blanking the entire catalog. coerceLiteral
// normalizes it to the field's declared type.
type Option struct {
Value any `json:"value"`
Description string `json:"description"`
}
// Field is one parameter or body/response field. Name is the parent map key,
// populated by the list accessors (not a JSON field). ref/annotations/enumName
// exist in the metadata but are intentionally not modeled (unused downstream).
type Field struct {
Name string `json:"-"`
Type string `json:"type"`
Location string `json:"location"` // "path" | "query"; empty for body/response
Required bool `json:"required"`
Description string `json:"description"`
Default any `json:"default"`
Example any `json:"example"`
Min string `json:"min"`
Max string `json:"max"`
Enum []any `json:"enum"`
Options []Option `json:"options"`
Properties map[string]Field `json:"properties"`
}
// FlagName is the kebab-case CLI flag for this field (chat_id -> chat-id).
func (f Field) FlagName() string { return strings.ReplaceAll(f.Name, "_", "-") }
// Children returns the field's nested properties sorted by name.
func (f Field) Children() []Field { return fieldsOf(f.Properties, nil) }
// Method is one API operation. Name is the parent map key. Affordance is kept
// raw so this package stays free of envelope concerns.
type Method struct {
Name string `json:"-"`
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"`
Tips []string `json:"tips"`
Scopes []string `json:"scopes"`
RequiredScopes []string `json:"requiredScopes"`
AccessTokens []Token `json:"accessTokens"`
Affordance json.RawMessage `json:"affordance"`
Parameters map[string]Field `json:"parameters"`
RequestBody map[string]Field `json:"requestBody"`
ResponseBody map[string]Field `json:"responseBody"`
}
// Params are the path/query parameters, sorted by name.
func (m Method) Params() []Field {
return fieldsOf(m.Parameters, func(f Field) bool {
return f.Location == "path" || f.Location == "query"
})
}
// Data are the non-file request-body fields (--data JSON), sorted by name.
func (m Method) Data() []Field {
return fieldsOf(m.RequestBody, func(f Field) bool { return f.Type != "file" })
}
// Files are the file-typed request-body fields (--file uploads), sorted by name.
func (m Method) Files() []Field {
return fieldsOf(m.RequestBody, func(f Field) bool { return f.Type == "file" })
}
// Response are the response-body fields, sorted by name.
func (m Method) Response() []Field { return fieldsOf(m.ResponseBody, nil) }
// fieldsOf materializes a name->field map into a name-injected slice, optionally
// filtered, sorted by name for deterministic output.
func fieldsOf(byName map[string]Field, keep func(Field) bool) []Field {
out := make([]Field, 0, len(byName))
for name, f := range byName {
f.Name = name
if keep == nil || keep(f) {
out = append(out, f)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// Resource groups methods (and may nest sub-resources). Name is the parent key.
type Resource struct {
Name string `json:"-"`
Methods map[string]Method `json:"methods"`
Resources map[string]Resource `json:"resources"`
}
// MethodList returns the resource's methods, name-injected and sorted by name.
func (r Resource) MethodList() []Method {
out := make([]Method, 0, len(r.Methods))
for name, m := range r.Methods {
m.Name = name
out = append(out, m)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// Method looks up one method by name with Name injected, or false if absent.
// Use this instead of indexing Methods directly so Name is never left empty.
func (r Resource) Method(name string) (Method, bool) {
m, ok := r.Methods[name]
if !ok {
return Method{}, false
}
m.Name = name
return m, true
}
// SubResources returns nested resources, name-injected and sorted by name.
func (r Resource) SubResources() []Resource { return resourcesOf(r.Resources) }
// Service is one API service. Name is a real JSON field (services is an array).
type Service struct {
Name string `json:"name"`
Version string `json:"version"`
Title string `json:"title"`
Description string `json:"description"`
ServicePath string `json:"servicePath"`
Resources map[string]Resource `json:"resources"`
}
// ResourceList returns the service's top-level resources, name-injected and
// sorted by name.
func (s Service) ResourceList() []Resource { return resourcesOf(s.Resources) }
// Resource looks up one (possibly dotted) resource by name with Name injected,
// or false if absent. Use this instead of indexing Resources directly.
func (s Service) Resource(name string) (Resource, bool) {
r, ok := s.Resources[name]
if !ok {
return Resource{}, false
}
r.Name = name
return r, true
}
func resourcesOf(byName map[string]Resource) []Resource {
out := make([]Resource, 0, len(byName))
for name, r := range byName {
r.Name = name
out = append(out, r)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// Registry is the top-level metadata document.
type Registry struct {
Services []Service `json:"services"`
Version string `json:"version"`
}
// Parse decodes the metadata JSON into the typed Registry. Returns a zero
// Registry for empty input.
func Parse(data []byte) (Registry, error) {
if len(data) == 0 {
return Registry{}, nil
}
var reg Registry
if err := json.Unmarshal(data, &reg); err != nil {
return Registry{}, err
}
return reg, nil
}
// FromMap decodes a single method spec from its map form into a typed Method.
// Convenience constructor for building typed values from map literals (tests).
func FromMap(method map[string]interface{}) Method {
b, err := json.Marshal(method)
if err != nil {
return Method{}
}
var m Method
_ = json.Unmarshal(b, &m)
return m
}
// ServiceFromMap decodes a service spec from its map form into a typed Service.
// Convenience constructor for building typed values from map literals (tests).
func ServiceFromMap(svc map[string]interface{}) Service {
b, err := json.Marshal(svc)
if err != nil {
return Service{}
}
var s Service
_ = json.Unmarshal(b, &s)
return s
}

140
internal/meta/meta_test.go Normal file
View File

@@ -0,0 +1,140 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package meta
import (
"reflect"
"testing"
)
const sampleJSON = `{
"version": "1.0.0",
"services": [
{
"name": "im",
"servicePath": "/open-apis/im/v1",
"resources": {
"chat.members": {
"methods": {
"create": {
"httpMethod": "POST",
"risk": "high-risk-write",
"parameters": {
"member_id_type": {"type": "string", "location": "query", "options": [{"value": "open_id"}, {"value": "user_id"}]},
"chat_id": {"type": "string", "location": "path", "required": true, "example": "oc_x"},
"x_header": {"type": "string", "location": "header"}
},
"requestBody": {
"id_list": {"type": "list", "required": true},
"avatar": {"type": "file"}
}
}
}
}
}
}
]
}`
func loadSample(t *testing.T) (Resource, Method) {
t.Helper()
reg, err := Parse([]byte(sampleJSON))
if err != nil {
t.Fatalf("Parse: %v", err)
}
res := reg.Services[0].ResourceList()
if len(res) != 1 {
t.Fatalf("want 1 resource, got %d", len(res))
}
methods := res[0].MethodList()
if len(methods) != 1 {
t.Fatalf("want 1 method, got %d", len(methods))
}
return res[0], methods[0]
}
func TestParse_TypedAndNameInjected(t *testing.T) {
res, m := loadSample(t)
if res.Name != "chat.members" {
t.Errorf("resource name = %q, want chat.members", res.Name)
}
if m.Name != "create" || m.HTTPMethod != "POST" || m.Risk != "high-risk-write" {
t.Errorf("method = %+v", m)
}
}
func TestMethod_AccessorsSortedByName(t *testing.T) {
_, m := loadSample(t)
// Params: path/query only (header dropped), sorted by name.
var params []string
for _, f := range m.Params() {
params = append(params, f.Name)
}
if want := []string{"chat_id", "member_id_type"}; !reflect.DeepEqual(params, want) {
t.Errorf("Params() = %v, want %v (sorted, header dropped)", params, want)
}
if d := m.Data(); len(d) != 1 || d[0].Name != "id_list" {
t.Errorf("Data() = %+v, want [id_list]", d)
}
if f := m.Files(); len(f) != 1 || f[0].Name != "avatar" {
t.Errorf("Files() = %+v, want [avatar]", f)
}
}
func TestField_FlagNameAndOptions(t *testing.T) {
_, m := loadSample(t)
by := make(map[string]Field)
for _, f := range m.Params() {
by[f.Name] = f
}
if got := by["chat_id"].FlagName(); got != "chat-id" {
t.Errorf("FlagName = %q, want chat-id", got)
}
if !by["chat_id"].Required || by["chat_id"].Example != "oc_x" {
t.Errorf("chat_id required/example wrong: %+v", by["chat_id"])
}
opts := by["member_id_type"].Options
if len(opts) != 2 || opts[0].Value != "open_id" || opts[1].Value != "user_id" {
t.Errorf("member_id_type options = %+v", opts)
}
}
// TestParse_TolerantOptionValue guards against whole-catalog blanking: a single
// options[].value that arrives as a JSON number (not the usual quoted string)
// must NOT fail the entire registry unmarshal. Option.Value is `any`, so it
// parses and coerces like Enum instead of returning an empty Registry.
func TestParse_TolerantOptionValue(t *testing.T) {
data := []byte(`{"services":[{"name":"im","servicePath":"/x","resources":{
"chat":{"methods":{"create":{"parameters":{
"flag":{"type":"integer","location":"query","options":[{"value":0,"description":"off"},{"value":1,"description":"on"}]}
}}}}}}]}`)
reg, err := Parse(data)
if err != nil {
t.Fatalf("Parse failed on numeric option value (would blank the catalog): %v", err)
}
if len(reg.Services) != 1 {
t.Fatalf("expected 1 service, got %d (catalog blanked)", len(reg.Services))
}
// The numeric option coerces into the typed enum as sorted int64 (not
// float64): the integer field's canonical type drives normalization.
m, _ := reg.Services[0].Resource("chat")
method, _ := m.Method("create")
by := map[string]Field{}
for _, f := range method.Params() {
by[f.Name] = f
}
if got := by["flag"].EnumValues(); !reflect.DeepEqual(got, []any{int64(0), int64(1)}) {
t.Errorf("numeric-valued enum did not coerce to sorted int64: %#v", got)
}
}
func TestParse_Empty(t *testing.T) {
reg, err := Parse(nil)
if err != nil || len(reg.Services) != 0 {
t.Fatalf("Parse(nil) = %+v, %v", reg, err)
}
}

202
internal/meta/normalize.go Normal file
View File

@@ -0,0 +1,202 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package meta
import (
"fmt"
"sort"
"strconv"
)
// CanonicalType maps meta_data's non-standard type names to the standard
// JSON-Schema/type vocabulary used downstream (envelope render, flag kinds):
// "file" -> "string", "list" -> "array"; other types pass through unchanged.
func (f Field) CanonicalType() string {
switch f.Type {
case "file":
return "string"
case "list":
return "array"
default:
return f.Type
}
}
// coerceLiteral converts a meta_data literal (default/enum/example) to the
// field's canonical type. Literals may arrive as strings (meta_data's usual
// form) OR already typed — a JSON number unmarshals to float64, a JSON bool to
// bool — so both must be normalized to the SAME Go type the canonical type
// implies (int64 for "integer", float64 for "number", bool for "boolean").
// Otherwise enumLess, which type-asserts on that Go type, can't order the
// values. Returns (value, true) on success, (nil, false) when the literal
// cannot be represented in the declared type.
func coerceLiteral(canonicalType string, raw any) (any, bool) {
switch canonicalType {
case "integer":
switch v := raw.(type) {
case string:
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
return n, true
}
case float64: // JSON number; accept only when it's a whole value
if v == float64(int64(v)) {
return int64(v), true
}
case int64:
return v, true
case int:
return int64(v), true
}
return nil, false
case "number":
switch v := raw.(type) {
case string:
if n, err := strconv.ParseFloat(v, 64); err == nil {
return n, true
}
case float64:
return v, true
case int64:
return float64(v), true
case int:
return float64(v), true
}
return nil, false
case "boolean":
switch v := raw.(type) {
case string:
switch v {
case "true":
return true, true
case "false":
return false, true
}
case bool:
return v, true
}
return nil, false
default: // "string", "array", "" (objects), or unknown — pass through as-is
return raw, true
}
}
// enumLess orders two coerced enum values for the canonical type, so integer
// enums end up [1 2 10] not lexicographic [1 10 2].
func enumLess(canonicalType string, a, b any) bool {
switch canonicalType {
case "integer":
ai, _ := a.(int64)
bi, _ := b.(int64)
return ai < bi
case "number":
af, _ := a.(float64)
bf, _ := b.(float64)
return af < bf
case "boolean":
ab, _ := a.(bool)
bb, _ := b.(bool)
return !ab && bb
default:
as, _ := a.(string)
bs, _ := b.(string)
return as < bs
}
}
// EnumOption is one allowed value paired with its human description. The
// description comes from options[].description and is empty for the bare `enum`
// form (which carries no descriptions).
type EnumOption struct {
Value any
Description string
}
// EnumOptions returns the field's allowed values paired with their descriptions
// — from enum, or from options when enum is absent — coerced to the canonical
// type and ordered: numeric and boolean values are sorted; string values keep
// source order (which can encode priority). Uncoercible literals are dropped.
// Returns nil when the field declares no enum constraint.
func (f Field) EnumOptions() []EnumOption {
ct := f.CanonicalType()
var out []EnumOption
switch {
case len(f.Enum) > 0:
for _, e := range f.Enum {
if v, ok := coerceLiteral(ct, e); ok {
out = append(out, EnumOption{Value: v})
}
}
case len(f.Options) > 0:
seen := make(map[string]bool)
for _, o := range f.Options {
key := fmt.Sprintf("%v", o.Value)
if seen[key] {
continue
}
seen[key] = true
if v, ok := coerceLiteral(ct, o.Value); ok {
out = append(out, EnumOption{Value: v, Description: o.Description})
}
}
}
if len(out) > 0 && ct != "string" && ct != "" {
sort.SliceStable(out, func(i, j int) bool { return enumLess(ct, out[i].Value, out[j].Value) })
}
return out
}
// EnumValues returns the field's allowed values — the value projection of
// EnumOptions, in the same order. nil when the field declares no enum
// constraint. (Kept as the values-only accessor for the envelope and flag
// completion, which don't need descriptions.)
func (f Field) EnumValues() []any {
opts := f.EnumOptions()
if len(opts) == 0 {
return nil
}
out := make([]any, len(opts))
for i, o := range opts {
out[i] = o.Value
}
return out
}
// CoercedDefault returns Default coerced to the canonical type, or nil when the
// field has no default or the literal cannot be coerced.
func (f Field) CoercedDefault() any { return f.coerce(f.Default) }
// CoercedExample returns Example coerced to the canonical type, or nil when the
// field has no example or the literal cannot be coerced.
func (f Field) CoercedExample() any { return f.coerce(f.Example) }
func (f Field) coerce(raw any) any {
if raw == nil {
return nil
}
if v, ok := coerceLiteral(f.CanonicalType(), raw); ok {
return v
}
return nil
}
// MinBound returns the field's min constraint as a number, or nil when absent
// or unparseable. meta_data carries min/max as strings and does not say
// whether they bound a value or a string's length; the accessors stay equally
// agnostic, so every renderer (envelope minimum/maximum, flag help) presents
// the same numbers without inventing a semantic the source doesn't declare.
func (f Field) MinBound() *float64 { return parseBound(f.Min) }
// MaxBound returns the field's max constraint as a number, or nil when absent
// or unparseable. See MinBound.
func (f Field) MaxBound() *float64 { return parseBound(f.Max) }
func parseBound(s string) *float64 {
if s == "" {
return nil
}
if v, err := strconv.ParseFloat(s, 64); err == nil {
return &v
}
return nil
}

View File

@@ -0,0 +1,160 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package meta
import (
"reflect"
"testing"
)
func TestField_CanonicalType(t *testing.T) {
cases := map[string]string{
"file": "string", // meta_data's non-standard "file" is a string with binary format
"list": "array", // "list" is meta_data's spelling of a JSON array
"integer": "integer",
"boolean": "boolean",
"string": "string",
"": "",
}
for in, want := range cases {
if got := (Field{Type: in}).CanonicalType(); got != want {
t.Errorf("CanonicalType(%q) = %q, want %q", in, got, want)
}
}
}
func TestField_EnumValues(t *testing.T) {
// string enum keeps source order (order can encode priority)
if got := (Field{Type: "string", Enum: []any{"b", "a", "c"}}).EnumValues(); !reflect.DeepEqual(got, []any{"b", "a", "c"}) {
t.Errorf("string enum = %v, want source order [b a c]", got)
}
// integer enum: string-stored literals coerced to int64 and numerically sorted
if got := (Field{Type: "integer", Enum: []any{"10", "2", "1"}}).EnumValues(); !reflect.DeepEqual(got, []any{int64(1), int64(2), int64(10)}) {
t.Errorf("integer enum = %v, want [1 2 10] coerced+sorted", got)
}
// options used when enum absent, deduped, source order for strings
if got := (Field{Type: "string", Options: []Option{{Value: "x"}, {Value: "x"}, {Value: "y"}}}).EnumValues(); !reflect.DeepEqual(got, []any{"x", "y"}) {
t.Errorf("options enum = %v, want [x y] deduped", got)
}
// uncoercible literal dropped
if got := (Field{Type: "integer", Enum: []any{"1", "nope", "2"}}).EnumValues(); !reflect.DeepEqual(got, []any{int64(1), int64(2)}) {
t.Errorf("bad enum = %v, want [1 2] (nope dropped)", got)
}
// no enum/options -> nil
if got := (Field{Type: "string"}).EnumValues(); got != nil {
t.Errorf("empty enum = %v, want nil", got)
}
}
func TestField_EnumOptions(t *testing.T) {
// options carry descriptions, kept paired with their (string) value in source order
fo := Field{Type: "string", Options: []Option{
{Value: "open_id", Description: "以 open_id 标识"},
{Value: "open_id", Description: "dup ignored"}, // dedup keeps first
{Value: "user_id", Description: "以 user_id 标识"},
}}
got := fo.EnumOptions()
want := []EnumOption{
{Value: "open_id", Description: "以 open_id 标识"},
{Value: "user_id", Description: "以 user_id 标识"},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("EnumOptions = %+v, want %+v", got, want)
}
// integer enum (bare form): values coerced + numerically sorted, no descriptions
fi := Field{Type: "integer", Enum: []any{"10", "2", "1"}}
gi := fi.EnumOptions()
if len(gi) != 3 || gi[0].Value != int64(1) || gi[2].Value != int64(10) || gi[0].Description != "" {
t.Errorf("EnumOptions(integer) = %+v, want [1 2 10] coerced+sorted, no desc", gi)
}
// EnumValues stays the value projection of EnumOptions (golden-critical)
if !reflect.DeepEqual(fo.EnumValues(), []any{"open_id", "user_id"}) {
t.Errorf("EnumValues diverged from EnumOptions values: %v", fo.EnumValues())
}
// unconstrained -> nil
if (Field{Type: "string"}).EnumOptions() != nil {
t.Error("EnumOptions should be nil when unconstrained")
}
}
func TestField_Enum_NumberAndBoolean(t *testing.T) {
// number: string-stored floats coerced to float64 and numerically sorted
if got := (Field{Type: "number", Enum: []any{"2.5", "1.5", "10"}}).EnumValues(); !reflect.DeepEqual(got, []any{1.5, 2.5, float64(10)}) {
t.Errorf("number enum = %v, want [1.5 2.5 10] coerced+sorted", got)
}
// number: uncoercible literal dropped
if got := (Field{Type: "number", Enum: []any{"1.5", "x", "2.5"}}).EnumValues(); !reflect.DeepEqual(got, []any{1.5, 2.5}) {
t.Errorf("number enum with bad value = %v, want [1.5 2.5]", got)
}
// boolean: true/false coerced and sorted (false before true); invalid dropped
if got := (Field{Type: "boolean", Enum: []any{"true", "maybe", "false"}}).EnumValues(); !reflect.DeepEqual(got, []any{false, true}) {
t.Errorf("boolean enum = %v, want [false true]", got)
}
}
func TestField_EnumOptions_NonStringValuesNormalized(t *testing.T) {
// JSON numbers/bools arrive already-typed (a number is float64, not a
// string) — e.g. options[].value: 0. They must still be normalized to the
// field's canonical type (int64 for "integer") and sorted numerically;
// leaving them as float64 both yields the wrong type and defeats enumLess,
// whose integer branch asserts int64 and would otherwise treat every value
// as zero (no sort).
if got := (Field{Type: "integer", Options: []Option{
{Value: float64(10)}, {Value: float64(2)}, {Value: float64(1)},
}}).EnumValues(); !reflect.DeepEqual(got, []any{int64(1), int64(2), int64(10)}) {
t.Errorf("integer options from float64 = %#v, want [int64(1) int64(2) int64(10)]", got)
}
// bare enum form, JSON numbers
if got := (Field{Type: "integer", Enum: []any{float64(3), float64(1), float64(2)}}).EnumValues(); !reflect.DeepEqual(got, []any{int64(1), int64(2), int64(3)}) {
t.Errorf("integer enum from float64 = %#v, want [int64(1) int64(2) int64(3)]", got)
}
// number field: a whole-valued float stays float64
if got := (Field{Type: "number", Enum: []any{float64(2), float64(1)}}).EnumValues(); !reflect.DeepEqual(got, []any{float64(1), float64(2)}) {
t.Errorf("number enum from float64 = %#v, want [float64(1) float64(2)]", got)
}
// boolean field: native bools coerce + sort (false < true)
if got := (Field{Type: "boolean", Enum: []any{true, false}}).EnumValues(); !reflect.DeepEqual(got, []any{false, true}) {
t.Errorf("boolean enum from bool = %#v, want [false true]", got)
}
// non-integral float under integer is uncoercible -> dropped (mirrors how "2.5" fails ParseInt)
if got := (Field{Type: "integer", Enum: []any{float64(1), float64(2.5), float64(3)}}).EnumValues(); !reflect.DeepEqual(got, []any{int64(1), int64(3)}) {
t.Errorf("integer enum with fractional float = %#v, want [int64(1) int64(3)]", got)
}
}
func TestField_CoercedDefaultAndExample(t *testing.T) {
if got := (Field{Type: "integer", Default: "5"}).CoercedDefault(); got != int64(5) {
t.Errorf("CoercedDefault integer = %v (%T), want int64(5)", got, got)
}
if got := (Field{Type: "integer", Default: "bad"}).CoercedDefault(); got != nil {
t.Errorf("CoercedDefault uncoercible = %v, want nil", got)
}
if got := (Field{Type: "string"}).CoercedDefault(); got != nil {
t.Errorf("CoercedDefault absent = %v, want nil", got)
}
if got := (Field{Type: "boolean", Example: "true"}).CoercedExample(); got != true {
t.Errorf("CoercedExample boolean = %v, want true", got)
}
}
func TestField_Bounds(t *testing.T) {
f := Field{Min: "1", Max: "100"}
if v := f.MinBound(); v == nil || *v != 1 {
t.Errorf("MinBound = %v, want 1", v)
}
if v := f.MaxBound(); v == nil || *v != 100 {
t.Errorf("MaxBound = %v, want 100", v)
}
if v := (Field{Min: "0.5"}).MinBound(); v == nil || *v != 0.5 {
t.Errorf("MinBound fractional = %v, want 0.5", v)
}
if v := (Field{}).MinBound(); v != nil {
t.Errorf("MinBound absent = %v, want nil", v)
}
if v := (Field{Max: "not_a_number"}).MaxBound(); v != nil {
t.Errorf("MaxBound unparseable = %v, want nil", v)
}
}

View File

@@ -35,9 +35,12 @@ const (
LarkErrAppNotInUse = 99991662 // app is disabled in this tenant
LarkErrAppUnauthorized = 99991673 // app status unavailable; check installation
// TAT-endpoint variant of the "wrong app credentials" condition.
// /open-apis/auth/v3/tenant_access_token/internal returns code 10014
// ("app secret invalid") instead of 99991543 when the secret is wrong.
// "Wrong app credentials" code from the LEGACY TAT endpoint
// (/open-apis/auth/v3/tenant_access_token/internal returns 10014, "app secret
// invalid", instead of 99991543). Since the OAuth v3 migration the CLI mints
// TAT via accounts/oauth/v3/token and reports this as the OAuth invalid_client
// error, so it no longer emits 10014 itself; the constant + codemeta mapping
// are retained as a defensive fallback should 10014 still arrive.
LarkErrTATInvalidSecret = 10014
// Rate limit.

View File

@@ -0,0 +1,20 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registry
import "github.com/larksuite/cli/internal/apicatalog"
// EmbeddedCatalog returns a navigation catalog over the embedded (overlay-free)
// metadata — deterministic across machines, for `lark-cli schema`, golden tests
// and schema lint.
func EmbeddedCatalog() apicatalog.Catalog {
return apicatalog.New(apicatalog.SourceEmbedded, EmbeddedServicesTyped())
}
// RuntimeCatalog returns a navigation catalog over the merged (embedded + remote
// overlay) metadata — for service command registration and scope discovery,
// where overlay methods must be reachable.
func RuntimeCatalog() apicatalog.Catalog {
return apicatalog.New(apicatalog.SourceRuntime, ServicesTyped())
}

View File

@@ -3,54 +3,17 @@
package registry
// GetStrFromMap extracts a string value from map[string]interface{}.
func GetStrFromMap(m map[string]interface{}, key string) string {
if m == nil {
return ""
}
if v, ok := m[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
import "github.com/larksuite/cli/internal/meta"
// GetStrSliceFromMap extracts a []string value from map[string]interface{}.
// Returns nil if the key is missing or the value is not a string slice.
func GetStrSliceFromMap(m map[string]interface{}, key string) []string {
if m == nil {
return nil
}
raw, ok := m[key].([]interface{})
if !ok {
return nil
}
result := make([]string, 0, len(raw))
for _, v := range raw {
if s, ok := v.(string); ok {
result = append(result, s)
}
}
if len(result) == 0 {
return nil
}
return result
}
// DeclaredScopesForMethod returns the scopes declared by a method's
// from_meta entry for the given identity. Prefers the explicit
// `requiredScopes` field when present; otherwise returns the single
// recommended scope from `scopes` (or the first scope as a final fallback).
// Returns nil when the method has no scope information.
func DeclaredScopesForMethod(method map[string]interface{}, identity string) []string {
if method == nil {
return nil
}
if requiredRaw, ok := method["requiredScopes"].([]interface{}); ok && len(requiredRaw) > 0 {
out := make([]string, 0, len(requiredRaw))
for _, v := range requiredRaw {
if s, ok := v.(string); ok && s != "" {
// DeclaredScopesForMethod returns the scopes declared by a method for the given
// identity. Prefers the explicit `requiredScopes` field when present; otherwise
// returns the single recommended scope from `scopes` (or the first scope as a
// final fallback). Returns nil when the method has no scope information.
func DeclaredScopesForMethod(m meta.Method, identity string) []string {
if len(m.RequiredScopes) > 0 {
out := make([]string, 0, len(m.RequiredScopes))
for _, s := range m.RequiredScopes {
if s != "" {
out = append(out, s)
}
}
@@ -58,54 +21,11 @@ func DeclaredScopesForMethod(method map[string]interface{}, identity string) []s
return out
}
}
rawScopes, _ := method["scopes"].([]interface{})
if len(rawScopes) == 0 {
if len(m.Scopes) == 0 {
return nil
}
recommended := SelectRecommendedScope(rawScopes, identity)
if recommended == "" {
for _, raw := range rawScopes {
if s, ok := raw.(string); ok && s != "" {
recommended = s
break
}
}
if recommended := SelectRecommendedScopeFromStrings(m.Scopes, identity); recommended != "" {
return []string{recommended}
}
if recommended == "" {
return nil
}
return []string{recommended}
}
// SelectRecommendedScope selects the known scope with the highest priority score
// (higher = more recommended / least privilege).
// Scopes not in the priority table are skipped to avoid recommending invalid/unknown scopes.
func SelectRecommendedScope(scopes []interface{}, identity string) string {
priorities := LoadScopePriorities()
bestScore := -1
bestScope := ""
for _, s := range scopes {
str, ok := s.(string)
if !ok {
continue
}
score, exists := priorities[str]
if !exists {
continue // skip unknown scopes
}
if score > bestScore {
bestScore = score
bestScope = str
}
}
if bestScope != "" {
return bestScope
}
// Fallback: if no scope is in the priority table, return the first one.
if len(scopes) > 0 {
if s, ok := scopes[0].(string); ok {
return s
}
}
return ""
return nil
}

View File

@@ -14,6 +14,7 @@ import (
"sync"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/meta"
)
//go:embed scope_priorities.json scope_overrides.json
@@ -22,68 +23,42 @@ var registryFS embed.FS
// embeddedMetaJSON is set by loader_embedded.go when meta_data.json is compiled in.
var embeddedMetaJSON []byte
// EmbeddedMetaJSON returns the raw embedded meta_data.json bytes for callers
// that need to parse key order or other JSON-level structure not exposed by
// LoadFromMeta (which loses map insertion order).
func EmbeddedMetaJSON() []byte {
return embeddedMetaJSON
}
var (
embeddedServicesMap map[string]map[string]interface{} // service name -> spec
embeddedServiceNames []string // sorted
embeddedParseOnce sync.Once
embeddedServices []meta.Service // parsed once, sorted by name (no overlay)
embeddedServicesByName map[string]meta.Service // same, keyed by name
embeddedVersion string // version from embedded meta_data.json
embeddedParseOnce sync.Once
)
// parseEmbeddedServices parses embeddedMetaJSON into a service name → spec map
// without touching mergedServices. Safe to call multiple times (sync.Once).
func parseEmbeddedServices() {
// parseEmbedded decodes the embedded meta_data.json into the typed model exactly
// once. It is the single parse of the embedded bytes: both the overlay-free
// envelope path (EmbeddedServicesTyped) and the merged command/scope path
// (loadEmbeddedIntoMerged) build from this result, so the JSON is never parsed
// twice and no map round-trip is needed downstream.
func parseEmbedded() {
embeddedParseOnce.Do(func() {
embeddedServicesMap = make(map[string]map[string]interface{})
if len(embeddedMetaJSON) == 0 {
return
reg, _ := meta.Parse(embeddedMetaJSON)
embeddedVersion = reg.Version
embeddedServices = reg.Services
sort.Slice(embeddedServices, func(i, j int) bool { return embeddedServices[i].Name < embeddedServices[j].Name })
embeddedServicesByName = make(map[string]meta.Service, len(embeddedServices))
for _, svc := range embeddedServices {
embeddedServicesByName[svc.Name] = svc
}
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{} {
parseEmbeddedServices()
return embeddedServicesMap[serviceName]
}
// EmbeddedServiceNames returns sorted embedded service names (no overlay).
// Returns a defensive copy — callers must not mutate the package-level slice.
func EmbeddedServiceNames() []string {
parseEmbeddedServices()
out := make([]string, len(embeddedServiceNames))
copy(out, embeddedServiceNames)
return out
// EmbeddedServicesTyped returns the embedded services (no remote overlay) as the
// typed meta model, sorted by name. This is the overlay-free parse boundary the
// schema envelope builds from — deterministic across machines.
func EmbeddedServicesTyped() []meta.Service {
parseEmbedded()
return embeddedServices
}
var (
mergedServices = make(map[string]map[string]interface{}) // project name → parsed spec
mergedProjectList []string // sorted project names
embeddedVersion string // version from embedded meta_data.json
mergedServices = make(map[string]meta.Service) // project name → typed service (embedded + overlay)
mergedProjectList []string // sorted project names
initOnce sync.Once
)
@@ -106,8 +81,8 @@ func InitWithBrand(brand core.LarkBrand) {
// 2. Remote overlay
if remoteEnabled() && cacheWritable() {
// Check if brand changed since last cache
meta, metaErr := loadCacheMeta()
brandChanged := metaErr == nil && meta.Brand != "" && meta.Brand != string(brand)
cm, metaErr := loadCacheMeta()
brandChanged := metaErr == nil && cm.Brand != "" && cm.Brand != string(brand)
if !brandChanged {
if cached, err := loadCachedMerged(); err == nil {
@@ -117,7 +92,7 @@ func InitWithBrand(brand core.LarkBrand) {
if len(mergedServices) == 0 || brandChanged {
// No data at all or brand changed — must sync fetch
doSyncFetch()
} else if shouldRefresh(meta) || metaErr != nil {
} else if shouldRefresh(cm) || metaErr != nil {
// Have embedded/cached data; refresh in background if TTL expired or first run
triggerBackgroundRefresh()
}
@@ -127,18 +102,13 @@ func InitWithBrand(brand core.LarkBrand) {
})
}
// loadEmbeddedIntoMerged parses the embedded meta_data.json and populates
// mergedServices. No-op if meta_data.json is not compiled in.
// loadEmbeddedIntoMerged seeds mergedServices from the embedded typed services
// (the same parse EmbeddedServicesTyped uses). No-op if no services compiled in.
func loadEmbeddedIntoMerged() {
if len(embeddedMetaJSON) == 0 {
return
parseEmbedded()
for name, svc := range embeddedServicesByName {
mergedServices[name] = svc
}
var reg MergedRegistry
if err := json.Unmarshal(embeddedMetaJSON, &reg); err != nil {
return
}
embeddedVersion = reg.Version
overlayMergedServices(&reg)
}
// rebuildProjectList rebuilds the sorted list of project names from mergedServices.
@@ -150,83 +120,32 @@ func rebuildProjectList() {
sort.Strings(mergedProjectList)
}
var cachedAllScopes map[string][]string
var (
servicesTyped []meta.Service
servicesTypedOnce sync.Once
)
// CollectAllScopesFromMeta collects all unique scopes from from_meta/*.json
// for the given identity ("user" or "tenant"). Results are deduplicated and sorted.
func CollectAllScopesFromMeta(identity string) []string {
if cachedAllScopes == nil {
cachedAllScopes = make(map[string][]string)
}
if cached, ok := cachedAllScopes[identity]; ok {
return cached
}
scopeSet := make(map[string]bool)
for _, project := range ListFromMetaProjects() {
spec := LoadFromMeta(project)
if spec == nil {
continue
// ServicesTyped returns the merged registry (embedded + remote overlay) as typed
// meta.Services, sorted by name. The merged store is already typed, so this just
// projects it into a sorted slice — no map round-trip. This is the typed entry
// the command tree and scope computation build from.
func ServicesTyped() []meta.Service {
servicesTypedOnce.Do(func() {
Init()
servicesTyped = make([]meta.Service, 0, len(mergedProjectList))
for _, name := range mergedProjectList {
servicesTyped = append(servicesTyped, mergedServices[name])
}
resources, ok := spec["resources"].(map[string]interface{})
if !ok {
continue
}
for _, resSpec := range resources {
resMap, ok := resSpec.(map[string]interface{})
if !ok {
continue
}
methods, ok := resMap["methods"].(map[string]interface{})
if !ok {
continue
}
for _, methodSpec := range methods {
methodMap, ok := methodSpec.(map[string]interface{})
if !ok {
continue
}
// Check if method supports the requested identity
if tokens, ok := methodMap["accessTokens"].([]interface{}); ok {
supported := false
for _, t := range tokens {
if ts, ok := t.(string); ok && ts == IdentityToAccessToken(identity) {
supported = true
break
}
}
if !supported {
continue
}
}
// Collect scopes
scopes, ok := methodMap["scopes"].([]interface{})
if !ok {
continue
}
for _, s := range scopes {
if str, ok := s.(string); ok {
scopeSet[str] = true
}
}
}
}
}
result := make([]string, 0, len(scopeSet))
for s := range scopeSet {
result = append(result, s)
}
sort.Strings(result)
cachedAllScopes[identity] = result
return result
})
return servicesTyped
}
// LoadFromMeta loads a service schema by project name.
// It returns data from the merged registry (embedded + cached remote overlay).
func LoadFromMeta(project string) map[string]interface{} {
// ServiceTyped returns one merged service (embedded + overlay) by name, or false
// if unknown.
func ServiceTyped(name string) (meta.Service, bool) {
Init()
return mergedServices[project]
svc, ok := mergedServices[name]
return svc, ok
}
// ListFromMetaProjects lists available service project names (sorted).

View File

@@ -65,8 +65,7 @@ func TestSelectRecommendedScope_PicksHighestScore(t *testing.T) {
}
t.Logf("%s=%d, %s=%d", scopeA, scoreA, scopeB, scoreB)
scopes := []interface{}{scopeB, scopeA}
result := SelectRecommendedScope(scopes, "user")
result := bestScope([]string{scopeB, scopeA}, priorities)
// Should pick the higher-scored one (higher = more recommended)
if scoreA > scoreB {
@@ -81,11 +80,11 @@ func TestSelectRecommendedScope_PicksHighestScore(t *testing.T) {
}
func TestSelectRecommendedScope_FallbackToFirst(t *testing.T) {
scopes := []interface{}{
scopes := []string{
"zzz_unknown:scope:a",
"zzz_unknown:scope:b",
}
result := SelectRecommendedScope(scopes, "user")
result := bestScope(scopes, LoadScopePriorities())
// All unknown scopes get DefaultScopeScore; first one with that score wins
if result != "zzz_unknown:scope:a" {
t.Errorf("expected zzz_unknown:scope:a, got %s", result)
@@ -93,13 +92,10 @@ func TestSelectRecommendedScope_FallbackToFirst(t *testing.T) {
}
func TestSelectRecommendedScope_Empty(t *testing.T) {
result := SelectRecommendedScope(nil, "user")
if result != "" {
if result := bestScope(nil, LoadScopePriorities()); result != "" {
t.Errorf("expected empty string, got %s", result)
}
result = SelectRecommendedScope([]interface{}{}, "user")
if result != "" {
if result := bestScope([]string{}, LoadScopePriorities()); result != "" {
t.Errorf("expected empty string, got %s", result)
}
}
@@ -318,27 +314,6 @@ func TestFilterAutoApproveScopes_Empty(t *testing.T) {
// --- Helper functions ---
func TestGetStrFromMap(t *testing.T) {
m := map[string]interface{}{
"key1": "value1",
"key2": 42,
"key3": nil,
}
if v := GetStrFromMap(m, "key1"); v != "value1" {
t.Errorf("expected value1, got %s", v)
}
if v := GetStrFromMap(m, "key2"); v != "" {
t.Errorf("expected empty for non-string value, got %s", v)
}
if v := GetStrFromMap(m, "missing"); v != "" {
t.Errorf("expected empty for missing key, got %s", v)
}
if v := GetStrFromMap(nil, "key"); v != "" {
t.Errorf("expected empty for nil map, got %s", v)
}
}
func TestGetRegistryDir(t *testing.T) {
dir := GetRegistryDir()
if dir == "" {

View File

@@ -17,6 +17,7 @@ import (
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
@@ -35,16 +36,20 @@ type CacheMeta struct {
Brand string `json:"brand,omitempty"`
}
// MergedRegistry is the top-level structure of remote_meta.json.
// MergedRegistry is the top-level structure of remote_meta.json. Services are
// decoded straight into the typed meta model so embedded, cached, and remote
// data share one representation (no map intermediary, no re-parse).
type MergedRegistry struct {
Version string `json:"version"`
Services []map[string]interface{} `json:"services"`
Version string `json:"version"`
Services []meta.Service `json:"services"`
}
// remoteResponse is the envelope returned by the remote API.
// remoteResponse is the envelope returned by the remote API. Data stays raw:
// the cache must store the server payload verbatim — a typed re-marshal would
// strip every field the model doesn't (yet) declare and starve future binaries.
type remoteResponse struct {
Msg string `json:"msg"`
Data MergedRegistry `json:"data"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
}
// configuredBrand is set by InitWithBrand and determines which API host to use.
@@ -125,22 +130,22 @@ func cacheWritable() bool {
// --- cache I/O ---
func loadCacheMeta() (CacheMeta, error) {
var meta CacheMeta
var cm CacheMeta
data, err := vfs.ReadFile(cacheMetaPath())
if err != nil {
return meta, err
return cm, err
}
if err = json.Unmarshal(data, &meta); err != nil {
return meta, err
if err = json.Unmarshal(data, &cm); err != nil {
return cm, err
}
return meta, nil
return cm, nil
}
func saveCacheMeta(meta CacheMeta) error {
func saveCacheMeta(cm CacheMeta) error {
if err := vfs.MkdirAll(cacheDir(), 0700); err != nil {
return err
}
data, err := json.Marshal(meta)
data, err := json.Marshal(cm)
if err != nil {
return err
}
@@ -163,14 +168,16 @@ func loadCachedMerged() (*MergedRegistry, error) {
return &reg, nil
}
func saveCachedMerged(data []byte, meta CacheMeta) error {
// saveCachedMerged writes the cache file. data must be the server's data
// payload verbatim (see remoteResponse) — never a typed re-marshal.
func saveCachedMerged(data []byte, cm CacheMeta) error {
if err := vfs.MkdirAll(cacheDir(), 0700); err != nil {
return err
}
if err := validate.AtomicWrite(cachePath(), data, 0644); err != nil {
return err
}
return saveCacheMeta(meta)
return saveCacheMeta(cm)
}
// --- HTTP fetch ---
@@ -211,18 +218,21 @@ func fetchRemoteMerged(localVersion string) (data []byte, reg *MergedRegistry, e
return nil, nil, fmt.Errorf("remote meta: unexpected msg %q", envelope.Msg)
}
// If data.Services is nil, the version is up-to-date (not modified)
if envelope.Data.Services == nil {
var parsed MergedRegistry
if len(envelope.Data) > 0 {
if err := json.Unmarshal(envelope.Data, &parsed); err != nil {
return nil, nil, fmt.Errorf("remote meta: parse data: %w", err)
}
}
// If data.services is nil, the version is up-to-date (not modified)
if parsed.Services == nil {
return nil, nil, nil
}
// Re-marshal just the data portion for caching
dataBytes, err := json.Marshal(envelope.Data)
if err != nil {
return nil, nil, err
}
return dataBytes, &envelope.Data, nil
// Cache the data portion verbatim (see remoteResponse for why not a typed
// re-marshal).
return envelope.Data, &parsed, nil
}
type httpError struct {
@@ -247,12 +257,12 @@ func doSyncFetch() {
})
return
}
meta := CacheMeta{
cm := CacheMeta{
LastCheckAt: time.Now().Unix(),
Version: reg.Version,
Brand: string(configuredBrand),
}
_ = saveCachedMerged(data, meta)
_ = saveCachedMerged(data, cm)
overlayMergedServices(reg)
}
@@ -275,22 +285,22 @@ func triggerBackgroundRefresh() {
func doBackgroundRefresh() {
defer func() { _ = recover() }()
meta, _ := loadCacheMeta()
version := meta.Version
cm, _ := loadCacheMeta()
version := cm.Version
if version == "" {
version = embeddedVersion
}
data, reg, err := fetchRemoteMerged(version)
if err != nil {
// On error, update last_check_at to avoid retrying every invocation
meta.LastCheckAt = time.Now().Unix()
_ = saveCacheMeta(meta)
cm.LastCheckAt = time.Now().Unix()
_ = saveCacheMeta(cm)
return
}
if reg == nil {
// Version unchanged — just update check time
meta.LastCheckAt = time.Now().Unix()
_ = saveCacheMeta(meta)
cm.LastCheckAt = time.Now().Unix()
_ = saveCacheMeta(cm)
return
}
newMeta := CacheMeta{
@@ -302,21 +312,20 @@ func doBackgroundRefresh() {
}
// shouldRefresh returns true if the cache TTL has expired.
func shouldRefresh(meta CacheMeta) bool {
if meta.LastCheckAt == 0 {
func shouldRefresh(cm CacheMeta) bool {
if cm.LastCheckAt == 0 {
return true
}
return time.Since(time.Unix(meta.LastCheckAt, 0)) > metaTTL()
return time.Since(time.Unix(cm.LastCheckAt, 0)) > metaTTL()
}
// overlayMergedServices merges remote services into the in-memory map.
// Remote entries override embedded entries with the same name.
func overlayMergedServices(reg *MergedRegistry) {
for _, svc := range reg.Services {
name, ok := svc["name"].(string)
if !ok || name == "" {
if svc.Name == "" {
continue
}
mergedServices[name] = svc
mergedServices[svc.Name] = svc
}
}

View File

@@ -15,6 +15,7 @@ import (
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/meta"
)
// waitBackgroundRefresh blocks until any in-flight background refresh started by
@@ -30,7 +31,10 @@ func resetInit() {
// reads globals this function mutates (see CI race: TestComputeMinimumScopeSet → Tenant).
waitBackgroundRefresh()
initOnce = sync.Once{}
mergedServices = make(map[string]map[string]interface{})
embeddedParseOnce = sync.Once{}
servicesTypedOnce = sync.Once{}
servicesTyped = nil
mergedServices = make(map[string]meta.Service)
mergedProjectList = nil
embeddedVersion = ""
cachedAllScopes = nil
@@ -71,13 +75,12 @@ func hasEmbeddedServices() bool {
func testRegistry(name string) MergedRegistry {
return MergedRegistry{
Version: "test-1.0",
Services: []map[string]interface{}{
Services: []meta.Service{
{
"name": name,
"version": "v1",
"title": name + " API",
"servicePath": "/open-apis/" + name + "/v1",
"resources": map[string]interface{}{},
Name: name,
Version: "v1",
Title: name + " API",
ServicePath: "/open-apis/" + name + "/v1",
},
},
}
@@ -91,9 +94,10 @@ func testCacheJSON(name string) []byte {
// testEnvelopeJSON returns the remote API envelope format: {"msg":"succeeded","data":{...}}.
func testEnvelopeJSON(name string) []byte {
regData, _ := json.Marshal(testRegistry(name))
resp := remoteResponse{
Msg: "succeeded",
Data: testRegistry(name),
Data: regData,
}
data, _ := json.Marshal(resp)
return data
@@ -131,7 +135,7 @@ func TestColdStart_NoEmbedded_SyncFetch(t *testing.T) {
Init()
if spec := LoadFromMeta("remote_calendar"); spec == nil {
if _, ok := ServiceTyped("remote_calendar"); !ok {
t.Fatal("expected remote_calendar from sync fetch")
}
}
@@ -150,7 +154,7 @@ func TestRemoteOff_SkipsRemoteLogic(t *testing.T) {
Init()
// "fake_remote_svc" should not be loaded when remote is off
if spec := LoadFromMeta("fake_remote_svc"); spec != nil {
if _, ok := ServiceTyped("fake_remote_svc"); ok {
t.Error("expected fake_remote_svc to NOT be loaded when remote is off")
}
}
@@ -181,12 +185,12 @@ func TestCacheHit_WithinTTL(t *testing.T) {
Init()
// custom_svc should be loaded from cache overlay
if spec := LoadFromMeta("custom_svc"); spec == nil {
if _, ok := ServiceTyped("custom_svc"); !ok {
t.Error("expected custom_svc from cache overlay")
}
// Embedded projects should still be present (if compiled in)
if hasEmbeddedServices() {
if spec := LoadFromMeta("calendar"); spec == nil {
if _, ok := ServiceTyped("calendar"); !ok {
t.Error("expected calendar from embedded data")
}
}
@@ -227,7 +231,7 @@ func TestNetworkError_SilentDegradation(t *testing.T) {
if len(projects) == 0 {
t.Fatal("expected projects after network error")
}
if spec := LoadFromMeta("cached_svc"); spec == nil {
if _, ok := ServiceTyped("cached_svc"); !ok {
t.Fatal("expected cached_svc after network error")
}
@@ -304,19 +308,19 @@ func TestMetaTTL(t *testing.T) {
func TestOverlayMergedServices(t *testing.T) {
resetInit()
mergedServices = make(map[string]map[string]interface{})
mergedServices["existing"] = map[string]interface{}{"name": "existing", "version": "v1"}
mergedServices = make(map[string]meta.Service)
mergedServices["existing"] = meta.Service{Name: "existing", Version: "v1"}
reg := &MergedRegistry{
Services: []map[string]interface{}{
{"name": "existing", "version": "v2"},
{"name": "brand_new", "version": "v1"},
Services: []meta.Service{
{Name: "existing", Version: "v2"},
{Name: "brand_new", Version: "v1"},
},
}
overlayMergedServices(reg)
// existing should be overridden
if v := mergedServices["existing"]["version"].(string); v != "v2" {
if v := mergedServices["existing"].Version; v != "v2" {
t.Errorf("expected existing to be overridden to v2, got %s", v)
}
// brand_new should be added
@@ -333,18 +337,18 @@ func TestOverlayMergedServicesDoesNotPolluteFollowingInit(t *testing.T) {
const leakedExisting = "test_isolation_existing_sentinel"
const leakedOverlay = "test_isolation_overlay_sentinel"
mergedServices = map[string]map[string]interface{}{
leakedExisting: {"name": leakedExisting, "version": "v1"},
mergedServices = map[string]meta.Service{
leakedExisting: {Name: leakedExisting, Version: "v1"},
}
overlayMergedServices(&MergedRegistry{Services: []map[string]interface{}{{"name": leakedOverlay, "version": "v1"}}})
overlayMergedServices(&MergedRegistry{Services: []meta.Service{{Name: leakedOverlay, Version: "v1"}}})
resetInit()
Init()
if spec := LoadFromMeta(leakedExisting); spec != nil {
if _, ok := ServiceTyped(leakedExisting); ok {
t.Fatalf("polluted service %q survived resetInit", leakedExisting)
}
if spec := LoadFromMeta(leakedOverlay); spec != nil {
if _, ok := ServiceTyped(leakedOverlay); ok {
t.Fatalf("polluted service %q survived resetInit", leakedOverlay)
}
}
@@ -372,6 +376,31 @@ func TestFetchRemoteMerged_200(t *testing.T) {
}
}
// TestFetchRemoteMerged_CacheBytesPreserveUnmodeledKeys pins that the bytes
// destined for the on-disk cache are the server's data payload verbatim: a key
// the typed model does not (yet) declare must survive (see remoteResponse for
// why).
func TestFetchRemoteMerged_CacheBytesPreserveUnmodeledKeys(t *testing.T) {
const payload = `{"msg":"succeeded","data":{"version":"test-1.0","services":[{"name":"svc","servicePath":"/open-apis/svc/v1","resources":{"items":{"methods":{"list":{"httpMethod":"GET","path":"items","parameters":{"status":{"type":"string","enumName":"StatusEnum"}}}}}}}]}}`
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(payload))
}))
defer ts.Close()
testMetaURL = ts.URL
data, reg, err := fetchRemoteMerged("")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if reg == nil || len(reg.Services) != 1 || reg.Services[0].Name != "svc" {
t.Fatalf("typed registry not decoded, got %+v", reg)
}
if !strings.Contains(string(data), `"enumName":"StatusEnum"`) {
t.Errorf("cache payload dropped unmodeled key enumName, got:\n%s", data)
}
}
func TestFetchRemoteMerged_VersionMatch(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
@@ -484,7 +513,7 @@ func TestBrandSwitchInvalidatesCache(t *testing.T) {
// The old feishu_svc should NOT be loaded from stale cache
// The new lark_svc from sync fetch should be available
if spec := LoadFromMeta("lark_svc"); spec == nil {
if _, ok := ServiceTyped("lark_svc"); !ok {
t.Error("expected lark_svc after brand switch sync fetch")
}
}

View File

@@ -44,22 +44,12 @@ func ExtractRequiredScopes(detail interface{}) []string {
return scopes
}
// SelectRecommendedScopeFromStrings is a string-typed convenience wrapper
// around SelectRecommendedScope. When no scope is recognized by the priority
// table, it falls back to the first input scope so callers always have
// something to surface to users.
func SelectRecommendedScopeFromStrings(scopes []string, identity string) string {
if len(scopes) == 0 {
return ""
}
ifaces := make([]interface{}, len(scopes))
for i, s := range scopes {
ifaces[i] = s
}
if recommended := SelectRecommendedScope(ifaces, identity); recommended != "" {
return recommended
}
return scopes[0]
// SelectRecommendedScopeFromStrings returns the highest-priority (least-privilege)
// scope to surface to users, or "" for no scopes. Unknown scopes score
// DefaultScopeScore, so an all-unknown list yields the first entry. Priority is
// identity-independent; the parameter is kept for call-site clarity.
func SelectRecommendedScopeFromStrings(scopes []string, _ string) string {
return bestScope(scopes, LoadScopePriorities())
}
// BuildConsoleScopeURL returns the developer-console "apply scope" URL for the

View File

@@ -6,16 +6,63 @@ package registry
import (
"sort"
"strings"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/meta"
)
// IdentityToAccessToken maps the --identity flag value to the corresponding
// accessTokens value used in from_meta JSON files. Bot identity uses
// tenant_access_token, so "bot" maps to "tenant".
func IdentityToAccessToken(identity string) string {
if identity == "bot" {
return "tenant"
// methodsForProjects walks the runtime catalog once and returns the methods in
// the given projects that are reachable by the identity. Catalog navigation is
// owned by apicatalog; the collectors below only apply scope policy.
func methodsForProjects(projects []string, identity string) []apicatalog.MethodRef {
want := make(map[string]bool, len(projects))
for _, p := range projects {
want[p] = true
}
return identity
wantToken := meta.TokenForIdentity(identity)
supported := func(m meta.Method) bool { return m.SupportsToken(wantToken) }
// Walk only the requested services (in catalog name order) instead of every
// service's methods then discarding the rest.
var out []apicatalog.MethodRef
for _, svc := range RuntimeCatalog().Services() {
if want[svc.Name] {
out = append(out, apicatalog.ServiceMethods(svc, supported)...)
}
}
return out
}
// bestScope returns the highest-priority scope from scopes (minimum privilege),
// or "" when scopes is empty.
func bestScope(scopes []string, priorities map[string]int) string {
best := ""
bestScore := -1
for _, s := range scopes {
score := DefaultScopeScore
if v, ok := priorities[s]; ok {
score = v
}
if score > bestScore {
bestScore = score
best = s
}
}
return best
}
// FilterForStrictMode returns a method filter enforcing the strict-mode forced
// identity, or nil when strict mode is inactive (no filtering). The
// token/identity vocabulary (meta.TokenForIdentity) and the "no accessTokens =
// permissive" predicate (meta.Method.SupportsToken) both live in meta, so this
// only composes them — schema completion/render and service commands never
// re-derive identity semantics.
func FilterForStrictMode(mode core.StrictMode) apicatalog.MethodFilter {
if !mode.IsActive() {
return nil
}
token := meta.TokenForIdentity(string(mode.ForcedIdentity()))
return func(m meta.Method) bool { return m.SupportsToken(token) }
}
// FilterScopes filters scopes by domain and permission level.
@@ -76,71 +123,45 @@ func FilterScopes(allScopes []string, domains []string, permissions []string) []
return result
}
var cachedAllScopes map[string][]string
// CollectAllScopesFromMeta collects all unique scopes from from_meta/*.json
// for the given identity ("user" or "tenant"). Results are deduplicated and sorted.
func CollectAllScopesFromMeta(identity string) []string {
if cachedAllScopes == nil {
cachedAllScopes = make(map[string][]string)
}
if cached, ok := cachedAllScopes[identity]; ok {
return cached
}
wantToken := meta.TokenForIdentity(identity)
supported := func(m meta.Method) bool { return m.SupportsToken(wantToken) }
scopeSet := make(map[string]bool)
for _, ref := range RuntimeCatalog().WalkMethods(supported) {
for _, s := range ref.Method.Scopes {
scopeSet[s] = true
}
}
result := make([]string, 0, len(scopeSet))
for s := range scopeSet {
result = append(result, s)
}
sort.Strings(result)
cachedAllScopes[identity] = result
return result
}
// CollectScopesForProjects collects the recommended scope for each API method
// in the specified from_meta projects. For each method, only the scope with
// the highest priority score is selected.
func CollectScopesForProjects(projects []string, identity string) []string {
priorities := LoadScopePriorities()
scopeSet := make(map[string]bool)
for _, project := range projects {
spec := LoadFromMeta(project)
if spec == nil {
continue
}
resources, ok := spec["resources"].(map[string]interface{})
if !ok {
continue
}
for _, resSpec := range resources {
resMap, ok := resSpec.(map[string]interface{})
if !ok {
continue
}
methods, ok := resMap["methods"].(map[string]interface{})
if !ok {
continue
}
for _, methodSpec := range methods {
methodMap, ok := methodSpec.(map[string]interface{})
if !ok {
continue
}
if tokens, ok := methodMap["accessTokens"].([]interface{}); ok {
supported := false
for _, t := range tokens {
if ts, ok := t.(string); ok && ts == IdentityToAccessToken(identity) {
supported = true
break
}
}
if !supported {
continue
}
}
scopes, ok := methodMap["scopes"].([]interface{})
if !ok || len(scopes) == 0 {
continue
}
bestScope := ""
bestScore := -1
for _, s := range scopes {
str, ok := s.(string)
if !ok {
continue
}
score := DefaultScopeScore
if v, exists := priorities[str]; exists {
score = v
}
if score > bestScore {
bestScore = score
bestScope = str
}
}
if bestScope != "" {
scopeSet[bestScope] = true
}
}
for _, ref := range methodsForProjects(projects, identity) {
if best := bestScope(ref.Method.Scopes, priorities); best != "" {
scopeSet[best] = true
}
}
@@ -165,78 +186,25 @@ func CollectScopesWithSources(projects []string, identity string) ([]string, map
scopeSet := make(map[string]bool)
sources := make(map[string]*ScopeSource)
for _, project := range projects {
spec := LoadFromMeta(project)
if spec == nil {
for _, ref := range methodsForProjects(projects, identity) {
m := ref.Method
best := bestScope(m.Scopes, priorities)
if best == "" {
continue
}
resources, ok := spec["resources"].(map[string]interface{})
if !ok {
continue
scopeSet[best] = true
if sources[best] == nil {
sources[best] = &ScopeSource{}
}
for resName, resSpec := range resources {
resMap, ok := resSpec.(map[string]interface{})
if !ok {
continue
}
methods, ok := resMap["methods"].(map[string]interface{})
if !ok {
continue
}
for methodName, methodSpec := range methods {
methodMap, ok := methodSpec.(map[string]interface{})
if !ok {
continue
}
if tokens, ok := methodMap["accessTokens"].([]interface{}); ok {
supported := false
for _, t := range tokens {
if ts, ok := t.(string); ok && ts == IdentityToAccessToken(identity) {
supported = true
break
}
}
if !supported {
continue
}
}
scopes, ok := methodMap["scopes"].([]interface{})
if !ok || len(scopes) == 0 {
continue
}
bestScope := ""
bestScore := -1
for _, s := range scopes {
str, ok := s.(string)
if !ok {
continue
}
score := DefaultScopeScore
if v, exists := priorities[str]; exists {
score = v
}
if score > bestScore {
bestScore = score
bestScope = str
}
}
if bestScope != "" {
scopeSet[bestScope] = true
if sources[bestScope] == nil {
sources[bestScope] = &ScopeSource{}
}
methodID := GetStrFromMap(methodMap, "id")
if methodID == "" {
methodID = project + "." + resName + "." + methodName
}
httpMethod := GetStrFromMap(methodMap, "httpMethod")
if httpMethod == "" {
httpMethod = "?"
}
sources[bestScope].APIs = append(sources[bestScope].APIs, httpMethod+" "+methodID)
}
}
methodID := m.ID
if methodID == "" {
methodID = ref.ServiceName() + "." + ref.ResourceName() + "." + ref.MethodName()
}
httpMethod := m.HTTPMethod
if httpMethod == "" {
httpMethod = "?"
}
sources[best].APIs = append(sources[best].APIs, httpMethod+" "+methodID)
}
// Sort API lists for stable output
@@ -267,92 +235,27 @@ type CommandEntry struct {
// - If the method has a "requiredScopes" field, all of those scopes are needed (conjunction).
// - Otherwise, only the highest-priority scope from "scopes" is shown (minimum privilege).
func CollectCommandScopes(projects []string, identity string) []CommandEntry {
priorities := LoadScopePriorities()
var entries []CommandEntry
for _, project := range projects {
spec := LoadFromMeta(project)
if spec == nil {
for _, ref := range methodsForProjects(projects, identity) {
m := ref.Method
if len(m.Scopes) == 0 {
continue
}
resources, ok := spec["resources"].(map[string]interface{})
if !ok {
// Effective-scope policy (requiredScopes conjunction, else recommended)
// lives once in DeclaredScopesForMethod.
effectiveScopes := DeclaredScopesForMethod(m, identity)
if len(effectiveScopes) == 0 {
continue
}
for resName, resSpec := range resources {
resMap, ok := resSpec.(map[string]interface{})
if !ok {
continue
}
methods, ok := resMap["methods"].(map[string]interface{})
if !ok {
continue
}
for methodName, methodSpec := range methods {
methodMap, ok := methodSpec.(map[string]interface{})
if !ok {
continue
}
if tokens, ok := methodMap["accessTokens"].([]interface{}); ok {
supported := false
for _, t := range tokens {
if ts, ok := t.(string); ok && ts == IdentityToAccessToken(identity) {
supported = true
break
}
}
if !supported {
continue
}
}
rawScopes, ok := methodMap["scopes"].([]interface{})
if !ok || len(rawScopes) == 0 {
continue
}
// Check for requiredScopes (conjunction — all needed)
var effectiveScopes []string
if reqRaw, ok := methodMap["requiredScopes"].([]interface{}); ok && len(reqRaw) > 0 {
for _, s := range reqRaw {
if str, ok := s.(string); ok {
effectiveScopes = append(effectiveScopes, str)
}
}
} else {
// Pick the single best scope (minimum privilege)
bestScope := ""
bestScore := -1
for _, s := range rawScopes {
str, ok := s.(string)
if !ok {
continue
}
score := DefaultScopeScore
if v, exists := priorities[str]; exists {
score = v
}
if score > bestScore {
bestScore = score
bestScope = str
}
}
if bestScope != "" {
effectiveScopes = []string{bestScope}
}
}
if len(effectiveScopes) == 0 {
continue
}
httpMethod := GetStrFromMap(methodMap, "httpMethod")
entries = append(entries, CommandEntry{
Command: resName + " " + methodName,
Type: "api",
Scopes: effectiveScopes,
HTTPMethod: httpMethod,
})
}
}
entries = append(entries, CommandEntry{
Command: ref.ResourceName() + " " + ref.MethodName(),
Type: "api",
Scopes: effectiveScopes,
HTTPMethod: m.HTTPMethod,
})
}
sort.Slice(entries, func(i, j int) bool {

View File

@@ -47,6 +47,10 @@
"en": { "title": "Minutes", "description": "Minutes content and metadata retrieval" },
"zh": { "title": "妙记", "description": "妙记信息获取、内容查询" }
},
"note": {
"en": { "title": "Note", "description": "Meeting note detail and unified transcript retrieval" },
"zh": { "title": "会议纪要", "description": "会议纪要详情与 unified 逐字稿查询" }
},
"sheets": {
"en": { "title": "Sheets", "description": "Spreadsheet operations" },
"zh": { "title": "电子表格", "description": "电子表格操作" }

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,8 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/registry"
)
@@ -35,58 +37,6 @@ func TestMain(m *testing.M) {
os.Exit(code)
}
func TestKeyOrderIndex_ImReactionsList(t *testing.T) {
// We only assert key-set membership, not absolute order — the upstream
// meta_data API does not guarantee a stable JSON key sequence across
// fetches, so hard-coding the order makes CI flaky. Order preservation
// from input to output is tested separately in TestBuildInputSchema_*.
order := lookupKeyOrder("im", []string{"reactions"}, "list")
if order == nil {
t.Fatal("expected key order for im.reactions.list, got nil")
}
wantParams := map[string]bool{
"message_id": true, "reaction_type": true, "page_token": true,
"page_size": true, "user_id_type": true,
}
if got, want := len(order.Parameters), len(wantParams); got != want {
t.Errorf("parameters count = %d, want %d (got %v)", got, want, order.Parameters)
}
for _, k := range order.Parameters {
if !wantParams[k] {
t.Errorf("unexpected parameter key %q", k)
}
}
// im.reactions.list 是 GET没有 requestBody
if len(order.RequestBody) != 0 {
t.Errorf("expected empty RequestBody, got %v", order.RequestBody)
}
}
func TestKeyOrderIndex_ImImagesCreate(t *testing.T) {
// Membership-only assertion; see comment on TestKeyOrderIndex_ImReactionsList.
order := lookupKeyOrder("im", []string{"images"}, "create")
if order == nil {
t.Fatal("expected key order for im.images.create, got nil")
}
wantBody := map[string]bool{"image_type": true, "image": true}
if got, want := len(order.RequestBody), len(wantBody); got != want {
t.Errorf("requestBody count = %d, want %d (got %v)", got, want, order.RequestBody)
}
for _, k := range order.RequestBody {
if !wantBody[k] {
t.Errorf("unexpected requestBody key %q", k)
}
}
}
func TestKeyOrderIndex_UnknownPath(t *testing.T) {
// 远端缓存的命令(不在 embedded 内)查不到 key order返回 nil 走字母序兜底
order := lookupKeyOrder("nonexistent_service", []string{"foo"}, "bar")
if order != nil {
t.Errorf("expected nil for unknown path, got %+v", order)
}
}
func TestConvertProperty_BasicTypes(t *testing.T) {
tests := []struct {
name string
@@ -288,9 +238,6 @@ func TestConvertProperty_DescriptionDefaultExample(t *testing.T) {
func TestBuildInputSchema_ReactionsList(t *testing.T) {
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
currentMethodOrder = mko
defer func() { currentMethodOrder = nil }()
is := buildInputSchema(method)
@@ -313,16 +260,13 @@ func TestBuildInputSchema_ReactionsList(t *testing.T) {
if !reflect.DeepEqual(params.Required, []string{"message_id"}) {
t.Errorf("params.Required = %v, want [message_id]", params.Required)
}
if !reflect.DeepEqual(params.Properties.Order, mko.Parameters) {
t.Errorf("params.properties order = %v, want (from key index) %v",
params.Properties.Order, mko.Parameters)
if want := []string{"message_id", "page_size", "page_token", "reaction_type", "user_id_type"}; !reflect.DeepEqual(params.Properties.Order, want) {
t.Errorf("params.properties order = %v, want %v (alphabetical)", params.Properties.Order, want)
}
}
func TestBuildInputSchema_ImagesCreate_FileAndBody(t *testing.T) {
method := loadMethodFromRegistry(t, "im", []string{"images"}, "create")
currentMethodOrder = lookupKeyOrder("im", []string{"images"}, "create")
defer func() { currentMethodOrder = nil }()
is := buildInputSchema(method)
@@ -382,10 +326,8 @@ func TestBuildInputSchema_HighRiskWriteInjectsYes(t *testing.T) {
},
},
}
currentMethodOrder = nil
defer func() { currentMethodOrder = nil }()
is := buildInputSchema(method)
is := buildInputSchema(meta.FromMap(method))
// yes lives at inputSchema.properties.yes (sibling of params/data)
yes, ok := is.Properties.Map["yes"]
@@ -413,9 +355,6 @@ func TestBuildInputSchema_HighRiskWriteInjectsYes(t *testing.T) {
func TestBuildInputSchema_NoYesForReadRisk(t *testing.T) {
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
currentMethodOrder = mko
defer func() { currentMethodOrder = nil }()
is := buildInputSchema(method)
if _, ok := is.Properties.Map["yes"]; ok {
@@ -425,9 +364,6 @@ func TestBuildInputSchema_NoYesForReadRisk(t *testing.T) {
func TestBuildOutputSchema_ReactionsList(t *testing.T) {
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
currentMethodOrder = mko
defer func() { currentMethodOrder = nil }()
os := buildOutputSchema(method)
@@ -450,31 +386,6 @@ func TestBuildOutputSchema_ReactionsList(t *testing.T) {
}
}
func TestConvertAccessTokens(t *testing.T) {
tests := []struct {
name string
input []interface{}
want []string
}{
{"tenant only", []interface{}{"tenant"}, []string{"bot"}},
{"user only", []interface{}{"user"}, []string{"user"}},
{"tenant then user", []interface{}{"tenant", "user"}, []string{"bot", "user"}},
{"user then tenant", []interface{}{"user", "tenant"}, []string{"bot", "user"}},
{"deduped", []interface{}{"tenant", "tenant", "user"}, []string{"bot", "user"}},
{"empty", []interface{}{}, []string{}},
{"nil", nil, []string{}},
{"unknown skipped", []interface{}{"user", "admin"}, []string{"user"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := convertAccessTokens(tt.input)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("got %v, want %v", got, tt.want)
}
})
}
}
func TestBuildMeta_FullFields(t *testing.T) {
// Synthesized method to avoid runtime variance from remote-cache overlay
// (which strips `risk` from merged services). All other field semantics
@@ -489,7 +400,7 @@ func TestBuildMeta_FullFields(t *testing.T) {
"accessTokens": []interface{}{"tenant"},
"docUrl": "https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/image/create",
}
m := buildMeta(method)
m := buildMeta(meta.FromMap(method))
if m.EnvelopeVersion != "1.0" {
t.Errorf("EnvelopeVersion = %q", m.EnvelopeVersion)
@@ -526,7 +437,7 @@ func TestBuildMeta_MissingRiskDefaultsToRead(t *testing.T) {
"accessTokens": []interface{}{"user"},
// no risk field
}
m := buildMeta(method)
m := buildMeta(meta.FromMap(method))
if m.Risk != "read" {
t.Errorf("Risk = %q, want \"read\" (default for missing risk)", m.Risk)
}
@@ -540,58 +451,26 @@ func TestBuildMeta_RequiredScopesPresent(t *testing.T) {
}
}
func TestParseAffordance_NilOrEmpty(t *testing.T) {
cases := []struct {
name string
raw interface{}
}{
{"nil", nil},
{"empty object", map[string]interface{}{}},
{"all-five-empty-arrays", map[string]interface{}{
"use_when": []interface{}{},
"do_not_use_when": []interface{}{},
"prerequisites": []interface{}{},
"examples": []interface{}{},
"related": []interface{}{},
}},
{"malformed (string)", "not an object"},
{"malformed (number)", 42},
{"malformed (nested type mismatch)", map[string]interface{}{
"examples": "should be a list, not a string",
}},
func TestConvert_EnumDescriptions(t *testing.T) {
// options carrying descriptions -> enum + parallel enumDescriptions
withDesc := Convert(meta.Field{Type: "string", Options: []meta.Option{
{Value: "open_id", Description: "A"},
{Value: "user_id", Description: "B"},
}})
if !reflect.DeepEqual(withDesc.Enum, []interface{}{"open_id", "user_id"}) {
t.Errorf("Enum = %v", withDesc.Enum)
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := parseAffordance(c.raw); got != nil {
t.Errorf("parseAffordance(%v) = %+v, want nil", c.raw, got)
}
})
if !reflect.DeepEqual(withDesc.EnumDescriptions, []string{"A", "B"}) {
t.Errorf("EnumDescriptions = %v, want [A B] aligned with enum", withDesc.EnumDescriptions)
}
}
func TestParseAffordance_FullPopulated(t *testing.T) {
raw := map[string]interface{}{
"use_when": []interface{}{"需要拿到当前用户的主日历 ID"},
"do_not_use_when": []interface{}{"已知具体某一个非主日历的 calendar_id"},
"prerequisites": []interface{}{"user 身份登录"},
"examples": []interface{}{
map[string]interface{}{"description": "获取主日历", "command": "lark-cli calendar calendars primary"},
},
"related": []interface{}{"calendars.list"},
// bare enum form (no descriptions) -> enumDescriptions omitted (nil)
bare := Convert(meta.Field{Type: "string", Enum: []any{"x", "y"}})
if !reflect.DeepEqual(bare.Enum, []interface{}{"x", "y"}) {
t.Errorf("bare Enum = %v", bare.Enum)
}
a := parseAffordance(raw)
if a == nil {
t.Fatal("parseAffordance returned nil, want populated")
}
if len(a.UseWhen) != 1 || a.UseWhen[0] != "需要拿到当前用户的主日历 ID" {
t.Errorf("UseWhen = %v", a.UseWhen)
}
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) != 1 || a.Related[0] != "calendars.list" {
t.Errorf("Related = %v", a.Related)
if bare.EnumDescriptions != nil {
t.Errorf("bare enum must have nil EnumDescriptions, got %v", bare.EnumDescriptions)
}
}
@@ -604,7 +483,7 @@ func TestBuildMeta_AffordanceFromMethod(t *testing.T) {
"use_when": []interface{}{"trigger"},
},
}
m := buildMeta(method)
m := buildMeta(meta.FromMap(method))
if m.Affordance == nil {
t.Fatal("Affordance should be populated from method[\"affordance\"]")
}
@@ -620,7 +499,7 @@ func TestBuildMeta_MissingDocURLOmitted(t *testing.T) {
"risk": "read",
// no docUrl
}
m := buildMeta(method)
m := buildMeta(meta.FromMap(method))
if m.DocURL != "" {
t.Errorf("DocURL = %q, want empty (will be omitempty)", m.DocURL)
}
@@ -634,8 +513,7 @@ func TestBuildMeta_MissingDocURLOmitted(t *testing.T) {
func TestBuildOutputSchema_EmptyResponseBody(t *testing.T) {
// 装配器对空 responseBody 应生成 properties = {} (不 nil
method := map[string]interface{}{}
currentMethodOrder = nil
os := buildOutputSchema(method)
os := buildOutputSchema(meta.FromMap(method))
if os.Type != "object" {
t.Errorf("Type = %q, want \"object\"", os.Type)
}
@@ -647,9 +525,16 @@ func TestBuildOutputSchema_EmptyResponseBody(t *testing.T) {
}
}
// synthEnvelope renders an envelope for a synthetic (service, resourcePath, method)
// via the public ref entry, so these unit tests build the same MethodRef the
// command layer feeds Envelope.
func synthEnvelope(serviceName string, resourcePath []string, m meta.Method) Envelope {
return EnvelopeOf(apicatalog.MethodRef{Service: meta.Service{Name: serviceName}, ResourcePath: resourcePath, Method: m})
}
func TestAssembleEnvelope_ReactionsList_FullStructure(t *testing.T) {
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
env := AssembleEnvelope("im", []string{"reactions"}, "list", method)
env := synthEnvelope("im", []string{"reactions"}, method)
if env.Name != "im reactions list" {
t.Errorf("Name = %q, want \"im reactions list\"", env.Name)
@@ -671,7 +556,7 @@ func TestAssembleEnvelope_NestedResource_NameJoinedWithSpaces(t *testing.T) {
// overlay strips `bots` from the loaded method map on this environment;
// the assertion is about name joining, not method specifics.
method := loadMethodFromRegistry(t, "im", []string{"chat.members"}, "create")
env := AssembleEnvelope("im", []string{"chat.members"}, "create", method)
env := synthEnvelope("im", []string{"chat.members"}, method)
// chat.members resourcePath stays as one element in the slice with a dot;
// name should split it to "im chat.members create" — we keep the dot as-is
// inside the resource segment to round-trip with completion logic.
@@ -683,8 +568,8 @@ func TestAssembleEnvelope_NestedResource_NameJoinedWithSpaces(t *testing.T) {
func TestAssembleEnvelope_JSONIsStable(t *testing.T) {
// Assemble twice; JSON output must be byte-identical (determinism).
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
a := AssembleEnvelope("im", []string{"reactions"}, "list", method)
b := AssembleEnvelope("im", []string{"reactions"}, "list", method)
a := synthEnvelope("im", []string{"reactions"}, method)
b := synthEnvelope("im", []string{"reactions"}, method)
ja, _ := json.MarshalIndent(a, "", " ")
jb, _ := json.MarshalIndent(b, "", " ")
if string(ja) != string(jb) {
@@ -693,8 +578,8 @@ func TestAssembleEnvelope_JSONIsStable(t *testing.T) {
}
func TestAssembleService_Im(t *testing.T) {
spec := registry.LoadFromMeta("im")
envs := AssembleService("im", spec, nil)
svc, _ := registry.ServiceTyped("im")
envs := Envelopes(apicatalog.ServiceMethods(svc, nil))
if len(envs) == 0 {
t.Fatal("expected non-empty envelopes for service im")
}
@@ -713,17 +598,16 @@ func TestAssembleService_Im(t *testing.T) {
}
func TestAssembleService_FilterByAccessToken(t *testing.T) {
spec := registry.LoadFromMeta("im")
svc, _ := registry.ServiceTyped("im")
// Filter to bot-only (--as bot, which corresponds to "tenant")
envs := AssembleService("im", spec, func(method map[string]interface{}) bool {
tokens, _ := method["accessTokens"].([]interface{})
for _, t := range tokens {
if s, _ := t.(string); s == "tenant" {
envs := Envelopes(apicatalog.ServiceMethods(svc, func(m meta.Method) bool {
for _, t := range m.AccessTokens {
if t == "tenant" {
return true
}
}
return false
})
}))
// Every envelope's _meta.access_tokens must contain "bot"
for _, e := range envs {
found := false
@@ -740,11 +624,11 @@ func TestAssembleService_FilterByAccessToken(t *testing.T) {
}
func TestAssembleAll_AtLeast193(t *testing.T) {
envs := AssembleAll(nil)
// Envelope assembly is overlay-independent (Task 17b): AssembleAll walks the
// embedded meta_data.json directly, so the count is stable across machines.
envs := Envelopes(registry.EmbeddedCatalog().WalkMethods(nil))
// Envelope assembly is overlay-independent: it walks the embedded
// meta_data.json directly, so the count is stable across machines.
if len(envs) < 193 {
t.Errorf("AssembleAll returned %d envelopes, expected >= 193", len(envs))
t.Errorf("envelope count = %d, expected >= 193", len(envs))
}
// Spot check: im reactions list should be present
found := false
@@ -759,24 +643,32 @@ func TestAssembleAll_AtLeast193(t *testing.T) {
}
}
// loadMethodFromRegistry is a test helper that pulls one method's spec from the
// real embedded meta_data.json via the registry package.
func loadMethodFromRegistry(t *testing.T, service string, resourcePath []string, methodName string) map[string]interface{} {
// loadMethodFromRegistry is a test helper that pulls one method from the real
// embedded meta_data.json via the registry's typed accessor, with Name set.
func loadMethodFromRegistry(t *testing.T, service string, resourcePath []string, methodName string) meta.Method {
t.Helper()
spec := registry.LoadFromMeta(service)
if spec == nil {
svc, ok := registry.ServiceTyped(service)
if !ok {
t.Fatalf("service %q not found in registry", service)
}
resources, _ := spec["resources"].(map[string]interface{})
resKey := strings.Join(resourcePath, ".")
res, ok := resources[resKey].(map[string]interface{})
res, ok := svc.Resources[resKey]
if !ok {
t.Fatalf("resource %q.%s not found", service, resKey)
}
methods, _ := res["methods"].(map[string]interface{})
m, ok := methods[methodName].(map[string]interface{})
m, ok := res.Methods[methodName]
if !ok {
t.Fatalf("method %q.%s.%s not found", service, resKey, methodName)
}
m.Name = methodName
return m
}
// convertProperty is a test helper: it decodes a single field-spec map into a
// meta.Field and renders its Property (the conversion the assembler does).
func convertProperty(fieldMap map[string]interface{}, _ string) Property {
b, _ := json.Marshal(fieldMap)
var f meta.Field
_ = json.Unmarshal(b, &f)
return Convert(f)
}

View File

@@ -7,7 +7,7 @@ import (
"errors"
"fmt"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
var validJSONSchemaTypes = map[string]bool{
@@ -81,7 +81,7 @@ func lintEnvelope(env Envelope) []error {
}
// ---- L3: cross-field self-consistency ----
dangerExpected := env.Meta.Risk == cmdutil.RiskWrite || env.Meta.Risk == cmdutil.RiskHighRiskWrite
dangerExpected := env.Meta.Risk == core.RiskWrite || env.Meta.Risk == core.RiskHighRiskWrite
if env.Meta.Danger != dangerExpected {
errs = append(errs, fmt.Errorf("L3: _meta.danger=%v inconsistent with risk=%q", env.Meta.Danger, env.Meta.Risk))
}
@@ -92,7 +92,7 @@ func lintEnvelope(env Envelope) []error {
if env.InputSchema != nil && env.InputSchema.Properties != nil {
_, hasYes = env.InputSchema.Properties.Map["yes"]
}
wantYes := env.Meta.Risk == cmdutil.RiskHighRiskWrite
wantYes := env.Meta.Risk == core.RiskHighRiskWrite
if hasYes != wantYes {
errs = append(errs, fmt.Errorf("L3: inputSchema `yes` property=%v inconsistent with risk=%q", hasYes, env.Meta.Risk))
}
@@ -125,6 +125,9 @@ func walkForL2(props *OrderedProps, errs *[]error) {
if p.Minimum != nil && p.Maximum != nil && *p.Minimum >= *p.Maximum {
*errs = append(*errs, fmt.Errorf("L2: field %q minimum (%v) >= maximum (%v)", k, *p.Minimum, *p.Maximum))
}
if n := len(p.EnumDescriptions); n > 0 && n != len(p.Enum) {
*errs = append(*errs, fmt.Errorf("L2: field %q enumDescriptions length (%d) != enum length (%d)", k, n, len(p.Enum)))
}
if len(p.Required) > 0 && p.Properties != nil {
for _, r := range p.Required {
if _, ok := p.Properties.Map[r]; !ok {

View File

@@ -7,6 +7,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/registry"
)
@@ -147,6 +148,18 @@ func TestLintEnvelope_L2_TypeChecks(t *testing.T) {
},
wantSub: "minimum",
},
{
name: "enumDescriptions length must match enum",
mutate: func(e *Envelope) {
e.InputSchema.Properties.Order = []string{"k"}
e.InputSchema.Properties.Map["k"] = Property{
Type: "string",
Enum: []interface{}{"a", "b", "c"},
EnumDescriptions: []string{"only one"}, // misaligned with 3 enum values
}
},
wantSub: "enumDescriptions",
},
{
// Regression guard: walkForL2 must recurse into the params/data
// sub-objects introduced by the 4-bucket inputSchema, not only the
@@ -334,9 +347,8 @@ func TestAllEnvelopesPass(t *testing.T) {
knownEnvelopes := map[string]bool{}
// Use embedded data only so the gate is deterministic across machines
// (matches Task 17b: envelope assembly is overlay-independent).
for _, svc := range registry.EmbeddedServiceNames() {
spec := registry.EmbeddedSpec(svc)
envs := AssembleService(svc, spec, nil)
for _, svc := range registry.EmbeddedServicesTyped() {
envs := Envelopes(apicatalog.ServiceMethods(svc, nil))
for _, env := range envs {
errs := lintEnvelope(env)
if len(errs) == 0 {
@@ -366,7 +378,7 @@ func TestAllEnvelopesPass(t *testing.T) {
}
// L4 coverage report (warn-only via t.Logf)
all := AssembleAll(nil)
all := Envelopes(registry.EmbeddedCatalog().WalkMethods(nil))
c := measureCoverage(all)
for metric, rate := range c {
baseline := coverageBaseline[metric]

View File

@@ -1,30 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package schema
import "strings"
// ParsePath normalizes the positional arguments of `lark-cli schema` into a
// slice of path segments. It accepts two equivalent forms:
//
// lark-cli schema im.messages.reply -> single arg, split on "."
// lark-cli schema im messages reply -> multiple args, used as-is
// lark-cli schema "im chat.members bots" is NOT a supported form; quote
// arguments individually if your shell needs it. Nested resources keep their
// internal dots (e.g. "chat.members").
//
// Returns nil for zero args (bare invocation).
func ParsePath(args []string) []string {
switch len(args) {
case 0:
return nil
case 1:
if strings.Contains(args[0], ".") {
return strings.Split(args[0], ".")
}
return []string{args[0]}
default:
return args
}
}

View File

@@ -8,6 +8,8 @@ import (
"encoding/json"
"fmt"
"sort"
"github.com/larksuite/cli/internal/meta"
)
// Envelope is the MCP Tool spec contract for a single API method command.
@@ -45,42 +47,32 @@ type Property struct {
Type string `json:"type,omitempty"`
Description string `json:"description,omitempty"`
Enum []interface{} `json:"enum,omitempty"`
Default interface{} `json:"default,omitempty"`
Example interface{} `json:"example,omitempty"`
Minimum *float64 `json:"minimum,omitempty"`
Maximum *float64 `json:"maximum,omitempty"`
Format string `json:"format,omitempty"`
Required []string `json:"required,omitempty"`
Properties *OrderedProps `json:"properties,omitempty"`
Items *Property `json:"items,omitempty"`
// EnumDescriptions, when present, is parallel to Enum: the human meaning of
// each allowed value, in the same order. Omitted when no value carries a
// description. This is the widely-recognized JSON-Schema extension (VS Code,
// OpenAPI tooling) that lets an AI consumer learn what each enum value means
// without a second lookup.
EnumDescriptions []string `json:"enumDescriptions,omitempty"`
Default interface{} `json:"default,omitempty"`
Example interface{} `json:"example,omitempty"`
Minimum *float64 `json:"minimum,omitempty"`
Maximum *float64 `json:"maximum,omitempty"`
Format string `json:"format,omitempty"`
Required []string `json:"required,omitempty"`
Properties *OrderedProps `json:"properties,omitempty"`
Items *Property `json:"items,omitempty"`
}
// Meta is the Lark-specific extension namespace.
type Meta struct {
EnvelopeVersion string `json:"envelope_version"`
Scopes []string `json:"scopes"`
RequiredScopes []string `json:"required_scopes"`
AccessTokens []string `json:"access_tokens"`
Danger bool `json:"danger"`
Risk string `json:"risk"`
DocURL string `json:"doc_url,omitempty"`
Affordance *Affordance `json:"affordance,omitempty"`
}
// Affordance is the hand-written overlay (PR-1 only defines the type, no YAML loaded).
type Affordance struct {
UseWhen []string `json:"use_when,omitempty"`
DoNotUseWhen []string `json:"do_not_use_when,omitempty"`
Prerequisites []string `json:"prerequisites,omitempty"`
Examples []AffordanceCase `json:"examples,omitempty"`
Related []string `json:"related,omitempty"`
}
// AffordanceCase is one example entry: a one-line description plus a
// ready-to-run lark-cli command string.
type AffordanceCase struct {
Description string `json:"description"`
Command string `json:"command"`
EnvelopeVersion string `json:"envelope_version"`
Scopes []string `json:"scopes"`
RequiredScopes []string `json:"required_scopes"`
AccessTokens []string `json:"access_tokens"`
Danger bool `json:"danger"`
Risk string `json:"risk"`
DocURL string `json:"doc_url,omitempty"`
Affordance *meta.Affordance `json:"affordance,omitempty"`
}
// OrderedProps is map[string]Property with preserved key order on MarshalJSON.
@@ -91,6 +83,20 @@ type OrderedProps struct {
Map map[string]Property
}
// Set adds or replaces a property, recording first-seen keys in Order so JSON
// output preserves insertion order. Re-setting an existing key updates its
// value without reordering. Centralizing mutation here keeps Order and Map from
// drifting out of sync.
func (o *OrderedProps) Set(key string, p Property) {
if o.Map == nil {
o.Map = make(map[string]Property)
}
if _, exists := o.Map[key]; !exists {
o.Order = append(o.Order, key)
}
o.Map[key] = p
}
// MarshalJSON emits keys in Order, not alphabetical. If Order is empty but
// Map has entries, fall back to alphabetical key order over Map so callers
// that only populated Map (no explicit ordering) still see their fields.

View File

@@ -8,6 +8,21 @@ import (
"testing"
)
func TestOrderedProps_Set(t *testing.T) {
op := &OrderedProps{}
op.Set("b", Property{Type: "string"})
op.Set("a", Property{Type: "integer"})
op.Set("b", Property{Type: "boolean"}) // re-set: updates value, keeps position
wantOrder := []string{"b", "a"}
if len(op.Order) != len(wantOrder) || op.Order[0] != "b" || op.Order[1] != "a" {
t.Errorf("Order = %v, want %v (insertion order, no duplicate on re-set)", op.Order, wantOrder)
}
if op.Map["b"].Type != "boolean" {
t.Errorf("re-set value = %q, want boolean", op.Map["b"].Type)
}
}
// OrderedProps 在测试里验证MarshalJSON 按 Order 切片顺序输出 key跳过 Go map 默认字母序。
func TestOrderedProps_MarshalJSON_PreservesOrder(t *testing.T) {
op := &OrderedProps{

View File

@@ -10,8 +10,6 @@ import (
"os"
"strings"
"sync"
"github.com/larksuite/cli/internal/envvars"
)
// Proxy environment constants control shared transport proxy behavior.
@@ -79,16 +77,8 @@ func WarnIfProxied(w io.Writer) {
// Shared), so its warning and disable instructions take precedence.
// Emitting the env-proxy warning here would be misleading: it tells the
// user to set LARK_CLI_NO_PROXY=1, which does NOT disable the plugin proxy.
if addr, caPath, enabled := proxyPluginStatus(); enabled {
fmt.Fprintf(w, "[lark-cli] [WARN] proxy plugin enabled: all requests (including credentials) are forced through %s. To disable, set %s=false or remove %s.\n",
redactProxyURL(addr), envvars.CliProxyEnable, Path())
if strings.TrimSpace(caPath) != "" {
// A custom CA means upstream TLS can be intercepted/inspected by
// the proxy (MITM). Surface it so the operator is aware traffic
// (including Bearer tokens) is decryptable on this host.
fmt.Fprintf(w, "[lark-cli] [WARN] proxy plugin trusts a custom CA (%s); TLS to upstreams can be intercepted/inspected by this proxy.\n",
caPath)
}
if _, _, enabled := proxyPluginStatus(); enabled {
fmt.Fprintln(w, "[lark-cli] [WARN] proxy plugin enabled: all requests are forced through proxy.")
return
}
if os.Getenv(EnvNoProxy) != "" {

View File

@@ -8,8 +8,6 @@ import (
"strings"
"sync"
"testing"
"github.com/larksuite/cli/internal/envvars"
)
// TestDetectProxyEnv verifies proxy environment detection priority and empty-state behavior.
@@ -120,9 +118,9 @@ func TestWarnIfProxied_OnlyOnce(t *testing.T) {
}
// TestWarnIfProxied_ProxyPluginEnabled verifies that when proxy plugin mode is
// enabled, the warning describes the plugin proxy and the correct disable method
// (LARKSUITE_CLI_PROXY_ENABLE=false) instead of the misleading LARK_CLI_NO_PROXY
// instruction — even when env proxy and LARK_CLI_NO_PROXY are also set.
// enabled, the warning is a single concise line that does not leak the proxy
// address or give the misleading LARK_CLI_NO_PROXY disable instruction — even
// when env proxy and LARK_CLI_NO_PROXY are also set.
func TestWarnIfProxied_ProxyPluginEnabled(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
@@ -140,51 +138,24 @@ func TestWarnIfProxied_ProxyPluginEnabled(t *testing.T) {
WarnIfProxied(&buf)
out := buf.String()
if !strings.Contains(out, "127.0.0.1:3128") {
t.Errorf("warning should mention the plugin proxy address, got: %s", out)
if !strings.Contains(out, "proxy plugin enabled") {
t.Errorf("warning should announce proxy plugin enabled, got: %s", out)
}
if !strings.Contains(out, envvars.CliProxyEnable) {
t.Errorf("warning should mention %s as the disable method, got: %s", envvars.CliProxyEnable, out)
// Single line only — no address, CA, or disable hints.
if strings.Count(out, "\n") != 1 {
t.Errorf("warning must be a single line, got: %s", out)
}
if strings.Contains(out, "127.0.0.1:3128") || strings.Contains(out, "corp-proxy") {
t.Errorf("warning must not leak the proxy address, got: %s", out)
}
if strings.Contains(out, "Set "+EnvNoProxy+"=1") {
t.Errorf("warning must NOT give the misleading %s disable instruction when plugin is enabled, got: %s", EnvNoProxy, out)
}
// No custom CA configured -> no interception warning.
if strings.Contains(out, "custom CA") {
t.Errorf("warning should not mention a custom CA when none is configured, got: %s", out)
}
}
// TestWarnIfProxied_ProxyPluginCustomCAWarns verifies that when a custom CA is
// trusted, the warning surfaces the TLS-interception capability.
func TestWarnIfProxied_ProxyPluginCustomCAWarns(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
proxyWarningOnce = sync.Once{}
old := proxyPluginStatus
proxyPluginStatus = func() (string, string, bool) {
return "http://127.0.0.1:3128", "/etc/lark/extra_ca.pem", true
}
t.Cleanup(func() { proxyPluginStatus = old })
var buf bytes.Buffer
WarnIfProxied(&buf)
out := buf.String()
if !strings.Contains(out, "custom CA") {
t.Errorf("warning should mention the custom CA, got: %s", out)
}
if !strings.Contains(out, "/etc/lark/extra_ca.pem") {
t.Errorf("warning should include the CA path, got: %s", out)
}
if !strings.Contains(out, "intercept") {
t.Errorf("warning should mention TLS interception, got: %s", out)
}
}
// TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials verifies the plugin
// warning never leaks credentials embedded in the configured proxy address.
// warning never leaks credentials embedded in the configured proxy address
// the simplified message omits the address entirely.
func TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
@@ -204,9 +175,6 @@ func TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials(t *testing.T) {
if strings.Contains(out, "user:") {
t.Errorf("plugin warning leaked username, got: %s", out)
}
if !strings.Contains(out, "***@127.0.0.1:3128") {
t.Errorf("plugin warning should contain redacted proxy URL, got: %s", out)
}
}
// TestRedactProxyURL verifies redaction of proxy credentials across supported formats.

View File

@@ -60,12 +60,12 @@ func safePath(raw, flagName string) (string, error) {
return "", err
}
path := filepath.Clean(raw)
if filepath.IsAbs(path) {
if isAbsolutePath(raw) {
return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: cd to the target directory first, or use a relative path like ./filename)", flagName, raw)
}
path := filepath.Clean(raw)
cwd, err := vfs.Getwd()
if err != nil {
return "", fmt.Errorf("cannot determine working directory: %w", err)
@@ -114,6 +114,21 @@ func resolveNearestAncestor(path string) (string, error) {
}
}
func isAbsolutePath(path string) bool {
path = strings.TrimSpace(path)
if path == "" {
return false
}
if filepath.IsAbs(path) || strings.HasPrefix(path, "/") || strings.HasPrefix(path, `\`) {
return true
}
if len(path) >= 3 && path[1] == ':' && (path[2] == '/' || path[2] == '\\') {
drive := path[0]
return ('A' <= drive && drive <= 'Z') || ('a' <= drive && drive <= 'z')
}
return false
}
func isUnderDir(child, parent string) bool {
rel, err := filepath.Rel(parent, child)
if err != nil {

View File

@@ -34,6 +34,10 @@ func TestSafeOutputPath_RejectsPathTraversalAndDangerousInput(t *testing.T) {
// ── GIVEN: absolute paths → THEN: rejected ──
{"absolute path unix", "/etc/passwd", true},
{"absolute path root", "/tmp/evil", true},
{"absolute path windows drive", `C:\Users\agent\secret.txt`, true},
{"absolute path windows drive slash", "C:/Users/agent/secret.txt", true},
{"absolute path windows rooted", `\Users\agent\secret.txt`, true},
{"absolute path windows unc", `\\server\share\secret.txt`, true},
// ── GIVEN: control characters in path → THEN: rejected ──
{"null byte", "file\x00.txt", true},
@@ -187,11 +191,23 @@ func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
}
func TestSafeUploadPath_RejectsNonTempAbsolutePath(t *testing.T) {
// GIVEN: an absolute path outside the temp directory
// WHEN / THEN: SafeUploadPath rejects it
_, err := SafeInputPath("/etc/passwd")
if err == nil {
t.Error("expected error for absolute non-temp path, got nil")
for _, tt := range []struct {
name string
input string
}{
{"absolute path unix", "/etc/passwd"},
{"absolute path windows drive", `C:\Users\agent\secret.txt`},
{"absolute path windows drive slash", "C:/Users/agent/secret.txt"},
{"absolute path windows rooted", `\Users\agent\secret.txt`},
{"absolute path windows unc", `\\server\share\secret.txt`},
} {
t.Run(tt.name, func(t *testing.T) {
// WHEN / THEN: SafeInputPath rejects absolute paths on every platform.
_, err := SafeInputPath(tt.input)
if err == nil {
t.Errorf("expected error for absolute path %q, got nil", tt.input)
}
})
}
}

View File

@@ -25,9 +25,11 @@ var migratedCommonHelperPaths = []string{
"shortcuts/doc/",
"shortcuts/drive/",
"shortcuts/event/",
"shortcuts/im/",
"shortcuts/mail/",
"shortcuts/markdown/",
"shortcuts/minutes/",
"shortcuts/note/",
"shortcuts/okr/",
"shortcuts/sheets/",
"shortcuts/slides/",

View File

@@ -26,9 +26,11 @@ var migratedEnvelopePaths = []string{
"shortcuts/doc/",
"shortcuts/drive/",
"shortcuts/event/",
"shortcuts/im/",
"shortcuts/mail/",
"shortcuts/markdown/",
"shortcuts/minutes/",
"shortcuts/note/",
"shortcuts/okr/",
"shortcuts/sheets/",
"shortcuts/slides/",
@@ -36,7 +38,6 @@ var migratedEnvelopePaths = []string{
"shortcuts/vc/",
"shortcuts/whiteboard/",
"shortcuts/wiki/",
"shortcuts/im/",
}
// legacyOutputImportPath is the import path of the package that declares the

View File

@@ -953,6 +953,7 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes
paths := []string{
"shortcuts/doc/docs_fetch_v2.go",
"shortcuts/drive/drive_search.go",
"shortcuts/im/im_messages_send.go",
"shortcuts/mail/mail_send.go",
"shortcuts/markdown/markdown_fetch.go",
"shortcuts/okr/okr_progress_create.go",
@@ -988,6 +989,18 @@ common.` + helper + `()
}
}
func TestMigratedCommonHelperPaths_CoverMigratedEnvelopePaths(t *testing.T) {
commonPaths := make(map[string]struct{}, len(migratedCommonHelperPaths))
for _, path := range migratedCommonHelperPaths {
commonPaths[path] = struct{}{}
}
for _, path := range migratedEnvelopePaths {
if _, ok := commonPaths[path]; !ok {
t.Fatalf("migratedEnvelopePaths contains %q but migratedCommonHelperPaths does not", path)
}
}
}
func TestCheckNoLegacyCommonHelperCall_RejectsDangerousCharsOnCalendarPath(t *testing.T) {
src := `package calendar

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.52",
"version": "1.0.53",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -13,12 +13,12 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// AppsAccessScopeGet reads the current access scope configuration of a Miaoda app.
// AppsAccessScopeGet reads the current access scope configuration of an app.
// 响应原样透传服务端契约(字符串 scope 枚举 All/Tenant/Range + 拆分的 users/departments/chats 数组)。
var AppsAccessScopeGet = common.Shortcut{
Service: appsService,
Command: "+access-scope-get",
Description: "Get Miaoda app access scope configuration",
Description: "Get app access scope configuration",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +access-scope-get --app-id <app_id>",
@@ -39,7 +39,7 @@ var AppsAccessScopeGet = common.Shortcut{
appID := strings.TrimSpace(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID))).
Desc("Get Miaoda app access scope")
Desc("Get app access scope")
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))

View File

@@ -24,7 +24,7 @@ var allowedAccessTargetTypes = map[string]bool{
var AppsAccessScopeSet = common.Shortcut{
Service: appsService,
Command: "+access-scope-set",
Description: "Set Miaoda app access scope (specific / public / tenant)",
Description: "Set app access scope (specific / public / tenant)",
Risk: "write",
Tips: []string{
`Example: lark-cli apps +access-scope-set --app-id <app_id> --scope tenant`,
@@ -52,7 +52,7 @@ var AppsAccessScopeSet = common.Shortcut{
appID := strings.TrimSpace(rctx.Str("app-id"))
dry := common.NewDryRunAPI().
PUT(fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID))).
Desc("Set Miaoda app access scope")
Desc("Set app access scope")
body, bodyErr := buildAccessScopeBody(rctx)
if bodyErr != nil {
dry.Set("body_error", bodyErr.Error())

View File

@@ -12,13 +12,13 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
const createHint = "verify --app-type is html or full_stack and --name is non-empty; if this is a permission error, confirm your account can create Miaoda apps"
const createHint = "verify --app-type is html or full_stack and --name is non-empty; if this is a permission error, confirm your account can create apps"
// AppsCreate creates a new Miaoda app.
// AppsCreate creates a new app.
var AppsCreate = common.Shortcut{
Service: appsService,
Command: "+create",
Description: "Create a new Miaoda app",
Description: "Create a new app",
Risk: "write",
Tips: []string{
`Example: lark-cli apps +create --name "审批系统" --app-type full_stack`,
@@ -42,7 +42,7 @@ var AppsCreate = common.Shortcut{
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST(apiBasePath + "/apps").
Desc("Create a Miaoda app").
Desc("Create an app").
Body(buildAppsCreateBody(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {

View File

@@ -14,7 +14,7 @@ import (
const dbEnvCreateHint = "verify --app-id is correct; if the app is already multi-env this is a conflict — inspect current tables with `lark-cli apps +db-table-list --app-id <app_id> --env dev`"
// AppsDBEnvCreate creates a DB environment for a Miaoda app拆分单库为 dev/online 多环境)。
// AppsDBEnvCreate creates a DB environment for an app拆分单库为 dev/online 多环境)。
//
// 调 POST /apps/{app_id}/db_dev_init。--env 指定要创建的环境,由调用方传入,目前只支持 dev。
// 不可逆:单库一旦拆成 dev/online 双库无法回退。Risk: high-risk-write 触发框架自动注入 --yes 确认关卡。
@@ -30,7 +30,7 @@ var AppsDBEnvCreate = common.Shortcut{
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "app-id", Desc: "app id", Required: true},
{Name: "env", Default: "dev", Enum: []string{"dev"}, Desc: "environment to create (only dev supported for now)"},
{Name: "sync-data", Type: "bool", Desc: "copy existing online data into the new environment (default off)"},
},
@@ -42,7 +42,7 @@ var AppsDBEnvCreate = common.Shortcut{
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
POST(appDbEnvCreatePath(appID)).
Desc("Create Miaoda app DB environment").
Desc("Create app DB environment").
Body(buildDBEnvCreateBody(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {

View File

@@ -17,7 +17,7 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// AppsDBExecute executes SQL against a Miaoda app database.
// AppsDBExecute executes SQL against an app database.
//
// POST /apps/{app_id}/sql_commandsCLI 永远带 ?transactional=false 进入 DBA 模式
// (不默认包事务、支持 DDL、result 字符串内嵌结构化 JSON
@@ -45,7 +45,7 @@ import (
var AppsDBExecute = common.Shortcut{
Service: appsService,
Command: "+db-execute",
Description: "Execute SQL (SELECT / DML / DDL) against a Miaoda app database",
Description: "Execute SQL (SELECT / DML / DDL) against an app database",
Risk: "high-risk-write",
Tips: []string{
`Example: lark-cli apps +db-execute --app-id <app_id> --sql "SELECT * FROM orders LIMIT 10" --yes`,
@@ -56,7 +56,7 @@ var AppsDBExecute = common.Shortcut{
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "app-id", Desc: "app id", Required: true},
{Name: "sql", Desc: "SQL text; use - to read stdin. Mutually exclusive with --file",
Input: []string{common.Stdin}},
{Name: "file", Desc: "path to a .sql file (relative to cwd). Mutually exclusive with --sql"},
@@ -97,7 +97,7 @@ var AppsDBExecute = common.Shortcut{
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
POST(appSQLPath(appID)).
Desc("Execute SQL on Miaoda app database").
Desc("Execute SQL on app database").
Params(buildDBSQLParams(rctx)).
Body(buildDBSQLBody(rctx))
},

View File

@@ -35,7 +35,7 @@ var AppsDBTableGet = common.Shortcut{
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "app-id", Desc: "app id", Required: true},
{Name: "table", Desc: "table name", Required: true},
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
},
@@ -52,7 +52,7 @@ var AppsDBTableGet = common.Shortcut{
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(appTablePath(appID, strings.TrimSpace(rctx.Str("table")))).
Desc("Get Miaoda app db table schema").
Desc("Get app db table schema").
Params(buildDBTableGetParams(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {

Some files were not shown because too many files have changed in this diff Show More