mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
fix(command): complete extension runtime contracts
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package affordance exposes the repository's default embedded command guidance.
|
||||
package affordance
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed *.md
|
||||
var content embed.FS
|
||||
|
||||
// DefaultFS returns the immutable default affordance tree rooted at domain files.
|
||||
func DefaultFS() fs.FS { return content }
|
||||
@@ -0,0 +1,15 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package affordance
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultFSContainsDomainGuidance(t *testing.T) {
|
||||
if _, err := fs.ReadFile(DefaultFS(), "im.md"); err != nil {
|
||||
t.Fatalf("read im.md: %v", err)
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -509,6 +509,10 @@ func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.App
|
||||
// Domains with auth_domain children are automatically expanded to include
|
||||
// their children's scopes.
|
||||
func collectScopesForDomains(domains []string, identity string, brand core.LarkBrand) []string {
|
||||
return collectScopesForDomainsWithShortcuts(domains, identity, brand, shortcuts.AllShortcuts())
|
||||
}
|
||||
|
||||
func collectScopesForDomainsWithShortcuts(domains []string, identity string, brand core.LarkBrand, registered []common.Shortcut) []string {
|
||||
scopeSet := make(map[string]bool)
|
||||
|
||||
// 1. API scopes from from_meta projects
|
||||
@@ -526,7 +530,7 @@ func collectScopesForDomains(domains []string, identity string, brand core.LarkB
|
||||
}
|
||||
|
||||
// 3. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
for _, sc := range registered {
|
||||
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
|
||||
continue
|
||||
}
|
||||
@@ -550,13 +554,17 @@ func collectScopesForDomains(domains []string, identity string, brand core.LarkB
|
||||
// shortcut services), excluding domains that have auth_domain set (they are
|
||||
// folded into their parent domain).
|
||||
func allKnownDomains(brand core.LarkBrand) map[string]bool {
|
||||
return allKnownDomainsWithShortcuts(brand, shortcuts.AllShortcuts())
|
||||
}
|
||||
|
||||
func allKnownDomainsWithShortcuts(brand core.LarkBrand, registered []common.Shortcut) map[string]bool {
|
||||
domains := make(map[string]bool)
|
||||
for _, p := range registry.ListFromMetaProjects() {
|
||||
if !registry.HasAuthDomain(p) {
|
||||
domains[p] = true
|
||||
}
|
||||
}
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
for _, sc := range registered {
|
||||
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -304,6 +305,21 @@ func TestGetDomainMetadataIncludesAuthorizableShortcutDomains(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalShortcutScopesParticipateInAuthDomainResolution(t *testing.T) {
|
||||
registered := []common.Shortcut{{
|
||||
Service: "im", Command: "+business-auth", AuthTypes: []string{"user"},
|
||||
UserScopes: []string{"im:business.scope:read"},
|
||||
}}
|
||||
domains := allKnownDomainsWithShortcuts("", registered)
|
||||
if !domains["im"] {
|
||||
t.Fatal("external shortcut domain is missing from auth domains")
|
||||
}
|
||||
scopes := collectScopesForDomainsWithShortcuts([]string{"im"}, "user", "", registered)
|
||||
if !slices.Contains(scopes, "im:business.scope:read") {
|
||||
t.Fatalf("external shortcut scope is missing: %v", scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomainMetadataMatchesAllKnownDomains(t *testing.T) {
|
||||
metadata := getDomainMetadata("zh")
|
||||
known := allKnownDomains("")
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
@@ -47,7 +48,7 @@ func businessCommand(name string, executed *bool) command.Command {
|
||||
}
|
||||
|
||||
func TestWithCommandSetsInIsolatedProcesses(t *testing.T) {
|
||||
for _, scenario := range []string{"official", "mount", "atomic", "governance"} {
|
||||
for _, scenario := range []string{"official", "mount", "atomic", "governance", "surface"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
process := exec.Command(os.Args[0], "-test.run=^TestCommandSetSubprocess$", "-test.v")
|
||||
process.Env = append(os.Environ(), "LARK_CLI_COMMAND_SET_SCENARIO="+scenario)
|
||||
@@ -134,6 +135,42 @@ func TestCommandSetSubprocess(t *testing.T) {
|
||||
if executed {
|
||||
t.Fatal("governance denial reached business Execute")
|
||||
}
|
||||
case "surface":
|
||||
var stdout, stderr bytes.Buffer
|
||||
root := Build(context.Background(), buildInvocationForTest(t),
|
||||
WithIO(strings.NewReader(""), &stdout, &stderr),
|
||||
WithCommandSets(command.Set{
|
||||
Domain: command.ExtendDomain(command.DomainIm),
|
||||
Commands: []command.Command{businessCommand("+business-surface", nil)},
|
||||
}),
|
||||
WithoutPlugins(), WithoutStrictMode(), WithoutServiceCommands(),
|
||||
)
|
||||
root.SetArgs([]string{"__complete", "im", "+"})
|
||||
if _, err := root.ExecuteC(); err != nil {
|
||||
t.Fatalf("complete external command: %v\nstderr: %s", err, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "+business-surface") {
|
||||
t.Fatalf("external command is missing from shell completion: %s", stdout.String())
|
||||
}
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
root.SetArgs([]string{"__complete", "schema", "im", "+business-"})
|
||||
if _, err := root.ExecuteC(); err != nil {
|
||||
t.Fatalf("complete external schema: %v\nstderr: %s", err, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "+business-surface") {
|
||||
t.Fatalf("external schema is missing from shell completion: %s", stdout.String())
|
||||
}
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
root.SetArgs([]string{"schema", "im", "+business-surface"})
|
||||
if _, err := root.ExecuteC(); err != nil {
|
||||
t.Fatalf("schema external command: %v\nstderr: %s", err, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"name": "im +business-surface"`) ||
|
||||
!strings.Contains(stdout.String(), `"inputSchema"`) || !strings.Contains(stdout.String(), `"outputSchema"`) {
|
||||
t.Fatalf("external schema = %s", stdout.String())
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unknown scenario %q", scenario)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -17,6 +18,8 @@ import (
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/schema"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -100,6 +103,7 @@ func completeSchemaPath(
|
||||
mode := f.ResolveStrictMode(cmd.Context())
|
||||
catalog := projectSchemaCatalog(registry.SchemaCatalog(), visibility)
|
||||
completions, noSpace := catalog.Complete(args, toComplete, registry.FilterForStrictMode(mode))
|
||||
completions = mergeSchemaCompletions(completions, shortcutSchemaCompletions(args, toComplete, visibility))
|
||||
directive := cobra.ShellCompDirectiveNoFileComp
|
||||
if noSpace {
|
||||
directive |= cobra.ShellCompDirectiveNoSpace
|
||||
@@ -135,6 +139,10 @@ func runSchemaCatalog(
|
||||
catalog apicatalog.Catalog,
|
||||
visibility CommandVisibility,
|
||||
) error {
|
||||
if contract, ok := resolveShortcutSchema(parts, visibility); ok {
|
||||
output.PrintJson(out, contract)
|
||||
return nil
|
||||
}
|
||||
// Test the source catalog before presentation projection. A distribution
|
||||
// that intentionally conceals every generated method still has metadata;
|
||||
// bare `schema` should render an empty list rather than claim metadata is
|
||||
@@ -164,6 +172,86 @@ func runSchemaCatalog(
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveShortcutSchema(parts []string, visibility CommandVisibility) (any, bool) {
|
||||
if len(parts) != 2 || !strings.HasPrefix(parts[1], "+") {
|
||||
return nil, false
|
||||
}
|
||||
for _, shortcut := range shortcuts.AllShortcuts() {
|
||||
if shortcut.Service != parts[0] || shortcut.Command != parts[1] {
|
||||
continue
|
||||
}
|
||||
if visibility != nil && !visibility([]string{shortcut.Service, shortcut.Command}) {
|
||||
return nil, false
|
||||
}
|
||||
return common.ShortcutSchema(shortcut)
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func shortcutSchemaCompletions(args []string, toComplete string, visibility CommandVisibility) []string {
|
||||
registered := shortcuts.AllShortcuts()
|
||||
if len(args) == 0 && strings.Contains(toComplete, ".") {
|
||||
parts := strings.SplitN(toComplete, ".", 2)
|
||||
return shortcutCommandCompletions(registered, parts[0], parts[1], parts[0]+".", visibility)
|
||||
}
|
||||
if len(args) == 0 {
|
||||
services := make(map[string]struct{})
|
||||
for _, shortcut := range registered {
|
||||
if !strings.HasPrefix(shortcut.Service, toComplete) || !shortcutSchemaVisible(shortcut, visibility) {
|
||||
continue
|
||||
}
|
||||
if _, ok := common.ShortcutSchema(shortcut); ok {
|
||||
services[shortcut.Service] = struct{}{}
|
||||
}
|
||||
}
|
||||
result := make([]string, 0, len(services))
|
||||
for service := range services {
|
||||
result = append(result, service)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
if len(args) == 1 {
|
||||
return shortcutCommandCompletions(registered, args[0], toComplete, "", visibility)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func shortcutCommandCompletions(registered []common.Shortcut, service, prefix, outputPrefix string, visibility CommandVisibility) []string {
|
||||
var result []string
|
||||
for _, shortcut := range registered {
|
||||
if shortcut.Service != service || !strings.HasPrefix(shortcut.Command, prefix) || !shortcutSchemaVisible(shortcut, visibility) {
|
||||
continue
|
||||
}
|
||||
if _, ok := common.ShortcutSchema(shortcut); ok {
|
||||
result = append(result, outputPrefix+shortcut.Command+"\t"+shortcut.Description)
|
||||
}
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func shortcutSchemaVisible(shortcut common.Shortcut, visibility CommandVisibility) bool {
|
||||
return visibility == nil || visibility([]string{shortcut.Service, shortcut.Command})
|
||||
}
|
||||
|
||||
func mergeSchemaCompletions(groups ...[]string) []string {
|
||||
seen := make(map[string]struct{})
|
||||
var result []string
|
||||
for _, group := range groups {
|
||||
for _, candidate := range group {
|
||||
name := strings.SplitN(candidate, "\t", 2)[0]
|
||||
if _, ok := seen[name]; ok {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
result = append(result, candidate)
|
||||
}
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
// projectSchemaCatalog produces the metadata view corresponding to one final
|
||||
// command surface. It lives in cmd/schema so apicatalog remains a policy-free
|
||||
// navigation module. Resolve, broad listings, and Complete all consume the
|
||||
|
||||
+6
-27
@@ -4,37 +4,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
|
||||
defaultaffordance "github.com/larksuite/cli/affordance"
|
||||
"github.com/larksuite/cli/cmd"
|
||||
defaultskills "github.com/larksuite/cli/skills"
|
||||
)
|
||||
|
||||
// embeddedContentFS bundles the agent-readable content that must ship in lockstep
|
||||
// with the binary: each skill's docs (SKILL.md + references/, plus whiteboard's
|
||||
// routes/ and scenes/) and the per-domain affordance guidance (affordance/*.md).
|
||||
// Machine-resource skill dirs (assets/, scripts/) are excluded. It's a whitelist —
|
||||
// a new content type is omitted until added to the embed list. The embed must live
|
||||
// in this root package because go:embed cannot reach up out of a package's dir.
|
||||
//
|
||||
//go:embed skills/*/SKILL.md skills/*/references skills/*/routes skills/*/scenes affordance/*.md
|
||||
var embeddedContentFS embed.FS
|
||||
|
||||
// init wires the embedded content into the CLI. It compiles into `go build .` but
|
||||
// not the single-file preview build (`go build ./main.go`), so that build stays
|
||||
// self-contained (shipping no embedded content). Assembly failures warn on stderr
|
||||
// rather than panicking — embedded content is nice-to-have, not load-bearing.
|
||||
// self-contained (shipping no embedded content). External wrapper distributions
|
||||
// can import the same default files from the skills and affordance packages.
|
||||
func init() {
|
||||
if sub, err := fs.Sub(embeddedContentFS, "skills"); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "warning: skills embed assembly failed, skills commands disabled:", err)
|
||||
} else {
|
||||
cmd.SetEmbeddedSkillContent(sub)
|
||||
}
|
||||
if sub, err := fs.Sub(embeddedContentFS, "affordance"); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "warning: affordance embed assembly failed, command guidance disabled:", err)
|
||||
} else {
|
||||
cmd.SetEmbeddedAffordanceContent(sub)
|
||||
}
|
||||
cmd.SetEmbeddedSkillContent(defaultskills.DefaultFS())
|
||||
cmd.SetEmbeddedAffordanceContent(defaultaffordance.DefaultFS())
|
||||
}
|
||||
|
||||
@@ -118,14 +118,12 @@ func TestCollectPagesUsesHostPolicyAndMetadata(t *testing.T) {
|
||||
var calls []RequestView
|
||||
ctx := NewCommandContext(ContextOptions{
|
||||
Identity: IdentityUser,
|
||||
CallJSON: func(_ context.Context, request Request) (map[string]any, error) {
|
||||
calls = append(calls, InspectRequest(request))
|
||||
response := responses[0]
|
||||
responses = responses[1:]
|
||||
return response, nil
|
||||
},
|
||||
PaginationOptions: func() (PaginationOptions, error) {
|
||||
return PaginationOptions{All: true, MaxPages: 10}, nil
|
||||
CollectPages: func(_ context.Context, request Request, all bool) ([]map[string]any, HostPagination, error) {
|
||||
if all {
|
||||
t.Fatal("CollectPages forced full pagination")
|
||||
}
|
||||
calls = append(calls, InspectRequest(request), InspectRequest(request.Set("page_token", "next")))
|
||||
return responses, HostPagination{Complete: true, Pages: 2}, nil
|
||||
},
|
||||
})
|
||||
page, err := CollectPages[contractData](context.Background(), ctx, GET("/open-apis/im/v1/chats"))
|
||||
@@ -145,6 +143,18 @@ func TestCollectPagesUsesHostPolicyAndMetadata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageResultCountsFilteredItems(t *testing.T) {
|
||||
page := Page[contractData]{
|
||||
Items: []contractData{{ID: "one"}, {ID: "two"}},
|
||||
meta: &paginationMeta{Complete: true, Pages: 1, Items: 2},
|
||||
}
|
||||
page.Items = page.Items[:1]
|
||||
result := hostResult(Success(page))
|
||||
if result.Pagination == nil || result.Pagination.Items != 1 {
|
||||
t.Fatalf("filtered pagination = %#v", result.Pagination)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunPreventsRequestsAndScopeChecks(t *testing.T) {
|
||||
calls := 0
|
||||
ctx := NewCommandContext(ContextOptions{
|
||||
|
||||
@@ -6,7 +6,9 @@ package commandtest_test
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/command"
|
||||
@@ -39,7 +41,10 @@ func documentGetDefinition() command.Definition[documentGetArgs, documentData] {
|
||||
},
|
||||
Execute: func(ctx context.Context, commandContext command.CommandContext, args *documentGetArgs) (command.Result[documentData], error) {
|
||||
data, err := command.CallJSON[documentData](ctx, commandContext, request(args))
|
||||
return command.Success(data), err
|
||||
if err != nil {
|
||||
return command.Result[documentData]{}, err
|
||||
}
|
||||
return command.Success(data), nil
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -71,7 +76,10 @@ func chatListDefinition() command.Definition[chatListArgs, command.Page[chatData
|
||||
},
|
||||
Execute: func(ctx context.Context, commandContext command.CommandContext, args *chatListArgs) (command.Result[command.Page[chatData]], error) {
|
||||
page, err := command.CollectPages[chatData](ctx, commandContext, request(args))
|
||||
return command.Success(page), err
|
||||
if err != nil {
|
||||
return command.Result[command.Page[chatData]]{}, err
|
||||
}
|
||||
return command.Success(page), nil
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -125,7 +133,7 @@ func taskAuditDefinition() command.Definition[taskAuditArgs, taskAuditData] {
|
||||
Execute: func(ctx context.Context, commandContext command.CommandContext, args *taskAuditArgs) (command.Result[taskAuditData], error) {
|
||||
tasks, err := command.CollectAllPages[taskRecord](ctx, commandContext, listRequest)
|
||||
if err != nil {
|
||||
return command.Success(taskAuditData{}), err
|
||||
return command.Result[taskAuditData]{}, err
|
||||
}
|
||||
data := taskAuditData{Items: make([]taskAuditItem, 0, len(tasks))}
|
||||
if !args.IncludeOwners {
|
||||
@@ -135,7 +143,7 @@ func taskAuditDefinition() command.Definition[taskAuditArgs, taskAuditData] {
|
||||
return command.Success(data), nil
|
||||
}
|
||||
if err := command.PreflightScopes(commandContext, "contact:user.base:readonly"); err != nil {
|
||||
return command.Success(data), err
|
||||
return command.Result[taskAuditData]{}, err
|
||||
}
|
||||
for _, task := range tasks {
|
||||
owner, ownerErr := command.CallJSON[struct {
|
||||
@@ -191,17 +199,23 @@ func memberListDefinition() command.Definition[memberListArgs, memberListData] {
|
||||
},
|
||||
Execute: func(ctx context.Context, commandContext command.CommandContext, args *memberListArgs) (command.Result[memberListData], error) {
|
||||
data, err := command.CallJSON[memberListData](ctx, commandContext, command.GET("/open-apis/im/v1/chats/"+args.ChatID))
|
||||
if err != nil || !args.IncludeMembers {
|
||||
return command.Success(data), err
|
||||
if err != nil {
|
||||
return command.Result[memberListData]{}, err
|
||||
}
|
||||
if !args.IncludeMembers {
|
||||
return command.Success(data), nil
|
||||
}
|
||||
if err := command.PreflightScopes(commandContext, "im:chat.members:read"); err != nil {
|
||||
return command.Success(data), err
|
||||
return command.Result[memberListData]{}, err
|
||||
}
|
||||
members, err := command.CallJSON[struct {
|
||||
Items []string `json:"items"`
|
||||
}](ctx, commandContext, command.GET("/open-apis/im/v1/chats/"+args.ChatID+"/members"))
|
||||
if err != nil {
|
||||
return command.Result[memberListData]{}, err
|
||||
}
|
||||
data.Members = members.Items
|
||||
return command.Success(data), err
|
||||
return command.Success(data), nil
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -241,6 +255,16 @@ func TestSingleReadAndDryRunUseSameRequest(t *testing.T) {
|
||||
recorder.AssertScriptConsumed()
|
||||
}
|
||||
|
||||
func TestSingleReadPreservesTypedAPIError(t *testing.T) {
|
||||
want := command.InvalidResponseErrorf("upstream response is malformed")
|
||||
recorder := commandtest.New(t, commandtest.Fail(want))
|
||||
_, err := commandtest.Execute(context.Background(), recorder, command.IdentityUser, documentGetDefinition(), &documentGetArgs{DocumentID: "doc_1"})
|
||||
if !errors.Is(err, want) {
|
||||
t.Fatalf("single read error = %v", err)
|
||||
}
|
||||
recorder.AssertScriptConsumed()
|
||||
}
|
||||
|
||||
func TestListCommandUsesHostPagination(t *testing.T) {
|
||||
recorder := commandtest.New(t,
|
||||
commandtest.Respond(map[string]any{
|
||||
@@ -250,8 +274,8 @@ func TestListCommandUsesHostPagination(t *testing.T) {
|
||||
"items": []map[string]any{{"chat_id": "chat_2", "name": "two"}}, "has_more": false,
|
||||
}),
|
||||
)
|
||||
recorder.SetPagination(command.PaginationOptions{All: true, MaxPages: 3})
|
||||
execution, err := commandtest.Execute(context.Background(), recorder, command.IdentityUser, chatListDefinition(), &chatListArgs{PageSize: 20})
|
||||
execution, err := commandtest.RunWithFlags(context.Background(), recorder, command.IdentityUser,
|
||||
chatListDefinition(), &chatListArgs{PageSize: 20}, "--page-all", "--page-limit=3", "--page-delay=0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -265,6 +289,105 @@ func TestListCommandUsesHostPagination(t *testing.T) {
|
||||
recorder.AssertScriptConsumed()
|
||||
}
|
||||
|
||||
func TestListCommandReadsOnePageByDefault(t *testing.T) {
|
||||
recorder := commandtest.New(t, commandtest.Respond(map[string]any{
|
||||
"items": []map[string]any{{"chat_id": "chat_1", "name": "one"}}, "has_more": true, "page_token": "next",
|
||||
}))
|
||||
execution, err := commandtest.Execute(context.Background(), recorder, command.IdentityUser, chatListDefinition(), &chatListArgs{PageSize: 20})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if execution.Data.Complete() || execution.Data.Pages() != 1 || execution.Data.NextToken() != "next" {
|
||||
t.Fatalf("default page complete=%v pages=%d next=%q", execution.Data.Complete(), execution.Data.Pages(), execution.Data.NextToken())
|
||||
}
|
||||
if len(recorder.Requests()) != 1 {
|
||||
t.Fatalf("default requests = %#v", recorder.Requests())
|
||||
}
|
||||
recorder.AssertScriptConsumed()
|
||||
}
|
||||
|
||||
func TestListCommandResumesAndStopsAtPageLimit(t *testing.T) {
|
||||
recorder := commandtest.New(t,
|
||||
commandtest.Respond(map[string]any{"items": []map[string]any{{"chat_id": "chat_1"}}, "has_more": true, "page_token": "next-1"}),
|
||||
commandtest.Respond(map[string]any{"items": []map[string]any{{"chat_id": "chat_2"}}, "has_more": true, "page_token": "next-2"}),
|
||||
)
|
||||
recorder.SetPagination(command.PaginationOptions{All: true, MaxPages: 2})
|
||||
page, err := command.CollectPages[chatData](context.Background(), recorder.CommandContext(command.IdentityUser),
|
||||
command.GET("/open-apis/im/v1/chats").Set("page_token", "resume"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.Complete() || page.Pages() != 2 || page.NextToken() != "next-2" || len(page.Items) != 2 {
|
||||
t.Fatalf("limited page complete=%v pages=%d next=%q items=%d", page.Complete(), page.Pages(), page.NextToken(), len(page.Items))
|
||||
}
|
||||
requests := recorder.Requests()
|
||||
if len(requests) != 2 || requests[0].Query["page_token"] != "resume" || requests[1].Query["page_token"] != "next-1" {
|
||||
t.Fatalf("resume requests = %#v", requests)
|
||||
}
|
||||
recorder.AssertScriptConsumed()
|
||||
}
|
||||
|
||||
func TestCollectAllPagesRejectsInvalidCursors(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
responses []commandtest.Response
|
||||
}{
|
||||
{name: "missing", responses: []commandtest.Response{
|
||||
commandtest.Respond(map[string]any{"items": []map[string]any{}, "has_more": true}),
|
||||
}},
|
||||
{name: "repeated", responses: []commandtest.Response{
|
||||
commandtest.Respond(map[string]any{"items": []map[string]any{}, "has_more": true, "page_token": "same"}),
|
||||
commandtest.Respond(map[string]any{"items": []map[string]any{}, "has_more": true, "page_token": "same"}),
|
||||
}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
recorder := commandtest.New(t, test.responses...)
|
||||
_, err := command.CollectAllPages[chatData](context.Background(), recorder.CommandContext(command.IdentityUser), command.GET("/open-apis/im/v1/chats"))
|
||||
if err == nil {
|
||||
t.Fatal("CollectAllPages() error is nil")
|
||||
}
|
||||
recorder.AssertScriptConsumed()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectAllPagesHardLimitPreventsFollowingWrite(t *testing.T) {
|
||||
responses := make([]commandtest.Response, 1000)
|
||||
for index := range responses {
|
||||
responses[index] = commandtest.Respond(map[string]any{
|
||||
"items": []map[string]any{}, "has_more": true, "page_token": fmt.Sprintf("page-%d", index+1),
|
||||
})
|
||||
}
|
||||
type args struct{}
|
||||
type data struct{}
|
||||
definition := command.Definition[args, data]{
|
||||
Metadata: command.CommandMetadata{
|
||||
Service: "task", Command: "+business-hard-limit", Description: "Test complete read", Risk: command.RiskWrite,
|
||||
Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{command.IdentityUser: {}}},
|
||||
},
|
||||
Hooks: command.Hooks[args, data]{
|
||||
Execute: func(ctx context.Context, commandContext command.CommandContext, _ *args) (command.Result[data], error) {
|
||||
if _, err := command.CollectAllPages[taskRecord](ctx, commandContext, command.GET("/open-apis/task/v2/tasks")); err != nil {
|
||||
return command.Result[data]{}, err
|
||||
}
|
||||
if _, err := command.CallJSON[map[string]any](ctx, commandContext, command.POST("/open-apis/task/v2/tasks")); err != nil {
|
||||
return command.Result[data]{}, err
|
||||
}
|
||||
return command.Success(data{}), nil
|
||||
},
|
||||
},
|
||||
}
|
||||
recorder := commandtest.New(t, responses...)
|
||||
_, err := commandtest.Execute(context.Background(), recorder, command.IdentityUser, definition, &args{})
|
||||
if err == nil || !strings.Contains(err.Error(), "hard limit") {
|
||||
t.Fatalf("hard-limit error = %v", err)
|
||||
}
|
||||
if requests := recorder.Requests(); len(requests) != 1000 || requests[len(requests)-1].Method != "GET" {
|
||||
t.Fatalf("requests after incomplete read = %d, last=%#v", len(requests), requests[len(requests)-1])
|
||||
}
|
||||
recorder.AssertScriptConsumed()
|
||||
}
|
||||
|
||||
func TestMultiCallCommandReturnsPartialData(t *testing.T) {
|
||||
wantFailure := command.InvalidResponseErrorf("owner record is unavailable")
|
||||
recorder := commandtest.New(t,
|
||||
|
||||
@@ -10,17 +10,23 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/extension/command"
|
||||
internalpagination "github.com/larksuite/cli/internal/pagination"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// Response is one scripted OpenAPI response.
|
||||
type Response struct {
|
||||
data any
|
||||
err error
|
||||
data any
|
||||
err error
|
||||
expectedMethod string
|
||||
expectedPath string
|
||||
}
|
||||
|
||||
// Respond creates a successful scripted response containing an OpenAPI data object.
|
||||
@@ -53,6 +59,15 @@ func New(testing testing.TB, responses ...Response) *Recorder {
|
||||
}
|
||||
}
|
||||
|
||||
// ReplyJSON appends an ordered successful response with an expected request method and path.
|
||||
func (r *Recorder) ReplyJSON(method, path string, data any) *Recorder {
|
||||
r.testing.Helper()
|
||||
r.mu.Lock()
|
||||
r.responses = append(r.responses, Response{data: data, expectedMethod: method, expectedPath: path})
|
||||
r.mu.Unlock()
|
||||
return r
|
||||
}
|
||||
|
||||
// CommandContext returns a restricted public command context.
|
||||
func (r *Recorder) CommandContext(identity command.Identity) command.CommandContext {
|
||||
return r.commandContext(identity, false)
|
||||
@@ -65,11 +80,11 @@ func (r *Recorder) DryRunContext(identity command.Identity) command.CommandConte
|
||||
|
||||
func (r *Recorder) commandContext(identity command.Identity, dryRun bool) command.CommandContext {
|
||||
return command.NewCommandContext(command.ContextOptions{
|
||||
Identity: identity,
|
||||
DryRun: dryRun,
|
||||
CallJSON: r.callJSON,
|
||||
PreflightScopes: r.preflightScopes,
|
||||
PaginationOptions: r.paginationOptions,
|
||||
Identity: identity,
|
||||
DryRun: dryRun,
|
||||
CallJSON: r.callJSON,
|
||||
PreflightScopes: r.preflightScopes,
|
||||
CollectPages: r.collectPages,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -99,6 +114,9 @@ func Execute[Args any, Data any](ctx context.Context, recorder *Recorder, identi
|
||||
}
|
||||
result, err := declaration.Hooks.Execute(ctx, commandContext, args)
|
||||
if err != nil {
|
||||
if result.Outcome != "" || result.Pagination != nil {
|
||||
return execution, command.InternalErrorf("business Execute returned both Result and error").WithCause(err)
|
||||
}
|
||||
return execution, err
|
||||
}
|
||||
data, ok := result.Data.(Data)
|
||||
@@ -108,6 +126,39 @@ func Execute[Args any, Data any](ctx context.Context, recorder *Recorder, identi
|
||||
return Execution[Data]{Data: data, Partial: result.Outcome == "partial"}, nil
|
||||
}
|
||||
|
||||
// RunWithFlags executes a page-returning command with the framework's standard pagination flags.
|
||||
func RunWithFlags[Args any, Data any](ctx context.Context, recorder *Recorder, identity command.Identity, definition command.Definition[Args, Data], args *Args, flags ...string) (Execution[Data], error) {
|
||||
if !command.InspectCommand(command.Define(definition)).PageOutput {
|
||||
return Execution[Data]{}, command.ValidationErrorf("framework pagination flags require a Page output")
|
||||
}
|
||||
options, err := parsePaginationFlags(flags)
|
||||
if err != nil {
|
||||
return Execution[Data]{}, err
|
||||
}
|
||||
restore := recorder.replacePagination(options)
|
||||
defer restore()
|
||||
return Execute(ctx, recorder, identity, definition, args)
|
||||
}
|
||||
|
||||
func parsePaginationFlags(arguments []string) (command.PaginationOptions, error) {
|
||||
flags := pflag.NewFlagSet("commandtest pagination", pflag.ContinueOnError)
|
||||
flags.SetOutput(io.Discard)
|
||||
pageAll := flags.Bool("page-all", false, "")
|
||||
pageLimit := flags.Int("page-limit", 10, "")
|
||||
pageDelay := flags.Int("page-delay", 200, "")
|
||||
if err := flags.Parse(arguments); err != nil {
|
||||
return command.PaginationOptions{}, command.ValidationErrorf("parse framework pagination flags: %v", err).WithCause(err)
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return command.PaginationOptions{}, command.ValidationErrorf("unexpected framework pagination argument %q", flags.Arg(0))
|
||||
}
|
||||
return command.PaginationOptions{
|
||||
All: *pageAll,
|
||||
MaxPages: *pageLimit,
|
||||
Delay: time.Duration(*pageDelay) * time.Millisecond,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Preview runs Normalize, Validate, and DryRun with an offline test context.
|
||||
func Preview[Args any, Data any](ctx context.Context, recorder *Recorder, identity command.Identity, definition command.Definition[Args, Data], args *Args) (*command.DryRun, error) {
|
||||
declaration := command.InspectCommand(command.Define(definition))
|
||||
@@ -122,9 +173,12 @@ func Preview[Args any, Data any](ctx context.Context, recorder *Recorder, identi
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if declaration.Hooks.DryRun == nil {
|
||||
if declaration.Hooks.DryRun == nil && declaration.Hooks.DryRunE == nil {
|
||||
return nil, errors.New("business command has no DryRun hook")
|
||||
}
|
||||
if declaration.Hooks.DryRunE != nil {
|
||||
return declaration.Hooks.DryRunE(ctx, commandContext, args)
|
||||
}
|
||||
return declaration.Hooks.DryRun(ctx, commandContext, args), nil
|
||||
}
|
||||
|
||||
@@ -159,6 +213,18 @@ func (r *Recorder) SetPagination(options command.PaginationOptions) {
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *Recorder) replacePagination(options command.PaginationOptions) func() {
|
||||
r.mu.Lock()
|
||||
previous := r.pagination
|
||||
r.pagination = options
|
||||
r.mu.Unlock()
|
||||
return func() {
|
||||
r.mu.Lock()
|
||||
r.pagination = previous
|
||||
r.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// SetScopeError makes every subsequent scope preflight return err after recording it.
|
||||
func (r *Recorder) SetScopeError(err error) {
|
||||
r.mu.Lock()
|
||||
@@ -238,6 +304,12 @@ func (r *Recorder) callJSON(ctx context.Context, request command.Request) (map[s
|
||||
cancel := r.cancel
|
||||
shouldCancel := r.cancelAfterRequest == requestNumber
|
||||
r.mu.Unlock()
|
||||
if response.expectedMethod != "" && response.expectedMethod != view.Method {
|
||||
return nil, fmt.Errorf("request %d method = %q, expected %q", requestNumber, view.Method, response.expectedMethod)
|
||||
}
|
||||
if response.expectedPath != "" && response.expectedPath != view.Path {
|
||||
return nil, fmt.Errorf("request %d path = %q, expected %q", requestNumber, view.Path, response.expectedPath)
|
||||
}
|
||||
|
||||
if response.err != nil {
|
||||
return nil, response.err
|
||||
@@ -252,6 +324,79 @@ func (r *Recorder) callJSON(ctx context.Context, request command.Request) (map[s
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (r *Recorder) collectPages(ctx context.Context, request command.Request, all bool) ([]map[string]any, command.HostPagination, error) {
|
||||
r.mu.Lock()
|
||||
options := r.pagination
|
||||
r.mu.Unlock()
|
||||
if all {
|
||||
options = command.PaginationOptions{All: true, MaxPages: 1000}
|
||||
} else if !options.All {
|
||||
options.MaxPages = 1
|
||||
}
|
||||
if options.MaxPages < 1 || options.MaxPages > 1000 {
|
||||
return nil, command.HostPagination{}, command.ValidationErrorf("pagination page limit must be between 1 and 1000")
|
||||
}
|
||||
if options.Delay < 0 || options.Delay > time.Minute {
|
||||
return nil, command.HostPagination{}, command.ValidationErrorf("pagination delay must be between 0 and 60000 milliseconds")
|
||||
}
|
||||
|
||||
var pages []map[string]any
|
||||
state, err := internalpagination.Walk(ctx, internalpagination.Options{
|
||||
InitialToken: requestPageToken(command.InspectRequest(request).Query),
|
||||
MaxPages: options.MaxPages,
|
||||
Delay: options.Delay,
|
||||
Fetch: func(ctx context.Context, _ int, token string) (bool, string, error) {
|
||||
pageRequest := request
|
||||
if token != "" {
|
||||
pageRequest = pageRequest.Set("page_token", token)
|
||||
}
|
||||
data, err := r.callJSON(ctx, pageRequest)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
pages = append(pages, data)
|
||||
hasMore, _ := data["has_more"].(bool)
|
||||
nextToken, _ := data["page_token"].(string)
|
||||
if nextToken == "" {
|
||||
nextToken, _ = data["next_page_token"].(string)
|
||||
}
|
||||
return hasMore, nextToken, nil
|
||||
},
|
||||
})
|
||||
pagination := command.HostPagination{Complete: state.Complete, Pages: state.Pages, NextToken: state.NextToken}
|
||||
if err == nil {
|
||||
return pages, pagination, nil
|
||||
}
|
||||
var cursorErr *internalpagination.CursorError
|
||||
if errors.As(err, &cursorErr) {
|
||||
if cursorErr.Kind == internalpagination.CursorMissing {
|
||||
return pages, pagination, command.InvalidResponseErrorf("pagination page %d reports has_more=true without a page token", cursorErr.Page)
|
||||
}
|
||||
return pages, pagination, command.InvalidResponseErrorf("pagination page %d repeated page token %q", cursorErr.Page, cursorErr.Token)
|
||||
}
|
||||
var waitErr *internalpagination.WaitError
|
||||
if errors.As(err, &waitErr) {
|
||||
return pages, pagination, command.PaginationInterruptedError(waitErr.Err)
|
||||
}
|
||||
return pages, pagination, err
|
||||
}
|
||||
|
||||
func requestPageToken(query map[string]any) string {
|
||||
switch value := query["page_token"].(type) {
|
||||
case string:
|
||||
return value
|
||||
case []string:
|
||||
if len(value) > 0 {
|
||||
return value[0]
|
||||
}
|
||||
case []any:
|
||||
if len(value) > 0 {
|
||||
return fmt.Sprint(value[0])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (r *Recorder) preflightScopes(scopes ...string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
@@ -259,12 +404,6 @@ func (r *Recorder) preflightScopes(scopes ...string) error {
|
||||
return r.scopeError
|
||||
}
|
||||
|
||||
func (r *Recorder) paginationOptions() (command.PaginationOptions, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.pagination, nil
|
||||
}
|
||||
|
||||
func responseDataObject(value any) (map[string]any, error) {
|
||||
if value == nil {
|
||||
return map[string]any{}, nil
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -60,13 +61,39 @@ func TestRecorderReturnsScriptedFailuresInOrder(t *testing.T) {
|
||||
recorder.AssertScriptConsumed()
|
||||
}
|
||||
|
||||
func TestRecorderReplyJSONChecksRequestInOrder(t *testing.T) {
|
||||
recorder := New(t).
|
||||
ReplyJSON("GET", "/open-apis/im/v1/chats/first", map[string]any{"id": "first"}).
|
||||
ReplyJSON("GET", "/open-apis/im/v1/chats/second", map[string]any{"id": "second"})
|
||||
commandContext := recorder.CommandContext(command.IdentityUser)
|
||||
for _, id := range []string{"first", "second"} {
|
||||
data, err := command.CallJSON[map[string]any](context.Background(), commandContext, command.GET("/open-apis/im/v1/chats/"+id))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if data["id"] != id {
|
||||
t.Fatalf("response = %#v", data)
|
||||
}
|
||||
}
|
||||
recorder.AssertScriptConsumed()
|
||||
}
|
||||
|
||||
func TestRecorderReplyJSONRejectsUnexpectedRequest(t *testing.T) {
|
||||
recorder := New(t).ReplyJSON("POST", "/open-apis/im/v1/chats", map[string]any{})
|
||||
_, err := command.CallJSON[map[string]any](context.Background(), recorder.CommandContext(command.IdentityUser), command.GET("/open-apis/im/v1/chats"))
|
||||
if err == nil || !strings.Contains(err.Error(), "expected") {
|
||||
t.Fatalf("CallJSON() error = %v", err)
|
||||
}
|
||||
recorder.AssertScriptConsumed()
|
||||
}
|
||||
|
||||
func TestRecorderInjectsCancellationIntoPaginationWait(t *testing.T) {
|
||||
recorder := New(t, Respond(map[string]any{
|
||||
"items": []map[string]any{{"id": "first"}},
|
||||
"has_more": true,
|
||||
"page_token": "next",
|
||||
}))
|
||||
recorder.SetPagination(command.PaginationOptions{All: true, MaxPages: 2, Delay: time.Hour})
|
||||
recorder.SetPagination(command.PaginationOptions{All: true, MaxPages: 2, Delay: time.Minute})
|
||||
recorder.CancelAfterRequest(1)
|
||||
ctx := recorder.ExecutionContext(context.Background())
|
||||
|
||||
@@ -110,3 +137,68 @@ func TestExecuteRunsPreparationAndReturnsTypedOutcome(t *testing.T) {
|
||||
t.Fatalf("execution = %#v", execution)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteRejectsResultAndErrorTogether(t *testing.T) {
|
||||
type args struct{}
|
||||
type data struct{}
|
||||
sentinel := errors.New("execute failed")
|
||||
definition := command.Definition[args, data]{
|
||||
Metadata: command.CommandMetadata{
|
||||
Service: "im", Command: "+test-result-error", Description: "Test result protocol", Risk: command.RiskRead,
|
||||
Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{command.IdentityUser: {}}},
|
||||
},
|
||||
Hooks: command.Hooks[args, data]{
|
||||
Execute: func(context.Context, command.CommandContext, *args) (command.Result[data], error) {
|
||||
return command.Success(data{}), sentinel
|
||||
},
|
||||
},
|
||||
}
|
||||
_, err := Execute(context.Background(), New(t), command.IdentityUser, definition, &args{})
|
||||
if err == nil || !errors.Is(err, sentinel) || err == sentinel {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewPropagatesDryRunE(t *testing.T) {
|
||||
type args struct{}
|
||||
type data struct{}
|
||||
sentinel := command.ValidationErrorf("dry-run input is invalid")
|
||||
definition := command.Definition[args, data]{
|
||||
Metadata: command.CommandMetadata{
|
||||
Service: "im", Command: "+test-dry-run-error", Description: "Test dry-run error", Risk: command.RiskRead,
|
||||
Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{command.IdentityUser: {}}},
|
||||
},
|
||||
Hooks: command.Hooks[args, data]{
|
||||
DryRunE: func(context.Context, command.CommandContext, *args) (*command.DryRun, error) {
|
||||
return nil, sentinel
|
||||
},
|
||||
Execute: func(context.Context, command.CommandContext, *args) (command.Result[data], error) {
|
||||
return command.Success(data{}), nil
|
||||
},
|
||||
},
|
||||
}
|
||||
_, err := Preview(context.Background(), New(t), command.IdentityUser, definition, &args{})
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("Preview() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWithFlagsRejectsNonPageOutput(t *testing.T) {
|
||||
type args struct{}
|
||||
type data struct{}
|
||||
definition := command.Definition[args, data]{
|
||||
Metadata: command.CommandMetadata{
|
||||
Service: "im", Command: "+test-non-page", Description: "Test non-page flags", Risk: command.RiskRead,
|
||||
Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{command.IdentityUser: {}}},
|
||||
},
|
||||
Hooks: command.Hooks[args, data]{
|
||||
Execute: func(context.Context, command.CommandContext, *args) (command.Result[data], error) {
|
||||
return command.Success(data{}), nil
|
||||
},
|
||||
},
|
||||
}
|
||||
_, err := RunWithFlags(context.Background(), New(t), command.IdentityUser, definition, &args{}, "--page-all")
|
||||
if err == nil {
|
||||
t.Fatal("RunWithFlags() error is nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,11 @@ import (
|
||||
|
||||
// CommandContext is an opaque, invocation-scoped set of safe host capabilities.
|
||||
type CommandContext struct {
|
||||
identity Identity
|
||||
dryRun bool
|
||||
callJSON func(context.Context, Request) (map[string]any, error)
|
||||
preflightScopes func(...string) error
|
||||
paginationOptions func() (PaginationOptions, error)
|
||||
identity Identity
|
||||
dryRun bool
|
||||
callJSON func(context.Context, Request) (map[string]any, error)
|
||||
preflightScopes func(...string) error
|
||||
collectPages func(context.Context, Request, bool) ([]map[string]any, HostPagination, error)
|
||||
}
|
||||
|
||||
// PaginationOptions carries host-owned pagination controls to the public helpers.
|
||||
@@ -30,21 +30,21 @@ type PaginationOptions struct {
|
||||
// ContextOptions supplies safe callbacks when a host creates a CommandContext.
|
||||
// It is intended for the lark-cli host adapter and commandtest.
|
||||
type ContextOptions struct {
|
||||
Identity Identity
|
||||
DryRun bool
|
||||
CallJSON func(context.Context, Request) (map[string]any, error)
|
||||
PreflightScopes func(...string) error
|
||||
PaginationOptions func() (PaginationOptions, error)
|
||||
Identity Identity
|
||||
DryRun bool
|
||||
CallJSON func(context.Context, Request) (map[string]any, error)
|
||||
PreflightScopes func(...string) error
|
||||
CollectPages func(context.Context, Request, bool) ([]map[string]any, HostPagination, error)
|
||||
}
|
||||
|
||||
// NewCommandContext creates a restricted context from host callbacks.
|
||||
func NewCommandContext(options ContextOptions) CommandContext {
|
||||
return CommandContext{
|
||||
identity: options.Identity,
|
||||
dryRun: options.DryRun,
|
||||
callJSON: options.CallJSON,
|
||||
preflightScopes: options.PreflightScopes,
|
||||
paginationOptions: options.PaginationOptions,
|
||||
identity: options.Identity,
|
||||
dryRun: options.DryRun,
|
||||
callJSON: options.CallJSON,
|
||||
preflightScopes: options.PreflightScopes,
|
||||
collectPages: options.CollectPages,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,24 +89,3 @@ func PreflightScopes(command CommandContext, scopes ...string) error {
|
||||
}
|
||||
return command.preflightScopes(scopes...)
|
||||
}
|
||||
|
||||
func (c CommandContext) pageOptions() (PaginationOptions, error) {
|
||||
if c.paginationOptions == nil {
|
||||
return PaginationOptions{MaxPages: 1}, nil
|
||||
}
|
||||
return c.paginationOptions()
|
||||
}
|
||||
|
||||
func waitForPage(ctx context.Context, delay time.Duration) error {
|
||||
if delay <= 0 {
|
||||
return nil
|
||||
}
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return PaginationInterruptedError(ctx.Err())
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +215,7 @@ type Hooks[Args any, Data any] struct {
|
||||
Normalize func(context.Context, CommandContext, *Args) error
|
||||
Validate func(context.Context, CommandContext, *Args) error
|
||||
DryRun func(context.Context, CommandContext, *Args) *DryRun
|
||||
DryRunE func(context.Context, CommandContext, *Args) (*DryRun, error)
|
||||
Execute func(context.Context, CommandContext, *Args) (Result[Data], error)
|
||||
Renderers map[string]Renderer[Data]
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ type HostHooks struct {
|
||||
Normalize func(context.Context, CommandContext, any) error
|
||||
Validate func(context.Context, CommandContext, any) error
|
||||
DryRun func(context.Context, CommandContext, any) *DryRun
|
||||
DryRunE func(context.Context, CommandContext, any) (*DryRun, error)
|
||||
Execute func(context.Context, CommandContext, any) (HostResult, error)
|
||||
Renderers map[string]func(io.Writer, any) error
|
||||
}
|
||||
@@ -88,6 +89,11 @@ func newCommand[Args any, Data any](definition Definition[Args, Data]) Command {
|
||||
return definition.Hooks.DryRun(ctx, command, args.(*Args))
|
||||
}
|
||||
}
|
||||
if definition.Hooks.DryRunE != nil {
|
||||
host.hooks.DryRunE = func(ctx context.Context, command CommandContext, args any) (*DryRun, error) {
|
||||
return definition.Hooks.DryRunE(ctx, command, args.(*Args))
|
||||
}
|
||||
}
|
||||
if definition.Hooks.Execute != nil {
|
||||
host.hooks.Execute = func(ctx context.Context, command CommandContext, args any) (HostResult, error) {
|
||||
result, err := definition.Hooks.Execute(ctx, command, args.(*Args))
|
||||
|
||||
@@ -4,12 +4,11 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
const collectAllPagesLimit = 1000
|
||||
|
||||
// Page contains items and host-owned pagination state.
|
||||
type Page[T any] struct {
|
||||
Items []T `json:"items" schema:"required;nonnullable" doc:"items returned by the API"`
|
||||
@@ -36,7 +35,13 @@ func (p Page[T]) Pages() int {
|
||||
return p.meta.Pages
|
||||
}
|
||||
|
||||
func (p Page[T]) commandPagination() *paginationMeta { return p.meta }
|
||||
func (p Page[T]) commandPagination() *paginationMeta {
|
||||
meta := clonePaginationMeta(p.meta)
|
||||
if meta != nil {
|
||||
meta.Items = len(p.Items)
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
type paginationMeta struct {
|
||||
Complete bool
|
||||
@@ -62,19 +67,12 @@ type pageEnvelope[T any] struct {
|
||||
|
||||
// CollectPages fetches one page by default or follows standard pagination flags.
|
||||
func CollectPages[T any](ctx context.Context, command CommandContext, request Request) (Page[T], error) {
|
||||
options, err := command.pageOptions()
|
||||
if err != nil {
|
||||
return Page[T]{}, err
|
||||
}
|
||||
if !options.All {
|
||||
options.MaxPages = 1
|
||||
}
|
||||
return collectPages[T](ctx, command, request, options)
|
||||
return collectPages[T](ctx, command, request, false)
|
||||
}
|
||||
|
||||
// CollectAllPages fetches until the endpoint is exhausted and ignores CLI paging flags.
|
||||
func CollectAllPages[T any](ctx context.Context, command CommandContext, request Request) ([]T, error) {
|
||||
page, err := collectPages[T](ctx, command, request, PaginationOptions{All: true, MaxPages: collectAllPagesLimit})
|
||||
page, err := collectPages[T](ctx, command, request, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -84,81 +82,36 @@ func CollectAllPages[T any](ctx context.Context, command CommandContext, request
|
||||
return page.Items, nil
|
||||
}
|
||||
|
||||
func collectPages[T any](ctx context.Context, command CommandContext, request Request, options PaginationOptions) (Page[T], error) {
|
||||
if options.MaxPages < 1 || options.MaxPages > collectAllPagesLimit {
|
||||
return Page[T]{}, ValidationErrorf("pagination page limit must be between 1 and %d", collectAllPagesLimit)
|
||||
}
|
||||
if options.Delay < 0 {
|
||||
return Page[T]{}, ValidationErrorf("pagination delay must not be negative")
|
||||
}
|
||||
|
||||
func collectPages[T any](ctx context.Context, command CommandContext, request Request, all bool) (Page[T], error) {
|
||||
result := Page[T]{meta: &paginationMeta{}}
|
||||
requestView := InspectRequest(request)
|
||||
token := queryPageToken(requestView.Query)
|
||||
seen := make(map[string]struct{}, options.MaxPages)
|
||||
if token != "" {
|
||||
seen[token] = struct{}{}
|
||||
if command.collectPages == nil {
|
||||
return result, InternalErrorf("command host does not provide pagination")
|
||||
}
|
||||
|
||||
for pageNumber := 1; pageNumber <= options.MaxPages; pageNumber++ {
|
||||
pageRequest := request
|
||||
if token != "" {
|
||||
pageRequest = pageRequest.Set("page_token", token)
|
||||
}
|
||||
page, err := CallJSON[pageEnvelope[T]](ctx, command, pageRequest)
|
||||
if err != nil {
|
||||
result.meta.NextToken = token
|
||||
return result, err
|
||||
pages, pagination, err := command.collectPages(ctx, request, all)
|
||||
result.meta.Complete = pagination.Complete
|
||||
result.meta.Pages = pagination.Pages
|
||||
result.meta.NextToken = pagination.NextToken
|
||||
for pageNumber, data := range pages {
|
||||
page, decodeErr := decodePageEnvelope[T](data)
|
||||
if decodeErr != nil {
|
||||
return result, InvalidResponseErrorf("decode pagination page %d: %v", pageNumber+1, decodeErr).WithCause(decodeErr)
|
||||
}
|
||||
result.Items = append(result.Items, page.Items...)
|
||||
result.meta.Pages++
|
||||
result.meta.Items = len(result.Items)
|
||||
|
||||
nextToken := page.PageToken
|
||||
if nextToken == "" {
|
||||
nextToken = page.NextPageToken
|
||||
}
|
||||
if !page.HasMore {
|
||||
result.meta.Complete = true
|
||||
result.meta.NextToken = ""
|
||||
return result, nil
|
||||
}
|
||||
if nextToken == "" {
|
||||
return result, InvalidResponseErrorf("pagination page %d reports has_more=true without a page token", pageNumber)
|
||||
}
|
||||
if _, duplicate := seen[nextToken]; duplicate {
|
||||
return result, InvalidResponseErrorf("pagination page %d repeated page token %q", pageNumber, nextToken)
|
||||
}
|
||||
result.meta.NextToken = nextToken
|
||||
if pageNumber == options.MaxPages {
|
||||
return result, nil
|
||||
}
|
||||
seen[nextToken] = struct{}{}
|
||||
token = nextToken
|
||||
if err := waitForPage(ctx, options.Delay); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
|
||||
return result, InternalErrorf("pagination finished without a terminal state")
|
||||
result.meta.Items = len(result.Items)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func queryPageToken(query map[string]any) string {
|
||||
value, ok := query["page_token"]
|
||||
if !ok {
|
||||
return ""
|
||||
func decodePageEnvelope[T any](data map[string]any) (pageEnvelope[T], error) {
|
||||
var page pageEnvelope[T]
|
||||
encoded, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return page, err
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return typed
|
||||
case []string:
|
||||
if len(typed) > 0 {
|
||||
return typed[0]
|
||||
}
|
||||
case []any:
|
||||
if len(typed) > 0 {
|
||||
return fmt.Sprint(typed[0])
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(encoded))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&page); err != nil {
|
||||
return page, err
|
||||
}
|
||||
return ""
|
||||
return page, nil
|
||||
}
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
defaultaffordance "github.com/larksuite/cli/affordance"
|
||||
"github.com/larksuite/cli/cmd"
|
||||
"github.com/larksuite/cli/extension/command"
|
||||
defaultskills "github.com/larksuite/cli/skills"
|
||||
|
||||
_ "github.com/larksuite/cli/extension/credential/env"
|
||||
)
|
||||
|
||||
type readArgs struct {
|
||||
ID string `flag:"id" schema:"required;minLength=1" doc:"resource identifier"`
|
||||
}
|
||||
|
||||
type readData struct {
|
||||
ID string `json:"id" schema:"required" doc:"resource identifier"`
|
||||
}
|
||||
|
||||
var readCommand = command.Define(command.Definition[readArgs, readData]{
|
||||
Metadata: command.CommandMetadata{
|
||||
Service: "im", Command: "+wrapper-read", Description: "Read one wrapper resource", Risk: command.RiskRead,
|
||||
Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{
|
||||
command.IdentityUser: {RequiredScopes: []string{"im:chat:read"}},
|
||||
}},
|
||||
},
|
||||
Hooks: command.Hooks[readArgs, readData]{
|
||||
DryRunE: func(_ context.Context, _ command.CommandContext, args *readArgs) (*command.DryRun, error) {
|
||||
return command.Preview(command.GET("/open-apis/im/v1/chats/" + args.ID)), nil
|
||||
},
|
||||
Execute: func(ctx context.Context, commandContext command.CommandContext, args *readArgs) (command.Result[readData], error) {
|
||||
data, err := command.CallJSON[readData](ctx, commandContext, command.GET("/open-apis/im/v1/chats/"+args.ID))
|
||||
if err != nil {
|
||||
return command.Result[readData]{}, err
|
||||
}
|
||||
return command.Success(data), nil
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
func main() {
|
||||
cmd.SetEmbeddedSkillContent(defaultskills.DefaultFS())
|
||||
cmd.SetEmbeddedAffordanceContent(defaultaffordance.DefaultFS())
|
||||
os.Exit(cmd.ExecuteWithOptions(
|
||||
cmd.WithCommandSets(command.Set{
|
||||
Domain: command.ExtendDomain(command.DomainIm),
|
||||
Commands: []command.Command{readCommand},
|
||||
}),
|
||||
cmd.WithoutPlugins(),
|
||||
cmd.WithoutStrictMode(),
|
||||
cmd.WithoutServiceCommands(),
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package command_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExternalWrapperCommandSurface(t *testing.T) {
|
||||
packageDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
binary := filepath.Join(t.TempDir(), "business-cli")
|
||||
build := exec.Command("go", "build", "-o", binary, "./testdata/wrapper")
|
||||
build.Dir = packageDir
|
||||
if output, err := build.CombinedOutput(); err != nil {
|
||||
t.Fatalf("build wrapper: %v\n%s", err, output)
|
||||
}
|
||||
|
||||
configDir := t.TempDir()
|
||||
baseEnv := withoutEnvironment(os.Environ(),
|
||||
"LARKSUITE_CLI_CONFIG_DIR", "LARKSUITE_CLI_APP_ID", "LARKSUITE_CLI_APP_SECRET",
|
||||
"LARKSUITE_CLI_USER_ACCESS_TOKEN", "LARKSUITE_CLI_PROFILE",
|
||||
)
|
||||
run := func(args ...string) string {
|
||||
t.Helper()
|
||||
process := exec.Command(binary, args...)
|
||||
process.Env = append(baseEnv,
|
||||
"LARKSUITE_CLI_CONFIG_DIR="+configDir,
|
||||
"LARKSUITE_CLI_APP_ID=wrapper_test_app",
|
||||
"LARKSUITE_CLI_APP_SECRET=wrapper_test_secret",
|
||||
"LARKSUITE_CLI_USER_ACCESS_TOKEN=expired_wrapper_token",
|
||||
"LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1",
|
||||
"LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1",
|
||||
)
|
||||
output, err := process.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("wrapper %v: %v\n%s", args, err, output)
|
||||
}
|
||||
return string(output)
|
||||
}
|
||||
|
||||
dryRun := run("im", "+wrapper-read", "--id", "chat_1", "--as", "user", "--dry-run")
|
||||
if !strings.Contains(dryRun, `"dry_run": true`) || !strings.Contains(dryRun, "/open-apis/im/v1/chats/chat_1") {
|
||||
t.Fatalf("wrapper dry-run = %s", dryRun)
|
||||
}
|
||||
schema := run("schema", "im", "+wrapper-read")
|
||||
if !strings.Contains(schema, `"name": "im +wrapper-read"`) || !strings.Contains(schema, `"outputSchema"`) {
|
||||
t.Fatalf("wrapper schema = %s", schema)
|
||||
}
|
||||
completion := run("__complete", "im", "+wrap")
|
||||
if !strings.Contains(completion, "+wrapper-read") {
|
||||
t.Fatalf("wrapper completion = %s", completion)
|
||||
}
|
||||
skills := run("skills", "list")
|
||||
if !strings.Contains(skills, "lark-doc") {
|
||||
t.Fatalf("wrapper skills = %s", skills)
|
||||
}
|
||||
}
|
||||
|
||||
func withoutEnvironment(environment []string, names ...string) []string {
|
||||
blocked := make(map[string]struct{}, len(names))
|
||||
for _, name := range names {
|
||||
blocked[name] = struct{}{}
|
||||
}
|
||||
result := make([]string, 0, len(environment))
|
||||
for _, entry := range environment {
|
||||
name, _, _ := strings.Cut(entry, "=")
|
||||
if _, ok := blocked[name]; !ok {
|
||||
result = append(result, entry)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -57,47 +57,37 @@ You should see `audit` in the plugin list.
|
||||
|
||||
That is sufficient for a hook-only plugin such as the audit observer. A
|
||||
wrapper main does not compile lark-cli's repository-root `content_embed.go`,
|
||||
so distribution content is a separate, explicit host choice.
|
||||
so distribution content remains an explicit host choice. The repository
|
||||
defaults are importable from `github.com/larksuite/cli/skills` and
|
||||
`github.com/larksuite/cli/affordance`.
|
||||
|
||||
### Ship skills and command guidance
|
||||
|
||||
If the distribution exposes embedded skills or customizes them with
|
||||
`EmbeddedSkills`, copy or generate both content trees under the wrapper
|
||||
package and wire both:
|
||||
If the distribution exposes the repository's embedded skills or customizes
|
||||
them with `EmbeddedSkills`, wire the default content before execution:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os"
|
||||
|
||||
_ "github.com/me/myplugin"
|
||||
_ "github.com/me/myplugin"
|
||||
|
||||
"github.com/larksuite/cli/cmd"
|
||||
defaultaffordance "github.com/larksuite/cli/affordance"
|
||||
"github.com/larksuite/cli/cmd"
|
||||
defaultskills "github.com/larksuite/cli/skills"
|
||||
)
|
||||
|
||||
//go:embed skills affordance
|
||||
var distributionContent embed.FS
|
||||
|
||||
func main() {
|
||||
skillTree, err := fs.Sub(distributionContent, "skills")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
affordanceTree, err := fs.Sub(distributionContent, "affordance")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
cmd.SetEmbeddedSkillContent(skillTree)
|
||||
cmd.SetEmbeddedAffordanceContent(affordanceTree)
|
||||
os.Exit(cmd.Execute())
|
||||
cmd.SetEmbeddedSkillContent(defaultskills.DefaultFS())
|
||||
cmd.SetEmbeddedAffordanceContent(defaultaffordance.DefaultFS())
|
||||
os.Exit(cmd.Execute())
|
||||
}
|
||||
```
|
||||
|
||||
`go:embed` only reads files in the package being compiled; it cannot reach
|
||||
into the replaced `github.com/larksuite/cli` module. Each
|
||||
Custom distributions may instead copy or generate both content trees under
|
||||
the wrapper package and wire their own `fs.FS` values. Each
|
||||
`skills/<name>/` must contain `SKILL.md`. The `affordance/*.md` files are the
|
||||
structured source for command help and canonical skill references; ship the
|
||||
ones for the domains your distribution retains. Without
|
||||
|
||||
@@ -15,7 +15,8 @@ import "io/fs"
|
||||
// Allow -> Remove -> Overlay, a same-named skill resolving to Overlay.
|
||||
// The repository's root binary provides its base from content_embed.go;
|
||||
// an external wrapper main has no implicit CLI default and must call
|
||||
// cmd.SetEmbeddedSkillContent before Execute if it relies on that base.
|
||||
// cmd.SetEmbeddedSkillContent before Execute if it relies on that base. The
|
||||
// repository default is available from skills.DefaultFS.
|
||||
//
|
||||
// Skills are addressed by exact name (a directory carrying SKILL.md,
|
||||
// e.g. "lark-doc"), not by command path and not by glob — the skill
|
||||
@@ -61,8 +62,8 @@ type SkillsOverlay struct {
|
||||
|
||||
// Base replaces the host-provided base skill tree instead of layering
|
||||
// over it. nil keeps whatever base the host wired with
|
||||
// cmd.SetEmbeddedSkillContent; it does not import the repository
|
||||
// binary's default into an external wrapper main. Every top-level
|
||||
// cmd.SetEmbeddedSkillContent; it does not select the repository
|
||||
// binary's default for an external wrapper main. Every top-level
|
||||
// entry must be a valid skill directory containing SKILL.md. Most
|
||||
// integrators leave Base nil and use Remove/Overlay so unchanged
|
||||
// host-provided skills need no copy inside the plugin.
|
||||
|
||||
@@ -90,6 +90,9 @@ func validateDomain(domain command.HostDomain, existing map[string]struct{}) err
|
||||
}
|
||||
|
||||
func compileCommand(definition command.HostDefinition) (common.Shortcut, error) {
|
||||
if definition.Hooks.DryRun != nil && definition.Hooks.DryRunE != nil {
|
||||
return common.Shortcut{}, fmt.Errorf("Hooks.DryRun and Hooks.DryRunE cannot both be set")
|
||||
}
|
||||
metadata := convertMetadata(definition.Metadata)
|
||||
input, err := convertInput(definition.Input)
|
||||
if err != nil {
|
||||
@@ -214,15 +217,29 @@ func convertOutput(output command.OutputDefinition) (common.OutputDefinition, er
|
||||
}
|
||||
|
||||
func convertHooks(hooks command.HostHooks) common.ErasedHooks {
|
||||
dryRun := adaptDryRunHook(hooks.DryRun)
|
||||
if hooks.DryRunE != nil {
|
||||
dryRun = adaptDryRunErrorHook(hooks.DryRunE)
|
||||
}
|
||||
return common.ErasedHooks{
|
||||
Normalize: adaptHook(hooks.Normalize),
|
||||
Validate: adaptHook(hooks.Validate),
|
||||
DryRun: adaptDryRunHook(hooks.DryRun),
|
||||
DryRun: dryRun,
|
||||
Execute: adaptExecuteHook(hooks.Execute),
|
||||
Renderers: cloneRenderers(hooks.Renderers),
|
||||
}
|
||||
}
|
||||
|
||||
func adaptDryRunErrorHook(hook func(context.Context, command.CommandContext, any) (*command.DryRun, error)) func(context.Context, common.CommandContext, any) (*common.DryRunAPI, error) {
|
||||
return func(ctx context.Context, host common.CommandContext, args any) (*common.DryRunAPI, error) {
|
||||
preview, err := hook(ctx, publicContext(host), args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return convertDryRun(preview)
|
||||
}
|
||||
}
|
||||
|
||||
func adaptHook(hook func(context.Context, command.CommandContext, any) error) func(context.Context, common.CommandContext, any) error {
|
||||
if hook == nil {
|
||||
return nil
|
||||
@@ -279,9 +296,19 @@ func publicContext(host common.CommandContext) command.CommandContext {
|
||||
return common.DoTypedAPIJSON(ctx, host, view.Method, view.Path, queryParams(view.Query), view.Body)
|
||||
},
|
||||
PreflightScopes: host.RequireConditionalScopes,
|
||||
PaginationOptions: func() (command.PaginationOptions, error) {
|
||||
options, err := host.PaginationOptions()
|
||||
return command.PaginationOptions{All: options.All, MaxPages: options.MaxPages, Delay: options.Delay}, err
|
||||
CollectPages: func(ctx context.Context, request command.Request, all bool) ([]map[string]any, command.HostPagination, error) {
|
||||
view := command.InspectRequest(request)
|
||||
if err := command.ValidateRequestView(view); err != nil {
|
||||
return nil, command.HostPagination{}, err
|
||||
}
|
||||
collection, err := common.CollectCommandPages(ctx, host, common.PageRequest{
|
||||
Method: view.Method, Path: view.Path, Params: view.Query, Body: view.Body,
|
||||
}, all)
|
||||
pagination := command.HostPagination{
|
||||
Complete: collection.Complete, Pages: collection.Pages,
|
||||
NextToken: collection.NextToken,
|
||||
}
|
||||
return collection.Data, pagination, err
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package commandhost
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -106,6 +107,30 @@ func TestCompileSetsRejectsSystemFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileSetsRejectsBothDryRunHooks(t *testing.T) {
|
||||
definition := command.Define(command.Definition[fixtureArgs, fixtureData]{
|
||||
Metadata: command.CommandMetadata{
|
||||
Service: "im", Command: "+external-dry-run-conflict", Description: "Dry-run conflict", Risk: command.RiskRead,
|
||||
Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{command.IdentityUser: {}}},
|
||||
},
|
||||
Hooks: command.Hooks[fixtureArgs, fixtureData]{
|
||||
DryRun: func(context.Context, command.CommandContext, *fixtureArgs) *command.DryRun {
|
||||
return command.NewDryRun()
|
||||
},
|
||||
DryRunE: func(context.Context, command.CommandContext, *fixtureArgs) (*command.DryRun, error) {
|
||||
return command.NewDryRun(), nil
|
||||
},
|
||||
Execute: func(context.Context, command.CommandContext, *fixtureArgs) (command.Result[fixtureData], error) {
|
||||
return command.Success(fixtureData{}), nil
|
||||
},
|
||||
},
|
||||
})
|
||||
_, err := CompileSets([]command.Set{{Domain: command.ExtendDomain(command.DomainIm), Commands: []command.Command{definition}}})
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot both be set") {
|
||||
t.Fatalf("CompileSets() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileSetsAddsPaginationFlags(t *testing.T) {
|
||||
declaration := command.Define(command.Definition[fixtureArgs, command.Page[fixtureData]]{
|
||||
Metadata: command.CommandMetadata{
|
||||
@@ -204,3 +229,35 @@ func TestExternalDryRunUsesOfflineContext(t *testing.T) {
|
||||
t.Fatalf("dry-run output = %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalDryRunEPropagatesError(t *testing.T) {
|
||||
sentinel := command.ValidationErrorf("dry-run input is invalid")
|
||||
declaration := command.Define(command.Definition[fixtureArgs, fixtureData]{
|
||||
Metadata: command.CommandMetadata{
|
||||
Service: "im", Command: "+external-dry-run-error", Description: "Offline preview error", Risk: command.RiskRead,
|
||||
Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{command.IdentityUser: {}}},
|
||||
},
|
||||
Hooks: command.Hooks[fixtureArgs, fixtureData]{
|
||||
DryRunE: func(context.Context, command.CommandContext, *fixtureArgs) (*command.DryRun, error) {
|
||||
return nil, sentinel
|
||||
},
|
||||
Execute: func(context.Context, command.CommandContext, *fixtureArgs) (command.Result[fixtureData], error) {
|
||||
return command.Success(fixtureData{}), nil
|
||||
},
|
||||
},
|
||||
})
|
||||
compiled, err := CompileSets([]command.Set{{Domain: command.ExtendDomain(command.DomainIm), Commands: []command.Command{declaration}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
factory, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{})
|
||||
root := &cobra.Command{Use: "lark-cli", SilenceErrors: true, SilenceUsage: true}
|
||||
service := &cobra.Command{Use: "im"}
|
||||
root.AddCommand(service)
|
||||
compiled[0].Mount(service, factory)
|
||||
root.SetArgs([]string{"im", "+external-dry-run-error", "--id", "chat_1", "--as", "user", "--dry-run"})
|
||||
_, err = root.ExecuteC()
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("dry-run error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package pagination owns the bounded cursor walk shared by built-in and
|
||||
// externally assembled commands.
|
||||
package pagination
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CursorErrorKind identifies an invalid cursor transition returned by an API.
|
||||
type CursorErrorKind uint8
|
||||
|
||||
const (
|
||||
// CursorMissing means the API reported another page without a cursor.
|
||||
CursorMissing CursorErrorKind = iota + 1
|
||||
// CursorRepeated means the API returned a cursor already observed by the walk.
|
||||
CursorRepeated
|
||||
)
|
||||
|
||||
// CursorError reports an invalid cursor transition.
|
||||
type CursorError struct {
|
||||
Kind CursorErrorKind
|
||||
Page int
|
||||
Token string
|
||||
}
|
||||
|
||||
func (e *CursorError) Error() string {
|
||||
switch e.Kind {
|
||||
case CursorMissing:
|
||||
return fmt.Sprintf("pagination page %d reports more pages without a page token", e.Page)
|
||||
case CursorRepeated:
|
||||
return fmt.Sprintf("pagination page %d repeated page token %q", e.Page, e.Token)
|
||||
default:
|
||||
return fmt.Sprintf("pagination page %d returned an invalid cursor", e.Page)
|
||||
}
|
||||
}
|
||||
|
||||
// WaitError reports cancellation or failure while delaying between pages.
|
||||
type WaitError struct{ Err error }
|
||||
|
||||
func (e *WaitError) Error() string { return e.Err.Error() }
|
||||
func (e *WaitError) Unwrap() error { return e.Err }
|
||||
|
||||
// State describes the completed portion of a cursor walk.
|
||||
type State struct {
|
||||
Complete bool
|
||||
Pages int
|
||||
NextToken string
|
||||
}
|
||||
|
||||
// Fetch obtains and consumes one page, then returns its cursor state.
|
||||
type Fetch func(ctx context.Context, pageNumber int, pageToken string) (hasMore bool, nextToken string, err error)
|
||||
|
||||
// Options configures one bounded cursor walk.
|
||||
type Options struct {
|
||||
InitialToken string
|
||||
MaxPages int
|
||||
Delay time.Duration
|
||||
Fetch Fetch
|
||||
Wait func(context.Context, time.Duration) error
|
||||
}
|
||||
|
||||
// Walk follows page tokens until exhaustion or MaxPages is reached.
|
||||
func Walk(ctx context.Context, options Options) (State, error) {
|
||||
state := State{NextToken: options.InitialToken}
|
||||
seen := make(map[string]struct{}, options.MaxPages)
|
||||
if options.InitialToken != "" {
|
||||
seen[options.InitialToken] = struct{}{}
|
||||
}
|
||||
token := options.InitialToken
|
||||
wait := options.Wait
|
||||
if wait == nil {
|
||||
wait = WaitContext
|
||||
}
|
||||
|
||||
for pageNumber := 1; pageNumber <= options.MaxPages; pageNumber++ {
|
||||
hasMore, nextToken, err := options.Fetch(ctx, pageNumber, token)
|
||||
if err != nil {
|
||||
state.NextToken = token
|
||||
return state, err
|
||||
}
|
||||
state.Pages++
|
||||
if !hasMore {
|
||||
state.Complete = true
|
||||
state.NextToken = ""
|
||||
return state, nil
|
||||
}
|
||||
if nextToken == "" {
|
||||
return state, &CursorError{Kind: CursorMissing, Page: pageNumber}
|
||||
}
|
||||
if _, duplicate := seen[nextToken]; duplicate {
|
||||
return state, &CursorError{Kind: CursorRepeated, Page: pageNumber, Token: nextToken}
|
||||
}
|
||||
state.NextToken = nextToken
|
||||
if pageNumber == options.MaxPages {
|
||||
return state, nil
|
||||
}
|
||||
seen[nextToken] = struct{}{}
|
||||
token = nextToken
|
||||
if options.Delay > 0 {
|
||||
if err := wait(ctx, options.Delay); err != nil {
|
||||
return state, &WaitError{Err: err}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return state, fmt.Errorf("pagination exhausted its page budget without producing a terminal result")
|
||||
}
|
||||
|
||||
// WaitContext waits for one inter-page delay and observes cancellation.
|
||||
func WaitContext(ctx context.Context, delay time.Duration) error {
|
||||
if delay <= 0 {
|
||||
return nil
|
||||
}
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pagination
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWalkFollowsCursorsToCompletion(t *testing.T) {
|
||||
var tokens []string
|
||||
state, err := Walk(context.Background(), Options{
|
||||
MaxPages: 3,
|
||||
Fetch: func(_ context.Context, page int, token string) (bool, string, error) {
|
||||
tokens = append(tokens, token)
|
||||
if page == 1 {
|
||||
return true, "next", nil
|
||||
}
|
||||
return false, "", nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !state.Complete || state.Pages != 2 || state.NextToken != "" {
|
||||
t.Fatalf("state = %#v", state)
|
||||
}
|
||||
if !reflect.DeepEqual(tokens, []string{"", "next"}) {
|
||||
t.Fatalf("tokens = %#v", tokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkRejectsInvalidCursorTransitions(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
walk func(context.Context, int, string) (bool, string, error)
|
||||
kind CursorErrorKind
|
||||
}{
|
||||
{name: "missing", kind: CursorMissing, walk: func(context.Context, int, string) (bool, string, error) {
|
||||
return true, "", nil
|
||||
}},
|
||||
{name: "repeated", kind: CursorRepeated, walk: func(context.Context, int, string) (bool, string, error) {
|
||||
return true, "resume", nil
|
||||
}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := Walk(context.Background(), Options{InitialToken: "resume", MaxPages: 2, Fetch: test.walk})
|
||||
var cursorErr *CursorError
|
||||
if !errors.As(err, &cursorErr) || cursorErr.Kind != test.kind {
|
||||
t.Fatalf("Walk() error = %T %v", err, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkPreservesResumeTokenWhenWaitIsCanceled(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
state, err := Walk(ctx, Options{
|
||||
MaxPages: 2,
|
||||
Delay: time.Second,
|
||||
Fetch: func(context.Context, int, string) (bool, string, error) {
|
||||
return true, "next", nil
|
||||
},
|
||||
})
|
||||
var waitErr *WaitError
|
||||
if !errors.As(err, &waitErr) || !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Walk() error = %T %v", err, err)
|
||||
}
|
||||
if state.Pages != 1 || state.NextToken != "next" {
|
||||
t.Fatalf("state = %#v", state)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
internalpagination "github.com/larksuite/cli/internal/pagination"
|
||||
)
|
||||
|
||||
// PageRequest describes one paginated API walk. Pagination controls are not
|
||||
@@ -58,79 +59,69 @@ func paginateInto[T any](runtime *RuntimeContext, request PageRequest, dst PageA
|
||||
if err != nil {
|
||||
return meta, err
|
||||
}
|
||||
|
||||
pageToken := pageTokenParam(request.Params)
|
||||
seen := make(map[string]struct{})
|
||||
if pageToken != "" {
|
||||
seen[pageToken] = struct{}{}
|
||||
ctx := runtime.Ctx()
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
// maxPages is always in [1, pageLimitMaximum]. Keeping the bound in the
|
||||
// loop statement makes finite execution a structural invariant, independent
|
||||
// of cursor quality and of any future exit-condition changes below.
|
||||
for pageNumber := 1; pageNumber <= policy.maxPages; pageNumber++ {
|
||||
params := clonePageParams(request.Params)
|
||||
if pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
if policy.showProgress {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "[page %d] fetching...\n", pageNumber)
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped(request.Method, request.Path, params, request.Body)
|
||||
if err != nil {
|
||||
meta.NextToken = pageToken
|
||||
return meta, err
|
||||
}
|
||||
page, err := decodePageData[T](data, pageNumber)
|
||||
if err != nil {
|
||||
meta.NextToken = pageToken
|
||||
return meta, err
|
||||
}
|
||||
if err := dst.AddPage(page); err != nil {
|
||||
meta.NextToken = pageToken
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return meta, err
|
||||
state, walkErr := internalpagination.Walk(ctx, internalpagination.Options{
|
||||
InitialToken: pageTokenParam(request.Params),
|
||||
MaxPages: policy.maxPages,
|
||||
Delay: policy.pageDelay,
|
||||
Wait: wait,
|
||||
Fetch: func(_ context.Context, pageNumber int, pageToken string) (bool, string, error) {
|
||||
params := clonePageParams(request.Params)
|
||||
if pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
return meta, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"accumulate pagination page %d: %v", pageNumber, err).
|
||||
WithCause(err)
|
||||
}
|
||||
meta.Pages++
|
||||
|
||||
hasMore, nextPageToken := PaginationMeta(data)
|
||||
if !hasMore {
|
||||
meta.Complete = true
|
||||
meta.NextToken = ""
|
||||
return meta, nil
|
||||
}
|
||||
if nextPageToken == "" {
|
||||
return meta, invalidPageCursor("response reports more pages but returned no page token")
|
||||
}
|
||||
if _, repeated := seen[nextPageToken]; repeated {
|
||||
return meta, invalidPageCursor("response repeated page token %q, which would paginate forever", nextPageToken)
|
||||
}
|
||||
|
||||
meta.NextToken = nextPageToken
|
||||
if pageNumber == policy.maxPages {
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
seen[nextPageToken] = struct{}{}
|
||||
pageToken = nextPageToken
|
||||
if policy.pageDelay > 0 {
|
||||
ctx := runtime.Ctx()
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
if policy.showProgress {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "[page %d] fetching...\n", pageNumber)
|
||||
}
|
||||
if err := wait(ctx, policy.pageDelay); err != nil {
|
||||
return meta, paginationWaitError(err)
|
||||
|
||||
data, err := runtime.CallAPITyped(request.Method, request.Path, params, request.Body)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
}
|
||||
page, err := decodePageData[T](data, pageNumber)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
if err := dst.AddPage(page); err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return false, "", err
|
||||
}
|
||||
return false, "", errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"accumulate pagination page %d: %v", pageNumber, err).
|
||||
WithCause(err)
|
||||
}
|
||||
hasMore, nextPageToken := PaginationMeta(data)
|
||||
return hasMore, nextPageToken, nil
|
||||
},
|
||||
})
|
||||
meta.Complete = state.Complete
|
||||
meta.Pages = state.Pages
|
||||
meta.NextToken = state.NextToken
|
||||
if walkErr == nil {
|
||||
return meta, nil
|
||||
}
|
||||
return meta, paginationWalkError(walkErr)
|
||||
}
|
||||
|
||||
return meta, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"pagination exhausted its page budget without producing a terminal result")
|
||||
func paginationWalkError(walkErr error) error {
|
||||
var cursorErr *internalpagination.CursorError
|
||||
if errors.As(walkErr, &cursorErr) {
|
||||
if cursorErr.Kind == internalpagination.CursorMissing {
|
||||
return invalidPageCursor("response reports more pages but returned no page token")
|
||||
}
|
||||
return invalidPageCursor("response repeated page token %q, which would paginate forever", cursorErr.Token)
|
||||
}
|
||||
var waitErr *internalpagination.WaitError
|
||||
if errors.As(walkErr, &waitErr) {
|
||||
return paginationWaitError(waitErr.Err)
|
||||
}
|
||||
if _, ok := errs.ProblemOf(walkErr); ok {
|
||||
return walkErr
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "paginate: %v", walkErr).WithCause(walkErr)
|
||||
}
|
||||
|
||||
type paginationPolicy struct {
|
||||
@@ -174,17 +165,7 @@ func paginationProgressEnabled(runtime *RuntimeContext) bool {
|
||||
}
|
||||
|
||||
func waitPageDelay(ctx context.Context, delay time.Duration) error {
|
||||
if delay <= 0 {
|
||||
return nil
|
||||
}
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
return internalpagination.WaitContext(ctx, delay)
|
||||
}
|
||||
|
||||
func paginationWaitError(err error) error {
|
||||
|
||||
@@ -1386,11 +1386,22 @@ func validateEnumFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
|
||||
// handleShortcutDryRun renders a shortcut plan without sending its API requests.
|
||||
func handleShortcutDryRun(f *cmdutil.Factory, rctx *RuntimeContext, s *Shortcut) error {
|
||||
if s.DryRun == nil {
|
||||
if s.DryRun == nil && s.DryRunE == nil {
|
||||
return ValidationErrorf("--dry-run is not supported for %s %s", s.Service, s.Command).
|
||||
WithParam("--dry-run")
|
||||
}
|
||||
dryResult := s.DryRun(rctx.ctx, rctx)
|
||||
var (
|
||||
dryResult *DryRunAPI
|
||||
err error
|
||||
)
|
||||
if s.DryRunE != nil {
|
||||
dryResult, err = s.DryRunE(rctx.ctx, rctx)
|
||||
} else {
|
||||
dryResult = s.DryRun(rctx.ctx, rctx)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dryResult != nil {
|
||||
// Same data.context contract as the service/api dry-run paths.
|
||||
dryResult.Context(rctx.Config.AppID, rctx.UserOpenId())
|
||||
|
||||
@@ -339,6 +339,27 @@ func TestRunShortcut_DryRunJSONUsesEnvelope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunShortcut_DryRunEReturnsTypedError(t *testing.T) {
|
||||
sentinel := errs.NewValidationError(errs.SubtypeInvalidArgument, "dry-run input is invalid")
|
||||
s := &Shortcut{
|
||||
Service: "test", Command: "test-shortcut", AuthTypes: []string{"bot"},
|
||||
DryRunE: func(context.Context, *RuntimeContext) (*DryRunAPI, error) {
|
||||
return nil, sentinel
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error {
|
||||
t.Fatal("Execute should not run in dry-run")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
f := newTestFactory()
|
||||
cmd := newTestShortcutCmd(s, f)
|
||||
cmd.Flags().Set("dry-run", "true")
|
||||
cmd.Flags().Set("as", "bot")
|
||||
if err := runShortcut(cmd, f, s, false); !errors.Is(err, sentinel) {
|
||||
t.Fatalf("runShortcut() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunShortcut_DryRunWithJq(t *testing.T) {
|
||||
s := &Shortcut{
|
||||
Service: "test",
|
||||
|
||||
@@ -40,6 +40,9 @@ func compileDefinition[Args any, Data any](definition Definition[Args, Data]) (*
|
||||
if definition.Hooks.Execute == nil {
|
||||
return nil, fmt.Errorf("Hooks.Execute is required")
|
||||
}
|
||||
if definition.Hooks.DryRun != nil && definition.Hooks.DryRunE != nil {
|
||||
return nil, fmt.Errorf("Hooks.DryRun and Hooks.DryRunE cannot both be set")
|
||||
}
|
||||
return compileDefinitionParts(
|
||||
definition.Metadata,
|
||||
definition.Input,
|
||||
@@ -247,6 +250,11 @@ func adaptHooks[Args any, Data any](hooks Hooks[Args, Data]) compiledHooks {
|
||||
return hooks.DryRun(ctx, cc, args.(*Args)), nil
|
||||
}
|
||||
}
|
||||
if hooks.DryRunE != nil {
|
||||
adapted.dryRun = func(ctx context.Context, cc CommandContext, args any) (*DryRunAPI, error) {
|
||||
return hooks.DryRunE(ctx, cc, args.(*Args))
|
||||
}
|
||||
}
|
||||
adapted.execute = func(ctx context.Context, cc CommandContext, args any) (compiledResult, error) {
|
||||
result, err := hooks.Execute(ctx, cc, args.(*Args))
|
||||
return compiledResult{data: result.Data, outcome: result.Outcome, meta: result.Meta}, err
|
||||
|
||||
@@ -168,6 +168,7 @@ type Hooks[Args any, Data any] struct {
|
||||
Normalize func(context.Context, CommandContext, *Args) error
|
||||
Validate func(context.Context, CommandContext, *Args) error
|
||||
DryRun func(context.Context, CommandContext, *Args) *DryRunAPI
|
||||
DryRunE func(context.Context, CommandContext, *Args) (*DryRunAPI, error)
|
||||
Execute func(context.Context, CommandContext, *Args) (Result[Data], error)
|
||||
Renderers map[string]Renderer[Data]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
internalpagination "github.com/larksuite/cli/internal/pagination"
|
||||
)
|
||||
|
||||
// CommandPageCollection is the host projection used by the public command adapter.
|
||||
type CommandPageCollection struct {
|
||||
Data []map[string]any
|
||||
Complete bool
|
||||
Pages int
|
||||
NextToken string
|
||||
}
|
||||
|
||||
// CollectCommandPages uses the shared cursor walker for an externally declared command.
|
||||
func CollectCommandPages(ctx context.Context, command CommandContext, request PageRequest, all bool) (CommandPageCollection, error) {
|
||||
policy, err := commandPagePolicy(command, all)
|
||||
if err != nil {
|
||||
return CommandPageCollection{}, err
|
||||
}
|
||||
collection := CommandPageCollection{}
|
||||
state, walkErr := internalpagination.Walk(ctx, internalpagination.Options{
|
||||
InitialToken: pageTokenParam(request.Params),
|
||||
MaxPages: policy.maxPages,
|
||||
Delay: policy.pageDelay,
|
||||
Fetch: func(ctx context.Context, _ int, pageToken string) (bool, string, error) {
|
||||
params := clonePageParams(request.Params)
|
||||
if pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
data, err := CallTypedAPI(ctx, command, request.Method, request.Path, params, request.Body)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
collection.Data = append(collection.Data, data)
|
||||
hasMore, nextToken := PaginationMeta(data)
|
||||
return hasMore, nextToken, nil
|
||||
},
|
||||
})
|
||||
collection.Complete = state.Complete
|
||||
collection.Pages = state.Pages
|
||||
collection.NextToken = state.NextToken
|
||||
if walkErr != nil {
|
||||
return collection, paginationWalkError(walkErr)
|
||||
}
|
||||
return collection, nil
|
||||
}
|
||||
|
||||
func commandPagePolicy(command CommandContext, all bool) (paginationPolicy, error) {
|
||||
if all {
|
||||
return paginationPolicy{maxPages: pageLimitMaximum}, nil
|
||||
}
|
||||
options, err := command.PaginationOptions()
|
||||
if err != nil {
|
||||
return paginationPolicy{}, err
|
||||
}
|
||||
if !options.All {
|
||||
options.MaxPages = 1
|
||||
}
|
||||
if options.MaxPages < 1 || options.MaxPages > pageLimitMaximum {
|
||||
return paginationPolicy{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"pagination page limit must be between 1 and %d", pageLimitMaximum)
|
||||
}
|
||||
if options.Delay < 0 || options.Delay > time.Duration(pageDelayMaximum)*time.Millisecond {
|
||||
return paginationPolicy{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"pagination delay must be between 0 and %d milliseconds", pageDelayMaximum)
|
||||
}
|
||||
return paginationPolicy{maxPages: options.MaxPages, pageDelay: options.Delay}, nil
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
// ShortcutSchema returns the immutable schema contract of a Typed Shortcut.
|
||||
func ShortcutSchema(shortcut Shortcut) (any, bool) {
|
||||
if shortcut.typed == nil {
|
||||
return nil, false
|
||||
}
|
||||
return shortcut.typed.contract, true
|
||||
}
|
||||
@@ -63,9 +63,10 @@ type Shortcut struct {
|
||||
// used to satisfy a Cobra Required flag; alternatives such as "A or legacy B"
|
||||
// are a business constraint and must be validated as such.
|
||||
Normalize FlagNormalizer
|
||||
DryRun func(ctx context.Context, runtime *RuntimeContext) *DryRunAPI // optional: framework prints & returns when --dry-run is set
|
||||
Validate func(ctx context.Context, runtime *RuntimeContext) error // optional pre-execution validation
|
||||
Execute func(ctx context.Context, runtime *RuntimeContext) error // main logic
|
||||
DryRun func(ctx context.Context, runtime *RuntimeContext) *DryRunAPI // optional: framework prints & returns when --dry-run is set
|
||||
DryRunE func(ctx context.Context, runtime *RuntimeContext) (*DryRunAPI, error) // optional error-capable dry-run; takes precedence over DryRun
|
||||
Validate func(ctx context.Context, runtime *RuntimeContext) error // optional pre-execution validation
|
||||
Execute func(ctx context.Context, runtime *RuntimeContext) error // main logic
|
||||
|
||||
// OnInvoke, when non-nil, runs from the command's cobra PreRunE — before
|
||||
// cobra validates required flags — so its side effect fires even when the
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package skills exposes the repository's default embedded skill content.
|
||||
package skills
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed */SKILL.md */references */routes */scenes
|
||||
var content embed.FS
|
||||
|
||||
// DefaultFS returns the immutable default skill tree rooted at skill names.
|
||||
func DefaultFS() fs.FS { return content }
|
||||
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skills
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultFSContainsSkillAndReference(t *testing.T) {
|
||||
for _, path := range []string{"lark-doc/SKILL.md", "lark-doc/references/lark-doc-fetch.md"} {
|
||||
if _, err := fs.ReadFile(DefaultFS(), path); err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user