diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f270ba615..2013dcea21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,10 @@ jobs: echo "::error::Unformatted Go files detected — run 'gofmt -w .' and commit" exit 1 fi + - name: Check generated Go files + run: | + go generate ./shortcuts/sheets/... + git diff --exit-code - name: Check go.mod tidiness run: | go mod tidy diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go index 9d5c8204a1..b626d43824 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -19,20 +19,30 @@ import ( "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/errclass" "github.com/larksuite/cli/internal/recovery" + "github.com/larksuite/cli/shortcuts" + shortcutcommon "github.com/larksuite/cli/shortcuts/common" ) // NewCmdAuth creates the auth command with subcommands. func NewCmdAuth(f *cmdutil.Factory) *cobra.Command { - return newCmdAuth(f, nil) + return newCmdAuth(f, nil, shortcuts.AllShortcuts()) } // NewCmdAuthWithRecovery creates the auth command with a build-local recovery -// presenter while preserving NewCmdAuth's established function signature. +// presenter, resolving domains from the registered shortcut set. Retained at its +// established signature: callers outside this module cannot name +// *recovery.Projector, but they can pass nil for it, so dropping this would +// break them at compile time. func NewCmdAuthWithRecovery(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Command { - return newCmdAuth(f, projector) + return NewCmdAuthWithRecoveryAndShortcuts(f, projector, shortcuts.AllShortcuts()) } -func newCmdAuth(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Command { +// NewCmdAuthWithRecoveryAndShortcuts creates auth commands from one build-local shortcut snapshot. +func NewCmdAuthWithRecoveryAndShortcuts(f *cmdutil.Factory, projector *recovery.Projector, registered []shortcutcommon.Shortcut) *cobra.Command { + return newCmdAuth(f, projector, registered) +} + +func newCmdAuth(f *cmdutil.Factory, projector *recovery.Projector, registered []shortcutcommon.Shortcut) *cobra.Command { cmd := &cobra.Command{ Use: "auth", Short: "OAuth credentials and authorization management", @@ -49,7 +59,7 @@ func newCmdAuth(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Comman } cmdutil.DisableAuthCheck(cmd) - cmd.AddCommand(NewCmdAuthLogin(f, nil)) + cmd.AddCommand(newCmdAuthLogin(f, nil, registered)) cmd.AddCommand(NewCmdAuthLogout(f, nil)) cmd.AddCommand(newCmdAuthStatus(f, nil, projector)) cmd.AddCommand(NewCmdAuthScopes(f, nil)) diff --git a/cmd/auth/auth_test.go b/cmd/auth/auth_test.go index f633a61433..58f7c34fe1 100644 --- a/cmd/auth/auth_test.go +++ b/cmd/auth/auth_test.go @@ -301,7 +301,7 @@ func TestDomainFlagCompletion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - comps := completeDomain(tt.toComplete) + comps := builtinResolver().complete(tt.toComplete, "") sort.Strings(comps) for _, want := range tt.wantContains { diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 6cbbce2811..99dbb0b965 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -43,7 +43,16 @@ var pollDeviceToken = larkauth.PollDeviceToken // NewCmdAuthLogin creates the auth login subcommand. func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command { + return newCmdAuthLogin(f, runF, shortcuts.AllShortcuts()) +} + +// newCmdAuthLogin resolves domains from one build's shortcut snapshot. The +// snapshot stays a closure capture rather than a LoginOptions field: LoginOptions +// is part of the exported runF signature, and an unexported field on it would +// end positional literals for every caller outside this module. +func newCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error, registered []common.Shortcut) *cobra.Command { opts := &LoginOptions{Factory: f} + resolver := newDomainResolver(registered) cmd := &cobra.Command{ Use: "login", @@ -65,7 +74,7 @@ to generate QR codes (supports ASCII and PNG formats).`, if runF != nil { return runF(opts) } - return authLoginRun(opts) + return authLoginRun(opts, resolver) }, } cmdutil.SetSupportedIdentities(cmd, []string{"user"}) @@ -79,7 +88,7 @@ to generate QR codes (supports ASCII and PNG formats).`, helpBrand = cfg.Brand } } - available := sortedKnownDomains(helpBrand) + available := resolver.sorted(helpBrand) cmd.Flags().StringSliceVar(&opts.Domains, "domain", nil, fmt.Sprintf("domain (repeatable or comma-separated, e.g. --domain calendar,task)\navailable: %s, all", strings.Join(available, ", "))) cmd.Flags().StringSliceVar(&opts.Exclude, "exclude", nil, @@ -89,15 +98,15 @@ to generate QR codes (supports ASCII and PNG formats).`, cmd.Flags().StringVar(&opts.DeviceCode, "device-code", "", "poll and complete authorization with a device code from a previous --no-wait call") cmdutil.RegisterFlagCompletion(cmd, "domain", func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completeDomain(toComplete), cobra.ShellCompDirectiveNoFileComp + return resolver.complete(toComplete, helpBrand), cobra.ShellCompDirectiveNoFileComp }) return cmd } -// completeDomain returns completions for comma-separated domain values. -func completeDomain(toComplete string) []string { - allDomains := registry.ListFromMetaProjects() +// complete returns completions for comma-separated domain values. +func (r domainResolver) complete(toComplete string, brand core.LarkBrand) []string { + allDomains := r.sorted(brand) parts := strings.Split(toComplete, ",") prefix := parts[len(parts)-1] base := strings.Join(parts[:len(parts)-1], ",") @@ -116,7 +125,7 @@ func completeDomain(toComplete string) []string { } // authLoginRun executes the login command logic. -func authLoginRun(opts *LoginOptions) error { +func authLoginRun(opts *LoginOptions, resolver domainResolver) error { f := opts.Factory config, err := f.Config() @@ -151,14 +160,14 @@ func authLoginRun(opts *LoginOptions) error { // Expand --domain all to all available domains (from_meta projects + shortcut services) for _, d := range selectedDomains { if strings.EqualFold(d, "all") { - selectedDomains = sortedKnownDomains(config.Brand) + selectedDomains = resolver.sorted(config.Brand) break } } // Validate domain names and suggest corrections for unknown ones if len(selectedDomains) > 0 { - knownDomains := allKnownDomains(config.Brand) + knownDomains := resolver.allKnown(config.Brand) for _, d := range selectedDomains { if !knownDomains[d] { if suggestion := suggestDomain(d, knownDomains); suggestion != "" { @@ -182,7 +191,7 @@ func authLoginRun(opts *LoginOptions) error { if !hasAnyOption { if !opts.JSON && f.IOStreams.IsTerminal { - result, err := runInteractiveLogin(f.IOStreams, lang.Base(), msg, config.Brand) + result, err := runInteractiveLogin(f.IOStreams, lang.Base(), msg, config.Brand, resolver) if err != nil { return err } @@ -220,10 +229,10 @@ func authLoginRun(opts *LoginOptions) error { if len(selectedDomains) > 0 || opts.Recommend { var candidateScopes []string if len(selectedDomains) > 0 { - candidateScopes = collectScopesForDomains(selectedDomains, "user", config.Brand) + candidateScopes = resolver.scopesFor(selectedDomains, "user", config.Brand) } else { // --recommend without --domain: all domains - candidateScopes = collectScopesForDomains(sortedKnownDomains(config.Brand), "user", config.Brand) + candidateScopes = resolver.scopesFor(resolver.sorted(config.Brand), "user", config.Brand) } // Filter to auto-approve scopes if --recommend or interactive "common" @@ -508,7 +517,25 @@ func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.App // shortcut scopes for the given domain names. // Domains with auth_domain children are automatically expanded to include // their children's scopes. -func collectScopesForDomains(domains []string, identity string, brand core.LarkBrand) []string { +// domainResolver answers auth domain and scope questions against one build's +// shortcut snapshot. The snapshot is a build-local input rather than a constant: +// a distribution assembled with cmd.WithCommandSets contributes business +// commands whose declared scopes must participate in --domain resolution, so +// every method here reads the snapshot it was constructed with instead of the +// built-in set. +type domainResolver struct { + registered []common.Shortcut +} + +func newDomainResolver(registered []common.Shortcut) domainResolver { + return domainResolver{registered: registered} +} + +// scopesFor collects API scopes (from from_meta projects) and shortcut scopes +// for the given domain names. +// Domains with auth_domain children are automatically expanded to include +// their children's scopes. +func (r domainResolver) scopesFor(domains []string, identity string, brand core.LarkBrand) []string { scopeSet := make(map[string]bool) // 1. API scopes from from_meta projects @@ -526,7 +553,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 r.registered { if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) { continue } @@ -549,17 +576,21 @@ func collectScopesForDomains(domains []string, identity string, brand core.LarkB // allKnownDomains returns all valid auth domain names (from_meta projects + // shortcut services), excluding domains that have auth_domain set (they are // folded into their parent domain). -func allKnownDomains(brand core.LarkBrand) map[string]bool { +func (r domainResolver) allKnown(brand core.LarkBrand) 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 r.registered { if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) { continue } + // No scope filter here: matching main, a scope-less domain (e.g. + // event) stays addressable via --domain and the --help list, and + // fails later with "no matching scopes found". Only the interactive + // selector hides it (see domainResolver.metadata). if !registry.HasAuthDomain(sc.Service) { domains[sc.Service] = true } @@ -567,9 +598,44 @@ func allKnownDomains(brand core.LarkBrand) map[string]bool { return domains } +func shortcutHasDeclaredScopes(shortcut common.Shortcut) bool { + for _, identity := range []string{"user", "bot"} { + if len(shortcut.DeclaredScopesForIdentity(identity)) > 0 { + return true + } + } + return false +} + +// scopelessShortcutOnlyDomains returns shortcut-only domains none of whose +// shortcuts declare any scope (e.g. event). The interactive selector hides +// them — picking one can only end in "no matching scopes found" — while +// --domain and the help list keep accepting them, matching main. +func (r domainResolver) scopeless() map[string]bool { + fromMeta := make(map[string]bool) + for _, p := range registry.ListFromMetaProjects() { + fromMeta[p] = true + } + hasScopes := make(map[string]bool) + seen := make(map[string]bool) + for _, sc := range r.registered { + seen[sc.Service] = true + if shortcutHasDeclaredScopes(sc) { + hasScopes[sc.Service] = true + } + } + scopeless := make(map[string]bool) + for service := range seen { + if !fromMeta[service] && !hasScopes[service] { + scopeless[service] = true + } + } + return scopeless +} + // sortedKnownDomains returns all valid domain names sorted alphabetically. -func sortedKnownDomains(brand core.LarkBrand) []string { - m := allKnownDomains(brand) +func (r domainResolver) sorted(brand core.LarkBrand) []string { + m := r.allKnown(brand) domains := make([]string, 0, len(m)) for d := range m { domains = append(domains, d) diff --git a/cmd/auth/login_brand_filter_test.go b/cmd/auth/login_brand_filter_test.go index b8eae24e53..e64d570607 100644 --- a/cmd/auth/login_brand_filter_test.go +++ b/cmd/auth/login_brand_filter_test.go @@ -7,26 +7,49 @@ import ( "testing" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/shortcuts" ) func TestBrandFilter_AppsExcludedOnLark(t *testing.T) { - feishuDomains := allKnownDomains(core.BrandFeishu) + feishuDomains := builtinResolver().allKnown(core.BrandFeishu) if !feishuDomains["apps"] { t.Errorf("expected apps domain to be known on Feishu brand") } - larkDomains := allKnownDomains(core.BrandLark) + larkDomains := builtinResolver().allKnown(core.BrandLark) if larkDomains["apps"] { t.Errorf("expected apps domain to be EXCLUDED on Lark brand") } - feishuScopes := collectScopesForDomains([]string{"apps"}, "user", core.BrandFeishu) + feishuScopes := builtinResolver().scopesFor([]string{"apps"}, "user", core.BrandFeishu) if len(feishuScopes) == 0 { t.Errorf("expected non-empty scopes for apps on Feishu brand, got %d", len(feishuScopes)) } - larkScopes := collectScopesForDomains([]string{"apps"}, "user", core.BrandLark) + larkScopes := builtinResolver().scopesFor([]string{"apps"}, "user", core.BrandLark) if len(larkScopes) != 0 { t.Errorf("expected empty scopes for apps on Lark brand, got %d: %v", len(larkScopes), larkScopes) } } + +func TestInteractiveDomainMetadataUsesActiveBrand(t *testing.T) { + registered := shortcuts.AllShortcuts() + feishuDomains := newDomainResolver(registered).metadata("en", core.BrandFeishu) + if !containsDomainMetadata(feishuDomains, "apps") { + t.Fatal("apps domain is missing for Feishu interactive login") + } + + larkDomains := newDomainResolver(registered).metadata("en", core.BrandLark) + if containsDomainMetadata(larkDomains, "apps") { + t.Fatal("apps domain is present for Lark interactive login") + } +} + +func containsDomainMetadata(domains []domainMeta, name string) bool { + for _, domain := range domains { + if domain.Name == name { + return true + } + } + return false +} diff --git a/cmd/auth/login_interactive.go b/cmd/auth/login_interactive.go index 70e01065cb..998152762e 100644 --- a/cmd/auth/login_interactive.go +++ b/cmd/auth/login_interactive.go @@ -15,7 +15,6 @@ import ( "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/registry" - "github.com/larksuite/cli/shortcuts" ) // domainMeta describes a domain for the interactive selector. @@ -31,46 +30,16 @@ type interactiveResult struct { ScopeLevel string // "common" or "all" } -// getDomainMetadata returns metadata for all known domains, sorted by name. -func getDomainMetadata(lang string) []domainMeta { - seen := make(map[string]bool) - var domains []domainMeta - - // 1. Domains from from_meta projects (skip domains with auth_domain) - for _, project := range registry.ListFromMetaProjects() { - if registry.HasAuthDomain(project) { - seen[project] = true +// metadata returns metadata for all known domains, sorted by name. +func (r domainResolver) metadata(lang string, brand core.LarkBrand) []domainMeta { + known := r.allKnown(brand) + scopeless := r.scopeless() + domains := make([]domainMeta, 0, len(known)) + for name := range known { + if scopeless[name] { continue } - dm := buildDomainMeta(project, lang) - domains = append(domains, dm) - seen[project] = true - } - - // 2. Shortcut-only domains - shortcutOnlyNames := getShortcutOnlyDomainNames() - for _, name := range shortcutOnlyNames { - if !seen[name] { - dm := buildDomainMeta(name, lang) - domains = append(domains, dm) - seen[name] = true - } - } - - // 3. Auto-discover remaining shortcut services that are listed as shortcut-only domains - // (skip domains with auth_domain — they are folded into their parent) - shortcutOnlySet := make(map[string]bool) - for _, n := range shortcutOnlyNames { - shortcutOnlySet[n] = true - } - for _, sc := range shortcuts.AllShortcuts() { - if !seen[sc.Service] { - if shortcutOnlySet[sc.Service] && !registry.HasAuthDomain(sc.Service) { - dm := buildDomainMeta(sc.Service, lang) - domains = append(domains, dm) - } - seen[sc.Service] = true - } + domains = append(domains, buildDomainMeta(name, lang)) } sort.Slice(domains, func(i, j int) bool { @@ -101,9 +70,8 @@ func buildDomainMeta(name, lang string) domainMeta { return dm } -// runInteractiveLogin shows an interactive TUI form for domain and permission selection. -func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, brand core.LarkBrand) (*interactiveResult, error) { - allDomains := getDomainMetadata(lang) +func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, brand core.LarkBrand, resolver domainResolver) (*interactiveResult, error) { + allDomains := resolver.metadata(lang, brand) // Build multi-select options options := make([]huh.Option[string], len(allDomains)) @@ -162,7 +130,7 @@ func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, bra } // Compute scope summary - scopes := collectScopesForDomains(selectedDomains, "user", brand) + scopes := resolver.scopesFor(selectedDomains, "user", brand) if permLevel == "common" { scopes = registry.FilterAutoApproveScopes(scopes) } diff --git a/cmd/auth/login_messages.go b/cmd/auth/login_messages.go index 46fbd3c41a..294f8bf2d3 100644 --- a/cmd/auth/login_messages.go +++ b/cmd/auth/login_messages.go @@ -156,10 +156,3 @@ func getLoginMsg(lang i18n.Lang) *loginMsg { } return loginMsgZh } - -// getShortcutOnlyDomainNames returns domain names that exist only as shortcuts -// (not backed by from_meta service specs). Descriptions are now centralized in -// service_descriptions.json. -func getShortcutOnlyDomainNames() []string { - return []string{"application", "base", "contact", "docs", "markdown", "apps", "note"} -} diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index 50b7f3fc27..917bbacd10 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -11,13 +11,16 @@ import ( "fmt" "io" "net/http" + "reflect" "slices" "sort" "strings" "testing" + "github.com/larksuite/cli/extension/command" larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/commandhost" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/output" @@ -34,6 +37,21 @@ func (failWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") } +// builtinResolver resolves domains from the built-in shortcut set. Tests that +// are not specifically about external command sets assert against exactly what +// a distribution built without cmd.WithCommandSets sees. +func builtinResolver() domainResolver { + return newDomainResolver(shortcuts.AllShortcuts()) +} + +type businessArgs struct { + ChatID string `flag:"chat-id" schema:"required;minLength=1" doc:"chat identifier"` +} + +type businessData struct { + ChatID string `json:"chat_id" schema:"required" doc:"chat identifier"` +} + func TestSuggestDomain_PrefixMatch(t *testing.T) { known := map[string]bool{ "calendar": true, @@ -136,23 +154,25 @@ func TestShortcutSupportsIdentity_BotOnly(t *testing.T) { } func TestCompleteDomain(t *testing.T) { - projects := registry.ListFromMetaProjects() - if len(projects) == 0 { + want := builtinResolver().sorted("") + if len(want) == 0 { t.Skip("no from_meta data available") } // Complete from empty prefix - completions := completeDomain("") + completions := builtinResolver().complete("", "") if len(completions) == 0 { t.Fatal("expected completions for empty prefix") } - // All completions should match from_meta projects - if len(completions) != len(projects) { - t.Errorf("expected %d completions, got %d", len(projects), len(completions)) + if !reflect.DeepEqual(completions, want) { + t.Errorf("complete() = %v, want %v", completions, want) + } + if !slices.Contains(builtinResolver().complete("not", ""), "note") { + t.Error("complete() omitted shortcut-only note domain") } // Complete with partial prefix - completions = completeDomain("cal") + completions = builtinResolver().complete("cal", "") for _, c := range completions { if c != "calendar" && c[:3] != "cal" { t.Errorf("unexpected completion %q for prefix 'cal'", c) @@ -167,7 +187,7 @@ func TestCompleteDomain_CommaSeparated(t *testing.T) { } // After a comma, should complete the next segment - completions := completeDomain("calendar,") + completions := builtinResolver().complete("calendar,", "") for _, c := range completions { if c[:9] != "calendar," { t.Errorf("expected 'calendar,' prefix, got %q", c) @@ -176,7 +196,7 @@ func TestCompleteDomain_CommaSeparated(t *testing.T) { } func TestAllKnownDomains(t *testing.T) { - domains := allKnownDomains("") + domains := builtinResolver().allKnown("") if len(domains) == 0 { t.Fatal("expected non-empty known domains") } @@ -190,7 +210,7 @@ func TestAllKnownDomains(t *testing.T) { } func TestSortedKnownDomains(t *testing.T) { - sorted := sortedKnownDomains("") + sorted := builtinResolver().sorted("") if len(sorted) == 0 { t.Fatal("expected non-empty sorted domains") } @@ -200,14 +220,20 @@ func TestSortedKnownDomains(t *testing.T) { } // Should match allKnownDomains - known := allKnownDomains("") + known := builtinResolver().allKnown("") if len(sorted) != len(known) { t.Errorf("sorted (%d) and known (%d) length mismatch", len(sorted), len(known)) } } -func TestGetShortcutOnlyDomainNames_HaveDescriptions(t *testing.T) { - for _, name := range getShortcutOnlyDomainNames() { +func TestShortcutDomainsHaveDescriptions(t *testing.T) { + seen := make(map[string]struct{}) + for _, shortcut := range shortcuts.AllShortcuts() { + name := shortcut.Service + if _, duplicate := seen[name]; duplicate { + continue + } + seen[name] = struct{}{} zhDesc := registry.GetServiceDescription(name, "zh") enDesc := registry.GetServiceDescription(name, "en") if zhDesc == "" { @@ -219,10 +245,13 @@ 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 TestGetDomainMetadataIncludesNote(t *testing.T) { + for _, domain := range builtinResolver().metadata("zh", "") { + if domain.Name == "note" { + return + } } + t.Fatal("domain metadata must include note so auth login can select vc:note:read") } func TestCollectScopesForDomains(t *testing.T) { @@ -231,7 +260,7 @@ func TestCollectScopesForDomains(t *testing.T) { t.Skip("no from_meta data available") } - scopes := collectScopesForDomains([]string{"calendar"}, "user", "") + scopes := builtinResolver().scopesFor([]string{"calendar"}, "user", "") if len(scopes) == 0 { t.Fatal("expected non-empty scopes for calendar domain") } @@ -258,14 +287,14 @@ func TestCollectScopesForDomains(t *testing.T) { } func TestCollectScopesForDomains_NonexistentDomain(t *testing.T) { - scopes := collectScopesForDomains([]string{"nonexistent_domain_xyz"}, "user", "") + scopes := builtinResolver().scopesFor([]string{"nonexistent_domain_xyz"}, "user", "") if len(scopes) != 0 { t.Errorf("expected empty scopes for nonexistent domain, got %d", len(scopes)) } } func TestGetDomainMetadata_IncludesFromMeta(t *testing.T) { - domains := getDomainMetadata("zh") + domains := builtinResolver().metadata("zh", "") nameSet := make(map[string]bool) for _, dm := range domains { nameSet[dm.Name] = true @@ -279,22 +308,180 @@ func TestGetDomainMetadata_IncludesFromMeta(t *testing.T) { } } -func TestGetDomainMetadata_IncludesShortcutOnlyDomains(t *testing.T) { - domains := getDomainMetadata("zh") +func TestGetDomainMetadataIncludesAuthorizableShortcutDomains(t *testing.T) { + domains := builtinResolver().metadata("zh", "") nameSet := make(map[string]bool) for _, dm := range domains { nameSet[dm.Name] = true } - for _, name := range getShortcutOnlyDomainNames() { - if !nameSet[name] { - t.Errorf("shortcut-only domain %q missing from getDomainMetadata", name) + for _, shortcut := range shortcuts.AllShortcuts() { + if registry.HasAuthDomain(shortcut.Service) || !shortcutHasDeclaredScopes(shortcut) { + continue + } + if !nameSet[shortcut.Service] { + t.Errorf("authorizable shortcut domain %q missing from getDomainMetadata", shortcut.Service) + } + } +} + +func TestExternalShortcutScopesParticipateInAuthDomainResolution(t *testing.T) { + registered := []common.Shortcut{{ + Service: "im", Command: "+business-auth", AuthTypes: []string{"user"}, + UserScopes: []string{"im:business.scope:read"}, + }} + domains := newDomainResolver(registered).allKnown("") + if !domains["im"] { + t.Fatal("external shortcut domain is missing from auth domains") + } + scopes := newDomainResolver(registered).scopesFor([]string{"im"}, "user", "") + if !slices.Contains(scopes, "im:business.scope:read") { + t.Fatalf("external shortcut scope is missing: %v", scopes) + } +} + +// A distribution's business command must have its declared scopes reach +// auth login --domain. This walks the chain a real wrapper walks -- +// command.Define, commandhost.CompileSets, shortcuts.AllShortcutsWithExternal -- +// so dropping the snapshot anywhere along it fails here instead of shipping a +// login that cannot request the distribution's own scopes. The assertion goes +// through the login command's observable behaviour rather than an internal +// field, because the snapshot is deliberately not part of LoginOptions. +func TestCompiledBusinessScopesReachLoginDomainResolution(t *testing.T) { + const businessScope = "im:business.compiled:read" + + declaration := command.Define(command.Definition[businessArgs, businessData]{ + Metadata: command.CommandMetadata{ + Service: "im", Command: "+business-compiled", Description: "Business command", Risk: command.RiskRead, + Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{ + command.IdentityUser: {RequiredScopes: []string{businessScope}}, + }}, + }, + Hooks: command.Hooks[businessArgs, businessData]{ + Execute: func(_ context.Context, _ command.CommandContext, args *businessArgs) (command.Result[businessData], error) { + return command.Success(businessData{ChatID: args.ChatID}), nil + }, + }, + }) + compiled, err := commandhost.CompileSets([]command.Set{{ + Domain: command.ExtendDomain(command.DomainIm), + Commands: []command.Command{declaration}, + }}) + if err != nil { + t.Fatal(err) + } + registered, err := shortcuts.AllShortcutsWithExternal(compiled) + if err != nil { + t.Fatal(err) + } + + // Guard against a false positive: the scope must be absent from the + // built-in set, or this test would pass without the snapshot arriving. + if builtin := builtinResolver().scopesFor([]string{"im"}, "user", ""); slices.Contains(builtin, businessScope) { + t.Fatalf("%q is a built-in im scope, so it cannot prove the snapshot arrived", businessScope) + } + if scopes := newDomainResolver(registered).scopesFor([]string{"im"}, "user", ""); !slices.Contains(scopes, businessScope) { + t.Fatalf("compiled business scope %q never reached domain resolution: %v", businessScope, scopes) + } +} + +// One process can build several command trees, and each tree's login must +// resolve against the snapshot it was handed. A distribution's business scopes +// belong to that distribution alone, so a later build without command sets +// cannot inherit them -- the reason the snapshot is a constructor argument +// rather than package-level state. +func TestEachLoginBuildResolvesAgainstItsOwnSnapshot(t *testing.T) { + const businessScope = "im:business.isolated:read" + withBusiness := append(shortcuts.AllShortcuts(), common.Shortcut{ + Service: "im", Command: "+business-isolated", AuthTypes: []string{"user"}, + UserScopes: []string{businessScope}, + }) + + first := newDomainResolver(withBusiness) + second := newDomainResolver(shortcuts.AllShortcuts()) + + if scopes := first.scopesFor([]string{"im"}, "user", core.BrandFeishu); !slices.Contains(scopes, businessScope) { + t.Fatalf("first build lost its own business scope %q: %v", businessScope, scopes) + } + if scopes := second.scopesFor([]string{"im"}, "user", core.BrandFeishu); slices.Contains(scopes, businessScope) { + t.Fatalf("second build inherited the first build's business scope %q", businessScope) + } +} + +// The login command must resolve --domain against the snapshot it was +// constructed with. The help text is the observable projection of that snapshot, +// so a business command's domain has to survive into it. +func TestLoginHelpListsDomainsFromTheGivenSnapshot(t *testing.T) { + registered := append(shortcuts.AllShortcuts(), common.Shortcut{ + Service: "im", Command: "+business-help", AuthTypes: []string{"user"}, + UserScopes: []string{"im:business.help:read"}, + }) + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + usage := newCmdAuthLogin(f, nil, registered).Flag("domain").Usage + for _, want := range newDomainResolver(registered).sorted(core.BrandFeishu) { + if !strings.Contains(usage, want) { + t.Fatalf("--domain usage omits %q resolved from the snapshot:\n%s", want, usage) + } + } +} + +// The interactive selector shows exactly allKnownDomains minus scope-less +// shortcut-only domains (e.g. event). --domain and the help list keep +// accepting those, matching main: selecting a scope-less domain fails later +// with "no matching scopes found" instead of "unknown domain". +func TestGetDomainMetadataMatchesAllKnownDomainsMinusScopeless(t *testing.T) { + metadata := builtinResolver().metadata("zh", "") + known := builtinResolver().allKnown("") + scopeless := builtinResolver().scopeless() + if len(scopeless) == 0 { + t.Fatal("expected at least one scope-less domain (event) to exercise the filter") + } + if len(metadata) != len(known)-len(scopeless) { + t.Fatalf("domain metadata count = %d, want allKnownDomains (%d) minus scopeless (%d)", + len(metadata), len(known), len(scopeless)) + } + for _, domain := range metadata { + if !known[domain.Name] { + t.Errorf("domain metadata contains %q outside allKnownDomains", domain.Name) + } + if scopeless[domain.Name] { + t.Errorf("interactive selector lists scope-less domain %q", domain.Name) } } } +// A scope-less domain stays addressable via --domain (main behavior): it +// passes domain validation and fails later on scope resolution, not with +// "unknown domain". +func TestScopelessDomainStaysAddressableViaDomainFlag(t *testing.T) { + known := builtinResolver().allKnown("") + if !known["event"] { + t.Fatal("event must remain in allKnownDomains to match main behavior") + } + if scopes := builtinResolver().scopesFor([]string{"event"}, "user", ""); len(scopes) != 0 { + t.Fatalf("event scopes = %v, want none", scopes) + } +} + +func TestAuthLoginHelpMatchesKnownDomains(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + factory, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{}) + login := NewCmdAuthLogin(factory, nil) + domainFlag := login.Flags().Lookup("domain") + if domainFlag == nil { + t.Fatal("auth login --domain flag is missing") + } + names := builtinResolver().sorted("") + want := "available: " + strings.Join(names, ", ") + ", all" + if !strings.Contains(domainFlag.Usage, want) { + t.Fatalf("domain help = %q, want %q", domainFlag.Usage, want) + } +} + func TestGetDomainMetadata_Sorted(t *testing.T) { - domains := getDomainMetadata("zh") + domains := builtinResolver().metadata("zh", "") for i := 1; i < len(domains); i++ { if domains[i].Name < domains[i-1].Name { t.Errorf("not sorted: %q before %q", domains[i-1].Name, domains[i].Name) @@ -303,7 +490,7 @@ func TestGetDomainMetadata_Sorted(t *testing.T) { } func TestGetDomainMetadata_HasTitleAndDescription(t *testing.T) { - domains := getDomainMetadata("zh") + domains := builtinResolver().metadata("zh", "") for _, dm := range domains { if dm.Title == "" { t.Errorf("domain %q has empty Title", dm.Name) @@ -365,7 +552,7 @@ func TestAuthLoginRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) { }) // TestFactory has IsTerminal=false by default opts := &LoginOptions{Factory: f, Ctx: context.Background()} - err := authLoginRun(opts) + err := authLoginRun(opts, builtinResolver()) if err == nil { t.Fatal("expected error for non-terminal without flags") } @@ -414,7 +601,7 @@ func TestGenericUserAuthorizationStartCommandPassesLoginValidation(t *testing.T) Recommend: true, NoWait: true, JSON: true, - }) + }, builtinResolver()) if err != nil { t.Fatalf("generic recovery start command failed before returning a verification URL: %v", err) } @@ -766,7 +953,7 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T) Factory: f, Ctx: context.Background(), Scope: "im:message:send", - }) + }, builtinResolver()) if err == nil { t.Fatal("expected error, got nil") } @@ -883,7 +1070,7 @@ func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) { Ctx: context.Background(), Scope: "im:message:send", NoWait: true, - }) + }, builtinResolver()) if err != nil { t.Fatalf("no-wait authLoginRun() error = %v", err) } @@ -898,7 +1085,7 @@ func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) { Factory: f, Ctx: context.Background(), DeviceCode: "device-code", - }) + }, builtinResolver()) if err != nil { t.Fatalf("device-code authLoginRun() error = %v", err) } @@ -971,7 +1158,7 @@ func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) { Factory: f, Ctx: context.Background(), DeviceCode: "device-code", - }) + }, builtinResolver()) if err == nil { t.Fatal("expected error for nil token") } @@ -1024,7 +1211,7 @@ func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) { Ctx: context.Background(), Scope: "im:message:send", JSON: true, - }) + }, builtinResolver()) if err == nil { t.Fatal("expected error for aborted authorization") } @@ -1092,7 +1279,7 @@ func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) { Scope: "im:message:send", NoWait: true, JSON: true, - }) + }, builtinResolver()) if err == nil { t.Fatal("expected error") } @@ -1127,7 +1314,7 @@ func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) { Ctx: context.Background(), Scope: "im:message:send", NoWait: true, - }) + }, builtinResolver()) if err != nil { t.Fatalf("authLoginRun() error = %v", err) } @@ -1213,7 +1400,7 @@ func TestAuthLoginRun_NoWaitJSONHintPreservesExplicitProfile(t *testing.T) { Scope: "im:message:send", NoWait: true, JSON: true, - }); err != nil { + }, builtinResolver()); err != nil { t.Fatalf("authLoginRun() error = %v", err) } @@ -1269,7 +1456,7 @@ func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t * Ctx: ctx, Scope: "im:message:send", JSON: true, - }) + }, builtinResolver()) if err == nil { t.Fatal("expected error") } @@ -1306,7 +1493,7 @@ func TestAuthLoginRun_JSONDeviceAuthorizationAgentHintIncludesRawURLGuidance(t * Ctx: ctx, Scope: "im:message:send", JSON: true, - }) + }, builtinResolver()) if err == nil { t.Fatal("expected error from cancelled context") } @@ -1342,7 +1529,7 @@ func TestAuthLoginRun_JSONDeviceAuthorizationAgentHintIncludesRawURLGuidance(t * } func TestGetDomainMetadata_ExcludesEvent(t *testing.T) { - domains := getDomainMetadata("zh") + domains := builtinResolver().metadata("zh", "") for _, dm := range domains { if dm.Name == "event" { t.Error("event should not appear in interactive domain list") @@ -1351,7 +1538,7 @@ func TestGetDomainMetadata_ExcludesEvent(t *testing.T) { } func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) { - domains := allKnownDomains("") + domains := builtinResolver().allKnown("") if domains["whiteboard"] { t.Error("whiteboard should not appear in known auth domains (it has auth_domain=docs)") } @@ -1361,7 +1548,7 @@ func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) { } func TestCollectScopesForDomains_ExpandsAuthDomainChildren(t *testing.T) { - scopes := collectScopesForDomains([]string{"docs"}, "user", "") + scopes := builtinResolver().scopesFor([]string{"docs"}, "user", "") // docs domain should include whiteboard shortcut scopes (board:whiteboard:*) found := false for _, s := range scopes { @@ -1371,12 +1558,12 @@ func TestCollectScopesForDomains_ExpandsAuthDomainChildren(t *testing.T) { } } if !found { - t.Error("collectScopesForDomains([docs]) should include whiteboard scopes (board:whiteboard:*)") + t.Error("builtinResolver().scopesFor([docs]) should include whiteboard scopes (board:whiteboard:*)") } } func TestGetDomainMetadata_ExcludesAuthDomainChildren(t *testing.T) { - domains := getDomainMetadata("zh") + domains := builtinResolver().metadata("zh", "") for _, dm := range domains { if dm.Name == "whiteboard" { t.Error("whiteboard should not appear in interactive domain list (has auth_domain=docs)") diff --git a/cmd/build.go b/cmd/build.go index ae766bfdd5..9b30053f05 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -21,11 +21,13 @@ import ( "github.com/larksuite/cli/cmd/skill" cmdupdate "github.com/larksuite/cli/cmd/update" "github.com/larksuite/cli/cmd/whoami" + "github.com/larksuite/cli/extension/command" "github.com/larksuite/cli/internal/affordance" "github.com/larksuite/cli/internal/apicatalog" "github.com/larksuite/cli/internal/build" "github.com/larksuite/cli/internal/cmdpolicy" "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/commandhost" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/hook" "github.com/larksuite/cli/internal/keychain" @@ -36,6 +38,7 @@ import ( "github.com/larksuite/cli/internal/skillref" "github.com/larksuite/cli/internal/surface" "github.com/larksuite/cli/shortcuts" + "github.com/larksuite/cli/shortcuts/common" "github.com/spf13/cobra" ) @@ -55,6 +58,7 @@ type buildConfig struct { startupBrand core.LarkBrand startupBrandSet bool hideProfileSet bool + commandSets []command.Set } // buildRuntime owns presentation state for exactly one command tree. Factory @@ -159,6 +163,16 @@ func WithServiceCatalog(catalog apicatalog.Catalog) BuildOption { } } +// WithCommandSets adds build-time business commands to an independently built CLI. +// The supplied declarations are copied when this option is created and compiled +// as one atomic contribution during command-tree construction. +func WithCommandSets(sets ...command.Set) BuildOption { + captured := command.CloneSets(sets) + return func(c *buildConfig) { + c.commandSets = append(c.commandSets, command.CloneSets(captured)...) + } +} + // Build constructs the full command tree. It also installs registered // plugins and emits the Startup lifecycle event during assembly -- // so Plugin.On(Startup) handlers run even if the returned command is @@ -192,6 +206,18 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B return buildInternalWithConfig(ctx, inv, cfg) } +// resolveShortcutSnapshot compiles this build's business command sets and returns +// one snapshot carrying built-in and external shortcuts together. The error is +// returned rather than raised so the caller still mounts a root command able to +// report it; on failure the snapshot holds whatever the host could resolve. +func resolveShortcutSnapshot(sets []command.Set) ([]common.Shortcut, error) { + external, err := commandhost.CompileSets(sets) + if err != nil { + return shortcuts.AllShortcuts(), err + } + return shortcuts.AllShortcutsWithExternal(external) +} + // buildInternalWithConfig assembles one command tree from an already-applied // option snapshot. Execute uses this boundary so stateful BuildOptions are // never evaluated once for bootstrap inspection and a second time for Build. @@ -199,6 +225,7 @@ func buildInternalWithConfig(ctx context.Context, inv cmdutil.InvocationContext, if cfg == nil { cfg = &buildConfig{} } + registeredShortcuts, commandSetErr := resolveShortcutSnapshot(cfg.commandSets) // Default streams when WithIO is not supplied so the root command's // SetIn/Out/Err calls below don't deref nil. NewDefault also normalizes // partial streams internally; keep both in sync so cfg.streams reflects @@ -271,7 +298,7 @@ func buildInternalWithConfig(ctx context.Context, inv cmdutil.InvocationContext, } rootCmd.AddCommand(cmdconfig.NewCmdConfigWithRecovery(f, runtime.recovery)) - rootCmd.AddCommand(auth.NewCmdAuthWithRecovery(f, runtime.recovery)) + rootCmd.AddCommand(auth.NewCmdAuthWithRecoveryAndShortcuts(f, runtime.recovery, registeredShortcuts)) rootCmd.AddCommand(profile.NewCmdProfile(f)) rootCmd.AddCommand(doctor.NewCmdDoctorWithRecovery(f, runtime.recovery)) rootCmd.AddCommand(whoami.NewCmdWhoamiWithRecovery(f, runtime.recovery)) @@ -290,7 +317,11 @@ func buildInternalWithConfig(ctx context.Context, inv cmdutil.InvocationContext, service.RegisterServiceCommandsWithContext(ctx, rootCmd, f) } } - shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f) + shortcuts.RegisterShortcutSnapshotWithContext(ctx, rootCmd, f, registeredShortcuts) + if commandSetErr != nil { + installCommandSetErrorGuard(rootCmd, commandSetErr) + return finalizeFailedBuild(runtime, rootCmd) + } classifyRootCommands(rootCmd) diff --git a/cmd/command_sets_test.go b/cmd/command_sets_test.go new file mode 100644 index 0000000000..29f9df7176 --- /dev/null +++ b/cmd/command_sets_test.go @@ -0,0 +1,206 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "bytes" + "context" + "errors" + "os" + "os/exec" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/command" + "github.com/larksuite/cli/extension/platform" +) + +type businessArgs struct { + ChatID string `flag:"chat-id" schema:"required;minLength=1" doc:"chat identifier"` +} + +type businessData struct { + ChatID string `json:"chat_id" schema:"required" doc:"chat identifier"` +} + +func businessCommand(name string, executed *bool) command.Command { + return command.Define(command.Definition[businessArgs, businessData]{ + Metadata: command.CommandMetadata{ + Service: "im", Command: name, Description: "Business command", Risk: command.RiskRead, + Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{ + command.IdentityUser: {RequiredScopes: []string{"im:chat:read"}}, + }}, + }, + Hooks: command.Hooks[businessArgs, businessData]{ + DryRun: func(_ context.Context, _ command.CommandContext, args *businessArgs) *command.DryRun { + return command.NewDryRun(command.GET("/open-apis/im/v1/chats/" + args.ChatID)) + }, + Execute: func(_ context.Context, _ command.CommandContext, args *businessArgs) (command.Result[businessData], error) { + if executed != nil { + *executed = true + } + return command.Success(businessData{ChatID: args.ChatID}), nil + }, + }, + }) +} + +func TestWithCommandSetsInIsolatedProcesses(t *testing.T) { + 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) + output, err := process.CombinedOutput() + if err != nil { + t.Fatalf("scenario %s failed: %v\n%s", scenario, err, output) + } + }) + } +} + +func TestFailedBuildDoesNotAffectNextBuild(t *testing.T) { + tmpHome(t) + platform.ResetForTesting() + t.Cleanup(platform.ResetForTesting) + platform.Register(&failingPlugin{ + name: "command-set-failure", + caps: platform.Capabilities{FailurePolicy: platform.FailClosed}, + err: errors.New("install failure after command assembly"), + }) + + failed := Build(context.Background(), buildInvocationForTest(t), + WithCommandSets(command.Set{ + Domain: command.ExtendDomain(command.DomainIm), + Commands: []command.Command{businessCommand("+business-failed-build", nil)}, + }), + WithoutStrictMode(), WithoutServiceCommands(), + ) + if findCommand(failed, "im +business-failed-build") == nil || failed.PersistentPreRunE == nil { + t.Fatal("failed build did not reach the post-mount plugin guard") + } + + platform.ResetForTesting() + clean := Build(context.Background(), buildInvocationForTest(t), WithoutPlugins(), WithoutStrictMode(), WithoutServiceCommands()) + if findCommand(clean, "im +business-failed-build") != nil { + t.Fatal("clean build contains a command from an earlier failed build") + } +} + +func TestCommandSetSubprocess(t *testing.T) { + scenario := os.Getenv("LARK_CLI_COMMAND_SET_SCENARIO") + if scenario == "" { + t.Skip("subprocess helper") + } + tmpHome(t) + switch scenario { + case "official": + root := Build(context.Background(), buildInvocationForTest(t), WithoutPlugins(), WithoutStrictMode(), WithoutServiceCommands()) + if findCommand(root, "im +business-official") != nil { + t.Fatal("official command tree contains an external command") + } + case "mount": + commands := []command.Command{businessCommand("+business-captured", nil)} + option := WithCommandSets(command.Set{Domain: command.ExtendDomain(command.DomainIm), Commands: commands}) + commands[0] = businessCommand("+business-mutated", nil) + root := Build(context.Background(), buildInvocationForTest(t), option, WithoutPlugins(), WithoutStrictMode(), WithoutServiceCommands()) + leaf := findCommand(root, "im +business-captured") + if leaf == nil { + t.Fatal("captured business command is missing") + } + if findCommand(root, "im +business-mutated") != nil { + t.Fatal("mutation after WithCommandSets changed the command tree") + } + leaf.InitDefaultHelpFlag() + var help strings.Builder + leaf.SetOut(&help) + leaf.SetErr(&help) + if err := leaf.Help(); err != nil { + t.Fatal(err) + } + if rendered := help.String(); !strings.Contains(rendered, "Risk: read") { + t.Fatalf("business metadata is missing from help:\n%s", rendered) + } + case "atomic": + set := command.Set{Domain: command.ExtendDomain(command.DomainIm), Commands: []command.Command{ + businessCommand("+business-valid", nil), businessCommand("+business-valid", nil), + }} + root := Build(context.Background(), buildInvocationForTest(t), WithCommandSets(set), WithoutPlugins(), WithoutStrictMode(), WithoutServiceCommands()) + if findCommand(root, "im +business-valid") != nil { + t.Fatal("part of an invalid contribution was mounted") + } + if root.PersistentPreRunE == nil { + t.Fatal("invalid contribution did not install a startup guard") + } + err := root.PersistentPreRunE(root, nil) + var validation *errs.ValidationError + if !errors.As(err, &validation) || validation.Subtype != errs.SubtypeFailedPrecondition { + t.Fatalf("startup error = %T %v", err, err) + } + if !strings.Contains(err.Error(), "conflicts") { + t.Fatalf("startup error omitted command conflict: %v", err) + } + case "governance": + executed := false + registerRestriction(t, []string{"im/+business-governed"}, nil) + root := Build(context.Background(), buildInvocationForTest(t), + WithCommandSets(command.Set{ + Domain: command.ExtendDomain(command.DomainIm), + Commands: []command.Command{businessCommand("+business-governed", &executed)}, + }), + WithoutStrictMode(), WithoutServiceCommands(), + ) + leaf := findCommand(root, "im +business-governed") + if leaf == nil || leaf.RunE == nil { + t.Fatal("governed business command is missing") + } + err := leaf.RunE(leaf, nil) + var validation *errs.ValidationError + if !errors.As(err, &validation) || validation.Subtype != errs.SubtypeFailedPrecondition { + t.Fatalf("governance error = %T %v", err, err) + } + 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()) + } + // The schema command serves the generated API catalog only; mounted + // shortcuts (external commands included) must stay invisible to it. + stdout.Reset() + stderr.Reset() + root.SetArgs([]string{"__complete", "schema", "im", "+business-"}) + if _, err := root.ExecuteC(); err != nil { + t.Fatalf("complete schema path: %v\nstderr: %s", err, stderr.String()) + } + if strings.Contains(stdout.String(), "+business-surface") { + t.Fatalf("schema completion leaked an external command: %s", stdout.String()) + } + stdout.Reset() + stderr.Reset() + root.SetArgs([]string{"schema", "im", "+business-surface"}) + if _, err := root.ExecuteC(); err == nil { + t.Fatalf("schema resolved an external command: %s", stdout.String()) + } + if strings.Contains(stdout.String(), "inputSchema") { + t.Fatalf("schema rendered an external command contract: %s", stdout.String()) + } + default: + t.Fatalf("unknown scenario %q", scenario) + } +} diff --git a/cmd/error_auth_hint.go b/cmd/error_auth_hint.go index 3d0d3f102a..1593d08715 100644 --- a/cmd/error_auth_hint.go +++ b/cmd/error_auth_hint.go @@ -9,12 +9,11 @@ import ( "github.com/spf13/cobra" "github.com/larksuite/cli/internal/apicatalog" + "github.com/larksuite/cli/internal/cmdmeta" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/recovery" "github.com/larksuite/cli/internal/registry" - "github.com/larksuite/cli/shortcuts" - shortcutcommon "github.com/larksuite/cli/shortcuts/common" ) // presentRootError uses the same build-local presenter as shortcut result @@ -61,19 +60,7 @@ func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string if cmd == nil || cmd.Parent() == nil || !strings.HasPrefix(cmd.Name(), "+") { return nil } - - service := cmd.Parent().Name() - for _, sc := range shortcuts.AllShortcuts() { - if sc.Service != service || sc.Command != cmd.Name() || !shortcutSupportsIdentity(sc, identity) { - continue - } - scopes := sc.DeclaredScopesForIdentity(identity) - if len(scopes) == 0 { - return nil - } - return append([]string(nil), scopes...) - } - return nil + return cmdmeta.DeclaredScopes(cmd, identity) } // resolveDeclaredServiceMethodScopes returns the scopes declared by a @@ -109,18 +96,3 @@ func commandCatalogPath(cmd *cobra.Command) []string { } return path } - -// shortcutSupportsIdentity reports whether a shortcut supports the requested -// identity, applying the default user-only behavior when AuthTypes is empty. -func shortcutSupportsIdentity(sc shortcutcommon.Shortcut, identity string) bool { - authTypes := sc.AuthTypes - if len(authTypes) == 0 { - authTypes = []string{string(core.AsUser)} - } - for _, authType := range authTypes { - if authType == identity { - return true - } - } - return false -} diff --git a/cmd/error_presenter_test.go b/cmd/error_presenter_test.go index 3dbeadee2a..1df1d5223d 100644 --- a/cmd/error_presenter_test.go +++ b/cmd/error_presenter_test.go @@ -10,6 +10,7 @@ import ( "github.com/larksuite/cli/errs" internalauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdmeta" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/errclass" @@ -76,6 +77,7 @@ func TestRootErrorPresenterUsesDeclaredScopesForCanonicalPermissionRecovery(t *t agenda := &cobra.Command{Use: "+agenda"} root.AddCommand(calendar) calendar.AddCommand(agenda) + cmdmeta.SetDeclaredScopes(agenda, map[string][]string{"user": {declaredScope}}) f.CurrentCommand = agenda newSource := func(t *testing.T) (error, *errs.PermissionError) { diff --git a/cmd/exported_constructors_test.go b/cmd/exported_constructors_test.go new file mode 100644 index 0000000000..31952fef15 --- /dev/null +++ b/cmd/exported_constructors_test.go @@ -0,0 +1,54 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "testing" + + "github.com/larksuite/cli/cmd/auth" + "github.com/larksuite/cli/cmd/schema" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/spf13/cobra" +) + +// Constructors that existed before the command-extension work stay callable at +// their established signatures. Taking a shortcut snapshot is a new entry +// point, not a replacement: distributions outside this module already build +// command trees through these, and dropping one breaks them at compile time +// with no deprecation window. NewCmdAuthWithRecovery counts even though +// *recovery.Projector is internal -- an outside caller cannot name the type but +// can still pass nil for it. +// +// The constructors are invoked rather than merely referenced. Only an outside +// module calls them, so nothing inside the repository would otherwise reach +// them, and a signature-only assertion leaves them looking unreachable while +// also proving nothing about whether they still build a working command. +func TestPreExistingExportedConstructorsStillBuildCommands(t *testing.T) { + factory := &cmdutil.Factory{} + + assertCommand := func(name string, built *cobra.Command, use string) { + t.Helper() + if built == nil { + t.Fatalf("%s returned nil", name) + } + if built.Use != use { + t.Fatalf("%s built %q, want %q", name, built.Use, use) + } + if !built.HasSubCommands() && use == "auth" { + t.Fatalf("%s built no subcommands", name) + } + } + + assertCommand("NewCmdAuth", auth.NewCmdAuth(factory), "auth") + // nil projector: an outside caller cannot name *recovery.Projector but can + // pass nil, which is exactly the call this wrapper exists to keep compiling. + assertCommand("NewCmdAuthWithRecovery", auth.NewCmdAuthWithRecovery(factory, nil), "auth") + assertCommand("NewCmdAuthLogin", auth.NewCmdAuthLogin(factory, nil), "login") + + visibility := schema.CommandVisibility(func([]string) bool { return true }) + assertCommand("NewCmdSchema", schema.NewCmdSchema(factory, nil), + "schema [path | service resource method]") + assertCommand("NewCmdSchemaWithVisibility", schema.NewCmdSchemaWithVisibility(factory, visibility, nil), + "schema [path | service resource method]") +} diff --git a/cmd/platform_guards.go b/cmd/platform_guards.go index f3ed664e30..4bdda73836 100644 --- a/cmd/platform_guards.go +++ b/cmd/platform_guards.go @@ -90,6 +90,16 @@ func installPluginInstallErrorGuard(rootCmd *cobra.Command, installErr error) { installFatalGuard(rootCmd, makeErr) } +func installCommandSetErrorGuard(rootCmd *cobra.Command, compileErr error) { + makeErr := func() error { + return errs.NewValidationError(errs.SubtypeFailedPrecondition, + "business command contribution is invalid: %s", compileErr.Error()). + WithHint("fix the command declarations passed to cmd.WithCommandSets before starting this distribution"). + WithCause(compileErr) + } + installFatalGuard(rootCmd, makeErr) +} + // installPluginConflictGuard surfaces a Plugin.Restrict() configuration // error (single plugin invalid Rule or multiple plugins each contributing // Restrict). The hint separates the two failure modes by reason code: diff --git a/cmd/root.go b/cmd/root.go index 31e7020a11..a9509e6473 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -9,6 +9,7 @@ import ( "fmt" "io/fs" "os" + "os/signal" "sort" "strings" @@ -79,7 +80,8 @@ func executeWithOptions(opts []BuildOption) int { } configureFlagCompletions(os.Args) - ctx := context.Background() + ctx, stopSignals := newExecutionContext(context.Background()) + defer stopSignals() if deferProfileError { cfg.deferStartup = true } @@ -123,6 +125,10 @@ func executeWithOptions(opts []BuildOption) int { return 0 } +func newExecutionContext(parent context.Context) (context.Context, context.CancelFunc) { + return signal.NotifyContext(parent, os.Interrupt) +} + // isDeferredBootstrapProfileError identifies the one bootstrap parse failure // an explicitly concealed distribution may need the completed tree to render. // Default and legacy builds never defer it. diff --git a/cmd/root_context_test.go b/cmd/root_context_test.go new file mode 100644 index 0000000000..037273a6d5 --- /dev/null +++ b/cmd/root_context_test.go @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "testing" + "time" +) + +func TestExecutionContextFollowsParentAndStop(t *testing.T) { + parent, cancelParent := context.WithCancel(context.Background()) + ctx, stop := newExecutionContext(parent) + cancelParent() + select { + case <-ctx.Done(): + case <-time.After(time.Second): + t.Fatal("execution context did not follow parent cancellation") + } + stop() + + ctx, stop = newExecutionContext(context.Background()) + stop() + select { + case <-ctx.Done(): + case <-time.After(time.Second): + t.Fatal("execution context stop did not release signal subscription") + } +} diff --git a/cmd/root_test.go b/cmd/root_test.go index 21836ea66d..d952fed082 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -20,6 +20,7 @@ import ( "github.com/larksuite/cli/cmd/schema" "github.com/larksuite/cli/errs" internalauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdmeta" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/deprecation" @@ -609,6 +610,12 @@ func TestApplyNeedAuthorizationHint_ShortcutUsesDeclaredScopesWhenNoUAT(t *testi shortcutCmd := &cobra.Command{Use: "+create"} root.AddCommand(serviceCmd) serviceCmd.AddCommand(shortcutCmd) + cmdmeta.SetDeclaredScopes(shortcutCmd, map[string][]string{"user": { + "docx:document:create", + "docs:document.media:upload", + "docx:document:write_only", + "docx:document:readonly", + }}) f.CurrentCommand = shortcutCmd authErr := newAuthErrorWithNeedAuthMarker() @@ -644,6 +651,10 @@ func TestApplyNeedAuthorizationHint_ShortcutIncludesConditionalScopes(t *testing shortcutCmd := &cobra.Command{Use: "+status"} root.AddCommand(serviceCmd) serviceCmd.AddCommand(shortcutCmd) + cmdmeta.SetDeclaredScopes(shortcutCmd, map[string][]string{"user": { + "drive:drive.metadata:readonly", + "drive:file:download", + }}) f.CurrentCommand = shortcutCmd authErr := newAuthErrorWithNeedAuthMarker() @@ -680,6 +691,12 @@ func TestApplyNeedAuthorizationHint_AppendsExistingHint(t *testing.T) { shortcutCmd := &cobra.Command{Use: "+create"} root.AddCommand(serviceCmd) serviceCmd.AddCommand(shortcutCmd) + cmdmeta.SetDeclaredScopes(shortcutCmd, map[string][]string{"user": { + "docx:document:create", + "docs:document.media:upload", + "docx:document:write_only", + "docx:document:readonly", + }}) f.CurrentCommand = shortcutCmd authErr := newAuthErrorWithNeedAuthMarker() diff --git a/extension/command/command_test.go b/extension/command/command_test.go new file mode 100644 index 0000000000..b766c27dc1 --- /dev/null +++ b/extension/command/command_test.go @@ -0,0 +1,482 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package command + +import ( + "context" + "errors" + "go/parser" + "go/token" + "io" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" + + "github.com/larksuite/cli/errs" +) + +type contractArgs struct { + ID string `flag:"id" schema:"required;minLength=1" doc:"resource ID"` +} + +type contractData struct { + ID string `json:"id" schema:"required" doc:"resource ID"` +} + +func TestDefineCopiesMutableMetadata(t *testing.T) { + scopes := []string{"im:chat:read"} + definition := Definition[contractArgs, contractData]{ + Metadata: CommandMetadata{ + Service: "im", Command: "+contract-copy", Description: "Copy test", Risk: RiskRead, + Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{ + IdentityUser: {RequiredScopes: scopes}, + }}, + }, + Hooks: Hooks[contractArgs, contractData]{ + Execute: func(_ context.Context, _ CommandContext, args *contractArgs) (Result[contractData], error) { + return Success(contractData{ID: args.ID}), nil + }, + }, + } + declared := Define(definition) + scopes[0] = "changed" + definition.Metadata.Authorization.Identities[IdentityUser] = IdentityAuthorization{} + + host := InspectCommand(declared) + if got := host.Metadata.Authorization.Identities[IdentityUser].RequiredScopes; !reflect.DeepEqual(got, []string{"im:chat:read"}) { + t.Fatalf("required scopes = %#v", got) + } +} + +func TestHostHooksRejectMismatchedErasedValues(t *testing.T) { + declaration := Define(Definition[contractArgs, contractData]{ + Metadata: CommandMetadata{ + Service: "im", Command: "+host-types", Description: "Host type checks", Risk: RiskRead, + Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}, + }, + Hooks: Hooks[contractArgs, contractData]{ + Normalize: func(context.Context, CommandContext, *contractArgs) error { return nil }, + Validate: func(context.Context, CommandContext, *contractArgs) error { return nil }, + DryRun: func(context.Context, CommandContext, *contractArgs) *DryRun { return NewDryRun() }, + Execute: func(context.Context, CommandContext, *contractArgs) (Result[contractData], error) { + return Success(contractData{}), nil + }, + PrettyRenderer: func(io.Writer, contractData) error { return nil }, + }, + }) + host := InspectCommand(declaration) + commandContext := NewCommandContext(ContextOptions{}) + wrong := &struct{}{} + assertInternal := func(name string, err error) { + t.Helper() + var internal *errs.InternalError + if !errors.As(err, &internal) || internal.Subtype != errs.SubtypeUnknown { + t.Fatalf("%s error = %#v", name, err) + } + } + + assertInternal("Normalize", host.Hooks.Normalize(context.Background(), commandContext, wrong)) + assertInternal("Validate", host.Hooks.Validate(context.Background(), commandContext, wrong)) + if dryRun := host.Hooks.DryRun(context.Background(), commandContext, wrong); dryRun != nil { + t.Fatalf("DryRun = %#v", dryRun) + } + _, err := host.Hooks.Execute(context.Background(), commandContext, wrong) + assertInternal("Execute", err) + assertInternal("renderer", host.Hooks.Renderers["pretty"](io.Discard, wrong)) +} + +func TestDefineCopiesNestedJSONValues(t *testing.T) { + defaultValue := map[string]any{"nested": []any{"original"}} + shapeValue := map[string]any{"state": "original"} + declaration := Define(Definition[contractArgs, contractData]{ + Metadata: CommandMetadata{ + Service: "im", Command: "+contract", Description: "Contract", Risk: RiskRead, + Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{ + IdentityUser: {RequiredScopes: []string{"im:chat:read"}}, + }}, + }, + Input: InputDefinition{Fields: []InputField{{ + Name: "id", Default: InputDefault{Set: true, Value: defaultValue}, Shape: ConstShape{Value: shapeValue}, + }}}, + Hooks: Hooks[contractArgs, contractData]{Execute: func(context.Context, CommandContext, *contractArgs) (Result[contractData], error) { + return Success(contractData{}), nil + }}, + }) + defaultValue["nested"].([]any)[0] = "mutated" + shapeValue["state"] = "mutated" + + definition := InspectCommand(declaration) + gotDefault := definition.Input.Fields[0].Default.Value.(map[string]any)["nested"].([]any)[0] + if gotDefault != "original" { + t.Fatalf("captured default = %v", gotDefault) + } + gotShape := definition.Input.Fields[0].Shape.(ConstShape).Value.(map[string]any)["state"] + if gotShape != "original" { + t.Fatalf("captured shape = %v", gotShape) + } +} + +func TestDefineCopiesShapePointersAndTypedJSONContainers(t *testing.T) { + minLength := 1 + maxLength := 64 + minItems := 1 + maxItems := 20 + minimum := int64(0) + maximum := 100.0 + defaultValue := map[string][]string{"ids": {"original"}} + declaration := Define(Definition[contractArgs, contractData]{ + Metadata: CommandMetadata{ + Service: "im", Command: "+contract-deep-copy", Description: "Deep copy", Risk: RiskRead, + Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}, + }, + Input: InputDefinition{Fields: []InputField{{ + Name: "id", + Shape: StringShape{MinLength: &minLength, MaxLength: &maxLength}, + Default: InputDefault{Set: true, Value: defaultValue}, + }}}, + Output: OutputDefinition{ + Data: DataDefinition{ + Shape: ArrayShape{ + Items: IntegerShape{Minimum: &minimum}, MinItems: &minItems, MaxItems: &maxItems, + }, + Overrides: []DataField{{Path: "/score", Shape: NumberShape{Maximum: &maximum}}}, + }, + }, + Hooks: Hooks[contractArgs, contractData]{Execute: func(context.Context, CommandContext, *contractArgs) (Result[contractData], error) { + return Success(contractData{}), nil + }}, + }) + + minLength = 2 + maxLength = 32 + minItems = 2 + maxItems = 10 + minimum = 1 + maximum = 50 + defaultValue["ids"][0] = "mutated" + + first := InspectCommand(declaration) + assertCopiedDefinitionValues(t, first) + *first.Input.Fields[0].Shape.(StringShape).MinLength = 9 + first.Input.Fields[0].Default.Value.(map[string][]string)["ids"][0] = "inspected" + + second := InspectCommand(declaration) + assertCopiedDefinitionValues(t, second) +} + +func assertCopiedDefinitionValues(t *testing.T, definition HostDefinition) { + t.Helper() + stringShape := definition.Input.Fields[0].Shape.(StringShape) + if *stringShape.MinLength != 1 || *stringShape.MaxLength != 64 { + t.Fatalf("string constraints = %#v", stringShape) + } + arrayShape := definition.Output.Data.Shape.(ArrayShape) + integerShape := arrayShape.Items.(IntegerShape) + if *arrayShape.MinItems != 1 || *arrayShape.MaxItems != 20 || *integerShape.Minimum != 0 { + t.Fatalf("array constraints = %#v, item constraints = %#v", arrayShape, integerShape) + } + numberShape := definition.Output.Data.Overrides[0].Shape.(NumberShape) + if *numberShape.Maximum != 100 { + t.Fatalf("number constraints = %#v", numberShape) + } + defaultValue := definition.Input.Fields[0].Default.Value.(map[string][]string) + if defaultValue["ids"][0] != "original" { + t.Fatalf("default value = %#v", defaultValue) + } +} + +func TestRequestCopiesNestedQueryAndBodyValues(t *testing.T) { + query := map[string][]string{"ids": {"original"}} + shared := []string{"first", "second"} + body := map[string]any{"items": []map[string]string{{"id": "original"}}} + request := GET("/open-apis/im/v1/chats"). + Params(map[string]any{"filter": query, "all": shared, "first": shared[:1]}). + Body(body) + + query["ids"][0] = "mutated" + body["items"].([]map[string]string)[0]["id"] = "mutated" + first := InspectRequest(request) + if got := first.Query["filter"].(map[string][]string)["ids"][0]; got != "original" { + t.Fatalf("query value = %q", got) + } + if got := len(first.Query["all"].([]string)); got != 2 { + t.Fatalf("full shared slice length = %d", got) + } + if got := len(first.Query["first"].([]string)); got != 1 { + t.Fatalf("short shared slice length = %d", got) + } + if got := first.Body.(map[string]any)["items"].([]map[string]string)[0]["id"]; got != "original" { + t.Fatalf("body value = %q", got) + } + + first.Query["filter"].(map[string][]string)["ids"][0] = "inspected" + first.Body.(map[string]any)["items"].([]map[string]string)[0]["id"] = "inspected" + second := InspectRequest(request) + if got := second.Query["filter"].(map[string][]string)["ids"][0]; got != "original" { + t.Fatalf("second query value = %q", got) + } + if got := second.Body.(map[string]any)["items"].([]map[string]string)[0]["id"]; got != "original" { + t.Fatalf("second body value = %q", got) + } +} + +func TestPublicOutputDefinitionExcludesFileArtifacts(t *testing.T) { + if _, present := reflect.TypeFor[OutputDefinition]().FieldByName("Artifacts"); present { + t.Fatal("OutputDefinition exposes file artifacts") + } +} + +func TestValueShapeClosedSet(t *testing.T) { + StringShape{}.valueShape() + BooleanShape{}.valueShape() + IntegerShape{}.valueShape() + NumberShape{}.valueShape() + NullShape{}.valueShape() + ConstShape{}.valueShape() + ArrayShape{}.valueShape() + ObjectShape{}.valueShape() + OneOfShape{}.valueShape() +} + +func TestRequestMethodsAndSameOriginValidation(t *testing.T) { + requests := []Request{ + GET("/open-apis/im/v1/chats"), POST("/open-apis/im/v1/chats"), + PUT("/open-apis/im/v1/chats/id"), PATCH("/open-apis/im/v1/chats/id"), DELETE("/open-apis/im/v1/chats/id"), + } + wantMethods := []string{"GET", "POST", "PUT", "PATCH", "DELETE"} + for index, request := range requests { + view := InspectRequest(request.Set("page_size", 20).Body(map[string]any{"name": "x"})) + if view.Method != wantMethods[index] { + t.Errorf("request %d method = %q", index, view.Method) + } + if err := ValidateRequestView(view); err != nil { + t.Errorf("request %d validation: %v", index, err) + } + } + for _, invalid := range []Request{ + GET("https://open.feishu.cn/open-apis/im/v1/chats"), + GET("/open-apis/../auth/v3/tenant_access_token/internal"), + GET("/internal/service"), + GET("/open-apis/im/v1/chats?token=x"), + } { + if err := ValidateRequestView(InspectRequest(invalid)); err == nil { + t.Errorf("request %#v passed same-origin validation", InspectRequest(invalid)) + } + } +} + +func TestDryRunBuilderSupportsEveryRequestMethodAndModifier(t *testing.T) { + dryRun := NewDryRun(). + GET("/open-apis/im/v1/chats"). + Set("page_size", 20). + Params(map[string]any{"page_size": 50}). + POST("/open-apis/im/v1/chats"). + Body(map[string]any{"name": "example"}). + PUT("/open-apis/im/v1/chats/chat_1"). + PATCH("/open-apis/im/v1/chats/chat_1"). + DELETE("/open-apis/im/v1/chats/chat_1") + view := InspectDryRun(dryRun) + if len(view.Requests) != 5 { + t.Fatalf("requests = %#v", view.Requests) + } + wantMethods := []string{"GET", "POST", "PUT", "PATCH", "DELETE"} + for index, request := range view.Requests { + if request.Method != wantMethods[index] { + t.Errorf("request %d method = %q", index, request.Method) + } + } + if view.Requests[0].Query["page_size"] != 50 { + t.Fatalf("GET query = %#v", view.Requests[0].Query) + } + if view.Requests[1].Body == nil { + t.Fatal("POST body is nil") + } +} + +func TestCollectPagesUsesHostPolicyAndMetadata(t *testing.T) { + responses := []map[string]any{ + {"items": []any{map[string]any{"id": "1"}}, "has_more": true, "page_token": "next"}, + {"items": []any{map[string]any{"id": "2"}}, "has_more": false}, + } + var calls []RequestView + ctx := NewCommandContext(ContextOptions{ + Identity: IdentityUser, + 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")) + if err != nil { + t.Fatal(err) + } + if len(page.Items) != 2 || page.Pages() != 2 || !page.Complete() || page.NextToken() != "" { + t.Fatalf("page = %#v, pages=%d complete=%v next=%q", page.Items, page.Pages(), page.Complete(), page.NextToken()) + } + if got := calls[1].Query["page_token"]; got != "next" { + t.Fatalf("second request page_token = %#v", got) + } + result := Success(page) + host := hostResult(result) + if host.Pagination == nil || host.Pagination.Pages != 2 || host.Pagination.Items != 2 || !host.Pagination.Complete { + t.Fatalf("host pagination = %#v", host.Pagination) + } +} + +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{ + Identity: IdentityUser, + DryRun: true, + CallJSON: func(context.Context, Request) (map[string]any, error) { + calls++ + return nil, nil + }, + PreflightScopes: func(...string) error { + calls++ + return nil + }, + CollectPages: func(context.Context, Request, bool) ([]map[string]any, HostPagination, error) { + calls++ + return nil, HostPagination{}, nil + }, + }) + if ctx.Identity() != IdentityUser { + t.Fatalf("identity = %q", ctx.Identity()) + } + if _, err := CallJSON[contractData](context.Background(), ctx, GET("/open-apis/im/v1/chats/id")); err == nil { + t.Fatal("CallJSON during dry-run succeeded") + } + if err := PreflightScopes(ctx, "im:chat:read"); err != nil { + t.Fatal(err) + } + if _, err := CollectPages[contractData](context.Background(), ctx, GET("/open-apis/im/v1/chats")); err == nil { + t.Fatal("CollectPages during dry-run succeeded") + } + if calls != 0 { + t.Fatalf("host callbacks during dry-run = %d", calls) + } +} + +func TestPublicPackageHasNoForbiddenImports(t *testing.T) { + files, err := filepath.Glob("*.go") + if err != nil { + t.Fatal(err) + } + for _, file := range files { + if strings.HasSuffix(file, "_test.go") { + continue + } + parsed, err := parser.ParseFile(token.NewFileSet(), file, nil, parser.ImportsOnly) + if err != nil { + t.Fatal(err) + } + for _, spec := range parsed.Imports { + importPath, err := strconv.Unquote(spec.Path.Value) + if err != nil { + t.Fatal(err) + } + if importPath == "github.com/larksuite/cli/cmd" || importPath == "github.com/larksuite/cli/shortcuts/common" || strings.Contains(importPath, "/internal/") { + t.Errorf("%s imports forbidden package %q", file, importPath) + } + } + } +} + +// PathSegment must keep one user value inside one path segment: separators, +// dot sequences, and query metacharacters cannot change the request target, +// and the escaped form must pass the same-origin validation. +func TestPathSegmentNeutralizesSeparatorsAndTraversal(t *testing.T) { + cases := map[string]string{ + "oc_plain": "oc_plain", + "a/b": "a%2Fb", + "..": "..", // url.PathEscape keeps dots; the ../ traversal form below is what must break + "../../outside": "..%2F..%2Foutside", + "a?x=1": "a%3Fx=1", + "a#frag": "a%23frag", + } + for input, want := range cases { + if got := PathSegment(input); got != want { + t.Errorf("PathSegment(%q) = %q, want %q", input, got, want) + } + } + // Traversal fails validation in both spellings: the validator decodes + // percent-encoding before the canonical check, so escaping cannot smuggle + // a dot sequence through, and raw concatenation is rejected outright. + for _, spelling := range []string{PathSegment("../../etc"), "../../etc"} { + request := GET("/open-apis/im/v1/chats/" + spelling) + if err := ValidateRequestView(InspectRequest(request)); err == nil { + t.Fatalf("traversal spelling %q must fail validation", spelling) + } + } + // A regular escaped ID stays valid. + ordinary := GET("/open-apis/im/v1/chats/" + PathSegment("oc_a b+c")) + if err := ValidateRequestView(InspectRequest(ordinary)); err != nil { + t.Fatalf("escaped ordinary id should validate: %v", err) + } +} + +// pageContext returns a context whose host callback replays the given pages. +func pageContext(pages []map[string]any) CommandContext { + return NewCommandContext(ContextOptions{ + Identity: IdentityUser, + CollectPages: func(_ context.Context, _ Request, _ bool) ([]map[string]any, HostPagination, error) { + return pages, HostPagination{Complete: true, Pages: len(pages)}, nil + }, + }) +} + +// Upstream list endpoints spell their array field differently (items, +// records, files, ...). Each page's single top-level array must normalize +// into Page.Items regardless of its name. +func TestCollectPagesNormalizesAnyTopLevelArrayField(t *testing.T) { + for _, field := range []string{"items", "records", "files"} { + pages := []map[string]any{ + {field: []any{map[string]any{"id": "1"}, map[string]any{"id": "2"}}, "has_more": false}, + } + page, err := CollectPages[contractData](context.Background(), pageContext(pages), GET("/open-apis/x/v1/list")) + if err != nil { + t.Fatalf("field %q: %v", field, err) + } + if len(page.Items) != 2 { + t.Fatalf("field %q: items = %d, want 2", field, len(page.Items)) + } + } +} + +// Zero or multiple top-level arrays must fail closed. Silently decoding +// nothing would let CollectAllPages report an empty-but-complete set and +// downstream writes would run against it. +func TestCollectPagesRejectsAmbiguousPageShapes(t *testing.T) { + cases := map[string][]map[string]any{ + "no array": {{"has_more": false, "page_token": ""}}, + "two arrays": {{"items": []any{}, "files": []any{}, "has_more": false}}, + "null-only page": {{"items": nil, "has_more": false}}, + } + for name, pages := range cases { + if _, err := CollectPages[contractData](context.Background(), pageContext(pages), GET("/open-apis/x/v1/list")); err == nil { + t.Errorf("%s: expected typed invalid-response error, got nil", name) + } + if _, err := CollectAllPages[contractData](context.Background(), pageContext(pages), GET("/open-apis/x/v1/list")); err == nil { + t.Errorf("%s: CollectAllPages must not report an empty complete set", name) + } + } +} diff --git a/extension/command/commandtest/business_commands_test.go b/extension/command/commandtest/business_commands_test.go new file mode 100644 index 0000000000..92b12cf9e9 --- /dev/null +++ b/extension/command/commandtest/business_commands_test.go @@ -0,0 +1,497 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package commandtest_test + +import ( + "context" + "errors" + "fmt" + "reflect" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/command" + "github.com/larksuite/cli/extension/command/commandtest" + "github.com/larksuite/cli/internal/commandhost" + internalpagination "github.com/larksuite/cli/internal/pagination" +) + +type documentGetArgs struct { + DocumentID string `flag:"document-id" schema:"required;minLength=1" doc:"document identifier"` +} + +type documentData struct { + Content string `json:"content" schema:"required" doc:"document text content"` +} + +func documentGetDefinition() command.Definition[documentGetArgs, documentData] { + request := func(args *documentGetArgs) command.Request { + return command.GET("/open-apis/docx/v1/documents/" + command.PathSegment(args.DocumentID) + "/raw_content") + } + return command.Definition[documentGetArgs, documentData]{ + Metadata: command.CommandMetadata{ + Service: "docs", Command: "+business-document-get", Description: "Read one document", Risk: command.RiskRead, + Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{ + command.IdentityUser: {RequiredScopes: []string{"docx:document:readonly"}}, + }}, + }, + Hooks: command.Hooks[documentGetArgs, documentData]{ + DryRun: func(_ context.Context, _ command.CommandContext, args *documentGetArgs) *command.DryRun { + return command.NewDryRun(request(args)) + }, + Execute: func(ctx context.Context, commandContext command.CommandContext, args *documentGetArgs) (command.Result[documentData], error) { + data, err := command.CallJSON[documentData](ctx, commandContext, request(args)) + if err != nil { + return command.Result[documentData]{}, err + } + return command.Success(data), nil + }, + }, + } +} + +type chatListArgs struct { + PageSize int `flag:"page-size" schema:"optional;default=20;minimum=1;maximum=100" doc:"items per page"` +} + +type chatData struct { + ChatID string `json:"chat_id" schema:"required" doc:"chat identifier"` + Name string `json:"name" schema:"required" doc:"chat name"` +} + +func chatListDefinition() command.Definition[chatListArgs, command.Page[chatData]] { + request := func(args *chatListArgs) command.Request { + return command.GET("/open-apis/im/v1/chats").Set("page_size", args.PageSize) + } + return command.Definition[chatListArgs, command.Page[chatData]]{ + Metadata: command.CommandMetadata{ + Service: "im", Command: "+business-chat-list", Description: "List chats", Risk: command.RiskRead, + Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{ + command.IdentityUser: {RequiredScopes: []string{"im:chat:read"}}, + }}, + }, + Hooks: command.Hooks[chatListArgs, command.Page[chatData]]{ + DryRun: func(_ context.Context, _ command.CommandContext, args *chatListArgs) *command.DryRun { + return command.NewDryRun(request(args)) + }, + 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)) + if err != nil { + return command.Result[command.Page[chatData]]{}, err + } + return command.Success(page), nil + }, + }, + } +} + +type taskAuditArgs struct { + IncludeOwners bool `flag:"include-owners" schema:"optional;default=false" doc:"include owner names"` +} + +type taskRecord struct { + TaskID string `json:"task_id" schema:"required" doc:"task identifier"` + OwnerID string `json:"owner_id" schema:"required" doc:"owner identifier"` +} + +type taskAuditItem struct { + TaskID string `json:"task_id" schema:"required" doc:"task identifier"` + OwnerName string `json:"owner_name" schema:"required" doc:"owner name"` + State string `json:"state" schema:"required;enum=success|failed" doc:"enrichment state"` +} + +type taskAuditData struct { + Items []taskAuditItem `json:"items" schema:"required;nonnullable" doc:"task audit results"` + Failures []command.Failure `json:"failures" schema:"required;nonnullable" doc:"safe failure details"` +} + +func taskAuditDefinition() command.Definition[taskAuditArgs, taskAuditData] { + listRequest := command.GET("/open-apis/task/v2/tasks").Set("page_size", 50) + return command.Definition[taskAuditArgs, taskAuditData]{ + Metadata: command.CommandMetadata{ + Service: "task", Command: "+business-task-audit", Description: "Audit tasks with optional owners", Risk: command.RiskRead, + Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{ + command.IdentityUser: { + RequiredScopes: []string{"task:task:read"}, + ConditionalScopes: []command.ConditionalScope{{ + Scopes: []string{"contact:user.base:readonly"}, When: "--include-owners is true", + Params: []string{"include-owners"}, Requirement: command.ScopeBestEffort, + }}, + }, + }}, + }, + Hooks: command.Hooks[taskAuditArgs, taskAuditData]{ + DryRun: func(_ context.Context, _ command.CommandContext, _ *taskAuditArgs) *command.DryRun { + return command.NewDryRun(listRequest).Desc("Owner requests depend on task owner identifiers returned by the list call.") + }, + 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.Result[taskAuditData]{}, err + } + data := taskAuditData{Items: make([]taskAuditItem, 0, len(tasks))} + if !args.IncludeOwners { + for _, task := range tasks { + data.Items = append(data.Items, taskAuditItem{TaskID: task.TaskID, State: "success"}) + } + return command.Success(data), nil + } + if err := command.PreflightScopes(commandContext, "contact:user.base:readonly"); err != nil { + for _, task := range tasks { + data.Items = append(data.Items, taskAuditItem{TaskID: task.TaskID, State: "failed"}) + } + data.Failures = append(data.Failures, command.SnapshotFailure(err)) + return command.Success(data), nil + } + for _, task := range tasks { + owner, ownerErr := command.CallJSON[struct { + Name string `json:"name"` + }](ctx, commandContext, command.GET("/open-apis/contact/v3/users/"+command.PathSegment(task.OwnerID))) + if ownerErr != nil { + data.Items = append(data.Items, taskAuditItem{TaskID: task.TaskID, State: "failed"}) + data.Failures = append(data.Failures, command.SnapshotFailure(ownerErr)) + continue + } + data.Items = append(data.Items, taskAuditItem{TaskID: task.TaskID, OwnerName: owner.Name, State: "success"}) + } + return command.Success(data), nil + }, + }, + } +} + +type memberListArgs struct { + ChatID string `flag:"chat-id" schema:"required;minLength=1" doc:"chat identifier"` + IncludeMembers bool `flag:"include-members" schema:"optional;default=false" doc:"include member identifiers"` +} + +type memberListData struct { + ChatID string `json:"chat_id" schema:"required" doc:"chat identifier"` + Members []string `json:"members" schema:"required;nonnullable" doc:"member identifiers"` +} + +func memberListDefinition() command.Definition[memberListArgs, memberListData] { + return command.Definition[memberListArgs, memberListData]{ + Metadata: command.CommandMetadata{ + Service: "im", Command: "+business-chat-inspect", Description: "Inspect a chat", Risk: command.RiskRead, + Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{ + command.IdentityUser: { + RequiredScopes: []string{"im:chat:read"}, + ConditionalScopes: []command.ConditionalScope{{ + Scopes: []string{"im:chat.members:read"}, When: "--include-members is true", + Params: []string{"include-members"}, Requirement: command.ScopeRequired, + }}, + }, + }}, + }, + Hooks: command.Hooks[memberListArgs, memberListData]{ + DryRun: func(_ context.Context, _ command.CommandContext, args *memberListArgs) *command.DryRun { + preview := command.NewDryRun(command.GET("/open-apis/im/v1/chats/" + command.PathSegment(args.ChatID))) + if args.IncludeMembers { + preview.Add(command.GET("/open-apis/im/v1/chats/" + command.PathSegment(args.ChatID) + "/members")) + } + return preview + }, + 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/"+command.PathSegment(args.ChatID))) + 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.Result[memberListData]{}, err + } + members, err := command.CallJSON[struct { + Items []string `json:"items"` + }](ctx, commandContext, command.GET("/open-apis/im/v1/chats/"+command.PathSegment(args.ChatID)+"/members")) + if err != nil { + return command.Result[memberListData]{}, err + } + data.Members = members.Items + return command.Success(data), nil + }, + }, + } +} + +func TestBusinessDefinitionsCompileTogether(t *testing.T) { + sets := []command.Set{ + {Domain: command.ExtendDomain(command.DomainDocs), Commands: []command.Command{command.Define(documentGetDefinition())}}, + {Domain: command.ExtendDomain(command.DomainIm), Commands: []command.Command{command.Define(chatListDefinition()), command.Define(memberListDefinition())}}, + {Domain: command.ExtendDomain(command.DomainTask), Commands: []command.Command{command.Define(taskAuditDefinition())}}, + } + compiled, err := commandhost.CompileSets(sets) + if err != nil { + t.Fatal(err) + } + if len(compiled) != 4 { + t.Fatalf("compiled commands = %d", len(compiled)) + } +} + +func TestSingleReadAndDryRunUseSameRequest(t *testing.T) { + definition := documentGetDefinition() + recorder := commandtest.New(t, commandtest.Respond(map[string]any{"content": "example"})) + args := &documentGetArgs{DocumentID: "doc_1"} + execution, err := commandtest.Execute(context.Background(), recorder, command.IdentityUser, definition, args) + if err != nil { + t.Fatal(err) + } + if execution.Data.Content != "example" { + t.Fatalf("document data = %#v", execution.Data) + } + preview, err := commandtest.Preview(context.Background(), recorder, command.IdentityUser, definition, args) + if err != nil { + t.Fatal(err) + } + recorder.AssertDryRunMatches(preview) + 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{ + "items": []map[string]any{{"chat_id": "chat_1", "name": "one"}}, "has_more": true, "page_token": "next", + }), + commandtest.Respond(map[string]any{ + "items": []map[string]any{{"chat_id": "chat_2", "name": "two"}}, "has_more": false, + }), + ) + 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) + } + if len(execution.Data.Items) != 2 || !execution.Data.Complete() || execution.Data.Pages() != 2 { + t.Fatalf("page = %#v, complete=%v, pages=%d", execution.Data.Items, execution.Data.Complete(), execution.Data.Pages()) + } + requests := recorder.Requests() + if len(requests) != 2 || requests[1].Query["page_token"] != "next" { + t.Fatalf("requests = %#v", requests) + } + 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) { + // Scripted from the shared bound, not a literal: a recorder that walked + // further than the production host adapter would let a business command + // pass its tests and then fail its first real --page-all run. + responses := make([]commandtest.Response, internalpagination.CollectAllHardPageBound) + 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) + } + var internal *errs.InternalError + if !errors.As(err, &internal) || internal.Subtype != errs.SubtypeQuotaExceeded { + t.Fatalf("hard-limit typed error = %#v", err) + } + if requests := recorder.Requests(); len(requests) != internalpagination.CollectAllHardPageBound || requests[len(requests)-1].Method != "GET" { + t.Fatalf("requests after incomplete read = %d, last=%#v", len(requests), requests[len(requests)-1]) + } + recorder.AssertScriptConsumed() +} + +func TestBestEffortScopeFailureMarksEveryItemFailed(t *testing.T) { + want := errs.NewPermissionError(errs.SubtypeMissingScope, "owner scope is unavailable") + recorder := commandtest.New(t, commandtest.Respond(map[string]any{ + "items": []map[string]any{ + {"task_id": "task_1", "owner_id": "user_1"}, + {"task_id": "task_2", "owner_id": "user_2"}, + }, + "has_more": false, + })) + recorder.SetScopeError(want) + execution, err := commandtest.Execute(context.Background(), recorder, command.IdentityUser, taskAuditDefinition(), &taskAuditArgs{IncludeOwners: true}) + if err != nil { + t.Fatal(err) + } + if len(execution.Data.Items) != 2 || len(execution.Data.Failures) != 1 { + t.Fatalf("execution = %#v", execution) + } + for _, item := range execution.Data.Items { + if item.State != "failed" { + t.Fatalf("items = %#v", execution.Data.Items) + } + } + if execution.Data.Failures[0].Subtype != string(errs.SubtypeMissingScope) { + t.Fatalf("failures = %#v", execution.Data.Failures) + } + if requests := recorder.Requests(); len(requests) != 1 || requests[0].Method != "GET" { + t.Fatalf("requests = %#v", requests) + } + recorder.AssertScriptConsumed() +} + +func TestMultiCallCommandRecordsTheFailedOwner(t *testing.T) { + wantFailure := command.InvalidResponseErrorf("owner record is unavailable") + recorder := commandtest.New(t, + commandtest.Respond(map[string]any{ + "items": []map[string]any{{"task_id": "task_1", "owner_id": "user_1"}}, "has_more": true, "page_token": "next", + }), + commandtest.Respond(map[string]any{ + "items": []map[string]any{{"task_id": "task_2", "owner_id": "user_2"}}, "has_more": false, + }), + commandtest.Respond(map[string]any{"name": "Owner One"}), + commandtest.Fail(wantFailure), + ) + execution, err := commandtest.Execute(context.Background(), recorder, command.IdentityUser, taskAuditDefinition(), &taskAuditArgs{IncludeOwners: true}) + if err != nil { + t.Fatal(err) + } + if len(execution.Data.Items) != 2 || len(execution.Data.Failures) != 1 { + t.Fatalf("execution = %#v", execution) + } + if execution.Data.Items[0].OwnerName != "Owner One" || execution.Data.Items[1].State != "failed" { + t.Fatalf("items = %#v", execution.Data.Items) + } + if got := recorder.ScopeChecks(); !reflect.DeepEqual(got, [][]string{{"contact:user.base:readonly"}}) { + t.Fatalf("scope checks = %#v", got) + } + requests := recorder.Requests() + if len(requests) != 4 || requests[1].Query["page_token"] != "next" { + t.Fatalf("requests = %#v", requests) + } + recorder.AssertScriptConsumed() +} + +func TestConditionalScopeBranchMatchesDryRun(t *testing.T) { + definition := memberListDefinition() + recorder := commandtest.New(t, + commandtest.Respond(map[string]any{"chat_id": "chat_1", "members": []string{}}), + commandtest.Respond(map[string]any{"items": []string{"user_1", "user_2"}}), + ) + args := &memberListArgs{ChatID: "chat_1", IncludeMembers: true} + execution, err := commandtest.Execute(context.Background(), recorder, command.IdentityUser, definition, args) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(execution.Data.Members, []string{"user_1", "user_2"}) { + t.Fatalf("members = %#v", execution.Data.Members) + } + if got := recorder.ScopeChecks(); !reflect.DeepEqual(got, [][]string{{"im:chat.members:read"}}) { + t.Fatalf("scope checks = %#v", got) + } + preview, err := commandtest.Preview(context.Background(), recorder, command.IdentityUser, definition, args) + if err != nil { + t.Fatal(err) + } + recorder.AssertDryRunMatches(preview) + recorder.AssertScriptConsumed() +} + +func TestConditionalScopeFailureStopsBranch(t *testing.T) { + want := errors.New("scope missing") + recorder := commandtest.New(t, commandtest.Respond(map[string]any{"chat_id": "chat_1", "members": []string{}})) + recorder.SetScopeError(want) + _, err := commandtest.Execute(context.Background(), recorder, command.IdentityUser, memberListDefinition(), &memberListArgs{ + ChatID: "chat_1", IncludeMembers: true, + }) + if !errors.Is(err, want) { + t.Fatalf("branch error = %v", err) + } + if len(recorder.Requests()) != 1 { + t.Fatalf("requests after scope failure = %#v", recorder.Requests()) + } + recorder.AssertScriptConsumed() +} diff --git a/extension/command/commandtest/commandtest.go b/extension/command/commandtest/commandtest.go new file mode 100644 index 0000000000..e551d7f348 --- /dev/null +++ b/extension/command/commandtest/commandtest.go @@ -0,0 +1,686 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package commandtest supplies an isolated runtime for business command tests. +package commandtest + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "reflect" + "sync" + "testing" + "time" + + "github.com/larksuite/cli/extension/command" + "github.com/larksuite/cli/internal/commandhost" + internalpagination "github.com/larksuite/cli/internal/pagination" + "github.com/spf13/pflag" +) + +// Response is one scripted OpenAPI response. +type Response struct { + data any + file *fileResponse + err error + expectedMethod string + expectedPath string + expectedURL string +} + +type fileResponse struct { + contentType string + content []byte +} + +// Respond creates a successful scripted response containing an OpenAPI data object. +func Respond(data any) Response { return Response{data: data} } + +// RespondFile creates a successful scripted file response. +func RespondFile(contentType string, content []byte) Response { + return Response{file: &fileResponse{contentType: contentType, content: append([]byte(nil), content...)}} +} + +// Fail creates a failed scripted response. +func Fail(err error) Response { return Response{err: err} } + +// Recorder supplies a restricted CommandContext and records its observable activity. +type Recorder struct { + testing testing.TB + + mu sync.Mutex + responses []Response + requests []command.RequestView + urls []string + files []RecordedFile + operations int + scopeChecks [][]string + scopeError error + pagination command.PaginationOptions + cancelAfterRequest int + cancel context.CancelFunc +} + +// New creates a Recorder with an ordered response script. +func New(testing testing.TB, responses ...Response) *Recorder { + testing.Helper() + return &Recorder{ + testing: testing, + responses: append([]Response(nil), responses...), + pagination: command.PaginationOptions{MaxPages: 1}, + } +} + +// 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 +} + +// ReplyFile appends an ordered successful file response with an expected +// request method and path. +func (r *Recorder) ReplyFile(method, path, contentType string, content []byte) *Recorder { + r.testing.Helper() + r.mu.Lock() + r.responses = append(r.responses, Response{ + file: &fileResponse{contentType: contentType, content: append([]byte(nil), content...)}, + expectedMethod: method, + expectedPath: path, + }) + r.mu.Unlock() + return r +} + +// ReplyURL appends an ordered successful direct-URL file response. +func (r *Recorder) ReplyURL(rawURL, contentType string, content []byte) *Recorder { + r.testing.Helper() + r.mu.Lock() + r.responses = append(r.responses, Response{ + file: &fileResponse{contentType: contentType, content: append([]byte(nil), content...)}, + expectedURL: rawURL, + }) + 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) +} + +// DryRunContext returns the network-free context the host gives a DryRun hook. +func (r *Recorder) DryRunContext(identity command.Identity) command.CommandContext { + return r.commandContext(identity, true) +} + +// InputStageContext returns the network-free context the host gives Normalize +// and Validate. Tests that drive those hooks directly must use it, or a command +// that calls the API before the high-risk confirmation gate passes its tests +// and fails only in production. +func (r *Recorder) InputStageContext(identity command.Identity) command.CommandContext { + return command.NewCommandContext(command.ContextOptions{ + Identity: identity, + InputStage: true, + PreflightScopes: r.preflightScopes, + }) +} + +func (r *Recorder) commandContext(identity command.Identity, dryRun bool) command.CommandContext { + return command.NewCommandContext(command.ContextOptions{ + Identity: identity, + DryRun: dryRun, + CallJSON: r.callJSON, + Download: r.download, + DownloadURL: r.downloadURL, + PreflightScopes: r.preflightScopes, + CollectPages: r.collectPages, + }) +} + +// Execution is the inspected outcome of one business Execute hook. +type Execution[Data any] struct { + Data Data +} + +// RecordedFile is one scripted download committed by the test runtime. +type RecordedFile struct { + Target command.FileTarget + Options command.DownloadOptions + SourceURL string + Artifact command.Artifact + Content []byte +} + +// Execute runs Normalize, Validate, and Execute with the restricted test runtime. +// compileForTest runs the production compiler and returns the erased view every +// entry point drives. Sharing it is what keeps a harness entry from silently +// skipping the contract checks a real CLI mount performs. +func compileForTest[Args any, Data any](definition command.Definition[Args, Data]) (command.HostDefinition, error) { + declaration := command.Define(definition) + if err := commandhost.ValidateDeclaration(declaration); err != nil { + return command.HostDefinition{}, err + } + return command.InspectCommand(declaration), nil +} + +func Execute[Args any, Data any](ctx context.Context, recorder *Recorder, identity command.Identity, definition command.Definition[Args, Data], args *Args) (Execution[Data], error) { + var execution Execution[Data] + declaration, err := compileForTest(definition) + if err != nil { + return execution, err + } + commandContext := recorder.CommandContext(identity) + inputContext := recorder.InputStageContext(identity) + if declaration.Hooks.Normalize != nil { + if err := declaration.Hooks.Normalize(ctx, inputContext, args); err != nil { + return execution, err + } + } + if declaration.Hooks.Validate != nil { + if err := declaration.Hooks.Validate(ctx, inputContext, args); err != nil { + return execution, err + } + } + if declaration.Hooks.Execute == nil { + return execution, errors.New("business command has no Execute hook") + } + 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 + } + // The same protocol the host adapter enforces. Without it a zero-value + // Result passes here -- generic erasure leaves a correctly typed zero Data + // behind, so the type assertion below succeeds and the test reports success + // for a command every real invocation would fail. + if err := command.ValidateHostResult(declaration, result); err != nil { + return execution, err + } + data, ok := result.Data.(Data) + if !ok { + return execution, fmt.Errorf("business Execute returned %T, expected %T", result.Data, execution.Data) + } + return Execution[Data]{Data: data}, 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) { + declaration, err := compileForTest(definition) + if err != nil { + return Execution[Data]{}, err + } + if !declaration.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 a network-free 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, err := compileForTest(definition) + if err != nil { + return nil, err + } + commandContext := recorder.DryRunContext(identity) + if declaration.Hooks.Normalize != nil { + if err := declaration.Hooks.Normalize(ctx, commandContext, args); err != nil { + return nil, err + } + } + if declaration.Hooks.Validate != nil { + if err := declaration.Hooks.Validate(ctx, commandContext, args); err != nil { + return nil, err + } + } + if declaration.Hooks.DryRun == nil { + return nil, errors.New("business command has no DryRun hook") + } + return declaration.Hooks.DryRun(ctx, commandContext, args), nil +} + +// ExecutionContext creates a cancellable context observed by scripted requests and pagination waits. +func (r *Recorder) ExecutionContext(parent context.Context) context.Context { + ctx, cancel := context.WithCancel(parent) + r.mu.Lock() + if r.cancel != nil { + r.cancel() + } + r.cancel = cancel + r.mu.Unlock() + r.testing.Cleanup(cancel) + return ctx +} + +// CancelAfterRequest injects cancellation immediately after the numbered request succeeds. +func (r *Recorder) CancelAfterRequest(number int) { + r.testing.Helper() + if number < 1 { + r.testing.Fatalf("request number must be positive: %d", number) + } + r.mu.Lock() + r.cancelAfterRequest = number + r.mu.Unlock() +} + +// SetPagination supplies host-owned pagination settings. +func (r *Recorder) SetPagination(options command.PaginationOptions) { + r.mu.Lock() + r.pagination = options + 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() + r.scopeError = err + r.mu.Unlock() +} + +// Requests returns copied requests in execution order. +func (r *Recorder) Requests() []command.RequestView { + r.mu.Lock() + defer r.mu.Unlock() + cloned, err := cloneRequestViews(r.requests) + if err != nil { + r.testing.Errorf("clone recorded requests: %v", err) + } + return cloned +} + +// Files returns copied file downloads in execution order. +func (r *Recorder) Files() []RecordedFile { + r.mu.Lock() + defer r.mu.Unlock() + files := make([]RecordedFile, len(r.files)) + for index, file := range r.files { + files[index] = file + files[index].Content = append([]byte(nil), file.Content...) + } + return files +} + +// URLs returns copied direct download URLs in execution order. +func (r *Recorder) URLs() []string { + r.mu.Lock() + defer r.mu.Unlock() + return append([]string(nil), r.urls...) +} + +// ScopeChecks returns copied scope preflights in execution order. +func (r *Recorder) ScopeChecks() [][]string { + r.mu.Lock() + defer r.mu.Unlock() + checks := make([][]string, len(r.scopeChecks)) + for index, scopes := range r.scopeChecks { + checks[index] = append([]string(nil), scopes...) + } + return checks +} + +// AssertScriptConsumed verifies that every scripted response was used. +func (r *Recorder) AssertScriptConsumed() { + r.testing.Helper() + r.mu.Lock() + remaining := len(r.responses) + r.mu.Unlock() + if remaining != 0 { + r.testing.Errorf("unused scripted responses: %d", remaining) + } +} + +// AssertDryRunMatches verifies method, path, query, body, count, and order. +func (r *Recorder) AssertDryRunMatches(dryRun *command.DryRun) { + r.testing.Helper() + claimed := command.InspectDryRun(dryRun).Requests + actual := r.Requests() + if len(claimed) != len(actual) { + r.testing.Errorf("dry-run request count = %d, executed request count = %d", len(claimed), len(actual)) + return + } + for index := range claimed { + claimedValue, err := comparableRequest(claimed[index]) + if err != nil { + r.testing.Errorf("dry-run request %d: %v", index+1, err) + continue + } + actualValue, err := comparableRequest(actual[index]) + if err != nil { + r.testing.Errorf("executed request %d: %v", index+1, err) + continue + } + if !reflect.DeepEqual(claimedValue, actualValue) { + r.testing.Errorf("dry-run request %d differs from executed request\nclaimed: %s\nexecuted: %s", index+1, claimedValue, actualValue) + } + } + claimedFiles := command.InspectDryRun(dryRun).Files + actualFiles := r.Files() + if len(claimedFiles) != len(actualFiles) { + r.testing.Errorf("dry-run file count = %d, executed download count = %d", len(claimedFiles), len(actualFiles)) + return + } + for index := range claimedFiles { + if claimedFiles[index].Name != actualFiles[index].Target.Name || claimedFiles[index].IfExists != actualFiles[index].Target.IfExists { + r.testing.Errorf("dry-run file %d target = %#v, executed target = %#v", index+1, claimedFiles[index], actualFiles[index].Target) + } + } +} + +func (r *Recorder) callJSON(ctx context.Context, request command.Request) (map[string]any, error) { + response, requestNumber, finish, err := r.nextResponse(ctx, request) + if err != nil { + return nil, err + } + if response.err != nil { + return nil, response.err + } + if response.file != nil { + return nil, fmt.Errorf("scripted response %d is a file, not a JSON data object", requestNumber) + } + data, err := responseDataObject(response.data) + if err != nil { + return nil, fmt.Errorf("scripted response %d: %w", requestNumber, err) + } + finish() + return data, nil +} + +func (r *Recorder) download(ctx context.Context, request command.Request, target command.FileTarget, options command.DownloadOptions) (command.Artifact, error) { + response, requestNumber, finish, err := r.nextResponse(ctx, request) + if err != nil { + return command.Artifact{}, err + } + if response.err != nil { + return command.Artifact{}, response.err + } + if response.file == nil { + return command.Artifact{}, fmt.Errorf("scripted response %d is JSON, not a file", requestNumber) + } + artifact := command.Artifact{ + Name: target.Name, Location: target.Name, + Size: int64(len(response.file.content)), ContentType: response.file.contentType, + } + r.mu.Lock() + r.files = append(r.files, RecordedFile{ + Target: target, Options: options, Artifact: artifact, Content: append([]byte(nil), response.file.content...), + }) + r.mu.Unlock() + finish() + return artifact, nil +} + +func (r *Recorder) downloadURL(ctx context.Context, rawURL string, target command.FileTarget, options command.DownloadOptions) (command.Artifact, error) { + response, requestNumber, finish, err := r.nextURLResponse(ctx, rawURL) + if err != nil { + return command.Artifact{}, err + } + if response.err != nil { + return command.Artifact{}, response.err + } + if response.file == nil { + return command.Artifact{}, fmt.Errorf("scripted response %d is JSON, not a file", requestNumber) + } + artifact := command.Artifact{ + Name: target.Name, Location: target.Name, + Size: int64(len(response.file.content)), ContentType: response.file.contentType, + } + r.mu.Lock() + r.files = append(r.files, RecordedFile{ + Target: target, Options: options, SourceURL: rawURL, + Artifact: artifact, Content: append([]byte(nil), response.file.content...), + }) + r.mu.Unlock() + finish() + return artifact, nil +} + +func (r *Recorder) nextResponse(ctx context.Context, request command.Request) (Response, int, func(), error) { + if err := ctx.Err(); err != nil { + return Response{}, 0, nil, err + } + view := command.InspectRequest(request) + cloned, cloneErr := cloneRequestView(view) + if cloneErr != nil { + r.testing.Errorf("clone executed request: %v", cloneErr) + } + r.mu.Lock() + r.requests = append(r.requests, cloned) + r.operations++ + requestNumber := r.operations + if len(r.responses) == 0 { + r.mu.Unlock() + return Response{}, 0, nil, fmt.Errorf("request %d has no scripted response", requestNumber) + } + response := r.responses[0] + r.responses = r.responses[1:] + cancel := r.cancel + shouldCancel := r.cancelAfterRequest == requestNumber + r.mu.Unlock() + if response.expectedMethod != "" && response.expectedMethod != view.Method { + return Response{}, 0, nil, fmt.Errorf("request %d method = %q, expected %q", requestNumber, view.Method, response.expectedMethod) + } + if response.expectedPath != "" && response.expectedPath != view.Path { + return Response{}, 0, nil, fmt.Errorf("request %d path = %q, expected %q", requestNumber, view.Path, response.expectedPath) + } + finish := func() { + if shouldCancel && cancel != nil { + cancel() + } + } + return response, requestNumber, finish, nil +} + +func (r *Recorder) nextURLResponse(ctx context.Context, rawURL string) (Response, int, func(), error) { + if err := ctx.Err(); err != nil { + return Response{}, 0, nil, err + } + r.mu.Lock() + r.urls = append(r.urls, rawURL) + r.operations++ + requestNumber := r.operations + if len(r.responses) == 0 { + r.mu.Unlock() + return Response{}, 0, nil, fmt.Errorf("request %d has no scripted response", requestNumber) + } + response := r.responses[0] + r.responses = r.responses[1:] + cancel := r.cancel + shouldCancel := r.cancelAfterRequest == requestNumber + r.mu.Unlock() + if response.expectedURL != "" && response.expectedURL != rawURL { + return Response{}, 0, nil, fmt.Errorf("request %d URL = %q, expected %q", requestNumber, rawURL, response.expectedURL) + } + finish := func() { + if shouldCancel && cancel != nil { + cancel() + } + } + return response, requestNumber, finish, 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 { + // Same bound as the production host adapter (commandPagePolicy), so a + // complete-set collection that passes here cannot fail only in production. + options = command.PaginationOptions{All: true, MaxPages: internalpagination.CollectAllHardPageBound} + } 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() + r.scopeChecks = append(r.scopeChecks, append([]string(nil), scopes...)) + return r.scopeError +} + +func responseDataObject(value any) (map[string]any, error) { + if value == nil { + return map[string]any{}, nil + } + encoded, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("encode data object: %w", err) + } + decoder := json.NewDecoder(bytes.NewReader(encoded)) + decoder.UseNumber() + var data map[string]any + if err := decoder.Decode(&data); err != nil { + return nil, fmt.Errorf("decode data object: %w", err) + } + if data == nil { + return nil, errors.New("response data must be a JSON object") + } + return data, nil +} + +func comparableRequest(request command.RequestView) (string, error) { + value := struct { + Method string `json:"method"` + Path string `json:"path"` + Query map[string]any `json:"query"` + Body any `json:"body,omitempty"` + }{Method: request.Method, Path: request.Path, Query: request.Query, Body: request.Body} + encoded, err := json.Marshal(value) + if err != nil { + return "", fmt.Errorf("encode comparable request: %w", err) + } + return string(encoded), nil +} + +func cloneRequestViews(requests []command.RequestView) ([]command.RequestView, error) { + cloned := make([]command.RequestView, len(requests)) + for index, request := range requests { + value, err := cloneRequestView(request) + if err != nil { + return cloned, fmt.Errorf("request %d: %w", index+1, err) + } + cloned[index] = value + } + return cloned, nil +} + +func cloneRequestView(request command.RequestView) (command.RequestView, error) { + encoded, err := json.Marshal(request) + if err != nil { + return request, fmt.Errorf("encode recorded request: %w", err) + } + decoder := json.NewDecoder(bytes.NewReader(encoded)) + decoder.UseNumber() + var cloned command.RequestView + if err := decoder.Decode(&cloned); err != nil { + return request, fmt.Errorf("decode recorded request: %w", err) + } + return cloned, nil +} diff --git a/extension/command/commandtest/commandtest_test.go b/extension/command/commandtest/commandtest_test.go new file mode 100644 index 0000000000..3cf02ca497 --- /dev/null +++ b/extension/command/commandtest/commandtest_test.go @@ -0,0 +1,403 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package commandtest + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strconv" + "strings" + "testing" + "time" + + "github.com/larksuite/cli/extension/command" + "github.com/larksuite/cli/extension/download" +) + +func TestRecorderScriptsRequestsScopesAndDryRun(t *testing.T) { + request := command.GET("/open-apis/im/v1/chats/chat_1").Set("user_id_type", "open_id") + recorder := New(t, Respond(map[string]any{"chat_id": "chat_1"})) + ctx := recorder.ExecutionContext(context.Background()) + commandContext := recorder.CommandContext(command.IdentityUser) + + if err := command.PreflightScopes(commandContext, "im:chat:read"); err != nil { + t.Fatal(err) + } + var data struct { + ChatID string `json:"chat_id"` + } + data, err := command.CallJSON[struct { + ChatID string `json:"chat_id"` + }](ctx, commandContext, request) + if err != nil { + t.Fatal(err) + } + if data.ChatID != "chat_1" { + t.Fatalf("chat ID = %q", data.ChatID) + } + if got := recorder.ScopeChecks(); !reflect.DeepEqual(got, [][]string{{"im:chat:read"}}) { + t.Fatalf("scope checks = %#v", got) + } + recorder.AssertDryRunMatches(command.NewDryRun(request)) + recorder.AssertScriptConsumed() +} + +func TestRecorderScriptsFileDownloadAndMatchesDryRunIntent(t *testing.T) { + request := command.GET("/open-apis/drive/v1/files/file_1/download").Set("version", "7") + target := command.FileTarget{Name: "reports/file.bin"} + recorder := New(t).ReplyFile("GET", "/open-apis/drive/v1/files/file_1/download", "application/octet-stream", []byte("payload")) + + options := command.DownloadOptions{Representation: download.Immutable, Transfer: download.Options{PartSize: 4}} + artifact, err := command.Download(context.Background(), recorder.CommandContext(command.IdentityUser), request, target, options) + if err != nil { + t.Fatal(err) + } + if artifact.Name != target.Name || artifact.Location != target.Name || artifact.Size != 7 || artifact.ContentType != "application/octet-stream" { + t.Fatalf("artifact = %#v", artifact) + } + files := recorder.Files() + if len(files) != 1 || string(files[0].Content) != "payload" || files[0].Target.Name != target.Name || !reflect.DeepEqual(files[0].Options, options) { + t.Fatalf("recorded files = %#v", files) + } + recorder.AssertDryRunMatches(command.NewDryRun(request).File(target.Intent("OpenAPI response body"))) + recorder.AssertScriptConsumed() +} + +func TestBusinessShortcutComposesOAPIAndURLDownload(t *testing.T) { + type args struct { + ID string `flag:"file-token" schema:"required;minLength=1" doc:"file token"` + Output string `flag:"output" schema:"required;minLength=1" doc:"output path"` + } + type descriptor struct { + DownloadURL string `json:"download_url"` + } + type data struct { + ID string `json:"id" schema:"required" doc:"file token"` + Artifact command.Artifact `json:"artifact" schema:"required" doc:"saved artifact"` + } + const sourceURL = "https://cdn.example.com/files/report.bin?signature=test" + request := func(args *args) command.Request { + return command.GET("/open-apis/drive/v1/files/" + command.PathSegment(args.ID) + "/download_url") + } + target := func(args *args) command.FileTarget { return command.FileTarget{Name: args.Output} } + definition := command.Definition[args, data]{ + Metadata: command.CommandMetadata{ + Service: command.DomainDrive, Command: "+test-backup", Description: "Back up one file", Risk: command.RiskWrite, + Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{command.IdentityUser: {}}}, + }, + Hooks: command.Hooks[args, data]{ + DryRun: func(_ context.Context, _ command.CommandContext, args *args) *command.DryRun { + return command.NewDryRun(request(args)).File(target(args).Intent("resolved URL response body")) + }, + Execute: func(ctx context.Context, commandContext command.CommandContext, args *args) (command.Result[data], error) { + resolved, err := command.CallJSON[descriptor](ctx, commandContext, request(args)) + if err != nil { + return command.Result[data]{}, err + } + artifact, err := command.DownloadURL(ctx, commandContext, resolved.DownloadURL, target(args), + command.DownloadOptions{Representation: download.Immutable}) + if err != nil { + return command.Result[data]{}, err + } + return command.Success(data{ID: args.ID, Artifact: artifact}), nil + }, + }, + } + input := &args{ID: "file_1", Output: "report.bin"} + recorder := New(t, Respond(map[string]any{"download_url": sourceURL}), RespondFile("application/octet-stream", []byte("payload"))) + execution, err := Execute(context.Background(), recorder, command.IdentityUser, definition, input) + if err != nil { + t.Fatal(err) + } + if execution.Data.ID != "file_1" || execution.Data.Artifact.Size != 7 || !reflect.DeepEqual(recorder.URLs(), []string{sourceURL}) { + t.Fatalf("execution = %#v, URLs = %#v", execution, recorder.URLs()) + } + files := recorder.Files() + if len(files) != 1 || files[0].SourceURL != sourceURL || string(files[0].Content) != "payload" { + t.Fatalf("recorded files = %#v", files) + } + preview, err := Preview(context.Background(), recorder, command.IdentityUser, definition, input) + if err != nil { + t.Fatal(err) + } + recorder.AssertDryRunMatches(preview) + recorder.AssertScriptConsumed() +} + +func TestRecorderRequestsAreDeepCopiedWithJSONNumbers(t *testing.T) { + body := map[string]any{"name": "original"} + request := command.POST("/open-apis/im/v1/chats").Set("page_size", 20).Body(body) + recorder := New(t, Respond(map[string]any{})) + if _, err := command.CallJSON[map[string]any](context.Background(), recorder.CommandContext(command.IdentityUser), request); err != nil { + t.Fatal(err) + } + body["name"] = "mutated" + + requests := recorder.Requests() + if len(requests) != 1 || requests[0].Query["page_size"] != json.Number("20") { + t.Fatalf("recorded query = %#v", requests) + } + recordedBody, ok := requests[0].Body.(map[string]any) + if !ok || recordedBody["name"] != "original" { + t.Fatalf("recorded body = %#v", requests[0].Body) + } +} + +func TestRecorderReturnsScriptedFailuresInOrder(t *testing.T) { + want := errors.New("scripted failure") + recorder := New(t, Fail(want), Respond(map[string]any{"id": "second"})) + commandContext := recorder.CommandContext(command.IdentityBot) + request := command.POST("/open-apis/base/v1/apps/app_1").Body(map[string]any{"name": "fixture"}) + + if _, err := command.CallJSON[map[string]any](context.Background(), commandContext, request); !errors.Is(err, want) { + t.Fatalf("first error = %v", err) + } + data, err := command.CallJSON[map[string]any](context.Background(), commandContext, request) + if err != nil { + t.Fatal(err) + } + if data["id"] != "second" { + t.Fatalf("second response = %#v", data) + } + 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 TestRecorderReplyURLChecksTheRequestedURLInOrder(t *testing.T) { + const first = "https://cdn.example.com/files/first.bin?signature=a" + const second = "https://cdn.example.com/files/second.bin?signature=b" + recorder := New(t). + ReplyURL(first, "application/octet-stream", []byte("one")). + ReplyURL(second, "image/png", []byte("two")) + commandContext := recorder.CommandContext(command.IdentityUser) + + for index, source := range []string{first, second} { + target := command.FileTarget{Name: "download-" + strconv.Itoa(index) + ".bin"} + artifact, err := command.DownloadURL(context.Background(), commandContext, source, target) + if err != nil { + t.Fatal(err) + } + if artifact.Name != target.Name || artifact.Size != 3 { + t.Fatalf("artifact %d = %#v", index+1, artifact) + } + } + if !reflect.DeepEqual(recorder.URLs(), []string{first, second}) { + t.Fatalf("URLs = %#v", recorder.URLs()) + } + files := recorder.Files() + if len(files) != 2 || files[0].SourceURL != first || files[1].SourceURL != second { + t.Fatalf("recorded files = %#v", files) + } + if files[0].Artifact.ContentType != "application/octet-stream" || files[1].Artifact.ContentType != "image/png" { + t.Fatalf("content types = %q / %q", files[0].Artifact.ContentType, files[1].Artifact.ContentType) + } + recorder.AssertScriptConsumed() +} + +func TestRecorderReplyURLRejectsUnexpectedURL(t *testing.T) { + recorder := New(t).ReplyURL("https://cdn.example.com/files/expected.bin", "application/octet-stream", []byte("payload")) + _, err := command.DownloadURL(context.Background(), recorder.CommandContext(command.IdentityUser), + "https://cdn.example.com/files/other.bin", command.FileTarget{Name: "download.bin"}) + if err == nil || !strings.Contains(err.Error(), "expected") { + t.Fatalf("DownloadURL() 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.Minute}) + recorder.CancelAfterRequest(1) + ctx := recorder.ExecutionContext(context.Background()) + + _, err := command.CollectPages[struct { + ID string `json:"id"` + }](ctx, recorder.CommandContext(command.IdentityUser), command.GET("/open-apis/contact/v3/users")) + if err == nil || !errors.Is(err, context.Canceled) { + t.Fatalf("pagination error = %v", err) + } + recorder.AssertScriptConsumed() +} + +func TestExecuteRunsPreparationAndReturnsTypedData(t *testing.T) { + type args struct { + ID string `flag:"id" schema:"required" doc:"identifier"` + } + type data struct { + ID string `json:"id" schema:"required" doc:"identifier"` + } + definition := command.Definition[args, data]{ + Metadata: command.CommandMetadata{ + Service: "im", Command: "+test-execute", Description: "Test execute", Risk: command.RiskRead, + Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{command.IdentityUser: {}}}, + }, + Hooks: command.Hooks[args, data]{ + Normalize: func(_ context.Context, _ command.CommandContext, args *args) error { + args.ID = "normalized-" + args.ID + return nil + }, + Execute: func(_ context.Context, _ command.CommandContext, args *args) (command.Result[data], error) { + return command.Success(data{ID: args.ID}), nil + }, + }, + } + recorder := New(t) + execution, err := Execute(context.Background(), recorder, command.IdentityUser, definition, &args{ID: "one"}) + if err != nil { + t.Fatal(err) + } + if execution.Data.ID != "normalized-one" { + 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) + } +} + +// DryRun cannot fail, so Validate is the only hook that can stop a preview. +func TestPreviewPropagatesValidateError(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]{ + Validate: func(context.Context, command.CommandContext, *args) error { + return sentinel + }, + DryRun: func(context.Context, command.CommandContext, *args) *command.DryRun { + return command.NewDryRun(command.GET("/open-apis/im/v1/chats")) + }, + 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") + } +} + +// Every harness entry point must run the production compiler. A declaration the +// real CLI would refuse to mount has to fail in the unit test too -- otherwise a +// green test ships a command that cannot mount. Preview was the entry that +// skipped this, so the case covers all three. +func TestEveryEntryPointRunsTheProductionCompiler(t *testing.T) { + type args struct { + Value string // no flag or arg tag: the compiler refuses this + } + type data struct { + OK bool `json:"ok" schema:"required" doc:"success state"` + } + definition := command.Definition[args, data]{ + Metadata: command.CommandMetadata{ + Service: command.DomainIm, Command: "+test-uncompilable", Description: "Missing flag tag", Risk: command.RiskRead, + Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{command.IdentityUser: {}}}, + }, + Hooks: command.Hooks[args, data]{ + DryRun: func(context.Context, command.CommandContext, *args) *command.DryRun { + return command.NewDryRun(command.GET("/open-apis/im/v1/chats")) + }, + Execute: func(context.Context, command.CommandContext, *args) (command.Result[data], error) { + return command.Success(data{OK: true}), nil + }, + }, + } + const want = "must declare exactly one of flag or arg" + + t.Run("Execute", func(t *testing.T) { + recorder := New(t, Respond(map[string]any{})) + if _, err := Execute(context.Background(), recorder, command.IdentityUser, definition, &args{}); err == nil || + !strings.Contains(err.Error(), want) { + t.Fatalf("Execute error = %v, want the compiler refusal", err) + } + }) + t.Run("Preview", func(t *testing.T) { + recorder := New(t) + if _, err := Preview(context.Background(), recorder, command.IdentityUser, definition, &args{}); err == nil || + !strings.Contains(err.Error(), want) { + t.Fatalf("Preview error = %v, want the compiler refusal", err) + } + }) + t.Run("RunWithFlags", func(t *testing.T) { + recorder := New(t) + if _, err := RunWithFlags(context.Background(), recorder, command.IdentityUser, definition, &args{}); err == nil || + !strings.Contains(err.Error(), want) { + t.Fatalf("RunWithFlags error = %v, want the compiler refusal", err) + } + }) +} diff --git a/extension/command/commandtest/result_protocol_test.go b/extension/command/commandtest/result_protocol_test.go new file mode 100644 index 0000000000..0310564a41 --- /dev/null +++ b/extension/command/commandtest/result_protocol_test.go @@ -0,0 +1,87 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package commandtest_test + +import ( + "context" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/command" + "github.com/larksuite/cli/extension/command/commandtest" +) + +type outcomeProbeArgs struct { + ID string `flag:"id" schema:"required;minLength=1" doc:"identifier"` +} + +type outcomeProbeData struct { + Content string `json:"content" schema:"required" doc:"document content"` +} + +// missingOutcomeDefinition models the mistake worth catching: Execute reports +// success by returning a bare Result instead of routing through Success. +func missingOutcomeDefinition() command.Definition[outcomeProbeArgs, outcomeProbeData] { + return command.Definition[outcomeProbeArgs, outcomeProbeData]{ + Metadata: command.CommandMetadata{ + Service: "docs", Command: "+business-missing-outcome", Description: "Probe", Risk: command.RiskRead, + Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{ + command.IdentityUser: {RequiredScopes: []string{"docx:document:readonly"}}, + }}, + }, + Hooks: command.Hooks[outcomeProbeArgs, outcomeProbeData]{ + Execute: func(context.Context, command.CommandContext, *outcomeProbeArgs) (command.Result[outcomeProbeData], error) { + return command.Result[outcomeProbeData]{}, nil + }, + }, + } +} + +func missingOutcomePageDefinition() command.Definition[outcomeProbeArgs, command.Page[outcomeProbeData]] { + return command.Definition[outcomeProbeArgs, command.Page[outcomeProbeData]]{ + Metadata: command.CommandMetadata{ + Service: "docs", Command: "+business-missing-outcome-page", Description: "Probe", Risk: command.RiskRead, + Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{ + command.IdentityUser: {RequiredScopes: []string{"docx:document:readonly"}}, + }}, + }, + Hooks: command.Hooks[outcomeProbeArgs, command.Page[outcomeProbeData]]{ + Execute: func(context.Context, command.CommandContext, *outcomeProbeArgs) (command.Result[command.Page[outcomeProbeData]], error) { + return command.Result[command.Page[outcomeProbeData]]{}, nil + }, + }, + } +} + +func assertMissingOutcome(t *testing.T, err error) { + t.Helper() + if err == nil { + t.Fatal("error = nil, want the missing-outcome protocol failure") + } + if !strings.Contains(err.Error(), "without an outcome") { + t.Fatalf("error = %v, want the missing-outcome protocol failure", err) + } + if !errs.IsInternal(err) { + t.Errorf("error %v is not an internal error", err) + } +} + +func TestExecuteRejectsResultWithoutOutcome(t *testing.T) { + recorder := commandtest.New(t) + _, err := commandtest.Execute( + context.Background(), recorder, command.IdentityUser, + missingOutcomeDefinition(), &outcomeProbeArgs{ID: "doc_1"}, + ) + assertMissingOutcome(t, err) +} + +func TestRunWithFlagsRejectsResultWithoutOutcome(t *testing.T) { + recorder := commandtest.New(t) + _, err := commandtest.RunWithFlags( + context.Background(), recorder, command.IdentityUser, + missingOutcomePageDefinition(), &outcomeProbeArgs{ID: "doc_1"}, "--page-all", + ) + assertMissingOutcome(t, err) +} diff --git a/extension/command/context.go b/extension/command/context.go new file mode 100644 index 0000000000..029d87c4b9 --- /dev/null +++ b/extension/command/context.go @@ -0,0 +1,110 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package command + +import ( + "bytes" + "context" + "encoding/json" + "time" +) + +// CommandContext is an opaque, invocation-scoped set of safe host capabilities. +type CommandContext struct { + identity Identity + dryRun bool + inputStage bool + callJSON func(context.Context, Request) (map[string]any, error) + download func(context.Context, Request, FileTarget, DownloadOptions) (Artifact, error) + downloadURL func(context.Context, string, FileTarget, DownloadOptions) (Artifact, 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. +// It is intended for host adapters and commandtest. +type PaginationOptions struct { + All bool + MaxPages int + Delay time.Duration +} + +// 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 + + // InputStage marks a context serving Normalize or Validate. Those hooks + // run before the high-risk confirmation gate, so the design gives them no + // network: Validate is specified as parameter checking that issues no + // request, and a command that reached out from there would produce remote + // side effects the user was never asked to confirm. + InputStage bool + + CallJSON func(context.Context, Request) (map[string]any, error) + Download func(context.Context, Request, FileTarget, DownloadOptions) (Artifact, error) + DownloadURL func(context.Context, string, FileTarget, DownloadOptions) (Artifact, 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, + inputStage: options.InputStage, + callJSON: options.CallJSON, + download: options.Download, + downloadURL: options.DownloadURL, + preflightScopes: options.PreflightScopes, + collectPages: options.CollectPages, + } +} + +// Identity returns the selected execution identity. +func (c CommandContext) Identity() Identity { return c.identity } + +// CallJSON executes one request and decodes its data object into T. +func CallJSON[T any](ctx context.Context, command CommandContext, request Request) (T, error) { + var result T + if err := validateRequest(request); err != nil { + return result, err + } + if command.inputStage { + return result, ValidationErrorf("network requests are unavailable in Normalize and Validate; move the call to Execute") + } + if command.dryRun { + return result, ValidationErrorf("network requests are unavailable during dry-run") + } + if command.callJSON == nil { + return result, InternalErrorf("command host does not provide OpenAPI requests") + } + data, err := command.callJSON(ctx, request) + if err != nil { + return result, err + } + encoded, err := json.Marshal(data) + if err != nil { + return result, InvalidResponseErrorf("encode OpenAPI response data: %v", err).WithCause(err) + } + decoder := json.NewDecoder(bytes.NewReader(encoded)) + decoder.UseNumber() + if err := decoder.Decode(&result); err != nil { + return result, InvalidResponseErrorf("decode OpenAPI response data: %v", err).WithCause(err) + } + return result, nil +} + +// PreflightScopes checks declared conditional scopes before a branch starts side effects. +func PreflightScopes(command CommandContext, scopes ...string) error { + if command.dryRun { + return nil + } + if command.preflightScopes == nil { + return InternalErrorf("command host does not provide conditional scope checks") + } + return command.preflightScopes(scopes...) +} diff --git a/extension/command/definition.go b/extension/command/definition.go new file mode 100644 index 0000000000..1dc4defa57 --- /dev/null +++ b/extension/command/definition.go @@ -0,0 +1,273 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package command defines the public contract for build-time command extensions. +// +// Business command authors use Definition, Define, the CommandContext helpers, +// and high-level effects such as Download. The Host* types, InspectCommand, +// InspectDomain and CloneSets are the +// erased read side that lark-cli's host adapter and commandtest consume. They +// stay exported because a Command holds its declaration unexported and Go gives +// a sibling package no way to reach it; business commands never call them. +package command + +import ( + "context" + "io" +) + +// JSONValue is a value representable by JSON encoding. +type JSONValue = any + +// Definition declares one typed command extension. +type Definition[Args any, Data any] struct { + Metadata CommandMetadata + Input InputDefinition + Output OutputDefinition + Hooks Hooks[Args, Data] +} + +// CommandMetadata describes the command name, help, risk, and authorization. +type CommandMetadata struct { + Service DomainName + Command string + Description string + Risk Risk + Hidden bool + Authorization AuthorizationDefinition +} + +// Identity selects a supported Lark identity. +type Identity string + +// Risk classifies the side effects of a command. +type Risk string + +const ( + // IdentityUser executes with a user access token. + IdentityUser Identity = "user" + // IdentityBot executes with a tenant access token. + IdentityBot Identity = "bot" + + // RiskRead declares a read-only command. + RiskRead Risk = "read" + // RiskWrite declares a command that changes remote state. + RiskWrite Risk = "write" + // RiskHighRiskWrite declares a command that requires explicit confirmation. + RiskHighRiskWrite Risk = "high-risk-write" +) + +// AuthorizationDefinition declares supported identities and their scopes. +type AuthorizationDefinition struct { + Identities map[Identity]IdentityAuthorization + IdentityOrder []Identity +} + +// IdentityAuthorization declares required and conditional scopes for one identity. +type IdentityAuthorization struct { + RequiredScopes []string `json:"required_scopes"` + ConditionalScopes []ConditionalScope `json:"conditional_scopes"` +} + +// ConditionalScope describes scopes required by only some execution branches. +type ConditionalScope struct { + Scopes []string `json:"scopes"` + When string `json:"when,omitempty"` + Params []string `json:"params,omitempty"` + Requirement ScopeRequirement `json:"requirement"` +} + +// ScopeRequirement defines whether a conditional scope is mandatory. +type ScopeRequirement string + +const ( + // ScopeRequired fails the selected execution branch when the scope is absent. + ScopeRequired ScopeRequirement = "required" + // ScopeBestEffort allows the primary operation to continue without the scope. + ScopeBestEffort ScopeRequirement = "best_effort" +) + +// InputDefinition supplements tags on Args with aliases, sources, and relations. +type InputDefinition struct { + Fields []InputField + Relations []Relation +} + +// InputField supplements one field declared by a flag tag. +type InputField struct { + Name string + Description string + Shape ValueShape + Default InputDefault + CLI CLIInput +} + +// InputDefault distinguishes an omitted default from a JSON zero value. +type InputDefault struct { + Set bool + Value JSONValue +} + +// CLIInput controls aliases, accepted value sources, encoding, and help visibility. +type CLIInput struct { + Aliases []FlagAlias + ValueSources []ValueSource + Encoding CLIEncoding + Hidden bool + Deprecated string +} + +// FlagAlias declares a compatibility spelling for a flag. +type FlagAlias struct { + Name string + Mode FlagAliasMode + Conflict AliasConflictPolicy + Hidden bool + Deprecated bool +} + +// FlagAliasMode defines whether an alias normalizes into the canonical flag. +type FlagAliasMode string + +// AliasConflictPolicy defines how canonical and alias values interact. +type AliasConflictPolicy string + +const ( + // AliasNormalize maps the alias value to the canonical field. + AliasNormalize FlagAliasMode = "normalize" + // AliasIndependent keeps the alias as a separate compatibility input. + AliasIndependent FlagAliasMode = "independent" + + // AliasCanonicalWins prefers the canonical flag when both spellings appear. + AliasCanonicalWins AliasConflictPolicy = "canonical_wins" + // AliasErrorIfBoth rejects simultaneous canonical and alias values. + AliasErrorIfBoth AliasConflictPolicy = "error_if_both" + // AliasTrimmedEqualOrError accepts both spellings only when trimmed values match. + AliasTrimmedEqualOrError AliasConflictPolicy = "trimmed_equal_or_error" +) + +// ValueSource identifies where a CLI input value may come from. +type ValueSource string + +const ( + // SourceFlag accepts a literal flag value. + SourceFlag ValueSource = "flag" + // SourceFile accepts an @path value and substitutes the file content. The + // path goes through the invocation's FileIO provider, so it stays relative + // to the working directory; @@ passes a literal leading @. + SourceFile ValueSource = "file" + // SourceStdin accepts a single dash and reads standard input. A process has + // one stdin, so at most one flag per invocation may use it -- declare + // SourceFile alongside it to keep the remaining values passable. + SourceStdin ValueSource = "stdin" +) + +// CLIEncoding defines how repeated or structured values are parsed. +type CLIEncoding string + +const ( + // EncodingRepeated accepts repeated flag occurrences. + EncodingRepeated CLIEncoding = "repeated" + // EncodingCommaOrRepeated accepts comma-separated or repeated values. + EncodingCommaOrRepeated CLIEncoding = "comma_or_repeated" + // EncodingJSON accepts a JSON-encoded value. + EncodingJSON CLIEncoding = "json" +) + +// Provided preserves whether the caller explicitly supplied a value. +type Provided[T any] struct { + Value T + Set bool +} + +// Relation declares a presence relationship among input fields. +type Relation struct { + Kind RelationKind `json:"kind"` + Params []string `json:"params"` + Presence PresenceMode `json:"presence"` + Stage RelationStage `json:"stage"` +} + +// RelationKind identifies the relationship among fields. +type RelationKind string + +// PresenceMode defines how a field counts as present. +type PresenceMode string + +// RelationStage selects when a relation is checked. +type RelationStage string + +const ( + // RelationExactlyOne requires exactly one field. + RelationExactlyOne RelationKind = "exactly_one" + // RelationAtLeastOne requires one or more fields. + RelationAtLeastOne RelationKind = "at_least_one" + // RelationCoOccur requires all named fields to appear together. + RelationCoOccur RelationKind = "co_occur" + // RelationRequires makes the first field require the remaining fields. + RelationRequires RelationKind = "requires" + // RelationConflicts rejects fields used together. + RelationConflicts RelationKind = "conflicts" + + // PresenceExplicit counts only values supplied by the caller. + PresenceExplicit PresenceMode = "explicit" + // PresenceNonZero counts non-zero values after normalization. + PresenceNonZero PresenceMode = "non_zero" + + // StageSourcePreRun checks source presence before hooks run. + StageSourcePreRun RelationStage = "source_pre_run" + // StageAfterPrepare checks prepared values after Normalize. + StageAfterPrepare RelationStage = "after_prepare" +) + +// Hooks contains the optional preparation hooks and required Execute hook. +type Hooks[Args any, Data any] struct { + // Normalize folds legacy input spellings into current semantics. It runs + // first and only when a legacy form exists. + // + // Normalize and Validate both run before the high-risk confirmation gate, + // so their CommandContext carries no network: CallJSON and CollectPages + // refuse there. A check that needs the API belongs in Execute, after the + // user has confirmed. + Normalize func(context.Context, CommandContext, *Args) error + + // Validate checks format, range and field combinations. Requirements the + // schema tag already states are enforced by the framework; this hook is for + // the rules a tag cannot express. It issues no request -- see Normalize. + Validate func(context.Context, CommandContext, *Args) error + + // DryRun returns the requests the command would send, which the framework + // prints instead of executing. Validate has already run, so the requests + // follow from Args alone and the hook reports nothing back. + DryRun func(context.Context, CommandContext, *Args) *DryRun + + // Execute carries the business logic and returns Success. It is + // the only hook that may call the API, and it must not write to stdout -- + // the framework owns the envelope, format and exit code. + Execute func(context.Context, CommandContext, *Args) (Result[Data], error) + + // PrettyRenderer customizes --format pretty. It is a single hook rather than + // a map keyed by format name because pretty is the only format a business + // command may render itself: JSON, table, CSV and NDJSON are produced by the + // framework formatters, and a map would let a command declare an entry the + // compiler can only reject. + PrettyRenderer Renderer[Data] +} + +// Renderer renders one successful result in a supported custom format. +type Renderer[Data any] func(io.Writer, Data) error + +// prettyFormatName is the only format a business command may render itself. The +// host hook set is keyed by format name, so PrettyRenderer is projected under +// this key. +const prettyFormatName = "pretty" + +// Command is an immutable typed command declaration returned by Define. +type Command struct { + definition hostDefinition +} + +// Define captures a typed command declaration for later host compilation. +func Define[Args any, Data any](definition Definition[Args, Data]) Command { + return newCommand(definition) +} diff --git a/extension/command/domain.go b/extension/command/domain.go new file mode 100644 index 0000000000..1fc4f6ea5b --- /dev/null +++ b/extension/command/domain.go @@ -0,0 +1,31 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package command + +// DomainName is the name of an existing Lark business domain. +type DomainName string + +type domainKind uint8 + +const domainExtended domainKind = iota + 1 + +// Domain is an opaque declaration of where a command set is mounted. +type Domain struct { + kind domainKind + name string +} + +// ExtendDomain declares that a set adds commands to an existing domain. +// V1 mounts business commands into existing domains only; declaring a brand new +// domain is not part of this surface, so no constructor for one is exported. +func ExtendDomain(name DomainName) Domain { + return Domain{kind: domainExtended, name: string(name)} +} + +// Set groups commands mounted into one domain. +type Set struct { + _ struct{} + Domain Domain + Commands []Command +} diff --git a/extension/command/domains.go b/extension/command/domains.go new file mode 100644 index 0000000000..51a57b7f5b --- /dev/null +++ b/extension/command/domains.go @@ -0,0 +1,59 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package command + +// The Lark business domains a command set may extend. The list is maintained by +// hand rather than generated from the shortcut registry: a domain exists once +// the CLI publishes it under `lark-cli --help`, which includes domains served +// only by typed and raw API commands. Generating from shortcuts.AllShortcuts +// would silently drop those. TestDomainConstantsCoverEveryService in +// internal/commandhost guards this list against the service registry. +const ( + // DomainApplication is the Application domain. + DomainApplication DomainName = "application" + // DomainApproval is the Approval domain. + DomainApproval DomainName = "approval" + // DomainApps is the Apps domain. + DomainApps DomainName = "apps" + // DomainAttendance is the Attendance domain. + DomainAttendance DomainName = "attendance" + // DomainBase is the Base domain. + DomainBase DomainName = "base" + // DomainCalendar is the Calendar domain. + DomainCalendar DomainName = "calendar" + // DomainContact is the Contacts domain. + DomainContact DomainName = "contact" + // DomainDocs is the Docs domain. + DomainDocs DomainName = "docs" + // DomainDrive is the Drive domain. + DomainDrive DomainName = "drive" + // DomainEvent is the Event domain. + DomainEvent DomainName = "event" + // DomainIm is the Messenger domain. + DomainIm DomainName = "im" + // DomainMail is the Mail domain. + DomainMail DomainName = "mail" + // DomainMarkdown is the Markdown domain. + DomainMarkdown DomainName = "markdown" + // DomainMindnotes is the Mindnote domain. + DomainMindnotes DomainName = "mindnotes" + // DomainMinutes is the Minutes domain. + DomainMinutes DomainName = "minutes" + // DomainNote is the Note domain. + DomainNote DomainName = "note" + // DomainOkr is the OKR domain. + DomainOkr DomainName = "okr" + // DomainSheets is the Sheets domain. + DomainSheets DomainName = "sheets" + // DomainSlides is the Slides domain. + DomainSlides DomainName = "slides" + // DomainTask is the Task domain. + DomainTask DomainName = "task" + // DomainVc is the VC domain. + DomainVc DomainName = "vc" + // DomainWhiteboard is the Whiteboard domain. + DomainWhiteboard DomainName = "whiteboard" + // DomainWiki is the Wiki domain. + DomainWiki DomainName = "wiki" +) diff --git a/extension/command/dryrun.go b/extension/command/dryrun.go new file mode 100644 index 0000000000..76d79f6642 --- /dev/null +++ b/extension/command/dryrun.go @@ -0,0 +1,114 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package command + +// DryRun is an opaque ordered description of requests that execution may send. +type DryRun struct { + description string + requests []Request + files []FileIntent +} + +// File appends one logical file effect. It does not inspect the destination or +// create storage; conflict and final-location decisions remain live-only. +func (d *DryRun) File(intent FileIntent) *DryRun { + if d == nil { + return d + } + d.files = append(d.files, intent) + return d +} + +// NewDryRun creates a dry-run request list from shared Request values. Passing +// no requests creates an empty list to fill in with the chained methods below. +func NewDryRun(requests ...Request) *DryRun { + return &DryRun{requests: append([]Request(nil), requests...)} +} + +// Add appends a shared request description. +func (d *DryRun) Add(request Request) *DryRun { + if d == nil { + return d + } + d.requests = append(d.requests, request) + return d +} + +// GET appends a GET request. +func (d *DryRun) GET(apiPath string) *DryRun { return d.Add(GET(apiPath)) } + +// POST appends a POST request. +func (d *DryRun) POST(apiPath string) *DryRun { return d.Add(POST(apiPath)) } + +// PUT appends a PUT request. +func (d *DryRun) PUT(apiPath string) *DryRun { return d.Add(PUT(apiPath)) } + +// PATCH appends a PATCH request. +func (d *DryRun) PATCH(apiPath string) *DryRun { return d.Add(PATCH(apiPath)) } + +// DELETE appends a DELETE request. +func (d *DryRun) DELETE(apiPath string) *DryRun { return d.Add(DELETE(apiPath)) } + +// Set adds a query parameter to the most recently appended request. +func (d *DryRun) Set(name string, value any) *DryRun { + if d == nil || len(d.requests) == 0 { + return d + } + d.requests[len(d.requests)-1] = d.requests[len(d.requests)-1].Set(name, value) + return d +} + +// Params replaces query parameters on the most recently appended request. +func (d *DryRun) Params(params map[string]any) *DryRun { + if d == nil || len(d.requests) == 0 { + return d + } + d.requests[len(d.requests)-1] = d.requests[len(d.requests)-1].Params(params) + return d +} + +// Body sets the body on the most recently appended request. +func (d *DryRun) Body(body any) *DryRun { + if d == nil || len(d.requests) == 0 { + return d + } + d.requests[len(d.requests)-1] = d.requests[len(d.requests)-1].Body(body) + return d +} + +// Desc sets a call description, or the top-level description before any call exists. +func (d *DryRun) Desc(description string) *DryRun { + if d == nil { + return d + } + if len(d.requests) == 0 { + d.description = description + return d + } + d.requests[len(d.requests)-1] = d.requests[len(d.requests)-1].Desc(description) + return d +} + +// DryRunView is a copied host and test projection of DryRun. +type DryRunView struct { + Description string + Requests []RequestView + Files []FileIntent +} + +// InspectDryRun returns a copied dry-run projection for host adapters and tests. +func InspectDryRun(dryRun *DryRun) DryRunView { + if dryRun == nil { + return DryRunView{} + } + view := DryRunView{ + Description: dryRun.description, + Requests: make([]RequestView, len(dryRun.requests)), + Files: append([]FileIntent(nil), dryRun.files...), + } + for index, request := range dryRun.requests { + view.Requests[index] = InspectRequest(request) + } + return view +} diff --git a/extension/command/errors.go b/extension/command/errors.go new file mode 100644 index 0000000000..0704d7e133 --- /dev/null +++ b/extension/command/errors.go @@ -0,0 +1,70 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package command + +import ( + "context" + "errors" + "fmt" + + "github.com/larksuite/cli/errs" +) + +// ValidationErrorf creates a typed invalid-argument error. +func ValidationErrorf(format string, args ...any) *errs.ValidationError { + return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...) +} + +// InvalidResponseErrorf creates a typed malformed-response error. +func InvalidResponseErrorf(format string, args ...any) *errs.InternalError { + return errs.NewInternalError(errs.SubtypeInvalidResponse, format, args...) +} + +// InternalErrorf creates a typed internal error for an invariant failure. +func InternalErrorf(format string, args ...any) *errs.InternalError { + return errs.NewInternalError(errs.SubtypeUnknown, format, args...) +} + +// PaginationLimitError reports an incomplete all-pages read with a resume token. +func PaginationLimitError(pages int, nextToken string) *errs.InternalError { + return errs.NewInternalError(errs.SubtypeQuotaExceeded, + "pagination reached the hard limit after %d page(s)", pages). + WithHint("resume from page_token %q after narrowing the request or increasing the host bound", nextToken) +} + +// PaginationInterruptedError converts context cancellation into a typed network error. +func PaginationInterruptedError(cause error) *errs.NetworkError { + subtype := errs.SubtypeNetworkTransport + if errors.Is(cause, context.DeadlineExceeded) { + subtype = errs.SubtypeNetworkTimeout + } + return errs.NewNetworkError(subtype, "pagination interrupted: %v", cause).WithCause(cause) +} + +// Failure is a stable snapshot suitable for embedding in partial result data. +type Failure struct { + Type string `json:"type" schema:"required" doc:"error category"` + Subtype string `json:"subtype,omitempty" schema:"optional" doc:"error subtype"` + Code int `json:"code,omitempty" schema:"optional" doc:"remote error code"` + Message string `json:"message" schema:"required" doc:"safe error message"` + Hint string `json:"hint,omitempty" schema:"optional" doc:"recovery hint"` + LogID string `json:"log_id,omitempty" schema:"optional" doc:"remote request log identifier"` + Retryable bool `json:"retryable,omitempty" schema:"optional" doc:"whether retry may succeed"` +} + +// SnapshotFailure copies safe typed error fields into result data. +func SnapshotFailure(err error) Failure { + if problem, ok := errs.ProblemOf(err); ok { + return Failure{ + Type: string(problem.Category), + Subtype: string(problem.Subtype), + Code: problem.Code, + Message: problem.Message, + Hint: problem.Hint, + LogID: problem.LogID, + Retryable: problem.Retryable, + } + } + return Failure{Type: string(errs.CategoryInternal), Subtype: string(errs.SubtypeUnknown), Message: fmt.Sprint(err)} +} diff --git a/extension/command/examples/chat-brief/main.go b/extension/command/examples/chat-brief/main.go new file mode 100644 index 0000000000..23f210d65a --- /dev/null +++ b/extension/command/examples/chat-brief/main.go @@ -0,0 +1,169 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Command chat-brief is a runnable lark-cli distribution that contributes +// two business commands to the existing im domain via WithCommandSets: +// +// im +chat-brief single read: Validate, shared DryRun request, CallJSON +// im +chat-brief-list list read: Page[T] Data auto-installs --page-all/--page-limit/--page-delay +// +// Unlike the test fixture under testdata/wrapper, this example keeps the +// real distribution shape: plugins, strict mode, and service commands all +// stay enabled; only the command sets are added. +// +// Build & run: +// +// cd extension/command/examples/chat-brief +// go build -o chat-brief-cli . +// ./chat-brief-cli im +chat-brief --help # description + flags from tags +// ./chat-brief-cli im +chat-brief --chat-id oc_xxx --dry-run # request preview, sends nothing +// ./chat-brief-cli im +chat-brief --chat-id oc_xxx # real call (requires auth login) +// ./chat-brief-cli im +chat-brief-list --page-all --page-limit 2 # framework pagination flags +// ./chat-brief-cli auth login --domain im # aggregates business scopes too +// go test ./... # hooks under commandtest, no network +package main + +import ( + "context" + "os" + "strings" + + "github.com/larksuite/cli/cmd" + "github.com/larksuite/cli/extension/command" + + _ "github.com/larksuite/cli/extension/credential/env" // activate env credential provider +) + +type chatBriefArgs struct { + ChatID string `flag:"chat-id" schema:"required;minLength=1" doc:"chat ID (oc_xxx)"` + IDType string `flag:"user-id-type" schema:"optional;default=\"open_id\";enum=open_id|union_id|user_id" doc:"user ID type"` +} + +type chatBriefData struct { + ChatID string `json:"chat_id" schema:"required" doc:"chat ID"` + Name string `json:"name" schema:"required" doc:"chat name"` + Owner string `json:"owner_id" schema:"required" doc:"owner open ID"` +} + +// chatWire holds the OpenAPI response fields this command projects. +type chatWire struct { + Name string `json:"name"` + Owner string `json:"owner_id"` +} + +// chatRequest is shared by Execute and DryRun so the preview cannot drift +// from the real call. User input concatenated into the path goes through +// PathSegment. +func chatRequest(args *chatBriefArgs) command.Request { + return command.GET("/open-apis/im/v1/chats/"+command.PathSegment(args.ChatID)). + Set("user_id_type", args.IDType) +} + +// chatBriefDefinition returns the declaration rather than an already compiled +// Command so commandtest.Execute can drive the same hooks in a unit test; see +// main_test.go. Define erases the type parameters, and a Command cannot hand +// its Definition back. +func chatBriefDefinition() command.Definition[chatBriefArgs, chatBriefData] { + return command.Definition[chatBriefArgs, chatBriefData]{ + Metadata: command.CommandMetadata{ + Service: command.DomainIm, + Command: "+chat-brief", + Description: "Get a concise chat projection", + Risk: command.RiskRead, + Authorization: command.AuthorizationDefinition{ + Identities: map[command.Identity]command.IdentityAuthorization{ + command.IdentityUser: {RequiredScopes: []string{"im:chat:read"}}, + }, + }, + }, + Hooks: command.Hooks[chatBriefArgs, chatBriefData]{ + Validate: func(_ context.Context, _ command.CommandContext, args *chatBriefArgs) error { + if !strings.HasPrefix(args.ChatID, "oc_") { + return command.ValidationErrorf("--chat-id must start with oc_") + } + return nil + }, + DryRun: func(_ context.Context, _ command.CommandContext, args *chatBriefArgs) *command.DryRun { + return command.NewDryRun(chatRequest(args)) + }, + Execute: func(ctx context.Context, c command.CommandContext, args *chatBriefArgs) (command.Result[chatBriefData], error) { + chat, err := command.CallJSON[chatWire](ctx, c, chatRequest(args)) + if err != nil { + return command.Result[chatBriefData]{}, err + } + return command.Success(chatBriefData{ + ChatID: args.ChatID, + Name: chat.Name, + Owner: chat.Owner, + }), nil + }, + }, + } +} + +var chatBrief = command.Define(chatBriefDefinition()) + +type chatListArgs struct { + PageSize int `flag:"page-size" schema:"optional;default=20;minimum=1;maximum=100" doc:"items per page"` + PageToken string `flag:"page-token" schema:"optional" doc:"resume cursor from a previous response"` +} + +type chatItem struct { + ChatID string `json:"chat_id" schema:"required" doc:"chat ID"` + Name string `json:"name" schema:"required" doc:"chat name"` +} + +// chatListRequest seeds page_token when resuming; --page-all and the +// starting cursor are independent, so a resumed walk keeps paginating. +func chatListRequest(args *chatListArgs) command.Request { + request := command.GET("/open-apis/im/v1/chats").Set("page_size", args.PageSize) + if args.PageToken != "" { + request = request.Set("page_token", args.PageToken) + } + return request +} + +// chatListDefinition declares Page[T] as its Data, so the compiler installs the +// framework pagination flags; the Args stay free of paging fields. It is a +// function for the same reason as chatBriefDefinition. +func chatListDefinition() command.Definition[chatListArgs, command.Page[chatItem]] { + return command.Definition[chatListArgs, command.Page[chatItem]]{ + Metadata: command.CommandMetadata{ + Service: command.DomainIm, + Command: "+chat-brief-list", + Description: "List visible chats", + Risk: command.RiskRead, + Authorization: command.AuthorizationDefinition{ + Identities: map[command.Identity]command.IdentityAuthorization{ + command.IdentityUser: {RequiredScopes: []string{"im:chat:read"}}, + }, + }, + }, + Hooks: command.Hooks[chatListArgs, command.Page[chatItem]]{ + DryRun: func(_ context.Context, _ command.CommandContext, args *chatListArgs) *command.DryRun { + return command.NewDryRun(chatListRequest(args)) + }, + Execute: func(ctx context.Context, c command.CommandContext, args *chatListArgs) (command.Result[command.Page[chatItem]], error) { + page, err := command.CollectPages[chatItem](ctx, c, chatListRequest(args)) + if err != nil { + return command.Result[command.Page[chatItem]]{}, err + } + return command.Success(page), nil + }, + }, + } +} + +var chatList = command.Define(chatListDefinition()) + +// main ships no embedded skill or affordance content: a wrapper does not +// compile the repository's root content_embed.go, and a distribution that wants +// agent guidance supplies its own tree through cmd.SetEmbeddedSkillContent. +func main() { + os.Exit(cmd.ExecuteWithOptions( + cmd.WithCommandSets(command.Set{ + Domain: command.ExtendDomain(command.DomainIm), + Commands: []command.Command{chatBrief, chatList}, + }), + )) +} diff --git a/extension/command/examples/chat-brief/main_test.go b/extension/command/examples/chat-brief/main_test.go new file mode 100644 index 0000000000..df2d54ec46 --- /dev/null +++ b/extension/command/examples/chat-brief/main_test.go @@ -0,0 +1,110 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package main + +import ( + "context" + "strings" + "testing" + + "github.com/larksuite/cli/extension/command" + "github.com/larksuite/cli/extension/command/commandtest" +) + +// These tests are the reason chatBriefDefinition and chatListDefinition are +// functions: commandtest.Execute takes the Definition, and Define only returns +// an opaque Command that cannot hand its Definition back. + +func TestChatBriefProjectsTheChat(t *testing.T) { + recorder := commandtest.New(t, commandtest.Respond(map[string]any{ + "name": "Team room", + "owner_id": "ou_owner", + })) + + execution, err := commandtest.Execute(context.Background(), recorder, command.IdentityUser, + chatBriefDefinition(), &chatBriefArgs{ChatID: "oc_123", IDType: "open_id"}) + if err != nil { + t.Fatal(err) + } + if execution.Data.ChatID != "oc_123" || execution.Data.Name != "Team room" || execution.Data.Owner != "ou_owner" { + t.Fatalf("projection = %+v", execution.Data) + } + + requests := recorder.Requests() + if len(requests) != 1 { + t.Fatalf("sent %d requests, want 1", len(requests)) + } + if path := requests[0].Path; path != "/open-apis/im/v1/chats/oc_123" { + t.Fatalf("path = %q", path) + } +} + +func TestChatBriefRejectsAChatIDWithoutThePrefix(t *testing.T) { + recorder := commandtest.New(t) + _, err := commandtest.Execute(context.Background(), recorder, command.IdentityUser, + chatBriefDefinition(), &chatBriefArgs{ChatID: "123", IDType: "open_id"}) + if err == nil || !strings.Contains(err.Error(), "must start with oc_") { + t.Fatalf("error = %v, want the Validate failure", err) + } + if sent := recorder.Requests(); len(sent) != 0 { + t.Fatalf("Validate ran but %d requests were sent", len(sent)) + } +} + +// TestChatListReadsOnePageByDefault pins the Page[T] contract: without paging +// flags the command fetches a single page and reports the resume cursor. +func TestChatListReadsOnePageByDefault(t *testing.T) { + recorder := commandtest.New(t, commandtest.Respond(map[string]any{ + "items": []map[string]any{{"chat_id": "oc_1", "name": "one"}}, + "has_more": true, + "page_token": "cursor_2", + })) + + execution, err := commandtest.Execute(context.Background(), recorder, command.IdentityUser, + chatListDefinition(), &chatListArgs{PageSize: 20}) + if err != nil { + t.Fatal(err) + } + if len(execution.Data.Items) != 1 || execution.Data.Items[0].ChatID != "oc_1" { + t.Fatalf("items = %+v", execution.Data.Items) + } + if execution.Data.Complete() { + t.Error("page reported complete despite has_more") + } + if token := execution.Data.NextToken(); token != "cursor_2" { + t.Errorf("next token = %q, want cursor_2", token) + } +} + +// TestChatListWalksEveryPageWithPageAll drives the framework pagination flags +// the Page[T] Data installs. +func TestChatListWalksEveryPageWithPageAll(t *testing.T) { + recorder := commandtest.New(t, + commandtest.Respond(map[string]any{ + "items": []map[string]any{{"chat_id": "oc_1", "name": "one"}}, + "has_more": true, + "page_token": "cursor_2", + }), + commandtest.Respond(map[string]any{ + "items": []map[string]any{{"chat_id": "oc_2", "name": "two"}}, + "has_more": false, + }), + ) + + execution, err := commandtest.RunWithFlags(context.Background(), recorder, command.IdentityUser, + chatListDefinition(), &chatListArgs{PageSize: 20}, "--page-all", "--page-delay", "0") + if err != nil { + t.Fatal(err) + } + if len(execution.Data.Items) != 2 { + t.Fatalf("collected %d items, want 2: %+v", len(execution.Data.Items), execution.Data.Items) + } + if !execution.Data.Complete() { + t.Error("page-all finished without reporting completion") + } + if pages := execution.Data.Pages(); pages != 2 { + t.Errorf("pages = %d, want 2", pages) + } + recorder.AssertScriptConsumed() +} diff --git a/extension/command/file.go b/extension/command/file.go new file mode 100644 index 0000000000..e0b9772b3d --- /dev/null +++ b/extension/command/file.go @@ -0,0 +1,171 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package command + +import ( + "context" + "net/http" + "net/url" + "path" + "strings" + + "github.com/larksuite/cli/extension/download" +) + +// FileTarget names one invocation-scoped download destination. Name is passed +// through the active FileIO provider and must not be treated as an absolute +// local path. +type FileTarget struct { + Name string + IfExists IfExistsPolicy + + _ struct{} +} + +// IfExistsPolicy controls an existing download target. +type IfExistsPolicy string + +const ( + // IfExistsFail preserves an existing file. It is also the zero-value policy. + IfExistsFail IfExistsPolicy = "fail" + // IfExistsOverwrite explicitly allows the provider to replace an existing file. + IfExistsOverwrite IfExistsPolicy = "overwrite" +) + +// Intent creates the matching dry-run file effect. +func (t FileTarget) Intent(content string) FileIntent { + policy := t.IfExists + if policy == "" { + policy = IfExistsFail + } + return FileIntent{Name: t.Name, IfExists: policy, Content: content} +} + +// Artifact is one file committed by the active host FileIO provider. +// It can be returned directly as typed command data. +type Artifact struct { + Name string `json:"name" schema:"required" doc:"logical artifact name"` + Location string `json:"location" schema:"required" doc:"host-resolved saved location"` + Size int64 `json:"size_bytes" schema:"required;minimum=0" doc:"committed byte count"` + ContentType string `json:"content_type,omitempty" schema:"optional" doc:"response media type"` + + _ struct{} +} + +// FileIntent describes a file effect in dry-run output without opening a +// stream or writing bytes. +type FileIntent struct { + Name string `json:"name"` + IfExists IfExistsPolicy `json:"if_exists"` + Content string `json:"content,omitempty"` + + _ struct{} +} + +// DownloadOptions selects the source-stability contract and the shared +// multipart engine settings. The zero value uses Mutable with production +// transfer defaults. +type DownloadOptions struct { + Representation download.Representation + Transfer download.Options + + _ struct{} +} + +// Download streams one authenticated OpenAPI GET response into the active +// invocation-scoped FileIO provider. The host owns range probing, bounded +// retries, response validation, body closure, provider-owned saving, and error +// typing. It is an Execute-hook capability and does not declare or register a +// CLI command. +func Download(ctx context.Context, command CommandContext, request Request, target FileTarget, options ...DownloadOptions) (Artifact, error) { + if err := validateRequest(request); err != nil { + return Artifact{}, err + } + view := InspectRequest(request) + if view.Method != http.MethodGet { + return Artifact{}, ValidationErrorf("file download requires GET, got %s", view.Method) + } + if view.Body != nil { + return Artifact{}, ValidationErrorf("file download GET request must not contain a body") + } + target, resolvedOptions, err := prepareDownload(command, target, options) + if err != nil { + return Artifact{}, err + } + if command.download == nil { + return Artifact{}, InternalErrorf("command host does not provide OpenAPI file downloads") + } + artifact, err := command.download(ctx, request, target, resolvedOptions) + return validateDownloadArtifact(artifact, err) +} + +// DownloadURL streams one HTTPS URL into the active invocation-scoped FileIO +// provider. The host applies external-request routing, SSRF protection, DNS/IP +// pinning, redirect validation, and the same multipart engine as Download. It +// is an Execute-hook capability, not a command definition. +func DownloadURL(ctx context.Context, command CommandContext, rawURL string, target FileTarget, options ...DownloadOptions) (Artifact, error) { + parsed, err := url.Parse(rawURL) + if err != nil || parsed == nil || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" || !strings.EqualFold(parsed.Scheme, "https") { + return Artifact{}, ValidationErrorf("download URL must be an absolute HTTPS URL") + } + if rawURL != strings.TrimSpace(rawURL) { + return Artifact{}, ValidationErrorf("download URL must be trimmed") + } + target, resolvedOptions, err := prepareDownload(command, target, options) + if err != nil { + return Artifact{}, err + } + if command.downloadURL == nil { + return Artifact{}, InternalErrorf("command host does not provide URL file downloads") + } + artifact, err := command.downloadURL(ctx, rawURL, target, resolvedOptions) + return validateDownloadArtifact(artifact, err) +} + +func prepareDownload(command CommandContext, target FileTarget, options []DownloadOptions) (FileTarget, DownloadOptions, error) { + if target.Name == "" || target.Name != strings.TrimSpace(target.Name) { + return FileTarget{}, DownloadOptions{}, ValidationErrorf("download target name must be non-empty and trimmed") + } + normalizedName := strings.ReplaceAll(target.Name, `\`, "/") + baseName := path.Base(normalizedName) + if strings.HasSuffix(normalizedName, "/") || baseName == "." || baseName == ".." { + return FileTarget{}, DownloadOptions{}, ValidationErrorf("download target %q must include a file name, not only a directory", target.Name) + } + if target.IfExists == "" { + target.IfExists = IfExistsFail + } + if target.IfExists != IfExistsFail && target.IfExists != IfExistsOverwrite { + return FileTarget{}, DownloadOptions{}, ValidationErrorf("unsupported download target conflict policy %q", target.IfExists) + } + if len(options) > 1 { + return FileTarget{}, DownloadOptions{}, ValidationErrorf("file download accepts at most one DownloadOptions value") + } + resolvedOptions := DownloadOptions{Representation: download.Mutable} + if len(options) == 1 { + resolvedOptions = options[0] + if resolvedOptions.Representation == "" { + resolvedOptions.Representation = download.Mutable + } + } + if resolvedOptions.Representation != download.Mutable && resolvedOptions.Representation != download.Immutable { + return FileTarget{}, DownloadOptions{}, ValidationErrorf("unsupported download representation %q", resolvedOptions.Representation) + } + if command.inputStage { + return FileTarget{}, DownloadOptions{}, ValidationErrorf("file downloads are unavailable in Normalize and Validate; move the download to Execute") + } + if command.dryRun { + return FileTarget{}, DownloadOptions{}, ValidationErrorf("file downloads are unavailable during dry-run") + } + return target, resolvedOptions, nil +} + +func validateDownloadArtifact(artifact Artifact, err error) (Artifact, error) { + if err != nil { + return Artifact{}, err + } + if artifact.Name == "" || artifact.Location == "" || artifact.Size < 0 { + return Artifact{}, InternalErrorf("command host returned an invalid download artifact") + } + return artifact, nil +} diff --git a/extension/command/file_test.go b/extension/command/file_test.go new file mode 100644 index 0000000000..0fbffcc3a6 --- /dev/null +++ b/extension/command/file_test.go @@ -0,0 +1,148 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package command + +import ( + "context" + "errors" + "reflect" + "testing" + + downloadcore "github.com/larksuite/cli/extension/download" +) + +func TestDownloadUsesHostCapability(t *testing.T) { + request := GET("/open-apis/drive/v1/files/file_1/download").Set("version", "7") + target := FileTarget{Name: "reports/file.bin"} + want := Artifact{Name: target.Name, Location: "/workspace/reports/file.bin", Size: 7, ContentType: "application/octet-stream"} + called := false + commandContext := NewCommandContext(ContextOptions{ + Identity: IdentityUser, + Download: func(_ context.Context, gotRequest Request, gotTarget FileTarget, options DownloadOptions) (Artifact, error) { + called = true + if !reflect.DeepEqual(InspectRequest(gotRequest), InspectRequest(request)) || gotTarget.Name != target.Name || gotTarget.IfExists != IfExistsFail || options.Representation != downloadcore.Mutable { + t.Fatalf("download input = %#v, %#v, %#v", InspectRequest(gotRequest), gotTarget, options) + } + return want, nil + }, + }) + + got, err := Download(context.Background(), commandContext, request, target) + if err != nil { + t.Fatal(err) + } + if !called || !reflect.DeepEqual(got, want) { + t.Fatalf("Download() = %#v, called=%v", got, called) + } +} + +func TestDownloadRejectsUnavailableOrInvalidEffects(t *testing.T) { + validRequest := GET("/open-apis/drive/v1/files/file_1/download") + validTarget := FileTarget{Name: "file.bin"} + tests := map[string]struct { + command CommandContext + request Request + target FileTarget + }{ + "input stage": {command: NewCommandContext(ContextOptions{InputStage: true}), request: validRequest, target: validTarget}, + "dry-run": {command: NewCommandContext(ContextOptions{DryRun: true}), request: validRequest, target: validTarget}, + "missing host": {command: NewCommandContext(ContextOptions{}), request: validRequest, target: validTarget}, + "write method": {command: NewCommandContext(ContextOptions{}), request: POST("/open-apis/drive/v1/files/file_1/download"), target: validTarget}, + "request body": {command: NewCommandContext(ContextOptions{}), request: validRequest.Body(map[string]any{"x": true}), target: validTarget}, + "empty target": {command: NewCommandContext(ContextOptions{}), request: validRequest, target: FileTarget{}}, + "directory target": {command: NewCommandContext(ContextOptions{}), request: validRequest, target: FileTarget{Name: "reports/"}}, + "dot target": {command: NewCommandContext(ContextOptions{}), request: validRequest, target: FileTarget{Name: "reports/."}}, + "bad policy": {command: NewCommandContext(ContextOptions{}), request: validRequest, target: FileTarget{Name: "file.bin", IfExists: IfExistsPolicy("rename")}}, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + if _, err := Download(context.Background(), test.command, test.request, test.target); err == nil { + t.Fatal("Download() error is nil") + } + }) + } +} + +func TestDownloadPreservesHostErrorAndRejectsInvalidArtifact(t *testing.T) { + want := errors.New("download failed") + ctx := NewCommandContext(ContextOptions{Download: func(context.Context, Request, FileTarget, DownloadOptions) (Artifact, error) { + return Artifact{}, want + }}) + _, err := Download(context.Background(), ctx, GET("/open-apis/drive/v1/files/file_1/download"), FileTarget{Name: "file.bin"}) + if !errors.Is(err, want) { + t.Fatalf("Download() error = %v", err) + } + + ctx = NewCommandContext(ContextOptions{Download: func(context.Context, Request, FileTarget, DownloadOptions) (Artifact, error) { + return Artifact{Name: "file.bin", Size: 1}, nil + }}) + if _, err := Download(context.Background(), ctx, GET("/open-apis/drive/v1/files/file_1/download"), FileTarget{Name: "file.bin"}); err == nil { + t.Fatal("invalid artifact was accepted") + } +} + +func TestDownloadForwardsImmutableMultipartOptions(t *testing.T) { + wantOptions := DownloadOptions{ + Representation: downloadcore.Immutable, + Transfer: downloadcore.Options{PartSize: 4, MaxPartRetries: 2}, + } + ctx := NewCommandContext(ContextOptions{Download: func(_ context.Context, _ Request, _ FileTarget, got DownloadOptions) (Artifact, error) { + if !reflect.DeepEqual(got, wantOptions) { + t.Fatalf("download options = %#v", got) + } + return Artifact{Name: "file.bin", Location: "file.bin", Size: 1}, nil + }}) + if _, err := Download(context.Background(), ctx, + GET("/open-apis/drive/v1/files/file_1/download"), FileTarget{Name: "file.bin"}, wantOptions); err != nil { + t.Fatal(err) + } + if _, err := Download(context.Background(), ctx, + GET("/open-apis/drive/v1/files/file_1/download"), FileTarget{Name: "file.bin"}, + DownloadOptions{Representation: downloadcore.Representation("unstable")}); err == nil { + t.Fatal("invalid representation was accepted") + } + if _, err := Download(context.Background(), ctx, + GET("/open-apis/drive/v1/files/file_1/download"), FileTarget{Name: "file.bin"}, DownloadOptions{}, DownloadOptions{}); err == nil { + t.Fatal("multiple option values were accepted") + } +} + +func TestDownloadURLUsesHostCapabilityAndRejectsUnsafeSchemes(t *testing.T) { + const sourceURL = "https://cdn.example.com/files/report.bin?signature=secret" + target := FileTarget{Name: "report.bin"} + options := DownloadOptions{Representation: downloadcore.Immutable} + called := false + ctx := NewCommandContext(ContextOptions{DownloadURL: func(_ context.Context, gotURL string, gotTarget FileTarget, gotOptions DownloadOptions) (Artifact, error) { + called = true + if gotURL != sourceURL || gotTarget.Name != target.Name || gotTarget.IfExists != IfExistsFail || !reflect.DeepEqual(gotOptions, options) { + t.Fatalf("URL download input = %q, %#v, %#v", gotURL, gotTarget, gotOptions) + } + return Artifact{Name: target.Name, Location: target.Name, Size: 4}, nil + }}) + if _, err := DownloadURL(context.Background(), ctx, sourceURL, target, options); err != nil { + t.Fatal(err) + } + if !called { + t.Fatal("DownloadURL did not reach host capability") + } + for _, invalid := range []string{ + "http://example.com/file", "file:///tmp/file", "/relative/file", " https://example.com/file", + "https://user:password@example.com/file", "https://example.com/file#fragment", + } { + if _, err := DownloadURL(context.Background(), ctx, invalid, target); err == nil { + t.Errorf("DownloadURL(%q) error is nil", invalid) + } + } +} + +func TestDryRunCopiesFileIntents(t *testing.T) { + target := FileTarget{Name: "file.bin"} + dryRun := NewDryRun(GET("/open-apis/drive/v1/files/file_1/download")).File(target.Intent("OpenAPI response body")) + first := InspectDryRun(dryRun) + first.Files[0].Name = "mutated.bin" + second := InspectDryRun(dryRun) + if len(second.Files) != 1 || second.Files[0].Name != "file.bin" || second.Files[0].IfExists != IfExistsFail { + t.Fatalf("file intents = %#v", second.Files) + } +} diff --git a/extension/command/host.go b/extension/command/host.go new file mode 100644 index 0000000000..484c576901 --- /dev/null +++ b/extension/command/host.go @@ -0,0 +1,415 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package command + +import ( + "context" + "io" + "reflect" +) + +// HostDefinition is the erased, copied declaration consumed by lark-cli's host adapter. +// Business command implementations should use Definition and Define instead. +type HostDefinition struct { + Metadata CommandMetadata + Input InputDefinition + Output OutputDefinition + ArgsType reflect.Type + DataType reflect.Type + NewArgs func() any + Hooks HostHooks + PageOutput bool +} + +// HostHooks is the erased hook set consumed by lark-cli's host adapter. +type HostHooks struct { + Normalize func(context.Context, CommandContext, any) error + Validate func(context.Context, CommandContext, any) error + DryRun func(context.Context, CommandContext, any) *DryRun + Execute func(context.Context, CommandContext, any) (HostResult, error) + Renderers map[string]func(io.Writer, any) error +} + +// HostResult is the erased result projection consumed by lark-cli's host adapter. +type HostResult struct { + Data any + Outcome string + Pagination *HostPagination +} + +// HostPagination is the copied pagination metadata consumed by lark-cli's host +// adapter. It also appears in ContextOptions and commandtest, which supply the +// page-collection callback a CommandContext exposes to business commands. +type HostPagination struct { + Complete bool + Pages int + Items int + NextToken string +} + +// HostDomain is the copied domain declaration consumed by lark-cli's host adapter. +type HostDomain struct { + Name string +} + +type hostDefinition struct { + metadata CommandMetadata + input InputDefinition + output OutputDefinition + argsType reflect.Type + dataType reflect.Type + newArgs func() any + hooks HostHooks + pageOutput bool +} + +func newCommand[Args any, Data any](definition Definition[Args, Data]) Command { + return Command{definition: hostDefinition{ + metadata: cloneMetadata(definition.Metadata), + input: cloneInputDefinition(definition.Input), + output: cloneOutputDefinition(definition.Output), + argsType: reflect.TypeFor[Args](), + dataType: reflect.TypeFor[Data](), + newArgs: func() any { return new(Args) }, + hooks: bindHooks(definition.Hooks), + pageOutput: reflect.TypeFor[Data]().Implements(reflect.TypeFor[interface{ commandPagination() *paginationMeta }]()), + }} +} + +// bindHooks erases the typed hook set for the host adapter. Every binder +// re-asserts the concrete type because the erased call site can no longer +// prove it, and a nil hook must stay nil so the adapter can tell a hook +// apart from one that was never declared. +func bindHooks[Args any, Data any](hooks Hooks[Args, Data]) HostHooks { + return HostHooks{ + Normalize: bindArgsHook(hooks.Normalize, "Normalize"), + Validate: bindArgsHook(hooks.Validate, "Validate"), + DryRun: bindDryRunHook(hooks.DryRun), + Execute: bindExecuteHook(hooks.Execute), + Renderers: bindPrettyRenderer(hooks.PrettyRenderer), + } +} + +func bindArgsHook[Args any](hook func(context.Context, CommandContext, *Args) error, name string) func(context.Context, CommandContext, any) error { + if hook == nil { + return nil + } + return func(ctx context.Context, command CommandContext, args any) error { + typed, ok := args.(*Args) + if !ok { + return InternalErrorf("%s received %T, expected %T", name, args, (*Args)(nil)) + } + return hook(ctx, command, typed) + } +} + +func bindDryRunHook[Args any](hook func(context.Context, CommandContext, *Args) *DryRun) func(context.Context, CommandContext, any) *DryRun { + if hook == nil { + return nil + } + return func(ctx context.Context, command CommandContext, args any) *DryRun { + typed, ok := args.(*Args) + if !ok { + return nil + } + return hook(ctx, command, typed) + } +} + +func bindExecuteHook[Args any, Data any](hook func(context.Context, CommandContext, *Args) (Result[Data], error)) func(context.Context, CommandContext, any) (HostResult, error) { + if hook == nil { + return nil + } + return func(ctx context.Context, command CommandContext, args any) (HostResult, error) { + typed, ok := args.(*Args) + if !ok { + return HostResult{}, InternalErrorf("Execute received %T, expected %T", args, (*Args)(nil)) + } + result, err := hook(ctx, command, typed) + return hostResult(result), err + } +} + +// bindPrettyRenderer projects the single declarable renderer onto the host's +// name-keyed shape, which the compiler and the format machinery already speak. +func bindPrettyRenderer[Data any](renderer Renderer[Data]) map[string]func(io.Writer, any) error { + if renderer == nil { + return nil + } + return map[string]func(io.Writer, any) error{ + prettyFormatName: func(writer io.Writer, data any) error { + typed, ok := data.(Data) + if !ok { + var expected Data + return InternalErrorf("renderer received %T, expected %T", data, expected) + } + return renderer(writer, typed) + }, + } +} + +func hostResult[Data any](result Result[Data]) HostResult { + host := HostResult{Data: result.data, Outcome: string(result.outcome)} + if result.pagination != nil { + host.Pagination = &HostPagination{ + Complete: result.pagination.Complete, + Pages: result.pagination.Pages, + Items: result.pagination.Items, + NextToken: result.pagination.NextToken, + } + } + return host +} + +// InspectCommand returns a deep-copied declaration for lark-cli's host adapter. +func InspectCommand(command Command) HostDefinition { + definition := command.definition + return HostDefinition{ + Metadata: cloneMetadata(definition.metadata), + Input: cloneInputDefinition(definition.input), + Output: cloneOutputDefinition(definition.output), + ArgsType: definition.argsType, + DataType: definition.dataType, + NewArgs: definition.newArgs, + Hooks: cloneHostHooks(definition.hooks), + PageOutput: definition.pageOutput, + } +} + +// InspectDomain returns a copied declaration for lark-cli's host adapter. +func InspectDomain(domain Domain) HostDomain { + return HostDomain{Name: domain.name} +} + +// CloneSets copies set slices and immutable command declarations for BuildOption +// capture. It is intended for the lark-cli host adapter, not for business commands. +func CloneSets(sets []Set) []Set { + cloned := make([]Set, len(sets)) + for index, set := range sets { + cloned[index] = Set{Domain: set.Domain, Commands: append([]Command(nil), set.Commands...)} + } + return cloned +} + +func cloneHostHooks(hooks HostHooks) HostHooks { + cloned := hooks + if len(hooks.Renderers) > 0 { + cloned.Renderers = make(map[string]func(io.Writer, any) error, len(hooks.Renderers)) + for name, renderer := range hooks.Renderers { + cloned.Renderers[name] = renderer + } + } + return cloned +} + +func cloneMetadata(metadata CommandMetadata) CommandMetadata { + metadata.Authorization.IdentityOrder = append([]Identity(nil), metadata.Authorization.IdentityOrder...) + identities := make(map[Identity]IdentityAuthorization, len(metadata.Authorization.Identities)) + for identity, authorization := range metadata.Authorization.Identities { + authorization.RequiredScopes = append([]string(nil), authorization.RequiredScopes...) + authorization.ConditionalScopes = append([]ConditionalScope(nil), authorization.ConditionalScopes...) + for index := range authorization.ConditionalScopes { + conditional := &authorization.ConditionalScopes[index] + conditional.Scopes = append([]string(nil), conditional.Scopes...) + conditional.Params = append([]string(nil), conditional.Params...) + } + identities[identity] = authorization + } + metadata.Authorization.Identities = identities + return metadata +} + +func cloneInputDefinition(input InputDefinition) InputDefinition { + input.Fields = append([]InputField(nil), input.Fields...) + for index := range input.Fields { + field := &input.Fields[index] + field.Shape = cloneValueShape(field.Shape) + field.Default.Value = cloneJSONValue(field.Default.Value) + field.CLI.Aliases = append([]FlagAlias(nil), field.CLI.Aliases...) + field.CLI.ValueSources = append([]ValueSource(nil), field.CLI.ValueSources...) + } + input.Relations = append([]Relation(nil), input.Relations...) + for index := range input.Relations { + input.Relations[index].Params = append([]string(nil), input.Relations[index].Params...) + } + return input +} + +func cloneOutputDefinition(output OutputDefinition) OutputDefinition { + output.Data.Shape = cloneValueShape(output.Data.Shape) + output.Data.Overrides = append([]DataField(nil), output.Data.Overrides...) + for index := range output.Data.Overrides { + output.Data.Overrides[index].Shape = cloneValueShape(output.Data.Overrides[index].Shape) + } + return output +} + +func cloneValueShape(shape ValueShape) ValueShape { + switch typed := shape.(type) { + case nil: + return nil + case StringShape: + typed.Enum = append([]string(nil), typed.Enum...) + typed.MinLength = cloneScalarPointer(typed.MinLength) + typed.MaxLength = cloneScalarPointer(typed.MaxLength) + return typed + case BooleanShape: + typed.Enum = append([]bool(nil), typed.Enum...) + return typed + case IntegerShape: + typed.Enum = append([]int64(nil), typed.Enum...) + typed.Minimum = cloneScalarPointer(typed.Minimum) + typed.Maximum = cloneScalarPointer(typed.Maximum) + return typed + case NumberShape: + typed.Enum = append([]float64(nil), typed.Enum...) + typed.Minimum = cloneScalarPointer(typed.Minimum) + typed.Maximum = cloneScalarPointer(typed.Maximum) + return typed + case NullShape: + return typed + case ConstShape: + typed.Value = cloneJSONValue(typed.Value) + return typed + case ArrayShape: + typed.Items = cloneValueShape(typed.Items) + typed.MinItems = cloneScalarPointer(typed.MinItems) + typed.MaxItems = cloneScalarPointer(typed.MaxItems) + return typed + case ObjectShape: + typed.Fields = append([]ValueField(nil), typed.Fields...) + for index := range typed.Fields { + typed.Fields[index].Shape = cloneValueShape(typed.Fields[index].Shape) + } + typed.AdditionalPropertiesShape = cloneValueShape(typed.AdditionalPropertiesShape) + return typed + case OneOfShape: + typed.Variants = append([]ValueShape(nil), typed.Variants...) + for index := range typed.Variants { + typed.Variants[index] = cloneValueShape(typed.Variants[index]) + } + return typed + default: + return shape + } +} + +func cloneScalarPointer[T any](value *T) *T { + if value == nil { + return nil + } + cloned := *value + return &cloned +} + +func cloneJSONValue(value any) any { + if value == nil { + return nil + } + return cloneJSONReflect(reflect.ValueOf(value), make(map[cloneVisit]reflect.Value)).Interface() +} + +type cloneVisit struct { + typeOf reflect.Type + pointer uintptr + length int +} + +func cloneJSONReflect(value reflect.Value, seen map[cloneVisit]reflect.Value) reflect.Value { + if !value.IsValid() { + return value + } + switch value.Kind() { + case reflect.Interface: + return cloneJSONInterface(value, seen) + case reflect.Pointer: + return cloneJSONPointer(value, seen) + case reflect.Map: + return cloneJSONMap(value, seen) + case reflect.Slice: + return cloneJSONSlice(value, seen) + case reflect.Array: + return cloneJSONArray(value, seen) + case reflect.Struct: + return cloneJSONStruct(value, seen) + default: + return value + } +} + +func cloneJSONInterface(value reflect.Value, seen map[cloneVisit]reflect.Value) reflect.Value { + if value.IsNil() { + return reflect.Zero(value.Type()) + } + cloned := cloneJSONReflect(value.Elem(), seen) + result := reflect.New(value.Type()).Elem() + result.Set(cloned) + return result +} + +func cloneJSONPointer(value reflect.Value, seen map[cloneVisit]reflect.Value) reflect.Value { + if value.IsNil() { + return reflect.Zero(value.Type()) + } + visit := cloneVisit{typeOf: value.Type(), pointer: value.Pointer()} + if cloned, ok := seen[visit]; ok { + return cloned + } + result := reflect.New(value.Type().Elem()) + seen[visit] = result + result.Elem().Set(cloneJSONReflect(value.Elem(), seen)) + return result +} + +func cloneJSONMap(value reflect.Value, seen map[cloneVisit]reflect.Value) reflect.Value { + if value.IsNil() { + return reflect.Zero(value.Type()) + } + visit := cloneVisit{typeOf: value.Type(), pointer: value.Pointer()} + if cloned, ok := seen[visit]; ok { + return cloned + } + result := reflect.MakeMapWithSize(value.Type(), value.Len()) + seen[visit] = result + iterator := value.MapRange() + for iterator.Next() { + result.SetMapIndex(cloneJSONReflect(iterator.Key(), seen), cloneJSONReflect(iterator.Value(), seen)) + } + return result +} + +func cloneJSONSlice(value reflect.Value, seen map[cloneVisit]reflect.Value) reflect.Value { + if value.IsNil() { + return reflect.Zero(value.Type()) + } + visit := cloneVisit{typeOf: value.Type(), pointer: value.Pointer(), length: value.Len()} + if cloned, ok := seen[visit]; ok { + return cloned + } + result := reflect.MakeSlice(value.Type(), value.Len(), value.Len()) + seen[visit] = result + for index := 0; index < value.Len(); index++ { + result.Index(index).Set(cloneJSONReflect(value.Index(index), seen)) + } + return result +} + +func cloneJSONArray(value reflect.Value, seen map[cloneVisit]reflect.Value) reflect.Value { + result := reflect.New(value.Type()).Elem() + for index := 0; index < value.Len(); index++ { + result.Index(index).Set(cloneJSONReflect(value.Index(index), seen)) + } + return result +} + +func cloneJSONStruct(value reflect.Value, seen map[cloneVisit]reflect.Value) reflect.Value { + result := reflect.New(value.Type()).Elem() + result.Set(value) + for index := 0; index < value.NumField(); index++ { + if value.Type().Field(index).PkgPath == "" { + result.Field(index).Set(cloneJSONReflect(value.Field(index), seen)) + } + } + return result +} diff --git a/extension/command/output.go b/extension/command/output.go new file mode 100644 index 0000000000..309457ef8f --- /dev/null +++ b/extension/command/output.go @@ -0,0 +1,60 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package command + +// OutputDefinition declares result formats. +type OutputDefinition struct { + Data DataDefinition + Meta ResultMetaDefinition + Mode OutputMode + + DisableHTMLEscaping bool +} + +// ResultMetaDefinition declares standard metadata a command may return. +// +// Only Pagination is declarable here. A count field would be unproducible: the +// opaque Result carries data, outcome and pagination, and exposes no way to set +// a count, so declaring one would make schema advertise a field the runtime can +// never emit. +type ResultMetaDefinition struct { + Pagination bool +} + +// OutputMode selects the framework output behavior. +type OutputMode string + +const ( + // OutputGeneric uses the selected framework formatter. + OutputGeneric OutputMode = "" + // OutputFixedJSON always emits the standard JSON envelope. + OutputFixedJSON OutputMode = "fixed_json" +) + +type outcomeKind string + +// outcomeSuccess is the only outcome a business command declares. It doubles as +// the marker that Execute produced a Result at all, which is how the host tells +// a returned result apart from the zero value accompanying an error. +const outcomeSuccess outcomeKind = "success" + +// Result is an opaque command result created with Success. +type Result[Data any] struct { + data Data + outcome outcomeKind + pagination *paginationMeta +} + +// Success creates a complete successful result. +func Success[Data any](data Data) Result[Data] { + return resultWithOutcome(data, outcomeSuccess) +} + +func resultWithOutcome[Data any](data Data, outcome outcomeKind) Result[Data] { + result := Result[Data]{data: data, outcome: outcome} + if provider, ok := any(data).(interface{ commandPagination() *paginationMeta }); ok { + result.pagination = clonePaginationMeta(provider.commandPagination()) + } + return result +} diff --git a/extension/command/pagination.go b/extension/command/pagination.go new file mode 100644 index 0000000000..da302f6c49 --- /dev/null +++ b/extension/command/pagination.go @@ -0,0 +1,142 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package command + +import ( + "bytes" + "context" + "encoding/json" + "sort" + "strings" +) + +// 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"` + + meta *paginationMeta +} + +// Complete reports whether the API had no remaining page. +func (p Page[T]) Complete() bool { return p.meta != nil && p.meta.Complete } + +// NextToken returns the next page token for an incomplete result. +func (p Page[T]) NextToken() string { + if p.meta == nil { + return "" + } + return p.meta.NextToken +} + +// Pages returns the number of API pages collected. +func (p Page[T]) Pages() int { + if p.meta == nil { + return 0 + } + return p.meta.Pages +} + +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 + Pages int + Items int + NextToken string +} + +func clonePaginationMeta(meta *paginationMeta) *paginationMeta { + if meta == nil { + return nil + } + copy := *meta + return © +} + +// pageItems extracts the single top-level array field of one page's data +// object, so upstream spellings (items, records, files, ...) all normalize +// into Page.Items. Zero or multiple array fields fail closed instead of +// silently dropping rows: pagination bookkeeping (has_more, page_token) +// would otherwise keep walking while every page decodes to nothing. +func pageItems[T any](data map[string]any) ([]T, error) { + arrays := make([]string, 0, 1) + for key, value := range data { + if _, isArray := value.([]any); isArray { + arrays = append(arrays, key) + } + } + sort.Strings(arrays) + if len(arrays) == 0 { + return nil, InvalidResponseErrorf("pagination page has no top-level array field") + } + if len(arrays) > 1 { + return nil, InvalidResponseErrorf("pagination page has multiple top-level array fields: %s", strings.Join(arrays, ", ")) + } + encoded, err := json.Marshal(data[arrays[0]]) + if err != nil { + return nil, err + } + var items []T + decoder := json.NewDecoder(bytes.NewReader(encoded)) + decoder.UseNumber() + if err := decoder.Decode(&items); err != nil { + return nil, err + } + return items, nil +} + +// 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) { + 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, true) + if err != nil { + return nil, err + } + if !page.Complete() { + return nil, PaginationLimitError(page.Pages(), page.NextToken()) + } + return page.Items, nil +} + +func collectPages[T any](ctx context.Context, command CommandContext, request Request, all bool) (Page[T], error) { + // Items starts non-nil so a zero-item page encodes as [] rather than null: + // the field is declared required;nonnullable, and a caller generating types + // from that schema would reject the null. + result := Page[T]{Items: make([]T, 0), meta: &paginationMeta{}} + if err := validateRequest(request); err != nil { + return result, err + } + if command.inputStage { + return result, ValidationErrorf("network requests are unavailable in Normalize and Validate; move the call to Execute") + } + if command.dryRun { + return result, ValidationErrorf("network requests are unavailable during dry-run") + } + if command.collectPages == nil { + return result, InternalErrorf("command host does not provide pagination") + } + 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 { + items, decodeErr := pageItems[T](data) + if decodeErr != nil { + return result, InvalidResponseErrorf("decode pagination page %d: %v", pageNumber+1, decodeErr).WithCause(decodeErr) + } + result.Items = append(result.Items, items...) + } + result.meta.Items = len(result.Items) + return result, err +} diff --git a/extension/command/pagination_json_test.go b/extension/command/pagination_json_test.go new file mode 100644 index 0000000000..915774be39 --- /dev/null +++ b/extension/command/pagination_json_test.go @@ -0,0 +1,53 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package command + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +// Page.Items is declared required;nonnullable, so a zero-item collection must +// encode as [] -- a caller generating types from the published schema would +// reject null, and an empty result page is an ordinary outcome, not an error. +func TestEmptyPageEncodesAsArrayNotNull(t *testing.T) { + host := NewCommandContext(ContextOptions{ + CollectPages: func(context.Context, Request, bool) ([]map[string]any, HostPagination, error) { + return []map[string]any{{"items": []any{}, "has_more": false}}, HostPagination{Complete: true, Pages: 1}, nil + }, + }) + page, err := CollectPages[string](context.Background(), host, GET("/open-apis/im/v1/chats")) + if err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(page) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encoded), "null") { + t.Fatalf("empty page = %s", encoded) + } + if string(encoded) != `{"items":[]}` { + t.Fatalf("empty page = %s", encoded) + } +} + +// The same has to hold when the walk fails partway: the caller still receives a +// Page, and its Items must satisfy the published schema. +func TestFailedPageCollectionStillEncodesItemsAsArray(t *testing.T) { + host := NewCommandContext(ContextOptions{DryRun: true}) + page, err := CollectPages[string](context.Background(), host, GET("/open-apis/im/v1/chats")) + if err == nil { + t.Fatal("dry-run page collection returned no error") + } + encoded, marshalErr := json.Marshal(page) + if marshalErr != nil { + t.Fatal(marshalErr) + } + if string(encoded) != `{"items":[]}` { + t.Fatalf("failed page = %s", encoded) + } +} diff --git a/extension/command/request.go b/extension/command/request.go new file mode 100644 index 0000000000..ce7ff0bdea --- /dev/null +++ b/extension/command/request.go @@ -0,0 +1,136 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package command + +import ( + "net/http" + "net/url" + "path" + "strings" +) + +// Request is an opaque same-origin OpenAPI request description. +type Request struct { + method string + path string + query map[string]any + body any + description string +} + +// GET creates a GET OpenAPI request. +func GET(apiPath string) Request { return newRequest(http.MethodGet, apiPath) } + +// POST creates a POST OpenAPI request. +func POST(apiPath string) Request { return newRequest(http.MethodPost, apiPath) } + +// PUT creates a PUT OpenAPI request. +func PUT(apiPath string) Request { return newRequest(http.MethodPut, apiPath) } + +// PATCH creates a PATCH OpenAPI request. +func PATCH(apiPath string) Request { return newRequest(http.MethodPatch, apiPath) } + +// DELETE creates a DELETE OpenAPI request. +func DELETE(apiPath string) Request { return newRequest(http.MethodDelete, apiPath) } + +// PathSegment escapes one user-provided value for use as a single OpenAPI +// path segment. Every variable concatenated into a request path must be +// wrapped with it, mirroring the host convention (internal/validate +// EncodePathSegment); an unescaped separator or dot sequence would otherwise +// change the request target. +func PathSegment(s string) string { return url.PathEscape(s) } + +func newRequest(method, apiPath string) Request { + return Request{method: method, path: apiPath, query: make(map[string]any)} +} + +// Set adds or replaces one query parameter and returns a copied request. +func (r Request) Set(name string, value any) Request { + r.query = cloneAnyMap(r.query) + r.query[name] = cloneJSONValue(value) + return r +} + +// Params replaces all query parameters and returns a copied request. +func (r Request) Params(params map[string]any) Request { + r.query = cloneAnyMap(params) + return r +} + +// Body sets the JSON request body and returns a copied request. +func (r Request) Body(body any) Request { + r.body = cloneJSONValue(body) + return r +} + +// Desc adds a dry-run explanation and returns a copied request. +func (r Request) Desc(description string) Request { + r.description = description + return r +} + +// RequestView is the immutable host and test projection of a Request. +type RequestView struct { + Method string + Path string + Query map[string]any + Body any + Description string +} + +// InspectRequest returns a copied projection for host adapters and tests. +func InspectRequest(request Request) RequestView { + return RequestView{ + Method: request.method, + Path: request.path, + Query: cloneAnyMap(request.query), + Body: cloneJSONValue(request.body), + Description: request.description, + } +} + +func validateRequest(request Request) error { + return ValidateRequestView(InspectRequest(request)) +} + +// ValidateRequestView checks the same-origin OpenAPI boundary for host adapters and tests. +func ValidateRequestView(request RequestView) error { + if request.Method != http.MethodGet && request.Method != http.MethodPost && request.Method != http.MethodPut && request.Method != http.MethodPatch && request.Method != http.MethodDelete { + return ValidationErrorf("unsupported OpenAPI request method %q", request.Method) + } + rawPath := strings.TrimSpace(request.Path) + if rawPath == "" || rawPath != request.Path { + return ValidationErrorf("OpenAPI request path must be a non-empty trimmed relative path") + } + parsed, err := url.Parse(rawPath) + if err != nil { + return ValidationErrorf("invalid OpenAPI request path %q: %v", rawPath, err) + } + if parsed.IsAbs() || parsed.Host != "" || parsed.Scheme != "" || parsed.RawQuery != "" || parsed.Fragment != "" { + return ValidationErrorf("OpenAPI request path must be same-origin and contain no query or fragment: %q", rawPath) + } + if !strings.HasPrefix(parsed.Path, "/open-apis/") { + return ValidationErrorf("OpenAPI request path must start with /open-apis/: %q", rawPath) + } + if cleaned := path.Clean(parsed.Path); cleaned != parsed.Path || strings.Contains(parsed.Path, "/../") { + return ValidationErrorf("OpenAPI request path is not canonical: %q", rawPath) + } + for name := range request.Query { + if strings.TrimSpace(name) == "" || name != strings.TrimSpace(name) { + return ValidationErrorf("OpenAPI query parameter name %q is invalid", name) + } + } + return nil +} + +func cloneAnyMap(input map[string]any) map[string]any { + if len(input) == 0 { + return map[string]any{} + } + result := make(map[string]any, len(input)) + for key, value := range input { + result[key] = cloneJSONValue(value) + } + return result +} diff --git a/extension/command/result_protocol.go b/extension/command/result_protocol.go new file mode 100644 index 0000000000..9989fbf489 --- /dev/null +++ b/extension/command/result_protocol.go @@ -0,0 +1,57 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package command + +// ValidateHostResult checks one erased Execute result against the protocol the +// framework depends on. Both lark-cli's host adapter and commandtest call it, +// so a Result the real CLI refuses can no longer pass a business command's own +// tests -- the divergence that mattered was a zero-value Result reading as a +// successful call in commandtest while every real invocation failed. +func ValidateHostResult(definition HostDefinition, result HostResult) error { + if err := validateHostOutcome(result.Outcome); err != nil { + return err + } + declaresPagination := definition.Output.Meta.Pagination || definition.PageOutput + return validateHostPagination(declaresPagination, result.Pagination) +} + +// validateHostOutcome rejects any outcome the framework did not produce. The +// empty outcome is the case worth naming: it means Execute returned Result{} +// instead of going through Success, which is indistinguishable from a real +// result everywhere except here. +func validateHostOutcome(outcome string) error { + switch outcome { + case string(outcomeSuccess): + return nil + case "": + return InternalErrorf("business Execute returned a Result without an outcome; return command.Success(data), or a non-nil error") + default: + return InternalErrorf("business Execute returned unsupported outcome %q", outcome) + } +} + +// validateHostPagination mirrors the receipt checks the host applies to +// pagination metadata, so a Page command cannot report a state the framework +// would reject when it renders the envelope. +func validateHostPagination(declared bool, pagination *HostPagination) error { + if pagination == nil { + return nil + } + if !declared { + return InternalErrorf("business Execute returned pagination metadata for a command that declares no Page output") + } + if pagination.Pages < 1 { + return InternalErrorf("business Execute returned pagination pages %d, want at least 1", pagination.Pages) + } + if pagination.Items < 0 { + return InternalErrorf("business Execute returned negative pagination items %d", pagination.Items) + } + if pagination.Complete && pagination.NextToken != "" { + return InternalErrorf("business Execute returned a complete page carrying a next token") + } + if !pagination.Complete && pagination.NextToken == "" { + return InternalErrorf("business Execute returned an incomplete page without a next token") + } + return nil +} diff --git a/extension/command/result_protocol_test.go b/extension/command/result_protocol_test.go new file mode 100644 index 0000000000..41d3137d86 --- /dev/null +++ b/extension/command/result_protocol_test.go @@ -0,0 +1,117 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package command + +import ( + "strings" + "testing" +) + +func pageDefinition() HostDefinition { + return HostDefinition{PageOutput: true} +} + +func TestValidateHostResultRejectsInvalidOutcomes(t *testing.T) { + tests := []struct { + name string + outcome string + want string + }{ + {"empty outcome", "", "without an outcome"}, + {"unknown outcome", "partial", `unsupported outcome "partial"`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateHostResult(HostDefinition{}, HostResult{Outcome: tt.outcome}) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error = %v, want containing %q", err, tt.want) + } + }) + } +} + +func TestValidateHostResultRejectsInvalidPagination(t *testing.T) { + tests := []struct { + name string + definition HostDefinition + pagination *HostPagination + want string + }{ + { + "undeclared pagination", + HostDefinition{}, + &HostPagination{Complete: true, Pages: 1}, + "declares no Page output", + }, + { + "zero pages", + pageDefinition(), + &HostPagination{Complete: true, Pages: 0}, + "pagination pages 0", + }, + { + "negative items", + pageDefinition(), + &HostPagination{Complete: true, Pages: 1, Items: -1}, + "negative pagination items", + }, + { + "complete with next token", + pageDefinition(), + &HostPagination{Complete: true, Pages: 1, NextToken: "tok"}, + "complete page carrying a next token", + }, + { + "incomplete without next token", + pageDefinition(), + &HostPagination{Complete: false, Pages: 1}, + "incomplete page without a next token", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := HostResult{Outcome: string(outcomeSuccess), Pagination: tt.pagination} + err := ValidateHostResult(tt.definition, result) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error = %v, want containing %q", err, tt.want) + } + }) + } +} + +func TestValidateHostResultAcceptsDeclaredResults(t *testing.T) { + tests := []struct { + name string + definition HostDefinition + result HostResult + }{ + { + "plain success", + HostDefinition{}, + HostResult{Outcome: string(outcomeSuccess)}, + }, + { + "complete page", + pageDefinition(), + HostResult{Outcome: string(outcomeSuccess), Pagination: &HostPagination{Complete: true, Pages: 2, Items: 7}}, + }, + { + "incomplete page with cursor", + pageDefinition(), + HostResult{Outcome: string(outcomeSuccess), Pagination: &HostPagination{Pages: 1, Items: 3, NextToken: "tok"}}, + }, + { + "pagination declared through Output.Meta", + HostDefinition{Output: OutputDefinition{Meta: ResultMetaDefinition{Pagination: true}}}, + HostResult{Outcome: string(outcomeSuccess), Pagination: &HostPagination{Complete: true, Pages: 1}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := ValidateHostResult(tt.definition, tt.result); err != nil { + t.Fatalf("ValidateHostResult() error = %v, want nil", err) + } + }) + } +} diff --git a/extension/command/shape.go b/extension/command/shape.go new file mode 100644 index 0000000000..22ba412eea --- /dev/null +++ b/extension/command/shape.go @@ -0,0 +1,86 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package command + +// ValueShape is the closed set of JSON shapes accepted by command definitions. +type ValueShape interface{ valueShape() } + +// StringShape describes a JSON string. +type StringShape struct { + Enum []string + Format string + MinLength *int + MaxLength *int +} + +// BooleanShape describes a JSON boolean. +type BooleanShape struct{ Enum []bool } + +// IntegerShape describes a JSON integer. +type IntegerShape struct { + Enum []int64 + Minimum *int64 + Maximum *int64 +} + +// NumberShape describes a JSON number. +type NumberShape struct { + Enum []float64 + Minimum *float64 + Maximum *float64 +} + +// NullShape describes JSON null. +type NullShape struct{} + +// ConstShape describes one exact JSON value. +type ConstShape struct{ Value JSONValue } + +// ArrayShape describes a JSON array. +type ArrayShape struct { + Items ValueShape + MinItems *int + MaxItems *int +} + +// ObjectShape describes a JSON object. +type ObjectShape struct { + Fields []ValueField + AdditionalProperties bool + AdditionalPropertiesShape ValueShape +} + +// ValueField describes one property of an ObjectShape. +type ValueField struct { + Name string + Description string + Required bool + Shape ValueShape +} + +// OneOfShape describes a value matching one of several shapes. +type OneOfShape struct{ Variants []ValueShape } + +func (StringShape) valueShape() {} +func (BooleanShape) valueShape() {} +func (IntegerShape) valueShape() {} +func (NumberShape) valueShape() {} +func (NullShape) valueShape() {} +func (ConstShape) valueShape() {} +func (ArrayShape) valueShape() {} +func (ObjectShape) valueShape() {} +func (OneOfShape) valueShape() {} + +// DataDefinition supplements the schema inferred from Data. +type DataDefinition struct { + Shape ValueShape + Overrides []DataField +} + +// DataField overrides one output field selected by a JSON pointer. +type DataField struct { + Path string + Description string + Shape ValueShape +} diff --git a/extension/command/testdata/wrapper/main.go b/extension/command/testdata/wrapper/main.go new file mode 100644 index 0000000000..c136dfe5f2 --- /dev/null +++ b/extension/command/testdata/wrapper/main.go @@ -0,0 +1,157 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package main + +import ( + "context" + "os" + + "github.com/larksuite/cli/cmd" + "github.com/larksuite/cli/extension/command" + "github.com/larksuite/cli/extension/download" + + _ "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"` +} + +// readRequest is shared by DryRun and Execute so the preview cannot drift from +// the real call. User input concatenated into the path goes through PathSegment. +func readRequest(args *readArgs) command.Request { + return command.GET("/open-apis/im/v1/chats/" + command.PathSegment(args.ID)) +} + +var readCommand = command.Define(command.Definition[readArgs, readData]{ + Metadata: command.CommandMetadata{ + Service: command.DomainIm, 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]{ + DryRun: func(_ context.Context, _ command.CommandContext, args *readArgs) *command.DryRun { + return command.NewDryRun(readRequest(args)) + }, + Execute: func(ctx context.Context, commandContext command.CommandContext, args *readArgs) (command.Result[readData], error) { + data, err := command.CallJSON[readData](ctx, commandContext, readRequest(args)) + if err != nil { + return command.Result[readData]{}, err + } + return command.Success(data), nil + }, + }, +}) + +type backupArgs struct { + FileToken string `flag:"file-token" schema:"required;minLength=1" doc:"file token"` + Output string `flag:"output" schema:"required;minLength=1" doc:"logical output name"` +} + +type backupDescriptor struct { + DownloadURL string `json:"download_url"` +} + +type backupData struct { + FileToken string `json:"file_token" schema:"required" doc:"backed-up file token"` + Artifact command.Artifact `json:"artifact" schema:"required" doc:"saved backup artifact"` +} + +func backupDescriptorRequest(args *backupArgs) command.Request { + return command.GET("/open-apis/drive/v1/files/" + command.PathSegment(args.FileToken) + "/download_url") +} + +func backupTarget(args *backupArgs) command.FileTarget { + return command.FileTarget{Name: args.Output} +} + +var backupCommand = command.Define(command.Definition[backupArgs, backupData]{ + Metadata: command.CommandMetadata{ + Service: command.DomainDrive, Command: "+wrapper-backup", Description: "Resolve and save one file backup", Risk: command.RiskWrite, + Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{ + command.IdentityUser: {RequiredScopes: []string{"drive:drive.metadata:readonly", "drive:file:download"}}, + }}, + }, + Hooks: command.Hooks[backupArgs, backupData]{ + DryRun: func(_ context.Context, _ command.CommandContext, args *backupArgs) *command.DryRun { + return command.NewDryRun(backupDescriptorRequest(args).Desc("resolve a short-lived download URL")). + File(backupTarget(args).Intent("file content returned by the resolved URL")) + }, + Execute: func(ctx context.Context, commandContext command.CommandContext, args *backupArgs) (command.Result[backupData], error) { + descriptor, err := command.CallJSON[backupDescriptor](ctx, commandContext, backupDescriptorRequest(args)) + if err != nil { + return command.Result[backupData]{}, err + } + artifact, err := command.DownloadURL(ctx, commandContext, descriptor.DownloadURL, backupTarget(args), command.DownloadOptions{ + Representation: download.Immutable, + }) + if err != nil { + return command.Result[backupData]{}, err + } + return command.Success(backupData{FileToken: args.FileToken, Artifact: artifact}), nil + }, + }, +}) + +type noteArgs struct { + ChatID string `flag:"chat-id" schema:"required;minLength=1" doc:"chat ID"` + Content string `flag:"content" schema:"required;minLength=1" doc:"note body"` +} + +type noteData struct { + MessageID string `json:"message_id" schema:"required" doc:"created message ID"` +} + +func noteRequest(args *noteArgs) command.Request { + return command.POST("/open-apis/im/v1/messages"). + Set("receive_id_type", "chat_id"). + Body(map[string]any{"receive_id": args.ChatID, "content": args.Content}) +} + +// noteCommand carries a body large enough to hit shell quoting limits, so it +// declares the file and stdin sources: --content @./note.xml and --content - +// both reach Execute already substituted with the content. +var noteCommand = command.Define(command.Definition[noteArgs, noteData]{ + Metadata: command.CommandMetadata{ + Service: command.DomainIm, Command: "+wrapper-note", Description: "Post one note from inline text, @file or stdin", Risk: command.RiskWrite, + Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{ + command.IdentityUser: {RequiredScopes: []string{"im:message:send_as_bot"}}, + }}, + }, + Input: command.InputDefinition{Fields: []command.InputField{{ + Name: "content", + CLI: command.CLIInput{ValueSources: []command.ValueSource{ + command.SourceFlag, command.SourceFile, command.SourceStdin, + }}, + }}}, + Hooks: command.Hooks[noteArgs, noteData]{ + DryRun: func(_ context.Context, _ command.CommandContext, args *noteArgs) *command.DryRun { + return command.NewDryRun(noteRequest(args)) + }, + Execute: func(ctx context.Context, commandContext command.CommandContext, args *noteArgs) (command.Result[noteData], error) { + data, err := command.CallJSON[noteData](ctx, commandContext, noteRequest(args)) + if err != nil { + return command.Result[noteData]{}, err + } + return command.Success(data), nil + }, + }, +}) + +func main() { + os.Exit(cmd.ExecuteWithOptions( + cmd.WithCommandSets( + command.Set{Domain: command.ExtendDomain(command.DomainIm), Commands: []command.Command{readCommand, noteCommand}}, + command.Set{Domain: command.ExtendDomain(command.DomainDrive), Commands: []command.Command{backupCommand}}, + ), + cmd.WithoutPlugins(), + cmd.WithoutStrictMode(), + cmd.WithoutServiceCommands(), + )) +} diff --git a/extension/command/wrapper_e2e_test.go b/extension/command/wrapper_e2e_test.go new file mode 100644 index 0000000000..c844fe3db6 --- /dev/null +++ b/extension/command/wrapper_e2e_test.go @@ -0,0 +1,119 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package command_test + +import ( + "io" + "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", + ) + // @file paths resolve against the process working directory, so the input + // cases run from a scratch directory instead of the package tree. + runFrom := func(dir string, stdin io.Reader, args ...string) string { + t.Helper() + process := exec.Command(binary, args...) + process.Dir = dir + process.Stdin = stdin + 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) + } + run := func(args ...string) string { + t.Helper() + return runFrom("", nil, args...) + } + + 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) + } + backupDryRun := run("drive", "+wrapper-backup", "--file-token", "file_1", "--output", "reports/file.bin", "--as", "user", "--dry-run") + if !strings.Contains(backupDryRun, "/open-apis/drive/v1/files/file_1/download_url") || !strings.Contains(backupDryRun, `"files"`) || + !strings.Contains(backupDryRun, `"name": "reports/file.bin"`) || !strings.Contains(backupDryRun, `"if_exists": "fail"`) { + t.Fatalf("wrapper backup dry-run = %s", backupDryRun) + } + // A value that is unchanged by escaping cannot tell whether the wrapper + // wrapped it at all, so send a separator: dropping PathSegment retargets + // the request at a sibling resource, and ValidateRequestView would not + // catch it because the concatenated path is still canonical. + escaped := run("im", "+wrapper-read", "--id", "chat/1", "--as", "user", "--dry-run") + if !strings.Contains(escaped, "/open-apis/im/v1/chats/chat%2F1") { + t.Fatalf("wrapper did not escape the path segment: %s", escaped) + } + if strings.Contains(escaped, "/open-apis/im/v1/chats/chat/1") { + t.Fatalf("wrapper leaked an unescaped separator into the path: %s", escaped) + } + inputDir := t.TempDir() + // The fixture keeps the markup and quoting that make callers reach for + // @file, but the assertion uses the marker alone: the body is JSON, so + // angle brackets and quotes arrive escaped. + const noteMarker = "and $shell chars" + noteContent := `
quotes " ` + noteMarker + `
` + if err := os.WriteFile(filepath.Join(inputDir, "note.xml"), []byte(noteContent), 0o600); err != nil { + t.Fatal(err) + } + fileInput := runFrom(inputDir, nil, "im", "+wrapper-note", "--chat-id", "oc_1", "--content", "@./note.xml", "--as", "user", "--dry-run") + if !strings.Contains(fileInput, noteMarker) { + t.Fatalf("wrapper did not substitute the @file content: %s", fileInput) + } + stdinInput := runFrom(inputDir, strings.NewReader(noteContent), "im", "+wrapper-note", "--chat-id", "oc_1", "--content", "-", "--as", "user", "--dry-run") + if !strings.Contains(stdinInput, noteMarker) { + t.Fatalf("wrapper did not substitute the stdin content: %s", stdinInput) + } + noteHelp := run("im", "+wrapper-note", "--help") + if !strings.Contains(noteHelp, "@file") || !strings.Contains(noteHelp, "stdin with -") { + t.Fatalf("wrapper note help = %s", noteHelp) + } + + completion := run("__complete", "im", "+wrap") + if !strings.Contains(completion, "+wrapper-read") { + t.Fatalf("wrapper completion = %s", completion) + } +} + +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 +} diff --git a/extension/download/README.md b/extension/download/README.md new file mode 100644 index 0000000000..29712510d4 --- /dev/null +++ b/extension/download/README.md @@ -0,0 +1,67 @@ +# Download extension + +`extension/download` is the public, transport-neutral download engine. It owns +range probing, multipart assembly, bounded retries, idle timeouts, response +validation, cancellation, and exact-length checks. + +The caller supplies a `Transport`. Authentication, URL trust policy, and HTTP +error classification belong to that transport; storage belongs to the consumer +of `Stream.Body`. + +Choose the representation contract deliberately: + +- `download.MutableSource`: safe default. Multipart assembly requires a strong + ETag; otherwise the engine restarts with one full response. +- `download.ImmutableSource`: permits multipart assembly without an ETag because + the caller guarantees the source identifier pins the bytes. + +External commands normally use the higher-level `command.Download`, which wires +an authenticated OpenAPI transport and invocation-scoped FileIO while retaining +the same engine options: + +```go +target := command.FileTarget{Name: args.Output} +options := command.DownloadOptions{ + Representation: download.Immutable, + Transfer: download.Options{ + PartSize: 8 << 20, + }, +} + +// Dry-run reports the logical destination without opening a stream. +dryRun := command.NewDryRun(request).File( + target.Intent("OpenAPI response body"), +) + +// Execute streams and saves the file; use the host-resolved location. +artifact, err := command.Download( + ctx, + commandContext, + request, + target, + options, +) +``` + +The zero `command.DownloadOptions` value selects `download.Mutable` and the +production transfer defaults. Existing targets fail by default; overwriting +requires `FileTarget{IfExists: command.IfExistsOverwrite}`. + +For a pre-signed or CDN URL, use `command.DownloadURL` with the same target and +options. It accepts HTTPS only and routes through the host's external-request, +SSRF, DNS/IP pinning, and redirect policies. Dry-run should describe the file +intent without echoing a signed URL: + +```go +dryRun := command.NewDryRun(). + Desc("Download an external HTTPS resource"). + File(target.Intent("external URL response body")) + +artifact, err := command.DownloadURL( + ctx, + commandContext, + args.URL, + target, + options, +) +``` diff --git a/internal/download/download.go b/extension/download/download.go similarity index 95% rename from internal/download/download.go rename to extension/download/download.go index d91f2f42c7..5ecbf6dcbf 100644 --- a/internal/download/download.go +++ b/extension/download/download.go @@ -1,7 +1,11 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -// Package download provides validated full and ranged streams. +// Package download provides validated full and ranged streams for extensions +// and built-in commands. It owns transfer bounds, retries, representation +// consistency, progress timeouts, and response-length checks; callers supply a +// Transport that owns authentication and source/URL policy, and they choose the +// destination that consumes Stream.Body. package download import ( @@ -36,6 +40,8 @@ const ( type ByteRange struct { Start int64 End int64 + + _ struct{} } // HeaderValue returns the value for an HTTP Range header. @@ -47,6 +53,8 @@ func (r ByteRange) HeaderValue() string { type Request struct { Range *ByteRange IfRange string + + _ struct{} } // Headers keeps byte offsets tied to the transferred representation. @@ -69,7 +77,11 @@ type Transport func(context.Context, Request) (*http.Response, error) // Options controls multipart behavior. Zero values select production defaults. type Options struct { - PartSize int64 + // PartSize is the maximum byte count requested per range. Zero selects + // DefaultPartSize. + PartSize int64 + // MaxResponses bounds the total responses in one logical stream. Zero derives + // a bound from the declared object size and PartSize. MaxResponses int // MaxPartRetries bounds retries per range. Zero selects the default. MaxPartRetries int @@ -82,13 +94,20 @@ type Options struct { IdleTimeout time.Duration // DisableMultipart forces one full response. DisableMultipart bool + + _ struct{} } // Stream is one logical full or multipart response. type Stream struct { - Body io.ReadCloser - Header http.Header + // Body joins all validated parts and must be closed by the caller. + Body io.ReadCloser + // Header is a copy of the first successful response headers. + Header http.Header + // ContentLength is the validated total size, or -1 when unknown. ContentLength int64 + + _ struct{} } // Open probes range support and returns one validated stream. @@ -273,7 +292,7 @@ func validateOptions(source Source, opts Options) error { if source.transport == nil { return errs.NewInternalError(errs.SubtypeUnknown, "download requires a configured transport") } - if source.representation != immutableRepresentation && source.representation != mutableRepresentation { + if source.representation != Immutable && source.representation != Mutable { return errs.NewInternalError(errs.SubtypeUnknown, "download requires an explicit representation contract") } if opts.PartSize <= 0 { diff --git a/internal/download/download_test.go b/extension/download/download_test.go similarity index 100% rename from internal/download/download_test.go rename to extension/download/download_test.go diff --git a/internal/download/exact_length.go b/extension/download/exact_length.go similarity index 100% rename from internal/download/exact_length.go rename to extension/download/exact_length.go diff --git a/internal/download/exact_length_test.go b/extension/download/exact_length_test.go similarity index 100% rename from internal/download/exact_length_test.go rename to extension/download/exact_length_test.go diff --git a/internal/download/idle_timeout.go b/extension/download/idle_timeout.go similarity index 100% rename from internal/download/idle_timeout.go rename to extension/download/idle_timeout.go diff --git a/internal/download/idle_timeout_test.go b/extension/download/idle_timeout_test.go similarity index 100% rename from internal/download/idle_timeout_test.go rename to extension/download/idle_timeout_test.go diff --git a/extension/download/public_contract_test.go b/extension/download/public_contract_test.go new file mode 100644 index 0000000000..66760245f5 --- /dev/null +++ b/extension/download/public_contract_test.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package download_test + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "testing" + + "github.com/larksuite/cli/extension/download" +) + +// This external-package test pins that extensions can implement Transport and +// opt into immutable multipart transfer using only the public package. +func TestPublicImmutableMultipartContract(t *testing.T) { + payload := []byte("abcdefgh") + var ranges []string + transport := download.Transport(func(_ context.Context, request download.Request) (*http.Response, error) { + if request.Range == nil { + t.Fatal("immutable multipart unexpectedly used a full request") + } + ranges = append(ranges, request.Range.HeaderValue()) + start, end := request.Range.Start, min(request.Range.End, int64(len(payload)-1)) + body := payload[start : end+1] + return &http.Response{ + StatusCode: http.StatusPartialContent, + Header: http.Header{ + "Content-Range": {fmt.Sprintf("bytes %d-%d/%d", start, end, len(payload))}, + }, + Body: io.NopCloser(bytes.NewReader(body)), + ContentLength: int64(len(body)), + }, nil + }) + + stream, err := download.Open(context.Background(), download.ImmutableSource(transport), download.Options{PartSize: 4}) + if err != nil { + t.Fatal(err) + } + defer stream.Body.Close() + content, err := io.ReadAll(stream.Body) + if err != nil || !bytes.Equal(content, payload) { + t.Fatalf("download = %q, %v", content, err) + } + if len(ranges) != 2 || ranges[0] != "bytes=0-3" || ranges[1] != "bytes=4-7" { + t.Fatalf("ranges = %#v", ranges) + } +} diff --git a/internal/download/response.go b/extension/download/response.go similarity index 100% rename from internal/download/response.go rename to extension/download/response.go diff --git a/internal/download/response_test.go b/extension/download/response_test.go similarity index 100% rename from internal/download/response_test.go rename to extension/download/response_test.go diff --git a/internal/download/source.go b/extension/download/source.go similarity index 80% rename from internal/download/source.go rename to extension/download/source.go index 94663f30dd..5e01b61e91 100644 --- a/internal/download/source.go +++ b/extension/download/source.go @@ -7,33 +7,37 @@ import ( "net/http" ) -type representationContract uint8 +// Representation declares whether repeated range requests are guaranteed to +// address the same bytes. +type Representation string const ( - representationUnspecified representationContract = iota - immutableRepresentation - mutableRepresentation + // Mutable requires a strong ETag before Open combines multiple responses. + Mutable Representation = "mutable" + // Immutable permits multipart reads without an ETag because the caller + // guarantees that the source identifier pins one representation. + Immutable Representation = "immutable" ) // Source binds a transport to its representation stability. type Source struct { transport Transport - representation representationContract + representation Representation } // ImmutableSource allows multipart reads without a validator. func ImmutableSource(transport Transport) Source { - return Source{transport: transport, representation: immutableRepresentation} + return Source{transport: transport, representation: Immutable} } // MutableSource requires a strong ETag before combining responses. func MutableSource(transport Transport) Source { - return Source{transport: transport, representation: mutableRepresentation} + return Source{transport: transport, representation: Mutable} } type representationSession struct { transport Transport - contract representationContract + contract Representation totalSize int64 validator string hasValidator bool @@ -51,7 +55,7 @@ func newRepresentationSession(source Source, first contentRange, header http.Hea } func (s *representationSession) multipartAllowed() bool { - return s.contract == immutableRepresentation || s.hasValidator + return s.contract == Immutable || s.hasValidator } func (s *representationSession) request(byteRange ByteRange) Request { diff --git a/extension/fileio/types.go b/extension/fileio/types.go index b5ae9f12ce..0e033b951d 100644 --- a/extension/fileio/types.go +++ b/extension/fileio/types.go @@ -42,6 +42,24 @@ type FileIO interface { Save(path string, opts SaveOptions, body io.Reader) (SaveResult, error) } +// ExclusiveFileIO is an optional extension for providers whose commit step can +// refuse an existing target. +// +// A no-clobber policy cannot be honoured by checking existence and then calling +// Save: another writer may create the target between the two, and the commit +// would overwrite it. Only a provider that makes the commit itself exclusive can +// promise otherwise, so a provider that cannot must leave this interface +// unimplemented -- callers then reject the policy explicitly instead of +// appearing to enforce it. +type ExclusiveFileIO interface { + FileIO + + // SaveExclusive writes content to path only when path does not exist, and + // returns an error satisfying errors.Is(err, fs.ErrExist) when it does. + // A failed write must not leave a partial artifact at path. + SaveExclusive(path string, opts SaveOptions, body io.Reader) (SaveResult, error) +} + // WorkspaceFileIO is an optional extension for commands that own temporary // workspace entries. RemoveWorkspaceEntry must remove exactly one file or one // empty directory, never recursively, and must apply the same path validation diff --git a/internal/cmdmeta/meta.go b/internal/cmdmeta/meta.go index 81d7624cd3..a3e1c2055f 100644 --- a/internal/cmdmeta/meta.go +++ b/internal/cmdmeta/meta.go @@ -31,6 +31,8 @@ package cmdmeta import ( + "encoding/json" + "github.com/spf13/cobra" "github.com/larksuite/cli/internal/cmdutil" @@ -59,6 +61,7 @@ const ( // +-prefixed shortcuts set these so help rendering shares one lookup path. affordanceServiceKey = "cmdmeta.affordance.service" affordanceMethodKey = "cmdmeta.affordance.method" + declaredScopesKey = "cmdmeta.declared_scopes" ) // Meta groups the three command-level metadata axes consumed by the policy @@ -162,6 +165,30 @@ func AffordanceRef(cmd *cobra.Command) (service, method string, ok bool) { return service, method, true } +// SetDeclaredScopes stores build-local shortcut scopes by identity. +func SetDeclaredScopes(cmd *cobra.Command, scopes map[string][]string) { + encoded, err := json.Marshal(scopes) + if err != nil { + return + } + if cmd.Annotations == nil { + cmd.Annotations = map[string]string{} + } + cmd.Annotations[declaredScopesKey] = string(encoded) +} + +// DeclaredScopes returns copied shortcut scopes stored on this command. +func DeclaredScopes(cmd *cobra.Command, identity string) []string { + if cmd == nil || cmd.Annotations == nil { + return nil + } + var scopes map[string][]string + if err := json.Unmarshal([]byte(cmd.Annotations[declaredScopesKey]), &scopes); err != nil { + return nil + } + return append([]string(nil), scopes[identity]...) +} + // Domain returns the nearest-ancestor domain for the command. Empty string // when no ancestor has the annotation -- this is the "unknown" state the // policy engine must treat as ALLOW. diff --git a/internal/cmdutil/dryrun.go b/internal/cmdutil/dryrun.go index 4afa6f75f5..5fe53816e3 100644 --- a/internal/cmdutil/dryrun.go +++ b/internal/cmdutil/dryrun.go @@ -40,6 +40,13 @@ type DryRunAPICall struct { Body interface{} `json:"body,omitempty"` } +// DryRunFileIntent describes one logical file effect without touching storage. +type DryRunFileIntent struct { + Name string `json:"name"` + IfExists string `json:"if_exists,omitempty"` + Content string `json:"content,omitempty"` +} + // DryRunContext is the execution context shared by every dry-run preview: // which app would make the call and, when known, as which user. The identity // itself lives at the envelope top level, not here. @@ -53,6 +60,7 @@ type DryRunContext struct { type DryRunAPI struct { desc string calls []DryRunAPICall + files []DryRunFileIntent context *DryRunContext extra map[string]interface{} } @@ -113,6 +121,13 @@ func (d *DryRunAPI) Set(key string, value interface{}) *DryRunAPI { return d } +// File appends a logical output intent. It never resolves the destination or +// writes content; the live command owns those effects. +func (d *DryRunAPI) File(intent DryRunFileIntent) *DryRunAPI { + d.files = append(d.files, intent) + return d +} + // Context records the calling app/user under data.context; empty values are // omitted, and a fully empty context is not emitted at all. func (d *DryRunAPI) Context(appID, userOpenID string) *DryRunAPI { @@ -147,7 +162,7 @@ func (d *DryRunAPI) MarshalJSON() ([]byte, error) { Body: c.Body, } } - m := make(map[string]interface{}, len(d.extra)+3) + m := make(map[string]interface{}, len(d.extra)+4) for k, v := range d.extra { m[k] = v } @@ -156,6 +171,9 @@ func (d *DryRunAPI) MarshalJSON() ([]byte, error) { m["description"] = d.desc } m["api"] = resolved + if len(d.files) > 0 { + m["files"] = append([]DryRunFileIntent(nil), d.files...) + } if d.context != nil { m["context"] = d.context } @@ -200,7 +218,29 @@ func (d *DryRunAPI) Format() string { } } - if len(d.calls) == 0 && len(d.extra) > 0 { + if len(d.files) > 0 { + if len(d.calls) > 0 || d.desc != "" { + b.WriteByte('\n') + } + b.WriteString("# File output\n") + for _, file := range d.files { + b.WriteString("WRITE ") + b.WriteString(file.Name) + if file.IfExists != "" { + b.WriteString(" (if exists: ") + b.WriteString(file.IfExists) + b.WriteByte(')') + } + b.WriteByte('\n') + if file.Content != "" { + b.WriteString(" content: ") + b.WriteString(file.Content) + b.WriteByte('\n') + } + } + } + + if len(d.calls) == 0 && len(d.files) == 0 && len(d.extra) > 0 { if d.desc != "" { b.WriteByte('\n') } diff --git a/internal/cmdutil/dryrun_test.go b/internal/cmdutil/dryrun_test.go index 6056bce188..1749820891 100644 --- a/internal/cmdutil/dryrun_test.go +++ b/internal/cmdutil/dryrun_test.go @@ -115,6 +115,33 @@ func TestDryRunAPI_MarshalJSON(t *testing.T) { } } +func TestDryRunAPI_FileIntentIsVisibleInJSONAndPrettyOutput(t *testing.T) { + dr := NewDryRunAPI(). + GET("/open-apis/drive/v1/files/file_1/download"). + File(DryRunFileIntent{Name: "reports/file.bin", IfExists: "fail", Content: "OpenAPI response body"}) + + data, err := json.Marshal(dr) + if err != nil { + t.Fatal(err) + } + var decoded map[string]any + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + files, ok := decoded["files"].([]any) + if !ok || len(files) != 1 { + t.Fatalf("files = %#v", decoded["files"]) + } + file, ok := files[0].(map[string]any) + if !ok || file["name"] != "reports/file.bin" || file["if_exists"] != "fail" || file["content"] != "OpenAPI response body" { + t.Fatalf("file intent = %#v", files[0]) + } + pretty := dr.Format() + if !strings.Contains(pretty, "WRITE reports/file.bin (if exists: fail)") || !strings.Contains(pretty, "content: OpenAPI response body") { + t.Fatalf("pretty dry-run = %s", pretty) + } +} + func TestDryRunAPI_MultipleCalls(t *testing.T) { dr := NewDryRunAPI(). GET("/open-apis/first").Desc("step 1"). diff --git a/internal/commandbridge/bridge.go b/internal/commandbridge/bridge.go new file mode 100644 index 0000000000..c383e1c1dc --- /dev/null +++ b/internal/commandbridge/bridge.go @@ -0,0 +1,71 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package commandbridge owns the internal, type-erased handoff between the +// public command authoring contract, its host adapter, and the existing +// shortcut runner. +package commandbridge + +import ( + "context" + "io" + "reflect" + + "github.com/larksuite/cli/extension/command" + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/client" + "github.com/larksuite/cli/internal/core" +) + +// Access seals the small exported handshake required by shortcuts/common. +// Packages outside this module cannot import an internal package, so these +// functions cannot become a second business-authoring surface. +type Access struct{} + +// RuntimeContext is the restricted host capability set consumed by erased +// hooks. It is internal implementation ABI, not an authoring contract. +type RuntimeContext interface { + Identity() command.Identity + Config() core.CliConfig + APIClient() (*client.APIClient, error) + FileIO() fileio.FileIO + InputResolvedFromSource(param string) bool + ValidatePath(path string) error + ResolveSavePath(path string) (string, error) + Stderr() io.Writer + StartSpinner(label string) func() + PresentError(err error) error + IsDryRun() bool + PaginationOptions() (command.PaginationOptions, error) + RequireConditionalScopes(scopes ...string) error +} + +// Hooks is the type-erased hook set captured while Args and Data are known. +type Hooks struct { + NewArgs func() any + Normalize func(context.Context, RuntimeContext, any) error + Validate func(context.Context, RuntimeContext, any) error + DryRun func(context.Context, RuntimeContext, any) (any, error) + Execute func(context.Context, RuntimeContext, any) (Result, error) + Renderers map[string]func(io.Writer, any) error +} + +// Result is the erased successful result returned to the shortcut runner. +type Result struct { + Data any + Outcome string + Pagination *command.HostPagination +} + +// Definition carries the one public declaration into the private compiler. +// Metadata, Input, and Output retain extension/command as their sole owner; +// the compiler lowers them into its private executable representation. +type Definition struct { + Metadata command.CommandMetadata + Input command.InputDefinition + Output command.OutputDefinition + ArgsType reflect.Type + DataType reflect.Type + Hooks Hooks + PageOutput bool +} diff --git a/internal/commandhost/compile.go b/internal/commandhost/compile.go new file mode 100644 index 0000000000..3db7d2191a --- /dev/null +++ b/internal/commandhost/compile.go @@ -0,0 +1,365 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package commandhost adapts the public command extension contract to Typed Shortcuts. +package commandhost + +import ( + "context" + "fmt" + "io" + "reflect" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/extension/command" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/commandbridge" + "github.com/larksuite/cli/internal/registry" + "github.com/larksuite/cli/shortcuts" + "github.com/larksuite/cli/shortcuts/common" +) + +// CompileSets validates and compiles a complete external contribution without registration. +func CompileSets(sets []command.Set) ([]common.Shortcut, error) { + sets = command.CloneSets(sets) + if len(sets) == 0 { + return nil, nil + } + + builtins := shortcuts.AllShortcuts() + paths := make(map[string]string, len(builtins)) + for _, shortcut := range builtins { + paths[shortcut.Service+" "+shortcut.Command] = "built-in command" + } + existingDomains := businessDomains() + + compiled := make([]common.Shortcut, 0) + for setIndex, set := range sets { + domain := command.InspectDomain(set.Domain) + if err := validateDomain(domain, existingDomains); err != nil { + return nil, fmt.Errorf("command set %d: %w", setIndex+1, err) + } + if len(set.Commands) == 0 { + return nil, fmt.Errorf("command set %d for domain %q has no commands", setIndex+1, domain.Name) + } + for commandIndex, declaration := range set.Commands { + definition := command.InspectCommand(declaration) + if string(definition.Metadata.Service) != domain.Name { + return nil, fmt.Errorf("command set %d command %d: Metadata.Service %q does not match domain %q", + setIndex+1, commandIndex+1, definition.Metadata.Service, domain.Name) + } + shortcutPath := string(definition.Metadata.Service) + " " + definition.Metadata.Command + if owner, duplicate := paths[shortcutPath]; duplicate { + return nil, fmt.Errorf("command set %d command %d: command path %q conflicts with %s", + setIndex+1, commandIndex+1, shortcutPath, owner) + } + shortcut, err := compileCommand(definition) + if err != nil { + return nil, fmt.Errorf("command set %d command %d (%s): %w", setIndex+1, commandIndex+1, shortcutPath, err) + } + paths[shortcutPath] = fmt.Sprintf("command set %d command %d", setIndex+1, commandIndex+1) + compiled = append(compiled, shortcut) + } + } + return compiled, nil +} + +// ValidateDeclaration compiles one declaration through the production compiler +// and discards the result. A business test harness calls it so a wrong tag, +// Shape or relation fails in the unit test rather than at CLI startup: executing +// hooks directly exercises none of the compiler's contract checks, which is the +// gap that let a green test ship a command that cannot mount. +// +// Domain existence and path collisions are deliberately not checked here. Those +// are properties of a whole contribution and belong to CompileSets, which runs +// during build. +func ValidateDeclaration(declaration command.Command) error { + _, err := CompileDeclaration(declaration) + return err +} + +// CompileDeclaration compiles one declaration into a mountable shortcut without +// the whole-contribution checks CompileSets performs. Host-side callers that +// need a compiled command outside a command set use it so they exercise the +// production compiler rather than a parallel construction path. +func CompileDeclaration(declaration command.Command) (common.Shortcut, error) { + return compileCommand(command.InspectCommand(declaration)) +} + +// businessDomains reports the domains a command set may extend. It reads the +// service registry rather than deriving domains from shortcuts.AllShortcuts: +// approval, attendance and mindnotes ship only typed and raw API commands, and +// a shortcut-derived set would reject them as non-existent. +func businessDomains() map[string]struct{} { + names := registry.AllServiceNames() + domains := make(map[string]struct{}, len(names)) + for _, name := range names { + domains[name] = struct{}{} + } + return domains +} + +func validateDomain(domain command.HostDomain, existing map[string]struct{}) error { + name := strings.TrimSpace(domain.Name) + if name == "" || name != domain.Name { + return fmt.Errorf("domain name must be non-empty and trimmed") + } + if _, ok := existing[name]; !ok { + return fmt.Errorf("ExtendDomain target %q does not exist", name) + } + return nil +} + +func compileCommand(definition command.HostDefinition) (common.Shortcut, error) { + hooks := convertHooks(definition) + hooks.NewArgs = definition.NewArgs + return common.CompileCommandDefinition(commandbridge.Definition{ + Metadata: definition.Metadata, + Input: definition.Input, + Output: definition.Output, + ArgsType: definition.ArgsType, + DataType: definition.DataType, + Hooks: hooks, + PageOutput: definition.PageOutput, + }, commandbridge.Access{}) +} + +func convertHooks(definition command.HostDefinition) commandbridge.Hooks { + hooks := definition.Hooks + return commandbridge.Hooks{ + Normalize: adaptHook(hooks.Normalize), + Validate: adaptHook(hooks.Validate), + DryRun: adaptDryRunHook(hooks.DryRun), + Execute: adaptExecuteHook(definition), + Renderers: cloneRenderers(hooks.Renderers), + } +} + +func adaptHook(hook func(context.Context, command.CommandContext, any) error) func(context.Context, commandbridge.RuntimeContext, any) error { + if hook == nil { + return nil + } + return func(ctx context.Context, host commandbridge.RuntimeContext, args any) error { + return hook(ctx, inputStageContext(host), args) + } +} + +func adaptDryRunHook(hook func(context.Context, command.CommandContext, any) *command.DryRun) func(context.Context, commandbridge.RuntimeContext, any) (any, error) { + if hook == nil { + return nil + } + return func(ctx context.Context, host commandbridge.RuntimeContext, args any) (any, error) { + return convertDryRun(hook(ctx, publicContext(host), args)) + } +} + +func adaptExecuteHook(definition command.HostDefinition) func(context.Context, commandbridge.RuntimeContext, any) (commandbridge.Result, error) { + hook := definition.Hooks.Execute + if hook == nil { + return nil + } + return func(ctx context.Context, host commandbridge.RuntimeContext, args any) (commandbridge.Result, error) { + result, err := hook(ctx, publicContext(host), args) + // Check the extension-facing protocol at the extension boundary, where + // the diagnostic can name the public constructor. commandtest runs the + // same check, so the two surfaces cannot drift apart. + if err == nil { + if invalid := command.ValidateHostResult(definition, result); invalid != nil { + return commandbridge.Result{}, invalid + } + } + return commandbridge.Result{Data: result.Data, Outcome: result.Outcome, Pagination: result.Pagination}, err + } +} + +func cloneRenderers(renderers map[string]func(io.Writer, any) error) map[string]func(io.Writer, any) error { + if len(renderers) == 0 { + return nil + } + cloned := make(map[string]func(io.Writer, any) error, len(renderers)) + for name, renderer := range renderers { + cloned[name] = renderer + } + return cloned +} + +// inputStageContext serves Normalize and Validate. Both run before the +// high-risk confirmation gate, so they get the same context minus the network: +// otherwise a high-risk command could reach the API from Validate and leave +// remote side effects behind before the user was ever asked to confirm. +func inputStageContext(host commandbridge.RuntimeContext) command.CommandContext { + return command.NewCommandContext(command.ContextOptions{ + Identity: host.Identity(), + DryRun: host.IsDryRun(), + InputStage: true, + PreflightScopes: host.RequireConditionalScopes, + }) +} + +// commandPages is the accumulator the public command contract needs: it keeps +// each page undecoded, because the business command's own item type lives in +// its module and Page[T] decodes there. +type commandPages struct{ data []map[string]any } + +func (c *commandPages) AddPage(page map[string]any) error { + c.data = append(c.data, page) + return nil +} + +func publicContext(host commandbridge.RuntimeContext) command.CommandContext { + return command.NewCommandContext(command.ContextOptions{ + Identity: host.Identity(), + DryRun: host.IsDryRun(), + CallJSON: func(ctx context.Context, request command.Request) (map[string]any, error) { + view := command.InspectRequest(request) + return common.DoHostedAPIJSON(ctx, host, view.Method, view.Path, queryParams(view.Query), view.Body, commandbridge.Access{}) + }, + Download: func(ctx context.Context, request command.Request, target command.FileTarget, options command.DownloadOptions) (command.Artifact, error) { + return downloadCommand(ctx, host, request, target, options) + }, + DownloadURL: func(ctx context.Context, rawURL string, target command.FileTarget, options command.DownloadOptions) (command.Artifact, error) { + return downloadURLCommand(ctx, host, rawURL, target, options) + }, + PreflightScopes: host.RequireConditionalScopes, + 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 + } + pages := &commandPages{} + meta, err := common.CollectHostedPages(ctx, host, common.PageRequest{ + Method: view.Method, Path: view.Path, Params: projectedQuery(view.Query), Body: view.Body, + }, all, pages, commandbridge.Access{}) + pagination := command.HostPagination{ + Complete: meta.Complete, Pages: meta.Pages, + NextToken: meta.NextToken, + } + return pages.data, pagination, err + }, + }) +} + +// canonicalQuery is the single projection from a business command's declared +// query onto the wire. Every consumer derives from it -- the live single +// request, the dry-run preview, downloads and the pagination walk -- so a +// preview can never describe a request the runtime would not send. Declaring +// it once is what keeps fmt.Sprint conversion and nil dropping from differing +// between paths. +func canonicalQuery(query map[string]any) map[string][]string { + canonical := make(map[string][]string, len(query)) + for name, value := range query { + if values := queryValues(value); len(values) > 0 { + canonical[name] = values + } + } + return canonical +} + +func queryParams(query map[string]any) larkcore.QueryParams { + return larkcore.QueryParams(canonicalQuery(query)) +} + +// projectedQuery renders the canonical projection for consumers whose parameter +// type is map[string]any. A single value stays scalar and repeated values stay a +// list; both carry exactly the strings the live request sends. +func projectedQuery(query map[string]any) map[string]any { + canonical := canonicalQuery(query) + if len(canonical) == 0 { + return nil + } + projected := make(map[string]any, len(canonical)) + for name, values := range canonical { + if len(values) == 1 { + projected[name] = values[0] + continue + } + projected[name] = values + } + return projected +} + +func queryValues(value any) []string { + value = derefQueryValue(value) + if value == nil { + return nil + } + reflected := reflect.ValueOf(value) + if reflected.Kind() != reflect.Array && reflected.Kind() != reflect.Slice { + return []string{fmt.Sprint(value)} + } + values := make([]string, 0, reflected.Len()) + for index := 0; index < reflected.Len(); index++ { + item := derefQueryValue(reflected.Index(index).Interface()) + if item != nil { + values = append(values, fmt.Sprint(item)) + } + } + return values +} + +func derefQueryValue(value any) any { + if value == nil { + return nil + } + reflected := reflect.ValueOf(value) + for reflected.Kind() == reflect.Pointer || reflected.Kind() == reflect.Interface { + if reflected.IsNil() { + return nil + } + reflected = reflected.Elem() + } + return reflected.Interface() +} + +func convertDryRun(preview *command.DryRun) (*common.DryRunAPI, error) { + if preview == nil { + return nil, nil + } + view := command.InspectDryRun(preview) + converted := common.NewDryRunAPI() + if view.Description != "" { + converted.Desc(view.Description) + } + for index, request := range view.Requests { + if err := command.ValidateRequestView(request); err != nil { + return nil, fmt.Errorf("dry-run request %d: %w", index+1, err) + } + switch request.Method { + case "GET": + converted.GET(request.Path) + case "POST": + converted.POST(request.Path) + case "PUT": + converted.PUT(request.Path) + case "PATCH": + converted.PATCH(request.Path) + case "DELETE": + converted.DELETE(request.Path) + } + if params := projectedQuery(request.Query); len(params) > 0 { + converted.Params(params) + } + if request.Body != nil { + converted.Body(request.Body) + } + if request.Description != "" { + converted.Desc(request.Description) + } + } + for index, file := range view.Files { + if file.Name == "" || file.Name != strings.TrimSpace(file.Name) { + return nil, command.ValidationErrorf("dry-run file %d: target name must be non-empty and trimmed", index+1) + } + policy := file.IfExists + if policy == "" { + policy = command.IfExistsFail + } + if policy != command.IfExistsFail && policy != command.IfExistsOverwrite { + return nil, command.ValidationErrorf("dry-run file %d: unsupported conflict policy %q", index+1, policy) + } + converted.File(cmdutil.DryRunFileIntent{Name: file.Name, IfExists: string(policy), Content: file.Content}) + } + return converted, nil +} diff --git a/internal/commandhost/compile_test.go b/internal/commandhost/compile_test.go new file mode 100644 index 0000000000..6441363b03 --- /dev/null +++ b/internal/commandhost/compile_test.go @@ -0,0 +1,502 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package commandhost + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "slices" + "strings" + "sync/atomic" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/command" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/commandbridge" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/credential" + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +type fixtureArgs struct { + ID string `flag:"id" schema:"required;minLength=1" doc:"resource ID"` +} + +type fixtureData struct { + ID string `json:"id" schema:"required" doc:"resource ID"` +} + +func fixtureCommand(name string) command.Command { + return fixtureCommandIn(command.DomainIm, name) +} + +func fixtureCommandIn(service command.DomainName, name string) command.Command { + return command.Define(command.Definition[fixtureArgs, fixtureData]{ + Metadata: command.CommandMetadata{ + Service: service, Command: name, Description: "Fixture command", Risk: command.RiskRead, + Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{ + command.IdentityUser: {RequiredScopes: []string{string(service) + ":read"}}, + }}, + }, + Hooks: command.Hooks[fixtureArgs, fixtureData]{ + Execute: func(_ context.Context, _ command.CommandContext, args *fixtureArgs) (command.Result[fixtureData], error) { + return command.Success(fixtureData{ID: args.ID}), nil + }, + }, + }) +} + +func TestCompileSetsCompilesTypedShortcut(t *testing.T) { + compiled, err := CompileSets([]command.Set{{ + Domain: command.ExtendDomain(command.DomainIm), Commands: []command.Command{fixtureCommand("+external-fixture")}, + }}) + if err != nil { + t.Fatal(err) + } + if len(compiled) != 1 || compiled[0].Service != "im" || compiled[0].Command != "+external-fixture" { + t.Fatalf("compiled shortcuts = %#v", compiled) + } + if len(compiled[0].AuthTypes) != 1 || compiled[0].AuthTypes[0] != "user" { + t.Fatalf("auth types = %#v", compiled[0].AuthTypes) + } +} + +func TestCompileDeclarationConsumesPublicContractDirectly(t *testing.T) { + declaration := command.Define(command.Definition[fixtureArgs, fixtureData]{ + Metadata: command.CommandMetadata{ + Service: command.DomainIm, Command: "+contract-projection", Description: "Contract projection", Risk: command.RiskRead, Hidden: true, + Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{ + command.IdentityUser: {RequiredScopes: []string{"im:chat:read"}}, + }}, + }, + Input: command.InputDefinition{Fields: []command.InputField{{ + Name: "id", CLI: command.CLIInput{ + Aliases: []command.FlagAlias{{Name: "legacy-id", Mode: command.AliasNormalize}}, + ValueSources: []command.ValueSource{command.SourceFlag, command.SourceFile, command.SourceStdin}, + }, + }}}, + Output: command.OutputDefinition{Mode: command.OutputFixedJSON, DisableHTMLEscaping: true}, + Hooks: command.Hooks[fixtureArgs, fixtureData]{ + Execute: func(_ context.Context, _ command.CommandContext, args *fixtureArgs) (command.Result[fixtureData], error) { + return command.Success(fixtureData{ID: args.ID}), nil + }, + }, + }) + compiled, err := CompileDeclaration(declaration) + if err != nil { + t.Fatal(err) + } + if !compiled.Hidden || len(compiled.Flags) != 1 || !slices.Equal(compiled.Flags[0].Aliases, []string{"legacy-id"}) || !slices.Equal(compiled.Flags[0].Input, []string{common.File, common.Stdin}) { + t.Fatalf("compiled shortcut = %#v", compiled) + } + contract, ok := common.ShortcutSchema(compiled, commandbridge.Access{}) + if !ok { + t.Fatal("compiled command has no schema contract") + } + encoded, err := json.Marshal(contract) + if err != nil { + t.Fatal(err) + } + var schema struct { + Meta struct { + Formats []struct { + SelectedBy []string `json:"selected_by"` + EscapeHTML *bool `json:"escape_html"` + } `json:"formats"` + } `json:"_meta"` + } + if err := json.Unmarshal(encoded, &schema); err != nil { + t.Fatal(err) + } + if len(schema.Meta.Formats) != 1 || !slices.Equal(schema.Meta.Formats[0].SelectedBy, []string{"json", "pretty", "table", "ndjson", "csv"}) || schema.Meta.Formats[0].EscapeHTML == nil || *schema.Meta.Formats[0].EscapeHTML { + t.Fatalf("schema formats = %#v", schema.Meta.Formats) + } +} + +// These three domains ship only typed and raw API commands, so deriving the +// mountable domains from shortcuts.AllShortcuts would reject them. +func TestCompileSetsExtendsDomainsWithoutShortcuts(t *testing.T) { + for _, domain := range []command.DomainName{command.DomainApproval, command.DomainAttendance, command.DomainMindnotes} { + t.Run(string(domain), func(t *testing.T) { + compiled, err := CompileSets([]command.Set{{ + Domain: command.ExtendDomain(domain), + Commands: []command.Command{fixtureCommandIn(domain, "+external-fixture")}, + }}) + if err != nil { + t.Fatal(err) + } + if len(compiled) != 1 || compiled[0].Service != string(domain) { + t.Fatalf("compiled shortcuts = %#v", compiled) + } + }) + } +} + +func TestQueryParamsOmitsTypedNilAndDereferencesValues(t *testing.T) { + value := "chat_1" + var missing *string + booleans := [2]bool{true, false} + params := queryParams(map[string]any{ + "missing": missing, + "value": &value, + "items": []any{missing, &value, 20}, + "numbers": []int{10, 20}, + "booleans": &booleans, + }) + if _, exists := params["missing"]; exists { + t.Fatalf("typed nil query = %#v", params["missing"]) + } + if got := params["value"]; len(got) != 1 || got[0] != "chat_1" { + t.Fatalf("pointer query = %#v", got) + } + if got := params["items"]; len(got) != 2 || got[0] != "chat_1" || got[1] != "20" { + t.Fatalf("list query = %#v", got) + } + if got := params["numbers"]; len(got) != 2 || got[0] != "10" || got[1] != "20" { + t.Fatalf("numeric list query = %#v", got) + } + if got := params["booleans"]; len(got) != 2 || got[0] != "true" || got[1] != "false" { + t.Fatalf("boolean array query = %#v", got) + } +} + +func TestCompileSetsIsAtomicAcrossDuplicatePaths(t *testing.T) { + set := command.Set{Domain: command.ExtendDomain(command.DomainIm), Commands: []command.Command{ + fixtureCommand("+external-duplicate"), fixtureCommand("+external-duplicate"), + }} + compiled, err := CompileSets([]command.Set{set}) + if err == nil || len(compiled) != 0 || !strings.Contains(err.Error(), "conflicts") { + t.Fatalf("CompileSets() = %#v, %v", compiled, err) + } +} + +func TestCompileSetsRejectsUnsupportedAndUnknownDomains(t *testing.T) { + tests := []struct { + name string + domain command.Domain + want string + }{ + // A reserved host namespace is not a business domain, so it fails the + // same way any other non-existent domain does: ExtendDomain is the only + // way to name a domain, and these are not extendable domains. + {name: "reserved host namespace", domain: command.ExtendDomain(command.DomainName("auth")), want: "does not exist"}, + {name: "unknown extension", domain: command.ExtendDomain(command.DomainName("missing")), want: "does not exist"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := CompileSets([]command.Set{{Domain: test.domain, Commands: []command.Command{fixtureCommand("+external-domain")}}}) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("CompileSets() error = %v, want %q", err, test.want) + } + }) + } +} + +func TestCompileSetsRejectsSystemFlag(t *testing.T) { + type args struct { + Format string `flag:"format" schema:"optional" doc:"format"` + } + declaration := command.Define(command.Definition[args, fixtureData]{ + Metadata: command.CommandMetadata{ + Service: "im", Command: "+external-format", Description: "Bad flag", Risk: command.RiskRead, + Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{ + command.IdentityUser: {}, + }}, + }, + Hooks: command.Hooks[args, fixtureData]{Execute: func(context.Context, command.CommandContext, *args) (command.Result[fixtureData], error) { + return command.Success(fixtureData{}), nil + }}, + }) + _, err := CompileSets([]command.Set{{Domain: command.ExtendDomain(command.DomainIm), Commands: []command.Command{declaration}}}) + if err == nil || !strings.Contains(err.Error(), "host output formatting flag") { + t.Fatalf("CompileSets() error = %v", err) + } +} + +func inputSourceDeclaration(name string, sources ...command.ValueSource) command.Command { + return command.Define(command.Definition[fixtureArgs, fixtureData]{ + Metadata: command.CommandMetadata{ + Service: "im", Command: name, Description: "Input sources", Risk: command.RiskRead, + Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{ + command.IdentityUser: {}, + }}, + }, + Input: command.InputDefinition{Fields: []command.InputField{{ + Name: "id", CLI: command.CLIInput{ValueSources: sources}, + }}}, + Hooks: command.Hooks[fixtureArgs, fixtureData]{Execute: func(context.Context, command.CommandContext, *fixtureArgs) (command.Result[fixtureData], error) { + return command.Success(fixtureData{}), nil + }}, + }) +} + +// TestCompileSetsCarriesFileInputSource pins the @file source through to the +// legacy flag, where resolveInputFlags substitutes the file content. Declaring +// it costs a business command nothing at the call site, so the regression to +// guard against is the host quietly dropping the source instead of rejecting it. +func TestCompileSetsCarriesFileInputSource(t *testing.T) { + declaration := inputSourceDeclaration("+external-file-input", command.SourceFlag, command.SourceFile, command.SourceStdin) + compiled, err := CompileSets([]command.Set{{Domain: command.ExtendDomain(command.DomainIm), Commands: []command.Command{declaration}}}) + if err != nil { + t.Fatal(err) + } + var sources []string + for _, flag := range compiled[0].Flags { + if flag.Name == "id" { + sources = flag.Input + } + } + if !slices.Contains(sources, common.File) || !slices.Contains(sources, common.Stdin) { + t.Fatalf("compiled --id input sources = %v", sources) + } +} + +func TestCompileSetsRejectsUnknownInputSource(t *testing.T) { + declaration := inputSourceDeclaration("+external-unknown-input", command.SourceFlag, command.ValueSource("clipboard")) + _, err := CompileSets([]command.Set{{Domain: command.ExtendDomain(command.DomainIm), Commands: []command.Command{declaration}}}) + if err == nil || !strings.Contains(err.Error(), "unknown value source \"clipboard\"") { + t.Fatalf("CompileSets() error = %v", err) + } +} + +func TestCompileSetsAddsPaginationFlags(t *testing.T) { + declaration := command.Define(command.Definition[fixtureArgs, command.Page[fixtureData]]{ + Metadata: command.CommandMetadata{ + Service: "im", Command: "+external-pages", Description: "Pages", Risk: command.RiskRead, + Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{ + command.IdentityUser: {}, + }}, + }, + Hooks: command.Hooks[fixtureArgs, command.Page[fixtureData]]{Execute: func(context.Context, command.CommandContext, *fixtureArgs) (command.Result[command.Page[fixtureData]], error) { + return command.Success(command.Page[fixtureData]{}), nil + }}, + }) + compiled, err := CompileSets([]command.Set{{Domain: command.ExtendDomain(command.DomainIm), Commands: []command.Command{declaration}}}) + if err != nil { + t.Fatal(err) + } + flags := make(map[string]bool) + for _, flag := range compiled[0].Flags { + flags[flag.Name] = true + } + for _, name := range []string{"page-all", "page-limit", "page-delay"} { + if !flags[name] { + t.Errorf("missing pagination flag --%s", name) + } + } +} + +type countingTokenResolver struct { + calls atomic.Int32 +} + +func (r *countingTokenResolver) ResolveToken(context.Context, credential.TokenSpec) (*credential.TokenResult, error) { + r.calls.Add(1) + return &credential.TokenResult{Token: "unexpected-token"}, nil +} + +func TestExternalDryRunContextSendsNothing(t *testing.T) { + request := command.GET("/open-apis/im/v1/chats/chat_1") + var callErr error + var scopeErr error + executed := false + declaration := command.Define(command.Definition[fixtureArgs, fixtureData]{ + Metadata: command.CommandMetadata{ + Service: "im", Command: "+external-preview", Description: "Preview", Risk: command.RiskRead, + Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{ + command.IdentityUser: { + RequiredScopes: []string{"im:chat:read"}, + ConditionalScopes: []command.ConditionalScope{{ + Scopes: []string{"im:chat:update"}, When: "the update branch runs", Requirement: command.ScopeRequired, + }}, + }, + }}, + }, + Hooks: command.Hooks[fixtureArgs, fixtureData]{ + DryRun: func(ctx context.Context, commandContext command.CommandContext, _ *fixtureArgs) *command.DryRun { + scopeErr = command.PreflightScopes(commandContext, "im:chat:update") + _, callErr = command.CallJSON[map[string]any](ctx, commandContext, request) + return command.NewDryRun(request) + }, + Execute: func(context.Context, command.CommandContext, *fixtureArgs) (command.Result[fixtureData], error) { + executed = true + 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, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "app-id", AppSecret: "app-secret"}) + resolver := &countingTokenResolver{} + factory.Credential = credential.NewCredentialProvider(nil, nil, resolver, nil) + 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-preview", "--id", "chat_1", "--as", "user", "--dry-run"}) + if _, err := root.ExecuteC(); err != nil { + t.Fatal(err) + } + if executed { + t.Fatal("Execute ran during dry-run") + } + if scopeErr != nil { + t.Fatalf("scope preflight error = %v", scopeErr) + } + if callErr == nil || !strings.Contains(callErr.Error(), "unavailable during dry-run") { + t.Fatalf("network attempt error = %v", callErr) + } + var validation *errs.ValidationError + if !errors.As(callErr, &validation) || validation.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("network attempt typed error = %#v", callErr) + } + if calls := resolver.calls.Load(); calls != 0 { + t.Fatalf("token resolver calls = %d", calls) + } + if !strings.Contains(stdout.String(), "/open-apis/im/v1/chats/chat_1") { + t.Fatalf("dry-run output = %s", stdout.String()) + } +} + +// DryRun reports nothing back, so Validate is what stops a preview. Its error +// has to reach the caller typed, not as a rendered dry-run. +func TestExternalDryRunSurfacesValidateError(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: "Preview error", Risk: command.RiskRead, + Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{command.IdentityUser: {}}}, + }, + Hooks: command.Hooks[fixtureArgs, fixtureData]{ + Validate: func(context.Context, command.CommandContext, *fixtureArgs) error { + return sentinel + }, + DryRun: func(context.Context, command.CommandContext, *fixtureArgs) *command.DryRun { + return command.NewDryRun(command.GET("/open-apis/im/v1/chats")) + }, + 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) + } + var validation *errs.ValidationError + if !errors.As(err, &validation) || validation.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("dry-run typed error = %#v", err) + } +} + +// A paginated command's dry-run renders exactly what the hook described. The +// framework appends no paging note of its own: built-in shortcuts that want one +// write it themselves with dry.Desc, and external commands do the same. +func TestExternalPageDryRunRendersOnlyTheBusinessDescription(t *testing.T) { + declaration := command.Define(command.Definition[fixtureArgs, command.Page[fixtureData]]{ + Metadata: command.CommandMetadata{ + Service: "im", Command: "+external-page-note", Description: "Page preview note", Risk: command.RiskRead, + Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{ + command.IdentityUser: {RequiredScopes: []string{"im:chat:read"}}, + }}, + }, + Hooks: command.Hooks[fixtureArgs, command.Page[fixtureData]]{ + DryRun: func(_ context.Context, _ command.CommandContext, _ *fixtureArgs) *command.DryRun { + return command.NewDryRun(command.GET("/open-apis/im/v1/chats").Desc("list visible chats")) + }, + Execute: func(context.Context, command.CommandContext, *fixtureArgs) (command.Result[command.Page[fixtureData]], error) { + return command.Success(command.Page[fixtureData]{}), nil + }, + }, + }) + compiled, err := CompileSets([]command.Set{{ + Domain: command.ExtendDomain(command.DomainIm), Commands: []command.Command{declaration}, + }}) + if err != nil { + t.Fatal(err) + } + factory, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "app-id", AppSecret: "app-secret"}) + 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-page-note", "--id", "chat_1", "--as", "user", "--dry-run"}) + if _, err := root.ExecuteC(); err != nil { + t.Fatal(err) + } + output := stdout.String() + if !strings.Contains(output, "list visible chats") { + t.Fatalf("business description is missing from the preview:\n%s", output) + } + if strings.Contains(output, "--page-all") || strings.Contains(output, "page_token") { + t.Fatalf("the framework added a paging note the hook did not write:\n%s", output) + } +} + +// A Request is the single owner of the wire shape: the live call, the dry-run +// preview and the pagination walk must all describe the same query. These are +// the three values that previously diverged -- a map stringified for live but +// emitted structurally in the preview, a nil element dropped for live but kept +// in the preview, and a nil value omitted for live but rendered as null. +func TestQueryProjectionIsSharedByLiveAndPreview(t *testing.T) { + var missing *string + query := map[string]any{ + "filter": map[string]string{"a": "b"}, + "items": []any{"x", nil}, + "absent": nil, + "typed": missing, + "single": "one", + } + + live := queryParams(query) + projected := projectedQuery(query) + + if len(live) != len(projected) { + t.Fatalf("live keys = %v, preview keys = %v", live, projected) + } + for name, values := range live { + switch previewed := projected[name].(type) { + case string: + if len(values) != 1 || values[0] != previewed { + t.Errorf("%s: live = %v, preview = %q", name, values, previewed) + } + case []string: + if !reflect.DeepEqual(values, previewed) { + t.Errorf("%s: live = %v, preview = %v", name, values, previewed) + } + default: + t.Errorf("%s: preview carried %T, which the live request cannot send", name, projected[name]) + } + } + if _, ok := projected["absent"]; ok { + t.Error("a nil value must be omitted from the preview because live omits it") + } + if _, ok := projected["typed"]; ok { + t.Error("a nil pointer must be omitted from the preview because live omits it") + } + if got := projected["filter"]; got != "map[a:b]" { + t.Errorf("filter preview = %#v, want the stringified form live sends", got) + } + if got := projected["items"]; got != "x" { + t.Errorf("items preview = %#v, want the nil element dropped as live drops it", got) + } +} diff --git a/internal/commandhost/download.go b/internal/commandhost/download.go new file mode 100644 index 0000000000..797f631f1e --- /dev/null +++ b/internal/commandhost/download.go @@ -0,0 +1,168 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package commandhost + +import ( + "context" + "errors" + "io/fs" + "net/http" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/command" + "github.com/larksuite/cli/extension/download" + "github.com/larksuite/cli/extension/fileio" + exttransport "github.com/larksuite/cli/extension/transport" + "github.com/larksuite/cli/internal/client" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/commandbridge" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/downloadtransport" + internaltransport "github.com/larksuite/cli/internal/transport" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// downloadCommand streams one logical OpenAPI file into the invocation's +// FileIO. MutableSource is the safe default: multipart transfer is used only +// when a strong validator binds every response to the same representation; +// immutable endpoints can gain an explicit fast path after a real need appears. +func downloadCommand(ctx context.Context, host commandbridge.RuntimeContext, request command.Request, target command.FileTarget, options command.DownloadOptions) (command.Artifact, error) { + view := command.InspectRequest(request) + if err := command.ValidateRequestView(view); err != nil { + return command.Artifact{}, err + } + if view.Method != http.MethodGet || view.Body != nil { + return command.Artifact{}, command.ValidationErrorf("file download requires a bodyless GET request") + } + + transport := func(ctx context.Context, part download.Request) (*http.Response, error) { + apiRequest := &larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: view.Path, + QueryParams: queryParams(view.Query), + } + return doCommandAPIStream(ctx, host, apiRequest, + client.WithHeaders(part.Headers()), client.WithReplaySafe()) + } + return downloadToFile(ctx, host, transport, target, options) +} + +func downloadURLCommand(ctx context.Context, host commandbridge.RuntimeContext, rawURL string, target command.FileTarget, options command.DownloadOptions) (command.Artifact, error) { + if err := validate.ValidateDownloadSourceURL(ctx, rawURL); err != nil { + return command.Artifact{}, errs.NewSecurityPolicyError(errs.SubtypeAccessDenied, + "blocked download URL: %v", err).WithCause(err) + } + apiClient, err := commandAPIClient(host) + if err != nil { + return command.Artifact{}, err + } + if apiClient.HTTP == nil { + return command.Artifact{}, errs.NewInternalError(errs.SubtypeUnknown, "command host has no HTTP client for URL downloads") + } + externalClient := internaltransport.ClientForRequestClass(apiClient.HTTP, exttransport.RequestClassExternal) + safeClient := validate.NewDownloadHTTPClient(externalClient, validate.DownloadHTTPClientOptions{}) + return downloadToFile(ctx, host, downloadtransport.URL(safeClient, rawURL), target, options) +} + +func downloadToFile(ctx context.Context, host commandbridge.RuntimeContext, transport download.Transport, target command.FileTarget, options command.DownloadOptions) (command.Artifact, error) { + fileIO := host.FileIO() + if fileIO == nil { + return command.Artifact{}, errs.NewInternalError(errs.SubtypeFileIO, "command host has no file I/O provider") + } + location, err := host.ResolveSavePath(target.Name) + if err != nil { + return command.Artifact{}, common.WrapSaveErrorTyped(err) + } + // The fail policy is enforced by the commit, not by a preceding existence + // check: the download happens between check and commit, so a target created + // in that window would be overwritten by a check-then-save sequence. A + // provider that cannot commit exclusively is refused here rather than served + // with a guarantee it does not implement. + var exclusive fileio.ExclusiveFileIO + if target.IfExists == command.IfExistsFail { + capable, ok := fileIO.(fileio.ExclusiveFileIO) + if !ok { + return command.Artifact{}, errs.NewValidationError(errs.SubtypeFailedPrecondition, + "the configured file provider cannot refuse an existing download target %q", target.Name). + WithHint("use the overwrite policy explicitly, or configure a provider that commits exclusively") + } + exclusive = capable + // Report the common case before spending the download: a target that + // already exists now will still exist at commit time. + if _, statErr := fileIO.Stat(target.Name); statErr == nil { + return command.Artifact{}, errs.NewValidationError(errs.SubtypeFailedPrecondition, + "download target %q already exists", target.Name). + WithHint("choose another target or explicitly use the overwrite policy") + } else if !errors.Is(statErr, fs.ErrNotExist) { + return command.Artifact{}, errs.NewInternalError(errs.SubtypeFileIO, + "inspect download target %q: %v", target.Name, statErr).WithCause(statErr) + } + } + + var source download.Source + switch options.Representation { + case download.Mutable: + source = download.MutableSource(transport) + case download.Immutable: + source = download.ImmutableSource(transport) + default: + return command.Artifact{}, errs.NewInternalError(errs.SubtypeUnknown, + "command host received unsupported download representation %q", options.Representation) + } + stream, err := download.Open(ctx, source, options.Transfer) + if err != nil { + return command.Artifact{}, err + } + defer stream.Body.Close() + + contentType := stream.Header.Get("Content-Type") + saveOptions := fileio.SaveOptions{ContentType: contentType, ContentLength: stream.ContentLength} + var saved fileio.SaveResult + if exclusive != nil { + saved, err = exclusive.SaveExclusive(target.Name, saveOptions, stream.Body) + if errors.Is(err, fs.ErrExist) { + return command.Artifact{}, errs.NewValidationError(errs.SubtypeFailedPrecondition, + "download target %q already exists", target.Name). + WithHint("choose another target or explicitly use the overwrite policy") + } + } else { + saved, err = fileIO.Save(target.Name, saveOptions, stream.Body) + } + if err != nil { + return command.Artifact{}, common.WrapSaveErrorTyped(err) + } + if stream.ContentLength >= 0 && saved.Size() != stream.ContentLength { + return command.Artifact{}, errs.NewInternalError(errs.SubtypeFileIO, + "download provider committed %d bytes, expected %d", saved.Size(), stream.ContentLength) + } + return command.Artifact{ + Name: target.Name, Location: location, Size: saved.Size(), ContentType: contentType, + }, nil +} + +func doCommandAPIStream(ctx context.Context, host commandbridge.RuntimeContext, request *larkcore.ApiReq, options ...client.Option) (*http.Response, error) { + apiClient, err := commandAPIClient(host) + if err != nil { + return nil, err + } + base := []client.Option{client.WithHeaders(cmdutil.BaseSecurityHeaders())} + if headers := cmdutil.ShortcutHeaders(ctx); headers != nil { + base = append(base, client.WithHeaders(headers)) + } + return apiClient.DoStream(ctx, request, core.Identity(host.Identity()), append(base, options...)...) +} + +func commandAPIClient(host commandbridge.RuntimeContext) (*client.APIClient, error) { + apiClient, err := host.APIClient() + if err != nil { + if _, typed := errs.ProblemOf(err); typed { + return nil, err + } + return nil, errs.WrapInternal(err) + } + return apiClient, nil +} diff --git a/internal/commandhost/download_test.go b/internal/commandhost/download_test.go new file mode 100644 index 0000000000..a66035303f --- /dev/null +++ b/internal/commandhost/download_test.go @@ -0,0 +1,324 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package commandhost + +import ( + "context" + "errors" + "io/fs" + "net/http" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/command" + "github.com/larksuite/cli/extension/download" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/vfs" + "github.com/spf13/cobra" +) + +type backupArgs struct { + FileToken string `flag:"file-token" schema:"required;minLength=1" doc:"file token"` + Output string `flag:"output" schema:"required;minLength=1" doc:"logical output name"` + Overwrite bool `flag:"overwrite" schema:"optional;default=false" doc:"replace an existing output"` +} + +func backupDownloadOptions() command.DownloadOptions { + return command.DownloadOptions{ + Representation: download.Immutable, + Transfer: download.Options{PartSize: 4, MaxPartRetries: 1}, + } +} + +func externalBackupCommand() command.Command { + return externalBackupCommandWithOptions("+external-backup", backupDownloadOptions()) +} + +func externalBackupCommandWithOptions(name string, options ...command.DownloadOptions) command.Command { + request := func(args *backupArgs) command.Request { + return command.GET("/open-apis/drive/v1/files/" + command.PathSegment(args.FileToken) + "/download") + } + target := func(args *backupArgs) command.FileTarget { + result := command.FileTarget{Name: args.Output} + if args.Overwrite { + result.IfExists = command.IfExistsOverwrite + } + return result + } + return command.Define(command.Definition[backupArgs, command.Artifact]{ + Metadata: command.CommandMetadata{ + Service: command.DomainDrive, Command: name, Description: "Download a file", Risk: command.RiskWrite, + Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{ + command.IdentityUser: {RequiredScopes: []string{"drive:file:download"}}, + }}, + }, + Hooks: command.Hooks[backupArgs, command.Artifact]{ + DryRun: func(_ context.Context, _ command.CommandContext, args *backupArgs) *command.DryRun { + return command.NewDryRun(request(args)).File(target(args).Intent("OpenAPI response body")) + }, + Execute: func(ctx context.Context, commandContext command.CommandContext, args *backupArgs) (command.Result[command.Artifact], error) { + artifact, err := command.Download(ctx, commandContext, request(args), target(args), options...) + if err != nil { + return command.Result[command.Artifact]{}, err + } + return command.Success(artifact), nil + }, + }, + }) +} + +func TestExternalDownloadPreservesExistingFileBeforeNetwork(t *testing.T) { + cmdutil.TestChdir(t, t.TempDir()) + if err := vfs.WriteFile("file.bin", []byte("original"), 0600); err != nil { + t.Fatal(err) + } + root, factory := mountExternalBackup(t) + called := false + factoryTestRegistry(t, factory).Register(&httpmock.Stub{ + Method: http.MethodGet, URL: "/open-apis/drive/v1/files/file_1/download", + RawBody: []byte("replacement"), ContentType: "application/octet-stream", Optional: true, + OnMatch: func(*http.Request) { called = true }, + }) + + root.SetArgs([]string{"drive", "+external-backup", "--file-token", "file_1", "--output", "file.bin", "--as", "user"}) + _, err := root.ExecuteC() + var validation *errs.ValidationError + if !errors.As(err, &validation) || validation.Subtype != errs.SubtypeFailedPrecondition { + t.Fatalf("download error = %#v", err) + } + content, readErr := vfs.ReadFile("file.bin") + if readErr != nil || string(content) != "original" || called { + t.Fatalf("existing file = %q, readErr=%v, networkCalled=%v", content, readErr, called) + } +} + +func TestExternalDownloadRejectsTraversalBeforeNetwork(t *testing.T) { + cmdutil.TestChdir(t, t.TempDir()) + root, factory := mountExternalBackup(t) + called := false + factoryTestRegistry(t, factory).Register(&httpmock.Stub{ + Method: http.MethodGet, URL: "/open-apis/drive/v1/files/file_1/download", + RawBody: []byte("payload"), ContentType: "application/octet-stream", Optional: true, + OnMatch: func(*http.Request) { called = true }, + }) + + root.SetArgs([]string{"drive", "+external-backup", "--file-token", "file_1", "--output", "../escape/file.bin", "--as", "user"}) + _, err := root.ExecuteC() + var validation *errs.ValidationError + if !errors.As(err, &validation) || validation.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("download error = %#v", err) + } + if called { + t.Fatal("unsafe output path reached the network") + } +} + +func TestExternalDownloadExplicitOverwriteReplacesExistingFile(t *testing.T) { + cmdutil.TestChdir(t, t.TempDir()) + if err := vfs.WriteFile("file.bin", []byte("original"), 0600); err != nil { + t.Fatal(err) + } + root, factory := mountExternalBackup(t) + factoryTestRegistry(t, factory).Register(&httpmock.Stub{ + Method: http.MethodGet, URL: "/open-apis/drive/v1/files/file_1/download", + RawBody: []byte("replacement"), ContentType: "application/octet-stream", + }) + + root.SetArgs([]string{"drive", "+external-backup", "--file-token", "file_1", "--output", "file.bin", "--overwrite", "--as", "user"}) + if _, err := root.ExecuteC(); err != nil { + t.Fatal(err) + } + content, err := vfs.ReadFile("file.bin") + if err != nil || string(content) != "replacement" { + t.Fatalf("overwritten file = %q, %v", content, err) + } +} + +func mountExternalBackup(t *testing.T) (*cobra.Command, *cmdutil.Factory) { + return mountDownloadCommand(t, externalBackupCommand()) +} + +func mountDownloadCommand(t *testing.T, declaration command.Command) (*cobra.Command, *cmdutil.Factory) { + t.Helper() + compiled, err := CompileSets([]command.Set{{ + Domain: command.ExtendDomain(command.DomainDrive), Commands: []command.Command{declaration}, + }}) + if err != nil { + t.Fatal(err) + } + factory, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "app-id", AppSecret: "app-secret"}) + root := &cobra.Command{Use: "lark-cli", SilenceErrors: true, SilenceUsage: true} + service := &cobra.Command{Use: "drive"} + root.AddCommand(service) + compiled[0].Mount(service, factory) + return root, factory +} + +func TestExternalDownloadDefaultsToMutableRepresentation(t *testing.T) { + cmdutil.TestChdir(t, t.TempDir()) + root, factory := mountDownloadCommand(t, externalBackupCommandWithOptions("+external-backup-mutable", command.DownloadOptions{ + Transfer: download.Options{PartSize: 4}, + })) + registry := factoryTestRegistry(t, factory) + probe := &httpmock.Stub{ + Method: http.MethodGet, URL: "/open-apis/drive/v1/files/file_1/download", + Status: http.StatusPartialContent, RawBody: []byte("payl"), + Headers: http.Header{ + "Content-Type": {"application/octet-stream"}, + "Content-Range": {"bytes 0-3/7"}, + }, + } + full := &httpmock.Stub{ + Method: http.MethodGet, URL: "/open-apis/drive/v1/files/file_1/download", + RawBody: []byte("payload"), ContentType: "application/octet-stream", + } + registry.Register(probe) + registry.Register(full) + + root.SetArgs([]string{"drive", "+external-backup-mutable", "--file-token", "file_1", "--output", "file.bin", "--as", "user"}) + if _, err := root.ExecuteC(); err != nil { + t.Fatal(err) + } + content, err := vfs.ReadFile("file.bin") + if err != nil || string(content) != "payload" { + t.Fatalf("downloaded content = %q, %v", content, err) + } + if probe.CapturedHeaders.Get("Range") != "bytes=0-3" || full.CapturedHeaders.Get("Range") != "" { + t.Fatalf("mutable requests = probe %#v, full %#v", probe.CapturedHeaders, full.CapturedHeaders) + } +} + +func TestExternalDownloadStreamsThroughCommonDownloadAndFileIO(t *testing.T) { + cmdutil.TestChdir(t, t.TempDir()) + root, factory := mountExternalBackup(t) + registry := factoryTestRegistry(t, factory) + first := &httpmock.Stub{ + Method: http.MethodGet, URL: "/open-apis/drive/v1/files/file_1/download", + Status: http.StatusPartialContent, RawBody: []byte("payl"), + Headers: http.Header{ + "Content-Type": {"application/octet-stream"}, + "Content-Range": {"bytes 0-3/7"}, + }, + } + second := &httpmock.Stub{ + Method: http.MethodGet, URL: "/open-apis/drive/v1/files/file_1/download", + Status: http.StatusPartialContent, RawBody: []byte("oad"), + Headers: http.Header{ + "Content-Type": {"application/octet-stream"}, + "Content-Range": {"bytes 4-6/7"}, + }, + } + registry.Register(first) + registry.Register(second) + + root.SetArgs([]string{"drive", "+external-backup", "--file-token", "file_1", "--output", "reports/file.bin", "--as", "user"}) + if _, err := root.ExecuteC(); err != nil { + t.Fatal(err) + } + content, err := vfs.ReadFile("reports/file.bin") + if err != nil || string(content) != "payload" { + t.Fatalf("downloaded content = %q, %v", content, err) + } + if first.CapturedHeaders.Get("Range") != "bytes=0-3" || second.CapturedHeaders.Get("Range") != "bytes=4-6" || + first.CapturedHeaders.Get("Accept-Encoding") != "identity" || second.CapturedHeaders.Get("Accept-Encoding") != "identity" { + t.Fatalf("download headers = first %#v, second %#v", first.CapturedHeaders, second.CapturedHeaders) + } + if first.CapturedHeaders.Get("Authorization") != "Bearer test-token" || second.CapturedHeaders.Get("Authorization") != "Bearer test-token" { + t.Fatalf("authorization = first %q, second %q", first.CapturedHeaders.Get("Authorization"), second.CapturedHeaders.Get("Authorization")) + } +} + +func TestExternalDownloadDryRunReportsFileWithoutWriting(t *testing.T) { + cmdutil.TestChdir(t, t.TempDir()) + compiled, err := CompileSets([]command.Set{{ + Domain: command.ExtendDomain(command.DomainDrive), Commands: []command.Command{externalBackupCommand()}, + }}) + if err != nil { + t.Fatal(err) + } + factory, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "app-id", AppSecret: "app-secret"}) + root := &cobra.Command{Use: "lark-cli", SilenceErrors: true, SilenceUsage: true} + service := &cobra.Command{Use: "drive"} + root.AddCommand(service) + compiled[0].Mount(service, factory) + root.SetArgs([]string{"drive", "+external-backup", "--file-token", "file_1", "--output", "reports/file.bin", "--as", "user", "--dry-run"}) + if _, err := root.ExecuteC(); err != nil { + t.Fatal(err) + } + if output := stdout.String(); !strings.Contains(output, `"files"`) || !strings.Contains(output, `"name": "reports/file.bin"`) { + t.Fatalf("dry-run output = %s", output) + } + if _, err := vfs.Stat("reports/file.bin"); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("dry-run created output: %v", err) + } +} + +func TestConvertDryRunValidatesFileIntent(t *testing.T) { + if _, err := convertDryRun(command.NewDryRun().File(command.FileIntent{})); err == nil { + t.Fatal("empty dry-run file intent was accepted") + } + if _, err := convertDryRun(command.NewDryRun().File(command.FileIntent{ + Name: "file.bin", IfExists: command.IfExistsPolicy("rename"), + })); err == nil { + t.Fatal("unsupported dry-run conflict policy was accepted") + } +} + +func TestURLDownloadBlocksLocalTargetBeforeHostCapabilities(t *testing.T) { + _, err := downloadURLCommand(context.Background(), stubHost{}, "https://127.0.0.1/file", command.FileTarget{ + Name: "file.bin", IfExists: command.IfExistsFail, + }, command.DownloadOptions{Representation: download.Mutable}) + var policy *errs.SecurityPolicyError + if !errors.As(err, &policy) || policy.Subtype != errs.SubtypeAccessDenied { + t.Fatalf("URL download error = %#v", err) + } +} + +// factoryTestRegistry returns the registry installed by TestFactory's HTTP +// client without adding another production seam. +func factoryTestRegistry(t *testing.T, factory *cmdutil.Factory) *httpmock.Registry { + t.Helper() + client, err := factory.HttpClient() + if err != nil { + t.Fatal(err) + } + registry, ok := client.Transport.(*httpmock.Registry) + if !ok { + t.Fatalf("test HTTP transport = %T", client.Transport) + } + return registry +} + +// The fail policy must survive a target that appears while the download is in +// flight. The existence check happens before the network, so only an exclusive +// commit can refuse the file at that point -- a check-then-save sequence would +// overwrite whatever the other writer put there. +func TestExternalDownloadRefusesTargetCreatedDuringTransfer(t *testing.T) { + cmdutil.TestChdir(t, t.TempDir()) + root, factory := mountExternalBackup(t) + factoryTestRegistry(t, factory).Register(&httpmock.Stub{ + Method: http.MethodGet, URL: "/open-apis/drive/v1/files/file_1/download", + RawBody: []byte("replacement"), ContentType: "application/octet-stream", + OnMatch: func(*http.Request) { + // Another writer wins the name after the pre-flight check passed. + if err := vfs.WriteFile("file.bin", []byte("concurrent"), 0600); err != nil { + t.Fatalf("simulate concurrent writer: %v", err) + } + }, + }) + + root.SetArgs([]string{"drive", "+external-backup", "--file-token", "file_1", "--output", "file.bin", "--as", "user"}) + _, err := root.ExecuteC() + var validation *errs.ValidationError + if !errors.As(err, &validation) || validation.Subtype != errs.SubtypeFailedPrecondition { + t.Fatalf("download error = %#v, want a failed-precondition refusal", err) + } + content, readErr := vfs.ReadFile("file.bin") + if readErr != nil || string(content) != "concurrent" { + t.Fatalf("concurrently created file = %q (readErr=%v), want it preserved", content, readErr) + } +} diff --git a/internal/commandhost/input_stage_test.go b/internal/commandhost/input_stage_test.go new file mode 100644 index 0000000000..1f81c0a090 --- /dev/null +++ b/internal/commandhost/input_stage_test.go @@ -0,0 +1,90 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package commandhost + +import ( + "context" + "strings" + "testing" + + "github.com/larksuite/cli/extension/command" + "github.com/larksuite/cli/internal/commandbridge" +) + +// stubHost embeds the interface so only the methods inputStageContext actually +// reads need an implementation; anything else it reached for would panic and +// name itself in the failure. +type stubHost struct { + commandbridge.RuntimeContext +} + +func (stubHost) Identity() command.Identity { return command.IdentityUser } +func (stubHost) IsDryRun() bool { return false } +func (stubHost) RequireConditionalScopes(...string) error { return nil } + +// Normalize and Validate run before the high-risk confirmation gate (see the +// documented hook order), so they must not reach the API: a high-risk command +// calling out from Validate would leave remote side effects behind before the +// user was ever asked to confirm. +func TestInputStageContextRefusesNetworkBeforeConfirmation(t *testing.T) { + commandContext := inputStageContext(stubHost{}) + + _, err := command.CallJSON[map[string]any](context.Background(), commandContext, + command.POST("/open-apis/im/v1/chats")) + if err == nil { + t.Fatal("CallJSON from an input-stage hook returned no error") + } + if !strings.Contains(err.Error(), "unavailable in Normalize and Validate") { + t.Fatalf("CallJSON error = %v", err) + } + + _, err = command.CollectAllPages[map[string]any](context.Background(), commandContext, + command.GET("/open-apis/im/v1/chats")) + if err == nil { + t.Fatal("CollectAllPages from an input-stage hook returned no error") + } + if !strings.Contains(err.Error(), "unavailable in Normalize and Validate") { + t.Fatalf("CollectAllPages error = %v", err) + } + + // Conditional scope checks stay available: they read the resolved token and + // are what a Validate hook legitimately needs. + if err := command.PreflightScopes(commandContext, "im:chat:read"); err != nil { + t.Fatalf("PreflightScopes from an input-stage hook = %v", err) + } +} + +// The guard holds even when a host wires the callbacks anyway, so the staging +// rule does not depend on every future adapter remembering to omit them. +func TestInputStageGuardOutranksWiredCallbacks(t *testing.T) { + commandContext := command.NewCommandContext(command.ContextOptions{ + InputStage: true, + CallJSON: func(context.Context, command.Request) (map[string]any, error) { + t.Fatal("input-stage CallJSON reached the host callback") + return nil, nil + }, + }) + if _, err := command.CallJSON[map[string]any](context.Background(), commandContext, + command.GET("/open-apis/im/v1/chats")); err == nil { + t.Fatal("wired input-stage CallJSON returned no error") + } +} + +// The Execute context keeps what the input stage gives up, so this is a staging +// rule rather than a wholesale removal. +func TestExecuteContextKeepsNetworkCapability(t *testing.T) { + commandContext := command.NewCommandContext(command.ContextOptions{ + CallJSON: func(context.Context, command.Request) (map[string]any, error) { + return map[string]any{"ok": true}, nil + }, + }) + data, err := command.CallJSON[map[string]any](context.Background(), commandContext, + command.GET("/open-apis/im/v1/chats")) + if err != nil { + t.Fatal(err) + } + if data["ok"] != true { + t.Fatalf("execute-stage CallJSON = %#v", data) + } +} diff --git a/internal/download/transport.go b/internal/downloadtransport/transport.go similarity index 81% rename from internal/download/transport.go rename to internal/downloadtransport/transport.go index 6f5ee6298f..9b8fd9beef 100644 --- a/internal/download/transport.go +++ b/internal/downloadtransport/transport.go @@ -1,18 +1,20 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package download +// Package downloadtransport adapts host-owned OAPI and URL clients to the +// public extension/download transport contract. +package downloadtransport import ( "context" "io" "net/http" - "strings" "time" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" "github.com/larksuite/cli/errs" + extdownload "github.com/larksuite/cli/extension/download" "github.com/larksuite/cli/internal/client" "github.com/larksuite/cli/internal/ratelimit" ) @@ -62,7 +64,7 @@ func QueryIf(name, value string) OAPIRequestOption { } // Get creates fresh SDK request state for every fetch. -func (o OAPI) Get(path string, options ...OAPIRequestOption) Transport { +func (o OAPI) Get(path string, options ...OAPIRequestOption) extdownload.Transport { spec := oapiRequestSpec{ pathParams: larkcore.PathParams{}, queryParams: larkcore.QueryParams{}, @@ -70,7 +72,7 @@ func (o OAPI) Get(path string, options ...OAPIRequestOption) Transport { for _, option := range options { option(&spec) } - return func(ctx context.Context, request Request) (*http.Response, error) { + return func(ctx context.Context, request extdownload.Request) (*http.Response, error) { if o.doStream == nil { return nil, errs.NewInternalError(errs.SubtypeUnknown, "OAPI download transport is not configured") } @@ -100,9 +102,10 @@ func cloneQueryParams(params larkcore.QueryParams) larkcore.QueryParams { return cloned } -// URL adapts a caller-validated URL and HTTP client to Transport. The caller -// owns source and redirect validation; Open owns transfer timeouts. -func URL(httpClient *http.Client, rawURL string) Transport { +// URL adapts a caller-validated URL and HTTP client to a public download +// Transport. The caller owns source and redirect validation; +// extension/download.Open owns transfer timeouts. +func URL(httpClient *http.Client, rawURL string) extdownload.Transport { var downloadClient *http.Client if httpClient != nil { downloadClient = &http.Client{ @@ -111,13 +114,13 @@ func URL(httpClient *http.Client, rawURL string) Transport { Jar: httpClient.Jar, } } - return func(ctx context.Context, request Request) (*http.Response, error) { + return func(ctx context.Context, request extdownload.Request) (*http.Response, error) { if downloadClient == nil { return nil, errs.NewInternalError(errs.SubtypeUnknown, "download URL transport requires an HTTP client") } req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { - return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "invalid download URL: %s", err).WithCause(err) + return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "invalid download URL").WithCause(err) } req.Header = request.Headers() @@ -131,9 +134,9 @@ func URL(httpClient *http.Client, rawURL string) Transport { return nil, err } if hasResponse { - return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "download redirect failed: %s", err).WithCause(err) + return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "download redirect failed").WithCause(err) } - return nil, client.WrapReplaySafeTransportError(ctx, err, "download failed: %s", err) + return nil, client.WrapReplaySafeTransportError(ctx, err, "download failed") } if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { return resp, nil @@ -143,11 +146,9 @@ func URL(httpClient *http.Client, rawURL string) Transport { } func urlResponseError(resp *http.Response) error { - var detail string if resp.Body != nil { defer resp.Body.Close() - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - detail = strings.TrimSpace(string(body)) + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) } subtype := errs.SubtypeNetworkTransport @@ -156,13 +157,7 @@ func urlResponseError(resp *http.Response) error { } else if resp.StatusCode >= http.StatusInternalServerError { subtype = errs.SubtypeNetworkServer } - message := "download failed: HTTP %d" - args := []any{resp.StatusCode} - if detail != "" { - message += ": %s" - args = append(args, detail) - } - networkErr := errs.NewNetworkError(subtype, message, args...).WithCode(resp.StatusCode) + networkErr := errs.NewNetworkError(subtype, "download failed: HTTP %d", resp.StatusCode).WithCode(resp.StatusCode) if resp.StatusCode == http.StatusRequestTimeout || resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= http.StatusInternalServerError { networkErr.WithRetryable() if retry := ratelimit.ParseStandardHeaders(resp.Header, time.Now()).RetryAfterSeconds(); retry > 0 { diff --git a/internal/download/transport_test.go b/internal/downloadtransport/transport_test.go similarity index 77% rename from internal/download/transport_test.go rename to internal/downloadtransport/transport_test.go index 36127eb87d..fe5ddcdb31 100644 --- a/internal/download/transport_test.go +++ b/internal/downloadtransport/transport_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package download +package downloadtransport import ( "context" @@ -17,6 +17,7 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" "github.com/larksuite/cli/errs" + extdownload "github.com/larksuite/cli/extension/download" "github.com/larksuite/cli/internal/client" ) @@ -34,8 +35,8 @@ func TestURLAppliesDownloadHeaders(t *testing.T) { }, nil })} - resp, err := URL(httpClient, "https://example.com/object")(context.Background(), Request{ - Range: &ByteRange{Start: 4, End: 9}, + resp, err := URL(httpClient, "https://example.com/object")(context.Background(), extdownload.Request{ + Range: &extdownload.ByteRange{Start: 4, End: 9}, IfRange: `"v1"`, }) if err != nil { @@ -59,9 +60,9 @@ func TestURLOpensImmutableMultipartSource(t *testing.T) { t.Fatalf("Range = %q: %v", value, err) } end = min(end, int64(len(payload))-1) - return testPartial(payload[start:end+1], start, end, int64(len(payload)), ""), nil + return transportTestPartial(payload[start:end+1], start, end, int64(len(payload))), nil })} - stream, err := Open(context.Background(), ImmutableSource(URL(httpClient, "https://example.com/object")), Options{PartSize: 4}) + stream, err := extdownload.Open(context.Background(), extdownload.ImmutableSource(URL(httpClient, "https://example.com/object")), extdownload.Options{PartSize: 4}) if err != nil { t.Fatalf("Open() error = %v", err) } @@ -96,7 +97,7 @@ func TestURLClassifiesHTTPStatus(t *testing.T) { Body: io.NopCloser(strings.NewReader("upstream response")), }, nil })} - _, err := URL(httpClient, "https://example.com/object")(context.Background(), Request{}) + _, err := URL(httpClient, "https://example.com/object")(context.Background(), extdownload.Request{}) problem, ok := errs.ProblemOf(err) if !ok || problem.Subtype != tt.subtype || problem.Code != tt.status || problem.Retryable != tt.retryable { t.Fatalf("problem = %#v, %v", problem, ok) @@ -125,7 +126,7 @@ func TestURLTransportFailureOwnsRetryability(t *testing.T) { httpClient := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, tt.err })} - _, err := URL(httpClient, "https://example.com/object")(context.Background(), Request{}) + _, err := URL(httpClient, "https://example.com/object")(context.Background(), extdownload.Request{}) problem, ok := errs.ProblemOf(err) if !ok || problem.Subtype != tt.subtype || problem.Retryable != tt.retryable { t.Fatalf("problem = %#v, %v", problem, ok) @@ -141,7 +142,7 @@ func TestURLCallerDeadlineIsNotRetryable(t *testing.T) { })} ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond) defer cancel() - _, err := URL(httpClient, "https://example.com/object")(ctx, Request{}) + _, err := URL(httpClient, "https://example.com/object")(ctx, extdownload.Request{}) problem, ok := errs.ProblemOf(err) if !ok || problem.Subtype != errs.SubtypeNetworkTimeout || problem.Retryable || !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("problem = %#v, %v; error = %v", problem, ok, err) @@ -162,13 +163,36 @@ func TestURLRedirectPolicyFailureIsNotRetryable(t *testing.T) { return errors.New("blocked redirect target") }, } - _, err := URL(httpClient, "https://example.com/object")(context.Background(), Request{}) + _, err := URL(httpClient, "https://example.com/object")(context.Background(), extdownload.Request{}) problem, ok := errs.ProblemOf(err) if !ok || problem.Subtype != errs.SubtypeNetworkTransport || problem.Retryable { t.Fatalf("problem = %#v, %v; error = %v", problem, ok, err) } } +func TestURLFailuresDoNotExposeSignedURLOrResponseBody(t *testing.T) { + const signedURL = "https://example.com/object?signature=top-secret" + transportFailure := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New("connection failed") + })} + _, err := URL(transportFailure, signedURL)(context.Background(), extdownload.Request{}) + if err == nil || strings.Contains(err.Error(), "top-secret") || strings.Contains(err.Error(), signedURL) { + t.Fatalf("transport error leaked signed URL: %v", err) + } + + httpFailure := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusForbidden, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("rejected signature=top-secret")), + }, nil + })} + _, err = URL(httpFailure, signedURL)(context.Background(), extdownload.Request{}) + if err == nil || strings.Contains(err.Error(), "top-secret") || strings.Contains(err.Error(), "rejected signature") { + t.Fatalf("HTTP error leaked signed response detail: %v", err) + } +} + func TestOAPIGetBuildsFreshRequestsFromInjectedStream(t *testing.T) { var requests []*larkcore.ApiReq doStream := APIStreamFunc(func(ctx context.Context, req *larkcore.ApiReq, options ...client.Option) (*http.Response, error) { @@ -203,7 +227,7 @@ func TestOAPIGetBuildsFreshRequestsFromInjectedStream(t *testing.T) { QueryIf("empty", ""), ) for i := 0; i < 2; i++ { - resp, err := transport(context.Background(), Request{Range: &ByteRange{Start: int64(i * 4), End: int64(i*4 + 3)}}) + resp, err := transport(context.Background(), extdownload.Request{Range: &extdownload.ByteRange{Start: int64(i * 4), End: int64(i*4 + 3)}}) if err != nil { t.Fatalf("transport() call %d error = %v", i+1, err) } @@ -219,3 +243,14 @@ type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } + +func transportTestPartial(body []byte, start, end, total int64) *http.Response { + return &http.Response{ + StatusCode: http.StatusPartialContent, + Header: http.Header{ + "Content-Range": {fmt.Sprintf("bytes %d-%d/%d", start, end, total)}, + }, + Body: io.NopCloser(strings.NewReader(string(body))), + ContentLength: int64(len(body)), + } +} diff --git a/internal/pagination/walk.go b/internal/pagination/walk.go new file mode 100644 index 0000000000..39c074664c --- /dev/null +++ b/internal/pagination/walk.go @@ -0,0 +1,138 @@ +// 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" +) + +// CollectAllHardPageBound caps complete-set collections. It is deliberately +// tighter than the user-facing --page-limit maximum (1000): such a collection +// holds every page in memory before the caller's writes run, so its upper bound +// is a host resource decision, not a display preference. Value from the +// extension design's Phase 0 (owner plan §8.3). +// +// It lives here because the production host adapter and the commandtest +// recorder must enforce the same bound: a business command whose tests pass at +// 300 pages but fails in production at 101 is worse than one that fails in both. +const CollectAllHardPageBound = 100 + +// 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 + } +} diff --git a/internal/pagination/walk_test.go b/internal/pagination/walk_test.go new file mode 100644 index 0000000000..71e3eabbe2 --- /dev/null +++ b/internal/pagination/walk_test.go @@ -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) + } +} diff --git a/internal/registry/service_desc.go b/internal/registry/service_desc.go index da2a6b5fc3..16bf53650d 100644 --- a/internal/registry/service_desc.go +++ b/internal/registry/service_desc.go @@ -6,6 +6,7 @@ package registry import ( _ "embed" "encoding/json" + "sort" ) //go:embed service_descriptions.json @@ -35,6 +36,19 @@ func loadServiceDescriptions() map[string]serviceDescEntry { return serviceDescMap } +// AllServiceNames returns every configured service domain, sorted. It covers +// domains served only by typed or raw API commands, so it is a superset of the +// domains reachable through shortcuts. +func AllServiceNames() []string { + m := loadServiceDescriptions() + names := make([]string, 0, len(m)) + for name := range m { + names = append(names, name) + } + sort.Strings(names) + return names +} + func getServiceLocale(name, lang string) *serviceDescLocale { m := loadServiceDescriptions() entry, ok := m[name] diff --git a/internal/vfs/default.go b/internal/vfs/default.go index 508c2399b9..d1d58e6039 100644 --- a/internal/vfs/default.go +++ b/internal/vfs/default.go @@ -33,5 +33,6 @@ func ReadDir(name string) ([]os.DirEntry, error) { return DefaultFS.ReadDi func Remove(name string) error { return DefaultFS.Remove(name) } func RemoveAll(path string) error { return DefaultFS.RemoveAll(path) } func Rename(oldpath, newpath string) error { return DefaultFS.Rename(oldpath, newpath) } +func Link(oldname, newname string) error { return DefaultFS.Link(oldname, newname) } func EvalSymlinks(path string) (string, error) { return DefaultFS.EvalSymlinks(path) } func Executable() (string, error) { return DefaultFS.Executable() } diff --git a/internal/vfs/fs.go b/internal/vfs/fs.go index 10825bd946..1285cfc67d 100644 --- a/internal/vfs/fs.go +++ b/internal/vfs/fs.go @@ -32,6 +32,12 @@ type FS interface { RemoveAll(path string) error Rename(oldpath, newpath string) error + // Link creates newname as a hard link to oldname. It fails with an error + // satisfying errors.Is(err, fs.ErrExist) when newname already exists, which + // makes it the commit step for a no-clobber write: unlike Rename, it never + // replaces an existing target. + Link(oldname, newname string) error + // Path resolution EvalSymlinks(path string) (string, error) Executable() (string, error) diff --git a/internal/vfs/localfileio/atomicwrite.go b/internal/vfs/localfileio/atomicwrite.go index 00fc6f91af..5c048965b3 100644 --- a/internal/vfs/localfileio/atomicwrite.go +++ b/internal/vfs/localfileio/atomicwrite.go @@ -34,6 +34,63 @@ func AtomicWriteFromReader(path string, reader io.Reader, perm os.FileMode) (int return copied, nil } +// ExclusiveWriteFromReader copies reader contents into path only when path does +// not already exist, and reports an error satisfying errors.Is(err, fs.ErrExist) +// when it does. +// +// Content is written to a temp file in the same directory and committed with +// Link, which combines both guarantees this call has to make: +// +// - No-clobber. Link fails with EEXIST instead of replacing an existing +// target, so the refusal is decided by the commit itself. A preceding +// existence check cannot do this: another writer may create the file while +// this one is still copying. +// - Whole-file visibility. The target name appears only once the content is +// complete and synced. Writing directly to the final name with O_EXCL would +// satisfy no-clobber but publish a partial file for the duration of the +// copy, and a killed process would leave that partial file behind as a +// phantom target for the next attempt. +// +// Rename cannot serve as the commit step because it replaces an existing target +// unconditionally. +func ExclusiveWriteFromReader(path string, reader io.Reader, perm os.FileMode) (int64, error) { + dir := filepath.Dir(path) + tmp, err := vfs.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp") + if err != nil { + return 0, fmt.Errorf("create temp file: %w", err) + } + tmpName := tmp.Name() + + closed := false + defer func() { + if !closed { + tmp.Close() + } + // The temp name is removed either way: on failure it is the partial + // artifact, on success the link has already published the content. + vfs.Remove(tmpName) + }() + + if err := tmp.Chmod(perm); err != nil { + return 0, err + } + copied, err := io.Copy(tmp, reader) + if err != nil { + return 0, err + } + if err := tmp.Sync(); err != nil { + return 0, err + } + if err := tmp.Close(); err != nil { + return 0, err + } + closed = true + if err := vfs.Link(tmpName, path); err != nil { + return 0, err + } + return copied, nil +} + func atomicWrite(path string, perm os.FileMode, writeFn func(tmp *os.File) error) error { dir := filepath.Dir(path) tmp, err := vfs.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp") diff --git a/internal/vfs/localfileio/atomicwrite_test.go b/internal/vfs/localfileio/atomicwrite_test.go index d8dbbb7510..93ef985bea 100644 --- a/internal/vfs/localfileio/atomicwrite_test.go +++ b/internal/vfs/localfileio/atomicwrite_test.go @@ -4,9 +4,13 @@ package localfileio import ( + "errors" + "io" + "io/fs" "os" "path/filepath" "runtime" + "strings" "sync" "testing" ) @@ -144,3 +148,67 @@ func TestAtomicWrite_HandlesCorrectlyUnderConcurrentWrites(t *testing.T) { t.Error("file is empty after concurrent writes") } } + +// The exclusive commit must publish the target only once the content is +// complete. A reader that blocks mid-copy proves the final name is absent while +// bytes are still in flight -- writing straight to the final name with O_EXCL +// would satisfy no-clobber but expose a partial file, and a killed process would +// leave it behind as a phantom target for the next attempt. +func TestExclusiveWriteFromReaderPublishesOnlyCompleteContent(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "artifact.bin") + + released := make(chan struct{}) + observed := make(chan error, 1) + reader := io.MultiReader( + strings.NewReader("first-half"), + readerFunc(func(p []byte) (int, error) { + // The copy is now half done; the final name must not exist yet. + _, statErr := os.Stat(target) + observed <- statErr + close(released) + return 0, io.EOF + }), + ) + + written, err := ExclusiveWriteFromReader(target, reader, 0600) + if err != nil { + t.Fatalf("ExclusiveWriteFromReader() error = %v", err) + } + <-released + if statErr := <-observed; !errors.Is(statErr, fs.ErrNotExist) { + t.Fatalf("target existed mid-copy: %v", statErr) + } + if written != int64(len("first-half")) { + t.Fatalf("written = %d", written) + } + content, readErr := os.ReadFile(target) + if readErr != nil || string(content) != "first-half" { + t.Fatalf("committed content = %q (err=%v)", content, readErr) + } +} + +// The commit refuses an existing target instead of replacing it. +func TestExclusiveWriteFromReaderRefusesExistingTarget(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "artifact.bin") + if err := os.WriteFile(target, []byte("original"), 0600); err != nil { + t.Fatal(err) + } + _, err := ExclusiveWriteFromReader(target, strings.NewReader("replacement"), 0600) + if !errors.Is(err, fs.ErrExist) { + t.Fatalf("error = %v, want fs.ErrExist", err) + } + content, readErr := os.ReadFile(target) + if readErr != nil || string(content) != "original" { + t.Fatalf("existing content = %q (err=%v), want it preserved", content, readErr) + } + entries, _ := os.ReadDir(dir) + if len(entries) != 1 { + t.Fatalf("directory holds %d entries, want only the original file (temp file leaked)", len(entries)) + } +} + +type readerFunc func(p []byte) (int, error) + +func (f readerFunc) Read(p []byte) (int, error) { return f(p) } diff --git a/internal/vfs/localfileio/localfileio.go b/internal/vfs/localfileio/localfileio.go index 9712b9e744..fbf783da74 100644 --- a/internal/vfs/localfileio/localfileio.go +++ b/internal/vfs/localfileio/localfileio.go @@ -5,7 +5,9 @@ package localfileio import ( "context" + "errors" "io" + "io/fs" "path/filepath" "github.com/larksuite/cli/extension/fileio" @@ -82,6 +84,27 @@ func (l *LocalFileIO) Save(path string, _ fileio.SaveOptions, body io.Reader) (f return &saveResult{size: n}, nil } +// SaveExclusive writes content only when path does not exist, satisfying +// fileio.ExclusiveFileIO. It exists so a no-clobber download policy is enforced +// by the commit itself rather than by an existence check the commit ignores. +func (l *LocalFileIO) SaveExclusive(path string, _ fileio.SaveOptions, body io.Reader) (fileio.SaveResult, error) { + safePath, err := SafeOutputPath(path) + if err != nil { + return nil, &fileio.PathValidationError{Err: err} + } + if err := vfs.MkdirAll(filepath.Dir(safePath), 0700); err != nil { + return nil, &fileio.MkdirError{Err: err} + } + n, err := ExclusiveWriteFromReader(safePath, body, 0600) + if err != nil { + if errors.Is(err, fs.ErrExist) { + return nil, err + } + return nil, &fileio.WriteError{Err: err} + } + return &saveResult{size: n}, nil +} + // RemoveWorkspaceEntry removes one workspace file or one empty workspace // directory after applying the same output-path validation as Save. func (l *LocalFileIO) RemoveWorkspaceEntry(path string) error { diff --git a/internal/vfs/osfs.go b/internal/vfs/osfs.go index 95922d5728..d88bb93012 100644 --- a/internal/vfs/osfs.go +++ b/internal/vfs/osfs.go @@ -36,6 +36,7 @@ func (OsFs) ReadDir(name string) ([]os.DirEntry, error) { return os.ReadDir(n func (OsFs) Remove(name string) error { return os.Remove(name) } func (OsFs) RemoveAll(path string) error { return os.RemoveAll(path) } func (OsFs) Rename(oldpath, newpath string) error { return os.Rename(oldpath, newpath) } +func (OsFs) Link(oldname, newname string) error { return os.Link(oldname, newname) } // Path resolution func (OsFs) EvalSymlinks(path string) (string, error) { return filepath.EvalSymlinks(path) } diff --git a/shortcuts/common/clone.go b/shortcuts/common/clone.go new file mode 100644 index 0000000000..fa2259a80b --- /dev/null +++ b/shortcuts/common/clone.go @@ -0,0 +1,270 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "io" + "reflect" + + "github.com/larksuite/cli/extension/command" + "github.com/larksuite/cli/internal/commandbridge" +) + +// cloneShortcut copies mutable declaration and compiled-contract data. +// Function values and values captured by business closures remain shared. +func cloneShortcut(shortcut Shortcut) Shortcut { + cloned := shortcut + cloned.Scopes = append([]string(nil), shortcut.Scopes...) + cloned.UserScopes = append([]string(nil), shortcut.UserScopes...) + cloned.BotScopes = append([]string(nil), shortcut.BotScopes...) + cloned.ConditionalScopes = append([]string(nil), shortcut.ConditionalScopes...) + cloned.ConditionalUserScopes = append([]string(nil), shortcut.ConditionalUserScopes...) + cloned.ConditionalBotScopes = append([]string(nil), shortcut.ConditionalBotScopes...) + cloned.AuthTypes = append([]string(nil), shortcut.AuthTypes...) + cloned.Tips = append([]string(nil), shortcut.Tips...) + cloned.Flags = make([]Flag, len(shortcut.Flags)) + for index, flag := range shortcut.Flags { + cloned.Flags[index] = flag + cloned.Flags[index].Aliases = append([]string(nil), flag.Aliases...) + cloned.Flags[index].Enum = append([]string(nil), flag.Enum...) + cloned.Flags[index].Input = append([]string(nil), flag.Input...) + } + cloned.typed = cloneCompiledCommand(shortcut.typed) + if cloned.typed != nil { + cloned.PrintFlagSchema = typedFlagSchemaPrinter(cloned.typed) + } + return cloned +} + +// CloneHostedShortcuts copies a shortcut slice for the internal registry. +func CloneHostedShortcuts(shortcuts []Shortcut, _ commandbridge.Access) []Shortcut { + cloned := make([]Shortcut, len(shortcuts)) + for index, shortcut := range shortcuts { + cloned[index] = cloneShortcut(shortcut) + } + return cloned +} + +func cloneCompiledCommand(command *compiledCommand) *compiledCommand { + if command == nil { + return nil + } + cloned := *command + cloned.metadata = normalizeCommandMetadata(command.metadata) + cloned.fields = make([]compiledInputField, len(command.fields)) + for index, field := range command.fields { + cloned.fields[index] = field + cloned.fields[index].index = append([]int(nil), field.index...) + cloned.fields[index].valueIndex = append([]int(nil), field.valueIndex...) + cloned.fields[index].shape = cloneCommonShape(field.shape) + cloned.fields[index].defaultValue.Value = cloneJSONValue(field.defaultValue.Value) + cloned.fields[index].cli.Aliases = append([]typedFlagAlias(nil), field.cli.Aliases...) + cloned.fields[index].cli.ValueSources = append([]typedValueSource(nil), field.cli.ValueSources...) + } + cloned.fieldByName = make(map[string]int, len(command.fieldByName)) + for name, index := range command.fieldByName { + cloned.fieldByName[name] = index + } + cloned.relations = make([]compiledRelation, len(command.relations)) + for index, relation := range command.relations { + cloned.relations[index] = relation + cloned.relations[index].fields = append([]int(nil), relation.fields...) + } + cloned.dataShape = cloneCommonShape(command.dataShape) + cloned.output = cloneCommonOutput(command.output) + cloned.hooks = command.hooks + if len(command.hooks.renderers) > 0 { + cloned.hooks.renderers = make(map[string]func(io.Writer, any) error, len(command.hooks.renderers)) + for name, renderer := range command.hooks.renderers { + cloned.hooks.renderers[name] = renderer + } + } + cloned.contract = buildTypedSchemaContract(&cloned) + return &cloned +} + +func cloneCommonOutput(output typedOutputDefinition) typedOutputDefinition { + output.Data.Shape = cloneAuthoringShape(output.Data.Shape) + output.Data.Overrides = append([]typedDataField(nil), output.Data.Overrides...) + for index := range output.Data.Overrides { + output.Data.Overrides[index].Shape = cloneAuthoringShape(output.Data.Overrides[index].Shape) + } + return output +} + +func cloneAuthoringShape(shape command.ValueShape) command.ValueShape { + if shape == nil { + return nil + } + return cloneJSONValue(shape).(command.ValueShape) +} + +func cloneCommonShape(shape typedValueShape) typedValueShape { + switch typed := shape.(type) { + case nil: + return nil + case typedStringShape: + typed.Enum = append([]string(nil), typed.Enum...) + typed.MinLength = cloneScalarPointer(typed.MinLength) + typed.MaxLength = cloneScalarPointer(typed.MaxLength) + return typed + case typedBooleanShape: + typed.Enum = append([]bool(nil), typed.Enum...) + return typed + case typedIntegerShape: + typed.Enum = append([]int64(nil), typed.Enum...) + typed.Minimum = cloneScalarPointer(typed.Minimum) + typed.Maximum = cloneScalarPointer(typed.Maximum) + return typed + case typedNumberShape: + typed.Enum = append([]float64(nil), typed.Enum...) + typed.Minimum = cloneScalarPointer(typed.Minimum) + typed.Maximum = cloneScalarPointer(typed.Maximum) + return typed + case typedNullShape, anyJSONShape: + return typed + case typedConstShape: + typed.Value = cloneJSONValue(typed.Value) + return typed + case typedArrayShape: + typed.Items = cloneCommonShape(typed.Items) + typed.MinItems = cloneScalarPointer(typed.MinItems) + typed.MaxItems = cloneScalarPointer(typed.MaxItems) + return typed + case typedObjectShape: + typed.Fields = append([]typedValueField(nil), typed.Fields...) + for index := range typed.Fields { + typed.Fields[index].Shape = cloneCommonShape(typed.Fields[index].Shape) + } + typed.AdditionalPropertiesShape = cloneCommonShape(typed.AdditionalPropertiesShape) + return typed + case typedOneOfShape: + typed.Variants = append([]typedValueShape(nil), typed.Variants...) + for index := range typed.Variants { + typed.Variants[index] = cloneCommonShape(typed.Variants[index]) + } + return typed + default: + return shape + } +} + +func cloneScalarPointer[T any](value *T) *T { + if value == nil { + return nil + } + cloned := *value + return &cloned +} + +func cloneJSONValue(value any) any { + if value == nil { + return nil + } + return cloneJSONReflect(reflect.ValueOf(value), make(map[cloneVisit]reflect.Value)).Interface() +} + +type cloneVisit struct { + typeOf reflect.Type + pointer uintptr + length int +} + +func cloneJSONReflect(value reflect.Value, seen map[cloneVisit]reflect.Value) reflect.Value { + if !value.IsValid() { + return value + } + switch value.Kind() { + case reflect.Interface: + return cloneJSONInterface(value, seen) + case reflect.Pointer: + return cloneJSONPointer(value, seen) + case reflect.Map: + return cloneJSONMap(value, seen) + case reflect.Slice: + return cloneJSONSlice(value, seen) + case reflect.Array: + return cloneJSONArray(value, seen) + case reflect.Struct: + return cloneJSONStruct(value, seen) + default: + return value + } +} + +func cloneJSONInterface(value reflect.Value, seen map[cloneVisit]reflect.Value) reflect.Value { + if value.IsNil() { + return reflect.Zero(value.Type()) + } + cloned := cloneJSONReflect(value.Elem(), seen) + result := reflect.New(value.Type()).Elem() + result.Set(cloned) + return result +} + +func cloneJSONPointer(value reflect.Value, seen map[cloneVisit]reflect.Value) reflect.Value { + if value.IsNil() { + return reflect.Zero(value.Type()) + } + visit := cloneVisit{typeOf: value.Type(), pointer: value.Pointer()} + if cloned, ok := seen[visit]; ok { + return cloned + } + result := reflect.New(value.Type().Elem()) + seen[visit] = result + result.Elem().Set(cloneJSONReflect(value.Elem(), seen)) + return result +} + +func cloneJSONMap(value reflect.Value, seen map[cloneVisit]reflect.Value) reflect.Value { + if value.IsNil() { + return reflect.Zero(value.Type()) + } + visit := cloneVisit{typeOf: value.Type(), pointer: value.Pointer()} + if cloned, ok := seen[visit]; ok { + return cloned + } + result := reflect.MakeMapWithSize(value.Type(), value.Len()) + seen[visit] = result + iterator := value.MapRange() + for iterator.Next() { + result.SetMapIndex(cloneJSONReflect(iterator.Key(), seen), cloneJSONReflect(iterator.Value(), seen)) + } + return result +} + +func cloneJSONSlice(value reflect.Value, seen map[cloneVisit]reflect.Value) reflect.Value { + if value.IsNil() { + return reflect.Zero(value.Type()) + } + visit := cloneVisit{typeOf: value.Type(), pointer: value.Pointer(), length: value.Len()} + if cloned, ok := seen[visit]; ok { + return cloned + } + result := reflect.MakeSlice(value.Type(), value.Len(), value.Len()) + seen[visit] = result + for index := 0; index < value.Len(); index++ { + result.Index(index).Set(cloneJSONReflect(value.Index(index), seen)) + } + return result +} + +func cloneJSONArray(value reflect.Value, seen map[cloneVisit]reflect.Value) reflect.Value { + result := reflect.New(value.Type()).Elem() + for index := 0; index < value.Len(); index++ { + result.Index(index).Set(cloneJSONReflect(value.Index(index), seen)) + } + return result +} + +func cloneJSONStruct(value reflect.Value, seen map[cloneVisit]reflect.Value) reflect.Value { + result := reflect.New(value.Type()).Elem() + result.Set(value) + for index := 0; index < value.NumField(); index++ { + if value.Type().Field(index).PkgPath == "" { + result.Field(index).Set(cloneJSONReflect(value.Field(index), seen)) + } + } + return result +} diff --git a/shortcuts/common/clone_test.go b/shortcuts/common/clone_test.go new file mode 100644 index 0000000000..f93f7956cd --- /dev/null +++ b/shortcuts/common/clone_test.go @@ -0,0 +1,74 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "context" + "testing" +) + +type cloneArgs struct { + Mode string `flag:"mode" schema:"required;enum=one|two" doc:"mode"` +} + +type cloneData struct { + ID string `json:"id" schema:"required" doc:"identifier"` +} + +func TestCloneShortcutCopiesCompiledContract(t *testing.T) { + original := defineTypedShortcut(typedDefinition[cloneArgs, cloneData]{ + Metadata: typedCommandMetadata{ + Service: "im", Command: "+clone", Description: "Clone", Risk: typedRiskRead, + Authorization: typedAuthorizationDefinition{Identities: map[typedIdentity]typedIdentityAuthorization{ + typedIdentityUser: {RequiredScopes: []string{"im:chat:read"}}, + }}, + }, + Hooks: typedHooks[cloneArgs, cloneData]{Execute: func(context.Context, typedRuntimeContext, *cloneArgs) (typedResult[cloneData], error) { + return typedSuccess(cloneData{}), nil + }}, + }) + minLength := 1 + shape := original.typed.fields[0].shape.(typedStringShape) + shape.MinLength = &minLength + original.typed.fields[0].shape = shape + cloned := cloneShortcut(original) + + original.UserScopes[0] = "mutated" + original.Flags[0].Enum[0] = "mutated" + original.typed.metadata.Authorization.Identities[typedIdentityUser] = typedIdentityAuthorization{RequiredScopes: []string{"mutated"}} + originalShape := original.typed.fields[0].shape.(typedStringShape) + originalShape.Enum[0] = "mutated" + *originalShape.MinLength = 2 + + if got := cloned.UserScopes[0]; got != "im:chat:read" { + t.Fatalf("cloned user scope = %q", got) + } + if got := cloned.Flags[0].Enum[0]; got != "one" { + t.Fatalf("cloned flag enum = %q", got) + } + if got := cloned.typed.metadata.Authorization.Identities[typedIdentityUser].RequiredScopes[0]; got != "im:chat:read" { + t.Fatalf("cloned typed scope = %q", got) + } + if got := cloned.typed.fields[0].shape.(typedStringShape).Enum[0]; got != "one" { + t.Fatalf("cloned typed enum = %q", got) + } + if got := *cloned.typed.fields[0].shape.(typedStringShape).MinLength; got != 1 { + t.Fatalf("cloned minimum length = %d", got) + } +} + +func TestExternalFlagNamespaceRejectsEverySystemFlag(t *testing.T) { + systemFlags := []string{ + "as", "dry-run", "flag-name", "format", "help", "jq", "json", "page-all", + "page-delay", "page-limit", "print-schema", "profile", "yes", + } + for _, name := range systemFlags { + t.Run(name, func(t *testing.T) { + compiled := &compiledCommand{fields: []compiledInputField{{goName: "Fixture", name: name}}} + if err := validateExternalFlagNamespace(compiled); err == nil { + t.Fatalf("system flag --%s was accepted", name) + } + }) + } +} diff --git a/shortcuts/common/paginate_into.go b/shortcuts/common/paginate_into.go index ac71e03df2..91467b02ae 100644 --- a/shortcuts/common/paginate_into.go +++ b/shortcuts/common/paginate_into.go @@ -9,10 +9,12 @@ import ( "encoding/json" "errors" "fmt" + "io" "time" "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 +60,114 @@ 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() + } + walk := pageWalk{ + policy: policy, + request: request, + wait: wait, + // The runtime carries its own context, so this fetch ignores the + // walker's -- unlike the externally declared commands, whose context + // arrives with the call. + fetch: func(_ context.Context, page PageRequest) (map[string]interface{}, error) { + return runtime.CallAPITyped(page.Method, page.Path, page.Params, page.Body) + }, + accumulate: func(data map[string]interface{}, pageNumber int) error { + return addDecodedPage(data, pageNumber, dst) + }, } + if policy.showProgress { + walk.progress = runtime.IO().ErrOut + } + state, walkErr := walk.run(ctx) + meta.Complete = state.Complete + meta.Pages = state.Pages + meta.NextToken = state.NextToken + return meta, walkErr +} - // 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) - } +// pageWalk is one pagination run. Built-in shortcuts and externally declared +// commands differ only in where the policy comes from, how a page is fetched +// and what accumulates it; the cursor walk, the inter-page delay and the error +// mapping are the same and live here. +type pageWalk struct { + policy paginationPolicy + request PageRequest + fetch func(context.Context, PageRequest) (map[string]interface{}, error) + accumulate func(data map[string]interface{}, pageNumber int) error + wait pageDelayWaiter + progress io.Writer // nil when the run reports no per-page progress +} - 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 +func (w pageWalk) run(ctx context.Context) (internalpagination.State, error) { + state, walkErr := internalpagination.Walk(ctx, internalpagination.Options{ + InitialToken: pageTokenParam(w.request.Params), + MaxPages: w.policy.maxPages, + Delay: w.policy.pageDelay, + Wait: w.wait, + Fetch: func(ctx context.Context, pageNumber int, pageToken string) (bool, string, error) { + page := w.request + page.Params = clonePageParams(w.request.Params) + if pageToken != "" { + page.Params["page_token"] = pageToken + } + if w.progress != nil { + fmt.Fprintf(w.progress, "[page %d] fetching...\n", pageNumber) } - 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() + data, err := w.fetch(ctx, page) + if err != nil { + return false, "", err } - if err := wait(ctx, policy.pageDelay); err != nil { - return meta, paginationWaitError(err) + if err := w.accumulate(data, pageNumber); err != nil { + return false, "", err } + hasMore, nextPageToken := PaginationMeta(data) + return hasMore, nextPageToken, nil + }, + }) + if walkErr != nil { + return state, paginationWalkError(walkErr) + } + return state, nil +} + +// addDecodedPage keeps the typed-accumulator half of the walk out of pageWalk, +// which stays generic-free so both entry points can share one struct. +func addDecodedPage[T any](data map[string]interface{}, pageNumber int, dst PageAccumulator[T]) error { + page, err := decodePageData[T](data, pageNumber) + if err != nil { + return err + } + if err := dst.AddPage(page); err != nil { + if _, ok := errs.ProblemOf(err); ok { + return err } + return errs.NewInternalError(errs.SubtypeUnknown, + "accumulate pagination page %d: %v", pageNumber, err). + WithCause(err) } + return nil +} - 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 +211,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 { diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index e0b1e1681a..1c6f5ce5b1 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -55,6 +55,7 @@ type RuntimeContext struct { larkSDK *lark.Client // eagerly initialized in mountDeclarative stdinConsumed bool // set when an Input flag has consumed stdin (`-`); guards against a second flag also using `-` within the same call inputResolved map[string]bool // flags whose value was replaced by @file / stdin content in resolveInputFlags; see InputResolvedFromSource + offline bool // dry-run context: API and credential-backed scope checks are disabled } // ── Identity ── @@ -145,6 +146,9 @@ type BotInfo struct { // Unlike UserOpenId() (which reads from config), this requires a network call and may fail. // Thread-safe via sync.OnceValues; the API is called at most once per RuntimeContext. func (ctx *RuntimeContext) BotInfo() (*BotInfo, error) { + if ctx.offline { + return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "BotInfo is unavailable during dry-run") + } if ctx.botInfoFunc == nil { return nil, fmt.Errorf("BotInfo not available (runtime context not fully initialized)") } @@ -195,6 +199,9 @@ func (ctx *RuntimeContext) Ctx() context.Context { return ctx.ctx } // Thread-safe via sync.OnceValues (initialized in newRuntimeContext). // Falls back to direct construction for test contexts that bypass newRuntimeContext. func (ctx *RuntimeContext) getAPIClient() (*client.APIClient, error) { + if ctx.offline { + return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "OpenAPI requests are unavailable during dry-run") + } if ctx.apiClientFunc != nil { return ctx.apiClientFunc() } @@ -246,6 +253,9 @@ func (ctx *RuntimeContext) LarkSDK() *lark.Client { // resolver doesn't expose scope metadata, this is a silent no-op — the // downstream API call still surfaces missing_scope at runtime. func (ctx *RuntimeContext) EnsureScopes(scopes []string) error { + if ctx.offline { + return nil + } return checkShortcutScopes(ctx.Factory, ctx.ctx, ctx.As(), ctx.Config, scopes) } @@ -906,7 +916,7 @@ func (s Shortcut) Mount(parent *cobra.Command, f *cmdutil.Factory) { // MountWithContext registers a shortcut while preserving the caller-provided context. func (s Shortcut) MountWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) { - if s.Execute != nil { + if s.Execute != nil || s.typed != nil { s.mountDeclarative(ctx, parent, f) } } @@ -914,6 +924,11 @@ func (s Shortcut) MountWithContext(ctx context.Context, parent *cobra.Command, f // mountDeclarative builds and registers the Cobra command described by a Shortcut. func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) { shortcut := s + if shortcut.typed != nil { + if err := validateTypedFlagMountPlan(shortcut.typed, shortcut.PrintFlagSchema != nil, typedRisk(shortcut.Risk)); err != nil { + panic(fmt.Sprintf("typed shortcut %s %s: %v", shortcut.Service, shortcut.Command, err)) + } + } if len(shortcut.AuthTypes) == 0 { shortcut.AuthTypes = []string{"user"} } @@ -925,6 +940,12 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f Hidden: shortcut.Hidden, Args: rejectPositionalArgs(), RunE: func(cmd *cobra.Command, _ []string) error { + if handled, err := runShortcutFlagSchema(cmd, f, &shortcut); handled { + return err + } + if shortcut.typed != nil { + return runTypedMountedShortcut(cmd, f, &shortcut, botOnly) + } return runShortcut(cmd, f, &shortcut, botOnly) }, } @@ -944,7 +965,13 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f if relaxRequiredForSchema { if want, _ := c.Flags().GetBool("print-schema"); want { c.Flags().VisitAll(func(fl *pflag.Flag) { + // Schema inspection is a local metadata operation. Bypass both + // individual required flags and Cobra groups so business inputs + // are never needed merely to inspect a complex field. delete(fl.Annotations, cobra.BashCompOneRequiredFlag) + delete(fl.Annotations, "cobra_annotation_one_required") + delete(fl.Annotations, "cobra_annotation_mutually_exclusive") + delete(fl.Annotations, "cobra_annotation_required_if_others_set") }) } } @@ -953,45 +980,133 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f } cmdmeta.SetSource(cmd, cmdmeta.SourceShortcut, false) cmdmeta.SetAffordanceRef(cmd, shortcut.Service, shortcut.Command) + cmdmeta.SetDeclaredScopes(cmd, map[string][]string{ + "user": shortcut.DeclaredScopesForIdentity("user"), + "bot": shortcut.DeclaredScopesForIdentity("bot"), + }) cmdutil.SetSupportedIdentities(cmd, shortcut.AuthTypes) registerShortcutFlagsWithContext(ctx, cmd, f, &shortcut) cmdutil.SetTips(cmd, shortcut.Tips) cmdutil.SetRisk(cmd, shortcut.Risk) parent.AddCommand(cmd) if shortcut.PostMount != nil { + var typedSnapshot typedMountSnapshot + if shortcut.typed != nil { + typedSnapshot = captureTypedMountSnapshot(cmd) + } shortcut.PostMount(cmd) + if shortcut.typed != nil { + if err := validateTypedPostMount(cmd, typedSnapshot); err != nil { + panic(fmt.Sprintf("typed shortcut %s %s: %v", shortcut.Service, shortcut.Command, err)) + } + } } installFlagAliases(cmd, shortcut.Flags) + if shortcut.typed != nil { + installTypedAnnotations(cmd, shortcut.typed) + installTypedHelp(cmd, shortcut.typed) + } +} + +func runTypedMountedShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bool) error { + dryRun, _ := cmd.Flags().GetBool("dry-run") + if dryRun { + as, err := resolveShortcutIdentity(cmd, f, s) + if err != nil { + return err + } + config, err := f.Config() + if err != nil { + return err + } + rctx := newDryRunRuntimeContext(cmd, f, s, config, as, botOnly) + if err := output.ValidateJqFlags(rctx.JqExpr, "", rctx.Format); err != nil { + return err + } + return runTypedShortcut(f, rctx, s) + } + as, err := resolveShortcutIdentity(cmd, f, s) + if err != nil { + return err + } + config, err := f.Config() + if err != nil { + return err + } + if err := checkShortcutScopes(f, cmd.Context(), as, config, s.ScopesForIdentity(string(as))); err != nil { + return err + } + rctx, err := newRuntimeContext(cmd, f, s, config, as, botOnly) + if err != nil { + return err + } + if err := output.ValidateJqFlags(rctx.JqExpr, "", rctx.Format); err != nil { + return err + } + return runTypedShortcut(f, rctx, s) +} +func installTypedAnnotations(cmd *cobra.Command, command *compiledCommand) { + for _, field := range command.fields { + if field.cli.Deprecated != "" { + _ = cmd.Flags().MarkDeprecated(field.name, field.cli.Deprecated) + } + for _, alias := range field.cli.Aliases { + if alias.Mode != typedAliasIndependent || !alias.Deprecated { + continue + } + _ = cmd.Flags().MarkDeprecated(alias.Name, "use --"+field.name+" instead") + } + } + for _, relation := range command.relations { + if relation.stage != typedStageSourcePreRun || relation.presence != typedPresenceExplicit { + continue + } + names := make([]string, 0, len(relation.fields)) + for _, index := range relation.fields { + names = append(names, command.fields[index].name) + } + switch relation.kind { + case typedRelationExactlyOne: + cmd.MarkFlagsOneRequired(names...) + cmd.MarkFlagsMutuallyExclusive(names...) + case typedRelationAtLeastOne: + cmd.MarkFlagsOneRequired(names...) + case typedRelationCoOccur: + cmd.MarkFlagsRequiredTogether(names...) + case typedRelationConflicts: + cmd.MarkFlagsMutuallyExclusive(names...) + } + } +} + +func runShortcutFlagSchema(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) (bool, error) { + if s.PrintFlagSchema == nil { + return false, nil + } + want, _ := cmd.Flags().GetBool("print-schema") + if !want { + return false, nil + } + flagName, _ := cmd.Flags().GetString("flag-name") + out, err := s.PrintFlagSchema(strings.TrimSpace(flagName)) + if err != nil { + // PrintFlagSchema implementations may return bare errors; wrap those so + // this agent-facing local introspection path stays machine-readable. + if !errs.IsTyped(err) { + err = errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()).WithCause(err) + } + return true, err + } + if len(out) > 0 { + fmt.Fprintln(f.IOStreams.Out, string(out)) + } + return true, nil } // runShortcut is the execution pipeline for a declarative shortcut. // Each step is a clear phase: identity → config → scopes → runtime → // canonical validation → execute. func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bool) error { - // --print-schema short-circuits everything below: it's pure local - // introspection, no identity / scope / network needed. The flag is - // only registered when the shortcut opts in via PrintFlagSchema. - if s.PrintFlagSchema != nil { - if want, _ := cmd.Flags().GetBool("print-schema"); want { - flagName, _ := cmd.Flags().GetString("flag-name") - out, err := s.PrintFlagSchema(strings.TrimSpace(flagName)) - if err != nil { - // PrintFlagSchema implementations return bare errors; wrap as a - // typed validation error so --print-schema (an agent-facing - // introspection path) yields a parseable envelope, not a plain - // string. - if !errs.IsTyped(err) { - err = errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()).WithCause(err) - } - return err - } - if len(out) == 0 { - return nil - } - fmt.Fprintln(f.IOStreams.Out, string(out)) - return nil - } - } as, err := resolveShortcutIdentity(cmd, f, s) if err != nil { return err @@ -1091,17 +1206,7 @@ func checkShortcutScopes(f *cmdutil.Factory, ctx context.Context, as core.Identi // newRuntimeContext assembles the dependencies and resolved identity for one shortcut execution. func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, config *core.CliConfig, as core.Identity, botOnly bool) (*RuntimeContext, error) { - ctx := cmd.Context() - ctx = cmdutil.ContextWithShortcut(ctx, s.Service+":"+s.Command, uuid.New().String()) - rctx := &RuntimeContext{ - ctx: ctx, - Config: config, - Cmd: cmd, - botOnly: botOnly, - resolvedAs: as, - Factory: f, - } - rctx.declaredScopes = s.DeclaredScopesForIdentity(string(rctx.As())) + rctx := newRuntimeContextBase(cmd, f, s, config, as, botOnly) rctx.apiClientFunc = sync.OnceValues(func() (*client.APIClient, error) { return f.NewAPIClientWithConfig(config) }) @@ -1112,11 +1217,31 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf return nil, err } rctx.larkSDK = sdk + return rctx, nil +} + +func newDryRunRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, config *core.CliConfig, as core.Identity, botOnly bool) *RuntimeContext { + rctx := newRuntimeContextBase(cmd, f, s, config, as, botOnly) + rctx.offline = true + return rctx +} +func newRuntimeContextBase(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, config *core.CliConfig, as core.Identity, botOnly bool) *RuntimeContext { + ctx := cmd.Context() + ctx = cmdutil.ContextWithShortcut(ctx, s.Service+":"+s.Command, uuid.New().String()) + rctx := &RuntimeContext{ + ctx: ctx, + Config: config, + Cmd: cmd, + botOnly: botOnly, + resolvedAs: as, + Factory: f, + } + rctx.declaredScopes = s.DeclaredScopesForIdentity(string(rctx.As())) applyJSONShorthand(cmd, s) rctx.Format = rctx.Str("format") rctx.JqExpr, _ = cmd.Flags().GetString("jq") - return rctx, nil + return rctx } // StripUTF8BOM removes a leading UTF-8 byte-order mark from content read from a @@ -1400,11 +1525,23 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f fmt.Sscanf(fl.Default, "%g", &d) cmd.Flags().Float64(fl.Name, d, desc) case "int_array": - cmd.Flags().IntSlice(fl.Name, nil, desc) + var values []int + if fl.Default != "" { + _ = json.Unmarshal([]byte(fl.Default), &values) + } + cmd.Flags().IntSlice(fl.Name, values, desc) case "string_array": - cmd.Flags().StringArray(fl.Name, nil, desc) + var values []string + if fl.Default != "" { + _ = json.Unmarshal([]byte(fl.Default), &values) + } + cmd.Flags().StringArray(fl.Name, values, desc) case "string_slice": - cmd.Flags().StringSlice(fl.Name, nil, desc) + var values []string + if fl.Default != "" { + _ = json.Unmarshal([]byte(fl.Default), &values) + } + cmd.Flags().StringSlice(fl.Name, values, desc) default: cmd.Flags().String(fl.Name, fl.Default, desc) } diff --git a/shortcuts/common/runner_botinfo_test.go b/shortcuts/common/runner_botinfo_test.go index f8d77f747d..c60aa4b22b 100644 --- a/shortcuts/common/runner_botinfo_test.go +++ b/shortcuts/common/runner_botinfo_test.go @@ -5,11 +5,13 @@ package common import ( "context" + "errors" "strings" "testing" "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/httpmock" @@ -300,3 +302,15 @@ func TestBotInfo_NilFunc(t *testing.T) { t.Errorf("unexpected error: %v", err) } } + +func TestBotInfo_OfflineReturnsTypedFailedPrecondition(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + rctx := TestNewRuntimeContext(cmd, &core.CliConfig{}) + rctx.offline = true + _, err := rctx.BotInfo() + var validation *errs.ValidationError + problem, ok := errs.ProblemOf(err) + if !ok || !errors.As(err, &validation) || problem.Subtype != errs.SubtypeFailedPrecondition { + t.Fatalf("error = %#v, problem = %#v", err, problem) + } +} diff --git a/shortcuts/common/typed_api.go b/shortcuts/common/typed_api.go new file mode 100644 index 0000000000..f304e5fc35 --- /dev/null +++ b/shortcuts/common/typed_api.go @@ -0,0 +1,69 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "context" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/client" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/commandbridge" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/errclass" +) + +// DoHostedAPIJSON executes one request for the internal command host. The +// internal access parameter keeps this bridge out of the authoring surface. +func DoHostedAPIJSON(ctx context.Context, command typedRuntimeContext, method, apiPath string, query larkcore.QueryParams, body any, _ commandbridge.Access) (map[string]any, error) { + return doHostedAPIJSONWithOptions(ctx, command, method, apiPath, query, body) +} + +func doHostedAPIJSONWithOptions(ctx context.Context, command typedRuntimeContext, method, apiPath string, query larkcore.QueryParams, body any, requestOptions ...larkcore.RequestOptionFunc) (map[string]any, error) { + apiClient, err := command.APIClient() + if err != nil { + return nil, typedOrInternal(err) + } + req := &larkcore.ApiReq{HttpMethod: method, ApiPath: apiPath, QueryParams: query} + if body != nil { + req.Body = body + } + opts := append([]larkcore.RequestOptionFunc(nil), requestOptions...) + if option := cmdutil.ShortcutHeaderOpts(ctx); option != nil { + opts = append(opts, option) + } + response, err := apiClient.DoSDKRequest(ctx, req, core.Identity(command.Identity()), opts...) + if err != nil { + return nil, typedOrInternal(err) + } + return ClassifyAPIResponseWith(response, typedClassifyContext(command)) +} + +// CallHostedAPI preserves RuntimeContext.CallAPITyped's raw request semantics +// for the internal pagination adapter. +func CallHostedAPI(ctx context.Context, command typedRuntimeContext, method, apiPath string, params map[string]interface{}, data any, _ commandbridge.Access) (map[string]interface{}, error) { + apiClient, err := command.APIClient() + if err != nil { + return nil, typedOrInternal(err) + } + request := client.RawApiRequest{Method: method, URL: apiPath, Params: params, Data: data, As: core.Identity(command.Identity())} + if option := cmdutil.ShortcutHeaderOpts(ctx); option != nil { + request.ExtraOpts = append(request.ExtraOpts, option) + } + response, err := apiClient.DoAPI(ctx, request) + if err != nil { + return nil, typedOrInternal(err) + } + return ClassifyAPIResponseWith(response, typedClassifyContext(command)) +} + +func typedClassifyContext(command typedRuntimeContext) errclass.ClassifyContext { + config := command.Config() + classify := errclass.ClassifyContext{Brand: string(config.Brand), AppID: config.AppID, Identity: string(command.Identity())} + if provider, ok := command.(interface{ typedCommandPath() string }); ok { + classify.LarkCmd = provider.typedCommandPath() + } + return classify +} diff --git a/shortcuts/common/typed_api_test.go b/shortcuts/common/typed_api_test.go new file mode 100644 index 0000000000..aa40d34fbf --- /dev/null +++ b/shortcuts/common/typed_api_test.go @@ -0,0 +1,57 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "context" + "net/http" + "testing" + + "github.com/larksuite/cli/internal/commandbridge" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/spf13/cobra" +) + +func TestTypedClassifyContextPreservesCommandPath(t *testing.T) { + root := &cobra.Command{Use: "lark"} + service := &cobra.Command{Use: "fixture"} + command := &cobra.Command{Use: "+typed"} + root.AddCommand(service) + service.AddCommand(command) + + ctx := typedCommandContext{runtime: &RuntimeContext{ + Cmd: command, + Config: &core.CliConfig{AppID: "cli_test", Brand: core.BrandFeishu}, + resolvedAs: core.AsBot, + }} + classify := typedClassifyContext(ctx) + if classify.AppID != "cli_test" || classify.Identity != string(core.AsBot) || classify.LarkCmd != "fixture +typed" { + t.Fatalf("classify context = %#v", classify) + } +} + +func TestDoHostedAPIJSONPreservesSuccessData(t *testing.T) { + runtime, registry := newCallAPITypedRuntime(t) + registry.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/x/y", + Headers: http.Header{ + "Content-Type": []string{"application/json"}, + "X-Tt-Logid": []string{"header-log-id"}, + }, + Body: map[string]any{ + "code": float64(0), + "data": map[string]any{"log_id": "business-log-id", "value": "original"}, + }, + }) + + data, err := DoHostedAPIJSON(context.Background(), typedCommandContext{runtime: runtime}, "GET", "/open-apis/x/y", nil, nil, commandbridge.Access{}) + if err != nil { + t.Fatal(err) + } + if data["log_id"] != "business-log-id" || data["value"] != "original" { + t.Fatalf("success data = %#v", data) + } +} diff --git a/shortcuts/common/typed_authorization_test.go b/shortcuts/common/typed_authorization_test.go new file mode 100644 index 0000000000..02f5873378 --- /dev/null +++ b/shortcuts/common/typed_authorization_test.go @@ -0,0 +1,125 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/credential" + "github.com/spf13/cobra" +) + +func typedAuthorizationContext(t *testing.T, scopes string) typedCommandContext { + t.Helper() + return typedAuthorizationContextFor(t, validCompilerDefinition(), core.AsUser, scopes) +} + +func typedAuthorizationContextFor(t *testing.T, definition typedDefinition[compilerArgs, compilerData], identity core.Identity, scopes string) typedCommandContext { + t.Helper() + command := defineTypedShortcut(definition).typed + factory := &cmdutil.Factory{Credential: credential.NewCredentialProvider(nil, nil, &scopeCheckTokenResolver{ + result: &credential.TokenResult{Token: "token", Scopes: scopes}, + }, nil)} + runtime := &RuntimeContext{ + ctx: context.Background(), + Config: &core.CliConfig{AppID: "app-id"}, + Cmd: &cobra.Command{Use: "+compile"}, + Factory: factory, + resolvedAs: identity, + } + return typedCommandContext{runtime: runtime, command: command} +} + +func TestRequireConditionalScopesChecksDeclaredScope(t *testing.T) { + command := typedAuthorizationContext(t, "fixture:write") + err := command.RequireConditionalScopes("fixture:read") + var permission *errs.PermissionError + problem, ok := errs.ProblemOf(err) + if !ok || !errors.As(err, &permission) || problem.Subtype != errs.SubtypeMissingScope { + t.Fatalf("error = %#v, problem = %#v", err, problem) + } + if permission.Identity != "user" || !equalStrings(permission.MissingScopes, []string{"fixture:read"}) { + t.Fatalf("permission error = %#v", permission) + } +} + +func TestRequireConditionalScopesRejectsUndeclaredScope(t *testing.T) { + command := typedAuthorizationContext(t, "fixture:write fixture:admin") + err := command.RequireConditionalScopes("fixture:admin") + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown { + t.Fatalf("error = %#v, problem = %#v", err, problem) + } + for _, want := range []string{"fixture +compile", "fixture:admin", "user", "undeclared conditional scope"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error %q does not contain %q", err, want) + } + } +} + +func TestRequireConditionalScopesDefersWhenTokenMetadataUnavailable(t *testing.T) { + command := typedAuthorizationContext(t, "") + if err := command.RequireConditionalScopes("fixture:read"); err != nil { + t.Fatalf("error = %v, want API fallback when token scope metadata is unavailable", err) + } +} + +func TestRequireConditionalScopesUsesSelectedIdentityContract(t *testing.T) { + definition := validCompilerDefinition() + definition.Metadata.Authorization.Identities[typedIdentityBot] = typedIdentityAuthorization{ + ConditionalScopes: []typedConditionalScope{{Scopes: []string{"fixture:bot-read"}, When: "the bot lookup path runs"}}, + } + command := typedAuthorizationContextFor(t, definition, core.AsBot, "fixture:read") + if err := command.RequireConditionalScopes("fixture:read"); err == nil || !strings.Contains(err.Error(), "undeclared conditional scope") { + t.Fatalf("user scope checked through bot contract: %v", err) + } + err := command.RequireConditionalScopes("fixture:bot-read") + var permission *errs.PermissionError + if !errors.As(err, &permission) || permission.Identity != "bot" || !equalStrings(permission.MissingScopes, []string{"fixture:bot-read"}) { + t.Fatalf("bot permission error = %#v (%v)", permission, err) + } +} + +func TestTypedAuthorizationHelpUsesCompiledDiscoveryFacts(t *testing.T) { + definition := validCompilerDefinition() + authorization := definition.Metadata.Authorization.Identities[typedIdentityUser] + authorization.ConditionalScopes = append(authorization.ConditionalScopes, typedConditionalScope{ + Scopes: []string{"fixture:enrich"}, When: "optional detail enrichment runs", Requirement: typedScopeBestEffort, + }) + definition.Metadata.Authorization.Identities[typedIdentityUser] = authorization + + factory, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{}) + service := &cobra.Command{Use: "fixture"} + defineTypedShortcut(definition).Mount(service, factory) + command, _, err := service.Find([]string{"+compile"}) + if err != nil { + t.Fatal(err) + } + command.InitDefaultHelpFlag() + var output strings.Builder + command.SetOut(&output) + command.SetErr(&output) + if err := command.Help(); err != nil { + t.Fatal(err) + } + got := output.String() + for _, want := range []string{ + "Authorization:\n User:\n Always required:\n fixture:write", + "Conditionally required:\n fixture:read", + "when: --payload selects the read path", + "related parameters: --payload", + "Optional capability:\n fixture:enrich", + "when: optional detail enrichment runs", + } { + if !strings.Contains(got, want) { + t.Fatalf("Help missing %q:\n%s", want, got) + } + } +} diff --git a/shortcuts/common/typed_binder.go b/shortcuts/common/typed_binder.go new file mode 100644 index 0000000000..d86091b17d --- /dev/null +++ b/shortcuts/common/typed_binder.go @@ -0,0 +1,654 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//nolint:forbidigo // Bare errors are private decode/constraint details and are wrapped as typed validation/internal errors at the binder boundary. +package common + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "reflect" + "slices" + "strconv" + "strings" + + "github.com/larksuite/cli/errs" +) + +type boundArgs struct { + value any + provided []bool +} + +// bindTypedArgs uses field/type plans compiled at registration. Runtime +// reflection is limited to indexed assignment into the fresh Args value; tag +// discovery and type/constraint decisions never happen on invocation. +func bindTypedArgs(runtime *RuntimeContext, command *compiledCommand) (*boundArgs, error) { + args := command.hooks.newArgs() + root := reflect.ValueOf(args) + if root.Kind() != reflect.Pointer || root.Elem().Type() != command.argsType { + return nil, errs.NewInternalError(errs.SubtypeUnknown, "typed shortcut binder created invalid Args value") + } + provided := make([]bool, len(command.fields)) + for i, field := range command.fields { + raw, set, err := readCompiledField(runtime, field) + if err != nil { + return nil, err + } + provided[i] = set + if !set && field.defaultValue.Set { + raw = field.defaultValue.Value + } + if !set && !field.defaultValue.Set { + if field.required { + return nil, typedRequiredFieldValidation(field) + } + continue + } + value, err := decodeCompiledValue(raw, field) + if err != nil { + return nil, typedFieldValidation(field, "%v", err).WithCause(err) + } + if err := validateCompiledValue(value, field); err != nil { + return nil, err + } + if err := assignCompiledField(root.Elem(), field, value, set); err != nil { + return nil, errs.NewInternalError(errs.SubtypeUnknown, "failed to bind --%s into Args.%s: %v", field.name, field.goName, err).WithCause(err) + } + } + if err := validateCompiledRelations(command, args, provided, typedStageSourcePreRun); err != nil { + return nil, err + } + return &boundArgs{value: args, provided: provided}, nil +} + +func readCompiledField(runtime *RuntimeContext, field compiledInputField) (any, bool, error) { + flag := runtime.Cmd.Flags().Lookup(field.name) + if flag == nil { + return nil, false, errs.NewInternalError(errs.SubtypeUnknown, "compiled flag --%s is not mounted", field.name) + } + canonicalSet := flag.Changed + canonicalRaw, err := readPFlagValue(runtime, field.name, field) + if err != nil { + return nil, false, err + } + value, set := canonicalRaw, canonicalSet + sourceName := field.name + sourceSet := canonicalSet + for _, alias := range field.cli.Aliases { + if alias.Mode != typedAliasIndependent { + continue + } + aliasFlag := runtime.Cmd.Flags().Lookup(alias.Name) + if aliasFlag == nil { + return nil, false, errs.NewInternalError(errs.SubtypeUnknown, "compiled alias --%s is not mounted", alias.Name) + } + if !aliasFlag.Changed { + continue + } + aliasRaw, err := readPFlagValue(runtime, alias.Name, field) + if err != nil { + return nil, false, err + } + switch alias.Conflict { + case typedAliasCanonicalWins: + if !sourceSet { + value = aliasRaw + set = true + sourceName, sourceSet = alias.Name, true + } + case typedAliasErrorIfBoth: + if sourceSet { + return nil, false, errs.NewValidationError(errs.SubtypeInvalidArgument, + "--%s cannot be used together with --%s", sourceName, alias.Name).WithParam("--" + alias.Name) + } + value, set = aliasRaw, true + sourceName, sourceSet = alias.Name, true + case typedAliasTrimmedEqualOrError: + if sourceSet { + if strings.TrimSpace(fmt.Sprint(value)) != strings.TrimSpace(fmt.Sprint(aliasRaw)) { + if alias.Deprecated { + return nil, false, errs.NewValidationError(errs.SubtypeInvalidArgument, + "--%s and --%s are both set with different values; pass --%s only (--%s is deprecated)", + sourceName, alias.Name, sourceName, alias.Name).WithParam("--" + alias.Name) + } + return nil, false, errs.NewValidationError(errs.SubtypeInvalidArgument, + "--%s and --%s are both set with different values; pass only one or use equal values", + sourceName, alias.Name).WithParam("--" + alias.Name) + } + value = strings.TrimSpace(fmt.Sprint(value)) + } else { + value, set = aliasRaw, true + sourceName, sourceSet = alias.Name, true + } + } + } + return value, set, nil +} + +func readPFlagValue(runtime *RuntimeContext, name string, field compiledInputField) (any, error) { + t := indirectType(field.valueType) + if field.cli.Encoding == typedEncodingJSON { + return runtime.Str(name), nil + } + switch t.Kind() { + case reflect.String: + return runtime.Cmd.Flags().GetString(name) + case reflect.Bool: + return runtime.Cmd.Flags().GetBool(name) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return runtime.Cmd.Flags().GetInt(name) + case reflect.Float32, reflect.Float64: + return runtime.Cmd.Flags().GetFloat64(name) + case reflect.Slice, reflect.Array: + switch field.cli.Encoding { + case typedEncodingRepeated: + return runtime.Cmd.Flags().GetStringArray(name) + case typedEncodingCommaOrRepeated: + if isIntegerKind(t.Elem().Kind()) { + return runtime.Cmd.Flags().GetIntSlice(name) + } + return runtime.Cmd.Flags().GetStringSlice(name) + } + } + return runtime.Cmd.Flags().GetString(name) +} + +func decodeCompiledValue(raw any, field compiledInputField) (any, error) { + if field.cli.Encoding == typedEncodingJSON { + text, ok := raw.(string) + if !ok { + encoded, err := json.Marshal(raw) + if err != nil { + return nil, err + } + text = string(encoded) + } + value := reflect.New(field.valueType) + decoder := json.NewDecoder(strings.NewReader(text)) + if objectShape, ok := shapeAsObject(field.shape); ok && !objectShape.AdditionalProperties { + decoder.DisallowUnknownFields() + } + if err := decoder.Decode(value.Interface()); err != nil { + return nil, fmt.Errorf("invalid JSON: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return nil, fmt.Errorf("invalid JSON: multiple values") + } + return nil, fmt.Errorf("invalid JSON trailing content: %w", err) + } + return value.Elem().Interface(), nil + } + return convertReflectValue(raw, field.valueType) +} + +func convertReflectValue(raw any, target reflect.Type) (any, error) { + if raw == nil { + return nil, nil + } + rawValue := reflect.ValueOf(raw) + if rawValue.Type().AssignableTo(target) { + return raw, nil + } + if target == jsonRawMessageType { + encoded, err := json.Marshal(raw) + if err != nil { + return nil, err + } + return json.RawMessage(encoded), nil + } + if target.Kind() == reflect.Pointer { + value, err := convertReflectValue(raw, target.Elem()) + if err != nil { + return nil, err + } + pointer := reflect.New(target.Elem()) + pointer.Elem().Set(reflect.ValueOf(value)) + return pointer.Interface(), nil + } + if target.Kind() == reflect.Array || target.Kind() == reflect.Slice { + if rawValue.Kind() != reflect.Array && rawValue.Kind() != reflect.Slice { + return nil, fmt.Errorf("expected list, got %T", raw) + } + if target.Kind() == reflect.Array && rawValue.Len() != target.Len() { + return nil, fmt.Errorf("expected exactly %d items for %s, got %d", target.Len(), target, rawValue.Len()) + } + result := reflect.MakeSlice(reflect.SliceOf(target.Elem()), rawValue.Len(), rawValue.Len()) + for i := 0; i < rawValue.Len(); i++ { + value, err := convertReflectValue(rawValue.Index(i).Interface(), target.Elem()) + if err != nil { + return nil, err + } + result.Index(i).Set(reflect.ValueOf(value)) + } + if target.Kind() == reflect.Array { + array := reflect.New(target).Elem() + reflect.Copy(array, result) + return array.Interface(), nil + } + return result.Convert(target).Interface(), nil + } + if rawValue.Type().ConvertibleTo(target) { + converted := reflect.New(target).Elem() + if isSignedIntegerKind(rawValue.Kind()) && isUnsignedIntegerKind(target.Kind()) { + if rawValue.Int() < 0 || converted.OverflowUint(uint64(rawValue.Int())) { + return nil, fmt.Errorf("%v cannot be represented as %s", raw, target) + } + } + if isSignedIntegerKind(rawValue.Kind()) && isSignedIntegerKind(target.Kind()) && converted.OverflowInt(rawValue.Int()) { + return nil, fmt.Errorf("%v overflows %s", raw, target) + } + if isUnsignedIntegerKind(rawValue.Kind()) && isUnsignedIntegerKind(target.Kind()) && converted.OverflowUint(rawValue.Uint()) { + return nil, fmt.Errorf("%v overflows %s", raw, target) + } + return rawValue.Convert(target).Interface(), nil + } + encoded, err := json.Marshal(raw) + if err != nil { + return nil, err + } + value := reflect.New(target) + if err := json.Unmarshal(encoded, value.Interface()); err != nil { + return nil, err + } + return value.Elem().Interface(), nil +} + +func assignCompiledField(root reflect.Value, field compiledInputField, value any, provided bool) error { + target := root.FieldByIndex(field.index) + if field.provided { + valueTarget := target.FieldByIndex(field.valueIndex) + converted, err := reflectValue(value, valueTarget.Type()) + if err != nil { + return err + } + valueTarget.Set(converted) + target.FieldByName("Set").SetBool(provided) + return nil + } + converted, err := reflectValue(value, target.Type()) + if err != nil { + return err + } + target.Set(converted) + return nil +} + +func reflectValue(value any, target reflect.Type) (reflect.Value, error) { + if value == nil { + return reflect.Zero(target), nil + } + v := reflect.ValueOf(value) + if v.Type().AssignableTo(target) { + return v, nil + } + if v.Type().ConvertibleTo(target) { + return v.Convert(target), nil + } + return reflect.Value{}, fmt.Errorf("%T is not assignable to %s", value, target) +} + +func validateCompiledValue(value any, field compiledInputField) error { + if value == nil { + if err := validateJSONValueAgainstShape(nil, field.shape, "value"); err != nil { + return typedFieldValidation(field, "%v", err).WithCause(err) + } + return nil + } + shape := field.shape + if one, ok := shape.(typedOneOfShape); ok { + for _, variant := range one.Variants { + if _, null := variant.(typedNullShape); !null { + shape = variant + break + } + } + } + switch constraint := shape.(type) { + case typedStringShape: + text := reflect.ValueOf(value) + for text.Kind() == reflect.Pointer { + text = text.Elem() + } + if text.Kind() != reflect.String { + break + } + length := len([]rune(text.String())) + if constraint.MinLength != nil && length < *constraint.MinLength { + return typedFieldValidation(field, "must contain at least %d characters", *constraint.MinLength) + } + if constraint.MaxLength != nil && length > *constraint.MaxLength { + return typedFieldValidation(field, "must contain at most %d characters", *constraint.MaxLength) + } + if len(constraint.Enum) > 0 && !slices.Contains(constraint.Enum, text.String()) { + return typedFieldValidation(field, "must be one of: %s", strings.Join(constraint.Enum, ", ")) + } + case typedIntegerShape: + number, err := numericFloat(value) + if err != nil { + break + } + if constraint.Minimum != nil && number < float64(*constraint.Minimum) { + return typedFieldValidation(field, "must be at least %d", *constraint.Minimum) + } + if constraint.Maximum != nil && number > float64(*constraint.Maximum) { + return typedFieldValidation(field, "must be at most %d", *constraint.Maximum) + } + case typedNumberShape: + number, err := numericFloat(value) + if err != nil { + break + } + if constraint.Minimum != nil && number < *constraint.Minimum { + return typedFieldValidation(field, "must be at least %v", *constraint.Minimum) + } + if constraint.Maximum != nil && number > *constraint.Maximum { + return typedFieldValidation(field, "must be at most %v", *constraint.Maximum) + } + case typedArrayShape: + v := reflect.ValueOf(value) + for v.Kind() == reflect.Pointer { + if v.IsNil() { + break + } + v = v.Elem() + } + if v.Kind() != reflect.Array && v.Kind() != reflect.Slice { + break + } + if constraint.MinItems != nil && v.Len() < *constraint.MinItems { + return typedFieldValidation(field, "must contain at least %d items", *constraint.MinItems) + } + if constraint.MaxItems != nil && v.Len() > *constraint.MaxItems { + return typedFieldValidation(field, "must contain at most %d items", *constraint.MaxItems) + } + } + encoded, err := json.Marshal(value) + if err != nil { + return typedFieldValidation(field, "cannot be represented as JSON: %v", err).WithCause(err) + } + jsonValue, err := decodeJSONValidationValue(encoded) + if err != nil { + return typedFieldValidation(field, "cannot be represented as JSON: %v", err).WithCause(err) + } + if err := validateJSONValueAgainstShape(jsonValue, field.shape, "value"); err != nil { + return typedFieldValidation(field, "%v", err).WithCause(err) + } + return nil +} + +func validateJSONValueAgainstShape(value any, shape typedValueShape, path string) error { + switch constraint := shape.(type) { + case anyJSONShape: + return nil + case typedOneOfShape: + for _, variant := range constraint.Variants { + if err := validateJSONValueAgainstShape(value, variant, path); err == nil { + return nil + } + } + return fmt.Errorf("%s does not match any allowed shape", path) + case typedNullShape: + if value != nil { + return fmt.Errorf("%s must be null", path) + } + return nil + case typedConstShape: + expectedJSON, err := json.Marshal(constraint.Value) + if err != nil { + return fmt.Errorf("%s has invalid const: %w", path, err) + } + expected, err := decodeJSONValidationValue(expectedJSON) + if err != nil { + return fmt.Errorf("%s has invalid const: %w", path, err) + } + if !reflect.DeepEqual(value, expected) { + return fmt.Errorf("%s must equal %v", path, constraint.Value) + } + return nil + case typedStringShape: + text, ok := value.(string) + if !ok { + return fmt.Errorf("%s must be a string", path) + } + length := len([]rune(text)) + if constraint.MinLength != nil && length < *constraint.MinLength { + return fmt.Errorf("%s must contain at least %d characters", path, *constraint.MinLength) + } + if constraint.MaxLength != nil && length > *constraint.MaxLength { + return fmt.Errorf("%s must contain at most %d characters", path, *constraint.MaxLength) + } + if len(constraint.Enum) > 0 && !slices.Contains(constraint.Enum, text) { + return fmt.Errorf("%s must be one of: %s", path, strings.Join(constraint.Enum, ", ")) + } + return nil + case typedBooleanShape: + boolean, ok := value.(bool) + if !ok { + return fmt.Errorf("%s must be a boolean", path) + } + if len(constraint.Enum) > 0 && !slices.Contains(constraint.Enum, boolean) { + return fmt.Errorf("%s has an unsupported boolean value", path) + } + return nil + case typedIntegerShape: + number, ok := validationInteger(value) + if !ok { + return fmt.Errorf("%s must be an integer", path) + } + if constraint.Minimum != nil && number < *constraint.Minimum { + return fmt.Errorf("%s must be at least %d", path, *constraint.Minimum) + } + if constraint.Maximum != nil && number > *constraint.Maximum { + return fmt.Errorf("%s must be at most %d", path, *constraint.Maximum) + } + if len(constraint.Enum) > 0 && !slices.Contains(constraint.Enum, number) { + return fmt.Errorf("%s has an unsupported integer value", path) + } + return nil + case typedNumberShape: + number, ok := validationNumber(value) + if !ok { + return fmt.Errorf("%s must be a number", path) + } + if len(constraint.Enum) > 0 && !slices.Contains(constraint.Enum, number) { + return fmt.Errorf("%s has an unsupported number value", path) + } + if constraint.Minimum != nil && number < *constraint.Minimum { + return fmt.Errorf("%s must be at least %v", path, *constraint.Minimum) + } + if constraint.Maximum != nil && number > *constraint.Maximum { + return fmt.Errorf("%s must be at most %v", path, *constraint.Maximum) + } + return nil + case typedArrayShape: + items, ok := value.([]any) + if !ok { + return fmt.Errorf("%s must be an array", path) + } + if constraint.MinItems != nil && len(items) < *constraint.MinItems { + return fmt.Errorf("%s must contain at least %d items", path, *constraint.MinItems) + } + if constraint.MaxItems != nil && len(items) > *constraint.MaxItems { + return fmt.Errorf("%s must contain at most %d items", path, *constraint.MaxItems) + } + for i, item := range items { + if err := validateJSONValueAgainstShape(item, constraint.Items, fmt.Sprintf("%s[%d]", path, i)); err != nil { + return err + } + } + return nil + case typedObjectShape: + object, ok := value.(map[string]any) + if !ok { + return fmt.Errorf("%s must be an object", path) + } + fields := make(map[string]typedValueField, len(constraint.Fields)) + for _, field := range constraint.Fields { + fields[field.Name] = field + if field.Required { + if _, exists := object[field.Name]; !exists { + return fmt.Errorf("%s.%s is required", path, field.Name) + } + } + } + for name, item := range object { + field, exists := fields[name] + if !exists { + if !constraint.AdditionalProperties { + return fmt.Errorf("%s contains unknown field %q", path, name) + } + if constraint.AdditionalPropertiesShape != nil { + if err := validateJSONValueAgainstShape(item, constraint.AdditionalPropertiesShape, path+"."+name); err != nil { + return err + } + } + continue + } + if err := validateJSONValueAgainstShape(item, field.Shape, path+"."+name); err != nil { + return err + } + } + return nil + default: + return fmt.Errorf("%s uses unsupported shape %T", path, shape) + } +} + +func decodeJSONValidationValue(encoded []byte) (any, error) { + decoder := json.NewDecoder(bytes.NewReader(encoded)) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + return nil, err + } + return value, nil +} + +func validationInteger(value any) (int64, bool) { + switch number := value.(type) { + case json.Number: + parsed, err := strconv.ParseInt(number.String(), 10, 64) + return parsed, err == nil + case float64: + if number != float64(int64(number)) { + return 0, false + } + return int64(number), true + default: + return 0, false + } +} + +func validationNumber(value any) (float64, bool) { + switch number := value.(type) { + case json.Number: + parsed, err := number.Float64() + return parsed, err == nil + case float64: + return number, true + default: + return 0, false + } +} + +func validateCompiledRelations(command *compiledCommand, args any, provided []bool, stage typedRelationStage) error { + root := reflect.ValueOf(args).Elem() + for _, relation := range command.relations { + if relation.stage != stage { + continue + } + present := make([]bool, len(relation.fields)) + names := make([]string, len(relation.fields)) + for i, fieldIndex := range relation.fields { + field := command.fields[fieldIndex] + names[i] = "--" + field.name + if relation.presence == typedPresenceExplicit { + present[i] = provided[fieldIndex] + } else { + present[i] = compiledFieldIsNonZero(root, field) + } + } + count := 0 + for _, value := range present { + if value { + count++ + } + } + var invalid bool + switch relation.kind { + case typedRelationExactlyOne: + invalid = count != 1 + case typedRelationAtLeastOne: + invalid = count == 0 + case typedRelationCoOccur: + invalid = count != 0 && count != len(present) + case typedRelationRequires: + invalid = present[0] && !present[1] + case typedRelationConflicts: + invalid = count > 1 + } + if invalid { + param := names[0] + switch relation.kind { + case typedRelationExactlyOne: + return errs.NewValidationError(errs.SubtypeInvalidArgument, "provide exactly one of %s", strings.Join(names, " or ")).WithParam(param) + case typedRelationAtLeastOne: + return errs.NewValidationError(errs.SubtypeInvalidArgument, "provide at least one of %s", strings.Join(names, " or ")).WithParam(param) + case typedRelationCoOccur: + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s must be provided together", strings.Join(names, " and ")).WithParam(param) + case typedRelationRequires: + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s requires %s", names[0], names[1]).WithParam(param) + case typedRelationConflicts: + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s cannot be used together", strings.Join(names, " and ")).WithParam(param) + } + } + } + return nil +} + +func compiledFieldIsNonZero(root reflect.Value, field compiledInputField) bool { + value := root.FieldByIndex(field.index) + if field.provided { + value = value.FieldByIndex(field.valueIndex) + } + for value.Kind() == reflect.Pointer || value.Kind() == reflect.Interface { + if value.IsNil() { + return false + } + value = value.Elem() + } + return !value.IsZero() +} + +func typedRequiredFieldValidation(field compiledInputField) *errs.ValidationError { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s is required", field.name).WithParam("--" + field.name) +} + +func typedFieldValidation(field compiledInputField, format string, args ...any) *errs.ValidationError { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s: %s", field.name, fmt.Sprintf(format, args...)).WithParam("--" + field.name) +} +func isSignedIntegerKind(kind reflect.Kind) bool { return kind >= reflect.Int && kind <= reflect.Int64 } +func isUnsignedIntegerKind(kind reflect.Kind) bool { + return kind >= reflect.Uint && kind <= reflect.Uint64 +} + +func numericFloat(value any) (float64, error) { + v := reflect.ValueOf(value) + for v.Kind() == reflect.Pointer { + v = v.Elem() + } + switch v.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return float64(v.Int()), nil + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return float64(v.Uint()), nil + case reflect.Float32, reflect.Float64: + return v.Float(), nil + } + return 0, fmt.Errorf("not numeric") +} diff --git a/shortcuts/common/typed_binder_benchmark_test.go b/shortcuts/common/typed_binder_benchmark_test.go new file mode 100644 index 0000000000..acf570d6f3 --- /dev/null +++ b/shortcuts/common/typed_binder_benchmark_test.go @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "reflect" + "testing" + + "github.com/larksuite/cli/extension/command" +) + +type binderBenchmarkArgs struct { + Value command.Provided[int] +} + +func BenchmarkTypedBinderIndexedAssignment(b *testing.B) { + field := compiledInputField{index: []int{0}, valueIndex: []int{0}, provided: true, valueType: reflect.TypeFor[int](), goName: "Value", name: "value"} + b.ReportAllocs() + for i := 0; i < b.N; i++ { + args := binderBenchmarkArgs{} + if err := assignCompiledField(reflect.ValueOf(&args).Elem(), field, 42, true); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkTypedBinderDirectAssignment(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + args := binderBenchmarkArgs{} + args.Value = command.Provided[int]{Value: 42, Set: true} + _ = args + } +} diff --git a/shortcuts/common/typed_compile_args.go b/shortcuts/common/typed_compile_args.go new file mode 100644 index 0000000000..8b972f7a1e --- /dev/null +++ b/shortcuts/common/typed_compile_args.go @@ -0,0 +1,644 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//nolint:forbidigo // Compiler diagnostics are build-time declaration errors wrapped by the command-set startup guard. +package common + +import ( + "encoding/json" + "fmt" + "math" + "reflect" + "regexp" + "strconv" + "strings" +) + +var ( + flagNamePattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) + aliasNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) +) + +const extensionCommandPkgPath = "github.com/larksuite/cli/extension/command" + +func compileInput(argsType reflect.Type, definition typedInputDefinition) ([]compiledInputField, map[string]int, error) { + if argsType.Kind() != reflect.Struct { + return nil, nil, fmt.Errorf("Args must be a non-pointer struct, got %s", argsType) + } + supplements := make(map[string]typedInputField, len(definition.Fields)) + for i, supplement := range definition.Fields { + if !flagNamePattern.MatchString(supplement.Name) { + return nil, nil, fmt.Errorf("Input.Fields[%d].Name %q is not a canonical flag name", i, supplement.Name) + } + if _, exists := supplements[supplement.Name]; exists { + return nil, nil, fmt.Errorf("Input.Fields contains duplicate flag %q", supplement.Name) + } + supplements[supplement.Name] = supplement + } + var fields []compiledInputField + seenGo := make(map[string]struct{}) + if err := collectArgFields(argsType, nil, false, &fields, seenGo, supplements); err != nil { + return nil, nil, err + } + fieldByName := make(map[string]int, len(fields)) + allNames := make(map[string]string, len(fields)) + for i := range fields { + field := &fields[i] + if previous, exists := allNames[field.name]; exists { + return nil, nil, fmt.Errorf("Args field %s flag --%s duplicates %s", field.goName, field.name, previous) + } + allNames[field.name] = "--" + field.name + fieldByName[field.name] = i + supplement, hasSupplement := supplements[field.name] + if hasSupplement { + if err := mergeInputSupplement(field, supplement); err != nil { + return nil, nil, fmt.Errorf("Args field %s (--%s): %w", field.goName, field.name, err) + } + delete(supplements, field.name) + } + if field.description == "" { + return nil, nil, fmt.Errorf("Args field %s (--%s): description is required via doc or InputField.Description", field.goName, field.name) + } + if err := validateInputCLI(field); err != nil { + return nil, nil, fmt.Errorf("Args field %s (--%s): %w", field.goName, field.name, err) + } + for _, alias := range field.cli.Aliases { + if previous, exists := allNames[alias.Name]; exists { + return nil, nil, fmt.Errorf("Args field %s (--%s): alias --%s duplicates %s", field.goName, field.name, alias.Name, previous) + } + allNames[alias.Name] = "alias of --" + field.name + } + } + if len(supplements) > 0 { + for name := range supplements { + return nil, nil, fmt.Errorf("Input.Fields references unknown flag --%s", name) + } + } + return fields, fieldByName, nil +} + +func collectArgFields(t reflect.Type, parentIndex []int, insideInline bool, out *[]compiledInputField, seenGo map[string]struct{}, supplements map[string]typedInputField) error { + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + if !field.IsExported() { + if hasAnyTag(field, "flag", "arg", "schema", "cli", "doc", "json") { + return fmt.Errorf("Args field %s is unexported but declares Typed input tags", field.Name) + } + continue + } + flagName, hasFlag := field.Tag.Lookup("flag") + argMode, hasArg := field.Tag.Lookup("arg") + if hasFlag == hasArg { + return fmt.Errorf("Args field %s must declare exactly one of flag or arg", field.Name) + } + if _, duplicate := seenGo[field.Name]; duplicate { + return fmt.Errorf("Args field %s is duplicated through inline expansion", field.Name) + } + seenGo[field.Name] = struct{}{} + index := append(append([]int(nil), parentIndex...), i) + if hasArg { + switch argMode { + case "local": + if insideInline { + return fmt.Errorf("Args field %s: arg:\"local\" is not allowed inside inline", field.Name) + } + if hasAnyTag(field, "flag", "schema", "cli", "doc", "json") { + return fmt.Errorf("Args field %s: arg:\"local\" cannot declare public input tags", field.Name) + } + case "inline": + if insideInline { + return fmt.Errorf("Args field %s: nested arg:\"inline\" is not allowed", field.Name) + } + inlineType := field.Type + if inlineType.Kind() == reflect.Pointer { + return fmt.Errorf("Args field %s: arg:\"inline\" must be a struct value, not pointer", field.Name) + } + if inlineType.Kind() != reflect.Struct { + return fmt.Errorf("Args field %s: arg:\"inline\" must be a struct, got %s", field.Name, field.Type) + } + if hasAnyTag(field, "flag", "schema", "cli", "doc", "json") { + return fmt.Errorf("Args field %s: arg:\"inline\" cannot declare public input tags", field.Name) + } + if err := collectArgFields(inlineType, index, true, out, seenGo, supplements); err != nil { + return err + } + default: + return fmt.Errorf("Args field %s: unknown arg mode %q", field.Name, argMode) + } + continue + } + if !flagNamePattern.MatchString(flagName) { + return fmt.Errorf("Args field %s: flag %q is not a canonical flag name", field.Name, flagName) + } + if hasAnyTag(field, "json") { + return fmt.Errorf("Args field %s (--%s): json tag is not allowed on a CLI field", field.Name, flagName) + } + valueType, valueIndex, isProvided, err := unwrapProvided(field.Type) + if err != nil { + return fmt.Errorf("Args field %s (--%s): %w", field.Name, flagName, err) + } + schema, err := parseSchemaTag(field.Tag.Get("schema"), valueType, true) + if err != nil { + return fmt.Errorf("Args field %s (--%s): %w", field.Name, flagName, err) + } + cli, err := parseCLITag(field.Tag.Get("cli")) + if err != nil { + return fmt.Errorf("Args field %s (--%s): %w", field.Name, flagName, err) + } + var shape typedValueShape + supplement, hasSupplement := supplements[flagName] + if hasSupplement && supplement.Shape != nil { + if schema.nullable != nil || schemaHasShapeConstraints(schema) { + return fmt.Errorf("Args field %s (--%s): InputField.Shape conflicts with schema constraints or nullable declaration", field.Name, flagName) + } + } else { + shape, err = shapeForType(valueType, schema, true, map[reflect.Type]struct{}{}) + if err != nil { + return fmt.Errorf("Args field %s (--%s): %w", field.Name, flagName, err) + } + } + *out = append(*out, compiledInputField{ + name: flagName, + goName: field.Name, + index: index, + valueIndex: valueIndex, + valueType: valueType, + provided: isProvided, + required: schema.required, + nullable: schema.nullable, + description: strings.TrimSpace(field.Tag.Get("doc")), + shape: shape, + defaultValue: schema.defaultValue, + cli: cli, + }) + } + return nil +} + +func hasAnyTag(field reflect.StructField, names ...string) bool { + for _, name := range names { + if _, ok := field.Tag.Lookup(name); ok { + return true + } + } + return false +} + +func unwrapProvided(t reflect.Type) (reflect.Type, []int, bool, error) { + publicProvided := t.PkgPath() == extensionCommandPkgPath && strings.HasPrefix(t.Name(), "Provided[") + if t.Kind() != reflect.Struct || !publicProvided { + return t, nil, false, nil + } + value, ok := t.FieldByName("Value") + if !ok { + return nil, nil, false, fmt.Errorf("Provided type has no Value field") + } + set, ok := t.FieldByName("Set") + if !ok || set.Type.Kind() != reflect.Bool { + return nil, nil, false, fmt.Errorf("Provided type has invalid Set field") + } + return value.Type, value.Index, true, nil +} + +func mergeInputSupplement(field *compiledInputField, supplement typedInputField) error { + if supplement.Description != "" { + if field.description != "" { + return fmt.Errorf("description is declared by both doc and InputField.Description") + } + field.description = strings.TrimSpace(supplement.Description) + } + if supplement.Shape != nil { + if shapeHasConstraints(field.shape) || field.nullable != nil { + return fmt.Errorf("Shape conflicts with schema constraints or nullable declaration") + } + shape, err := lowerAuthoringShape(supplement.Shape) + if err != nil { + return err + } + if err := validateShape(shape, "InputField.Shape"); err != nil { + return err + } + if !shapeCompatibleWithType(shape, field.valueType) { + return fmt.Errorf("InputField.Shape %T is incompatible with Go type %s", supplement.Shape, field.valueType) + } + field.shape = shape + field.shapeExplicit = true + } + if supplement.Default.Set { + if field.defaultValue.Set { + return fmt.Errorf("default is declared by both schema and InputField.Default") + } + if field.required { + return fmt.Errorf("required input cannot declare a default") + } + field.defaultValue = supplement.Default + } + if len(supplement.CLI.Aliases) > 0 { + if len(field.cli.Aliases) > 0 { + return fmt.Errorf("CLI.Aliases is declared by both cli tag and InputField.CLI") + } + field.cli.Aliases = append([]typedFlagAlias(nil), supplement.CLI.Aliases...) + } + if len(supplement.CLI.ValueSources) > 0 { + if len(field.cli.ValueSources) > 0 { + return fmt.Errorf("CLI.ValueSources is declared by both cli tag and InputField.CLI") + } + field.cli.ValueSources = append([]typedValueSource(nil), supplement.CLI.ValueSources...) + } + if supplement.CLI.Encoding != "" { + if field.cli.Encoding != "" { + return fmt.Errorf("CLI.Encoding is declared by both cli tag and InputField.CLI") + } + field.cli.Encoding = supplement.CLI.Encoding + } + if supplement.CLI.Hidden { + if field.cli.Hidden { + return fmt.Errorf("CLI.Hidden is declared twice") + } + field.cli.Hidden = true + } + if supplement.CLI.Deprecated != "" { + if field.cli.Deprecated != "" { + return fmt.Errorf("CLI.Deprecated is declared twice") + } + field.cli.Deprecated = supplement.CLI.Deprecated + } + return nil +} + +func validateInputCLI(field *compiledInputField) error { + if field.cli.Deprecated != "" && !field.cli.Hidden { + return fmt.Errorf("deprecated primary flag must be hidden") + } + if field.required && field.defaultValue.Set { + return fmt.Errorf("required input cannot declare a default") + } + if field.defaultValue.Set { + if err := valueAssignableTo(field.defaultValue.Value, field.valueType); err != nil { + return fmt.Errorf("default: %w", err) + } + } + seenSources := make(map[typedValueSource]struct{}) + for _, source := range field.cli.ValueSources { + if source != typedSourceFlag && source != typedSourceFile && source != typedSourceStdin { + return fmt.Errorf("unknown value source %q", source) + } + if _, duplicate := seenSources[source]; duplicate { + return fmt.Errorf("duplicate value source %q", source) + } + seenSources[source] = struct{}{} + } + if len(field.cli.ValueSources) > 0 { + if _, ok := seenSources[typedSourceFlag]; !ok { + return fmt.Errorf("ValueSources must include flag") + } + if (len(seenSources) > 1) && indirectKind(field.valueType) != reflect.String && field.cli.Encoding != typedEncodingJSON { + return fmt.Errorf("file/stdin sources require string input or encoding=json") + } + } + kind := indirectKind(field.valueType) + if kind == reflect.Slice || kind == reflect.Array || kind == reflect.Struct || kind == reflect.Map || kind == reflect.Interface { + if field.cli.Encoding == "" { + return fmt.Errorf("%s input must explicitly declare CLI encoding", kind) + } + } + switch field.cli.Encoding { + case "": + if kind == reflect.Slice || kind == reflect.Array || kind == reflect.Struct || kind == reflect.Map || kind == reflect.Interface { + return fmt.Errorf("complex input requires encoding") + } + case typedEncodingRepeated: + if kind != reflect.Slice && kind != reflect.Array { + return fmt.Errorf("encoding repeated requires an array or slice") + } + if indirectType(field.valueType).Elem().Kind() != reflect.String { + return fmt.Errorf("encoding repeated only supports string arrays") + } + if field.nullable != nil { + return fmt.Errorf("encoding repeated does not allow nullable/nonnullable") + } + case typedEncodingCommaOrRepeated: + if kind != reflect.Slice && kind != reflect.Array { + return fmt.Errorf("encoding comma_or_repeated requires an array or slice") + } + elementKind := indirectType(field.valueType).Elem().Kind() + if elementKind != reflect.String && !isIntegerKind(elementKind) { + return fmt.Errorf("encoding comma_or_repeated only supports string or integer arrays") + } + if field.nullable != nil { + return fmt.Errorf("encoding comma_or_repeated does not allow nullable/nonnullable") + } + case typedEncodingJSON: + if kind != reflect.Slice && kind != reflect.Array && kind != reflect.Struct && kind != reflect.Map && kind != reflect.Interface { + return fmt.Errorf("encoding json requires array, object, oneOf, or custom JSON input") + } + if isNilCapable(field.valueType) && field.nullable == nil && !field.shapeExplicit && !shapeExplicitlyNullable(field.shape) { + return fmt.Errorf("nil-capable encoding=json input must declare nullable or nonnullable") + } + default: + return fmt.Errorf("unknown CLI encoding %q", field.cli.Encoding) + } + seenAliases := make(map[string]struct{}) + for i, alias := range field.cli.Aliases { + if !aliasNamePattern.MatchString(alias.Name) { + return fmt.Errorf("alias[%d] name %q is invalid", i, alias.Name) + } + if alias.Name == field.name { + return fmt.Errorf("alias[%d] duplicates canonical flag --%s", i, field.name) + } + if _, duplicate := seenAliases[alias.Name]; duplicate { + return fmt.Errorf("duplicate alias --%s", alias.Name) + } + seenAliases[alias.Name] = struct{}{} + switch alias.Mode { + case typedAliasNormalize: + if alias.Conflict != "" { + return fmt.Errorf("normalize alias --%s cannot declare Conflict", alias.Name) + } + if alias.Deprecated { + return fmt.Errorf("deprecated alias --%s must use independent mode so Cobra can emit its warning", alias.Name) + } + case typedAliasIndependent: + switch alias.Conflict { + case typedAliasCanonicalWins, typedAliasErrorIfBoth: + case typedAliasTrimmedEqualOrError: + if indirectKind(field.valueType) != reflect.String { + return fmt.Errorf("trimmed_equal_or_error alias --%s requires string input", alias.Name) + } + default: + return fmt.Errorf("independent alias --%s must declare a supported Conflict", alias.Name) + } + default: + return fmt.Errorf("alias --%s has invalid Mode %q", alias.Name, alias.Mode) + } + } + return nil +} + +type schemaTag struct { + required bool + optional bool + nullable *bool + defaultValue typedInputDefault + enum []string + format string + minLength *int + maxLength *int + minimum *float64 + maximum *float64 + minItems *int + maxItems *int +} + +func schemaHasShapeConstraints(schema schemaTag) bool { + return len(schema.enum) > 0 || schema.format != "" || hasStringConstraints(schema) || hasNumberConstraints(schema) || hasItemConstraints(schema) +} + +func parseSchemaTag(raw string, valueType reflect.Type, input bool) (schemaTag, error) { + var result schemaTag + if raw == "" { + return result, fmt.Errorf("schema tag must declare exactly one of required or optional") + } + seen := make(map[string]struct{}) + for _, token := range strings.Split(raw, ";") { + if token == "" || token != strings.TrimSpace(token) { + return result, fmt.Errorf("schema contains blank or untrimmed token %q", token) + } + key, value, hasValue := strings.Cut(token, "=") + if _, duplicate := seen[key]; duplicate { + return result, fmt.Errorf("schema token %q is duplicated", key) + } + seen[key] = struct{}{} + switch key { + case "required": + if hasValue { + return result, fmt.Errorf("schema token required does not accept a value") + } + result.required = true + case "optional": + if hasValue { + return result, fmt.Errorf("schema token optional does not accept a value") + } + result.optional = true + case "nullable", "nonnullable": + if hasValue { + return result, fmt.Errorf("schema token %s does not accept a value", key) + } + if result.nullable != nil { + return result, fmt.Errorf("schema cannot declare both nullable and nonnullable") + } + v := key == "nullable" + result.nullable = &v + case "default": + if !hasValue || value == "" { + return result, fmt.Errorf("schema default requires a JSON literal") + } + if !input { + return result, fmt.Errorf("Data field cannot declare default") + } + var decoded any + if err := json.Unmarshal([]byte(value), &decoded); err != nil { + return result, fmt.Errorf("schema default is not valid JSON: %w", err) + } + result.defaultValue = typedInputDefault{Set: true, Value: decoded} + case "enum": + if !hasValue || value == "" { + return result, fmt.Errorf("schema enum requires at least one value") + } + result.enum = strings.Split(value, "|") + case "format": + if !hasValue || value == "" { + return result, fmt.Errorf("schema format requires a name") + } + result.format = value + case "minLength": + v, err := parseNonnegativeInt(value) + if err != nil { + return result, fmt.Errorf("schema minLength: %w", err) + } + result.minLength = &v + case "maxLength": + v, err := parseNonnegativeInt(value) + if err != nil { + return result, fmt.Errorf("schema maxLength: %w", err) + } + result.maxLength = &v + case "minimum": + v, err := parseFiniteFloat(value) + if err != nil { + return result, fmt.Errorf("schema minimum: %w", err) + } + result.minimum = &v + case "maximum": + v, err := parseFiniteFloat(value) + if err != nil { + return result, fmt.Errorf("schema maximum: %w", err) + } + result.maximum = &v + case "minItems": + v, err := parseNonnegativeInt(value) + if err != nil { + return result, fmt.Errorf("schema minItems: %w", err) + } + result.minItems = &v + case "maxItems": + v, err := parseNonnegativeInt(value) + if err != nil { + return result, fmt.Errorf("schema maxItems: %w", err) + } + result.maxItems = &v + default: + return result, fmt.Errorf("unknown schema token %q", key) + } + } + if result.required == result.optional { + return result, fmt.Errorf("schema must declare exactly one of required or optional") + } + if result.required && result.defaultValue.Set { + return result, fmt.Errorf("required input cannot declare default") + } + if result.nullable != nil && *result.nullable && !isNilCapable(valueType) { + return result, fmt.Errorf("nullable requires a nil-capable Go type") + } + if result.minLength != nil && result.maxLength != nil && *result.minLength > *result.maxLength { + return result, fmt.Errorf("minLength exceeds maxLength") + } + if result.minimum != nil && result.maximum != nil && *result.minimum > *result.maximum { + return result, fmt.Errorf("minimum exceeds maximum") + } + if result.minItems != nil && result.maxItems != nil && *result.minItems > *result.maxItems { + return result, fmt.Errorf("minItems exceeds maxItems") + } + return result, nil +} + +func parseCLITag(raw string) (typedCLIInput, error) { + var result typedCLIInput + if raw == "" { + return result, nil + } + seen := make(map[string]struct{}) + for _, token := range strings.Split(raw, ";") { + key, value, ok := strings.Cut(token, "=") + if !ok || value == "" || token != strings.TrimSpace(token) { + return result, fmt.Errorf("invalid cli token %q", token) + } + if _, duplicate := seen[key]; duplicate { + return result, fmt.Errorf("cli token %q is duplicated", key) + } + seen[key] = struct{}{} + switch key { + case "sources": + for _, source := range strings.Split(value, "|") { + result.ValueSources = append(result.ValueSources, typedValueSource(source)) + } + case "encoding": + result.Encoding = typedCLIEncoding(value) + default: + return result, fmt.Errorf("unknown cli token %q", key) + } + } + return result, nil +} + +func parseFiniteFloat(value string) (float64, error) { + return parseFiniteFloatBits(value, 64) +} + +func parseFiniteFloatBits(value string, bits int) (float64, error) { + parsed, err := strconv.ParseFloat(value, bits) + if err != nil { + return 0, err + } + if math.IsNaN(parsed) || math.IsInf(parsed, 0) { + return 0, fmt.Errorf("must be finite") + } + return parsed, nil +} + +func parseNonnegativeInt(value string) (int, error) { + parsed, err := strconv.ParseUint(value, 10, 31) + if err != nil { + return 0, fmt.Errorf("must be a nonnegative integer: %w", err) + } + return int(parsed), nil +} + +func indirectType(t reflect.Type) reflect.Type { + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + return t +} +func indirectKind(t reflect.Type) reflect.Kind { return indirectType(t).Kind() } +func isIntegerKind(kind reflect.Kind) bool { + return kind >= reflect.Int && kind <= reflect.Int64 || kind >= reflect.Uint && kind <= reflect.Uint64 +} +func isNilCapable(t reflect.Type) bool { + switch t.Kind() { + case reflect.Pointer, reflect.Slice, reflect.Map, reflect.Interface: + return true + default: + return false + } +} +func shapeCompatibleWithType(shape typedValueShape, target reflect.Type) bool { + base := indirectType(target) + if base == jsonRawMessageType || base.Kind() == reflect.Interface { + return true + } + switch value := shape.(type) { + case anyJSONShape: + return true + case typedOneOfShape: + for _, variant := range value.Variants { + if !shapeCompatibleWithType(variant, target) { + return false + } + } + return true + case typedNullShape: + return isNilCapable(target) + case typedConstShape: + return valueAssignableTo(value.Value, target) == nil + case typedStringShape: + return base.Kind() == reflect.String + case typedBooleanShape: + return base.Kind() == reflect.Bool + case typedIntegerShape: + return isIntegerKind(base.Kind()) + case typedNumberShape: + return base.Kind() == reflect.Float32 || base.Kind() == reflect.Float64 + case typedArrayShape: + return base.Kind() == reflect.Slice || base.Kind() == reflect.Array + case typedObjectShape: + return base.Kind() == reflect.Struct || base.Kind() == reflect.Map || base.Kind() == reflect.Interface + default: + return false + } +} + +func valueAssignableTo(value any, target reflect.Type) error { + base := indirectType(target) + if base.Kind() == reflect.Array && value != nil { + source := reflect.ValueOf(value) + for source.Kind() == reflect.Pointer || source.Kind() == reflect.Interface { + if source.IsNil() { + break + } + source = source.Elem() + } + if (source.Kind() == reflect.Array || source.Kind() == reflect.Slice) && source.Len() != base.Len() { + return fmt.Errorf("array default for %s requires exactly %d items, got %d", target, base.Len(), source.Len()) + } + } + encoded, err := json.Marshal(value) + if err != nil { + return err + } + decoded := reflect.New(target) + if err := json.Unmarshal(encoded, decoded.Interface()); err != nil { + return fmt.Errorf("value is incompatible with %s: %w", target, err) + } + return nil +} diff --git a/shortcuts/common/typed_compile_contract.go b/shortcuts/common/typed_compile_contract.go new file mode 100644 index 0000000000..85a281c5d9 --- /dev/null +++ b/shortcuts/common/typed_compile_contract.go @@ -0,0 +1,216 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//nolint:forbidigo // Compiler diagnostics are build-time declaration errors wrapped by the command-set startup guard. +package common + +import ( + "encoding/json" + "fmt" + "strings" +) + +func compileRelations(definitions []typedRelation, fieldByName map[string]int) ([]compiledRelation, error) { + result := make([]compiledRelation, 0, len(definitions)) + seen := make(map[string]struct{}) + for i, definition := range definitions { + minimum := 2 + exact := 0 + switch definition.Kind { + case typedRelationExactlyOne, typedRelationAtLeastOne, typedRelationCoOccur, typedRelationConflicts: + case typedRelationRequires: + exact = 2 + default: + return nil, fmt.Errorf("Input.Relations[%d].Kind %q is invalid", i, definition.Kind) + } + if exact > 0 && len(definition.Params) != exact || exact == 0 && len(definition.Params) < minimum { + return nil, fmt.Errorf("Input.Relations[%d] kind %s has invalid param count %d", i, definition.Kind, len(definition.Params)) + } + if definition.Presence != typedPresenceExplicit && definition.Presence != typedPresenceNonZero { + return nil, fmt.Errorf("Input.Relations[%d].Presence %q is invalid", i, definition.Presence) + } + if definition.Stage != typedStageSourcePreRun && definition.Stage != typedStageAfterPrepare { + return nil, fmt.Errorf("Input.Relations[%d].Stage %q is invalid", i, definition.Stage) + } + compiled := compiledRelation{kind: definition.Kind, presence: definition.Presence, stage: definition.Stage} + local := make(map[string]struct{}) + for _, name := range definition.Params { + field, ok := fieldByName[name] + if !ok { + return nil, fmt.Errorf("Input.Relations[%d] references unknown param --%s", i, name) + } + if _, duplicate := local[name]; duplicate { + return nil, fmt.Errorf("Input.Relations[%d] repeats param --%s", i, name) + } + local[name] = struct{}{} + compiled.fields = append(compiled.fields, field) + } + keyBytes, _ := json.Marshal(definition) + key := string(keyBytes) + if _, duplicate := seen[key]; duplicate { + return nil, fmt.Errorf("Input.Relations[%d] duplicates an earlier relation", i) + } + seen[key] = struct{}{} + result = append(result, compiled) + } + return result, nil +} + +func compileAuthorization(definition typedAuthorizationDefinition, fields []compiledInputField, fieldByName map[string]int) error { + for identity, authorization := range definition.Identities { + for i, conditional := range authorization.ConditionalScopes { + path := fmt.Sprintf("Authorization.%s.ConditionalScopes[%d]", identity, i) + if len(conditional.Params) > 0 && conditional.When == "" { + return fmt.Errorf("%s.Params requires agent-readable When text", path) + } + seen := make(map[string]struct{}, len(conditional.Params)) + for j, param := range conditional.Params { + if param == "" || param != strings.TrimSpace(param) { + return fmt.Errorf("%s.Params[%d] must be a non-blank trimmed param", path, j) + } + fieldIndex, ok := fieldByName[param] + if !ok { + return fmt.Errorf("%s references unknown param --%s", path, param) + } + if fields[fieldIndex].cli.Hidden { + return fmt.Errorf("%s references hidden param --%s; use a public canonical param", path, param) + } + if _, duplicate := seen[param]; duplicate { + return fmt.Errorf("%s.Params contains duplicate param --%s", path, param) + } + seen[param] = struct{}{} + } + } + } + return nil +} + +func validateOutput(definition typedOutputDefinition, dataShape typedValueShape) error { + switch definition.Mode { + case typedOutputGeneric, typedOutputFixedJSON: + default: + return fmt.Errorf("Output.Mode %q is invalid", definition.Mode) + } + return nil +} + +func decodeJSONPointerSegment(segment string) (string, bool) { + var builder strings.Builder + for index := 0; index < len(segment); index++ { + if segment[index] != '~' { + builder.WriteByte(segment[index]) + continue + } + if index+1 >= len(segment) { + return "", false + } + index++ + switch segment[index] { + case '0': + builder.WriteByte('~') + case '1': + builder.WriteByte('/') + default: + return "", false + } + } + return builder.String(), true +} + +func resolveShapePointer(shape typedValueShape, pointer string) (typedValueShape, error) { + if pointer == "" { + return shape, nil + } + if !strings.HasPrefix(pointer, "/") { + return nil, fmt.Errorf("must be an RFC 6901 JSON Pointer") + } + current := shape + for _, encoded := range strings.Split(strings.TrimPrefix(pointer, "/"), "/") { + name, valid := decodeJSONPointerSegment(encoded) + if !valid { + return nil, fmt.Errorf("segment %q has invalid RFC 6901 escaping", encoded) + } + var err error + current, err = resolveShapeField(current, name) + if err != nil { + return nil, err + } + } + return current, nil +} + +func resolveShapeField(shape typedValueShape, name string) (typedValueShape, error) { + switch value := shape.(type) { + case typedObjectShape: + for _, field := range value.Fields { + if field.Name == name { + return field.Shape, nil + } + } + return nil, fmt.Errorf("field %q does not exist", name) + case typedOneOfShape: + var resolved []typedValueShape + for _, variant := range value.Variants { + if _, null := variant.(typedNullShape); null { + continue + } + field, err := resolveShapeField(variant, name) + if err != nil { + return nil, err + } + resolved = append(resolved, field) + } + return combineResolvedShapes(resolved) + default: + return nil, fmt.Errorf("segment %q traverses non-object shape", name) + } +} + +func combineResolvedShapes(shapes []typedValueShape) (typedValueShape, error) { + switch len(shapes) { + case 0: + return nil, fmt.Errorf("shape has no applicable variant") + case 1: + return shapes[0], nil + default: + return typedOneOfShape{Variants: shapes}, nil + } +} + +func shapeAsObject(shape typedValueShape) (typedObjectShape, bool) { + if object, ok := shape.(typedObjectShape); ok { + return object, true + } + if one, ok := shape.(typedOneOfShape); ok { + var combined typedObjectShape + found := false + for _, variant := range one.Variants { + if _, null := variant.(typedNullShape); null { + continue + } + object, ok := shapeAsObject(variant) + if !ok { + return typedObjectShape{}, false + } + if !found { + combined = object + found = true + } else if object.AdditionalProperties { + combined.AdditionalProperties = true + } + } + return combined, found + } + return typedObjectShape{}, false +} +func valueCompatibleWithShape(value any, shape typedValueShape) error { + encoded, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("value is not JSON-encodable: %w", err) + } + normalized, err := decodeJSONValidationValue(encoded) + if err != nil { + return fmt.Errorf("value is not valid JSON: %w", err) + } + return validateJSONValueAgainstShape(normalized, shape, "value") +} diff --git a/shortcuts/common/typed_compile_data.go b/shortcuts/common/typed_compile_data.go new file mode 100644 index 0000000000..20c1edc49d --- /dev/null +++ b/shortcuts/common/typed_compile_data.go @@ -0,0 +1,460 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//nolint:forbidigo // Compiler diagnostics are build-time declaration errors wrapped by the command-set startup guard. +package common + +import ( + "encoding" + "encoding/json" + "fmt" + "math" + "reflect" + "strconv" + "strings" +) + +var ( + jsonRawMessageType = reflect.TypeFor[json.RawMessage]() + jsonMarshalerType = reflect.TypeFor[json.Marshaler]() + textMarshalerType = reflect.TypeFor[encoding.TextMarshaler]() +) + +func compileData(dataType reflect.Type, definition typedDataDefinition) (typedValueShape, error) { + if definition.Shape != nil && len(definition.Overrides) > 0 { + return nil, fmt.Errorf("Output.Data.Shape and Output.Data.Overrides are mutually exclusive") + } + if definition.Shape != nil { + shape, err := lowerAuthoringShape(definition.Shape) + if err != nil { + return nil, err + } + if err := validateShape(shape, "Output.Data.Shape"); err != nil { + return nil, err + } + return shape, nil + } + if dataType.Kind() == reflect.Interface && dataType.NumMethod() == 0 { + if len(definition.Overrides) > 0 { + return nil, fmt.Errorf("Output.Data.Overrides require struct Data") + } + return anyJSONShape{}, nil + } + if dataType.Kind() != reflect.Struct { + return nil, fmt.Errorf("Data must be a non-pointer struct or any unless Output.Data.Shape is explicit, got %s", dataType) + } + shape, err := compileStructShape(dataType, false, "Data", map[reflect.Type]struct{}{}) + if err != nil { + return nil, err + } + for i, override := range definition.Overrides { + if override.Path == "" || !strings.HasPrefix(override.Path, "/") { + return nil, fmt.Errorf("Output.Data.Overrides[%d].Path %q must be an RFC 6901 JSON Pointer", i, override.Path) + } + if err := applyDataOverride(&shape, override); err != nil { + return nil, fmt.Errorf("Output.Data.Overrides[%d] path %q: %w", i, override.Path, err) + } + } + if err := validateShape(shape, "Output.Data"); err != nil { + return nil, err + } + return shape, nil +} + +// shapeForType derives the ValueShape of one Go type. active carries the struct +// types on the current recursion path so a self-referential type is rejected +// with a compile error; see compileStructShape. +func shapeForType(t reflect.Type, schema schemaTag, input bool, active map[reflect.Type]struct{}) (typedValueShape, error) { + baseType := t + for baseType.Kind() == reflect.Pointer { + baseType = baseType.Elem() + } + var shape typedValueShape + switch baseType.Kind() { + case reflect.String: + stringShape := typedStringShape{Format: schema.format, MinLength: schema.minLength, MaxLength: schema.maxLength} + for _, raw := range schema.enum { + stringShape.Enum = append(stringShape.Enum, raw) + } + shape = stringShape + case reflect.Bool: + booleanShape := typedBooleanShape{} + for _, raw := range schema.enum { + v, err := parseBool(raw) + if err != nil { + return nil, fmt.Errorf("enum value %q is not boolean", raw) + } + booleanShape.Enum = append(booleanShape.Enum, v) + } + if hasStringConstraints(schema) || hasNumberConstraints(schema) || hasItemConstraints(schema) { + return nil, fmt.Errorf("boolean field has incompatible schema constraint") + } + shape = booleanShape + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + if input && baseType.Kind() >= reflect.Uint && baseType.Kind() <= reflect.Uint64 { + return nil, fmt.Errorf("unsigned integer CLI input %s is not supported; use int or an explicit JSON encoding", baseType) + } + integerShape := typedIntegerShape{} + if schema.minimum != nil { + v := int64(*schema.minimum) + if float64(v) != *schema.minimum { + return nil, fmt.Errorf("minimum must be an integer") + } + integerShape.Minimum = &v + } + if schema.maximum != nil { + v := int64(*schema.maximum) + if float64(v) != *schema.maximum { + return nil, fmt.Errorf("maximum must be an integer") + } + integerShape.Maximum = &v + } + for _, raw := range schema.enum { + v, err := strconv.ParseInt(raw, 10, baseType.Bits()) + if err != nil { + return nil, fmt.Errorf("enum value %q is not integer", raw) + } + integerShape.Enum = append(integerShape.Enum, v) + } + if hasStringConstraints(schema) || hasItemConstraints(schema) || schema.format != "" { + return nil, fmt.Errorf("integer field has incompatible schema constraint") + } + shape = integerShape + case reflect.Float32, reflect.Float64: + numberShape := typedNumberShape{Minimum: schema.minimum, Maximum: schema.maximum} + for _, raw := range schema.enum { + v, err := parseFiniteFloatBits(raw, baseType.Bits()) + if err != nil { + return nil, fmt.Errorf("enum value %q is not a finite number", raw) + } + numberShape.Enum = append(numberShape.Enum, v) + } + if hasStringConstraints(schema) || hasItemConstraints(schema) || schema.format != "" { + return nil, fmt.Errorf("number field has incompatible schema constraint") + } + shape = numberShape + case reflect.Slice, reflect.Array: + if baseType == jsonRawMessageType { + return nil, fmt.Errorf("json.RawMessage requires an explicit Shape") + } + if baseType.Elem().Kind() == reflect.Uint8 { + return nil, fmt.Errorf("byte slice or array %s requires an explicit Shape", baseType) + } + if len(schema.enum) > 0 || hasStringConstraints(schema) || hasNumberConstraints(schema) || schema.format != "" { + return nil, fmt.Errorf("array field has incompatible schema constraint") + } + elementSchema := schemaTag{required: true} + elementShape, err := shapeForType(baseType.Elem(), elementSchema, input, active) + if err != nil { + return nil, fmt.Errorf("array item: %w", err) + } + shape = typedArrayShape{Items: elementShape, MinItems: schema.minItems, MaxItems: schema.maxItems} + case reflect.Struct: + if implementsCustomEncoding(baseType) { + return nil, fmt.Errorf("custom JSON type %s requires an explicit Shape", baseType) + } + if len(schema.enum) > 0 || hasStringConstraints(schema) || hasNumberConstraints(schema) || hasItemConstraints(schema) || schema.format != "" { + return nil, fmt.Errorf("object field has incompatible schema constraint") + } + object, err := compileStructShape(baseType, input, baseType.String(), active) + if err != nil { + return nil, err + } + shape = object + case reflect.Map: + return nil, fmt.Errorf("map type %s requires an explicit Shape", baseType) + case reflect.Interface: + return nil, fmt.Errorf("interface type %s requires an explicit Shape", baseType) + default: + return nil, fmt.Errorf("Go type %s cannot be mapped to a ValueShape", t) + } + if schema.nullable != nil && *schema.nullable { + shape = typedOneOfShape{Variants: []typedValueShape{shape, typedNullShape{}}} + } + return shape, nil +} + +// compileStructShape walks one struct into an ObjectShape. active holds the +// struct types already open on the current recursion path, so a type that +// refers back to itself is reported as a compile error. Without the guard the +// walk never terminates and the goroutine stack is exhausted -- that is a +// fatal runtime error, not a panic, so no recover boundary can contain it and +// the whole CLI dies during command registration. +// +// Membership is scoped to the path rather than the whole walk: a type is +// removed once its fields are compiled, so the same type appearing twice as a +// sibling stays legal. +func compileStructShape(t reflect.Type, input bool, path string, active map[reflect.Type]struct{}) (typedObjectShape, error) { + if _, cyclic := active[t]; cyclic { + return typedObjectShape{}, fmt.Errorf("recursive type %s requires an explicit Shape", t) + } + active[t] = struct{}{} + defer delete(active, t) + + shape := typedObjectShape{} + seen := make(map[string]string) + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + if !field.IsExported() { + continue + } + rawJSON, ok := field.Tag.Lookup("json") + if !ok { + return typedObjectShape{}, fmt.Errorf("%s field %s must declare json tag", path, field.Name) + } + parts := strings.Split(rawJSON, ",") + name := parts[0] + if name == "-" { + continue + } + if name == "" { + return typedObjectShape{}, fmt.Errorf("%s field %s json tag must explicitly name the field", path, field.Name) + } + omitempty := false + for _, option := range parts[1:] { + switch option { + case "omitempty": + omitempty = true + case "": + default: + return typedObjectShape{}, fmt.Errorf("%s field %s has unsupported json option %q", path, field.Name, option) + } + } + if previous, exists := seen[name]; exists { + return typedObjectShape{}, fmt.Errorf("%s field %s JSON name %q duplicates field %s", path, field.Name, name, previous) + } + seen[name] = field.Name + schema, err := parseSchemaTag(field.Tag.Get("schema"), field.Type, input) + if err != nil { + return typedObjectShape{}, fmt.Errorf("%s field %s (%s): %w", path, field.Name, name, err) + } + if !input && schema.defaultValue.Set { + return typedObjectShape{}, fmt.Errorf("%s field %s (%s): Data field cannot declare default", path, field.Name, name) + } + if schema.required && omitempty { + return typedObjectShape{}, fmt.Errorf("%s field %s (%s): required Data field cannot use omitempty", path, field.Name, name) + } + if schema.optional && !omitempty { + return typedObjectShape{}, fmt.Errorf("%s field %s (%s): optional Data field must use omitempty", path, field.Name, name) + } + if isNilCapable(field.Type) && schema.nullable == nil { + return typedObjectShape{}, fmt.Errorf("%s field %s (%s): nil-capable field must declare nullable or nonnullable", path, field.Name, name) + } + description := strings.TrimSpace(field.Tag.Get("doc")) + if input && description == "" { + return typedObjectShape{}, fmt.Errorf("%s field %s (%s): description is required via doc", path, field.Name, name) + } + fieldShape, err := shapeForType(field.Type, schema, input, active) + if err != nil { + return typedObjectShape{}, fmt.Errorf("%s field %s (%s): %w", path, field.Name, name, err) + } + shape.Fields = append(shape.Fields, typedValueField{Name: name, Description: description, Required: schema.required, Shape: fieldShape}) + } + return shape, nil +} + +func validateShape(shape typedValueShape, path string) error { + if shape == nil { + return fmt.Errorf("%s is nil", path) + } + switch value := shape.(type) { + case anyJSONShape: + case typedStringShape: + if value.MinLength != nil && *value.MinLength < 0 || value.MaxLength != nil && *value.MaxLength < 0 { + return fmt.Errorf("%s string lengths must be nonnegative", path) + } + if value.MinLength != nil && value.MaxLength != nil && *value.MinLength > *value.MaxLength { + return fmt.Errorf("%s minLength exceeds maxLength", path) + } + case typedBooleanShape: + case typedIntegerShape: + if value.Minimum != nil && value.Maximum != nil && *value.Minimum > *value.Maximum { + return fmt.Errorf("%s minimum exceeds maximum", path) + } + case typedNumberShape: + for _, number := range append(append([]float64{}, value.Enum...), pointerFloats(value.Minimum, value.Maximum)...) { + if math.IsNaN(number) || math.IsInf(number, 0) { + return fmt.Errorf("%s number constraints must be finite", path) + } + } + if value.Minimum != nil && value.Maximum != nil && *value.Minimum > *value.Maximum { + return fmt.Errorf("%s minimum exceeds maximum", path) + } + case typedNullShape: + case typedConstShape: + if _, err := json.Marshal(value.Value); err != nil { + return fmt.Errorf("%s const is not JSON-encodable: %w", path, err) + } + case typedArrayShape: + if value.Items == nil { + return fmt.Errorf("%s.Items is required", path) + } + if value.MinItems != nil && *value.MinItems < 0 || value.MaxItems != nil && *value.MaxItems < 0 { + return fmt.Errorf("%s item lengths must be nonnegative", path) + } + if value.MinItems != nil && value.MaxItems != nil && *value.MinItems > *value.MaxItems { + return fmt.Errorf("%s minItems exceeds maxItems", path) + } + return validateShape(value.Items, path+".Items") + case typedObjectShape: + seen := make(map[string]struct{}) + for i := range value.Fields { + field := &value.Fields[i] + if field.Name == "" { + return fmt.Errorf("%s.Fields[%d].Name is required", path, i) + } + if _, duplicate := seen[field.Name]; duplicate { + return fmt.Errorf("%s contains duplicate field %q", path, field.Name) + } + seen[field.Name] = struct{}{} + if strings.TrimSpace(field.Description) == "" { + return fmt.Errorf("%s field %q Description is required", path, field.Name) + } + if err := validateShape(field.Shape, path+"/"+field.Name); err != nil { + return err + } + } + if value.AdditionalPropertiesShape != nil && !value.AdditionalProperties { + return fmt.Errorf("%s AdditionalPropertiesShape requires AdditionalProperties", path) + } + if value.AdditionalPropertiesShape != nil { + return validateShape(value.AdditionalPropertiesShape, path+".AdditionalPropertiesShape") + } + case typedOneOfShape: + if len(value.Variants) < 2 { + return fmt.Errorf("%s oneOf requires at least two variants", path) + } + for i, variant := range value.Variants { + if err := validateShape(variant, fmt.Sprintf("%s.Variants[%d]", path, i)); err != nil { + return err + } + } + default: + return fmt.Errorf("%s uses unknown ValueShape %T", path, shape) + } + return nil +} + +func applyDataOverride(root *typedObjectShape, override typedDataField) error { + encodedParts := strings.Split(strings.TrimPrefix(override.Path, "/"), "/") + if len(encodedParts) == 0 || encodedParts[0] == "" { + return fmt.Errorf("pointer must identify a field") + } + parts := make([]string, len(encodedParts)) + for i, encoded := range encodedParts { + decoded, ok := decodeJSONPointerSegment(encoded) + if !ok { + return fmt.Errorf("segment %q has invalid RFC 6901 escaping", encoded) + } + parts[i] = decoded + } + return mutateObjectField(root, parts, func(field *typedValueField) error { + if override.Description != "" { + if field.Description != "" { + return fmt.Errorf("description is declared by both doc and DataField.Description") + } + field.Description = strings.TrimSpace(override.Description) + } + if override.Shape != nil { + if shapeHasConstraints(field.Shape) { + return fmt.Errorf("Shape conflicts with schema constraints") + } + shape, err := lowerAuthoringShape(override.Shape) + if err != nil { + return err + } + if err := validateShape(shape, "DataField.Shape"); err != nil { + return err + } + field.Shape = shape + } + return nil + }) +} + +func mutateObjectField(object *typedObjectShape, parts []string, mutate func(*typedValueField) error) error { + name := parts[0] + for i := range object.Fields { + field := &object.Fields[i] + if field.Name != name { + continue + } + if len(parts) == 1 { + return mutate(field) + } + switch nested := field.Shape.(type) { + case typedObjectShape: + err := mutateObjectField(&nested, parts[1:], mutate) + field.Shape = nested + return err + case typedOneOfShape: + for variantIndex, variant := range nested.Variants { + if nestedObject, ok := variant.(typedObjectShape); ok { + err := mutateObjectField(&nestedObject, parts[1:], mutate) + nested.Variants[variantIndex] = nestedObject + field.Shape = nested + return err + } + } + } + return fmt.Errorf("segment %q traverses non-object shape", name) + } + return fmt.Errorf("field %q does not exist", name) +} + +func pointerFloats(values ...*float64) []float64 { + result := make([]float64, 0, len(values)) + for _, value := range values { + if value != nil { + result = append(result, *value) + } + } + return result +} + +func shapeHasConstraints(shape typedValueShape) bool { + switch value := shape.(type) { + case typedStringShape: + return len(value.Enum) > 0 || value.Format != "" || value.MinLength != nil || value.MaxLength != nil + case typedBooleanShape: + return len(value.Enum) > 0 + case typedIntegerShape: + return len(value.Enum) > 0 || value.Minimum != nil || value.Maximum != nil + case typedNumberShape: + return len(value.Enum) > 0 || value.Minimum != nil || value.Maximum != nil + case typedArrayShape: + return value.MinItems != nil || value.MaxItems != nil + case typedOneOfShape: + return true + default: + return false + } +} +func shapeExplicitlyNullable(shape typedValueShape) bool { + one, ok := shape.(typedOneOfShape) + if !ok { + return false + } + for _, variant := range one.Variants { + if _, ok := variant.(typedNullShape); ok { + return true + } + } + return false +} +func hasStringConstraints(s schemaTag) bool { return s.minLength != nil || s.maxLength != nil } +func hasNumberConstraints(s schemaTag) bool { return s.minimum != nil || s.maximum != nil } +func hasItemConstraints(s schemaTag) bool { return s.minItems != nil || s.maxItems != nil } +func implementsCustomEncoding(t reflect.Type) bool { + return t.Implements(jsonMarshalerType) || reflect.PointerTo(t).Implements(jsonMarshalerType) || t.Implements(textMarshalerType) || reflect.PointerTo(t).Implements(textMarshalerType) +} +func parseBool(raw string) (bool, error) { + switch raw { + case "true": + return true, nil + case "false": + return false, nil + default: + return false, fmt.Errorf("invalid boolean") + } +} diff --git a/shortcuts/common/typed_compile_output.go b/shortcuts/common/typed_compile_output.go new file mode 100644 index 0000000000..8dfd3f1a5a --- /dev/null +++ b/shortcuts/common/typed_compile_output.go @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//nolint:forbidigo // Compiler diagnostics are build-time declaration errors wrapped by the command-set startup guard. +package common + +import ( + "fmt" + "sort" +) + +func validateOutputHooks(definition typedOutputDefinition, renderers map[string]rendererMarker) error { + rendererNames := make([]string, 0, len(renderers)) + for name := range renderers { + rendererNames = append(rendererNames, name) + } + sort.Strings(rendererNames) + for _, name := range rendererNames { + renderer := renderers[name] + if renderer.isNil { + return fmt.Errorf("Hooks.Renderers[%q] is nil", name) + } + if name != "pretty" { + return fmt.Errorf("Hooks.Renderers[%q] is invalid: custom renderers are only supported for pretty; table, csv, and ndjson use framework formatters", name) + } + if definition.Mode == typedOutputFixedJSON { + return fmt.Errorf("Hooks.Renderers[%q] conflicts with Output.Mode %q: fixed JSON output does not execute custom renderers", name, definition.Mode) + } + } + return nil +} + +// rendererMarker lets the bridge compiler inspect nil renderer values without +// exposing the private compiled hook type. +type rendererMarker struct{ isNil bool } diff --git a/shortcuts/common/typed_compile_recursion_test.go b/shortcuts/common/typed_compile_recursion_test.go new file mode 100644 index 0000000000..b43499b469 --- /dev/null +++ b/shortcuts/common/typed_compile_recursion_test.go @@ -0,0 +1,133 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "context" + "reflect" + "strings" + "testing" + + "github.com/larksuite/cli/internal/commandbridge" +) + +// recursiveSlice refers back to itself through a slice field. +type recursiveSlice struct { + Label string `json:"label" schema:"required" doc:"node label"` + Children []recursiveSlice `json:"children" schema:"required;nonnullable" doc:"child nodes"` +} + +// recursivePointer refers back to itself through a pointer field. +type recursivePointer struct { + Next *recursivePointer `json:"next" schema:"required;nullable" doc:"next link"` +} + +// recursiveLeft and recursiveRight close the cycle through each other rather +// than directly, so a guard that only compares against the immediate parent +// would miss them. +type recursiveLeft struct { + Right *recursiveRight `json:"right" schema:"required;nullable" doc:"right side"` +} + +type recursiveRight struct { + Left *recursiveLeft `json:"left" schema:"required;nullable" doc:"left side"` +} + +// sharedLeaf is reused at several positions without ever forming a cycle. +type sharedLeaf struct { + Value string `json:"value" schema:"required" doc:"leaf value"` +} + +type siblingLeaves struct { + First sharedLeaf `json:"first" schema:"required" doc:"first leaf"` + Second sharedLeaf `json:"second" schema:"required" doc:"second leaf"` +} + +type nestedLeaves struct { + Child siblingLeaves `json:"child" schema:"required" doc:"nested pair"` + Leaf sharedLeaf `json:"leaf" schema:"required" doc:"direct leaf"` +} + +func TestCompileDataRejectsRecursiveTypes(t *testing.T) { + tests := []struct { + name string + typ reflect.Type + want string + }{ + {"self through slice", reflect.TypeFor[recursiveSlice](), "recursive type common.recursiveSlice"}, + {"self through pointer", reflect.TypeFor[recursivePointer](), "recursive type common.recursivePointer"}, + {"mutual through pointers", reflect.TypeFor[recursiveLeft](), "recursive type common.recursiveLeft"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := compileData(tt.typ, typedDataDefinition{}) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error = %v, want containing %q", err, tt.want) + } + if !strings.Contains(err.Error(), "explicit Shape") { + t.Errorf("error %q does not point at the explicit-Shape escape hatch", err) + } + }) + } +} + +func TestCompileDataAllowsRepeatedNonRecursiveTypes(t *testing.T) { + tests := []struct { + name string + typ reflect.Type + }{ + {"same type as siblings", reflect.TypeFor[siblingLeaves]()}, + {"same type at two depths", reflect.TypeFor[nestedLeaves]()}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := compileData(tt.typ, typedDataDefinition{}); err != nil { + t.Fatalf("compileData() error = %v, want nil", err) + } + }) + } +} + +func TestCompileInputRejectsRecursiveJSONTypes(t *testing.T) { + args := reflect.TypeFor[struct { + Tree recursiveSlice `flag:"tree" schema:"required" cli:"encoding=json" doc:"tree payload"` + }]() + _, _, err := compileInput(args, typedInputDefinition{}) + if err == nil || !strings.Contains(err.Error(), "recursive type common.recursiveSlice") { + t.Fatalf("error = %v, want containing recursive type diagnostic", err) + } +} + +// TestCompileCommandDefinitionRejectsRecursiveDataWithoutCrashing pins the +// public contract: a recursive type is a returned error, not a stack overflow +// that takes the whole process down during command registration. +func TestCompileCommandDefinitionRejectsRecursiveDataWithoutCrashing(t *testing.T) { + type recursionArgs struct { + Name string `flag:"name" schema:"optional" doc:"a name"` + } + _, err := CompileCommandDefinition(commandbridge.Definition{ + Metadata: typedCommandMetadata{ + Service: "probe", + Command: "+tree", + Description: "probe", + Risk: typedRiskRead, + Authorization: typedAuthorizationDefinition{ + Identities: map[typedIdentity]typedIdentityAuthorization{ + typedIdentityUser: {RequiredScopes: []string{"probe:read"}}, + }, + }, + }, + ArgsType: reflect.TypeFor[recursionArgs](), + DataType: reflect.TypeFor[recursiveSlice](), + Hooks: commandbridge.Hooks{ + NewArgs: func() any { return &recursionArgs{} }, + Execute: func(context.Context, typedRuntimeContext, any) (commandbridge.Result, error) { + return commandbridge.Result{Data: recursiveSlice{}, Outcome: string(typedOutcomeSuccess)}, nil + }, + }, + }, commandbridge.Access{}) + if err == nil || !strings.Contains(err.Error(), "recursive type common.recursiveSlice") { + t.Fatalf("CompileCommandDefinition() error = %v, want containing recursive type diagnostic", err) + } +} diff --git a/shortcuts/common/typed_compiler.go b/shortcuts/common/typed_compiler.go new file mode 100644 index 0000000000..ebaf3aa9a3 --- /dev/null +++ b/shortcuts/common/typed_compiler.go @@ -0,0 +1,332 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//nolint:forbidigo // Compiler diagnostics are build-time declaration errors wrapped by the command-set startup guard. +package common + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" +) + +func compileDefinitionParts( + metadata typedCommandMetadata, + input typedInputDefinition, + output typedOutputDefinition, + argsType reflect.Type, + dataType reflect.Type, + hooks compiledHooks, + renderers map[string]rendererMarker, + pageOutput bool, +) (*compiledCommand, error) { + metadata = normalizeCommandMetadata(metadata) + if err := validateCommandMetadata(metadata); err != nil { + return nil, err + } + fields, fieldByName, err := compileInput(argsType, input) + if err != nil { + return nil, err + } + relations, err := compileRelations(input.Relations, fieldByName) + if err != nil { + return nil, err + } + if err := compileAuthorization(metadata.Authorization, fields, fieldByName); err != nil { + return nil, err + } + dataShape, err := compileData(dataType, output.Data) + if err != nil { + return nil, err + } + if hooks.execute == nil { + return nil, fmt.Errorf("Hooks.Execute is required") + } + if err := validateOutput(output, dataShape); err != nil { + return nil, err + } + if err := validateOutputHooks(output, renderers); err != nil { + return nil, err + } + command := &compiledCommand{ + metadata: metadata, + argsType: argsType, + dataType: dataType, + fields: fields, + fieldByName: fieldByName, + relations: relations, + dataShape: dataShape, + output: output, + hooks: hooks, + pageOutput: pageOutput, + } + command.contract = buildTypedSchemaContract(command) + return command, nil +} + +func normalizeCommandMetadata(metadata typedCommandMetadata) typedCommandMetadata { + identities := make(map[typedIdentity]typedIdentityAuthorization, len(metadata.Authorization.Identities)) + for identity, authorization := range metadata.Authorization.Identities { + authorization.RequiredScopes = append([]string(nil), authorization.RequiredScopes...) + authorization.ConditionalScopes = append([]typedConditionalScope(nil), authorization.ConditionalScopes...) + for i := range authorization.ConditionalScopes { + conditional := &authorization.ConditionalScopes[i] + conditional.Scopes = append([]string(nil), conditional.Scopes...) + conditional.Params = append([]string(nil), conditional.Params...) + if conditional.Requirement == "" { + conditional.Requirement = typedScopeRequired + } + } + identities[identity] = authorization + } + metadata.Authorization.Identities = identities + metadata.Authorization.IdentityOrder = append([]typedIdentity(nil), metadata.Authorization.IdentityOrder...) + return metadata +} + +func validateCommandMetadata(metadata typedCommandMetadata) error { + service := strings.TrimSpace(string(metadata.Service)) + if service == "" { + return fmt.Errorf("Metadata.Service is required") + } + if service != string(metadata.Service) || strings.ContainsAny(service, " \t\r\n/") { + return fmt.Errorf("Metadata.Service %q must be one trimmed command segment", metadata.Service) + } + command := strings.TrimSpace(metadata.Command) + if command == "" { + return fmt.Errorf("Metadata.Command is required") + } + if command != metadata.Command || strings.ContainsAny(command, " \t\r\n/") { + return fmt.Errorf("Metadata.Command %q must be one trimmed command segment", metadata.Command) + } + if !strings.HasPrefix(command, "+") { + return fmt.Errorf("Metadata.Command %q must start with '+'", metadata.Command) + } + if command == "+" { + return fmt.Errorf("Metadata.Command must contain a name after '+'") + } + if strings.TrimSpace(metadata.Description) == "" { + return fmt.Errorf("Metadata.Description is required") + } + switch metadata.Risk { + case typedRiskRead, typedRiskWrite, typedRiskHighRiskWrite: + default: + return fmt.Errorf("Metadata.Risk %q is invalid", metadata.Risk) + } + if len(metadata.Authorization.Identities) == 0 { + return fmt.Errorf("Metadata.Authorization.Identities must declare at least one identity") + } + for identity, auth := range metadata.Authorization.Identities { + if identity != typedIdentityUser && identity != typedIdentityBot { + return fmt.Errorf("Metadata.Authorization identity %q is invalid", identity) + } + if err := validateScopeList(auth.RequiredScopes, fmt.Sprintf("Authorization.%s.RequiredScopes", identity)); err != nil { + return err + } + requiredScopes := make(map[string]struct{}, len(auth.RequiredScopes)) + for _, scope := range auth.RequiredScopes { + requiredScopes[scope] = struct{}{} + } + for i, conditional := range auth.ConditionalScopes { + path := fmt.Sprintf("Authorization.%s.ConditionalScopes[%d]", identity, i) + if err := validateScopeList(conditional.Scopes, path); err != nil { + return err + } + if len(conditional.Scopes) == 0 { + return fmt.Errorf("%s.Scopes is empty", path) + } + for _, scope := range conditional.Scopes { + if _, alwaysRequired := requiredScopes[scope]; alwaysRequired { + return fmt.Errorf("%s scope %q is already always required for identity %q", path, scope, identity) + } + } + if conditional.When != strings.TrimSpace(conditional.When) { + return fmt.Errorf("%s.When must be trimmed", path) + } + switch conditional.Requirement { + case typedScopeRequired, typedScopeBestEffort: + default: + return fmt.Errorf("%s.Requirement %q is invalid", path, conditional.Requirement) + } + } + } + if len(metadata.Authorization.IdentityOrder) > 0 { + if len(metadata.Authorization.IdentityOrder) != len(metadata.Authorization.Identities) { + return fmt.Errorf("Metadata.Authorization.IdentityOrder must contain each declared identity exactly once") + } + seen := make(map[typedIdentity]struct{}, len(metadata.Authorization.IdentityOrder)) + for _, identity := range metadata.Authorization.IdentityOrder { + if _, ok := metadata.Authorization.Identities[identity]; !ok { + return fmt.Errorf("Metadata.Authorization.IdentityOrder contains undeclared identity %q", identity) + } + if _, duplicate := seen[identity]; duplicate { + return fmt.Errorf("Metadata.Authorization.IdentityOrder contains duplicate identity %q", identity) + } + seen[identity] = struct{}{} + } + } + return nil +} + +func validateScopeList(scopes []string, path string) error { + seen := make(map[string]struct{}, len(scopes)) + for i, scope := range scopes { + if strings.TrimSpace(scope) == "" || scope != strings.TrimSpace(scope) { + return fmt.Errorf("%s[%d] must be a non-blank trimmed scope", path, i) + } + if _, ok := seen[scope]; ok { + return fmt.Errorf("%s contains duplicate scope %q", path, scope) + } + seen[scope] = struct{}{} + } + return nil +} + +func shortcutFromCompiled(compiled *compiledCommand) Shortcut { + metadata := compiled.metadata + shortcut := Shortcut{ + Service: string(metadata.Service), + Command: metadata.Command, + Description: metadata.Description, + Risk: string(metadata.Risk), + Hidden: metadata.Hidden, + typed: compiled, + } + identities := make([]string, 0, len(metadata.Authorization.Identities)) + identityOrder := metadata.Authorization.IdentityOrder + if len(identityOrder) == 0 { + identityOrder = []typedIdentity{typedIdentityUser, typedIdentityBot} + } + for _, identity := range identityOrder { + if auth, ok := metadata.Authorization.Identities[identity]; ok { + identities = append(identities, string(identity)) + scopes := append([]string(nil), auth.RequiredScopes...) + conditional := flattenConditionalScopes(auth.ConditionalScopes) + switch identity { + case typedIdentityUser: + shortcut.UserScopes = scopes + shortcut.ConditionalUserScopes = conditional + case typedIdentityBot: + shortcut.BotScopes = scopes + shortcut.ConditionalBotScopes = conditional + } + } + } + shortcut.AuthTypes = identities + shortcut.Flags = legacyFlagsFromCompiled(compiled.fields) + shortcut.PrintFlagSchema = typedFlagSchemaPrinter(compiled) + return shortcut +} + +func flattenConditionalScopes(definitions []typedConditionalScope) []string { + seen := make(map[string]struct{}) + var result []string + for _, definition := range definitions { + for _, scope := range definition.Scopes { + if _, ok := seen[scope]; ok { + continue + } + seen[scope] = struct{}{} + result = append(result, scope) + } + } + return result +} + +func legacyFlagsFromCompiled(fields []compiledInputField) []Flag { + flags := make([]Flag, 0, len(fields)) + for _, field := range fields { + flag := Flag{ + Name: field.name, + Type: legacyFlagType(field), + Desc: field.description, + Required: field.required, + Hidden: field.cli.Hidden, + Input: legacyInputSources(field.cli.ValueSources), + } + if field.defaultValue.Set { + if encoded, err := json.Marshal(field.defaultValue.Value); err == nil && (field.valueType.Kind() == reflect.Slice || field.valueType.Kind() == reflect.Array) { + flag.Default = string(encoded) + } else { + flag.Default = fmt.Sprint(field.defaultValue.Value) + } + } + for _, alias := range field.cli.Aliases { + if alias.Mode == typedAliasNormalize { + flag.Aliases = append(flag.Aliases, alias.Name) + } + } + if stringShape, ok := field.shape.(typedStringShape); ok { + flag.Enum = append([]string(nil), stringShape.Enum...) + } + if hasIndependentAlias(field.cli.Aliases) { + flag.Required = false + } + flags = append(flags, flag) + for _, alias := range field.cli.Aliases { + if alias.Mode != typedAliasIndependent { + continue + } + aliasFlag := flag + aliasFlag.Name = alias.Name + aliasFlag.Aliases = nil + aliasFlag.Default = "" + aliasFlag.Required = false + aliasFlag.Hidden = alias.Hidden + aliasFlag.Desc = fmt.Sprintf("Compatibility alias for --%s", field.name) + flags = append(flags, aliasFlag) + } + } + return flags +} + +func hasIndependentAlias(aliases []typedFlagAlias) bool { + for _, alias := range aliases { + if alias.Mode == typedAliasIndependent { + return true + } + } + return false +} + +func legacyFlagType(field compiledInputField) string { + t := field.valueType + if t.Kind() == reflect.Pointer { + t = t.Elem() + } + switch t.Kind() { + case reflect.Bool: + return "bool" + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return "int" + case reflect.Float32, reflect.Float64: + return "float64" + case reflect.Slice, reflect.Array: + switch field.cli.Encoding { + case typedEncodingRepeated: + if t.Elem().Kind() == reflect.String { + return "string_array" + } + case typedEncodingCommaOrRepeated: + if isIntegerKind(t.Elem().Kind()) { + return "int_array" + } + return "string_slice" + } + } + return "string" +} + +func legacyInputSources(sources []typedValueSource) []string { + var result []string + for _, source := range sources { + switch source { + case typedSourceFile: + result = append(result, File) + case typedSourceStdin: + result = append(result, Stdin) + } + } + return result +} diff --git a/shortcuts/common/typed_compiler_invalid_test.go b/shortcuts/common/typed_compiler_invalid_test.go new file mode 100644 index 0000000000..d20aa1ea22 --- /dev/null +++ b/shortcuts/common/typed_compiler_invalid_test.go @@ -0,0 +1,234 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "context" + "math" + "reflect" + "strings" + "testing" + + "github.com/larksuite/cli/extension/command" + "github.com/larksuite/cli/internal/commandbridge" +) + +func TestCompileCommandDefinitionConvertsNewArgsPanicToError(t *testing.T) { + _, err := CompileCommandDefinition(commandbridge.Definition{ + ArgsType: reflect.TypeFor[compilerArgs](), + DataType: reflect.TypeFor[compilerData](), + Hooks: commandbridge.Hooks{NewArgs: func() any { + panic("constructor failure") + }, Execute: func(context.Context, typedRuntimeContext, any) (commandbridge.Result, error) { + return commandbridge.Result{}, nil + }}, + }, commandbridge.Access{}) + if err == nil || !strings.Contains(err.Error(), "Hooks.NewArgs panicked: constructor failure") { + t.Fatalf("CompileCommandDefinition() error = %v", err) + } +} + +func TestParseSchemaTagRejectsInvalidGrammar(t *testing.T) { + tests := []struct { + name, tag, want string + typ reflect.Type + input bool + }{ + {"missing cardinality", "minLength=1", "exactly one", reflect.TypeFor[string](), true}, + {"both cardinalities", "required;optional", "exactly one", reflect.TypeFor[string](), true}, + {"duplicate token", "optional;format=uri;format=date-time", "duplicated", reflect.TypeFor[string](), true}, + {"both nullability", "optional;nullable;nonnullable", "both nullable", reflect.TypeFor[*string](), true}, + {"scalar nullable", "optional;nullable", "nil-capable", reflect.TypeFor[string](), true}, + {"required default", "required;default=false", "required input", reflect.TypeFor[bool](), true}, + {"data default", "optional;default=0", "Data field", reflect.TypeFor[int](), false}, + {"unknown", "optional;pattern=x", "unknown schema token", reflect.TypeFor[string](), true}, + {"bad range", "optional;minimum=3;maximum=2", "exceeds", reflect.TypeFor[int](), true}, + {"non-finite minimum", "optional;minimum=NaN", "finite", reflect.TypeFor[float64](), true}, + {"bad length", "optional;minLength=-1", "nonnegative", reflect.TypeFor[string](), true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := parseSchemaTag(tt.tag, tt.typ, tt.input) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error = %v, want %q", err, tt.want) + } + }) + } +} + +func TestCompileDefinitionRejectsInvalidCommandSegments(t *testing.T) { + tests := []struct { + name string + service string + command string + }{ + {name: "service whitespace", service: "fixture service", command: "+compile"}, + {name: "service separator", service: "fixture/service", command: "+compile"}, + {name: "command whitespace", service: "fixture", command: "+compile other"}, + {name: "command separator", service: "fixture", command: "+compile/other"}, + {name: "empty command name", service: "fixture", command: "+"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + definition := validCompilerDefinition() + definition.Metadata.Service = command.DomainName(test.service) + definition.Metadata.Command = test.command + if _, err := compileDefinition(definition); err == nil { + t.Fatalf("Metadata.Service=%q Metadata.Command=%q was accepted", test.service, test.command) + } + }) + } +} + +func TestParseSchemaTagPreservesExplicitZeroFalseAndEmptyDefaults(t *testing.T) { + for _, tt := range []struct { + tag string + typ reflect.Type + want any + }{ + {"optional;default=0", reflect.TypeFor[int](), float64(0)}, + {"optional;default=false", reflect.TypeFor[bool](), false}, + {"optional;default=\"\"", reflect.TypeFor[string](), ""}, + } { + got, err := parseSchemaTag(tt.tag, tt.typ, true) + if err != nil { + t.Fatal(err) + } + if !got.defaultValue.Set || !reflect.DeepEqual(got.defaultValue.Value, tt.want) { + t.Fatalf("%s default = %#v", tt.tag, got.defaultValue) + } + } +} + +func TestParseCLITagRejectsInvalidGrammar(t *testing.T) { + for _, tt := range []struct{ tag, want string }{ + {"encoding=yaml", "unknown CLI encoding"}, + {"encoding=json;encoding=repeated", "duplicated"}, + {"sources=flag;unknown=x", "unknown cli token"}, + {"sources=", "invalid cli token"}, + } { + cli, err := parseCLITag(tt.tag) + if err == nil { + field := compiledInputField{name: "x", goName: "X", valueType: reflect.TypeFor[string](), cli: cli} + err = validateInputCLI(&field) + } + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("parse %q error = %v, want %q", tt.tag, err, tt.want) + } + } +} + +func TestCompileInputRejectsInvalidFieldContracts(t *testing.T) { + tests := []struct { + name string + typ reflect.Type + input typedInputDefinition + want string + }{ + {"not struct", reflect.TypeFor[string](), typedInputDefinition{}, "Args must"}, + {"missing marker", reflect.TypeFor[struct{ Value string }](), typedInputDefinition{}, "exactly one"}, + {"tagged unexported field", reflect.TypeFor[struct { + value string `flag:"value" schema:"optional" doc:"value"` + }](), typedInputDefinition{}, "unexported"}, + {"both markers", reflect.TypeFor[struct { + Value string `flag:"value" arg:"local"` + }](), typedInputDefinition{}, "exactly one"}, + {"unknown arg", reflect.TypeFor[struct { + Value string `arg:"derived"` + }](), typedInputDefinition{}, "unknown arg mode"}, + {"complex missing encoding", reflect.TypeFor[struct { + Values []string `flag:"values" schema:"optional" doc:"values"` + }](), typedInputDefinition{}, "explicitly declare CLI encoding"}, + {"fixed array default length", reflect.TypeFor[struct { + Values [2]string `flag:"values" schema:"optional;default=[\"one\",\"two\",\"three\"]" cli:"encoding=repeated" doc:"values"` + }](), typedInputDefinition{}, "requires exactly 2 items"}, + {"json nil unspecified", reflect.TypeFor[struct { + Values []string `flag:"values" schema:"optional" cli:"encoding=json" doc:"values"` + }](), typedInputDefinition{}, "must declare nullable"}, + {"file on int", reflect.TypeFor[struct { + Value int `flag:"value" schema:"optional" cli:"sources=flag|file" doc:"value"` + }](), typedInputDefinition{}, "file/stdin"}, + {"unknown supplement", reflect.TypeFor[struct { + Value string `flag:"value" schema:"optional" doc:"value"` + }](), typedInputDefinition{Fields: []typedInputField{{Name: "other"}}}, "unknown flag"}, + {"description conflict", reflect.TypeFor[struct { + Value string `flag:"value" schema:"optional" doc:"value"` + }](), typedInputDefinition{Fields: []typedInputField{{Name: "value", Description: "again"}}}, "both doc"}, + {"oneOf includes unrepresentable variant", reflect.TypeFor[struct { + Value string `flag:"value" schema:"optional" doc:"value"` + }](), typedInputDefinition{Fields: []typedInputField{{Name: "value", Shape: command.OneOfShape{Variants: []command.ValueShape{command.StringShape{}, command.IntegerShape{}}}}}}, "incompatible with Go type"}, + {"explicit shape with schema constraints", reflect.TypeFor[struct { + Value string `flag:"value" schema:"optional;minLength=1" doc:"value"` + }](), typedInputDefinition{Fields: []typedInputField{{Name: "value", Shape: command.StringShape{}}}}, "conflicts with schema constraints"}, + {"repeated non-string elements", reflect.TypeFor[struct { + Values []int `flag:"values" schema:"optional" cli:"encoding=repeated" doc:"values"` + }](), typedInputDefinition{}, "only supports string arrays"}, + {"byte slice inference", reflect.TypeFor[struct { + Value []byte `flag:"value" schema:"optional;nonnullable" cli:"encoding=json" doc:"value"` + }](), typedInputDefinition{}, "requires an explicit Shape"}, + {"alias missing conflict", reflect.TypeFor[struct { + Value string `flag:"value" schema:"optional" doc:"value"` + }](), typedInputDefinition{Fields: []typedInputField{{Name: "value", CLI: typedCLIInput{Aliases: []typedFlagAlias{{Name: "old", Mode: typedAliasIndependent}}}}}}, "must declare"}, + {"deprecated normalize alias", reflect.TypeFor[struct { + Value string `flag:"value" schema:"optional" doc:"value"` + }](), typedInputDefinition{Fields: []typedInputField{{Name: "value", CLI: typedCLIInput{Aliases: []typedFlagAlias{{Name: "old", Mode: typedAliasNormalize, Deprecated: true}}}}}}, "must use independent mode"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, _, err := compileInput(tt.typ, tt.input) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error = %v, want containing %q", err, tt.want) + } + }) + } +} + +func TestCompileDataRejectsJSONContractDrift(t *testing.T) { + tests := []struct { + name string + typ reflect.Type + want string + }{ + {"required omitempty", reflect.TypeFor[struct { + Value string `json:"value,omitempty" schema:"required" doc:"value"` + }](), "required Data field"}, + {"optional no omitempty", reflect.TypeFor[struct { + Value string `json:"value" schema:"optional" doc:"value"` + }](), "optional Data field"}, + {"nil unspecified", reflect.TypeFor[struct { + Value []string `json:"value" schema:"required" doc:"value"` + }](), "must declare nullable"}, + {"map inferred", reflect.TypeFor[struct { + Value map[string]string `json:"value" schema:"required;nonnullable" doc:"value"` + }](), "requires an explicit Shape"}, + {"missing description", reflect.TypeFor[struct { + Value string `json:"value" schema:"required"` + }](), "Description is required"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := compileData(tt.typ, typedDataDefinition{}) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error = %v, want containing %q", err, tt.want) + } + }) + } +} + +func TestValidateShapeRejectsMalformedExplicitShapes(t *testing.T) { + for _, tt := range []struct { + shape typedValueShape + want string + }{ + {typedOneOfShape{Variants: []typedValueShape{typedStringShape{}}}, "at least two"}, + {typedArrayShape{}, "Items is required"}, + {typedObjectShape{Fields: []typedValueField{{Name: "x", Shape: typedStringShape{}}}}, "Description is required"}, + {typedObjectShape{AdditionalPropertiesShape: typedStringShape{}}, "requires AdditionalProperties"}, + {typedNumberShape{Enum: []float64{math.Inf(1)}}, "must be finite"}, + } { + if err := validateShape(tt.shape, "shape"); err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("shape %T error = %v, want %q", tt.shape, err, tt.want) + } + } +} diff --git a/shortcuts/common/typed_compiler_test.go b/shortcuts/common/typed_compiler_test.go new file mode 100644 index 0000000000..b41b86c18f --- /dev/null +++ b/shortcuts/common/typed_compiler_test.go @@ -0,0 +1,444 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "context" + "encoding/json" + "io" + "reflect" + "strings" + "testing" + + "github.com/larksuite/cli/extension/command" +) + +type CompilerInlineArgs struct { + Labels []string `flag:"labels" schema:"optional" cli:"encoding=repeated" doc:"labels to attach"` +} + +type compilerPayload struct { + Mode string `json:"mode" schema:"required;enum=fast|full" doc:"execution mode"` +} + +type compilerCustomJSON struct { + Value string `json:"value"` +} + +func (value compilerCustomJSON) MarshalJSON() ([]byte, error) { + return json.Marshal(map[string]string{"value": value.Value}) +} + +type compilerArgs struct { + Token string `flag:"token" schema:"required;minLength=1" doc:"target token"` + Limit command.Provided[int] `flag:"limit" schema:"optional;default=20;minimum=1;maximum=100" doc:"maximum results"` + Payload compilerPayload `flag:"payload" schema:"optional;nonnullable" cli:"sources=flag|file|stdin;encoding=json" doc:"request payload"` + CompilerInlineArgs `arg:"inline"` + NormalizedToken string `arg:"local"` +} + +type compilerItem struct { + ID string `json:"id" schema:"required" doc:"item identity"` + State string `json:"state" schema:"required;enum=ok|failed" doc:"item state"` +} + +type compilerData struct { + Title string `json:"title" schema:"required" doc:"result title"` + Items []compilerItem `json:"items" schema:"required;nonnullable" doc:"processed items"` + Next *string `json:"next,omitempty" schema:"optional;nullable" doc:"next page token"` +} + +func validCompilerDefinition() typedDefinition[compilerArgs, compilerData] { + return typedDefinition[compilerArgs, compilerData]{ + Metadata: typedCommandMetadata{ + Service: "fixture", Command: "+compile", Description: "Compile a fixture", Risk: typedRiskWrite, + Authorization: typedAuthorizationDefinition{Identities: map[typedIdentity]typedIdentityAuthorization{ + typedIdentityUser: { + RequiredScopes: []string{"fixture:write"}, + ConditionalScopes: []typedConditionalScope{{Scopes: []string{"fixture:read"}, When: "--payload selects the read path", Params: []string{"payload"}}}, + }, + }}, + }, + Input: typedInputDefinition{ + Fields: []typedInputField{{Name: "token", CLI: typedCLIInput{Aliases: []typedFlagAlias{{Name: "legacy-token", Mode: typedAliasIndependent, Conflict: typedAliasTrimmedEqualOrError, Hidden: true}}}}}, + Relations: []typedRelation{{Kind: typedRelationRequires, Params: []string{"payload", "token"}, Presence: typedPresenceExplicit, Stage: typedStageSourcePreRun}}, + }, + Output: typedOutputDefinition{Mode: typedOutputFixedJSON}, + Hooks: typedHooks[compilerArgs, compilerData]{Execute: func(context.Context, typedRuntimeContext, *compilerArgs) (typedResult[compilerData], error) { + return typedSuccess(compilerData{}), nil + }}, + } +} + +func TestDefineCompilesTypedContract(t *testing.T) { + shortcut := defineTypedShortcut(validCompilerDefinition()) + if shortcut.typed == nil { + t.Fatal("defineTypedShortcut() did not attach compiled contract") + } + if shortcut.Service != "fixture" || shortcut.Command != "+compile" || shortcut.Risk != "write" { + t.Fatalf("Shortcut metadata = %#v", shortcut) + } + if got, want := shortcut.AuthTypes, []string{"user"}; !equalStrings(got, want) { + t.Fatalf("AuthTypes = %v, want %v", got, want) + } + if got, want := shortcut.UserScopes, []string{"fixture:write"}; !equalStrings(got, want) { + t.Fatalf("UserScopes = %v, want %v", got, want) + } + if got, want := shortcut.ConditionalUserScopes, []string{"fixture:read"}; !equalStrings(got, want) { + t.Fatalf("ConditionalUserScopes = %v, want %v", got, want) + } + conditional := shortcut.typed.metadata.Authorization.Identities[typedIdentityUser].ConditionalScopes[0] + if conditional.Requirement != typedScopeRequired || conditional.When == "" || !equalStrings(conditional.Params, []string{"payload"}) { + t.Fatalf("normalized conditional scope = %#v", conditional) + } + if got, want := len(shortcut.typed.fields), 4; got != want { + t.Fatalf("compiled fields = %d, want %d", got, want) + } + limit := shortcut.typed.fields[shortcut.typed.fieldByName["limit"]] + if !limit.provided || !limit.defaultValue.Set || limit.defaultValue.Value != float64(20) { + t.Fatalf("compiled limit = %#v", limit) + } + payload := shortcut.typed.fields[shortcut.typed.fieldByName["payload"]] + if payload.cli.Encoding != typedEncodingJSON || len(payload.cli.ValueSources) != 3 { + t.Fatalf("compiled payload CLI = %#v", payload.cli) + } + if got := shortcut.typed.hooks.newArgs(); got == nil { + t.Fatal("newArgs() = nil") + } + if _, ok := shortcut.typed.dataShape.(typedObjectShape); !ok { + t.Fatalf("data shape = %T, want ObjectShape", shortcut.typed.dataShape) + } +} + +func TestValueShapeClosedSet(t *testing.T) { + shapes := []typedValueShape{ + typedStringShape{}, + typedBooleanShape{}, + typedIntegerShape{}, + typedNumberShape{}, + typedNullShape{}, + typedConstShape{}, + typedArrayShape{}, + typedObjectShape{}, + typedOneOfShape{}, + anyJSONShape{}, + } + for _, shape := range shapes { + shape.typedValueShape() + } +} + +func TestCompiledTypedSchemaContract(t *testing.T) { + definition := validCompilerDefinition() + definition.Output.Meta = typedResultMetaDefinition{Pagination: true} + contract := defineTypedShortcut(definition).typed.contract + if contract.Name != "fixture +compile" || contract.InputSchema.Type != "object" || contract.OutputSchema.Type != "object" { + t.Fatalf("contract identity or root shapes = %#v", contract) + } + if contract.InputSchema.Required == nil || !equalStrings(*contract.InputSchema.Required, []string{"token"}) { + t.Fatalf("required inputs = %#v", contract.InputSchema.Required) + } + if len(contract.InputSchema.Properties) != 4 { + t.Fatalf("input properties = %#v", contract.InputSchema.Properties) + } + token := contract.InputSchema.Properties["token"] + if token.Flag != "--token" || token.Aliases == nil || len(*token.Aliases) != 1 || (*token.Aliases)[0].Flag != "--legacy-token" { + t.Fatalf("token contract = %#v", token) + } + if contract.Meta.EnvelopeVersion != "1.0" || contract.Meta.Risk != typedRiskWrite || !equalStrings(contract.Meta.AccessTokens, []string{"user"}) { + t.Fatalf("contract metadata = %#v", contract.Meta) + } + conditional := contract.Meta.Authorization.Identities[typedIdentityUser].ConditionalScopes[0] + if conditional.When != "--payload selects the read path" || conditional.Requirement != typedScopeRequired || !equalStrings(conditional.Params, []string{"payload"}) { + t.Fatalf("conditional authorization contract = %#v", conditional) + } + if contract.Meta.Outcomes.PartialFailure.Supported { + t.Fatalf("partial outcome = %#v, want unsupported", contract.Meta.Outcomes.PartialFailure) + } + if contract.Meta.ResultMeta == nil { + t.Fatal("result_meta contract is nil") + } + pagination := contract.Meta.ResultMeta.Properties["pagination"] + if pagination.Type != "object" || pagination.Required == nil || !equalStrings(*pagination.Required, []string{"complete", "pages", "items"}) { + t.Fatalf("result meta pagination = %#v", pagination) + } + if pages := pagination.Properties["pages"]; pages.Minimum == nil || *pages.Minimum != 1 { + t.Fatalf("pagination pages = %#v", pages) + } + if items := pagination.Properties["items"]; items.Minimum == nil || *items.Minimum != 0 { + t.Fatalf("pagination items = %#v", items) + } + if token := pagination.Properties["next_token"]; token.Type != "string" { + t.Fatalf("pagination next_token = %#v", token) + } +} + +func TestCompiledSchemaRecordsJSONHTMLEscapingPolicy(t *testing.T) { + definition := validCompilerDefinition() + contract := defineTypedShortcut(definition).typed.contract + if got := contract.Meta.Formats[0].EscapeHTML; got == nil || !*got { + t.Fatalf("default JSON escape_html = %#v, want true", got) + } + + definition.Output.DisableHTMLEscaping = true + contract = defineTypedShortcut(definition).typed.contract + if got := contract.Meta.Formats[0].EscapeHTML; got == nil || *got { + t.Fatalf("unescaped JSON escape_html = %#v, want false", got) + } +} + +func TestCompileOutputDerivesExecutableGenericFormats(t *testing.T) { + definition := validCompilerDefinition() + definition.Output.Mode = typedOutputGeneric + definition.Hooks.Renderers = map[string]typedRenderer[compilerData]{"pretty": func(io.Writer, compilerData) error { return nil }} + command, err := compileDefinition(definition) + if err != nil { + t.Fatal(err) + } + formats := command.contract.Meta.Formats + var names []string + for _, format := range formats { + names = append(names, format.Name) + if !reflect.DeepEqual(format.SelectedBy, []string{format.Name}) { + t.Fatalf("format %q selected_by = %v", format.Name, format.SelectedBy) + } + } + if want := []string{"json", "pretty", "table", "ndjson", "csv"}; !reflect.DeepEqual(names, want) { + t.Fatalf("format names = %v, want %v", names, want) + } +} + +func TestCompileOutputRecordsCompatibilityFallbacks(t *testing.T) { + fixed := defineTypedShortcut(validCompilerDefinition()).typed.contract.Meta.Formats + if len(fixed) != 1 || fixed[0].Name != "json" || !reflect.DeepEqual(fixed[0].SelectedBy, []string{"json", "pretty", "table", "ndjson", "csv"}) { + t.Fatalf("fixed JSON formats = %#v", fixed) + } + + definition := validCompilerDefinition() + definition.Output.Mode = typedOutputGeneric + generic := defineTypedShortcut(definition).typed.contract.Meta.Formats + if len(generic) != 4 || generic[0].Name != "json" || !reflect.DeepEqual(generic[0].SelectedBy, []string{"json", "pretty"}) { + t.Fatalf("generic formats without pretty renderer = %#v", generic) + } +} + +func TestDefinePreservesCollectionDefaultForCobra(t *testing.T) { + type args struct { + Values []string `flag:"values" schema:"optional" cli:"encoding=repeated" doc:"values"` + } + type data struct { + OK bool `json:"ok" schema:"required" doc:"success state"` + } + definition := typedDefinition[args, data]{ + Metadata: typedCommandMetadata{Service: "fixture", Command: "+collection-default", Description: "collection default", Risk: typedRiskRead, Authorization: typedAuthorizationDefinition{Identities: map[typedIdentity]typedIdentityAuthorization{typedIdentityUser: {}}}}, + Input: typedInputDefinition{Fields: []typedInputField{{Name: "values", Default: typedInputDefault{Set: true, Value: []string{"a", "b"}}}}}, + Hooks: typedHooks[args, data]{Execute: func(context.Context, typedRuntimeContext, *args) (typedResult[data], error) { + return typedSuccess(data{OK: true}), nil + }}, + } + shortcut := defineTypedShortcut(definition) + if got := shortcut.Flags[0].Default; got != `["a","b"]` { + t.Fatalf("legacy default = %q", got) + } +} + +func TestDefinePanicIncludesCommandAndFieldContext(t *testing.T) { + type badArgs struct { + Token string `flag:"token" schema:"required"` + } + type data struct { + OK bool `json:"ok" schema:"required" doc:"success state"` + } + definition := typedDefinition[badArgs, data]{ + Metadata: validCompilerDefinition().Metadata, + Hooks: typedHooks[badArgs, data]{Execute: func(context.Context, typedRuntimeContext, *badArgs) (typedResult[data], error) { + return typedSuccess(data{}), nil + }}, + } + defer func() { + panicValue := recover() + if panicValue == nil { + t.Fatal("defineTypedShortcut() did not panic") + } + message := panicValue.(string) + for _, want := range []string{"typed shortcut fixture +compile", "Args field Token", "--token", "description is required"} { + if !strings.Contains(message, want) { + t.Fatalf("panic %q does not contain %q", message, want) + } + } + }() + _ = defineTypedShortcut(definition) +} + +func TestCompileDefinitionRejectsInvalidContracts(t *testing.T) { + tests := []struct { + name string + mutate func(*typedDefinition[compilerArgs, compilerData]) + want string + }{ + {"missing service", func(d *typedDefinition[compilerArgs, compilerData]) { d.Metadata.Service = "" }, "Metadata.Service is required"}, + {"unknown risk", func(d *typedDefinition[compilerArgs, compilerData]) { d.Metadata.Risk = typedRisk("delete") }, "Metadata.Risk"}, + {"unknown relation param", func(d *typedDefinition[compilerArgs, compilerData]) { d.Input.Relations[0].Params[1] = "missing" }, "unknown param --missing"}, + {"unknown conditional param", func(d *typedDefinition[compilerArgs, compilerData]) { + auth := d.Metadata.Authorization.Identities[typedIdentityUser] + auth.ConditionalScopes[0].Params = []string{"missing"} + d.Metadata.Authorization.Identities[typedIdentityUser] = auth + }, "unknown param --missing"}, + {"scope both required and conditional", func(d *typedDefinition[compilerArgs, compilerData]) { + auth := d.Metadata.Authorization.Identities[typedIdentityUser] + auth.ConditionalScopes[0].Scopes = []string{"fixture:write"} + d.Metadata.Authorization.Identities[typedIdentityUser] = auth + }, "already always required"}, + {"invalid conditional requirement", func(d *typedDefinition[compilerArgs, compilerData]) { + auth := d.Metadata.Authorization.Identities[typedIdentityUser] + auth.ConditionalScopes[0].Requirement = typedScopeRequirement("sometimes") + d.Metadata.Authorization.Identities[typedIdentityUser] = auth + }, "Requirement \"sometimes\" is invalid"}, + {"conditional params without when", func(d *typedDefinition[compilerArgs, compilerData]) { + auth := d.Metadata.Authorization.Identities[typedIdentityUser] + auth.ConditionalScopes[0].When = "" + d.Metadata.Authorization.Identities[typedIdentityUser] = auth + }, "Params requires agent-readable When text"}, + {"hidden conditional param", func(d *typedDefinition[compilerArgs, compilerData]) { + d.Input.Fields = append(d.Input.Fields, typedInputField{Name: "payload", CLI: typedCLIInput{Hidden: true}}) + }, "references hidden param --payload"}, + {"missing execute", func(d *typedDefinition[compilerArgs, compilerData]) { d.Hooks.Execute = nil }, "Hooks.Execute is required"}, + {"nil renderer", func(d *typedDefinition[compilerArgs, compilerData]) { + d.Hooks.Renderers = map[string]typedRenderer[compilerData]{"pretty": nil} + }, "Hooks.Renderers[\"pretty\"] is nil"}, + {"table renderer", func(d *typedDefinition[compilerArgs, compilerData]) { + d.Output.Mode = typedOutputGeneric + d.Hooks.Renderers = map[string]typedRenderer[compilerData]{"table": func(io.Writer, compilerData) error { return nil }} + }, "custom renderers are only supported for pretty"}, + {"fixed JSON renderer", func(d *typedDefinition[compilerArgs, compilerData]) { + d.Hooks.Renderers = map[string]typedRenderer[compilerData]{"pretty": func(io.Writer, compilerData) error { return nil }} + }, "conflicts with Output.Mode \"fixed_json\""}, + {"invalid output mode", func(d *typedDefinition[compilerArgs, compilerData]) { + d.Output.Mode = typedOutputMode("yaml") + }, "Output.Mode \"yaml\" is invalid"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + definition := validCompilerDefinition() + tt.mutate(&definition) + _, err := compileDefinition(definition) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("compileDefinition() error = %v, want containing %q", err, tt.want) + } + }) + } +} + +func TestCompileInputAcceptsExplicitShapeForCustomJSONType(t *testing.T) { + type args struct { + Payload compilerCustomJSON `flag:"payload" schema:"optional" cli:"encoding=json" doc:"custom payload"` + } + type data struct { + OK bool `json:"ok" schema:"required" doc:"success state"` + } + shape := command.ObjectShape{Fields: []command.ValueField{{Name: "value", Description: "custom value", Required: true, Shape: command.StringShape{}}}} + definition := typedDefinition[args, data]{ + Metadata: typedCommandMetadata{Service: "fixture", Command: "+custom-json", Description: "custom JSON input", Risk: typedRiskRead, Authorization: typedAuthorizationDefinition{Identities: map[typedIdentity]typedIdentityAuthorization{typedIdentityUser: {}}}}, + Input: typedInputDefinition{Fields: []typedInputField{{Name: "payload", Shape: shape}}}, + Hooks: typedHooks[args, data]{Execute: func(context.Context, typedRuntimeContext, *args) (typedResult[data], error) { + return typedSuccess(data{OK: true}), nil + }}, + } + shortcut := defineTypedShortcut(definition) + field := shortcut.typed.fields[shortcut.typed.fieldByName["payload"]] + value, err := decodeCompiledValue(`{"value":"x"}`, field) + if err != nil { + t.Fatal(err) + } + if got := value.(compilerCustomJSON).Value; got != "x" { + t.Fatalf("payload value = %q", got) + } +} + +func TestCompileDataAcceptsCompleteExplicitShapeForDynamicData(t *testing.T) { + shape := command.ObjectShape{AdditionalProperties: true, AdditionalPropertiesShape: command.StringShape{}} + compiled, err := compileData(reflect.TypeFor[map[string]any](), typedDataDefinition{Shape: shape}) + if err != nil { + t.Fatal(err) + } + object, ok := compiled.(typedObjectShape) + if !ok || !object.AdditionalProperties || object.AdditionalPropertiesShape == nil { + t.Fatalf("compiled shape = %#v", compiled) + } + node := schemaNodeFromShape(compiled) + if node.AdditionalProperties == nil { + t.Fatal("schema additionalProperties missing") + } + if _, ok := (*node.AdditionalProperties).(typedSchemaNode); !ok { + t.Fatalf("additionalProperties = %#v", *node.AdditionalProperties) + } +} + +func TestCompileDataAcceptsAnyForLegacyJSONPassthrough(t *testing.T) { + compiled, err := compileData(reflect.TypeFor[any](), typedDataDefinition{}) + if err != nil { + t.Fatal(err) + } + if _, ok := compiled.(anyJSONShape); !ok { + t.Fatalf("compiled shape = %T, want anyJSONShape", compiled) + } + if node := schemaNodeFromShape(compiled); !reflect.DeepEqual(node, typedSchemaNode{}) { + t.Fatalf("schema node = %#v, want unconstrained JSON schema", node) + } + for _, value := range []any{nil, "text", true, float64(7), []any{"x", float64(1)}, map[string]any{"nested": []any{false, nil}}} { + if err := valueCompatibleWithShape(value, compiled); err != nil { + t.Fatalf("value %#v rejected: %v", value, err) + } + } + if _, err := compileData(reflect.TypeFor[any](), typedDataDefinition{Overrides: []typedDataField{{Path: "/value"}}}); err == nil || !strings.Contains(err.Error(), "Overrides require struct Data") { + t.Fatalf("override error = %v", err) + } +} + +func TestCompileDataOverrideRejectsInvalidJSONPointerEscaping(t *testing.T) { + type data struct { + Value string `json:"value" schema:"required" doc:"value"` + } + _, err := compileData(reflectType[data](), typedDataDefinition{Overrides: []typedDataField{{Path: "/value~2", Description: "override"}}}) + if err == nil || !strings.Contains(err.Error(), "invalid RFC 6901 escaping") { + t.Fatalf("error = %v", err) + } +} + +func TestCompileDataOverrideMutatesNestedShape(t *testing.T) { + type nested struct { + Value string `json:"value" schema:"required"` + } + type data struct { + Nested nested `json:"nested" schema:"required" doc:"nested result"` + } + shape, err := compileData(reflectType[data](), typedDataDefinition{Overrides: []typedDataField{{Path: "/nested/value", Description: "overridden value"}}}) + if err != nil { + t.Fatalf("compileData() error = %v", err) + } + nestedShape, err := resolveShapePointer(shape, "/nested/value") + if err != nil { + t.Fatal(err) + } + _ = nestedShape + root := shape.(typedObjectShape) + child := root.Fields[0].Shape.(typedObjectShape) + if got := child.Fields[0].Description; got != "overridden value" { + t.Fatalf("nested description = %q", got) + } +} + +func reflectType[T any]() reflect.Type { return reflect.TypeFor[T]() } + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/shortcuts/common/typed_contract.go b/shortcuts/common/typed_contract.go new file mode 100644 index 0000000000..0da2019c75 --- /dev/null +++ b/shortcuts/common/typed_contract.go @@ -0,0 +1,62 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "context" + "io" + "reflect" +) + +type compiledCommand struct { + metadata typedCommandMetadata + argsType reflect.Type + dataType reflect.Type + fields []compiledInputField + fieldByName map[string]int + relations []compiledRelation + dataShape typedValueShape + output typedOutputDefinition + contract typedSchemaContract + hooks compiledHooks + pageOutput bool +} + +type compiledInputField struct { + name string + goName string + index []int + valueIndex []int + valueType reflect.Type + provided bool + required bool + nullable *bool + description string + shape typedValueShape + shapeExplicit bool + defaultValue typedInputDefault + cli typedCLIInput +} + +type compiledRelation struct { + kind typedRelationKind + fields []int + presence typedPresenceMode + stage typedRelationStage +} + +type compiledResult struct { + data any + outcome typedOutcomeKind + meta *typedResultMeta +} + +type compiledHooks struct { + newArgs func() any + normalize func(context.Context, typedRuntimeContext, any) error + validate func(context.Context, typedRuntimeContext, any) error + dryRun func(context.Context, typedRuntimeContext, any) (*DryRunAPI, error) + execute func(context.Context, typedRuntimeContext, any) (compiledResult, error) + renderers map[string]func(io.Writer, any) error +} diff --git a/shortcuts/common/typed_definition.go b/shortcuts/common/typed_definition.go new file mode 100644 index 0000000000..b9bf3e59f7 --- /dev/null +++ b/shortcuts/common/typed_definition.go @@ -0,0 +1,75 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "github.com/larksuite/cli/extension/command" + "github.com/larksuite/cli/internal/commandbridge" +) + +// The public command model has exactly one owner: extension/command. These +// unexported aliases let the existing compiler and runner consume that model +// directly without publishing a second authoring contract from common. +type typedJSONValue = command.JSONValue +type typedCommandMetadata = command.CommandMetadata +type typedIdentity = command.Identity +type typedRisk = command.Risk +type typedAuthorizationDefinition = command.AuthorizationDefinition +type typedIdentityAuthorization = command.IdentityAuthorization +type typedConditionalScope = command.ConditionalScope +type typedScopeRequirement = command.ScopeRequirement +type typedInputDefinition = command.InputDefinition +type typedInputField = command.InputField +type typedInputDefault = command.InputDefault +type typedCLIInput = command.CLIInput +type typedFlagAlias = command.FlagAlias +type typedFlagAliasMode = command.FlagAliasMode +type typedAliasConflictPolicy = command.AliasConflictPolicy +type typedValueSource = command.ValueSource +type typedCLIEncoding = command.CLIEncoding +type typedRelation = command.Relation +type typedRelationKind = command.RelationKind +type typedPresenceMode = command.PresenceMode +type typedRelationStage = command.RelationStage +type typedRuntimeContext = commandbridge.RuntimeContext +type typedPaginationOptions = command.PaginationOptions + +const ( + typedIdentityUser = command.IdentityUser + typedIdentityBot = command.IdentityBot + + typedRiskRead = command.RiskRead + typedRiskWrite = command.RiskWrite + typedRiskHighRiskWrite = command.RiskHighRiskWrite + + typedScopeRequired = command.ScopeRequired + typedScopeBestEffort = command.ScopeBestEffort + + typedAliasNormalize = command.AliasNormalize + typedAliasIndependent = command.AliasIndependent + + typedAliasCanonicalWins = command.AliasCanonicalWins + typedAliasErrorIfBoth = command.AliasErrorIfBoth + typedAliasTrimmedEqualOrError = command.AliasTrimmedEqualOrError + + typedSourceFlag = command.SourceFlag + typedSourceFile = command.SourceFile + typedSourceStdin = command.SourceStdin + + typedEncodingRepeated = command.EncodingRepeated + typedEncodingCommaOrRepeated = command.EncodingCommaOrRepeated + typedEncodingJSON = command.EncodingJSON + + typedRelationExactlyOne = command.RelationExactlyOne + typedRelationAtLeastOne = command.RelationAtLeastOne + typedRelationCoOccur = command.RelationCoOccur + typedRelationRequires = command.RelationRequires + typedRelationConflicts = command.RelationConflicts + + typedPresenceExplicit = command.PresenceExplicit + typedPresenceNonZero = command.PresenceNonZero + + typedStageSourcePreRun = command.StageSourcePreRun + typedStageAfterPrepare = command.StageAfterPrepare +) diff --git a/shortcuts/common/typed_external.go b/shortcuts/common/typed_external.go new file mode 100644 index 0000000000..7d259a20d8 --- /dev/null +++ b/shortcuts/common/typed_external.go @@ -0,0 +1,147 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//nolint:forbidigo // Definition diagnostics are build-time errors wrapped by the command-set startup guard. +package common + +import ( + "context" + "fmt" + "io" + "reflect" + + "github.com/larksuite/cli/internal/commandbridge" +) + +// CompileCommandDefinition is the single sealed entry from commandhost into +// the existing shortcut compiler. All authoring fields retain +// extension/command as their owner; the internal token prevents this function +// from becoming a second public compiler API. +func CompileCommandDefinition(definition commandbridge.Definition, _ commandbridge.Access) (Shortcut, error) { + if definition.ArgsType == nil || definition.DataType == nil { + return Shortcut{}, fmt.Errorf("ArgsType and DataType are required") + } + if definition.Hooks.NewArgs == nil { + return Shortcut{}, fmt.Errorf("Hooks.NewArgs is required") + } + newArgs, err := probeNewArgs(definition.Hooks.NewArgs) + if err != nil { + return Shortcut{}, err + } + if newArgs == nil || reflect.TypeOf(newArgs) != reflect.PointerTo(definition.ArgsType) { + return Shortcut{}, fmt.Errorf("Hooks.NewArgs must return *%s", definition.ArgsType) + } + + output := definition.Output + if definition.PageOutput { + output.Meta.Pagination = true + } + compiled, err := compileDefinitionParts( + definition.Metadata, + definition.Input, + output, + definition.ArgsType, + definition.DataType, + adaptBridgeHooks(definition.Hooks), + bridgeRendererMarkers(definition.Hooks.Renderers), + definition.PageOutput, + ) + if err != nil { + return Shortcut{}, err + } + if err := validateExternalFlagNamespace(compiled); err != nil { + return Shortcut{}, err + } + shortcut := shortcutFromCompiled(compiled) + if definition.PageOutput { + shortcut.Flags = append(shortcut.Flags, PageAllFlags()...) + } + if err := validateTypedFlagMountPlan(compiled, shortcut.PrintFlagSchema != nil, typedRisk(shortcut.Risk)); err != nil { + return Shortcut{}, err + } + return shortcut, nil +} + +func probeNewArgs(newArgs func() any) (result any, err error) { + defer func() { + if recovered := recover(); recovered != nil { + result = nil + err = fmt.Errorf("Hooks.NewArgs panicked: %v", recovered) + } + }() + return newArgs(), nil +} + +func adaptBridgeHooks(hooks commandbridge.Hooks) compiledHooks { + adapted := compiledHooks{ + newArgs: hooks.NewArgs, + normalize: hooks.Normalize, + validate: hooks.Validate, + renderers: hooks.Renderers, + } + if hooks.DryRun != nil { + adapted.dryRun = func(ctx context.Context, runtime typedRuntimeContext, args any) (*DryRunAPI, error) { + value, err := hooks.DryRun(ctx, runtime, args) + if err != nil || value == nil { + return nil, err + } + preview, ok := value.(*DryRunAPI) + if !ok { + return nil, fmt.Errorf("bridged DryRun returned %T, expected *common.DryRunAPI", value) + } + return preview, nil + } + } + if hooks.Execute != nil { + adapted.execute = func(ctx context.Context, runtime typedRuntimeContext, args any) (compiledResult, error) { + result, err := hooks.Execute(ctx, runtime, args) + converted := compiledResult{data: result.Data, outcome: typedOutcomeKind(result.Outcome)} + if result.Pagination != nil { + converted.meta = &typedResultMeta{Pagination: &typedResultPaginationMeta{ + Complete: result.Pagination.Complete, + Pages: result.Pagination.Pages, Items: result.Pagination.Items, + NextToken: result.Pagination.NextToken, + }} + } + return converted, err + } + } + return adapted +} + +func bridgeRendererMarkers(renderers map[string]func(io.Writer, any) error) map[string]rendererMarker { + markers := make(map[string]rendererMarker, len(renderers)) + for name, renderer := range renderers { + markers[name] = rendererMarker{isNil: renderer == nil} + } + return markers +} + +func validateExternalFlagNamespace(command *compiledCommand) error { + reserved := map[string]string{ + "as": "identity selection", + "dry-run": "dry-run execution", + "flag-name": "schema inspection", + "format": "output formatting", + "help": "Cobra help", + "jq": "output filtering", + "json": "JSON output shorthand", + "page-all": "pagination", + "page-delay": "pagination", + "page-limit": "pagination", + "print-schema": "schema inspection", + "profile": "profile selection", + "yes": "high-risk confirmation", + } + for _, field := range command.fields { + if role, exists := reserved[field.name]; exists { + return fmt.Errorf("Args field %s (--%s) conflicts with host %s flag", field.goName, field.name, role) + } + for _, alias := range field.cli.Aliases { + if role, exists := reserved[alias.Name]; exists { + return fmt.Errorf("Args field %s (--%s) alias --%s conflicts with host %s flag", field.goName, field.name, alias.Name, role) + } + } + } + return nil +} diff --git a/shortcuts/common/typed_external_pagination.go b/shortcuts/common/typed_external_pagination.go new file mode 100644 index 0000000000..93a5f88dce --- /dev/null +++ b/shortcuts/common/typed_external_pagination.go @@ -0,0 +1,70 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "context" + "time" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/commandbridge" + "github.com/larksuite/cli/internal/output" + internalpagination "github.com/larksuite/cli/internal/pagination" +) + +// CollectHostedPages is PaginateInto for an externally declared command. Such +// a command compiles in the business module and so reaches the CLI through the +// CommandContext interface rather than a *RuntimeContext; the policy and the +// call arrive through that interface, and the context is a parameter because +// the interface carries none. Everything else is PaginateInto: the same cursor +// walk, the same per-page decode into T, the same accumulator, the same +// metadata. +// +// all selects the complete-set policy CollectAllPages needs -- collect to +// exhaustion under a hard page bound rather than obey --page-all and +// --page-limit. Built-in shortcuts have no equivalent, which is why it is a +// parameter here and not in PaginateInto. +func CollectHostedPages[T any](ctx context.Context, command typedRuntimeContext, request PageRequest, all bool, dst PageAccumulator[T], _ commandbridge.Access) (*output.PaginationMeta, error) { + meta := &output.PaginationMeta{} + policy, err := commandPagePolicy(command, all) + if err != nil { + return meta, err + } + state, walkErr := pageWalk{ + policy: policy, + request: request, + fetch: func(ctx context.Context, page PageRequest) (map[string]interface{}, error) { + return CallHostedAPI(ctx, command, page.Method, page.Path, page.Params, page.Body, commandbridge.Access{}) + }, + accumulate: func(data map[string]interface{}, pageNumber int) error { + return addDecodedPage(data, pageNumber, dst) + }, + }.run(ctx) + meta.Complete = state.Complete + meta.Pages = state.Pages + meta.NextToken = state.NextToken + return meta, walkErr +} + +func commandPagePolicy(command typedRuntimeContext, all bool) (paginationPolicy, error) { + if all { + return paginationPolicy{maxPages: internalpagination.CollectAllHardPageBound}, 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 +} diff --git a/shortcuts/common/typed_external_pagination_test.go b/shortcuts/common/typed_external_pagination_test.go new file mode 100644 index 0000000000..33ef02007b --- /dev/null +++ b/shortcuts/common/typed_external_pagination_test.go @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "testing" + + internalpagination "github.com/larksuite/cli/internal/pagination" +) + +// A complete-set collection holds every page in memory before the workflow's +// writes run, so its bound is a host resource decision — deliberately tighter +// than the user-facing --page-limit maximum. +func TestCollectAllPolicyUsesWorkflowHardBound(t *testing.T) { + policy, err := commandPagePolicy(typedRuntimeContext(nil), true) + if err != nil { + t.Fatal(err) + } + if policy.maxPages != internalpagination.CollectAllHardPageBound { + t.Fatalf("collect-all maxPages = %d, want %d", policy.maxPages, internalpagination.CollectAllHardPageBound) + } + if internalpagination.CollectAllHardPageBound >= pageLimitMaximum { + t.Fatalf("workflow bound (%d) must stay below the user-facing --page-limit maximum (%d)", + internalpagination.CollectAllHardPageBound, pageLimitMaximum) + } + if internalpagination.CollectAllHardPageBound != 100 { + t.Fatalf("workflow bound = %d, want the Phase 0 value 100", internalpagination.CollectAllHardPageBound) + } +} diff --git a/shortcuts/common/typed_flag_collisions.go b/shortcuts/common/typed_flag_collisions.go new file mode 100644 index 0000000000..2ec316cede --- /dev/null +++ b/shortcuts/common/typed_flag_collisions.go @@ -0,0 +1,71 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//nolint:forbidigo // These diagnostics describe registration-time programmer errors surfaced through Define or Mount panics. +package common + +import "fmt" + +// validateTypedFlagMountPlan mirrors the flags the existing Shortcut mounter +// will add for this command. It does not create a new reserved namespace: names +// such as --json, --format, and non-high-risk --yes retain their established +// business meanings when declared as real flags. +func validateTypedFlagMountPlan(command *compiledCommand, hasFlagSchema bool, mountedRisk typedRisk) error { + flags := legacyFlagsFromCompiled(command.fields) + view := Shortcut{Flags: flags} + + primaryConflicts := map[string]string{ + "as": "framework identity selection", + "dry-run": "framework dry-run execution", + "help": "Cobra help", + "jq": "framework output filtering", + "profile": "inherited profile selection", + } + if mountedRisk == typedRiskHighRiskWrite { + primaryConflicts["yes"] = "framework high-risk confirmation" + } + if hasFlagSchema { + primaryConflicts["print-schema"] = "framework complex-input introspection" + primaryConflicts["flag-name"] = "framework complex-input introspection" + } + + // Normalized aliases are installed after all framework flags. Unlike a real + // business --format or --json flag, an alias cannot suppress framework flag + // registration, so it must be checked against the final mounted long names. + aliasConflicts := map[string]string{ + "as": "framework identity selection", + "dry-run": "framework dry-run execution", + "format": "framework or business output format", + "jq": "framework output filtering", + "profile": "inherited profile selection", + "help": "Cobra help", + } + if !shortcutDeclaresJSONFlag(&view) && shortcutFormatSupportsJSON(&view) { + aliasConflicts["json"] = "framework JSON output shorthand" + } + if mountedRisk == typedRiskHighRiskWrite { + aliasConflicts["yes"] = "framework high-risk confirmation" + } + if hasFlagSchema { + aliasConflicts["print-schema"] = "framework complex-input introspection" + aliasConflicts["flag-name"] = "framework complex-input introspection" + } + + for _, field := range command.fields { + if reason, conflict := primaryConflicts[field.name]; conflict { + return fmt.Errorf("Args field %s (--%s): business flag conflicts with %s flag --%s", field.goName, field.name, reason, field.name) + } + for _, alias := range field.cli.Aliases { + conflicts := primaryConflicts + kind := "independent alias" + if alias.Mode == typedAliasNormalize { + conflicts = aliasConflicts + kind = "normalize alias" + } + if reason, conflict := conflicts[alias.Name]; conflict { + return fmt.Errorf("Args field %s (--%s): %s --%s conflicts with %s flag --%s", field.goName, field.name, kind, alias.Name, reason, alias.Name) + } + } + } + return nil +} diff --git a/shortcuts/common/typed_flag_collisions_test.go b/shortcuts/common/typed_flag_collisions_test.go new file mode 100644 index 0000000000..83b4cf0429 --- /dev/null +++ b/shortcuts/common/typed_flag_collisions_test.go @@ -0,0 +1,190 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "context" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/spf13/cobra" +) + +type collisionData struct { + OK bool `json:"ok" schema:"required" doc:"success state"` +} + +type collisionDryRunArgs struct { + Value bool `flag:"dry-run" schema:"optional" doc:"business dry run"` +} + +type collisionAsArgs struct { + Value string `flag:"as" schema:"optional" doc:"business identity"` +} + +type collisionJQArgs struct { + Value string `flag:"jq" schema:"optional" doc:"business filter"` +} + +type collisionProfileArgs struct { + Value string `flag:"profile" schema:"optional" doc:"business profile"` +} + +type collisionHelpArgs struct { + Value bool `flag:"help" schema:"optional" doc:"business help"` +} + +type collisionYesArgs struct { + Value bool `flag:"yes" schema:"optional" doc:"business confirmation"` +} + +type collisionPrintSchemaArgs struct { + PrintSchema bool `flag:"print-schema" schema:"optional" doc:"business schema switch"` + Payload compilerPayload `flag:"payload" schema:"optional;nonnullable" cli:"encoding=json" doc:"structured payload"` +} + +type collisionFlagNameArgs struct { + FlagName string `flag:"flag-name" schema:"optional" doc:"business field name"` + Payload compilerPayload `flag:"payload" schema:"optional;nonnullable" cli:"encoding=json" doc:"structured payload"` +} + +type collisionAllowedArgs struct { + JSON string `flag:"json" schema:"optional" doc:"request JSON"` + Format string `flag:"format" schema:"optional;enum=json|data" doc:"business output format"` + Yes bool `flag:"yes" schema:"optional" doc:"business acknowledgement"` + Version string `flag:"version" schema:"optional" doc:"resource version"` +} + +type collisionAliasArgs struct { + Value string `flag:"value" schema:"optional" doc:"business value"` +} + +type collisionPrintOnlyArgs struct { + PrintSchema bool `flag:"print-schema" schema:"optional" doc:"business schema switch"` +} + +func collisionDefinition[Args any](risk typedRisk, input typedInputDefinition) typedDefinition[Args, collisionData] { + return typedDefinition[Args, collisionData]{ + Metadata: typedCommandMetadata{ + Service: "fixture", Command: "+collision", Description: "collision fixture", Risk: risk, + Authorization: typedAuthorizationDefinition{Identities: map[typedIdentity]typedIdentityAuthorization{typedIdentityUser: {}}}, + }, + Input: input, + Hooks: typedHooks[Args, collisionData]{Execute: func(context.Context, typedRuntimeContext, *Args) (typedResult[collisionData], error) { + return typedSuccess(collisionData{OK: true}), nil + }}, + } +} + +func requireTypedCollisionPanic(t *testing.T, run func(), wants ...string) { + t.Helper() + defer func() { + value := recover() + if value == nil { + t.Fatal("expected Typed Shortcut registration to panic") + } + message, ok := value.(string) + if !ok { + t.Fatalf("panic = %#v, want string", value) + } + for _, want := range wants { + if !strings.Contains(message, want) { + t.Fatalf("panic %q does not contain %q", message, want) + } + } + }() + run() +} + +func TestDefineRejectsActiveFrameworkFlagCollisions(t *testing.T) { + tests := []struct { + name string + run func() + want string + }{ + {name: "dry-run", run: func() { + _ = defineTypedShortcut(collisionDefinition[collisionDryRunArgs](typedRiskRead, typedInputDefinition{})) + }, want: "framework dry-run execution"}, + {name: "as", run: func() { + _ = defineTypedShortcut(collisionDefinition[collisionAsArgs](typedRiskRead, typedInputDefinition{})) + }, want: "framework identity selection"}, + {name: "jq", run: func() { + _ = defineTypedShortcut(collisionDefinition[collisionJQArgs](typedRiskRead, typedInputDefinition{})) + }, want: "framework output filtering"}, + {name: "profile", run: func() { + _ = defineTypedShortcut(collisionDefinition[collisionProfileArgs](typedRiskRead, typedInputDefinition{})) + }, want: "inherited profile selection"}, + {name: "help", run: func() { + _ = defineTypedShortcut(collisionDefinition[collisionHelpArgs](typedRiskRead, typedInputDefinition{})) + }, want: "Cobra help"}, + {name: "high-risk yes", run: func() { + _ = defineTypedShortcut(collisionDefinition[collisionYesArgs](typedRiskHighRiskWrite, typedInputDefinition{})) + }, want: "framework high-risk confirmation"}, + {name: "print-schema when introspection is active", run: func() { + _ = defineTypedShortcut(collisionDefinition[collisionPrintSchemaArgs](typedRiskRead, typedInputDefinition{})) + }, want: "framework complex-input introspection"}, + {name: "flag-name when introspection is active", run: func() { + _ = defineTypedShortcut(collisionDefinition[collisionFlagNameArgs](typedRiskRead, typedInputDefinition{})) + }, want: "framework complex-input introspection"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + requireTypedCollisionPanic(t, test.run, "typed shortcut fixture +collision", test.want) + }) + } +} + +func TestDefinePreservesExistingBusinessFlagMeanings(t *testing.T) { + shortcut := defineTypedShortcut(collisionDefinition[collisionAllowedArgs](typedRiskWrite, typedInputDefinition{})) + for _, name := range []string{"json", "format", "yes", "version"} { + found := false + for _, flag := range shortcut.Flags { + if flag.Name == name { + found = true + break + } + } + if !found { + t.Fatalf("business flag --%s was not preserved", name) + } + } + if _, claimedAsSystem := shortcut.typed.contract.Meta.CLI.Flags["json"]; claimedAsSystem { + t.Fatal("Typed Schema described business --json as the framework output shorthand") + } + format := shortcut.typed.contract.Meta.CLI.Flags["format"] + if format.Default == nil || *format.Default != "" || !equalStrings(format.Enum, []string{"json", "data"}) { + t.Fatalf("Typed Schema format = %#v, want preserved business contract", format) + } + + factory, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{}) + parent := &cobra.Command{Use: "fixture"} + shortcut.Mount(parent, factory) + mounted, _, err := parent.Find([]string{shortcut.Command}) + if err != nil { + t.Fatal(err) + } + for name, wantType := range map[string]string{"json": "string", "format": "string", "yes": "bool", "version": "string"} { + flag := mounted.Flags().Lookup(name) + if flag == nil || flag.Value.Type() != wantType { + t.Fatalf("mounted --%s = %#v, want business type %q", name, flag, wantType) + } + } +} + +func TestDefineRejectsNormalizeAliasThatWouldCollideAfterMount(t *testing.T) { + definition := collisionDefinition[collisionAliasArgs](typedRiskRead, typedInputDefinition{Fields: []typedInputField{{ + Name: "value", CLI: typedCLIInput{Aliases: []typedFlagAlias{{Name: "format", Mode: typedAliasNormalize}}}, + }}}) + requireTypedCollisionPanic(t, func() { _ = defineTypedShortcut(definition) }, "Args field Value", "normalize alias --format", "output format") +} + +func TestMountRejectsSchemaCollisionAddedAfterDefine(t *testing.T) { + shortcut := defineTypedShortcut(collisionDefinition[collisionPrintOnlyArgs](typedRiskRead, typedInputDefinition{})) + shortcut.PrintFlagSchema = func(string) ([]byte, error) { return []byte(`{}`), nil } + factory, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{}) + parent := &cobra.Command{Use: "fixture"} + requireTypedCollisionPanic(t, func() { shortcut.Mount(parent, factory) }, "typed shortcut fixture +collision", "--print-schema", "complex-input introspection") +} diff --git a/shortcuts/common/typed_flag_schema.go b/shortcuts/common/typed_flag_schema.go new file mode 100644 index 0000000000..6c5dad4ac9 --- /dev/null +++ b/shortcuts/common/typed_flag_schema.go @@ -0,0 +1,70 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "encoding/json" + "sort" + + "github.com/larksuite/cli/errs" +) + +// typedFlagSchemaPrinter exposes complete compiled shapes through the existing +// local --print-schema contract. Default Help remains concise; only callers that +// explicitly request a complex flag's schema receive its nested structure. +func typedFlagSchemaPrinter(command *compiledCommand) func(string) ([]byte, error) { + schemas := make(map[string]typedSchemaNode) + for _, field := range command.fields { + if field.cli.Hidden || field.cli.Encoding != typedEncodingJSON || !isCompositeValueShape(field.shape) { + continue + } + node := schemaNodeFromShape(field.shape) + node.Description = field.description + schemas[field.name] = node + } + if len(schemas) == 0 { + return nil + } + + flags := make([]string, 0, len(schemas)) + for name := range schemas { + flags = append(flags, name) + } + sort.Strings(flags) + + return func(flagName string) ([]byte, error) { + if flagName == "" { + return json.MarshalIndent(map[string]any{ + "shortcut": command.metadata.Command, + "introspectable_flags": flags, + "hint": "run again with --flag-name