From cc8c7d84084e24df56d01a207ce50f6740a0df96 Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:00:11 +0800 Subject: [PATCH 01/47] feat(shortcuts): import typed shortcut framework from c07b64621 --- shortcuts/common/runner.go | 152 ++++- shortcuts/common/typed_api.go | 120 ++++ shortcuts/common/typed_api_test.go | 29 + shortcuts/common/typed_authorization_test.go | 125 ++++ shortcuts/common/typed_binder.go | 637 ++++++++++++++++++ .../common/typed_binder_benchmark_test.go | 33 + shortcuts/common/typed_compile_args.go | 630 +++++++++++++++++ shortcuts/common/typed_compile_contract.go | 268 ++++++++ shortcuts/common/typed_compile_data.go | 430 ++++++++++++ shortcuts/common/typed_compile_output.go | 43 ++ shortcuts/common/typed_compiler.go | 376 +++++++++++ .../common/typed_compiler_invalid_test.go | 182 +++++ shortcuts/common/typed_compiler_test.go | 487 +++++++++++++ shortcuts/common/typed_contract.go | 61 ++ shortcuts/common/typed_definition.go | 193 ++++++ shortcuts/common/typed_flag_collisions.go | 71 ++ .../common/typed_flag_collisions_test.go | 174 +++++ shortcuts/common/typed_flag_schema.go | 70 ++ shortcuts/common/typed_flag_schema_test.go | 162 +++++ shortcuts/common/typed_help.go | 206 ++++++ shortcuts/common/typed_help_render.go | 483 +++++++++++++ shortcuts/common/typed_help_render_test.go | 91 +++ shortcuts/common/typed_map_binder.go | 98 +++ shortcuts/common/typed_map_binder_test.go | 302 +++++++++ shortcuts/common/typed_mount_guard.go | 103 +++ shortcuts/common/typed_output.go | 116 ++++ shortcuts/common/typed_result_protocol.go | 253 +++++++ .../common/typed_result_protocol_test.go | 155 +++++ shortcuts/common/typed_runner.go | 228 +++++++ shortcuts/common/typed_runner_test.go | 624 +++++++++++++++++ shortcuts/common/typed_schema.go | 330 +++++++++ shortcuts/common/typed_shape.go | 71 ++ shortcuts/common/types.go | 5 + 33 files changed, 7281 insertions(+), 27 deletions(-) create mode 100644 shortcuts/common/typed_api.go create mode 100644 shortcuts/common/typed_api_test.go create mode 100644 shortcuts/common/typed_authorization_test.go create mode 100644 shortcuts/common/typed_binder.go create mode 100644 shortcuts/common/typed_binder_benchmark_test.go create mode 100644 shortcuts/common/typed_compile_args.go create mode 100644 shortcuts/common/typed_compile_contract.go create mode 100644 shortcuts/common/typed_compile_data.go create mode 100644 shortcuts/common/typed_compile_output.go create mode 100644 shortcuts/common/typed_compiler.go create mode 100644 shortcuts/common/typed_compiler_invalid_test.go create mode 100644 shortcuts/common/typed_compiler_test.go create mode 100644 shortcuts/common/typed_contract.go create mode 100644 shortcuts/common/typed_definition.go create mode 100644 shortcuts/common/typed_flag_collisions.go create mode 100644 shortcuts/common/typed_flag_collisions_test.go create mode 100644 shortcuts/common/typed_flag_schema.go create mode 100644 shortcuts/common/typed_flag_schema_test.go create mode 100644 shortcuts/common/typed_help.go create mode 100644 shortcuts/common/typed_help_render.go create mode 100644 shortcuts/common/typed_help_render_test.go create mode 100644 shortcuts/common/typed_map_binder.go create mode 100644 shortcuts/common/typed_map_binder_test.go create mode 100644 shortcuts/common/typed_mount_guard.go create mode 100644 shortcuts/common/typed_output.go create mode 100644 shortcuts/common/typed_result_protocol.go create mode 100644 shortcuts/common/typed_result_protocol_test.go create mode 100644 shortcuts/common/typed_runner.go create mode 100644 shortcuts/common/typed_runner_test.go create mode 100644 shortcuts/common/typed_schema.go create mode 100644 shortcuts/common/typed_shape.go diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index e0b1e1681a..dfa6cb9cc5 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -906,7 +906,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 +914,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, Risk(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 +930,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 +955,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") }) } } @@ -959,39 +976,108 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f 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 { + 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 != AliasIndependent || !alias.Deprecated { + continue + } + _ = cmd.Flags().MarkDeprecated(alias.Name, "use --"+field.name+" instead") + } + } + for _, relation := range command.relations { + if relation.stage != StageSourcePreRun || relation.presence != PresenceExplicit { + continue + } + names := make([]string, 0, len(relation.fields)) + for _, index := range relation.fields { + names = append(names, command.fields[index].name) + } + switch relation.kind { + case RelationExactlyOne: + cmd.MarkFlagsOneRequired(names...) + cmd.MarkFlagsMutuallyExclusive(names...) + case RelationAtLeastOne: + cmd.MarkFlagsOneRequired(names...) + case RelationCoOccur: + cmd.MarkFlagsRequiredTogether(names...) + case RelationConflicts: + cmd.MarkFlagsMutuallyExclusive(names...) + } + } } // 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 +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 +} + +func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bool) error { as, err := resolveShortcutIdentity(cmd, f, s) if err != nil { return err @@ -1400,11 +1486,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/typed_api.go b/shortcuts/common/typed_api.go new file mode 100644 index 0000000000..ac21aac87b --- /dev/null +++ b/shortcuts/common/typed_api.go @@ -0,0 +1,120 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "context" + "net/http" + + 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/core" + "github.com/larksuite/cli/internal/errclass" +) + +// DoTypedAPIJSON executes and classifies one JSON API request through the +// restricted CommandContext. It preserves the legacy RuntimeContext typed API +// classification while keeping hooks independent of RuntimeContext flags and +// output methods. It also carries a header-only log_id in returned data so a +// hook that detects a malformed success payload can attach the request ID to +// its own typed invalid-response error. +func DoTypedAPIJSON(ctx context.Context, command CommandContext, method, apiPath string, query larkcore.QueryParams, body any) (map[string]any, error) { + return DoTypedAPIJSONWithOptions(ctx, command, method, apiPath, query, body) +} + +// DoTypedAPIJSONWithOptions is DoTypedAPIJSON with SDK request options, used by +// multipart/form-data callers that must opt into file upload handling. +func DoTypedAPIJSONWithOptions(ctx context.Context, command CommandContext, 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) + } + data, err := ClassifyAPIResponseWith(response, typedClassifyContext(command)) + if data == nil { + data = map[string]any{} + } + if logID := response.Header.Get("x-tt-logid"); logID != "" { + data["log_id"] = logID + } + return data, err +} + +// CallTypedAPI preserves RuntimeContext.CallAPITyped's raw request semantics +// for Typed hooks whose request params are represented as loose query maps. +func CallTypedAPI(ctx context.Context, command CommandContext, method, apiPath string, params map[string]interface{}, data any) (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)) +} + +// DoTypedAPIStream executes a finite streaming HTTP response through the +// restricted CommandContext. Successful response-body ownership belongs to the +// caller; APIClient.DoStream closes HTTP error bodies and couples request +// cancellation to closing a successful body. +func DoTypedAPIStream(ctx context.Context, command CommandContext, req *larkcore.ApiReq, options ...client.Option) (*http.Response, error) { + apiClient, err := command.APIClient() + if err != nil { + return nil, typedOrInternal(err) + } + base := []client.Option{client.WithHeaders(cmdutil.BaseSecurityHeaders())} + if headers := cmdutil.ShortcutHeaders(ctx); headers != nil { + base = append(base, client.WithHeaders(headers)) + } + response, err := apiClient.DoStream(ctx, req, core.Identity(command.Identity()), append(base, options...)...) + if err != nil { + return nil, typedOrInternal(err) + } + return response, nil +} + +// CallTypedRawAPI mirrors RuntimeContext.RawAPI for Typed hooks that must +// inspect the complete legacy API envelope themselves. +func CallTypedRawAPI(ctx context.Context, command CommandContext, method, apiPath string, params map[string]interface{}, data any) (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) + } + result, err := apiClient.CallAPI(ctx, request) + if err != nil { + return nil, typedOrInternal(err) + } + return result, nil +} + +func typedClassifyContext(command CommandContext) 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..939e4df342 --- /dev/null +++ b/shortcuts/common/typed_api_test.go @@ -0,0 +1,29 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "testing" + + "github.com/larksuite/cli/internal/core" + "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) + } +} diff --git a/shortcuts/common/typed_authorization_test.go b/shortcuts/common/typed_authorization_test.go new file mode 100644 index 0000000000..45ccbd6491 --- /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 Definition[compilerArgs, compilerData], identity core.Identity, scopes string) typedCommandContext { + t.Helper() + command := Define(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[IdentityBot] = IdentityAuthorization{ + ConditionalScopes: []ConditionalScope{{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[IdentityUser] + authorization.ConditionalScopes = append(authorization.ConditionalScopes, ConditionalScope{ + Scopes: []string{"fixture:enrich"}, When: "optional detail enrichment runs", Requirement: ScopeBestEffort, + }) + definition.Metadata.Authorization.Identities[IdentityUser] = authorization + + factory, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{}) + service := &cobra.Command{Use: "fixture"} + Define(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..6850f4fe98 --- /dev/null +++ b/shortcuts/common/typed_binder.go @@ -0,0 +1,637 @@ +// 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, StageSourcePreRun); 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 + for _, alias := range field.cli.Aliases { + if alias.Mode != AliasIndependent { + 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 AliasCanonicalWins: + if !canonicalSet { + value = aliasRaw + set = true + } + case AliasErrorIfBoth: + if canonicalSet { + return nil, false, typedFieldValidation(field, "cannot be used together with --%s", alias.Name) + } + value, set = aliasRaw, true + case AliasTrimmedEqualOrError: + if canonicalSet { + if strings.TrimSpace(fmt.Sprint(canonicalRaw)) != 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)", + field.name, alias.Name, field.name, 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", + field.name, alias.Name).WithParam("--" + alias.Name) + } + value = strings.TrimSpace(fmt.Sprint(canonicalRaw)) + } else { + value, set = aliasRaw, true + } + } + } + return value, set, nil +} + +func readPFlagValue(runtime *RuntimeContext, name string, field compiledInputField) (any, error) { + t := indirectType(field.valueType) + if field.cli.Encoding == EncodingJSON { + 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 EncodingRepeated: + return runtime.Cmd.Flags().GetStringArray(name) + case EncodingCommaOrRepeated: + 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 == EncodingJSON { + 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()) && 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.(OneOfShape); ok { + for _, variant := range one.Variants { + if _, null := variant.(NullShape); !null { + shape = variant + break + } + } + } + switch constraint := shape.(type) { + case StringShape: + 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 IntegerShape: + 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 NumberShape: + 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 ArrayShape: + v := reflect.ValueOf(value) + for v.Kind() == reflect.Pointer { + v = v.Elem() + } + 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 ValueShape, path string) error { + switch constraint := shape.(type) { + case anyJSONShape: + return nil + case OneOfShape: + 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 NullShape: + if value != nil { + return fmt.Errorf("%s must be null", path) + } + return nil + case ConstShape: + 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 StringShape: + 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 BooleanShape: + 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 IntegerShape: + 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 NumberShape: + 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 ArrayShape: + 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 ObjectShape: + object, ok := value.(map[string]any) + if !ok { + return fmt.Errorf("%s must be an object", path) + } + fields := make(map[string]ValueField, 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 RelationStage) 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 == PresenceExplicit { + 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 RelationExactlyOne: + invalid = count != 1 + case RelationAtLeastOne: + invalid = count == 0 + case RelationCoOccur: + invalid = count != 0 && count != len(present) + case RelationRequires: + invalid = present[0] && !present[1] + case RelationConflicts: + invalid = count > 1 + } + if invalid { + param := names[0] + switch relation.kind { + case RelationExactlyOne: + return errs.NewValidationError(errs.SubtypeInvalidArgument, "provide exactly one of %s", strings.Join(names, " or ")).WithParam(param) + case RelationAtLeastOne: + return errs.NewValidationError(errs.SubtypeInvalidArgument, "provide at least one of %s", strings.Join(names, " or ")).WithParam(param) + case RelationCoOccur: + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s must be provided together", strings.Join(names, " and ")).WithParam(param) + case RelationRequires: + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s requires %s", names[0], names[1]).WithParam(param) + case RelationConflicts: + 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..3accaca63f --- /dev/null +++ b/shortcuts/common/typed_binder_benchmark_test.go @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "reflect" + "testing" +) + +type binderBenchmarkArgs struct { + Value 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 = 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..cae138f7b2 --- /dev/null +++ b/shortcuts/common/typed_compile_args.go @@ -0,0 +1,630 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//nolint:forbidigo // Compiler diagnostics are registration-time programmer errors consumed by Define's panic boundary, not command-facing failures. +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_-]*$`) +) + +var providedPkgPath = reflect.TypeFor[Provided[any]]().PkgPath() + +func compileInput(argsType reflect.Type, definition InputDefinition) ([]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]InputField, 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]InputField) 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 ValueShape + if supplement, ok := supplements[flagName]; !ok || supplement.Shape == nil { + shape, err = shapeForType(valueType, schema, true) + 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) { + if t.Kind() != reflect.Struct || t.PkgPath() != providedPkgPath || !strings.HasPrefix(t.Name(), "Provided[") { + 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 InputField) 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") + } + if err := validateShape(supplement.Shape, "InputField.Shape"); err != nil { + return err + } + if !shapeCompatibleWithType(supplement.Shape, field.valueType) { + return fmt.Errorf("InputField.Shape %T is incompatible with Go type %s", supplement.Shape, field.valueType) + } + field.shape = supplement.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([]FlagAlias(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([]ValueSource(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[ValueSource]struct{}) + for _, source := range field.cli.ValueSources { + if source != SourceFlag && source != SourceFile && source != SourceStdin { + 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[SourceFlag]; !ok { + return fmt.Errorf("ValueSources must include flag") + } + if (len(seenSources) > 1) && indirectKind(field.valueType) != reflect.String && field.cli.Encoding != EncodingJSON { + 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 EncodingRepeated: + if kind != reflect.Slice && kind != reflect.Array { + return fmt.Errorf("encoding repeated requires an array or slice") + } + if indirectType(field.valueType).Elem().Kind() == reflect.Struct || indirectType(field.valueType).Elem().Kind() == reflect.Map { + return fmt.Errorf("encoding repeated only supports scalar arrays") + } + if field.nullable != nil { + return fmt.Errorf("encoding repeated does not allow nullable/nonnullable") + } + case EncodingCommaOrRepeated: + 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 EncodingJSON: + 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 AliasNormalize: + 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 AliasIndependent: + switch alias.Conflict { + case AliasCanonicalWins, AliasErrorIfBoth: + case AliasTrimmedEqualOrError: + 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 InputDefault + enum []string + format string + minLength *int + maxLength *int + minimum *float64 + maximum *float64 + minItems *int + maxItems *int +} + +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 = InputDefault{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) (CLIInput, error) { + var result CLIInput + 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, ValueSource(source)) + } + case "encoding": + result.Encoding = CLIEncoding(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 ValueShape, 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 OneOfShape: + for _, variant := range value.Variants { + if !shapeCompatibleWithType(variant, target) { + return false + } + } + return true + case NullShape: + return isNilCapable(target) + case ConstShape: + return valueAssignableTo(value.Value, target) == nil + case StringShape: + return base.Kind() == reflect.String + case BooleanShape: + return base.Kind() == reflect.Bool + case IntegerShape: + return isIntegerKind(base.Kind()) + case NumberShape: + return base.Kind() == reflect.Float32 || base.Kind() == reflect.Float64 + case ArrayShape: + return base.Kind() == reflect.Slice || base.Kind() == reflect.Array + case ObjectShape: + 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..477e57929b --- /dev/null +++ b/shortcuts/common/typed_compile_contract.go @@ -0,0 +1,268 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//nolint:forbidigo // Compiler diagnostics are registration-time programmer errors consumed by Define's panic boundary, not command-facing failures. +package common + +import ( + "encoding/json" + "fmt" + "strings" +) + +func compileRelations(definitions []Relation, 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 RelationExactlyOne, RelationAtLeastOne, RelationCoOccur, RelationConflicts: + case RelationRequires: + 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 != PresenceExplicit && definition.Presence != PresenceNonZero { + return nil, fmt.Errorf("Input.Relations[%d].Presence %q is invalid", i, definition.Presence) + } + if definition.Stage != StageSourcePreRun && definition.Stage != StageAfterPrepare { + 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 AuthorizationDefinition, 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 OutputDefinition, dataShape ValueShape) error { + switch definition.Mode { + case OutputGeneric, OutputFixedJSON: + default: + return fmt.Errorf("Output.Mode %q is invalid", definition.Mode) + } + partial := definition.Outcomes.PartialFailure + if partial != nil { + if partial.ExitCode <= 0 { + return fmt.Errorf("Output.Outcomes.PartialFailure.ExitCode must be non-zero") + } + if failed := partial.FailedItems; failed != nil { + itemsShape, err := resolveShapePointer(dataShape, failed.ItemsPath) + if err != nil { + return fmt.Errorf("Output partial failed items path %q: %w", failed.ItemsPath, err) + } + array, ok := unwrapArray(itemsShape) + if !ok { + return fmt.Errorf("Output partial failed items path %q must identify an array", failed.ItemsPath) + } + for _, path := range failed.IdentityPaths { + if _, err := resolveShapePointer(array.Items, path); err != nil { + return fmt.Errorf("Output partial identity path %q: %w", path, err) + } + } + if failed.AllItems { + if failed.StatePath != "" || len(failed.FailedValues) > 0 { + return fmt.Errorf("Output partial FailedItems.AllItems conflicts with StatePath/FailedValues") + } + } else { + if failed.StatePath == "" || len(failed.FailedValues) == 0 { + return fmt.Errorf("Output partial FailedItems requires AllItems or StatePath with FailedValues") + } + stateShape, err := resolveShapePointer(array.Items, failed.StatePath) + if err != nil { + return fmt.Errorf("Output partial state path %q: %w", failed.StatePath, err) + } + for i, value := range failed.FailedValues { + if err := valueCompatibleWithShape(value, stateShape); err != nil { + return fmt.Errorf("Output partial FailedValues[%d]: %w", i, err) + } + } + } + } + } + artifactNames := make(map[string]struct{}) + for i, artifact := range definition.Artifacts { + if strings.TrimSpace(artifact.Name) == "" { + return fmt.Errorf("Output.Artifacts[%d].Name is required", i) + } + if _, duplicate := artifactNames[artifact.Name]; duplicate { + return fmt.Errorf("Output.Artifacts contains duplicate name %q", artifact.Name) + } + artifactNames[artifact.Name] = struct{}{} + if artifact.PathField == "" { + return fmt.Errorf("Output.Artifacts[%d].PathField is required", i) + } + items, err := resolveShapePointer(dataShape, artifact.ItemsPath) + if err != nil { + return fmt.Errorf("Output.Artifacts[%d].ItemsPath %q: %w", i, artifact.ItemsPath, err) + } + itemShape := items + if array, ok := unwrapArray(items); ok { + itemShape = array.Items + } + for label, path := range map[string]string{"PathField": artifact.PathField, "MediaTypeField": artifact.MediaTypeField, "SizeField": artifact.SizeField} { + if path == "" { + continue + } + resolved, err := resolveShapePointer(itemShape, path) + if err != nil { + return fmt.Errorf("Output.Artifacts[%d].%s %q: %w", i, label, path, err) + } + switch label { + case "PathField", "MediaTypeField": + if !shapeHasType(resolved, "string") { + return fmt.Errorf("Output.Artifacts[%d].%s %q must identify a string", i, label, path) + } + case "SizeField": + if !shapeHasType(resolved, "integer") { + return fmt.Errorf("Output.Artifacts[%d].SizeField %q must identify an integer", i, path) + } + } + } + } + return nil +} + +func resolveShapePointer(shape ValueShape, pointer string) (ValueShape, 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) + } + object, ok := shapeAsObject(current) + if !ok { + return nil, fmt.Errorf("segment %q traverses non-object shape", name) + } + found := false + for _, field := range object.Fields { + if field.Name == name { + current = field.Shape + found = true + break + } + } + if !found { + return nil, fmt.Errorf("field %q does not exist", name) + } + } + return current, nil +} + +func shapeAsObject(shape ValueShape) (ObjectShape, bool) { + if object, ok := shape.(ObjectShape); ok { + return object, true + } + if one, ok := shape.(OneOfShape); ok { + for _, variant := range one.Variants { + if object, ok := variant.(ObjectShape); ok { + return object, true + } + } + } + return ObjectShape{}, false +} +func unwrapArray(shape ValueShape) (ArrayShape, bool) { + if array, ok := shape.(ArrayShape); ok { + return array, true + } + if one, ok := shape.(OneOfShape); ok { + for _, variant := range one.Variants { + if array, ok := variant.(ArrayShape); ok { + return array, true + } + } + } + return ArrayShape{}, false +} +func shapeHasType(shape ValueShape, want string) bool { + switch value := shape.(type) { + case StringShape: + return want == "string" + case IntegerShape: + return want == "integer" + case NumberShape: + return want == "number" + case BooleanShape: + return want == "boolean" + case ArrayShape: + return want == "array" + case ObjectShape: + return want == "object" + case OneOfShape: + for _, variant := range value.Variants { + if shapeHasType(variant, want) { + return true + } + } + } + return false +} + +func valueCompatibleWithShape(value any, shape ValueShape) 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..de3761e3b9 --- /dev/null +++ b/shortcuts/common/typed_compile_data.go @@ -0,0 +1,430 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//nolint:forbidigo // Compiler diagnostics are registration-time programmer errors consumed by Define's panic boundary, not command-facing failures. +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 DataDefinition) (ValueShape, 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 { + if err := validateShape(definition.Shape, "Output.Data.Shape"); err != nil { + return nil, err + } + return definition.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") + 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 +} + +func shapeForType(t reflect.Type, schema schemaTag, input bool) (ValueShape, error) { + baseType := t + for baseType.Kind() == reflect.Pointer { + baseType = baseType.Elem() + } + var shape ValueShape + switch baseType.Kind() { + case reflect.String: + stringShape := StringShape{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 := BooleanShape{} + 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 := IntegerShape{} + 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 := NumberShape{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 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) + if err != nil { + return nil, fmt.Errorf("array item: %w", err) + } + shape = ArrayShape{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()) + 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 = OneOfShape{Variants: []ValueShape{shape, NullShape{}}} + } + return shape, nil +} + +func compileStructShape(t reflect.Type, input bool, path string) (ObjectShape, error) { + shape := ObjectShape{} + 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 ObjectShape{}, 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 ObjectShape{}, 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 ObjectShape{}, fmt.Errorf("%s field %s has unsupported json option %q", path, field.Name, option) + } + } + if previous, exists := seen[name]; exists { + return ObjectShape{}, 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 ObjectShape{}, fmt.Errorf("%s field %s (%s): %w", path, field.Name, name, err) + } + if !input && schema.defaultValue.Set { + return ObjectShape{}, fmt.Errorf("%s field %s (%s): Data field cannot declare default", path, field.Name, name) + } + if schema.required && omitempty { + return ObjectShape{}, fmt.Errorf("%s field %s (%s): required Data field cannot use omitempty", path, field.Name, name) + } + if schema.optional && !omitempty { + return ObjectShape{}, fmt.Errorf("%s field %s (%s): optional Data field must use omitempty", path, field.Name, name) + } + if isNilCapable(field.Type) && schema.nullable == nil { + return ObjectShape{}, 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 ObjectShape{}, fmt.Errorf("%s field %s (%s): description is required via doc", path, field.Name, name) + } + fieldShape, err := shapeForType(field.Type, schema, input) + if err != nil { + return ObjectShape{}, fmt.Errorf("%s field %s (%s): %w", path, field.Name, name, err) + } + shape.Fields = append(shape.Fields, ValueField{Name: name, Description: description, Required: schema.required, Shape: fieldShape}) + } + return shape, nil +} + +func validateShape(shape ValueShape, path string) error { + if shape == nil { + return fmt.Errorf("%s is nil", path) + } + switch value := shape.(type) { + case anyJSONShape: + case StringShape: + 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 BooleanShape: + case IntegerShape: + if value.Minimum != nil && value.Maximum != nil && *value.Minimum > *value.Maximum { + return fmt.Errorf("%s minimum exceeds maximum", path) + } + case NumberShape: + 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 NullShape: + case ConstShape: + if _, err := json.Marshal(value.Value); err != nil { + return fmt.Errorf("%s const is not JSON-encodable: %w", path, err) + } + case ArrayShape: + 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 ObjectShape: + 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 OneOfShape: + 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 *ObjectShape, override DataField) 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 *ValueField) 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") + } + if err := validateShape(override.Shape, "DataField.Shape"); err != nil { + return err + } + field.Shape = override.Shape + } + return nil + }) +} + +func mutateObjectField(object *ObjectShape, parts []string, mutate func(*ValueField) 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 ObjectShape: + err := mutateObjectField(&nested, parts[1:], mutate) + field.Shape = nested + return err + case OneOfShape: + for variantIndex, variant := range nested.Variants { + if nestedObject, ok := variant.(ObjectShape); 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 ValueShape) bool { + switch value := shape.(type) { + case StringShape: + return len(value.Enum) > 0 || value.Format != "" || value.MinLength != nil || value.MaxLength != nil + case BooleanShape: + return len(value.Enum) > 0 + case IntegerShape: + return len(value.Enum) > 0 || value.Minimum != nil || value.Maximum != nil + case NumberShape: + return len(value.Enum) > 0 || value.Minimum != nil || value.Maximum != nil + case ArrayShape: + return value.MinItems != nil || value.MaxItems != nil + case OneOfShape: + return true + default: + return false + } +} +func shapeExplicitlyNullable(shape ValueShape) bool { + one, ok := shape.(OneOfShape) + if !ok { + return false + } + for _, variant := range one.Variants { + if _, ok := variant.(NullShape); 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..c465142b48 --- /dev/null +++ b/shortcuts/common/typed_compile_output.go @@ -0,0 +1,43 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//nolint:forbidigo // Registration-time compiler diagnostics are programmer errors surfaced through Define's panic boundary. +package common + +import ( + "fmt" + "sort" +) + +func validateOutputHooks(definition OutputDefinition, 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 == OutputFixedJSON { + 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 generic compiler inspect nil renderer values without +// adapting Args/Data hooks or exposing the private compiled hook type. +type RendererMarker struct{ isNil bool } + +func rendererMarkers[Data any](renderers map[string]Renderer[Data]) map[string]RendererMarker { + markers := make(map[string]RendererMarker, len(renderers)) + for name, renderer := range renderers { + markers[name] = RendererMarker{isNil: renderer == nil} + } + return markers +} diff --git a/shortcuts/common/typed_compiler.go b/shortcuts/common/typed_compiler.go new file mode 100644 index 0000000000..4306b79ac0 --- /dev/null +++ b/shortcuts/common/typed_compiler.go @@ -0,0 +1,376 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//nolint:forbidigo // Compiler diagnostics are registration-time programmer errors converted to contextual Define panics, not command-facing failures. +package common + +import ( + "context" + "encoding/json" + "fmt" + "io" + "reflect" + "strings" +) + +// Define compiles a Typed Shortcut definition. Invalid definitions are +// programmer errors and panic during registration; no partial legacy fallback +// is returned. +func Define[Args any, Data any](definition Definition[Args, Data]) Shortcut { + compiled, err := compileDefinition(definition) + if err != nil { + service := strings.TrimSpace(definition.Metadata.Service) + command := strings.TrimSpace(definition.Metadata.Command) + if service == "" { + service = "" + } + if command == "" { + command = "" + } + panic(fmt.Sprintf("typed shortcut %s %s: %v", service, command, err)) + } + shortcut := shortcutFromCompiled(compiled) + if err := validateTypedFlagMountPlan(compiled, shortcut.PrintFlagSchema != nil, Risk(shortcut.Risk)); err != nil { + panic(fmt.Sprintf("typed shortcut %s %s: %v", compiled.metadata.Service, compiled.metadata.Command, err)) + } + return shortcut +} + +func compileDefinition[Args any, Data any](definition Definition[Args, Data]) (*compiledCommand, error) { + metadata := normalizeCommandMetadata(definition.Metadata) + if err := validateCommandMetadata(metadata); err != nil { + return nil, err + } + argsType := reflect.TypeFor[Args]() + dataType := reflect.TypeFor[Data]() + fields, fieldByName, err := compileInput(argsType, definition.Input) + if err != nil { + return nil, err + } + relations, err := compileRelations(definition.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, definition.Output.Data) + if err != nil { + return nil, err + } + if definition.Hooks.Execute == nil { + return nil, fmt.Errorf("Hooks.Execute is required") + } + if err := validateOutput(definition.Output, dataShape); err != nil { + return nil, err + } + if err := validateOutputHooks(definition.Output, rendererMarkers(definition.Hooks.Renderers)); err != nil { + return nil, err + } + command := &compiledCommand{ + metadata: metadata, + argsType: argsType, + dataType: dataType, + fields: fields, + fieldByName: fieldByName, + relations: relations, + dataShape: dataShape, + output: definition.Output, + hooks: adaptHooks(definition.Hooks), + } + command.contract = buildTypedSchemaContract(command) + return command, nil +} + +func normalizeCommandMetadata(metadata CommandMetadata) CommandMetadata { + 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 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 = ScopeRequired + } + } + identities[identity] = authorization + } + metadata.Authorization.Identities = identities + metadata.Authorization.IdentityOrder = append([]Identity(nil), metadata.Authorization.IdentityOrder...) + metadata.Tips = append([]string(nil), metadata.Tips...) + return metadata +} + +func validateCommandMetadata(metadata CommandMetadata) error { + if strings.TrimSpace(metadata.Service) == "" { + return fmt.Errorf("Metadata.Service is required") + } + if strings.TrimSpace(metadata.Command) == "" { + return fmt.Errorf("Metadata.Command is required") + } + if !strings.HasPrefix(metadata.Command, "+") { + return fmt.Errorf("Metadata.Command %q must start with '+'", metadata.Command) + } + if strings.TrimSpace(metadata.Description) == "" { + return fmt.Errorf("Metadata.Description is required") + } + for i, tip := range metadata.Tips { + if strings.TrimSpace(tip) == "" { + return fmt.Errorf("Metadata.Tips[%d] must not be blank", i) + } + } + switch metadata.Risk { + case RiskRead, RiskWrite, RiskHighRiskWrite: + 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 != IdentityUser && identity != IdentityBot { + 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 ScopeRequired, ScopeBestEffort: + 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[Identity]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 adaptHooks[Args any, Data any](hooks Hooks[Args, Data]) compiledHooks { + adapted := compiledHooks{newArgs: func() any { return new(Args) }} + if hooks.Normalize != nil { + adapted.normalize = func(ctx context.Context, cc CommandContext, args any) error { + return hooks.Normalize(ctx, cc, args.(*Args)) + } + } + if hooks.Validate != nil { + adapted.validate = func(ctx context.Context, cc CommandContext, args any) error { + return hooks.Validate(ctx, cc, args.(*Args)) + } + } + if hooks.DryRun != nil { + adapted.dryRun = func(ctx context.Context, cc CommandContext, args any) *DryRunAPI { + return hooks.DryRun(ctx, cc, args.(*Args)) + } + } + adapted.execute = func(ctx context.Context, cc CommandContext, args any) (compiledResult, error) { + result, err := hooks.Execute(ctx, cc, args.(*Args)) + return compiledResult{data: result.Data, outcome: result.Outcome, meta: result.Meta}, err + } + if len(hooks.Renderers) > 0 { + adapted.renderers = make(map[string]func(io.Writer, any) error, len(hooks.Renderers)) + for name, renderer := range hooks.Renderers { + r := renderer + adapted.renderers[name] = func(w io.Writer, data any) error { return r(w, data.(Data)) } + } + } + return adapted +} + +func shortcutFromCompiled(compiled *compiledCommand) Shortcut { + metadata := compiled.metadata + shortcut := Shortcut{ + Service: metadata.Service, + Command: metadata.Command, + Description: metadata.Description, + Risk: string(metadata.Risk), + Hidden: metadata.Hidden, + Tips: append([]string(nil), metadata.Tips...), + typed: compiled, + } + identities := make([]string, 0, len(metadata.Authorization.Identities)) + identityOrder := metadata.Authorization.IdentityOrder + if len(identityOrder) == 0 { + identityOrder = []Identity{IdentityUser, IdentityBot} + } + 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 IdentityUser: + shortcut.UserScopes = scopes + shortcut.ConditionalUserScopes = conditional + case IdentityBot: + shortcut.BotScopes = scopes + shortcut.ConditionalBotScopes = conditional + } + } + } + shortcut.AuthTypes = identities + shortcut.Flags = legacyFlagsFromCompiled(compiled.fields) + shortcut.PrintFlagSchema = typedFlagSchemaPrinter(compiled) + return shortcut +} + +func flattenConditionalScopes(definitions []ConditionalScope) []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 == AliasNormalize { + flag.Aliases = append(flag.Aliases, alias.Name) + } + } + if stringShape, ok := field.shape.(StringShape); 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 != AliasIndependent { + 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 []FlagAlias) bool { + for _, alias := range aliases { + if alias.Mode == AliasIndependent { + 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 EncodingRepeated: + if t.Elem().Kind() == reflect.String { + return "string_array" + } + case EncodingCommaOrRepeated: + if isIntegerKind(t.Elem().Kind()) { + return "int_array" + } + return "string_slice" + } + } + return "string" +} + +func legacyInputSources(sources []ValueSource) []string { + var result []string + for _, source := range sources { + switch source { + case SourceFile: + result = append(result, File) + case SourceStdin: + 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..91f4e006c7 --- /dev/null +++ b/shortcuts/common/typed_compiler_invalid_test.go @@ -0,0 +1,182 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "math" + "reflect" + "strings" + "testing" +) + +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 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 InputDefinition + want string + }{ + {"not struct", reflect.TypeFor[string](), InputDefinition{}, "Args must"}, + {"missing marker", reflect.TypeFor[struct{ Value string }](), InputDefinition{}, "exactly one"}, + {"tagged unexported field", reflect.TypeFor[struct { + value string `flag:"value" schema:"optional" doc:"value"` + }](), InputDefinition{}, "unexported"}, + {"both markers", reflect.TypeFor[struct { + Value string `flag:"value" arg:"local"` + }](), InputDefinition{}, "exactly one"}, + {"unknown arg", reflect.TypeFor[struct { + Value string `arg:"derived"` + }](), InputDefinition{}, "unknown arg mode"}, + {"complex missing encoding", reflect.TypeFor[struct { + Values []string `flag:"values" schema:"optional" doc:"values"` + }](), InputDefinition{}, "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"` + }](), InputDefinition{}, "requires exactly 2 items"}, + {"json nil unspecified", reflect.TypeFor[struct { + Values []string `flag:"values" schema:"optional" cli:"encoding=json" doc:"values"` + }](), InputDefinition{}, "must declare nullable"}, + {"file on int", reflect.TypeFor[struct { + Value int `flag:"value" schema:"optional" cli:"sources=flag|file" doc:"value"` + }](), InputDefinition{}, "file/stdin"}, + {"unknown supplement", reflect.TypeFor[struct { + Value string `flag:"value" schema:"optional" doc:"value"` + }](), InputDefinition{Fields: []InputField{{Name: "other"}}}, "unknown flag"}, + {"description conflict", reflect.TypeFor[struct { + Value string `flag:"value" schema:"optional" doc:"value"` + }](), InputDefinition{Fields: []InputField{{Name: "value", Description: "again"}}}, "both doc"}, + {"oneOf includes unrepresentable variant", reflect.TypeFor[struct { + Value string `flag:"value" schema:"optional" doc:"value"` + }](), InputDefinition{Fields: []InputField{{Name: "value", Shape: OneOfShape{Variants: []ValueShape{StringShape{}, IntegerShape{}}}}}}, "incompatible with Go type"}, + {"alias missing conflict", reflect.TypeFor[struct { + Value string `flag:"value" schema:"optional" doc:"value"` + }](), InputDefinition{Fields: []InputField{{Name: "value", CLI: CLIInput{Aliases: []FlagAlias{{Name: "old", Mode: AliasIndependent}}}}}}, "must declare"}, + {"deprecated normalize alias", reflect.TypeFor[struct { + Value string `flag:"value" schema:"optional" doc:"value"` + }](), InputDefinition{Fields: []InputField{{Name: "value", CLI: CLIInput{Aliases: []FlagAlias{{Name: "old", Mode: AliasNormalize, 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, DataDefinition{}) + 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 ValueShape + want string + }{ + {OneOfShape{Variants: []ValueShape{StringShape{}}}, "at least two"}, + {ArrayShape{}, "Items is required"}, + {ObjectShape{Fields: []ValueField{{Name: "x", Shape: StringShape{}}}}, "Description is required"}, + {ObjectShape{AdditionalPropertiesShape: StringShape{}}, "requires AdditionalProperties"}, + {NumberShape{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..ed18725e60 --- /dev/null +++ b/shortcuts/common/typed_compiler_test.go @@ -0,0 +1,487 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "context" + "encoding/json" + "io" + "reflect" + "strings" + "testing" +) + +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 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() Definition[compilerArgs, compilerData] { + return Definition[compilerArgs, compilerData]{ + Metadata: CommandMetadata{ + Service: "fixture", Command: "+compile", Description: "Compile a fixture", Risk: RiskWrite, + Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{ + IdentityUser: { + RequiredScopes: []string{"fixture:write"}, + ConditionalScopes: []ConditionalScope{{Scopes: []string{"fixture:read"}, When: "--payload selects the read path", Params: []string{"payload"}}}, + }, + }}, + }, + Input: InputDefinition{ + Fields: []InputField{{Name: "token", CLI: CLIInput{Aliases: []FlagAlias{{Name: "legacy-token", Mode: AliasIndependent, Conflict: AliasTrimmedEqualOrError, Hidden: true}}}}}, + Relations: []Relation{{Kind: RelationRequires, Params: []string{"payload", "token"}, Presence: PresenceExplicit, Stage: StageSourcePreRun}}, + }, + Output: OutputDefinition{ + Outcomes: OutcomeDefinition{PartialFailure: &PartialFailureDefinition{ + ExitCode: 3, + FailedItems: &FailedItemDefinition{ItemsPath: "/items", IdentityPaths: []string{"/id"}, StatePath: "/state", FailedValues: []JSONValue{"failed"}}, + }}, + Mode: OutputFixedJSON, + }, + Hooks: Hooks[compilerArgs, compilerData]{Execute: func(context.Context, CommandContext, *compilerArgs) (Result[compilerData], error) { + return Success(compilerData{}), nil + }}, + } +} + +func TestDefineCompilesTypedContract(t *testing.T) { + shortcut := Define(validCompilerDefinition()) + if shortcut.typed == nil { + t.Fatal("Define() 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[IdentityUser].ConditionalScopes[0] + if conditional.Requirement != ScopeRequired || 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 != EncodingJSON || 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.(ObjectShape); !ok { + t.Fatalf("data shape = %T, want ObjectShape", shortcut.typed.dataShape) + } +} + +func TestDefineClonesTipsAndRejectsBlankTips(t *testing.T) { + definition := validCompilerDefinition() + definition.Metadata.Tips = []string{"first tip", " second tip "} + shortcut := Define(definition) + definition.Metadata.Tips[0] = "mutated" + want := []string{"first tip", " second tip "} + if got := shortcut.Tips; !reflect.DeepEqual(got, want) { + t.Fatalf("Shortcut.Tips = %#v, want %#v", got, want) + } + if got := shortcut.typed.metadata.Tips; !reflect.DeepEqual(got, want) { + t.Fatalf("compiled Metadata.Tips = %#v, want %#v", got, want) + } + + definition = validCompilerDefinition() + definition.Metadata.Tips = []string{" \t "} + if _, err := compileDefinition(definition); err == nil || !strings.Contains(err.Error(), "Metadata.Tips[0]") { + t.Fatalf("compileDefinition() error = %v, want blank Tip rejection", err) + } +} + +func TestCompiledTypedSchemaContract(t *testing.T) { + definition := validCompilerDefinition() + definition.Output.Meta = ResultMetaDefinition{Count: true, Pagination: true} + contract := Define(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 != RiskWrite || !equalStrings(contract.Meta.AccessTokens, []string{"user"}) { + t.Fatalf("contract metadata = %#v", contract.Meta) + } + conditional := contract.Meta.Authorization.Identities[IdentityUser].ConditionalScopes[0] + if conditional.When != "--payload selects the read path" || conditional.Requirement != ScopeRequired || !equalStrings(conditional.Params, []string{"payload"}) { + t.Fatalf("conditional authorization contract = %#v", conditional) + } + if !contract.Meta.Outcomes.PartialFailure.Supported || contract.Meta.Outcomes.PartialFailure.ExitCode != 3 { + t.Fatalf("partial outcome = %#v", contract.Meta.Outcomes.PartialFailure) + } + if contract.Meta.ResultMeta == nil { + t.Fatal("result_meta contract is nil") + } + count := contract.Meta.ResultMeta.Properties["count"] + if count.Type != "integer" || count.Minimum == nil || *count.Minimum != 0 { + t.Fatalf("result meta count = %#v", count) + } + 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 TestCompileOutputAcceptsResultLevelPartial(t *testing.T) { + definition := validCompilerDefinition() + definition.Output.Outcomes.PartialFailure.FailedItems = nil + shortcut := Define(definition) + partial := shortcut.typed.contract.Meta.Outcomes.PartialFailure + if !partial.Supported || partial.ExitCode != 3 || partial.FailedItems != nil { + t.Fatalf("result-level partial contract = %#v", partial) + } +} + +func TestCompiledSchemaRecordsJSONHTMLEscapingPolicy(t *testing.T) { + definition := validCompilerDefinition() + contract := Define(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 = Define(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 = OutputGeneric + definition.Hooks.Renderers = map[string]Renderer[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 := Define(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 = OutputGeneric + generic := Define(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 TestDefinePreservesCollectionDefaultForCobraAndMapBinder(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 := Definition[args, data]{ + Metadata: CommandMetadata{Service: "fixture", Command: "+collection-default", Description: "collection default", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, + Input: InputDefinition{Fields: []InputField{{Name: "values", Default: InputDefault{Set: true, Value: []string{"a", "b"}}}}}, + Hooks: Hooks[args, data]{Execute: func(context.Context, CommandContext, *args) (Result[data], error) { + return Success(data{OK: true}), nil + }}, + } + shortcut := Define(definition) + if got := shortcut.Flags[0].Default; got != `["a","b"]` { + t.Fatalf("legacy default = %q", got) + } + bound, err := bindTypedMap(shortcut.typed, nil) + if err != nil { + t.Fatal(err) + } + if got := bound.value.(*args).Values; !reflect.DeepEqual(got, []string{"a", "b"}) { + t.Fatalf("bound default = %#v", 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 := Definition[badArgs, data]{ + Metadata: validCompilerDefinition().Metadata, + Hooks: Hooks[badArgs, data]{Execute: func(context.Context, CommandContext, *badArgs) (Result[data], error) { return Success(data{}), nil }}, + } + defer func() { + panicValue := recover() + if panicValue == nil { + t.Fatal("Define() 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) + } + } + }() + _ = Define(definition) +} + +func TestCompileDefinitionRejectsInvalidContracts(t *testing.T) { + tests := []struct { + name string + mutate func(*Definition[compilerArgs, compilerData]) + want string + }{ + {"missing service", func(d *Definition[compilerArgs, compilerData]) { d.Metadata.Service = "" }, "Metadata.Service is required"}, + {"unknown risk", func(d *Definition[compilerArgs, compilerData]) { d.Metadata.Risk = Risk("delete") }, "Metadata.Risk"}, + {"unknown relation param", func(d *Definition[compilerArgs, compilerData]) { d.Input.Relations[0].Params[1] = "missing" }, "unknown param --missing"}, + {"unknown conditional param", func(d *Definition[compilerArgs, compilerData]) { + auth := d.Metadata.Authorization.Identities[IdentityUser] + auth.ConditionalScopes[0].Params = []string{"missing"} + d.Metadata.Authorization.Identities[IdentityUser] = auth + }, "unknown param --missing"}, + {"scope both required and conditional", func(d *Definition[compilerArgs, compilerData]) { + auth := d.Metadata.Authorization.Identities[IdentityUser] + auth.ConditionalScopes[0].Scopes = []string{"fixture:write"} + d.Metadata.Authorization.Identities[IdentityUser] = auth + }, "already always required"}, + {"invalid conditional requirement", func(d *Definition[compilerArgs, compilerData]) { + auth := d.Metadata.Authorization.Identities[IdentityUser] + auth.ConditionalScopes[0].Requirement = ScopeRequirement("sometimes") + d.Metadata.Authorization.Identities[IdentityUser] = auth + }, "Requirement \"sometimes\" is invalid"}, + {"conditional params without when", func(d *Definition[compilerArgs, compilerData]) { + auth := d.Metadata.Authorization.Identities[IdentityUser] + auth.ConditionalScopes[0].When = "" + d.Metadata.Authorization.Identities[IdentityUser] = auth + }, "Params requires agent-readable When text"}, + {"hidden conditional param", func(d *Definition[compilerArgs, compilerData]) { + d.Input.Fields = append(d.Input.Fields, InputField{Name: "payload", CLI: CLIInput{Hidden: true}}) + }, "references hidden param --payload"}, + {"missing execute", func(d *Definition[compilerArgs, compilerData]) { d.Hooks.Execute = nil }, "Hooks.Execute is required"}, + {"invalid partial path", func(d *Definition[compilerArgs, compilerData]) { + d.Output.Outcomes.PartialFailure.FailedItems.ItemsPath = "/missing" + }, "field \"missing\" does not exist"}, + {"invalid pointer escaping", func(d *Definition[compilerArgs, compilerData]) { + d.Output.Outcomes.PartialFailure.FailedItems.ItemsPath = "/items/~2" + }, "invalid RFC 6901 escaping"}, + {"all-items state conflict", func(d *Definition[compilerArgs, compilerData]) { + d.Output.Outcomes.PartialFailure.FailedItems.AllItems = true + }, "AllItems conflicts"}, + {"missing failure discriminator", func(d *Definition[compilerArgs, compilerData]) { + d.Output.Outcomes.PartialFailure.FailedItems.StatePath = "" + d.Output.Outcomes.PartialFailure.FailedItems.FailedValues = nil + }, "requires AllItems"}, + {"failure discriminator outside state enum", func(d *Definition[compilerArgs, compilerData]) { + d.Output.Outcomes.PartialFailure.FailedItems.FailedValues = []JSONValue{"unknown"} + }, "must be one of: ok, failed"}, + {"artifact path field required", func(d *Definition[compilerArgs, compilerData]) { + d.Output.Artifacts = []ArtifactDefinition{{Name: "items", ItemsPath: "/items"}} + }, "PathField is required"}, + {"nil renderer", func(d *Definition[compilerArgs, compilerData]) { + d.Hooks.Renderers = map[string]Renderer[compilerData]{"pretty": nil} + }, "Hooks.Renderers[\"pretty\"] is nil"}, + {"table renderer", func(d *Definition[compilerArgs, compilerData]) { + d.Output.Mode = OutputGeneric + d.Hooks.Renderers = map[string]Renderer[compilerData]{"table": func(io.Writer, compilerData) error { return nil }} + }, "custom renderers are only supported for pretty"}, + {"fixed JSON renderer", func(d *Definition[compilerArgs, compilerData]) { + d.Hooks.Renderers = map[string]Renderer[compilerData]{"pretty": func(io.Writer, compilerData) error { return nil }} + }, "conflicts with Output.Mode \"fixed_json\""}, + {"invalid output mode", func(d *Definition[compilerArgs, compilerData]) { + d.Output.Mode = OutputMode("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 := ObjectShape{Fields: []ValueField{{Name: "value", Description: "custom value", Required: true, Shape: StringShape{}}}} + definition := Definition[args, data]{ + Metadata: CommandMetadata{Service: "fixture", Command: "+custom-json", Description: "custom JSON input", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, + Input: InputDefinition{Fields: []InputField{{Name: "payload", Shape: shape}}}, + Hooks: Hooks[args, data]{Execute: func(context.Context, CommandContext, *args) (Result[data], error) { + return Success(data{OK: true}), nil + }}, + } + shortcut := Define(definition) + bound, err := bindTypedMap(shortcut.typed, map[string]any{"payload": map[string]any{"value": "x"}}) + if err != nil { + t.Fatal(err) + } + if got := bound.value.(*args).Payload.Value; got != "x" { + t.Fatalf("payload value = %q", got) + } +} + +func TestCompileDataAcceptsCompleteExplicitShapeForDynamicData(t *testing.T) { + shape := ObjectShape{AdditionalProperties: true, AdditionalPropertiesShape: StringShape{}} + compiled, err := compileData(reflect.TypeFor[map[string]any](), DataDefinition{Shape: shape}) + if err != nil { + t.Fatal(err) + } + object, ok := compiled.(ObjectShape) + 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](), DataDefinition{}) + 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](), DataDefinition{Overrides: []DataField{{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](), DataDefinition{Overrides: []DataField{{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](), DataDefinition{Overrides: []DataField{{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.(ObjectShape) + child := root.Fields[0].Shape.(ObjectShape) + 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..b6235ea59b --- /dev/null +++ b/shortcuts/common/typed_contract.go @@ -0,0 +1,61 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "context" + "io" + "reflect" +) + +type compiledCommand struct { + metadata CommandMetadata + argsType reflect.Type + dataType reflect.Type + fields []compiledInputField + fieldByName map[string]int + relations []compiledRelation + dataShape ValueShape + output OutputDefinition + contract typedSchemaContract + hooks compiledHooks +} + +type compiledInputField struct { + name string + goName string + index []int + valueIndex []int + valueType reflect.Type + provided bool + required bool + nullable *bool + description string + shape ValueShape + shapeExplicit bool + defaultValue InputDefault + cli CLIInput +} + +type compiledRelation struct { + kind RelationKind + fields []int + presence PresenceMode + stage RelationStage +} + +type compiledResult struct { + data any + outcome OutcomeKind + meta *ResultMeta +} + +type compiledHooks struct { + newArgs func() any + normalize func(context.Context, CommandContext, any) error + validate func(context.Context, CommandContext, any) error + dryRun func(context.Context, CommandContext, any) *DryRunAPI + execute func(context.Context, CommandContext, 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..5a9f2ed739 --- /dev/null +++ b/shortcuts/common/typed_definition.go @@ -0,0 +1,193 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "context" + "io" + + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/client" + "github.com/larksuite/cli/internal/core" +) + +// JSONValue is a value representable by JSON encoding. +type JSONValue = any + +// Definition is the single source of truth for a Typed Shortcut. +// See TYPED_SHORTCUTS.md for the framework contract and migration guide. +type Definition[Args any, Data any] struct { + Metadata CommandMetadata + Input InputDefinition + Output OutputDefinition + Hooks Hooks[Args, Data] +} + +type CommandMetadata struct { + Service string + Command string + Description string + Risk Risk + Hidden bool + Tips []string + Authorization AuthorizationDefinition +} + +type Identity string +type Risk string + +const ( + IdentityUser Identity = "user" + IdentityBot Identity = "bot" + + RiskRead Risk = "read" + RiskWrite Risk = "write" + RiskHighRiskWrite Risk = "high-risk-write" +) + +type AuthorizationDefinition struct { + Identities map[Identity]IdentityAuthorization + IdentityOrder []Identity // optional CLI compatibility order; must contain each declared identity exactly once +} + +type IdentityAuthorization struct { + RequiredScopes []string `json:"required_scopes"` + ConditionalScopes []ConditionalScope `json:"conditional_scopes"` +} + +type ConditionalScope struct { + Scopes []string `json:"scopes"` + When string `json:"when,omitempty"` + Params []string `json:"params,omitempty"` + Requirement ScopeRequirement `json:"requirement"` +} + +type ScopeRequirement string + +const ( + ScopeRequired ScopeRequirement = "required" + ScopeBestEffort ScopeRequirement = "best_effort" +) + +type InputDefinition struct { + Fields []InputField + Relations []Relation +} + +type InputField struct { + Name string + Description string + Shape ValueShape + Default InputDefault + CLI CLIInput +} + +type InputDefault struct { + Set bool + Value JSONValue +} + +type CLIInput struct { + Aliases []FlagAlias + ValueSources []ValueSource + Encoding CLIEncoding + Hidden bool // compatibility-only primary flags omitted from default Help + Deprecated string // optional Cobra deprecation message for a primary flag +} + +type FlagAlias struct { + Name string + Mode FlagAliasMode + Conflict AliasConflictPolicy + Hidden bool + Deprecated bool +} + +type FlagAliasMode string +type AliasConflictPolicy string + +const ( + AliasNormalize FlagAliasMode = "normalize" + AliasIndependent FlagAliasMode = "independent" + + AliasCanonicalWins AliasConflictPolicy = "canonical_wins" + AliasErrorIfBoth AliasConflictPolicy = "error_if_both" + AliasTrimmedEqualOrError AliasConflictPolicy = "trimmed_equal_or_error" +) + +type ValueSource string + +const ( + SourceFlag ValueSource = "flag" + SourceFile ValueSource = "file" + SourceStdin ValueSource = "stdin" +) + +type CLIEncoding string + +const ( + EncodingRepeated CLIEncoding = "repeated" + EncodingCommaOrRepeated CLIEncoding = "comma_or_repeated" + EncodingJSON CLIEncoding = "json" +) + +// Provided preserves whether the caller explicitly supplied a value. +type Provided[T any] struct { + Value T + Set bool +} + +type Relation struct { + Kind RelationKind `json:"kind"` + Params []string `json:"params"` + Presence PresenceMode `json:"presence"` + Stage RelationStage `json:"stage"` +} + +type RelationKind string +type PresenceMode string +type RelationStage string + +const ( + RelationExactlyOne RelationKind = "exactly_one" + RelationAtLeastOne RelationKind = "at_least_one" + RelationCoOccur RelationKind = "co_occur" + RelationRequires RelationKind = "requires" + RelationConflicts RelationKind = "conflicts" + + PresenceExplicit PresenceMode = "explicit" + PresenceNonZero PresenceMode = "non_zero" + + StageSourcePreRun RelationStage = "source_pre_run" + StageAfterPrepare RelationStage = "after_prepare" +) + +type Hooks[Args any, Data any] struct { + Normalize func(context.Context, CommandContext, *Args) error + Validate func(context.Context, CommandContext, *Args) error + DryRun func(context.Context, CommandContext, *Args) *DryRunAPI + Execute func(context.Context, CommandContext, *Args) (Result[Data], error) + Renderers map[string]Renderer[Data] +} + +type Renderer[Data any] func(io.Writer, Data) error + +// CommandContext exposes only runtime capabilities available to Typed hooks. +type CommandContext interface { + Identity() 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 + + // RequireConditionalScopes checks scopes that the Definition declares as + // path-dependent for the selected identity. Domain code calls it only after + // it has determined that the path requiring those scopes will execute. + RequireConditionalScopes(scopes ...string) error +} diff --git a/shortcuts/common/typed_flag_collisions.go b/shortcuts/common/typed_flag_collisions.go new file mode 100644 index 0000000000..60f293ef30 --- /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 Risk) 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 == RiskHighRiskWrite { + 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 == RiskHighRiskWrite { + 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 == AliasNormalize { + 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..e5ed5dc63e --- /dev/null +++ b/shortcuts/common/typed_flag_collisions_test.go @@ -0,0 +1,174 @@ +// 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 Risk, input InputDefinition) Definition[Args, collisionData] { + return Definition[Args, collisionData]{ + Metadata: CommandMetadata{ + Service: "fixture", Command: "+collision", Description: "collision fixture", Risk: risk, + Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}, + }, + Input: input, + Hooks: Hooks[Args, collisionData]{Execute: func(context.Context, CommandContext, *Args) (Result[collisionData], error) { + return Success(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() { _ = Define(collisionDefinition[collisionDryRunArgs](RiskRead, InputDefinition{})) }, want: "framework dry-run execution"}, + {name: "as", run: func() { _ = Define(collisionDefinition[collisionAsArgs](RiskRead, InputDefinition{})) }, want: "framework identity selection"}, + {name: "jq", run: func() { _ = Define(collisionDefinition[collisionJQArgs](RiskRead, InputDefinition{})) }, want: "framework output filtering"}, + {name: "profile", run: func() { _ = Define(collisionDefinition[collisionProfileArgs](RiskRead, InputDefinition{})) }, want: "inherited profile selection"}, + {name: "help", run: func() { _ = Define(collisionDefinition[collisionHelpArgs](RiskRead, InputDefinition{})) }, want: "Cobra help"}, + {name: "high-risk yes", run: func() { _ = Define(collisionDefinition[collisionYesArgs](RiskHighRiskWrite, InputDefinition{})) }, want: "framework high-risk confirmation"}, + {name: "print-schema when introspection is active", run: func() { _ = Define(collisionDefinition[collisionPrintSchemaArgs](RiskRead, InputDefinition{})) }, want: "framework complex-input introspection"}, + {name: "flag-name when introspection is active", run: func() { _ = Define(collisionDefinition[collisionFlagNameArgs](RiskRead, InputDefinition{})) }, 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 := Define(collisionDefinition[collisionAllowedArgs](RiskWrite, InputDefinition{})) + 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](RiskRead, InputDefinition{Fields: []InputField{{ + Name: "value", CLI: CLIInput{Aliases: []FlagAlias{{Name: "format", Mode: AliasNormalize}}}, + }}}) + requireTypedCollisionPanic(t, func() { _ = Define(definition) }, "Args field Value", "normalize alias --format", "output format") +} + +func TestMountRejectsSchemaCollisionAddedAfterDefine(t *testing.T) { + shortcut := Define(collisionDefinition[collisionPrintOnlyArgs](RiskRead, InputDefinition{})) + 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..4fd0d5fd69 --- /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 != EncodingJSON || !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 to dump the JSON Schema for that flag", + }, "", " ") + } + schema, ok := schemas[flagName] + if !ok { + return nil, errs.NewValidationError( + errs.SubtypeInvalidArgument, + "no JSON Schema registered for %s --%s; available: %v", + command.metadata.Command, + flagName, + flags, + ).WithParam("--flag-name") + } + return json.MarshalIndent(schema, "", " ") + } +} + +func isCompositeValueShape(shape ValueShape) bool { + switch value := shape.(type) { + case ObjectShape, ArrayShape: + return true + case OneOfShape: + for _, variant := range value.Variants { + if isCompositeValueShape(variant) { + return true + } + } + } + return false +} diff --git a/shortcuts/common/typed_flag_schema_test.go b/shortcuts/common/typed_flag_schema_test.go new file mode 100644 index 0000000000..35a0e72d2b --- /dev/null +++ b/shortcuts/common/typed_flag_schema_test.go @@ -0,0 +1,162 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/spf13/cobra" +) + +func runTypedFlagSchema(t *testing.T, shortcut Shortcut, args ...string) (string, error) { + t.Helper() + factory, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{}) + factory.Config = func() (*core.CliConfig, error) { + t.Fatal("--print-schema loaded configuration") + return nil, errors.New("unreachable") + } + service := &cobra.Command{Use: "fixture", SilenceErrors: true, SilenceUsage: true} + shortcut.Mount(service, factory) + service.SetArgs(append([]string{shortcut.Command}, args...)) + err := service.Execute() + return stdout.String(), err +} + +func TestTypedFlagSchemaListsAndPrintsCompositeInputsBeforeExecution(t *testing.T) { + definition := validCompilerDefinition() + definition.Input.Relations = append(definition.Input.Relations, Relation{ + Kind: RelationExactlyOne, Params: []string{"token", "labels"}, Presence: PresenceExplicit, Stage: StageSourcePreRun, + }) + called := false + definition.Hooks.Normalize = func(context.Context, CommandContext, *compilerArgs) error { + called = true + return nil + } + definition.Hooks.Validate = func(context.Context, CommandContext, *compilerArgs) error { + called = true + return nil + } + definition.Hooks.Execute = func(context.Context, CommandContext, *compilerArgs) (Result[compilerData], error) { + called = true + return Success(compilerData{}), nil + } + shortcut := Define(definition) + + listing, err := runTypedFlagSchema(t, shortcut, "--print-schema") + if err != nil { + t.Fatal(err) + } + var index struct { + Shortcut string `json:"shortcut"` + IntrospectableFlags []string `json:"introspectable_flags"` + } + if err := json.Unmarshal([]byte(listing), &index); err != nil { + t.Fatalf("listing %q: %v", listing, err) + } + if index.Shortcut != "+compile" || !equalStrings(index.IntrospectableFlags, []string{"payload"}) { + t.Fatalf("listing = %#v", index) + } + + schemaJSON, err := runTypedFlagSchema(t, shortcut, "--print-schema", "--flag-name", "payload") + if err != nil { + t.Fatal(err) + } + var schema struct { + Type string `json:"type"` + Required []string `json:"required"` + Properties map[string]typedSchemaNode `json:"properties"` + } + if err := json.Unmarshal([]byte(schemaJSON), &schema); err != nil { + t.Fatalf("schema %q: %v", schemaJSON, err) + } + mode := schema.Properties["mode"] + if schema.Type != "object" || !equalStrings(schema.Required, []string{"mode"}) || mode.Type != "string" || len(mode.Enum) != 2 || mode.Enum[0] != "fast" || mode.Enum[1] != "full" { + t.Fatalf("schema = %#v", schema) + } + if called { + t.Fatal("--print-schema called a business hook") + } +} + +func TestIsCompositeValueShape(t *testing.T) { + for _, test := range []struct { + name string + shape ValueShape + want bool + }{ + {name: "object", shape: ObjectShape{}, want: true}, + {name: "array", shape: ArrayShape{Items: StringShape{}}, want: true}, + {name: "nullable object", shape: OneOfShape{Variants: []ValueShape{NullShape{}, ObjectShape{}}}, want: true}, + {name: "scalar one-of", shape: OneOfShape{Variants: []ValueShape{StringShape{}, NullShape{}}}, want: false}, + {name: "string", shape: StringShape{}, want: false}, + } { + t.Run(test.name, func(t *testing.T) { + if got := isCompositeValueShape(test.shape); got != test.want { + t.Fatalf("isCompositeValueShape(%T) = %v, want %v", test.shape, got, test.want) + } + }) + } +} + +func TestTypedFlagSchemaUnknownFlagIsTypedValidationError(t *testing.T) { + _, err := runTypedFlagSchema(t, Define(validCompilerDefinition()), "--print-schema", "--flag-name", "missing") + var validation *errs.ValidationError + problem, ok := errs.ProblemOf(err) + if !ok || !errors.As(err, &validation) || problem.Subtype != errs.SubtypeInvalidArgument || validation.Param != "--flag-name" { + t.Fatalf("error = %#v, problem = %#v", err, problem) + } + if !strings.Contains(err.Error(), "available: [payload]") { + t.Fatalf("error = %q", err) + } +} + +func TestTypedFlagSchemaNotRegisteredForScalarInputs(t *testing.T) { + type args struct { + Token string `flag:"token" schema:"required" doc:"target token"` + } + type data struct { + OK bool `json:"ok" schema:"required" doc:"success state"` + } + shortcut := Define(Definition[args, data]{ + Metadata: CommandMetadata{Service: "fixture", Command: "+scalar", Description: "scalar fixture", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, + Hooks: Hooks[args, data]{Execute: func(context.Context, CommandContext, *args) (Result[data], error) { + return Success(data{OK: true}), nil + }}, + }) + if shortcut.PrintFlagSchema != nil { + t.Fatal("scalar-only Typed Shortcut registered PrintFlagSchema") + } + + factory, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{}) + service := &cobra.Command{Use: "fixture"} + shortcut.Mount(service, factory) + command, _, err := service.Find([]string{shortcut.Command}) + if err != nil { + t.Fatal(err) + } + if command.Flags().Lookup("print-schema") != nil || command.Flags().Lookup("flag-name") != nil { + t.Fatal("scalar-only command registered schema flags") + } +} + +func TestTypedFlagSchemaAllowsCompatibilityOverride(t *testing.T) { + shortcut := Define(validCompilerDefinition()) + shortcut.PrintFlagSchema = func(flagName string) ([]byte, error) { + return []byte(`{"source":"legacy","flag":"` + flagName + `"}`), nil + } + stdout, err := runTypedFlagSchema(t, shortcut, "--print-schema", "--flag-name", "payload") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout, `"source":"legacy"`) { + t.Fatalf("stdout = %q", stdout) + } +} diff --git a/shortcuts/common/typed_help.go b/shortcuts/common/typed_help.go new file mode 100644 index 0000000000..ba2ed764b3 --- /dev/null +++ b/shortcuts/common/typed_help.go @@ -0,0 +1,206 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func installTypedHelp(cmd *cobra.Command, command *compiledCommand) { + installTypedGroupedUsage(cmd, typedHelpFacts(command)) +} + +func typedHelpFacts(command *compiledCommand) typedCommandHelpFacts { + facts := typedCommandHelpFacts{ + Parameters: make([]typedParameterHelpFact, 0, len(command.fields)), + Constraints: make([]typedConstraintHelpFact, 0, len(command.relations)), + Execution: []typedHelpFlagRef{{Name: "as"}, {Name: "dry-run"}, {Name: "yes"}}, + OutputFlags: []typedHelpFlagRef{{Name: "format"}, {Name: "json"}, {Name: "jq"}}, + Other: []typedHelpFlagRef{{Name: "print-schema"}, {Name: "flag-name"}, {Name: "help"}}, + } + parameterIndex := make(map[int]int, len(command.fields)) + stdinParameters := 0 + for fieldIndex, field := range command.fields { + if field.cli.Hidden { + continue + } + fact := typedParameterHelpFact{ + Name: field.name, Type: helpType(field.shape, field.cli.Encoding), Description: field.description, + Required: field.required, DefaultSet: field.defaultValue.Set, Default: field.defaultValue.Value, + Encoding: string(field.cli.Encoding), + } + for _, source := range field.cli.ValueSources { + fact.Sources = append(fact.Sources, string(source)) + if source == SourceStdin { + stdinParameters++ + } + } + for _, alias := range field.cli.Aliases { + fact.Aliases = append(fact.Aliases, typedAliasHelpFact{Name: alias.Name, Hidden: alias.Hidden, Deprecated: alias.Deprecated}) + } + applyShapeToHelpFact(&fact, field.shape) + parameterIndex[fieldIndex] = len(facts.Parameters) + facts.Parameters = append(facts.Parameters, fact) + } + if stdinParameters > 1 { + facts.Constraints = append(facts.Constraints, typedConstraintHelpFact{Text: "at most one parameter may read stdin in one invocation"}) + } + for _, relation := range command.relations { + params := make([]string, 0, len(relation.fields)) + visibleFields := make([]int, 0, len(relation.fields)) + for _, index := range relation.fields { + if _, visible := parameterIndex[index]; !visible { + continue + } + visibleFields = append(visibleFields, index) + params = append(params, command.fields[index].name) + } + if len(visibleFields) != len(relation.fields) { + if len(visibleFields) == 1 && (relation.kind == RelationExactlyOne || relation.kind == RelationAtLeastOne) { + parameter := &facts.Parameters[parameterIndex[visibleFields[0]]] + parameter.Required = true + parameter.Explicit = relation.presence == PresenceExplicit + } + continue + } + facts.Constraints = append(facts.Constraints, typedConstraintHelpFact{Kind: string(relation.kind), Params: params, Presence: string(relation.presence)}) + } + if command.output.Artifacts != nil { + facts.Output = append(facts.Output, typedOutputHelpFact{Text: "writes local artifacts described in the JSON result"}) + } + if command.output.Outcomes.PartialFailure != nil { + facts.Output = append(facts.Output, typedOutputHelpFact{Text: "may return a partial-failure result with per-item failures"}) + } + if command.output.Meta.Count { + facts.Output = append(facts.Output, typedOutputHelpFact{Text: "JSON and jq envelopes may include meta.count"}) + } + if command.output.Meta.Pagination { + text := "pagination metadata reports completion, pages, items, and a resume token when incomplete" + if command.output.Mode != OutputFixedJSON { + summaryFormats := "table" + if command.hooks.renderers["pretty"] != nil { + summaryFormats = "pretty/table" + } + text += "; successful " + summaryFormats + " output appends a pagination summary" + } + facts.Output = append(facts.Output, typedOutputHelpFact{Text: text}) + } + identityOrder := command.metadata.Authorization.IdentityOrder + if len(identityOrder) == 0 { + identityOrder = []Identity{IdentityUser, IdentityBot} + } + for _, identity := range identityOrder { + authorization, ok := command.metadata.Authorization.Identities[identity] + if !ok || len(authorization.RequiredScopes)+len(authorization.ConditionalScopes) == 0 { + continue + } + fact := typedAuthorizationHelpFact{Identity: string(identity), RequiredScopes: append([]string(nil), authorization.RequiredScopes...)} + for _, conditional := range authorization.ConditionalScopes { + fact.ConditionalScopes = append(fact.ConditionalScopes, typedConditionalScopeHelpFact{ + Scopes: append([]string(nil), conditional.Scopes...), When: conditional.When, + Params: append([]string(nil), conditional.Params...), Requirement: conditional.Requirement, + }) + } + facts.Authorization = append(facts.Authorization, fact) + } + return facts +} + +func helpType(shape ValueShape, encoding CLIEncoding) string { + if encoding == EncodingJSON { + return "json" + } + shape = nonNullableShape(shape) + switch value := shape.(type) { + case BooleanShape: + return "boolean" + case IntegerShape: + return "integer" + case NumberShape: + return "number" + case StringShape: + return "string" + case ArrayShape: + item := helpType(value.Items, "") + if item == "boolean" { + item = "bool" + } + if item == "json" || item == "" { + return "array" + } + return item + "[]" + case ObjectShape, OneOfShape: + return "json" + case ConstShape: + switch value.Value.(type) { + case bool: + return "boolean" + case string: + return "string" + default: + return "value" + } + case NullShape: + return "null" + default: + return "value" + } +} + +func nonNullableShape(shape ValueShape) ValueShape { + if oneOf, ok := shape.(OneOfShape); ok && len(oneOf.Variants) == 2 { + if _, null := oneOf.Variants[0].(NullShape); null { + return oneOf.Variants[1] + } + if _, null := oneOf.Variants[1].(NullShape); null { + return oneOf.Variants[0] + } + } + return shape +} + +func applyShapeToHelpFact(fact *typedParameterHelpFact, shape ValueShape) { + shape = nonNullableShape(shape) + switch value := shape.(type) { + case StringShape: + fact.Enum = append([]string{}, value.Enum...) + fact.Format, fact.MinLength, fact.MaxLength = value.Format, cloneInt(value.MinLength), cloneInt(value.MaxLength) + case IntegerShape: + for _, item := range value.Enum { + fact.Enum = append(fact.Enum, fmt.Sprint(item)) + } + fact.Minimum, fact.Maximum = int64AsFloat(value.Minimum), int64AsFloat(value.Maximum) + case NumberShape: + for _, item := range value.Enum { + fact.Enum = append(fact.Enum, fmt.Sprintf("%g", item)) + } + fact.Minimum, fact.Maximum = cloneFloat(value.Minimum), cloneFloat(value.Maximum) + case ArrayShape: + fact.MinItems, fact.MaxItems = cloneInt(value.MinItems), cloneInt(value.MaxItems) + } +} + +func cloneInt(value *int) *int { + if value == nil { + return nil + } + copied := *value + return &copied +} +func cloneFloat(value *float64) *float64 { + if value == nil { + return nil + } + copied := *value + return &copied +} +func int64AsFloat(value *int64) *float64 { + if value == nil { + return nil + } + copied := float64(*value) + return &copied +} diff --git a/shortcuts/common/typed_help_render.go b/shortcuts/common/typed_help_render.go new file mode 100644 index 0000000000..36ddec2cb9 --- /dev/null +++ b/shortcuts/common/typed_help_render.go @@ -0,0 +1,483 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// typedCommandHelpFacts is the presentation-only input for grouped command usage. +type typedCommandHelpFacts struct { + Parameters []typedParameterHelpFact + Constraints []typedConstraintHelpFact + Authorization []typedAuthorizationHelpFact + Execution []typedHelpFlagRef + Output []typedOutputHelpFact + OutputFlags []typedHelpFlagRef + Other []typedHelpFlagRef +} + +// typedParameterHelpFact describes one public business parameter. +type typedParameterHelpFact struct { + Name string + Type string + Description string + Required bool + Explicit bool + DefaultSet bool + Default any + Enum []string + Format string + Minimum *float64 + Maximum *float64 + MinLength *int + MaxLength *int + MinItems *int + MaxItems *int + Sources []string + Encoding string + Aliases []typedAliasHelpFact +} + +// typedAliasHelpFact describes a public compatibility spelling for a parameter. +type typedAliasHelpFact struct { + Name string + Hidden bool + Deprecated bool +} + +// typedConstraintHelpFact describes a relation among top-level parameters. +type typedConstraintHelpFact struct { + Kind string + Params []string + Presence string + Text string +} + +type typedAuthorizationHelpFact struct { + Identity string + RequiredScopes []string + ConditionalScopes []typedConditionalScopeHelpFact +} + +type typedConditionalScopeHelpFact struct { + Scopes []string + When string + Params []string + Requirement ScopeRequirement +} + +// typedHelpFlagRef selects a registered Cobra flag for a system section. +type typedHelpFlagRef struct{ Name string } + +// typedOutputHelpFact is a concise output-contract sentence. +type typedOutputHelpFact struct{ Text string } + +// installTypedGroupedUsage replaces Cobra's flat local flag list with a grouped, +// deterministic parameter view. Long descriptions, affordances, inherited +// flags and the root HelpFunc remain untouched. +func installTypedGroupedUsage(cmd *cobra.Command, facts typedCommandHelpFacts) { + facts = cloneTypedHelpFacts(facts) + cmd.SetUsageFunc(func(c *cobra.Command) error { + w := c.OutOrStderr() + fmt.Fprintf(w, "Usage:\n %s\n", c.UseLine()) + body := renderTypedGroupedUsage(c, facts) + if body != "" { + fmt.Fprintf(w, "\n%s\n", body) + } + if c.HasAvailableInheritedFlags() { + fmt.Fprintf(w, "\nGlobal Flags:\n%s\n", strings.TrimRight(c.InheritedFlags().FlagUsages(), " \t\n")) + } + return nil + }) +} + +// renderTypedGroupedUsage renders local grouped sections without Usage or inherited +// flags for Typed Shortcut commands. +func renderTypedGroupedUsage(cmd *cobra.Command, facts typedCommandHelpFacts) string { + var b strings.Builder + seen := map[string]bool{} + required, optional := splitTypedHelpParameters(facts.Parameters) + if len(required)+len(optional) > 0 { + b.WriteString("Parameters:\n") + writeTypedHelpParameters(&b, " Required:", required, seen) + writeTypedHelpParameters(&b, " Optional:", optional, seen) + } + if len(facts.Constraints) > 0 { + writeTypedHelpGap(&b) + b.WriteString("Constraints:\n") + for _, fact := range facts.Constraints { + fmt.Fprintf(&b, " %s\n", typedHelpConstraintText(fact)) + } + } + writeTypedAuthorizationHelp(&b, facts.Authorization) + writeTypedHelpFlagSection(&b, cmd, "Execution", facts.Execution, seen) + if len(facts.OutputFlags) > 0 || len(facts.Output) > 0 { + writeTypedHelpGap(&b) + b.WriteString("Output:\n") + writeTypedHelpFlags(&b, cmd, facts.OutputFlags, seen) + for _, fact := range facts.Output { + if strings.TrimSpace(fact.Text) != "" { + fmt.Fprintf(&b, " %s\n", strings.TrimSpace(fact.Text)) + } + } + } + other := typedHelpReferencedFlags(cmd, facts.Other, seen) + cmd.LocalFlags().VisitAll(func(flag *pflag.Flag) { + if !flag.Hidden && !seen[flag.Name] { + seen[flag.Name] = true + other = append(other, flag) + } + }) + if len(other) > 0 { + writeTypedHelpGap(&b) + b.WriteString("Other:\n") + writeTypedHelpPFlags(&b, other) + } + return strings.TrimRight(b.String(), "\n") +} + +func splitTypedHelpParameters(parameters []typedParameterHelpFact) (required, optional []typedParameterHelpFact) { + for _, parameter := range parameters { + if parameter.Required { + required = append(required, parameter) + } else { + optional = append(optional, parameter) + } + } + return required, optional +} + +func writeTypedHelpParameters(b *strings.Builder, heading string, parameters []typedParameterHelpFact, seen map[string]bool) { + if len(parameters) == 0 { + return + } + fmt.Fprintln(b, heading) + specs := make([]string, len(parameters)) + width := 0 + for i, parameter := range parameters { + specs[i] = " --" + parameter.Name + if parameter.Type != "" && parameter.Type != "boolean" { + specs[i] += " <" + parameter.Type + ">" + } + if len(specs[i]) > width { + width = len(specs[i]) + } + } + for i, parameter := range parameters { + seen[parameter.Name] = true + fmt.Fprintf(b, "%-*s %s\n", width, specs[i], strings.TrimSpace(parameter.Description)) + for _, note := range typedHelpParameterNotes(parameter) { + fmt.Fprintf(b, "%*s%s\n", width+3+4, "", note) + } + } +} + +func typedHelpParameterNotes(fact typedParameterHelpFact) []string { + var notes []string + if fact.Explicit { + notes = append(notes, "must be explicitly provided") + } + if fact.DefaultSet { + notes = append(notes, "default: "+formatTypedHelpValue(fact.Default)) + } + if len(fact.Enum) > 0 { + notes = append(notes, "allowed values: "+strings.Join(fact.Enum, ", ")) + } + if fact.Format != "" { + notes = append(notes, "format: "+fact.Format) + } + if fact.Minimum != nil || fact.Maximum != nil { + switch { + case fact.Minimum != nil && fact.Maximum != nil: + notes = append(notes, fmt.Sprintf("range: %s to %s", typedHelpNumber(*fact.Minimum), typedHelpNumber(*fact.Maximum))) + case fact.Minimum != nil: + notes = append(notes, "minimum: "+typedHelpNumber(*fact.Minimum)) + default: + notes = append(notes, "maximum: "+typedHelpNumber(*fact.Maximum)) + } + } + if fact.MinLength != nil { + notes = append(notes, fmt.Sprintf("minimum length: %d", *fact.MinLength)) + } + if fact.MaxLength != nil { + notes = append(notes, fmt.Sprintf("maximum length: %d", *fact.MaxLength)) + } + if fact.MinItems != nil { + notes = append(notes, fmt.Sprintf("minimum items: %d", *fact.MinItems)) + } + if fact.MaxItems != nil { + notes = append(notes, fmt.Sprintf("maximum items: %d", *fact.MaxItems)) + } + if source := typedHelpSourceText(fact.Sources, fact.Encoding); source != "" { + notes = append(notes, source) + } + var aliases, deprecated []string + for _, alias := range fact.Aliases { + if alias.Hidden { + continue + } + name := "--" + alias.Name + if alias.Deprecated { + deprecated = append(deprecated, name) + } else { + aliases = append(aliases, name) + } + } + if len(aliases) > 0 { + notes = append(notes, "aliases: "+strings.Join(aliases, ", ")) + } + if len(deprecated) > 0 { + notes = append(notes, "deprecated aliases: "+strings.Join(deprecated, ", ")) + } + return notes +} + +func typedHelpSourceText(sources []string, encoding string) string { + has := func(want string) bool { + for _, source := range sources { + if source == want { + return true + } + } + return false + } + var text string + switch { + case has("file") && has("stdin"): + if encoding == "json" { + text = "accepts inline JSON, @file, or stdin with -" + } else { + text = "accepts inline text, @file, or stdin with -" + } + case has("file"): + if encoding == "json" { + text = "accepts inline JSON or @file" + } else { + text = "accepts inline value or @file" + } + case has("stdin"): + if encoding == "json" { + text = "accepts inline JSON or stdin with -" + } else { + text = "accepts inline value or stdin with -" + } + } + switch encoding { + case "repeated": + if text != "" { + text += "; " + } + text += "flag may be repeated" + case "comma_or_repeated": + if text != "" { + text += "; " + } + text += "accepts comma-separated values or repeated flags" + case "json": + if text == "" { + text = "accepts inline JSON" + } + } + return text +} + +func writeTypedAuthorizationHelp(b *strings.Builder, identities []typedAuthorizationHelpFact) { + if len(identities) == 0 { + return + } + writeTypedHelpGap(b) + b.WriteString("Authorization:\n") + for _, identity := range identities { + fmt.Fprintf(b, " %s:\n", strings.ToUpper(identity.Identity[:1])+identity.Identity[1:]) + if len(identity.RequiredScopes) > 0 { + b.WriteString(" Always required:\n") + for _, scope := range identity.RequiredScopes { + fmt.Fprintf(b, " %s\n", scope) + } + } + for _, requirement := range []ScopeRequirement{ScopeRequired, ScopeBestEffort} { + var conditional []typedConditionalScopeHelpFact + for _, scope := range identity.ConditionalScopes { + if scope.Requirement == requirement { + conditional = append(conditional, scope) + } + } + if len(conditional) == 0 { + continue + } + heading := "Conditionally required:" + if requirement == ScopeBestEffort { + heading = "Optional capability:" + } + fmt.Fprintf(b, " %s\n", heading) + for _, scope := range conditional { + fmt.Fprintf(b, " %s\n", strings.Join(scope.Scopes, ", ")) + if scope.When != "" { + fmt.Fprintf(b, " when: %s\n", scope.When) + } + if len(scope.Params) > 0 { + params := make([]string, 0, len(scope.Params)) + for _, param := range scope.Params { + params = append(params, "--"+param) + } + fmt.Fprintf(b, " related parameters: %s\n", strings.Join(params, ", ")) + } + } + } + } +} + +func writeTypedHelpFlagSection(b *strings.Builder, cmd *cobra.Command, heading string, refs []typedHelpFlagRef, seen map[string]bool) { + flags := typedHelpReferencedFlags(cmd, refs, seen) + if len(flags) == 0 { + return + } + writeTypedHelpGap(b) + fmt.Fprintf(b, "%s:\n", heading) + writeTypedHelpPFlags(b, flags) +} + +func writeTypedHelpFlags(b *strings.Builder, cmd *cobra.Command, refs []typedHelpFlagRef, seen map[string]bool) { + writeTypedHelpPFlags(b, typedHelpReferencedFlags(cmd, refs, seen)) +} + +func typedHelpReferencedFlags(cmd *cobra.Command, refs []typedHelpFlagRef, seen map[string]bool) []*pflag.Flag { + var flags []*pflag.Flag + for _, ref := range refs { + flag := cmd.LocalFlags().Lookup(ref.Name) + if flag == nil || flag.Hidden || seen[flag.Name] { + continue + } + seen[flag.Name] = true + flags = append(flags, flag) + } + return flags +} + +func writeTypedHelpPFlags(b *strings.Builder, flags []*pflag.Flag) { + if len(flags) == 0 { + return + } + specs := make([]string, len(flags)) + width := 0 + for i, flag := range flags { + specs[i] = typedHelpFlagSpec(flag) + if len(specs[i]) > width { + width = len(specs[i]) + } + } + for i, flag := range flags { + _, usage := pflag.UnquoteUsage(flag) + if showTypedHelpDefault(flag) && !strings.Contains(strings.ToLower(usage), "default") { + usage += fmt.Sprintf(" (default %s)", flag.DefValue) + } + fmt.Fprintf(b, "%-*s %s\n", width, specs[i], strings.TrimSpace(usage)) + } +} + +func typedHelpFlagSpec(flag *pflag.Flag) string { + typeName, _ := pflag.UnquoteUsage(flag) + spec := " --" + flag.Name + if flag.Shorthand != "" && flag.ShorthandDeprecated == "" { + spec = " -" + flag.Shorthand + ", --" + flag.Name + } + if typeName != "" { + spec += " <" + typeName + ">" + } + return spec +} + +func showTypedHelpDefault(flag *pflag.Flag) bool { + switch flag.DefValue { + case "", "0", "false", "[]": + return false + } + return true +} + +func typedHelpConstraintText(fact typedConstraintHelpFact) string { + if strings.TrimSpace(fact.Text) != "" { + return strings.TrimSpace(fact.Text) + } + params := make([]string, 0, len(fact.Params)) + for _, param := range fact.Params { + params = append(params, "--"+param) + } + joined := strings.Join(params, ", ") + var text string + switch fact.Kind { + case "at_most_one": + text = "at most one of: " + joined + case "exactly_one": + text = "exactly one of: " + joined + case "at_least_one": + text = "at least one of: " + joined + case "requires": + if len(params) > 1 { + text = params[0] + " requires " + strings.Join(params[1:], ", ") + } else { + text = joined + " has an incomplete requires constraint" + } + case "conflicts": + text = "conflicting parameters: " + joined + case string(RelationCoOccur): + text = "all or none of: " + joined + case "same_value": + text = "must have the same value: " + joined + default: + text = strings.ReplaceAll(fact.Kind, "_", " ") + ": " + joined + } + if fact.Presence == string(PresenceNonZero) { + text += " (using non-zero values)" + } + return text +} + +func writeTypedHelpGap(b *strings.Builder) { + if b.Len() > 0 && !strings.HasSuffix(b.String(), "\n\n") { + b.WriteByte('\n') + } +} +func typedHelpNumber(value float64) string { return fmt.Sprintf("%g", value) } +func formatTypedHelpValue(value any) string { + encoded, err := json.Marshal(value) + if err == nil { + return string(encoded) + } + return fmt.Sprint(value) +} + +func cloneTypedHelpFacts(facts typedCommandHelpFacts) typedCommandHelpFacts { + facts.Parameters = append([]typedParameterHelpFact{}, facts.Parameters...) + facts.Constraints = append([]typedConstraintHelpFact{}, facts.Constraints...) + facts.Authorization = append([]typedAuthorizationHelpFact{}, facts.Authorization...) + facts.Execution = append([]typedHelpFlagRef{}, facts.Execution...) + facts.Output = append([]typedOutputHelpFact{}, facts.Output...) + facts.OutputFlags = append([]typedHelpFlagRef{}, facts.OutputFlags...) + facts.Other = append([]typedHelpFlagRef{}, facts.Other...) + // The compiler order is meaningful for business parameters. System refs are + // sorted only by the caller's declared order; remaining Cobra flags are + // already deterministic. Keep this explicit to avoid map-order coupling. + for i := range facts.Parameters { + facts.Parameters[i].Enum = append([]string{}, facts.Parameters[i].Enum...) + facts.Parameters[i].Sources = append([]string{}, facts.Parameters[i].Sources...) + facts.Parameters[i].Aliases = append([]typedAliasHelpFact{}, facts.Parameters[i].Aliases...) + } + for i := range facts.Authorization { + facts.Authorization[i].RequiredScopes = append([]string{}, facts.Authorization[i].RequiredScopes...) + facts.Authorization[i].ConditionalScopes = append([]typedConditionalScopeHelpFact{}, facts.Authorization[i].ConditionalScopes...) + for j := range facts.Authorization[i].ConditionalScopes { + conditional := &facts.Authorization[i].ConditionalScopes[j] + conditional.Scopes = append([]string{}, conditional.Scopes...) + conditional.Params = append([]string{}, conditional.Params...) + } + } + return facts +} diff --git a/shortcuts/common/typed_help_render_test.go b/shortcuts/common/typed_help_render_test.go new file mode 100644 index 0000000000..ab8430409f --- /dev/null +++ b/shortcuts/common/typed_help_render_test.go @@ -0,0 +1,91 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "bytes" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestInstallTypedGroupedUsage(t *testing.T) { + cmd := &cobra.Command{Use: "+fixture"} + cmd.Flags().String("token", "", "target token") + cmd.Flags().StringSlice("labels", nil, "labels") + cmd.Flags().Bool("dry-run", false, "print request without executing") + cmd.Flags().String("format", "json", "output format") + cmd.Flags().Bool("print-schema", false, "print schema") + cmd.Flags().String("unused", "", "unclassified flag") + cmd.Flags().String("hidden", "", "hidden flag") + _ = cmd.Flags().MarkHidden("hidden") + minimum, maximum, minItems := 1.0, 5.0, 1 + facts := typedCommandHelpFacts{ + Parameters: []typedParameterHelpFact{ + {Name: "token", Type: "string", Description: "target token", Required: true, Sources: []string{"flag", "file", "stdin"}, Aliases: []typedAliasHelpFact{{Name: "old-token", Deprecated: true}}}, + {Name: "labels", Type: "array", Description: "labels", DefaultSet: true, Default: []string{}, Enum: []string{"a", "b"}, Minimum: &minimum, Maximum: &maximum, MinItems: &minItems, Encoding: "comma_or_repeated"}, + }, + Constraints: []typedConstraintHelpFact{{Kind: "exactly_one", Params: []string{"token", "labels"}, Presence: "provided"}}, + Authorization: []typedAuthorizationHelpFact{{Identity: "user", RequiredScopes: []string{"fixture:write"}, ConditionalScopes: []typedConditionalScopeHelpFact{ + {Scopes: []string{"fixture:read"}, When: "--labels requests lookup", Params: []string{"labels"}, Requirement: ScopeRequired}, + {Scopes: []string{"fixture:enrich"}, When: "detail enrichment is available", Requirement: ScopeBestEffort}, + }}}, + Execution: []typedHelpFlagRef{{Name: "dry-run"}}, + OutputFlags: []typedHelpFlagRef{{Name: "format"}}, + Output: []typedOutputHelpFact{{Text: "successful output is a JSON object"}}, + Other: []typedHelpFlagRef{{Name: "print-schema"}}, + } + installTypedGroupedUsage(cmd, facts) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + if err := cmd.Usage(); err != nil { + t.Fatal(err) + } + got := out.String() + for _, want := range []string{ + "Parameters:\n Required:\n --token ", + "accepts inline text, @file, or stdin with -", + "deprecated aliases: --old-token", + " Optional:\n --labels ", + "default: []", "allowed values: a, b", "range: 1 to 5", "minimum items: 1", + "accepts comma-separated values or repeated flags", + "Constraints:\n exactly one of: --token, --labels", + "Authorization:\n User:\n Always required:\n fixture:write", + "Conditionally required:\n fixture:read", "when: --labels requests lookup", "related parameters: --labels", + "Optional capability:\n fixture:enrich", "when: detail enrichment is available", + "Execution:\n --dry-run", + "Output:\n --format ", "successful output is a JSON object", + "Other:\n --print-schema", "--unused", + } { + if !strings.Contains(got, want) { + t.Errorf("usage missing %q:\n%s", want, got) + } + } + if strings.Count(got, "Other:\n") != 1 { + t.Fatalf("Other section count != 1:\n%s", got) + } + if strings.Contains(got, "--hidden") { + t.Fatalf("hidden flag leaked:\n%s", got) + } +} + +func TestConstraintTextKinds(t *testing.T) { + tests := map[string]string{ + string(RelationExactlyOne): "exactly one of: --a, --b", + string(RelationAtLeastOne): "at least one of: --a, --b", + string(RelationRequires): "--a requires --b", + string(RelationConflicts): "conflicting parameters: --a, --b", + string(RelationCoOccur): "all or none of: --a, --b", + } + for kind, want := range tests { + if got := typedHelpConstraintText(typedConstraintHelpFact{Kind: kind, Params: []string{"a", "b"}}); got != want { + t.Errorf("%s = %q, want %q", kind, got, want) + } + } + if got := typedHelpConstraintText(typedConstraintHelpFact{Kind: string(RelationExactlyOne), Params: []string{"a", "b"}, Presence: string(PresenceNonZero)}); !strings.Contains(got, "using non-zero values") { + t.Errorf("nonzero = %q", got) + } +} diff --git a/shortcuts/common/typed_map_binder.go b/shortcuts/common/typed_map_binder.go new file mode 100644 index 0000000000..0410ba53c4 --- /dev/null +++ b/shortcuts/common/typed_map_binder.go @@ -0,0 +1,98 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "fmt" + "reflect" + "strings" + + "github.com/larksuite/cli/errs" +) + +// bindTypedMap binds already-resolved values used by batch/internal callers. +// Unlike the CLI entry point it never interprets @file or stdin markers: map +// values are final content unless a caller explicitly runs source resolution. +func bindTypedMap(command *compiledCommand, values map[string]any) (*boundArgs, error) { + args := command.hooks.newArgs() + root := reflect.ValueOf(args).Elem() + provided := make([]bool, len(command.fields)) + known := make(map[string]struct{}, len(command.fields)) + for i, field := range command.fields { + known[field.name] = struct{}{} + canonical, canonicalSet := values[field.name] + value, set := canonical, canonicalSet + for _, alias := range field.cli.Aliases { + known[alias.Name] = struct{}{} + aliasValue, aliasSet := values[alias.Name] + if !aliasSet { + continue + } + switch alias.Mode { + case AliasNormalize: + value, set = aliasValue, true + case AliasIndependent: + switch alias.Conflict { + case AliasCanonicalWins: + if !canonicalSet { + value, set = aliasValue, true + } + case AliasErrorIfBoth: + if canonicalSet { + return nil, typedFieldValidation(field, "cannot be used together with --%s", alias.Name) + } + value, set = aliasValue, true + case AliasTrimmedEqualOrError: + if canonicalSet { + if strings.TrimSpace(fmt.Sprint(canonical)) != strings.TrimSpace(fmt.Sprint(aliasValue)) { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s and --%s are both set with different values", field.name, alias.Name).WithParam("--" + alias.Name) + } + value = strings.TrimSpace(fmt.Sprint(canonical)) + } else { + value, set = aliasValue, true + } + } + } + } + provided[i] = set + if !set && field.defaultValue.Set { + value = field.defaultValue.Value + } + if !set && !field.defaultValue.Set { + if field.required { + return nil, typedRequiredFieldValidation(field) + } + continue + } + decoded, err := decodeCompiledMapValue(value, field) + if err != nil { + return nil, typedFieldValidation(field, "%v", err).WithCause(err) + } + if err := validateCompiledValue(decoded, field); err != nil { + return nil, err + } + if err := assignCompiledField(root, field, decoded, set); err != nil { + return nil, errs.NewInternalError(errs.SubtypeUnknown, "failed to bind map value %s: %v", field.name, err).WithCause(err) + } + } + for name := range values { + if _, ok := known[name]; !ok { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown parameter %q", name).WithParam(name) + } + } + if err := validateCompiledRelations(command, args, provided, StageSourcePreRun); err != nil { + return nil, err + } + return &boundArgs{value: args, provided: provided}, nil +} + +func decodeCompiledMapValue(value any, field compiledInputField) (any, error) { + if field.cli.Encoding == EncodingJSON { + if text, ok := value.(string); ok { + return decodeCompiledValue(text, field) + } + return convertReflectValue(value, field.valueType) + } + return convertReflectValue(value, field.valueType) +} diff --git a/shortcuts/common/typed_map_binder_test.go b/shortcuts/common/typed_map_binder_test.go new file mode 100644 index 0000000000..d284b9d4a2 --- /dev/null +++ b/shortcuts/common/typed_map_binder_test.go @@ -0,0 +1,302 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "testing" + + "github.com/larksuite/cli/errs" +) + +type aliasBinderArgs struct { + Value string `flag:"value" schema:"optional" doc:"fixture value"` +} +type aliasBinderData struct { + OK bool `json:"ok" schema:"required" doc:"success state"` +} + +func aliasBinderCommand(t *testing.T, alias FlagAlias) *compiledCommand { + t.Helper() + definition := Definition[aliasBinderArgs, aliasBinderData]{ + Metadata: CommandMetadata{Service: "fixture", Command: "+alias", Description: "alias fixture", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, + Input: InputDefinition{Fields: []InputField{{Name: "value", CLI: CLIInput{Aliases: []FlagAlias{alias}}}}}, + Hooks: Hooks[aliasBinderArgs, aliasBinderData]{Execute: func(context.Context, CommandContext, *aliasBinderArgs) (Result[aliasBinderData], error) { + return Success(aliasBinderData{OK: true}), nil + }}, + } + command, err := compileDefinition(definition) + if err != nil { + t.Fatal(err) + } + return command +} + +func TestBindTypedMapBindsFinalValuesDefaultsAndPresence(t *testing.T) { + command, err := compileDefinition(validCompilerDefinition()) + if err != nil { + t.Fatal(err) + } + bound, err := bindTypedMap(command, map[string]any{ + "token": "tok", "payload": map[string]any{"mode": "fast"}, "labels": []string{"a", "b"}, "limit": 1, + }) + if err != nil { + t.Fatal(err) + } + args := bound.value.(*compilerArgs) + if args.Token != "tok" || args.Payload.Mode != "fast" { + t.Fatalf("Args = %#v", args) + } + if args.Limit != (Provided[int]{Value: 1, Set: true}) { + t.Fatalf("Limit = %#v", args.Limit) + } + if got := args.Labels; len(got) != 2 || got[1] != "b" { + t.Fatalf("Labels = %#v", got) + } + + bound, err = bindTypedMap(command, map[string]any{"token": "tok"}) + if err != nil { + t.Fatal(err) + } + if got := bound.value.(*compilerArgs).Limit; got != (Provided[int]{Value: 20, Set: false}) { + t.Fatalf("default Limit = %#v", got) + } +} + +func TestBindTypedMapRequiredMessageUsesLegacyCLIForm(t *testing.T) { + command, err := compileDefinition(validCompilerDefinition()) + if err != nil { + t.Fatal(err) + } + _, err = bindTypedMap(command, map[string]any{}) + problem, ok := errs.ProblemOf(err) + if !ok || problem.Message != "--token is required" { + t.Fatalf("error = %v, problem = %#v", err, problem) + } +} + +func TestBindTypedMapAliasPolicies(t *testing.T) { + t.Run("normalize", func(t *testing.T) { + bound, err := bindTypedMap(aliasBinderCommand(t, FlagAlias{Name: "old", Mode: AliasNormalize}), map[string]any{"old": "alias"}) + if err != nil || bound.value.(*aliasBinderArgs).Value != "alias" { + t.Fatalf("bound = %#v, err = %v", bound, err) + } + }) + t.Run("canonical wins", func(t *testing.T) { + bound, err := bindTypedMap(aliasBinderCommand(t, FlagAlias{Name: "old", Mode: AliasIndependent, Conflict: AliasCanonicalWins}), map[string]any{"value": "canonical", "old": "alias"}) + if err != nil || bound.value.(*aliasBinderArgs).Value != "canonical" { + t.Fatalf("bound = %#v, err = %v", bound, err) + } + }) + t.Run("error if both", func(t *testing.T) { + _, err := bindTypedMap(aliasBinderCommand(t, FlagAlias{Name: "old", Mode: AliasIndependent, Conflict: AliasErrorIfBoth}), map[string]any{"value": "canonical", "old": "alias"}) + var validation *errs.ValidationError + if !errors.As(err, &validation) { + t.Fatalf("error = %#v", err) + } + }) + t.Run("trimmed equal", func(t *testing.T) { + bound, err := bindTypedMap(aliasBinderCommand(t, FlagAlias{Name: "old", Mode: AliasIndependent, Conflict: AliasTrimmedEqualOrError}), map[string]any{"value": " same ", "old": "same"}) + if err != nil || bound.value.(*aliasBinderArgs).Value != "same" { + t.Fatalf("bound = %#v, err = %v", bound, err) + } + }) +} + +func TestBindTypedMapCreatesIndependentArgsConcurrently(t *testing.T) { + command, err := compileDefinition(validCompilerDefinition()) + if err != nil { + t.Fatal(err) + } + const workers = 32 + results := make([]*compilerArgs, workers) + var wg sync.WaitGroup + for i := 0; i < workers; i++ { + wg.Add(1) + go func(index int) { + defer wg.Done() + bound, bindErr := bindTypedMap(command, map[string]any{"token": fmt.Sprintf("token-%d", index)}) + if bindErr != nil { + t.Errorf("bind %d: %v", index, bindErr) + return + } + results[index] = bound.value.(*compilerArgs) + }(i) + } + wg.Wait() + seen := make(map[*compilerArgs]struct{}, workers) + for i, result := range results { + if result == nil || result.Token != fmt.Sprintf("token-%d", i) { + t.Fatalf("result[%d] = %#v", i, result) + } + if _, duplicate := seen[result]; duplicate { + t.Fatalf("Args pointer reused at %d", i) + } + seen[result] = struct{}{} + } +} + +func TestBindTypedMapPresenceNonZeroUsesProvidedValue(t *testing.T) { + type args struct { + First Provided[string] `flag:"first" schema:"optional" doc:"first value"` + Second Provided[string] `flag:"second" schema:"optional" doc:"second value"` + } + definition := Definition[args, aliasBinderData]{ + Metadata: CommandMetadata{Service: "fixture", Command: "+presence", Description: "presence fixture", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, + Input: InputDefinition{Relations: []Relation{{Kind: RelationExactlyOne, Params: []string{"first", "second"}, Presence: PresenceNonZero, Stage: StageAfterPrepare}}}, + Hooks: Hooks[args, aliasBinderData]{Execute: func(context.Context, CommandContext, *args) (Result[aliasBinderData], error) { + return Success(aliasBinderData{OK: true}), nil + }}, + } + command, err := compileDefinition(definition) + if err != nil { + t.Fatal(err) + } + bound, err := bindTypedMap(command, map[string]any{"first": "", "second": "value"}) + if err != nil { + t.Fatal(err) + } + if err := validateCompiledRelations(command, bound.value, bound.provided, StageAfterPrepare); err != nil { + t.Fatalf("explicit empty Provided value must remain non-zero absent: %v", err) + } + bound, err = bindTypedMap(command, map[string]any{"first": "", "second": ""}) + if err != nil { + t.Fatal(err) + } + if err := validateCompiledRelations(command, bound.value, bound.provided, StageAfterPrepare); err == nil { + t.Fatal("two explicit empty Provided values unexpectedly satisfied non-zero exactly-one") + } +} + +func TestBindTypedMapAcceptsStructuredRawJSONValue(t *testing.T) { + type args struct { + Payload json.RawMessage `flag:"payload" schema:"required" cli:"encoding=json" doc:"payload"` + } + shape := ObjectShape{Fields: []ValueField{{Name: "name", Description: "name", Required: true, Shape: StringShape{}}}} + definition := Definition[args, aliasBinderData]{ + Metadata: CommandMetadata{Service: "fixture", Command: "+raw-json", Description: "raw JSON fixture", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, + Input: InputDefinition{Fields: []InputField{{Name: "payload", Shape: shape}}}, + Hooks: Hooks[args, aliasBinderData]{Execute: func(context.Context, CommandContext, *args) (Result[aliasBinderData], error) { + return Success(aliasBinderData{OK: true}), nil + }}, + } + command, err := compileDefinition(definition) + if err != nil { + t.Fatal(err) + } + bound, err := bindTypedMap(command, map[string]any{"payload": map[string]any{"name": "fixture"}}) + if err != nil { + t.Fatal(err) + } + if got := string(bound.value.(*args).Payload); got != `{"name":"fixture"}` { + t.Fatalf("payload = %q", got) + } +} + +func TestBindTypedMapRejectsNullOutsideExplicitShape(t *testing.T) { + type args struct { + Payload map[string]string `flag:"payload" schema:"optional" cli:"encoding=json" doc:"payload"` + } + definition := Definition[args, aliasBinderData]{ + Metadata: CommandMetadata{Service: "fixture", Command: "+null", Description: "null fixture", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, + Input: InputDefinition{Fields: []InputField{{Name: "payload", Shape: ObjectShape{AdditionalProperties: true, AdditionalPropertiesShape: StringShape{}}}}}, + Hooks: Hooks[args, aliasBinderData]{Execute: func(context.Context, CommandContext, *args) (Result[aliasBinderData], error) { + return Success(aliasBinderData{OK: true}), nil + }}, + } + command, err := compileDefinition(definition) + if err != nil { + t.Fatal(err) + } + _, err = bindTypedMap(command, map[string]any{"payload": nil}) + var validation *errs.ValidationError + if !errors.As(err, &validation) || validation.Param != "--payload" { + t.Fatalf("error = %#v", err) + } +} + +func TestBindTypedMapEnforcesNumberEnum(t *testing.T) { + type args struct { + Ratio float64 `flag:"ratio" schema:"required;enum=0.5|1.5" doc:"ratio"` + } + definition := Definition[args, aliasBinderData]{ + Metadata: CommandMetadata{Service: "fixture", Command: "+number-enum", Description: "number enum fixture", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, + Hooks: Hooks[args, aliasBinderData]{Execute: func(context.Context, CommandContext, *args) (Result[aliasBinderData], error) { + return Success(aliasBinderData{OK: true}), nil + }}, + } + command, err := compileDefinition(definition) + if err != nil { + t.Fatal(err) + } + _, err = bindTypedMap(command, map[string]any{"ratio": 2.5}) + var validation *errs.ValidationError + if !errors.As(err, &validation) || validation.Param != "--ratio" || !strings.Contains(err.Error(), "unsupported number value") { + t.Fatalf("error = %#v", err) + } +} + +func TestBindTypedMapPreservesLargeIntegerEnum(t *testing.T) { + type args struct { + Sequence int64 `flag:"sequence" schema:"required;enum=9007199254740993" doc:"sequence"` + } + definition := Definition[args, aliasBinderData]{ + Metadata: CommandMetadata{Service: "fixture", Command: "+large-integer", Description: "large integer fixture", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, + Hooks: Hooks[args, aliasBinderData]{Execute: func(context.Context, CommandContext, *args) (Result[aliasBinderData], error) { + return Success(aliasBinderData{OK: true}), nil + }}, + } + command, err := compileDefinition(definition) + if err != nil { + t.Fatal(err) + } + if _, err := bindTypedMap(command, map[string]any{"sequence": int64(9007199254740993)}); err != nil { + t.Fatalf("valid large integer enum rejected: %v", err) + } +} + +func TestBindTypedMapRejectsWrongFixedArrayLength(t *testing.T) { + type args struct { + Values [2]string `flag:"values" schema:"required" cli:"encoding=repeated" doc:"two values"` + } + definition := Definition[args, aliasBinderData]{ + Metadata: CommandMetadata{Service: "fixture", Command: "+array", Description: "array fixture", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, + Hooks: Hooks[args, aliasBinderData]{Execute: func(context.Context, CommandContext, *args) (Result[aliasBinderData], error) { + return Success(aliasBinderData{OK: true}), nil + }}, + } + command, err := compileDefinition(definition) + if err != nil { + t.Fatal(err) + } + for _, values := range [][]string{{"one"}, {"one", "two", "three"}} { + _, err := bindTypedMap(command, map[string]any{"values": values}) + var validation *errs.ValidationError + if !errors.As(err, &validation) || validation.Param != "--values" || !strings.Contains(err.Error(), "expected exactly 2 items") { + t.Fatalf("values=%v error=%#v", values, err) + } + } +} + +func TestBindTypedMapRejectsUnknownAndNestedInvalidValues(t *testing.T) { + command, err := compileDefinition(validCompilerDefinition()) + if err != nil { + t.Fatal(err) + } + _, err = bindTypedMap(command, map[string]any{"token": "tok", "unknown": true}) + var validation *errs.ValidationError + if !errors.As(err, &validation) || validation.Param != "unknown" { + t.Fatalf("unknown error = %#v", err) + } + + _, err = bindTypedMap(command, map[string]any{"token": "tok", "payload": map[string]any{"mode": "unsupported"}}) + if !errors.As(err, &validation) || validation.Param != "--payload" { + t.Fatalf("nested error = %#v", err) + } +} diff --git a/shortcuts/common/typed_mount_guard.go b/shortcuts/common/typed_mount_guard.go new file mode 100644 index 0000000000..c6d24b9eef --- /dev/null +++ b/shortcuts/common/typed_mount_guard.go @@ -0,0 +1,103 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//nolint:forbidigo // PostMount guard errors are registration-time programmer diagnostics consumed by the mount panic boundary. +package common + +import ( + "fmt" + "reflect" + "sort" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +type typedMountSnapshot struct { + use, short, long, example string + aliases []string + argsFunc, preRunFunc, runFunc, usageFunc, helpFunc uintptr + annotations map[string]string + flags map[string]typedMountedFlag +} + +type typedMountedFlag struct { + valueType, defaultValue, usage, shorthand, noOptDefault, deprecated string + hidden bool + annotations map[string][]string +} + +func captureTypedMountSnapshot(command *cobra.Command) typedMountSnapshot { + snapshot := typedMountSnapshot{ + use: command.Use, short: command.Short, long: command.Long, example: command.Example, + aliases: append([]string{}, command.Aliases...), annotations: make(map[string]string, len(command.Annotations)), flags: make(map[string]typedMountedFlag), + } + for key, value := range command.Annotations { + snapshot.annotations[key] = value + } + if command.Args != nil { + snapshot.argsFunc = reflect.ValueOf(command.Args).Pointer() + } + if command.PreRunE != nil { + snapshot.preRunFunc = reflect.ValueOf(command.PreRunE).Pointer() + } + if command.RunE != nil { + snapshot.runFunc = reflect.ValueOf(command.RunE).Pointer() + } + if usage := command.UsageFunc(); usage != nil { + snapshot.usageFunc = reflect.ValueOf(usage).Pointer() + } + if help := command.HelpFunc(); help != nil { + snapshot.helpFunc = reflect.ValueOf(help).Pointer() + } + command.LocalFlags().VisitAll(func(flag *pflag.Flag) { + annotations := make(map[string][]string, len(flag.Annotations)) + for key, values := range flag.Annotations { + annotations[key] = append([]string{}, values...) + } + snapshot.flags[flag.Name] = typedMountedFlag{ + valueType: flag.Value.Type(), defaultValue: flag.DefValue, usage: flag.Usage, + shorthand: flag.Shorthand, noOptDefault: flag.NoOptDefVal, deprecated: flag.Deprecated, + hidden: flag.Hidden, annotations: annotations, + } + }) + return snapshot +} + +func validateTypedPostMount(command *cobra.Command, before typedMountSnapshot) error { + after := captureTypedMountSnapshot(command) + if before.use != after.use || before.short != after.short || before.long != after.long || before.example != after.example || !reflect.DeepEqual(before.aliases, after.aliases) { + return fmt.Errorf("PostMount modified Typed command metadata or Help text") + } + if before.argsFunc != after.argsFunc || before.preRunFunc != after.preRunFunc || before.runFunc != after.runFunc || before.usageFunc != after.usageFunc || before.helpFunc != after.helpFunc { + return fmt.Errorf("PostMount replaced Typed command validation, execution, or Help functions") + } + if !reflect.DeepEqual(before.annotations, after.annotations) { + return fmt.Errorf("PostMount modified Typed command annotations") + } + names := make([]string, 0, len(before.flags)+len(after.flags)) + seen := make(map[string]struct{}, len(before.flags)+len(after.flags)) + for name := range before.flags { + seen[name] = struct{}{} + names = append(names, name) + } + for name := range after.flags { + if _, ok := seen[name]; !ok { + names = append(names, name) + } + } + sort.Strings(names) + for _, name := range names { + beforeFlag, existedBefore := before.flags[name] + afterFlag, existsAfter := after.flags[name] + switch { + case !existedBefore: + return fmt.Errorf("PostMount added undeclared Typed flag --%s", name) + case !existsAfter: + return fmt.Errorf("PostMount removed Typed flag --%s", name) + case !reflect.DeepEqual(beforeFlag, afterFlag): + return fmt.Errorf("PostMount modified Typed flag --%s or its input annotations", name) + } + } + return nil +} diff --git a/shortcuts/common/typed_output.go b/shortcuts/common/typed_output.go new file mode 100644 index 0000000000..af267de186 --- /dev/null +++ b/shortcuts/common/typed_output.go @@ -0,0 +1,116 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import "github.com/larksuite/cli/internal/output" + +type OutputDefinition struct { + Data DataDefinition + Outcomes OutcomeDefinition + Artifacts []ArtifactDefinition + Meta ResultMetaDefinition + Mode OutputMode + + // DisableHTMLEscaping preserves literal <, >, and & characters in JSON + // envelopes and jq JSON output. It does not enable bare stdout output or + // bypass content-safety scanning. + DisableHTMLEscaping bool +} + +// ResultMetaDefinition declares which standard envelope metadata a command may +// return. It is deliberately narrower than output.Meta: rollback and arbitrary +// metadata are not part of the Typed Result contract. +type ResultMetaDefinition struct { + Count bool + Pagination bool +} + +// ResultPaginationMeta reuses the standard output envelope pagination contract. +// The name avoids colliding with the existing PaginationMeta response helper. +type ResultPaginationMeta = output.PaginationMeta + +// ResultMeta carries optional standard envelope metadata for one Result. +// Count is a pointer so the runner can distinguish an omitted count from an +// explicitly supplied zero while preserving output.Meta's existing JSON rules. +type ResultMeta struct { + Count *int + Pagination *ResultPaginationMeta +} + +type Result[Data any] struct { + Data Data + Outcome OutcomeKind + Meta *ResultMeta +} + +type OutcomeKind string + +const ( + OutcomeSuccess OutcomeKind = "success" + OutcomePartial OutcomeKind = "partial" +) + +func Success[Data any](data Data) Result[Data] { + return Result[Data]{Data: data, Outcome: OutcomeSuccess} +} + +func Partial[Data any](data Data) Result[Data] { + return Result[Data]{Data: data, Outcome: OutcomePartial} +} + +// WithMeta attaches standard envelope metadata to a Result. +func (result Result[Data]) WithMeta(meta ResultMeta) Result[Data] { + result.Meta = &meta + return result +} + +// CountMeta constructs count metadata while preserving an explicit zero. +func CountMeta(count int) ResultMeta { + return ResultMeta{Count: &count} +} + +// PaginationResultMeta constructs pagination metadata. +func PaginationResultMeta(pagination *ResultPaginationMeta) ResultMeta { + return ResultMeta{Pagination: pagination} +} + +type OutcomeDefinition struct{ PartialFailure *PartialFailureDefinition } +type PartialFailureDefinition struct { + ExitCode int + // FailedItems declares an item-ledger receipt. Leave it nil for a + // result-level partial failure whose recovery state lives directly in Data. + FailedItems *FailedItemDefinition +} +type FailedItemDefinition struct { + ItemsPath string `json:"items_path"` + IdentityPaths []string `json:"identity_paths"` + AllItems bool `json:"all_items,omitempty"` + StatePath string `json:"state_path,omitempty"` + FailedValues []JSONValue `json:"failed_values,omitempty"` +} + +// ArtifactDefinition identifies file receipts in Data. It does not write, +// stat, or enforce overwrite policy for the referenced files. +type ArtifactDefinition struct { + Name string `json:"name"` + ItemsPath string `json:"items_path"` + // Optional allows ItemsPath to be absent or null when an invocation + // legitimately produces no file. Any present receipt is still validated. + Optional bool `json:"optional,omitempty"` + PathField string `json:"path_field"` + MediaTypeField string `json:"media_type_field,omitempty"` + SizeField string `json:"size_field,omitempty"` +} + +// OutputMode selects one of the output paths the Typed runner actually +// executes. Generic delegates record formats to the framework formatter and +// uses an optional pretty renderer. FixedJSON preserves Legacy Out/OutRaw +// behavior: --format remains accepted, but successful output is always a JSON +// envelope. +type OutputMode string + +const ( + OutputGeneric OutputMode = "" + OutputFixedJSON OutputMode = "fixed_json" +) diff --git a/shortcuts/common/typed_result_protocol.go b/shortcuts/common/typed_result_protocol.go new file mode 100644 index 0000000000..c7d9e9c911 --- /dev/null +++ b/shortcuts/common/typed_result_protocol.go @@ -0,0 +1,253 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "encoding/json" + "reflect" + "strconv" + "strings" + + "github.com/larksuite/cli/errs" +) + +// validateTypedResultProtocol checks only the local Outcome/Artifact receipts +// declared by OutputDefinition. It deliberately does not validate Data against +// the complete output schema on every invocation. +func validateTypedResultProtocol(command *compiledCommand, result compiledResult) error { + if result.outcome != OutcomeSuccess && result.outcome != OutcomePartial { + return nil + } + if err := validateTypedResultMeta(command.output.Meta, result.meta); err != nil { + return err + } + if result.outcome != OutcomePartial && len(command.output.Artifacts) == 0 { + return nil + } + encoded, err := json.Marshal(result.data) + if err != nil { + return errs.NewInternalError(errs.SubtypeInvalidResponse, "typed result cannot be inspected for its declared output protocol").WithCause(err) + } + var data any + if err := json.Unmarshal(encoded, &data); err != nil { + return errs.NewInternalError(errs.SubtypeInvalidResponse, "typed result cannot be decoded for its declared output protocol").WithCause(err) + } + if result.outcome == OutcomePartial { + if err := validatePartialReceipt(command.output.Outcomes.PartialFailure, data); err != nil { + return err + } + } + for _, artifact := range command.output.Artifacts { + if err := validateArtifactReceipt(artifact, data); err != nil { + return err + } + } + return nil +} + +func validateTypedResultMeta(definition ResultMetaDefinition, meta *ResultMeta) error { + if meta == nil { + return nil + } + if meta.Count == nil && meta.Pagination == nil { + return resultProtocolError("typed Result Meta is empty") + } + if meta.Count != nil { + if !definition.Count { + return resultProtocolError("typed Result returned undeclared meta.count") + } + if *meta.Count < 0 { + return resultProtocolError("typed Result meta.count must be non-negative") + } + } + if meta.Pagination != nil { + if !definition.Pagination { + return resultProtocolError("typed Result returned undeclared meta.pagination") + } + pagination := meta.Pagination + if pagination.Pages < 1 { + return resultProtocolError("typed Result meta.pagination.pages must be at least 1") + } + if pagination.Items < 0 { + return resultProtocolError("typed Result meta.pagination.items must be non-negative") + } + if pagination.Complete && pagination.NextToken != "" { + return resultProtocolError("typed Result complete pagination must not include next_token") + } + if !pagination.Complete && pagination.NextToken == "" { + return resultProtocolError("typed Result incomplete pagination must include next_token") + } + } + return nil +} + +func validatePartialReceipt(definition *PartialFailureDefinition, data any) error { + if definition == nil { + return errs.NewInternalError(errs.SubtypeUnknown, "typed Partial result has no compiled partial-failure contract") + } + if definition.FailedItems == nil { + return nil + } + failed := definition.FailedItems + value, ok := jsonPointerValue(data, failed.ItemsPath) + if !ok { + return resultProtocolError("partial failed-items path %q is missing from Data", failed.ItemsPath) + } + items, ok := value.([]any) + if !ok { + return resultProtocolError("partial failed-items path %q is not an array", failed.ItemsPath) + } + if len(items) == 0 { + return resultProtocolError("Partial result contains no failed items at %q", failed.ItemsPath) + } + matched := 0 + for index, item := range items { + for _, identityPath := range failed.IdentityPaths { + if _, ok := jsonPointerValue(item, identityPath); !ok { + return resultProtocolError("partial failed item %d is missing identity path %q", index, identityPath) + } + } + if failed.AllItems { + matched++ + continue + } + state, ok := jsonPointerValue(item, failed.StatePath) + if !ok { + return resultProtocolError("partial failed item %d is missing state path %q", index, failed.StatePath) + } + for _, expected := range failed.FailedValues { + if reflect.DeepEqual(state, normalizedJSONValue(expected)) { + matched++ + break + } + } + } + if matched == 0 { + return resultProtocolError("Partial result has no item matching the declared failed values") + } + return nil +} + +func validateArtifactReceipt(definition ArtifactDefinition, data any) error { + value, ok := jsonPointerValue(data, definition.ItemsPath) + if !ok || value == nil { + if definition.Optional { + return nil + } + return resultProtocolError("artifact %q items path %q is missing from Data", definition.Name, definition.ItemsPath) + } + items := []any{value} + if array, ok := value.([]any); ok { + items = array + } + for index, item := range items { + pathValue, ok := jsonPointerValue(item, definition.PathField) + if !ok { + return resultProtocolError("artifact %q item %d is missing path field %q", definition.Name, index, definition.PathField) + } + path, ok := pathValue.(string) + if !ok || path == "" { + return resultProtocolError("artifact %q item %d has an invalid path receipt", definition.Name, index) + } + if definition.SizeField != "" { + sizeValue, ok := jsonPointerValue(item, definition.SizeField) + if !ok { + return resultProtocolError("artifact %q item %d is missing size field %q", definition.Name, index, definition.SizeField) + } + size, ok := jsonInteger(sizeValue) + if !ok || size < 0 { + return resultProtocolError("artifact %q item %d has an invalid size receipt", definition.Name, index) + } + } + if definition.MediaTypeField != "" { + mediaType, ok := jsonPointerValue(item, definition.MediaTypeField) + if !ok { + return resultProtocolError("artifact %q item %d is missing media type field %q", definition.Name, index, definition.MediaTypeField) + } + if _, ok := mediaType.(string); !ok { + return resultProtocolError("artifact %q item %d has a non-string media type receipt", definition.Name, index) + } + } + } + return nil +} + +func jsonPointerValue(value any, pointer string) (any, bool) { + if pointer == "" { + return value, true + } + if !strings.HasPrefix(pointer, "/") { + return nil, false + } + current := value + for _, encoded := range strings.Split(strings.TrimPrefix(pointer, "/"), "/") { + segment, ok := decodeJSONPointerSegment(encoded) + if !ok { + return nil, false + } + switch container := current.(type) { + case map[string]any: + current, ok = container[segment] + if !ok { + return nil, false + } + case []any: + index, err := strconv.Atoi(segment) + if err != nil || index < 0 || index >= len(container) { + return nil, false + } + current = container[index] + default: + return nil, false + } + } + return current, true +} + +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 normalizedJSONValue(value any) any { + encoded, err := json.Marshal(value) + if err != nil { + return value + } + var normalized any + if err := json.Unmarshal(encoded, &normalized); err != nil { + return value + } + return normalized +} + +func jsonInteger(value any) (int64, bool) { + number, ok := value.(float64) + if !ok || number != float64(int64(number)) { + return 0, false + } + return int64(number), true +} + +func resultProtocolError(format string, args ...any) error { + return errs.NewInternalError(errs.SubtypeUnknown, format, args...) +} diff --git a/shortcuts/common/typed_result_protocol_test.go b/shortcuts/common/typed_result_protocol_test.go new file mode 100644 index 0000000000..b5144822ae --- /dev/null +++ b/shortcuts/common/typed_result_protocol_test.go @@ -0,0 +1,155 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/errs" +) + +type protocolArtifact struct { + Path string `json:"path"` + Size int64 `json:"size"` + MediaType string `json:"media_type"` +} +type protocolData struct { + Artifacts []protocolArtifact `json:"artifacts"` + Failures []compilerItem `json:"failures"` +} + +func TestValidateTypedResultProtocolArtifactAndPartial(t *testing.T) { + command := &compiledCommand{output: OutputDefinition{ + Artifacts: []ArtifactDefinition{{Name: "files", ItemsPath: "/artifacts", PathField: "/path", SizeField: "/size", MediaTypeField: "/media_type"}}, + Outcomes: OutcomeDefinition{PartialFailure: &PartialFailureDefinition{ExitCode: 7, FailedItems: &FailedItemDefinition{ItemsPath: "/failures", IdentityPaths: []string{"/id"}, StatePath: "/state", FailedValues: []JSONValue{"failed"}}}}, + }} + result := compiledResult{outcome: OutcomePartial, data: protocolData{ + Artifacts: []protocolArtifact{{Path: "artifacts/artifact.bin", Size: 3, MediaType: "application/octet-stream"}}, + Failures: []compilerItem{{ID: "item-1", State: "failed"}}, + }} + if err := validateTypedResultProtocol(command, result); err != nil { + t.Fatal(err) + } +} + +func TestValidateTypedResultMetaContract(t *testing.T) { + count := 2 + validComplete := &ResultPaginationMeta{Complete: true, Pages: 1, Items: 0} + validIncomplete := &ResultPaginationMeta{Complete: false, Pages: 2, Items: 3, NextToken: "next"} + for _, meta := range []*ResultMeta{ + {Count: &count}, + {Pagination: validComplete}, + {Count: &count, Pagination: validIncomplete}, + } { + if err := validateTypedResultMeta(ResultMetaDefinition{Count: true, Pagination: true}, meta); err != nil { + t.Fatalf("valid meta %#v: %v", meta, err) + } + } + + negative := -1 + tests := []struct { + name string + definition ResultMetaDefinition + meta *ResultMeta + want string + }{ + {name: "empty", definition: ResultMetaDefinition{Count: true}, meta: &ResultMeta{}, want: "Meta is empty"}, + {name: "undeclared count", meta: &ResultMeta{Count: &count}, want: "undeclared meta.count"}, + {name: "negative count", definition: ResultMetaDefinition{Count: true}, meta: &ResultMeta{Count: &negative}, want: "count must be non-negative"}, + {name: "undeclared pagination", meta: &ResultMeta{Pagination: validComplete}, want: "undeclared meta.pagination"}, + {name: "zero pages", definition: ResultMetaDefinition{Pagination: true}, meta: &ResultMeta{Pagination: &ResultPaginationMeta{Complete: true}}, want: "pages must be at least 1"}, + {name: "negative items", definition: ResultMetaDefinition{Pagination: true}, meta: &ResultMeta{Pagination: &ResultPaginationMeta{Complete: true, Pages: 1, Items: -1}}, want: "items must be non-negative"}, + {name: "complete with token", definition: ResultMetaDefinition{Pagination: true}, meta: &ResultMeta{Pagination: &ResultPaginationMeta{Complete: true, Pages: 1, NextToken: "next"}}, want: "must not include next_token"}, + {name: "incomplete without token", definition: ResultMetaDefinition{Pagination: true}, meta: &ResultMeta{Pagination: &ResultPaginationMeta{Pages: 1}}, want: "must include next_token"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateTypedResultMeta(test.definition, test.meta) + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryInternal || !strings.Contains(problem.Message, test.want) { + t.Fatalf("error = %#v, problem = %#v, want containing %q", err, problem, test.want) + } + }) + } +} + +func TestOutputMetaFromTypedClonesPagination(t *testing.T) { + count := 4 + pagination := &ResultPaginationMeta{Complete: false, Pages: 1, Items: 4, NextToken: "next"} + meta := &ResultMeta{Count: &count, Pagination: pagination} + converted := outputMetaFromTyped(meta) + count = 9 + pagination.NextToken = "mutated" + if converted.Count != 4 || converted.Pagination == nil || converted.Pagination.NextToken != "next" { + t.Fatalf("converted meta was mutated through caller pointers: %#v", converted) + } +} + +func TestValidateTypedResultProtocolRejectsInvalidArtifactReceipt(t *testing.T) { + command := &compiledCommand{output: OutputDefinition{Artifacts: []ArtifactDefinition{{Name: "files", ItemsPath: "/artifacts", PathField: "/path", SizeField: "/size"}}}} + result := compiledResult{outcome: OutcomeSuccess, data: protocolData{Artifacts: []protocolArtifact{{Path: "", Size: -1}}}} + err := validateTypedResultProtocol(command, result) + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryInternal || !strings.Contains(problem.Message, "invalid path receipt") { + t.Fatalf("error = %#v, problem = %#v", err, problem) + } +} + +func TestValidateTypedResultProtocolOptionalArtifact(t *testing.T) { + command := &compiledCommand{output: OutputDefinition{Artifacts: []ArtifactDefinition{{Name: "file", ItemsPath: "/artifact", Optional: true, PathField: "/path", SizeField: "/size", MediaTypeField: "/media_type"}}}} + for _, data := range []any{map[string]any{}, map[string]any{"artifact": nil}} { + if err := validateTypedResultProtocol(command, compiledResult{outcome: OutcomeSuccess, data: data}); err != nil { + t.Fatalf("optional artifact data %#v: %v", data, err) + } + } + valid := map[string]any{"artifact": map[string]any{"path": "file.bin", "size": 3, "media_type": "application/octet-stream"}} + if err := validateTypedResultProtocol(command, compiledResult{outcome: OutcomeSuccess, data: valid}); err != nil { + t.Fatalf("present valid optional artifact: %v", err) + } + for _, test := range []struct { + name string + data map[string]any + want string + }{ + {name: "path", data: map[string]any{"artifact": map[string]any{"path": "", "size": 3, "media_type": "application/octet-stream"}}, want: "invalid path receipt"}, + {name: "size", data: map[string]any{"artifact": map[string]any{"path": "file.bin", "size": -1, "media_type": "application/octet-stream"}}, want: "invalid size receipt"}, + {name: "media type", data: map[string]any{"artifact": map[string]any{"path": "file.bin", "size": 3, "media_type": 7}}, want: "non-string media type receipt"}, + } { + t.Run(test.name, func(t *testing.T) { + err := validateTypedResultProtocol(command, compiledResult{outcome: OutcomeSuccess, data: test.data}) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want containing %q", err, test.want) + } + }) + } +} + +func TestValidateTypedResultProtocolRequiredArtifactRejectsMissingReceipt(t *testing.T) { + command := &compiledCommand{output: OutputDefinition{Artifacts: []ArtifactDefinition{{Name: "file", ItemsPath: "/artifact", PathField: "/path"}}}} + for _, data := range []any{map[string]any{}, map[string]any{"artifact": nil}} { + err := validateTypedResultProtocol(command, compiledResult{outcome: OutcomeSuccess, data: data}) + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryInternal || !strings.Contains(problem.Message, "is missing from Data") { + t.Fatalf("required artifact data %#v: error = %#v, problem = %#v", data, err, problem) + } + } +} + +func TestValidateTypedResultProtocolAcceptsResultLevelPartial(t *testing.T) { + command := &compiledCommand{output: OutputDefinition{Outcomes: OutcomeDefinition{PartialFailure: &PartialFailureDefinition{ExitCode: 7}}}} + data := map[string]any{"resource_id": "resource-1", "reason": "follow-up write failed"} + if err := validateTypedResultProtocol(command, compiledResult{outcome: OutcomePartial, data: data}); err != nil { + t.Fatal(err) + } +} + +func TestValidateTypedResultProtocolRejectsEmptyPartial(t *testing.T) { + command := &compiledCommand{output: OutputDefinition{Outcomes: OutcomeDefinition{PartialFailure: &PartialFailureDefinition{ExitCode: 7, FailedItems: &FailedItemDefinition{ItemsPath: "/failures", AllItems: true}}}}} + err := validateTypedResultProtocol(command, compiledResult{outcome: OutcomePartial, data: protocolData{Failures: []compilerItem{}}}) + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryInternal || !strings.Contains(problem.Message, "no failed items") { + t.Fatalf("error = %#v, problem = %#v", err, problem) + } +} diff --git a/shortcuts/common/typed_runner.go b/shortcuts/common/typed_runner.go new file mode 100644 index 0000000000..bb9cc1469a --- /dev/null +++ b/shortcuts/common/typed_runner.go @@ -0,0 +1,228 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "io" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/client" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" +) + +func runTypedShortcut(cmdFactory *cmdutil.Factory, runtime *RuntimeContext, shortcut *Shortcut) error { + command := shortcut.typed + if command == nil { + return errs.NewInternalError(errs.SubtypeUnknown, "typed runner received a legacy shortcut") + } + if err := validateTypedStdinInputs(runtime, command); err != nil { + return err + } + if err := resolveInputFlags(runtime, shortcut.Flags); err != nil { + return attributeAliasValidationError(runtime, err) + } + bound, err := bindTypedArgs(runtime, command) + if err != nil { + return attributeAliasValidationError(runtime, err) + } + commandContext := typedCommandContext{runtime: runtime, command: command} + if command.hooks.normalize != nil { + if err := command.hooks.normalize(runtime.ctx, commandContext, bound.value); err != nil { + return attributeAliasValidationError(runtime, err) + } + } + if err := validateCompiledRelations(command, bound.value, bound.provided, StageAfterPrepare); err != nil { + return err + } + if command.hooks.validate != nil { + if err := command.hooks.validate(runtime.ctx, commandContext, bound.value); err != nil { + return attributeAliasValidationError(runtime, err) + } + } + if runtime.Bool("dry-run") { + if command.hooks.dryRun == nil { + return ValidationErrorf("--dry-run is not supported for %s %s", shortcut.Service, shortcut.Command).WithParam("--dry-run") + } + preview := command.hooks.dryRun(runtime.ctx, commandContext, bound.value) + if preview != nil { + preview.Context(runtime.Config.AppID, runtime.UserOpenId()) + } + return cmdutil.WriteDryRun(preview, cmdutil.DryRunOutputOptions{Format: runtime.Format, JqExpr: runtime.JqExpr, CommandPath: runtime.Cmd.CommandPath(), Identity: runtime.As(), Out: cmdFactory.IOStreams.Out, ErrOut: cmdFactory.IOStreams.ErrOut}) + } + if shortcut.Risk == string(RiskHighRiskWrite) && !runtime.Bool("yes") { + return cmdutil.RequireConfirmation(shortcut.Service + " " + shortcut.Command) + } + result, err := command.hooks.execute(runtime.ctx, commandContext, bound.value) + if err != nil { + if result.outcome != "" || result.meta != nil { + return errs.NewInternalError(errs.SubtypeUnknown, "typed Execute returned both Result and error").WithCause(err) + } + return attributeAliasValidationError(runtime, err) + } + if err := validateTypedResultProtocol(command, result); err != nil { + return err + } + return emitTypedResult(runtime, command, result) +} + +func validateTypedStdinInputs(runtime *RuntimeContext, command *compiledCommand) error { + var selected []string + for _, field := range command.fields { + supportsStdin := false + for _, source := range field.cli.ValueSources { + if source == SourceStdin { + supportsStdin = true + break + } + } + if !supportsStdin { + continue + } + names := []string{field.name} + for _, alias := range field.cli.Aliases { + names = append(names, alias.Name) + } + for _, name := range names { + flag := runtime.Cmd.Flags().Lookup(name) + if flag == nil || !flag.Changed { + continue + } + value, err := runtime.Cmd.Flags().GetString(name) + if err != nil { + return errs.NewInternalError(errs.SubtypeUnknown, "failed to inspect stdin source --%s", name).WithCause(err) + } + if value == "-" { + selected = append(selected, field.name) + break + } + } + } + if len(selected) > 1 { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "at most one parameter may read stdin in one invocation; use @file for the others").WithParam("--" + selected[1]) + } + return nil +} + +func emitTypedResult(runtime *RuntimeContext, command *compiledCommand, result compiledResult) error { + if result.outcome == "" { + return errs.NewInternalError(errs.SubtypeUnknown, "typed Execute returned a Result without Outcome") + } + var pretty output.PrettyRenderer + if runtime.Format == "pretty" { + if renderer := command.hooks.renderers["pretty"]; renderer != nil { + pretty = func(w io.Writer, _ bool) error { return renderer(w, result.data) } + } + } + format := runtime.Format + if command.output.Mode == OutputFixedJSON { + // Compatibility for Legacy hooks that used RuntimeContext.Out: the + // injected --format flag existed but output was always JSON. + format = "" + } + options := output.EmitOptions{Format: format, Raw: command.output.DisableHTMLEscaping, JQ: runtime.JqExpr, Pretty: pretty, Meta: outputMetaFromTyped(result.meta)} + switch result.outcome { + case OutcomeSuccess: + runtime.handleEmitterError(runtime.newEmitter().Success(result.data, options)) + return runtime.outputErr + case OutcomePartial: + partial := command.output.Outcomes.PartialFailure + if partial == nil { + return errs.NewInternalError(errs.SubtypeUnknown, "typed Execute returned Partial but Output does not declare partial failure") + } + runtime.handleEmitterError(runtime.newEmitter().PartialFailure(result.data, options)) + if runtime.outputErr != nil { + return runtime.outputErr + } + return output.PartialFailure(partial.ExitCode) + default: + return errs.NewInternalError(errs.SubtypeUnknown, "typed Execute returned invalid Outcome %q", result.outcome) + } +} + +func outputMetaFromTyped(meta *ResultMeta) *output.Meta { + if meta == nil { + return nil + } + converted := &output.Meta{} + if meta.Count != nil { + converted.Count = *meta.Count + } + if meta.Pagination != nil { + pagination := *meta.Pagination + converted.Pagination = &pagination + } + return converted +} + +type typedCommandContext struct { + runtime *RuntimeContext + command *compiledCommand +} + +func (c typedCommandContext) Identity() Identity { return Identity(c.runtime.As()) } +func (c typedCommandContext) Config() core.CliConfig { return *c.runtime.Config } +func (c typedCommandContext) APIClient() (*client.APIClient, error) { return c.runtime.getAPIClient() } +func (c typedCommandContext) FileIO() fileio.FileIO { return c.runtime.FileIO() } +func (c typedCommandContext) InputResolvedFromSource(param string) bool { + if c.runtime.InputResolvedFromSource(param) { + return true + } + fieldIndex, ok := c.command.fieldByName[param] + if !ok { + return false + } + for _, alias := range c.command.fields[fieldIndex].cli.Aliases { + if alias.Mode == AliasIndependent && c.runtime.InputResolvedFromSource(alias.Name) { + return true + } + } + return false +} +func (c typedCommandContext) ValidatePath(path string) error { return c.runtime.ValidatePath(path) } +func (c typedCommandContext) ResolveSavePath(path string) (string, error) { + return c.runtime.ResolveSavePath(path) +} +func (c typedCommandContext) Stderr() io.Writer { return c.runtime.IO().ErrOut } +func (c typedCommandContext) StartSpinner(label string) func() { + return c.runtime.StartSpinner(label) +} +func (c typedCommandContext) PresentError(err error) error { return c.runtime.PresentError(err) } +func (c typedCommandContext) typedCommandPath() string { + if c.runtime == nil || c.runtime.Cmd == nil { + return "" + } + return strings.TrimPrefix(c.runtime.Cmd.CommandPath(), "lark ") +} +func (c typedCommandContext) RequireConditionalScopes(scopes ...string) error { + identity := c.Identity() + authorization, ok := c.command.metadata.Authorization.Identities[identity] + if !ok { + return errs.NewInternalError(errs.SubtypeUnknown, "typed shortcut %s %s has no authorization contract for identity %q", c.command.metadata.Service, c.command.metadata.Command, identity) + } + declared := make(map[string]struct{}) + for _, conditional := range authorization.ConditionalScopes { + for _, scope := range conditional.Scopes { + declared[scope] = struct{}{} + } + } + seen := make(map[string]struct{}, len(scopes)) + requested := make([]string, 0, len(scopes)) + for _, scope := range scopes { + if _, duplicate := seen[scope]; duplicate { + continue + } + seen[scope] = struct{}{} + if _, ok := declared[scope]; !ok { + return errs.NewInternalError(errs.SubtypeUnknown, "typed shortcut %s %s requested undeclared conditional scope %q for identity %q", c.command.metadata.Service, c.command.metadata.Command, scope, identity) + } + requested = append(requested, scope) + } + return c.runtime.EnsureScopes(requested) +} + +var _ CommandContext = typedCommandContext{} diff --git a/shortcuts/common/typed_runner_test.go b/shortcuts/common/typed_runner_test.go new file mode 100644 index 0000000000..120cf48ad1 --- /dev/null +++ b/shortcuts/common/typed_runner_test.go @@ -0,0 +1,624 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" + "github.com/spf13/cobra" +) + +type typedRunnerPayload struct { + Name string `json:"name" schema:"required" doc:"payload name"` +} +type typedRunnerArgs struct { + Token string `flag:"token" schema:"required;minLength=1" doc:"target token"` + Count Provided[int] `flag:"count" schema:"optional;default=7;minimum=0" doc:"item count"` + Enabled Provided[bool] `flag:"enabled" schema:"optional;default=true" doc:"enabled state"` + Payload *typedRunnerPayload `flag:"payload" schema:"optional;nullable" cli:"sources=flag|stdin;encoding=json" doc:"JSON payload"` + Template *typedRunnerPayload `flag:"template" schema:"optional;nullable" cli:"sources=flag|stdin;encoding=json" doc:"JSON template"` + Prepared string `arg:"local"` +} +type typedRunnerItem struct { + State string `json:"state" schema:"required" doc:"item state"` +} + +type typedRunnerData struct { + Token string `json:"token" schema:"required" doc:"bound token"` + Count int `json:"count" schema:"required" doc:"bound count"` + CountSet bool `json:"count_set" schema:"required" doc:"whether count was explicit"` + Enabled bool `json:"enabled" schema:"required" doc:"bound enabled state"` + Prepared string `json:"prepared" schema:"required" doc:"normalized value"` + Items []typedRunnerItem `json:"items" schema:"required;nonnullable" doc:"item outcomes"` +} + +func typedRunnerDefinition(capture func(*typedRunnerArgs), partial bool) Definition[typedRunnerArgs, typedRunnerData] { + outputDefinition := OutputDefinition{} + if partial { + outputDefinition.Outcomes.PartialFailure = &PartialFailureDefinition{ExitCode: 9, FailedItems: &FailedItemDefinition{ItemsPath: "/items", StatePath: "/state", FailedValues: []JSONValue{"failed"}}} + } + return Definition[typedRunnerArgs, typedRunnerData]{ + Metadata: CommandMetadata{Service: "fixture", Command: "+typed", Description: "Run typed fixture", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, + Input: InputDefinition{Fields: []InputField{{Name: "token", CLI: CLIInput{Aliases: []FlagAlias{{Name: "legacy-token", Mode: AliasIndependent, Conflict: AliasTrimmedEqualOrError, Hidden: true, Deprecated: true}}}}}}, + Output: outputDefinition, + Hooks: Hooks[typedRunnerArgs, typedRunnerData]{ + Normalize: func(_ context.Context, _ CommandContext, args *typedRunnerArgs) error { + args.Prepared = strings.ToUpper(args.Token) + return nil + }, + Execute: func(_ context.Context, _ CommandContext, args *typedRunnerArgs) (Result[typedRunnerData], error) { + if capture != nil { + capture(args) + } + data := typedRunnerData{Token: args.Token, Count: args.Count.Value, CountSet: args.Count.Set, Enabled: args.Enabled.Value, Prepared: args.Prepared, Items: []typedRunnerItem{{State: "failed"}}} + if partial { + return Partial(data), nil + } + return Success(data), nil + }, + }, + } +} + +func runTypedFixture(t *testing.T, definition Definition[typedRunnerArgs, typedRunnerData], stdin string, args ...string) (string, string, error) { + t.Helper() + factory, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "typed-app", AppSecret: "typed-secret", Brand: core.BrandFeishu}) + factory.IOStreams.In = strings.NewReader(stdin) + root := &cobra.Command{Use: "lark-cli", SilenceUsage: true, SilenceErrors: true} + service := &cobra.Command{Use: "fixture"} + root.AddCommand(service) + Define(definition).Mount(service, factory) + root.SetArgs(append([]string{"fixture", "+typed", "--as", "user"}, args...)) + _, err := root.ExecuteC() + return stdout.String(), stderr.String(), err +} + +func TestTypedHelpSummarizesDeepJSONWithoutExpandingShape(t *testing.T) { + type args struct { + Properties json.RawMessage `flag:"properties" schema:"required" cli:"sources=flag|file;encoding=json" doc:"chart properties"` + } + type data struct { + OK bool `json:"ok" schema:"required" doc:"success state"` + } + deepShape := ObjectShape{Fields: []ValueField{{Name: "level_one", Description: "level one", Required: true, Shape: ObjectShape{Fields: []ValueField{{Name: "secret_depth_field", Description: "deep field", Required: true, Shape: StringShape{}}}}}}} + definition := Definition[args, data]{ + Metadata: CommandMetadata{Service: "fixture", Command: "+deep-json", Description: "deep JSON fixture", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, + Input: InputDefinition{Fields: []InputField{{Name: "properties", Shape: deepShape}}}, + Hooks: Hooks[args, data]{Execute: func(context.Context, CommandContext, *args) (Result[data], error) { + return Success(data{OK: true}), nil + }}, + } + factory, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{}) + service := &cobra.Command{Use: "fixture"} + Define(definition).Mount(service, factory) + command, _, err := service.Find([]string{"+deep-json"}) + 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() + if !strings.Contains(got, "--properties ") || !strings.Contains(got, "accepts inline JSON or @file") { + t.Fatalf("JSON summary missing:\n%s", got) + } + if strings.Contains(got, "secret_depth_field") { + t.Fatalf("deep shape leaked into default Help:\n%s", got) + } + if !strings.Contains(got, "--print-schema") || !strings.Contains(got, "--flag-name") { + t.Fatalf("complex-input introspection flags missing:\n%s", got) + } +} + +func TestTypedHelpSupportsCommandWithoutBusinessParameters(t *testing.T) { + type args struct{} + type data struct { + OK bool `json:"ok" schema:"required" doc:"success state"` + } + definition := Definition[args, data]{ + Metadata: CommandMetadata{Service: "fixture", Command: "+no-input", Description: "no input fixture", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, + Hooks: Hooks[args, data]{Execute: func(context.Context, CommandContext, *args) (Result[data], error) { + return Success(data{OK: true}), nil + }}, + } + factory, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{}) + service := &cobra.Command{Use: "fixture"} + shortcut := Define(definition) + shortcut.Mount(service, factory) + command, _, err := service.Find([]string{"+no-input"}) + 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() + if strings.Contains(got, "Parameters:") { + t.Fatalf("empty Parameters section:\n%s", got) + } + if !strings.Contains(got, "Execution:") || !strings.Contains(got, "Output:") { + t.Fatalf("system sections missing:\n%s", got) + } +} + +func TestTypedMountRejectsPostMountContractMutation(t *testing.T) { + tests := []struct { + name string + mutate func(*cobra.Command) + }{ + {name: "flag", mutate: func(cmd *cobra.Command) { cmd.Flags().String("extra", "", "undeclared input") }}, + {name: "help text", mutate: func(cmd *cobra.Command) { cmd.Long = "replacement help" }}, + {name: "help function", mutate: func(cmd *cobra.Command) { cmd.SetHelpFunc(func(*cobra.Command, []string) {}) }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + factory, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{}) + root := &cobra.Command{Use: "lark-cli"} + service := &cobra.Command{Use: "fixture"} + root.AddCommand(service) + shortcut := Define(typedRunnerDefinition(nil, false)) + shortcut.PostMount = tt.mutate + defer func() { + value := recover() + if value == nil || !strings.Contains(fmt.Sprint(value), "PostMount") { + t.Fatalf("panic = %#v", value) + } + }() + shortcut.Mount(service, factory) + }) + } +} + +func TestTypedMountAllowsNoOpPostMount(t *testing.T) { + factory, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{}) + service := &cobra.Command{Use: "fixture"} + shortcut := Define(typedRunnerDefinition(nil, false)) + shortcut.PostMount = func(*cobra.Command) {} + shortcut.Mount(service, factory) +} + +func TestTypedRunnerInstallsGroupedHelpFromCompiledFacts(t *testing.T) { + factory, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{}) + root := &cobra.Command{Use: "lark-cli"} + service := &cobra.Command{Use: "fixture"} + root.AddCommand(service) + definition := typedRunnerDefinition(nil, true) + definition.Output.Meta = ResultMetaDefinition{Count: true, Pagination: true} + Define(definition).Mount(service, factory) + cmd, _, err := root.Find([]string{"fixture", "+typed"}) + if err != nil { + t.Fatal(err) + } + var out strings.Builder + cmd.SetOut(&out) + cmd.SetErr(&out) + if err := cmd.Help(); err != nil { + t.Fatal(err) + } + got := out.String() + for _, want := range []string{ + "Parameters:\n Required:\n --token ", + " Optional:\n --count ", + "default: 7", "minimum: 0", "accepts inline JSON or stdin with -", + "Constraints:\n at most one parameter may read stdin in one invocation", + "Execution:\n --as ", "--dry-run", + "Output:\n --format ", "partial-failure result", + "JSON and jq envelopes may include meta.count", "pagination metadata reports completion, pages, items", + } { + if !strings.Contains(got, want) { + t.Errorf("help missing %q:\n%s", want, got) + } + } + if strings.Contains(got, "legacy-token") { + t.Fatalf("hidden alias leaked:\n%s", got) + } +} + +func TestTypedHelpPaginationSummaryMatchesExecutableOutputPaths(t *testing.T) { + tests := []struct { + name string + mode OutputMode + pretty bool + want string + mustNotMatch string + }{ + {name: "generic table only", want: "pagination metadata reports completion, pages, items, and a resume token when incomplete; successful table output appends a pagination summary", mustNotMatch: "pretty/table"}, + {name: "generic pretty and table", pretty: true, want: "pagination metadata reports completion, pages, items, and a resume token when incomplete; successful pretty/table output appends a pagination summary"}, + {name: "fixed JSON", mode: OutputFixedJSON, want: "pagination metadata reports completion, pages, items, and a resume token when incomplete", mustNotMatch: "appends a pagination summary"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + definition := typedRunnerDefinition(nil, false) + definition.Output.Mode = test.mode + definition.Output.Meta.Pagination = true + if test.pretty { + definition.Hooks.Renderers = map[string]Renderer[typedRunnerData]{"pretty": func(io.Writer, typedRunnerData) error { return nil }} + } + compiled, err := compileDefinition(definition) + if err != nil { + t.Fatal(err) + } + var text string + for _, fact := range typedHelpFacts(compiled).Output { + if strings.HasPrefix(fact.Text, "pagination metadata") { + text = fact.Text + break + } + } + if text != test.want { + t.Fatalf("pagination help = %q, want %q", text, test.want) + } + if test.mustNotMatch != "" && strings.Contains(text, test.mustNotMatch) { + t.Fatalf("pagination help %q must not contain %q", text, test.mustNotMatch) + } + }) + } +} + +func TestTypedCommandContextProjectsIndependentAliasSourceState(t *testing.T) { + command, err := compileDefinition(validCompilerDefinition()) + if err != nil { + t.Fatal(err) + } + ctx := typedCommandContext{runtime: &RuntimeContext{inputResolved: map[string]bool{"legacy-token": true}}, command: command} + if !ctx.InputResolvedFromSource("token") { + t.Fatal("canonical field did not inherit its independent alias source state") + } +} + +func TestTypedRunnerBindsDefaultsPresenceAliasAndNormalize(t *testing.T) { + var captured typedRunnerArgs + stdout, _, err := runTypedFixture(t, typedRunnerDefinition(func(args *typedRunnerArgs) { captured = *args }, false), "", "--legacy-token", " abc ", "--count", "0", "--enabled=false") + if err != nil { + t.Fatalf("ExecuteC() error = %v", err) + } + if captured.Token != " abc " || captured.Prepared != " ABC " { + t.Fatalf("captured token/prepared = %q/%q", captured.Token, captured.Prepared) + } + if captured.Count.Value != 0 || !captured.Count.Set { + t.Fatalf("Count = %#v", captured.Count) + } + if captured.Enabled.Value || !captured.Enabled.Set { + t.Fatalf("Enabled = %#v", captured.Enabled) + } + var envelope struct { + OK bool `json:"ok"` + Data typedRunnerData `json:"data"` + } + if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { + t.Fatalf("stdout %q: %v", stdout, err) + } + if !envelope.OK || !envelope.Data.CountSet || envelope.Data.Prepared != " ABC " { + t.Fatalf("envelope = %#v", envelope) + } + + var defaults typedRunnerArgs + _, _, err = runTypedFixture(t, typedRunnerDefinition(func(args *typedRunnerArgs) { defaults = *args }, false), "", "--token", "x") + if err != nil { + t.Fatal(err) + } + if defaults.Count != (Provided[int]{Value: 7, Set: false}) || defaults.Enabled != (Provided[bool]{Value: true, Set: false}) { + t.Fatalf("defaults = count %#v enabled %#v", defaults.Count, defaults.Enabled) + } +} + +func TestTypedRunnerResolvesStdinAndRejectsUnknownJSON(t *testing.T) { + var captured typedRunnerArgs + _, _, err := runTypedFixture(t, typedRunnerDefinition(func(args *typedRunnerArgs) { captured = *args }, false), "\uFEFF{\"name\":\"stdin\"}", "--token", "x", "--payload", "-") + if err != nil { + t.Fatalf("ExecuteC() error = %v", err) + } + if captured.Payload == nil || captured.Payload.Name != "stdin" { + t.Fatalf("Payload = %#v", captured.Payload) + } + + _, _, err = runTypedFixture(t, typedRunnerDefinition(nil, false), "", "--token", "x", "--payload", `{\"name\":\"ok\",\"extra\":1}`) + problem, ok := errs.ProblemOf(err) + var validation *errs.ValidationError + if !ok || problem.Category != errs.CategoryValidation || !errors.As(err, &validation) || validation.Param != "--payload" { + t.Fatalf("error = %#v, problem = %#v", err, problem) + } +} + +func TestTypedRunnerRejectsMultipleStdinParametersBeforeReading(t *testing.T) { + _, _, err := runTypedFixture(t, typedRunnerDefinition(nil, false), `{}`, "--token", "x", "--payload", "-", "--template", "-") + problem, ok := errs.ProblemOf(err) + var validation *errs.ValidationError + if !ok || !errors.As(err, &validation) || problem.Subtype != errs.SubtypeInvalidArgument || validation.Param != "--template" { + t.Fatalf("error = %#v, problem = %#v", err, problem) + } +} + +func TestTypedRunnerAliasConflictAndRequiredStructuralError(t *testing.T) { + _, _, err := runTypedFixture(t, typedRunnerDefinition(nil, false), "", "--token", "a", "--legacy-token", "b") + problem, ok := errs.ProblemOf(err) + var validation *errs.ValidationError + if !ok || !errors.As(err, &validation) || validation.Param != "--legacy-token" { + t.Fatalf("error = %v, problem = %#v", err, problem) + } + + _, _, err = runTypedFixture(t, typedRunnerDefinition(nil, false), "") + problem, ok = errs.ProblemOf(err) + if !ok || problem.Message != "--token is required" { + t.Fatalf("missing required error = %v, problem = %#v", err, problem) + } +} + +func TestTypedRunnerEmitsResultLevelPartialWithoutFailedItems(t *testing.T) { + definition := typedRunnerDefinition(nil, true) + definition.Output.Outcomes.PartialFailure.FailedItems = nil + definition.Hooks.Execute = func(_ context.Context, _ CommandContext, args *typedRunnerArgs) (Result[typedRunnerData], error) { + return Partial(typedRunnerData{Token: args.Token, Prepared: "follow-up write failed"}), nil + } + definition.Hooks.Renderers = map[string]Renderer[typedRunnerData]{"pretty": func(w io.Writer, _ typedRunnerData) error { + _, err := io.WriteString(w, "partial pretty must not run") + return err + }} + stdout, stderr, err := runTypedFixture(t, definition, "", "--token", "resource-1", "--format", "pretty") + if output.ExitCodeOf(err) != 9 || stderr != "" { + t.Fatalf("error = %v, exit = %d, stderr = %q", err, output.ExitCodeOf(err), stderr) + } + var envelope struct { + OK bool `json:"ok"` + Data typedRunnerData `json:"data"` + } + if unmarshalErr := json.Unmarshal([]byte(stdout), &envelope); unmarshalErr != nil { + t.Fatalf("stdout = %q: %v", stdout, unmarshalErr) + } + if strings.Contains(stdout, "partial pretty must not run") { + t.Fatalf("partial result used a pretty renderer: %q", stdout) + } + if envelope.OK || envelope.Data.Token != "resource-1" || envelope.Data.Prepared != "follow-up write failed" { + t.Fatalf("envelope = %#v", envelope) + } +} + +func TestTypedRunnerRejectsInvalidPartialReceiptBeforeWritingStdout(t *testing.T) { + definition := typedRunnerDefinition(nil, true) + definition.Hooks.Execute = func(_ context.Context, _ CommandContext, args *typedRunnerArgs) (Result[typedRunnerData], error) { + return Partial(typedRunnerData{Token: args.Token, Items: []typedRunnerItem{}}), nil + } + stdout, _, err := runTypedFixture(t, definition, "", "--token", "x") + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryInternal || stdout != "" { + t.Fatalf("stdout = %q, error = %#v, problem = %#v", stdout, err, problem) + } +} + +func TestTypedRunnerEmitsCountMetaForSuccessJSONAndJQ(t *testing.T) { + definition := typedRunnerDefinition(nil, false) + definition.Output.Meta.Count = true + definition.Hooks.Execute = func(_ context.Context, _ CommandContext, args *typedRunnerArgs) (Result[typedRunnerData], error) { + return Success(typedRunnerData{Token: args.Token, Items: []typedRunnerItem{}}).WithMeta(CountMeta(3)), nil + } + stdout, stderr, err := runTypedFixture(t, definition, "", "--token", "x") + if err != nil || stderr != "" { + t.Fatalf("stdout = %q, stderr = %q, error = %v", stdout, stderr, err) + } + var envelope struct { + Meta output.Meta `json:"meta"` + } + if err := json.Unmarshal([]byte(stdout), &envelope); err != nil || envelope.Meta.Count != 3 { + t.Fatalf("stdout = %q, envelope = %#v, error = %v", stdout, envelope, err) + } + + stdout, stderr, err = runTypedFixture(t, definition, "", "--token", "x", "--jq", ".meta.count") + if err != nil || stderr != "" || strings.TrimSpace(stdout) != "3" { + t.Fatalf("jq stdout = %q, stderr = %q, error = %v", stdout, stderr, err) + } +} + +func TestTypedRunnerEmitsPaginationMetaForSuccessPretty(t *testing.T) { + definition := typedRunnerDefinition(nil, false) + definition.Output.Meta.Pagination = true + definition.Hooks.Execute = func(_ context.Context, _ CommandContext, args *typedRunnerArgs) (Result[typedRunnerData], error) { + pagination := &ResultPaginationMeta{Complete: false, Pages: 2, Items: 1, NextToken: "resume-token"} + return Success(typedRunnerData{Token: args.Token, Items: []typedRunnerItem{{State: "failed"}}}).WithMeta(PaginationResultMeta(pagination)), nil + } + definition.Hooks.Renderers = map[string]Renderer[typedRunnerData]{"pretty": func(w io.Writer, data typedRunnerData) error { + _, err := fmt.Fprintf(w, "token=%s\n", data.Token) + return err + }} + stdout, stderr, err := runTypedFixture(t, definition, "", "--token", "x", "--format", "pretty") + if err != nil || stderr != "" { + t.Fatalf("stdout = %q, stderr = %q, error = %v", stdout, stderr, err) + } + for _, want := range []string{"token=x", "Pagination: incomplete", "2 page(s)", "1 item(s)", `resume token: "resume-token"`} { + if !strings.Contains(stdout, want) { + t.Errorf("pretty stdout missing %q: %q", want, stdout) + } + } + + stdout, stderr, err = runTypedFixture(t, definition, "", "--token", "x", "--format", "table") + if err != nil || stderr != "" || !strings.Contains(stdout, "Pagination: incomplete") || !strings.Contains(stdout, `resume token: "resume-token"`) { + t.Fatalf("table stdout = %q, stderr = %q, error = %v", stdout, stderr, err) + } +} + +func TestTypedRunnerEmitsPaginationMetaForPartialJSONAndJQ(t *testing.T) { + definition := typedRunnerDefinition(nil, true) + definition.Output.Meta.Pagination = true + definition.Hooks.Execute = func(_ context.Context, _ CommandContext, args *typedRunnerArgs) (Result[typedRunnerData], error) { + pagination := &ResultPaginationMeta{Complete: false, Pages: 1, Items: 1, NextToken: "partial-token"} + data := typedRunnerData{Token: args.Token, Items: []typedRunnerItem{{State: "failed"}}} + return Partial(data).WithMeta(PaginationResultMeta(pagination)), nil + } + stdout, stderr, err := runTypedFixture(t, definition, "", "--token", "x") + if output.ExitCodeOf(err) != 9 || stderr != "" { + t.Fatalf("stdout = %q, stderr = %q, error = %v", stdout, stderr, err) + } + var envelope struct { + OK bool `json:"ok"` + Meta output.Meta `json:"meta"` + } + if decodeErr := json.Unmarshal([]byte(stdout), &envelope); decodeErr != nil || envelope.OK || envelope.Meta.Pagination == nil || envelope.Meta.Pagination.NextToken != "partial-token" { + t.Fatalf("stdout = %q, envelope = %#v, decode error = %v", stdout, envelope, decodeErr) + } + + stdout, stderr, err = runTypedFixture(t, definition, "", "--token", "x", "--jq", ".meta.pagination.next_token") + if output.ExitCodeOf(err) != 9 || stderr != "" || strings.TrimSpace(stdout) != "partial-token" { + t.Fatalf("jq stdout = %q, stderr = %q, error = %v", stdout, stderr, err) + } +} + +func TestTypedRunnerTableUsesFrameworkFormatter(t *testing.T) { + stdout, _, err := runTypedFixture(t, typedRunnerDefinition(nil, false), "", "--token", "x", "--format", "table") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout, "state") || !strings.Contains(stdout, "failed") { + t.Fatalf("generic table output missing expected item columns: %q", stdout) + } + if strings.Contains(stdout, `"ok"`) || strings.Contains(stdout, `"data"`) { + t.Fatalf("table output unexpectedly used a JSON envelope: %q", stdout) + } +} + +func TestTypedRunnerGenericPrettyCompatibilityAndOptIn(t *testing.T) { + definition := typedRunnerDefinition(nil, false) + stdout, stderr, err := runTypedFixture(t, definition, "", "--token", "x", "--format", "pretty") + if err != nil || stderr != "" || !json.Valid([]byte(stdout)) || !strings.Contains(stdout, `"ok": true`) { + t.Fatalf("generic pretty fallback: stdout = %q, stderr = %q, error = %v", stdout, stderr, err) + } + + definition.Hooks.Renderers = map[string]Renderer[typedRunnerData]{"pretty": func(w io.Writer, data typedRunnerData) error { + _, err := fmt.Fprintf(w, "prepared=%s\n", data.Prepared) + return err + }} + stdout, stderr, err = runTypedFixture(t, definition, "", "--token", "x", "--format", "pretty") + if err != nil || stderr != "" || stdout != "prepared=X\n" { + t.Fatalf("opt-in pretty renderer: stdout = %q, stderr = %q, error = %v", stdout, stderr, err) + } +} + +func TestTypedRunnerFixedJSONPreservesIgnoredFormatFlags(t *testing.T) { + definition := typedRunnerDefinition(nil, false) + definition.Output.Mode = OutputFixedJSON + for _, format := range []string{"json", "pretty", "table", "ndjson", "csv"} { + t.Run(format, func(t *testing.T) { + stdout, stderr, err := runTypedFixture(t, definition, "", "--token", "x", "--format", format) + if err != nil || stderr != "" || !json.Valid([]byte(stdout)) || !strings.Contains(stdout, `"ok": true`) { + t.Fatalf("stdout = %q, stderr = %q, error = %v", stdout, stderr, err) + } + }) + } +} + +func TestTypedRunnerJSONHTMLEscapingPolicy(t *testing.T) { + const markup = "A&B" + + stdout, _, err := runTypedFixture(t, typedRunnerDefinition(nil, false), "", "--token", markup) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout, `\u003cb\u003eA\u0026B\u003c/b\u003e`) || strings.Contains(stdout, markup) { + t.Fatalf("default JSON did not escape HTML characters: %q", stdout) + } + + definition := typedRunnerDefinition(nil, false) + definition.Output.DisableHTMLEscaping = true + stdout, _, err = runTypedFixture(t, definition, "", "--token", markup) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout, markup) || strings.Contains(stdout, `\u003c`) { + t.Fatalf("unescaped JSON did not preserve markup: %q", stdout) + } + if !json.Valid([]byte(stdout)) { + t.Fatalf("unescaped output is not valid JSON: %q", stdout) + } + + stdout, _, err = runTypedFixture(t, definition, "", "--token", markup, "--jq", ".data.token") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout, markup) || strings.Contains(stdout, `\u003c`) { + t.Fatalf("unescaped jq output did not preserve markup: %q", stdout) + } +} + +func TestTypedRunnerPartialUsesUnescapedJSONPolicy(t *testing.T) { + const markup = "A&B" + definition := typedRunnerDefinition(nil, true) + definition.Output.DisableHTMLEscaping = true + stdout, _, err := runTypedFixture(t, definition, "", "--token", markup) + if output.ExitCodeOf(err) != 9 { + t.Fatalf("error = %v, exit = %d", err, output.ExitCodeOf(err)) + } + if !strings.Contains(stdout, markup) || strings.Contains(stdout, `\u003c`) { + t.Fatalf("partial JSON did not preserve markup: %q", stdout) + } + if !json.Valid([]byte(stdout)) { + t.Fatalf("partial output is not valid JSON: %q", stdout) + } +} + +func TestTypedRunnerPartialUsesDeclaredExitCodeAndSingleEnvelope(t *testing.T) { + stdout, stderr, err := runTypedFixture(t, typedRunnerDefinition(nil, true), "", "--token", "x") + if output.ExitCodeOf(err) != 9 { + t.Fatalf("error = %v, exit = %d", err, output.ExitCodeOf(err)) + } + if stderr != "" { + t.Fatalf("stderr = %q", stderr) + } + var envelope struct { + OK bool `json:"ok"` + Data typedRunnerData `json:"data"` + } + if unmarshalErr := json.Unmarshal([]byte(stdout), &envelope); unmarshalErr != nil { + t.Fatalf("stdout = %q: %v", stdout, unmarshalErr) + } + if envelope.OK || len(envelope.Data.Items) != 1 { + t.Fatalf("envelope = %#v", envelope) + } + if strings.Count(strings.TrimSpace(stdout), "\n{\"") > 0 { + t.Fatalf("stdout contains more than one envelope: %q", stdout) + } +} + +func TestTypedRunnerRejectsResultAndErrorTogether(t *testing.T) { + definition := typedRunnerDefinition(nil, false) + sentinel := errs.NewValidationError(errs.SubtypeFailedPrecondition, "fixture unavailable") + definition.Hooks.Execute = func(context.Context, CommandContext, *typedRunnerArgs) (Result[typedRunnerData], error) { + return Success(typedRunnerData{}), sentinel + } + _, _, err := runTypedFixture(t, definition, "", "--token", "x") + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryInternal || !errors.Is(err, sentinel) { + t.Fatalf("error = %#v, problem = %#v", err, problem) + } +} + +func TestTypedRunnerExecuteErrorPassesThrough(t *testing.T) { + definition := typedRunnerDefinition(nil, false) + sentinel := errs.NewValidationError(errs.SubtypeFailedPrecondition, "fixture unavailable") + definition.Hooks.Execute = func(context.Context, CommandContext, *typedRunnerArgs) (Result[typedRunnerData], error) { + return Result[typedRunnerData]{}, sentinel + } + _, _, err := runTypedFixture(t, definition, "", "--token", "x") + if err != sentinel { + t.Fatalf("error = %v, want sentinel", err) + } + if output.ExitCodeOf(err) != output.ExitValidation { + t.Fatalf("exit code = %d", output.ExitCodeOf(err)) + } +} diff --git a/shortcuts/common/typed_schema.go b/shortcuts/common/typed_schema.go new file mode 100644 index 0000000000..48e3d3668d --- /dev/null +++ b/shortcuts/common/typed_schema.go @@ -0,0 +1,330 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import "fmt" + +// typedSchemaContract is intentionally private during migration. Compiler and +// snapshot tests consume it now; public cmd/schema registration happens only +// after all shortcuts are migrated. +type typedSchemaContract struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema typedSchemaNode `json:"inputSchema"` + OutputSchema typedSchemaNode `json:"outputSchema"` + Meta typedSchemaMeta `json:"_meta"` +} + +type typedSchemaNode struct { + Type string `json:"type,omitempty"` + Description string `json:"description,omitempty"` + Flag string `json:"flag,omitempty"` + Hidden bool `json:"hidden,omitempty"` + Deprecated string `json:"deprecated,omitempty"` + Aliases *[]typedSchemaAlias `json:"aliases,omitempty"` + ValueSources []ValueSource `json:"value_sources,omitempty"` + Enum []JSONValue `json:"enum,omitempty"` + Default *JSONValue `json:"default,omitempty"` + Format string `json:"format,omitempty"` + Minimum *float64 `json:"minimum,omitempty"` + Maximum *float64 `json:"maximum,omitempty"` + MinLength *int `json:"minLength,omitempty"` + MaxLength *int `json:"maxLength,omitempty"` + MinItems *int `json:"minItems,omitempty"` + MaxItems *int `json:"maxItems,omitempty"` + Required *[]string `json:"required,omitempty"` + Properties map[string]typedSchemaNode `json:"properties,omitempty"` + Items *typedSchemaNode `json:"items,omitempty"` + OneOf []typedSchemaNode `json:"oneOf,omitempty"` + Const *JSONValue `json:"const,omitempty"` + AdditionalProperties *JSONValue `json:"additionalProperties,omitempty"` +} + +type typedSchemaAlias struct { + Name string `json:"name"` + Flag string `json:"flag"` + Mode FlagAliasMode `json:"mode"` + Conflict AliasConflictPolicy `json:"conflict,omitempty"` + Hidden bool `json:"hidden,omitempty"` + Deprecated bool `json:"deprecated,omitempty"` +} + +type typedSchemaMeta struct { + EnvelopeVersion string `json:"envelope_version"` + AccessTokens []string `json:"access_tokens"` + Danger bool `json:"danger"` + Risk Risk `json:"risk"` + Authorization typedSchemaAuthorization `json:"authorization"` + CLI typedSchemaCLI `json:"cli"` + Relations []Relation `json:"relations"` + Formats []typedSchemaFormat `json:"formats"` + Outcomes typedSchemaOutcomes `json:"outcomes"` + ResultMeta *typedSchemaNode `json:"result_meta,omitempty"` + Artifacts []ArtifactDefinition `json:"artifacts"` +} + +type typedSchemaAuthorization struct { + Identities map[Identity]IdentityAuthorization `json:"identities"` +} +type typedSchemaCLI struct { + Flags map[string]typedSchemaSystemFlag `json:"flags"` + Constraints []typedSchemaCLIConstraint `json:"constraints"` +} +type typedSchemaSystemFlag struct { + Flag string `json:"flag"` + Short string `json:"short,omitempty"` + Role string `json:"role"` + Type string `json:"type"` + Default *JSONValue `json:"default,omitempty"` + Enum []string `json:"enum,omitempty"` + AliasFor string `json:"alias_for,omitempty"` + Omitted string `json:"omitted,omitempty"` +} +type typedSchemaCLIConstraint struct { + Kind string `json:"kind"` + Param string `json:"param"` + Overrides string `json:"overrides,omitempty"` + When string `json:"when,omitempty"` + AllowedValues []string `json:"allowed_values,omitempty"` +} +type typedSchemaFormat struct { + Name string `json:"name"` + Default bool `json:"default"` + MediaType string `json:"media_type"` + SelectedBy []string `json:"selected_by"` + EscapeHTML *bool `json:"escape_html,omitempty"` +} + +type typedSchemaOutcomes struct { + Success typedSchemaOutcome `json:"success"` + PartialFailure typedSchemaOutcome `json:"partial_failure"` +} +type typedSchemaOutcome struct { + Supported bool `json:"supported"` + EnvelopeOK bool `json:"envelope_ok"` + ExitCode int `json:"exit_code"` + Stdout string `json:"stdout,omitempty"` + FailedItems *FailedItemDefinition `json:"failed_items,omitempty"` +} + +func buildTypedSchemaContract(command *compiledCommand) typedSchemaContract { + required := []string{} + input := typedSchemaNode{Type: "object", Required: &required, Properties: make(map[string]typedSchemaNode)} + closed := JSONValue(false) + input.AdditionalProperties = &closed + for _, field := range command.fields { + node := schemaNodeFromShape(field.shape) + node.Description = field.description + node.Flag = "--" + field.name + node.Hidden = field.cli.Hidden + node.Deprecated = field.cli.Deprecated + aliases := []typedSchemaAlias{} + node.Aliases = &aliases + for _, alias := range field.cli.Aliases { + *node.Aliases = append(*node.Aliases, typedSchemaAlias{Name: alias.Name, Flag: "--" + alias.Name, Mode: alias.Mode, Conflict: alias.Conflict, Hidden: alias.Hidden, Deprecated: alias.Deprecated}) + } + node.ValueSources = append([]ValueSource(nil), field.cli.ValueSources...) + if len(node.ValueSources) == 0 { + node.ValueSources = []ValueSource{SourceFlag} + } + if field.defaultValue.Set { + value := field.defaultValue.Value + node.Default = &value + } + input.Properties[field.name] = node + if field.required { + *input.Required = append(*input.Required, field.name) + } + } + schemaFormats := typedOutputFormats(command) + authorizationIdentities := make(map[Identity]IdentityAuthorization, len(command.metadata.Authorization.Identities)) + for identity, authorization := range command.metadata.Authorization.Identities { + authorization.RequiredScopes = append([]string{}, authorization.RequiredScopes...) + authorization.ConditionalScopes = append([]ConditionalScope{}, authorization.ConditionalScopes...) + authorizationIdentities[identity] = authorization + } + accessTokens := make([]string, 0, len(command.metadata.Authorization.Identities)) + for _, identity := range []Identity{IdentityBot, IdentityUser} { + if _, ok := command.metadata.Authorization.Identities[identity]; ok { + accessTokens = append(accessTokens, string(identity)) + } + } + businessFlags := legacyFlagsFromCompiled(command.fields) + partial := typedSchemaOutcome{Supported: false} + if definition := command.output.Outcomes.PartialFailure; definition != nil { + partial = typedSchemaOutcome{Supported: true, EnvelopeOK: false, ExitCode: definition.ExitCode, Stdout: "result_envelope", FailedItems: definition.FailedItems} + } + return typedSchemaContract{ + Name: command.metadata.Service + " " + command.metadata.Command, + Description: command.metadata.Description, + InputSchema: input, + OutputSchema: schemaNodeFromShape(command.dataShape), + Meta: typedSchemaMeta{ + EnvelopeVersion: "1.0", AccessTokens: accessTokens, Danger: command.metadata.Risk == RiskHighRiskWrite, Risk: command.metadata.Risk, + Authorization: typedSchemaAuthorization{Identities: authorizationIdentities}, + CLI: defaultTypedCLI(accessTokens, businessFlags), Relations: append([]Relation{}, commandInputRelations(command)...), Formats: schemaFormats, + Outcomes: typedSchemaOutcomes{Success: typedSchemaOutcome{Supported: true, EnvelopeOK: true, ExitCode: 0, Stdout: "result_envelope"}, PartialFailure: partial}, + ResultMeta: typedResultMetaSchema(command.output.Meta), + Artifacts: append([]ArtifactDefinition{}, command.output.Artifacts...), + }, + } +} + +func typedResultMetaSchema(definition ResultMetaDefinition) *typedSchemaNode { + if !definition.Count && !definition.Pagination { + return nil + } + additional := JSONValue(false) + result := &typedSchemaNode{Type: "object", Properties: make(map[string]typedSchemaNode), AdditionalProperties: &additional} + if definition.Count { + zero := float64(0) + result.Properties["count"] = typedSchemaNode{Type: "integer", Minimum: &zero, Description: "number of returned business records"} + } + if definition.Pagination { + zero, one := float64(0), float64(1) + required := []string{"complete", "pages", "items"} + pagination := typedSchemaNode{Type: "object", Required: &required, Properties: map[string]typedSchemaNode{ + "complete": {Type: "boolean", Description: "whether the server exhausted the result set"}, + "pages": {Type: "integer", Minimum: &one, Description: "successful API pages included in the result"}, + "items": {Type: "integer", Minimum: &zero, Description: "records returned after command-level processing"}, + "next_token": {Type: "string", Description: "resume token for an incomplete result"}, + }, AdditionalProperties: &additional} + result.Properties["pagination"] = pagination + } + return result +} + +func commandInputRelations(command *compiledCommand) []Relation { + result := make([]Relation, 0, len(command.relations)) + for _, relation := range command.relations { + params := make([]string, 0, len(relation.fields)) + for _, index := range relation.fields { + params = append(params, command.fields[index].name) + } + result = append(result, Relation{Kind: relation.kind, Params: params, Presence: relation.presence, Stage: relation.stage}) + } + return result +} + +func schemaNodeFromShape(shape ValueShape) typedSchemaNode { + switch value := shape.(type) { + case anyJSONShape: + return typedSchemaNode{} + case StringShape: + node := typedSchemaNode{Type: "string", Format: value.Format, MinLength: value.MinLength, MaxLength: value.MaxLength} + for _, item := range value.Enum { + node.Enum = append(node.Enum, item) + } + return node + case BooleanShape: + node := typedSchemaNode{Type: "boolean"} + for _, item := range value.Enum { + node.Enum = append(node.Enum, item) + } + return node + case IntegerShape: + node := typedSchemaNode{Type: "integer"} + if value.Minimum != nil { + v := float64(*value.Minimum) + node.Minimum = &v + } + if value.Maximum != nil { + v := float64(*value.Maximum) + node.Maximum = &v + } + for _, item := range value.Enum { + node.Enum = append(node.Enum, item) + } + return node + case NumberShape: + node := typedSchemaNode{Type: "number", Minimum: value.Minimum, Maximum: value.Maximum} + for _, item := range value.Enum { + node.Enum = append(node.Enum, item) + } + return node + case NullShape: + return typedSchemaNode{Type: "null"} + case ConstShape: + item := value.Value + return typedSchemaNode{Const: &item} + case ArrayShape: + item := schemaNodeFromShape(value.Items) + return typedSchemaNode{Type: "array", Items: &item, MinItems: value.MinItems, MaxItems: value.MaxItems} + case ObjectShape: + additional := JSONValue(value.AdditionalProperties) + if value.AdditionalPropertiesShape != nil { + additional = schemaNodeFromShape(value.AdditionalPropertiesShape) + } + required := []string{} + node := typedSchemaNode{Type: "object", Required: &required, Properties: make(map[string]typedSchemaNode), AdditionalProperties: &additional} + for _, field := range value.Fields { + child := schemaNodeFromShape(field.Shape) + child.Description = field.Description + node.Properties[field.Name] = child + if field.Required { + *node.Required = append(*node.Required, field.Name) + } + } + return node + case OneOfShape: + node := typedSchemaNode{} + for _, variant := range value.Variants { + node.OneOf = append(node.OneOf, schemaNodeFromShape(variant)) + } + return node + default: + panic(fmt.Sprintf("uncompiled ValueShape %T", shape)) + } +} + +func typedOutputFormats(command *compiledCommand) []typedSchemaFormat { + jsonSelectors := []string{"json"} + if command.output.Mode == OutputFixedJSON { + jsonSelectors = []string{"json", "pretty", "table", "ndjson", "csv"} + } else if command.hooks.renderers["pretty"] == nil { + // Legacy OutFormat accepts --format pretty without a renderer and falls + // back to the JSON envelope. Keep that argv compatibility explicit while + // advertising only output formats the command can actually produce. + jsonSelectors = append(jsonSelectors, "pretty") + } + escapeHTML := !command.output.DisableHTMLEscaping + formats := []typedSchemaFormat{{Name: "json", Default: true, MediaType: "application/json", SelectedBy: jsonSelectors, EscapeHTML: &escapeHTML}} + if command.output.Mode == OutputFixedJSON { + return formats + } + if command.hooks.renderers["pretty"] != nil { + formats = append(formats, typedSchemaFormat{Name: "pretty", MediaType: "text/plain", SelectedBy: []string{"pretty"}}) + } + return append(formats, + typedSchemaFormat{Name: "table", MediaType: "text/plain", SelectedBy: []string{"table"}}, + typedSchemaFormat{Name: "ndjson", MediaType: "application/x-ndjson", SelectedBy: []string{"ndjson"}}, + typedSchemaFormat{Name: "csv", MediaType: "text/csv", SelectedBy: []string{"csv"}}, + ) +} + +func defaultTypedCLI(identities []string, businessFlags []Flag) typedSchemaCLI { + falseValue, jsonValue := JSONValue(false), JSONValue("json") + format := typedSchemaSystemFlag{Flag: "--format", Role: "output", Type: "string", Default: &jsonValue, Enum: []string{"json", "pretty", "table", "ndjson", "csv"}} + for _, flag := range businessFlags { + if flag.Name != "format" { + continue + } + value := JSONValue(flag.Default) + format.Default = &value + format.Enum = append([]string(nil), flag.Enum...) + break + } + view := Shortcut{Flags: businessFlags} + cli := typedSchemaCLI{Flags: map[string]typedSchemaSystemFlag{ + "as": {Flag: "--as", Role: "identity", Type: "string", Enum: identities, Omitted: "resolve profile default, then auto-detect"}, + "dry-run": {Flag: "--dry-run", Role: "execution", Type: "boolean", Default: &falseValue}, + "format": format, + "jq": {Flag: "--jq", Short: "-q", Role: "output", Type: "string"}, + }, Constraints: []typedSchemaCLIConstraint{{Kind: "requires_format", Param: "jq", AllowedValues: []string{"json"}}}} + if !shortcutDeclaresJSONFlag(&view) && shortcutFormatSupportsJSON(&view) { + cli.Flags["json"] = typedSchemaSystemFlag{Flag: "--json", Role: "output", Type: "boolean", AliasFor: "--format=json"} + cli.Constraints = append([]typedSchemaCLIConstraint{{Kind: "overrides", Param: "format", Overrides: "json", When: "both_explicit"}}, cli.Constraints...) + } + return cli +} diff --git a/shortcuts/common/typed_shape.go b/shortcuts/common/typed_shape.go new file mode 100644 index 0000000000..e09880d729 --- /dev/null +++ b/shortcuts/common/typed_shape.go @@ -0,0 +1,71 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +// ValueShape is the closed set of JSON shapes accepted by Typed Shortcut. +type ValueShape interface{ valueShape() } + +type StringShape struct { + Enum []string + Format string + MinLength *int + MaxLength *int +} +type BooleanShape struct{ Enum []bool } +type IntegerShape struct { + Enum []int64 + Minimum *int64 + Maximum *int64 +} +type NumberShape struct { + Enum []float64 + Minimum *float64 + Maximum *float64 +} +type NullShape struct{} +type ConstShape struct{ Value JSONValue } +type ArrayShape struct { + Items ValueShape + MinItems *int + MaxItems *int +} +type ObjectShape struct { + Fields []ValueField + AdditionalProperties bool + AdditionalPropertiesShape ValueShape +} +type ValueField struct { + Name string + Description string + Required bool + Shape ValueShape +} +type OneOfShape struct{ Variants []ValueShape } + +// anyJSONShape is inferred only for Data=any. It is intentionally unexported: +// arbitrary JSON is a migration escape hatch for an established standard +// envelope that already forwarded every JSON value, not a general input shape. +type anyJSONShape struct{} + +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() {} +func (anyJSONShape) valueShape() {} + +type DataDefinition struct { + Shape ValueShape + Overrides []DataField +} + +type DataField struct { + Path string + Description string + Shape ValueShape +} diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index 02bc04dd50..b787527b40 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -95,6 +95,11 @@ type Shortcut struct { // has attached it to the parent. Use it to install custom help functions or // tweak the command; cmd.Parent() is available at this point. PostMount func(cmd *cobra.Command) + + // typed is the fully compiled contract produced by Define. It remains + // private so legacy registry and public Schema cannot observe a partially + // migrated Typed Shortcut. + typed *compiledCommand } // ScopesForIdentity returns the scopes applicable for the given identity. From c9e06c070126da54ffd2ac428e349b74a0e96dd5 Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:18:17 +0800 Subject: [PATCH 02/47] feat(extension): add public command contract --- extension/command/command_test.go | 196 +++++++++++++++++ extension/command/context.go | 112 ++++++++++ extension/command/definition.go | 233 ++++++++++++++++++++ extension/command/domain.go | 62 ++++++ extension/command/domains_gen.go | 49 +++++ extension/command/dryrun.go | 100 +++++++++ extension/command/errors.go | 70 ++++++ extension/command/generate.go | 6 + extension/command/host.go | 291 +++++++++++++++++++++++++ extension/command/internal/gen/main.go | 97 +++++++++ extension/command/output.go | 93 ++++++++ extension/command/pagination.go | 164 ++++++++++++++ extension/command/request.go | 142 ++++++++++++ extension/command/shape.go | 86 ++++++++ 14 files changed, 1701 insertions(+) create mode 100644 extension/command/command_test.go create mode 100644 extension/command/context.go create mode 100644 extension/command/definition.go create mode 100644 extension/command/domain.go create mode 100644 extension/command/domains_gen.go create mode 100644 extension/command/dryrun.go create mode 100644 extension/command/errors.go create mode 100644 extension/command/generate.go create mode 100644 extension/command/host.go create mode 100644 extension/command/internal/gen/main.go create mode 100644 extension/command/output.go create mode 100644 extension/command/pagination.go create mode 100644 extension/command/request.go create mode 100644 extension/command/shape.go diff --git a/extension/command/command_test.go b/extension/command/command_test.go new file mode 100644 index 0000000000..098d650bcd --- /dev/null +++ b/extension/command/command_test.go @@ -0,0 +1,196 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package command + +import ( + "context" + "go/parser" + "go/token" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" +) + +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"} + tips := []string{"Example"} + definition := Definition[contractArgs, contractData]{ + Metadata: CommandMetadata{ + Service: "im", Command: "+contract-copy", Description: "Copy test", Risk: RiskRead, Tips: tips, + 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" + tips[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) + } + if !reflect.DeepEqual(host.Metadata.Tips, []string{"Example"}) { + t.Fatalf("tips = %#v", host.Metadata.Tips) + } +} + +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 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 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, + CallJSON: func(_ context.Context, request Request) (map[string]any, error) { + calls = append(calls, InspectRequest(request)) + response := responses[0] + responses = responses[1:] + return response, nil + }, + PaginationOptions: func() (PaginationOptions, error) { + return PaginationOptions{All: true, MaxPages: 10}, nil + }, + }) + 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 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 + }, + }) + 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 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) + } + } + } +} diff --git a/extension/command/context.go b/extension/command/context.go new file mode 100644 index 0000000000..239dc400ed --- /dev/null +++ b/extension/command/context.go @@ -0,0 +1,112 @@ +// 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 + callJSON func(context.Context, Request) (map[string]any, error) + preflightScopes func(...string) error + paginationOptions func() (PaginationOptions, 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 + CallJSON func(context.Context, Request) (map[string]any, error) + PreflightScopes func(...string) error + PaginationOptions func() (PaginationOptions, error) +} + +// NewCommandContext creates a restricted context from host callbacks. +func NewCommandContext(options ContextOptions) CommandContext { + return CommandContext{ + identity: options.Identity, + dryRun: options.DryRun, + callJSON: options.CallJSON, + preflightScopes: options.PreflightScopes, + paginationOptions: options.PaginationOptions, + } +} + +// Identity 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.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...) +} + +func (c CommandContext) pageOptions() (PaginationOptions, error) { + if c.paginationOptions == nil { + return PaginationOptions{MaxPages: 1}, nil + } + return c.paginationOptions() +} + +func waitForPage(ctx context.Context, delay time.Duration) error { + if delay <= 0 { + return nil + } + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return PaginationInterruptedError(ctx.Err()) + case <-timer.C: + return nil + } +} diff --git a/extension/command/definition.go b/extension/command/definition.go new file mode 100644 index 0000000000..91452ffbb3 --- /dev/null +++ b/extension/command/definition.go @@ -0,0 +1,233 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package command defines the public contract for build-time command extensions. +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 string + Command string + Description string + Risk Risk + Hidden bool + Tips []string + 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 @relative-path input. + SourceFile ValueSource = "file" + // SourceStdin accepts a single dash and reads standard input. + 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 func(context.Context, CommandContext, *Args) error + Validate func(context.Context, CommandContext, *Args) error + DryRun func(context.Context, CommandContext, *Args) *DryRun + Execute func(context.Context, CommandContext, *Args) (Result[Data], error) + Renderers map[string]Renderer[Data] +} + +// Renderer renders one successful result in a supported custom format. +type Renderer[Data any] func(io.Writer, Data) error + +// 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..48e7d14052 --- /dev/null +++ b/extension/command/domain.go @@ -0,0 +1,62 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package command + +// DomainName is a generated name of an existing shortcut domain. +type DomainName string + +type domainKind uint8 + +const ( + domainExtended domainKind = iota + 1 + domainNew +) + +// Domain is an opaque declaration of where a command set is mounted. +type Domain struct { + kind domainKind + name string + options []DomainOption +} + +// DomainOption is an opaque property declaration reserved for future new domains. +type DomainOption struct { + kind domainOptionKind + lang string + value string +} + +type domainOptionKind uint8 + +const ( + domainTitle domainOptionKind = iota + 1 + domainDescription +) + +// ExtendDomain declares that a set adds commands to an existing domain. +func ExtendDomain(name DomainName) Domain { + return Domain{kind: domainExtended, name: string(name)} +} + +// NewDomain fixes the future new-domain API shape. V1 host compilation rejects it. +func NewDomain(name string, opts ...DomainOption) Domain { + return Domain{kind: domainNew, name: name, options: append([]DomainOption(nil), opts...)} +} + +// Title declares one localized title for a future new domain. +func Title(lang, s string) DomainOption { + return DomainOption{kind: domainTitle, lang: lang, value: s} +} + +// Description declares one localized description for a future new domain. +func Description(lang, s string) DomainOption { + return DomainOption{kind: domainDescription, lang: lang, value: s} +} + +// Set groups commands mounted into one domain. +type Set struct { + _ struct{} + Domain Domain + Commands []Command +} diff --git a/extension/command/domains_gen.go b/extension/command/domains_gen.go new file mode 100644 index 0000000000..b33612e8d1 --- /dev/null +++ b/extension/command/domains_gen.go @@ -0,0 +1,49 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Code generated from shortcuts.AllShortcuts; DO NOT EDIT. + +package command + +const ( + // DomainApplication 表示应用管理域。 + DomainApplication DomainName = "application" + // DomainApps 表示应用域。 + DomainApps DomainName = "apps" + // DomainBase 表示多维表格域。 + DomainBase DomainName = "base" + // DomainCalendar 表示日历域。 + DomainCalendar DomainName = "calendar" + // DomainContact 表示通讯录域。 + DomainContact DomainName = "contact" + // DomainDocs 表示文档域。 + DomainDocs DomainName = "docs" + // DomainDrive 表示云空间域。 + DomainDrive DomainName = "drive" + // DomainEvent 表示事件订阅域。 + DomainEvent DomainName = "event" + // DomainIM 表示消息与群组域。 + DomainIM DomainName = "im" + // DomainMail 表示邮箱域。 + DomainMail DomainName = "mail" + // DomainMarkdown 表示Markdown域。 + DomainMarkdown DomainName = "markdown" + // DomainMinutes 表示妙记域。 + DomainMinutes DomainName = "minutes" + // DomainNote 表示会议纪要域。 + DomainNote DomainName = "note" + // DomainOKR 表示OKR域。 + DomainOKR DomainName = "okr" + // DomainSheets 表示电子表格域。 + DomainSheets DomainName = "sheets" + // DomainSlides 表示幻灯片域。 + DomainSlides DomainName = "slides" + // DomainTask 表示任务域。 + DomainTask DomainName = "task" + // DomainVC 表示视频会议域。 + DomainVC DomainName = "vc" + // DomainWhiteboard 表示画板域。 + DomainWhiteboard DomainName = "whiteboard" + // DomainWiki 表示知识库域。 + DomainWiki DomainName = "wiki" +) diff --git a/extension/command/dryrun.go b/extension/command/dryrun.go new file mode 100644 index 0000000000..5852e15ca8 --- /dev/null +++ b/extension/command/dryrun.go @@ -0,0 +1,100 @@ +// 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 +} + +// NewDryRun creates an empty dry-run request list. +func NewDryRun() *DryRun { return &DryRun{} } + +// Preview creates a dry-run request list from shared Request values. +func Preview(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 +} + +// 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))} + 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..4ca8457ee6 --- /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.SubtypeInvalidResponse, + "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"` + Subtype string `json:"subtype,omitempty"` + Code int `json:"code,omitempty"` + Message string `json:"message"` + Hint string `json:"hint,omitempty"` + LogID string `json:"log_id,omitempty"` + Retryable bool `json:"retryable,omitempty"` +} + +// 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/generate.go b/extension/command/generate.go new file mode 100644 index 0000000000..ffb75c0812 --- /dev/null +++ b/extension/command/generate.go @@ -0,0 +1,6 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package command + +//go:generate go run ./internal/gen diff --git a/extension/command/host.go b/extension/command/host.go new file mode 100644 index 0000000000..863e46b703 --- /dev/null +++ b/extension/command/host.go @@ -0,0 +1,291 @@ +// 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. +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 + IsNew bool +} + +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 { + host := 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) }, + pageOutput: reflect.TypeFor[Data]().Implements(reflect.TypeFor[interface{ commandPagination() *paginationMeta }]()), + } + if definition.Hooks.Normalize != nil { + host.hooks.Normalize = func(ctx context.Context, command CommandContext, args any) error { + return definition.Hooks.Normalize(ctx, command, args.(*Args)) + } + } + if definition.Hooks.Validate != nil { + host.hooks.Validate = func(ctx context.Context, command CommandContext, args any) error { + return definition.Hooks.Validate(ctx, command, args.(*Args)) + } + } + if definition.Hooks.DryRun != nil { + host.hooks.DryRun = func(ctx context.Context, command CommandContext, args any) *DryRun { + return definition.Hooks.DryRun(ctx, command, args.(*Args)) + } + } + if definition.Hooks.Execute != nil { + host.hooks.Execute = func(ctx context.Context, command CommandContext, args any) (HostResult, error) { + result, err := definition.Hooks.Execute(ctx, command, args.(*Args)) + return hostResult(result), err + } + } + if len(definition.Hooks.Renderers) > 0 { + host.hooks.Renderers = make(map[string]func(io.Writer, any) error, len(definition.Hooks.Renderers)) + for name, renderer := range definition.Hooks.Renderers { + typedRenderer := renderer + host.hooks.Renderers[name] = func(writer io.Writer, data any) error { + return typedRenderer(writer, data.(Data)) + } + } + } + return Command{definition: host} +} + +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, IsNew: domain.kind == domainNew} +} + +// CloneSets copies set slices and immutable command declarations for BuildOption capture. +func CloneSets(sets []Set) []Set { + cloned := make([]Set, len(sets)) + for index, set := range sets { + cloned[index] = Set{Domain: cloneDomain(set.Domain), Commands: append([]Command(nil), set.Commands...)} + } + return cloned +} + +func cloneDomain(domain Domain) Domain { + domain.options = append([]DomainOption(nil), domain.options...) + return domain +} + +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.Tips = append([]string(nil), metadata.Tips...) + 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) + } + output.Artifacts = append([]ArtifactDefinition(nil), output.Artifacts...) + if output.Outcomes.PartialFailure != nil { + partial := *output.Outcomes.PartialFailure + if partial.FailedItems != nil { + failed := *partial.FailedItems + failed.IdentityPaths = append([]string(nil), failed.IdentityPaths...) + failed.FailedValues = append([]JSONValue(nil), failed.FailedValues...) + for index := range failed.FailedValues { + failed.FailedValues[index] = cloneJSONValue(failed.FailedValues[index]) + } + partial.FailedItems = &failed + } + output.Outcomes.PartialFailure = &partial + } + return output +} + +func cloneValueShape(shape ValueShape) ValueShape { + switch typed := shape.(type) { + case nil: + return nil + case StringShape: + typed.Enum = append([]string(nil), typed.Enum...) + return typed + case BooleanShape: + typed.Enum = append([]bool(nil), typed.Enum...) + return typed + case IntegerShape: + typed.Enum = append([]int64(nil), typed.Enum...) + return typed + case NumberShape: + typed.Enum = append([]float64(nil), typed.Enum...) + return typed + case NullShape: + return typed + case ConstShape: + typed.Value = cloneJSONValue(typed.Value) + return typed + case ArrayShape: + typed.Items = cloneValueShape(typed.Items) + 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 cloneJSONValue(value any) any { + switch typed := value.(type) { + case map[string]any: + cloned := make(map[string]any, len(typed)) + for key, item := range typed { + cloned[key] = cloneJSONValue(item) + } + return cloned + case []any: + cloned := make([]any, len(typed)) + for index, item := range typed { + cloned[index] = cloneJSONValue(item) + } + return cloned + case []string: + return append([]string(nil), typed...) + case []int: + return append([]int(nil), typed...) + case []int64: + return append([]int64(nil), typed...) + case []float64: + return append([]float64(nil), typed...) + default: + return value + } +} diff --git a/extension/command/internal/gen/main.go b/extension/command/internal/gen/main.go new file mode 100644 index 0000000000..5402eaf27d --- /dev/null +++ b/extension/command/internal/gen/main.go @@ -0,0 +1,97 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Command gen regenerates the public existing-domain enumeration. +package main + +import ( + "bytes" + "fmt" + "go/format" + "log" + "os" + "path/filepath" + "regexp" + "runtime" + "sort" + "strings" + + "github.com/larksuite/cli/internal/registry" + "github.com/larksuite/cli/shortcuts" +) + +var serviceNamePattern = regexp.MustCompile(`^[a-z][a-z0-9]*$`) + +func commandDir() string { + _, sourceFile, _, ok := runtime.Caller(0) + if !ok { + log.Fatal("command domain generator: cannot resolve source location") + } + return filepath.Join(filepath.Dir(sourceFile), "..", "..") +} + +func main() { + seen := make(map[string]struct{}) + for _, shortcut := range shortcuts.AllShortcuts() { + seen[shortcut.Service] = struct{}{} + } + if len(seen) == 0 { + log.Fatal("command domain generator: no shortcut domains found") + } + + domains := make([]string, 0, len(seen)) + for domain := range seen { + if !serviceNamePattern.MatchString(domain) { + log.Fatalf("command domain generator: service %q cannot form a stable Go identifier", domain) + } + for _, lang := range []string{"en", "zh"} { + if registry.GetServiceTitle(domain, lang) == "" { + log.Fatalf("command domain generator: service %q has no %s title", domain, lang) + } + if registry.GetServiceDescription(domain, lang) == "" { + log.Fatalf("command domain generator: service %q has no %s description", domain, lang) + } + } + domains = append(domains, domain) + } + sort.Strings(domains) + + var output bytes.Buffer + output.WriteString(`// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Code generated from shortcuts.AllShortcuts; DO NOT EDIT. + +package command + +const ( +`) + for _, domain := range domains { + identifier := domainIdentifier(domain) + fmt.Fprintf(&output, "\t// Domain%s 表示%s域。\n", identifier, registry.GetServiceTitle(domain, "zh")) + fmt.Fprintf(&output, "\tDomain%s DomainName = %q\n", identifier, domain) + } + output.WriteString(")\n") + + formatted, err := format.Source(output.Bytes()) + if err != nil { + log.Fatalf("command domain generator: format output: %v", err) + } + target := filepath.Join(commandDir(), "domains_gen.go") + if err := os.WriteFile(target, formatted, 0o644); err != nil { + log.Fatalf("command domain generator: write %s: %v", target, err) + } +} + +func domainIdentifier(domain string) string { + switch domain { + case "im": + return "IM" + case "okr": + return "OKR" + case "vc": + return "VC" + default: + return strings.ToUpper(domain[:1]) + domain[1:] + } +} diff --git a/extension/command/output.go b/extension/command/output.go new file mode 100644 index 0000000000..e8f86971ef --- /dev/null +++ b/extension/command/output.go @@ -0,0 +1,93 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package command + +// OutputDefinition declares result formats, partial outcomes, and file receipts. +type OutputDefinition struct { + Data DataDefinition + Outcomes OutcomeDefinition + Artifacts []ArtifactDefinition + Meta ResultMetaDefinition + Mode OutputMode + + DisableHTMLEscaping bool +} + +// ResultMetaDefinition declares standard metadata a command may return. +type ResultMetaDefinition struct { + Count bool + Pagination bool +} + +// OutcomeDefinition declares optional non-success outcomes. +type OutcomeDefinition struct { + PartialFailure *PartialFailureDefinition +} + +// PartialFailureDefinition declares the exit code and optional failed-item receipt. +type PartialFailureDefinition struct { + ExitCode int + FailedItems *FailedItemDefinition +} + +// FailedItemDefinition identifies failed records in a partial result. +type FailedItemDefinition struct { + ItemsPath string `json:"items_path"` + IdentityPaths []string `json:"identity_paths"` + AllItems bool `json:"all_items,omitempty"` + StatePath string `json:"state_path,omitempty"` + FailedValues []JSONValue `json:"failed_values,omitempty"` +} + +// ArtifactDefinition identifies file receipts already present in Data. +type ArtifactDefinition struct { + Name string `json:"name"` + ItemsPath string `json:"items_path"` + Optional bool `json:"optional,omitempty"` + PathField string `json:"path_field"` + MediaTypeField string `json:"media_type_field,omitempty"` + SizeField string `json:"size_field,omitempty"` +} + +// 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 + +const ( + outcomeSuccess outcomeKind = "success" + outcomePartial outcomeKind = "partial" +) + +// Result is an opaque command result created with Success or Partial. +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) +} + +// Partial creates a partial result whose completed operations remain in Data. +func Partial[Data any](data Data) Result[Data] { + return resultWithOutcome(data, outcomePartial) +} + +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..0daf45f677 --- /dev/null +++ b/extension/command/pagination.go @@ -0,0 +1,164 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package command + +import ( + "context" + "fmt" +) + +const collectAllPagesLimit = 1000 + +// Page contains items and host-owned pagination state. +type Page[T any] struct { + Items []T `json:"items" schema:"required;nonnullable" doc:"items returned by the API"` + + 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 { return p.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 © +} + +type pageEnvelope[T any] struct { + Items []T `json:"items"` + HasMore bool `json:"has_more"` + PageToken string `json:"page_token"` + NextPageToken string `json:"next_page_token"` +} + +// CollectPages fetches one page by default or follows standard pagination flags. +func CollectPages[T any](ctx context.Context, command CommandContext, request Request) (Page[T], error) { + options, err := command.pageOptions() + if err != nil { + return Page[T]{}, err + } + if !options.All { + options.MaxPages = 1 + } + return collectPages[T](ctx, command, request, options) +} + +// CollectAllPages fetches until the endpoint is exhausted and ignores CLI paging flags. +func CollectAllPages[T any](ctx context.Context, command CommandContext, request Request) ([]T, error) { + page, err := collectPages[T](ctx, command, request, PaginationOptions{All: true, MaxPages: collectAllPagesLimit}) + 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, options PaginationOptions) (Page[T], error) { + if options.MaxPages < 1 || options.MaxPages > collectAllPagesLimit { + return Page[T]{}, ValidationErrorf("pagination page limit must be between 1 and %d", collectAllPagesLimit) + } + if options.Delay < 0 { + return Page[T]{}, ValidationErrorf("pagination delay must not be negative") + } + + result := Page[T]{meta: &paginationMeta{}} + requestView := InspectRequest(request) + token := queryPageToken(requestView.Query) + seen := make(map[string]struct{}, options.MaxPages) + if token != "" { + seen[token] = struct{}{} + } + + for pageNumber := 1; pageNumber <= options.MaxPages; pageNumber++ { + pageRequest := request + if token != "" { + pageRequest = pageRequest.Set("page_token", token) + } + page, err := CallJSON[pageEnvelope[T]](ctx, command, pageRequest) + if err != nil { + result.meta.NextToken = token + return result, err + } + result.Items = append(result.Items, page.Items...) + result.meta.Pages++ + result.meta.Items = len(result.Items) + + nextToken := page.PageToken + if nextToken == "" { + nextToken = page.NextPageToken + } + if !page.HasMore { + result.meta.Complete = true + result.meta.NextToken = "" + return result, nil + } + if nextToken == "" { + return result, InvalidResponseErrorf("pagination page %d reports has_more=true without a page token", pageNumber) + } + if _, duplicate := seen[nextToken]; duplicate { + return result, InvalidResponseErrorf("pagination page %d repeated page token %q", pageNumber, nextToken) + } + result.meta.NextToken = nextToken + if pageNumber == options.MaxPages { + return result, nil + } + seen[nextToken] = struct{}{} + token = nextToken + if err := waitForPage(ctx, options.Delay); err != nil { + return result, err + } + } + + return result, InternalErrorf("pagination finished without a terminal state") +} + +func queryPageToken(query map[string]any) string { + value, ok := query["page_token"] + if !ok { + return "" + } + switch typed := value.(type) { + case string: + return typed + case []string: + if len(typed) > 0 { + return typed[0] + } + case []any: + if len(typed) > 0 { + return fmt.Sprint(typed[0]) + } + } + return "" +} diff --git a/extension/command/request.go b/extension/command/request.go new file mode 100644 index 0000000000..2a00b2fe20 --- /dev/null +++ b/extension/command/request.go @@ -0,0 +1,142 @@ +// 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) } + +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] = 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 = 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: 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] = cloneQueryValue(value) + } + return result +} + +func cloneQueryValue(value any) any { + switch typed := value.(type) { + case []string: + return append([]string(nil), typed...) + case []int: + return append([]int(nil), typed...) + case []any: + return append([]any(nil), typed...) + default: + return value + } +} 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 +} From 096035e7fb3b0e7145c61b6490058b7be13fd73c Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:23:11 +0800 Subject: [PATCH 03/47] feat(extension): compile and register business commands --- internal/commandhost/compile.go | 407 +++++++++++++++++++++++++ internal/commandhost/compile_test.go | 206 +++++++++++++ shortcuts/common/clone.go | 162 ++++++++++ shortcuts/common/clone_test.go | 65 ++++ shortcuts/common/runner.go | 77 ++++- shortcuts/common/typed_compile_args.go | 4 +- shortcuts/common/typed_compiler.go | 50 ++- shortcuts/common/typed_contract.go | 3 +- shortcuts/common/typed_definition.go | 10 + shortcuts/common/typed_external.go | 136 +++++++++ shortcuts/common/typed_runner.go | 13 +- shortcuts/register.go | 49 ++- shortcuts/register_external_test.go | 55 ++++ 13 files changed, 1207 insertions(+), 30 deletions(-) create mode 100644 internal/commandhost/compile.go create mode 100644 internal/commandhost/compile_test.go create mode 100644 shortcuts/common/clone.go create mode 100644 shortcuts/common/clone_test.go create mode 100644 shortcuts/common/typed_external.go create mode 100644 shortcuts/register_external_test.go diff --git a/internal/commandhost/compile.go b/internal/commandhost/compile.go new file mode 100644 index 0000000000..bf9b1d940f --- /dev/null +++ b/internal/commandhost/compile.go @@ -0,0 +1,407 @@ +// 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" + "sort" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/extension/command" + "github.com/larksuite/cli/shortcuts" + "github.com/larksuite/cli/shortcuts/common" +) + +var reservedRootNames = map[string]struct{}{ + "api": {}, "auth": {}, "completion": {}, "config": {}, "doctor": {}, "event": {}, + "help": {}, "profile": {}, "schema": {}, "skills": {}, "update": {}, "whoami": {}, +} + +// 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() + existingDomains := make(map[string]struct{}) + paths := make(map[string]string, len(builtins)) + for _, shortcut := range builtins { + existingDomains[shortcut.Service] = struct{}{} + paths[shortcut.Service+" "+shortcut.Command] = "built-in command" + } + + 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 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 := 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 +} + +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 domain.IsNew { + if _, reserved := reservedRootNames[name]; reserved { + return fmt.Errorf("new domain %q conflicts with a reserved host namespace", name) + } + if _, occupied := existing[name]; occupied { + return fmt.Errorf("new domain %q conflicts with an existing domain", name) + } + return fmt.Errorf("NewDomain(%q) is not supported in V1", name) + } + 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) { + metadata := convertMetadata(definition.Metadata) + input, err := convertInput(definition.Input) + if err != nil { + return common.Shortcut{}, err + } + output, err := convertOutput(definition.Output) + if err != nil { + return common.Shortcut{}, err + } + hooks := convertHooks(definition.Hooks) + hooks.NewArgs = definition.NewArgs + return common.CompileErasedDefinition(common.ErasedDefinition{ + Metadata: metadata, + Input: input, + Output: output, + ArgsType: definition.ArgsType, + DataType: definition.DataType, + Hooks: hooks, + PageOutput: definition.PageOutput, + }) +} + +func convertMetadata(metadata command.CommandMetadata) common.CommandMetadata { + identities := make(map[common.Identity]common.IdentityAuthorization, len(metadata.Authorization.Identities)) + for identity, authorization := range metadata.Authorization.Identities { + conditional := make([]common.ConditionalScope, len(authorization.ConditionalScopes)) + for index, scope := range authorization.ConditionalScopes { + conditional[index] = common.ConditionalScope{ + Scopes: append([]string(nil), scope.Scopes...), + When: scope.When, + Params: append([]string(nil), scope.Params...), + Requirement: common.ScopeRequirement(scope.Requirement), + } + } + identities[common.Identity(identity)] = common.IdentityAuthorization{ + RequiredScopes: append([]string(nil), authorization.RequiredScopes...), + ConditionalScopes: conditional, + } + } + identityOrder := make([]common.Identity, len(metadata.Authorization.IdentityOrder)) + for index, identity := range metadata.Authorization.IdentityOrder { + identityOrder[index] = common.Identity(identity) + } + return common.CommandMetadata{ + Service: metadata.Service, Command: metadata.Command, Description: metadata.Description, + Risk: common.Risk(metadata.Risk), Hidden: metadata.Hidden, Tips: append([]string(nil), metadata.Tips...), + Authorization: common.AuthorizationDefinition{Identities: identities, IdentityOrder: identityOrder}, + } +} + +func convertInput(input command.InputDefinition) (common.InputDefinition, error) { + converted := common.InputDefinition{Fields: make([]common.InputField, len(input.Fields)), Relations: make([]common.Relation, len(input.Relations))} + for index, field := range input.Fields { + shape, err := convertShape(field.Shape) + if err != nil { + return common.InputDefinition{}, fmt.Errorf("Input.Fields[%d].Shape: %w", index, err) + } + aliases := make([]common.FlagAlias, len(field.CLI.Aliases)) + for aliasIndex, alias := range field.CLI.Aliases { + aliases[aliasIndex] = common.FlagAlias{ + Name: alias.Name, Mode: common.FlagAliasMode(alias.Mode), Conflict: common.AliasConflictPolicy(alias.Conflict), + Hidden: alias.Hidden, Deprecated: alias.Deprecated, + } + } + sources := make([]common.ValueSource, len(field.CLI.ValueSources)) + for sourceIndex, source := range field.CLI.ValueSources { + sources[sourceIndex] = common.ValueSource(source) + } + converted.Fields[index] = common.InputField{ + Name: field.Name, Description: field.Description, Shape: shape, + Default: common.InputDefault{Set: field.Default.Set, Value: field.Default.Value}, + CLI: common.CLIInput{Aliases: aliases, ValueSources: sources, Encoding: common.CLIEncoding(field.CLI.Encoding), Hidden: field.CLI.Hidden, Deprecated: field.CLI.Deprecated}, + } + } + for index, relation := range input.Relations { + converted.Relations[index] = common.Relation{ + Kind: common.RelationKind(relation.Kind), Params: append([]string(nil), relation.Params...), + Presence: common.PresenceMode(relation.Presence), Stage: common.RelationStage(relation.Stage), + } + } + return converted, nil +} + +func convertOutput(output command.OutputDefinition) (common.OutputDefinition, error) { + dataShape, err := convertShape(output.Data.Shape) + if err != nil { + return common.OutputDefinition{}, fmt.Errorf("Output.Data.Shape: %w", err) + } + dataOverrides := make([]common.DataField, len(output.Data.Overrides)) + for index, override := range output.Data.Overrides { + shape, shapeErr := convertShape(override.Shape) + if shapeErr != nil { + return common.OutputDefinition{}, fmt.Errorf("Output.Data.Overrides[%d].Shape: %w", index, shapeErr) + } + dataOverrides[index] = common.DataField{Path: override.Path, Description: override.Description, Shape: shape} + } + converted := common.OutputDefinition{ + Data: common.DataDefinition{Shape: dataShape, Overrides: dataOverrides}, + Meta: common.ResultMetaDefinition{Count: output.Meta.Count, Pagination: output.Meta.Pagination}, + Mode: common.OutputMode(output.Mode), DisableHTMLEscaping: output.DisableHTMLEscaping, + Artifacts: make([]common.ArtifactDefinition, len(output.Artifacts)), + } + for index, artifact := range output.Artifacts { + converted.Artifacts[index] = common.ArtifactDefinition{ + Name: artifact.Name, ItemsPath: artifact.ItemsPath, Optional: artifact.Optional, + PathField: artifact.PathField, MediaTypeField: artifact.MediaTypeField, SizeField: artifact.SizeField, + } + } + if output.Outcomes.PartialFailure != nil { + partial := output.Outcomes.PartialFailure + convertedPartial := &common.PartialFailureDefinition{ExitCode: partial.ExitCode} + if partial.FailedItems != nil { + failed := partial.FailedItems + convertedPartial.FailedItems = &common.FailedItemDefinition{ + ItemsPath: failed.ItemsPath, IdentityPaths: append([]string(nil), failed.IdentityPaths...), AllItems: failed.AllItems, + StatePath: failed.StatePath, FailedValues: append([]common.JSONValue(nil), failed.FailedValues...), + } + } + converted.Outcomes.PartialFailure = convertedPartial + } + return converted, nil +} + +func convertHooks(hooks command.HostHooks) common.ErasedHooks { + return common.ErasedHooks{ + Normalize: adaptHook(hooks.Normalize), + Validate: adaptHook(hooks.Validate), + DryRun: adaptDryRunHook(hooks.DryRun), + Execute: adaptExecuteHook(hooks.Execute), + Renderers: cloneRenderers(hooks.Renderers), + } +} + +func adaptHook(hook func(context.Context, command.CommandContext, any) error) func(context.Context, common.CommandContext, any) error { + if hook == nil { + return nil + } + return func(ctx context.Context, host common.CommandContext, args any) error { + return hook(ctx, publicContext(host), args) + } +} + +func adaptDryRunHook(hook func(context.Context, command.CommandContext, any) *command.DryRun) func(context.Context, common.CommandContext, any) (*common.DryRunAPI, error) { + if hook == nil { + return nil + } + return func(ctx context.Context, host common.CommandContext, args any) (*common.DryRunAPI, error) { + preview := hook(ctx, publicContext(host), args) + return convertDryRun(preview) + } +} + +func adaptExecuteHook(hook func(context.Context, command.CommandContext, any) (command.HostResult, error)) func(context.Context, common.CommandContext, any) (common.ErasedResult, error) { + if hook == nil { + return nil + } + return func(ctx context.Context, host common.CommandContext, args any) (common.ErasedResult, error) { + result, err := hook(ctx, publicContext(host), args) + converted := common.ErasedResult{Data: result.Data, Outcome: common.OutcomeKind(result.Outcome)} + if result.Pagination != nil { + converted.Meta = &common.ResultMeta{Pagination: &common.ResultPaginationMeta{ + Complete: result.Pagination.Complete, Pages: result.Pagination.Pages, + Items: result.Pagination.Items, NextToken: result.Pagination.NextToken, + }} + } + return converted, 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 +} + +func publicContext(host common.CommandContext) command.CommandContext { + return command.NewCommandContext(command.ContextOptions{ + Identity: command.Identity(host.Identity()), + DryRun: host.IsDryRun(), + CallJSON: func(ctx context.Context, request command.Request) (map[string]any, error) { + view := command.InspectRequest(request) + return common.DoTypedAPIJSON(ctx, host, view.Method, view.Path, queryParams(view.Query), view.Body) + }, + PreflightScopes: host.RequireConditionalScopes, + PaginationOptions: func() (command.PaginationOptions, error) { + options, err := host.PaginationOptions() + return command.PaginationOptions{All: options.All, MaxPages: options.MaxPages, Delay: options.Delay}, err + }, + }) +} + +func queryParams(query map[string]any) larkcore.QueryParams { + params := make(larkcore.QueryParams, len(query)) + for name, value := range query { + switch typed := value.(type) { + case nil: + continue + case []string: + params[name] = append([]string(nil), typed...) + case []any: + values := make([]string, len(typed)) + for index, item := range typed { + values[index] = fmt.Sprint(item) + } + params[name] = values + default: + params[name] = []string{fmt.Sprint(value)} + } + } + return params +} + +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 len(request.Query) > 0 { + converted.Params(request.Query) + } + if request.Body != nil { + converted.Body(request.Body) + } + if request.Description != "" { + converted.Desc(request.Description) + } + } + return converted, nil +} + +func convertShape(shape command.ValueShape) (common.ValueShape, error) { + switch typed := shape.(type) { + case nil: + return nil, nil + case command.StringShape: + return common.StringShape{Enum: append([]string(nil), typed.Enum...), Format: typed.Format, MinLength: typed.MinLength, MaxLength: typed.MaxLength}, nil + case command.BooleanShape: + return common.BooleanShape{Enum: append([]bool(nil), typed.Enum...)}, nil + case command.IntegerShape: + return common.IntegerShape{Enum: append([]int64(nil), typed.Enum...), Minimum: typed.Minimum, Maximum: typed.Maximum}, nil + case command.NumberShape: + return common.NumberShape{Enum: append([]float64(nil), typed.Enum...), Minimum: typed.Minimum, Maximum: typed.Maximum}, nil + case command.NullShape: + return common.NullShape{}, nil + case command.ConstShape: + return common.ConstShape{Value: typed.Value}, nil + case command.ArrayShape: + items, err := convertShape(typed.Items) + if err != nil { + return nil, err + } + return common.ArrayShape{Items: items, MinItems: typed.MinItems, MaxItems: typed.MaxItems}, nil + case command.ObjectShape: + fields := make([]common.ValueField, len(typed.Fields)) + for index, field := range typed.Fields { + fieldShape, err := convertShape(field.Shape) + if err != nil { + return nil, fmt.Errorf("field %q: %w", field.Name, err) + } + fields[index] = common.ValueField{Name: field.Name, Description: field.Description, Required: field.Required, Shape: fieldShape} + } + additional, err := convertShape(typed.AdditionalPropertiesShape) + if err != nil { + return nil, err + } + return common.ObjectShape{Fields: fields, AdditionalProperties: typed.AdditionalProperties, AdditionalPropertiesShape: additional}, nil + case command.OneOfShape: + variants := make([]common.ValueShape, len(typed.Variants)) + for index, variant := range typed.Variants { + converted, err := convertShape(variant) + if err != nil { + return nil, fmt.Errorf("variant %d: %w", index, err) + } + variants[index] = converted + } + return common.OneOfShape{Variants: variants}, nil + default: + return nil, fmt.Errorf("unsupported public shape %T", shape) + } +} + +// SortedReservedRoots returns the host namespaces used by validation tests. +func SortedReservedRoots() []string { + result := make([]string, 0, len(reservedRootNames)) + for name := range reservedRootNames { + result = append(result, name) + } + sort.Strings(result) + return result +} diff --git a/internal/commandhost/compile_test.go b/internal/commandhost/compile_test.go new file mode 100644 index 0000000000..d0fbca283c --- /dev/null +++ b/internal/commandhost/compile_test.go @@ -0,0 +1,206 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package commandhost + +import ( + "context" + "strings" + "sync/atomic" + "testing" + + "github.com/larksuite/cli/extension/command" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/credential" + "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 command.Define(command.Definition[fixtureArgs, fixtureData]{ + Metadata: command.CommandMetadata{ + Service: "im", Command: name, Description: "Fixture command", Risk: command.RiskRead, + Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{ + command.IdentityUser: {RequiredScopes: []string{"im:chat: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 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 + }{ + {name: "reserved new domain", domain: command.NewDomain("auth", command.Title("en", "Auth")), want: "reserved"}, + {name: "unsupported new domain", domain: command.NewDomain("business"), want: "not supported in V1"}, + {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 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 TestExternalDryRunUsesOfflineContext(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-offline", Description: "Offline 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.Preview(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-offline", "--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) + } + 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()) + } +} diff --git a/shortcuts/common/clone.go b/shortcuts/common/clone.go new file mode 100644 index 0000000000..ab7e8705c4 --- /dev/null +++ b/shortcuts/common/clone.go @@ -0,0 +1,162 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import "io" + +// 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 +} + +// CloneShortcuts copies a shortcut slice and each mutable declaration. +func CloneShortcuts(shortcuts []Shortcut) []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([]FlagAlias(nil), field.cli.Aliases...) + cloned.fields[index].cli.ValueSources = append([]ValueSource(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 OutputDefinition) OutputDefinition { + output.Data.Shape = cloneCommonShape(output.Data.Shape) + output.Data.Overrides = append([]DataField(nil), output.Data.Overrides...) + for index := range output.Data.Overrides { + output.Data.Overrides[index].Shape = cloneCommonShape(output.Data.Overrides[index].Shape) + } + output.Artifacts = append([]ArtifactDefinition(nil), output.Artifacts...) + if output.Outcomes.PartialFailure != nil { + partial := *output.Outcomes.PartialFailure + if partial.FailedItems != nil { + failed := *partial.FailedItems + failed.IdentityPaths = append([]string(nil), failed.IdentityPaths...) + failed.FailedValues = append([]JSONValue(nil), failed.FailedValues...) + partial.FailedItems = &failed + } + output.Outcomes.PartialFailure = &partial + } + return output +} + +func cloneCommonShape(shape ValueShape) ValueShape { + switch typed := shape.(type) { + case nil: + return nil + case StringShape: + typed.Enum = append([]string(nil), typed.Enum...) + return typed + case BooleanShape: + typed.Enum = append([]bool(nil), typed.Enum...) + return typed + case IntegerShape: + typed.Enum = append([]int64(nil), typed.Enum...) + return typed + case NumberShape: + typed.Enum = append([]float64(nil), typed.Enum...) + return typed + case NullShape, anyJSONShape: + return typed + case ConstShape: + typed.Value = cloneJSONValue(typed.Value) + return typed + case ArrayShape: + typed.Items = cloneCommonShape(typed.Items) + return typed + case ObjectShape: + typed.Fields = append([]ValueField(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 OneOfShape: + typed.Variants = append([]ValueShape(nil), typed.Variants...) + for index := range typed.Variants { + typed.Variants[index] = cloneCommonShape(typed.Variants[index]) + } + return typed + default: + return shape + } +} + +func cloneJSONValue(value any) any { + switch typed := value.(type) { + case map[string]any: + cloned := make(map[string]any, len(typed)) + for key, item := range typed { + cloned[key] = cloneJSONValue(item) + } + return cloned + case []any: + cloned := make([]any, len(typed)) + for index, item := range typed { + cloned[index] = cloneJSONValue(item) + } + return cloned + case []string: + return append([]string(nil), typed...) + default: + return value + } +} diff --git a/shortcuts/common/clone_test.go b/shortcuts/common/clone_test.go new file mode 100644 index 0000000000..6f77959f41 --- /dev/null +++ b/shortcuts/common/clone_test.go @@ -0,0 +1,65 @@ +// 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 := Define(Definition[cloneArgs, cloneData]{ + Metadata: CommandMetadata{ + Service: "im", Command: "+clone", Description: "Clone", Risk: RiskRead, + Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{ + IdentityUser: {RequiredScopes: []string{"im:chat:read"}}, + }}, + }, + Hooks: Hooks[cloneArgs, cloneData]{Execute: func(context.Context, CommandContext, *cloneArgs) (Result[cloneData], error) { + return Success(cloneData{}), nil + }}, + }) + cloned := CloneShortcut(original) + + original.UserScopes[0] = "mutated" + original.Flags[0].Enum[0] = "mutated" + original.typed.metadata.Authorization.Identities[IdentityUser] = IdentityAuthorization{RequiredScopes: []string{"mutated"}} + original.typed.fields[0].shape.(StringShape).Enum[0] = "mutated" + + 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[IdentityUser].RequiredScopes[0]; got != "im:chat:read" { + t.Fatalf("cloned typed scope = %q", got) + } + if got := cloned.typed.fields[0].shape.(StringShape).Enum[0]; got != "one" { + t.Fatalf("cloned typed enum = %q", 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/runner.go b/shortcuts/common/runner.go index dfa6cb9cc5..a2401b05dc 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 ── @@ -195,6 +196,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 +250,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) } @@ -995,6 +1002,22 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f } func runTypedMountedShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bool) error { + dryRun, _ := cmd.Flags().GetBool("dry-run") + if dryRun { + config, err := f.Config() + if err != nil { + return err + } + as, err := resolveDryRunIdentity(cmd, f, s, 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 @@ -1016,6 +1039,26 @@ func runTypedMountedShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut return runTypedShortcut(f, rctx, s) } +func resolveDryRunIdentity(cmd *cobra.Command, f *cmdutil.Factory, shortcut *Shortcut, config *core.CliConfig) (core.Identity, error) { + requested, _ := cmd.Flags().GetString("as") + identity := core.Identity(requested) + if !cmd.Flags().Changed("as") || identity == "" || identity == core.AsAuto { + identity = config.DefaultAs + if identity == "" || identity == core.AsAuto { + if len(shortcut.AuthTypes) == 1 { + identity = core.Identity(shortcut.AuthTypes[0]) + } else { + identity = core.AsBot + } + } + } + f.IdentityAutoDetected = false + if err := f.CheckIdentity(identity, shortcut.AuthTypes); err != nil { + return "", err + } + return identity, nil +} + func installTypedAnnotations(cmd *cobra.Command, command *compiledCommand) { for _, field := range command.fields { if field.cli.Deprecated != "" { @@ -1177,17 +1220,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) }) @@ -1198,11 +1231,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 diff --git a/shortcuts/common/typed_compile_args.go b/shortcuts/common/typed_compile_args.go index cae138f7b2..7fc5f64dbb 100644 --- a/shortcuts/common/typed_compile_args.go +++ b/shortcuts/common/typed_compile_args.go @@ -21,6 +21,8 @@ var ( var providedPkgPath = reflect.TypeFor[Provided[any]]().PkgPath() +const extensionCommandPkgPath = "github.com/larksuite/cli/extension/command" + func compileInput(argsType reflect.Type, definition InputDefinition) ([]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) @@ -180,7 +182,7 @@ func hasAnyTag(field reflect.StructField, names ...string) bool { } func unwrapProvided(t reflect.Type) (reflect.Type, []int, bool, error) { - if t.Kind() != reflect.Struct || t.PkgPath() != providedPkgPath || !strings.HasPrefix(t.Name(), "Provided[") { + if t.Kind() != reflect.Struct || (t.PkgPath() != providedPkgPath && t.PkgPath() != extensionCommandPkgPath) || !strings.HasPrefix(t.Name(), "Provided[") { return t, nil, false, nil } value, ok := t.FieldByName("Value") diff --git a/shortcuts/common/typed_compiler.go b/shortcuts/common/typed_compiler.go index 4306b79ac0..e0cc7fa065 100644 --- a/shortcuts/common/typed_compiler.go +++ b/shortcuts/common/typed_compiler.go @@ -37,34 +37,57 @@ func Define[Args any, Data any](definition Definition[Args, Data]) Shortcut { } func compileDefinition[Args any, Data any](definition Definition[Args, Data]) (*compiledCommand, error) { - metadata := normalizeCommandMetadata(definition.Metadata) + if definition.Hooks.Execute == nil { + return nil, fmt.Errorf("Hooks.Execute is required") + } + return compileDefinitionParts( + definition.Metadata, + definition.Input, + definition.Output, + reflect.TypeFor[Args](), + reflect.TypeFor[Data](), + adaptHooks(definition.Hooks), + rendererMarkers(definition.Hooks.Renderers), + false, + ) +} + +func compileDefinitionParts( + metadata CommandMetadata, + input InputDefinition, + output OutputDefinition, + 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 } - argsType := reflect.TypeFor[Args]() - dataType := reflect.TypeFor[Data]() - fields, fieldByName, err := compileInput(argsType, definition.Input) + fields, fieldByName, err := compileInput(argsType, input) if err != nil { return nil, err } - relations, err := compileRelations(definition.Input.Relations, fieldByName) + 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, definition.Output.Data) + dataShape, err := compileData(dataType, output.Data) if err != nil { return nil, err } - if definition.Hooks.Execute == nil { + if hooks.execute == nil { return nil, fmt.Errorf("Hooks.Execute is required") } - if err := validateOutput(definition.Output, dataShape); err != nil { + if err := validateOutput(output, dataShape); err != nil { return nil, err } - if err := validateOutputHooks(definition.Output, rendererMarkers(definition.Hooks.Renderers)); err != nil { + if err := validateOutputHooks(output, renderers); err != nil { return nil, err } command := &compiledCommand{ @@ -75,8 +98,9 @@ func compileDefinition[Args any, Data any](definition Definition[Args, Data]) (* fieldByName: fieldByName, relations: relations, dataShape: dataShape, - output: definition.Output, - hooks: adaptHooks(definition.Hooks), + output: output, + hooks: hooks, + pageOutput: pageOutput, } command.contract = buildTypedSchemaContract(command) return command, nil @@ -208,8 +232,8 @@ func adaptHooks[Args any, Data any](hooks Hooks[Args, Data]) compiledHooks { } } if hooks.DryRun != nil { - adapted.dryRun = func(ctx context.Context, cc CommandContext, args any) *DryRunAPI { - return hooks.DryRun(ctx, cc, args.(*Args)) + adapted.dryRun = func(ctx context.Context, cc CommandContext, args any) (*DryRunAPI, error) { + return hooks.DryRun(ctx, cc, args.(*Args)), nil } } adapted.execute = func(ctx context.Context, cc CommandContext, args any) (compiledResult, error) { diff --git a/shortcuts/common/typed_contract.go b/shortcuts/common/typed_contract.go index b6235ea59b..b2ea347015 100644 --- a/shortcuts/common/typed_contract.go +++ b/shortcuts/common/typed_contract.go @@ -20,6 +20,7 @@ type compiledCommand struct { output OutputDefinition contract typedSchemaContract hooks compiledHooks + pageOutput bool } type compiledInputField struct { @@ -55,7 +56,7 @@ type compiledHooks struct { newArgs func() any normalize func(context.Context, CommandContext, any) error validate func(context.Context, CommandContext, any) error - dryRun func(context.Context, CommandContext, any) *DryRunAPI + dryRun func(context.Context, CommandContext, any) (*DryRunAPI, error) execute func(context.Context, CommandContext, 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 index 5a9f2ed739..35dec36273 100644 --- a/shortcuts/common/typed_definition.go +++ b/shortcuts/common/typed_definition.go @@ -6,6 +6,7 @@ package common import ( "context" "io" + "time" "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/client" @@ -185,9 +186,18 @@ type CommandContext interface { Stderr() io.Writer StartSpinner(label string) func() PresentError(err error) error + IsDryRun() bool + PaginationOptions() (PaginationOptions, error) // RequireConditionalScopes checks scopes that the Definition declares as // path-dependent for the selected identity. Domain code calls it only after // it has determined that the path requiring those scopes will execute. RequireConditionalScopes(scopes ...string) error } + +// PaginationOptions reports the standard pagination flags for one invocation. +type PaginationOptions struct { + All bool + MaxPages int + Delay time.Duration +} diff --git a/shortcuts/common/typed_external.go b/shortcuts/common/typed_external.go new file mode 100644 index 0000000000..44bae4324f --- /dev/null +++ b/shortcuts/common/typed_external.go @@ -0,0 +1,136 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "context" + "fmt" + "io" + "reflect" +) + +// ErasedDefinition is the internal host form used to compile an external typed command. +type ErasedDefinition struct { + Metadata CommandMetadata + Input InputDefinition + Output OutputDefinition + ArgsType reflect.Type + DataType reflect.Type + Hooks ErasedHooks + PageOutput bool +} + +// ErasedHooks adapts public generic hooks without exposing RuntimeContext. +type ErasedHooks struct { + NewArgs func() any + Normalize func(context.Context, CommandContext, any) error + Validate func(context.Context, CommandContext, any) error + DryRun func(context.Context, CommandContext, any) (*DryRunAPI, error) + Execute func(context.Context, CommandContext, any) (ErasedResult, error) + Renderers map[string]func(io.Writer, any) error +} + +// ErasedResult is the internal non-generic result used by the host adapter. +type ErasedResult struct { + Data any + Outcome OutcomeKind + Meta *ResultMeta +} + +// CompileErasedDefinition compiles one public command declaration without panic. +func CompileErasedDefinition(definition ErasedDefinition) (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 := definition.Hooks.NewArgs() + 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, + adaptErasedHooks(definition.Hooks), + erasedRendererMarkers(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, Risk(shortcut.Risk)); err != nil { + return Shortcut{}, err + } + return shortcut, nil +} + +func adaptErasedHooks(hooks ErasedHooks) compiledHooks { + adapted := compiledHooks{ + newArgs: hooks.NewArgs, + normalize: hooks.Normalize, + validate: hooks.Validate, + dryRun: hooks.DryRun, + renderers: hooks.Renderers, + } + if hooks.Execute != nil { + adapted.execute = func(ctx context.Context, command CommandContext, args any) (compiledResult, error) { + result, err := hooks.Execute(ctx, command, args) + return compiledResult{data: result.Data, outcome: result.Outcome, meta: result.Meta}, err + } + } + return adapted +} + +func erasedRendererMarkers(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_runner.go b/shortcuts/common/typed_runner.go index bb9cc1469a..092582cecb 100644 --- a/shortcuts/common/typed_runner.go +++ b/shortcuts/common/typed_runner.go @@ -48,7 +48,10 @@ func runTypedShortcut(cmdFactory *cmdutil.Factory, runtime *RuntimeContext, shor if command.hooks.dryRun == nil { return ValidationErrorf("--dry-run is not supported for %s %s", shortcut.Service, shortcut.Command).WithParam("--dry-run") } - preview := command.hooks.dryRun(runtime.ctx, commandContext, bound.value) + preview, err := command.hooks.dryRun(runtime.ctx, commandContext, bound.value) + if err != nil { + return attributeAliasValidationError(runtime, err) + } if preview != nil { preview.Context(runtime.Config.AppID, runtime.UserOpenId()) } @@ -192,6 +195,14 @@ func (c typedCommandContext) StartSpinner(label string) func() { return c.runtime.StartSpinner(label) } func (c typedCommandContext) PresentError(err error) error { return c.runtime.PresentError(err) } +func (c typedCommandContext) IsDryRun() bool { return c.runtime != nil && c.runtime.Bool("dry-run") } +func (c typedCommandContext) PaginationOptions() (PaginationOptions, error) { + values, err := pageAllValues(c.runtime) + if err != nil { + return PaginationOptions{}, err + } + return PaginationOptions{All: values.enabled, MaxPages: values.maxPages, Delay: values.delay}, nil +} func (c typedCommandContext) typedCommandPath() string { if c.runtime == nil || c.runtime.Cmd == nil { return "" diff --git a/shortcuts/register.go b/shortcuts/register.go index ab5fd9ca51..3dbdde97c6 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "slices" + "sync" "github.com/larksuite/cli/shortcuts/okr" "github.com/spf13/cobra" @@ -66,6 +67,8 @@ func IsShortcutServiceAvailable(service string, brand core.LarkBrand) bool { // allShortcuts aggregates shortcuts from all domain packages. var allShortcuts []common.Shortcut +var shortcutRegistryMu sync.RWMutex +var externalRegistered bool func init() { allShortcuts = append(allShortcuts, apps.Shortcuts()...) @@ -99,7 +102,46 @@ func init() { // //go:noinline func AllShortcuts() []common.Shortcut { - return append([]common.Shortcut(nil), allShortcuts...) + shortcutRegistryMu.RLock() + defer shortcutRegistryMu.RUnlock() + return common.CloneShortcuts(allShortcuts) +} + +// RegisterExternal atomically adds one build's compiled business commands. +// The process supports one explicit external contribution because V1 mounts one +// command tree per process. A second registration fails instead of replacing it. +func RegisterExternal(commands []common.Shortcut) error { + if len(commands) == 0 { + return nil + } + shortcutRegistryMu.Lock() + defer shortcutRegistryMu.Unlock() + cloned, err := prepareExternalRegistration(allShortcuts, commands, externalRegistered) + if err != nil { + return err + } + allShortcuts = append(allShortcuts, cloned...) + externalRegistered = true + return nil +} + +func prepareExternalRegistration(existing, commands []common.Shortcut, alreadyRegistered bool) ([]common.Shortcut, error) { + if alreadyRegistered { + return nil, fmt.Errorf("external command set is already registered") + } + cloned := common.CloneShortcuts(commands) + paths := make(map[string]struct{}, len(existing)+len(cloned)) + for _, shortcut := range existing { + paths[shortcut.Service+" "+shortcut.Command] = struct{}{} + } + for _, shortcut := range cloned { + path := shortcut.Service + " " + shortcut.Command + if _, duplicate := paths[path]; duplicate { + return nil, fmt.Errorf("external command path %q is already registered", path) + } + paths[path] = struct{}{} + } + return cloned, nil } // RegisterShortcuts registers all +shortcut commands on the program. @@ -108,6 +150,9 @@ func RegisterShortcuts(program *cobra.Command, f *cmdutil.Factory) { } func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f *cmdutil.Factory) { + shortcutRegistryMu.RLock() + registered := common.CloneShortcuts(allShortcuts) + shortcutRegistryMu.RUnlock() // Factory.Config may be nil in tests that pass a zero-value factory. var brand core.LarkBrand if f != nil && f.Config != nil { @@ -118,7 +163,7 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f // Group by service byService := make(map[string][]common.Shortcut) - for _, s := range allShortcuts { + for _, s := range registered { byService[s.Service] = append(byService[s.Service], s) } diff --git a/shortcuts/register_external_test.go b/shortcuts/register_external_test.go new file mode 100644 index 0000000000..f341780762 --- /dev/null +++ b/shortcuts/register_external_test.go @@ -0,0 +1,55 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package shortcuts + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/shortcuts/common" +) + +func TestPrepareExternalRegistrationCopiesInput(t *testing.T) { + commands := []common.Shortcut{{ + Service: "im", Command: "+external-copy", Scopes: []string{"im:chat:read"}, + Flags: []common.Flag{{Name: "id", Enum: []string{"one"}}}, + }} + registered, err := prepareExternalRegistration(nil, commands, false) + if err != nil { + t.Fatal(err) + } + commands[0].Scopes[0] = "mutated" + commands[0].Flags[0].Enum[0] = "mutated" + if got := registered[0].Scopes[0]; got != "im:chat:read" { + t.Fatalf("registered scope = %q", got) + } + if got := registered[0].Flags[0].Enum[0]; got != "one" { + t.Fatalf("registered enum = %q", got) + } +} + +func TestPrepareExternalRegistrationRejectsWholeContribution(t *testing.T) { + existing := []common.Shortcut{{Service: "im", Command: "+existing"}} + commands := []common.Shortcut{ + {Service: "im", Command: "+new"}, + {Service: "im", Command: "+existing"}, + } + registered, err := prepareExternalRegistration(existing, commands, false) + if err == nil || !strings.Contains(err.Error(), "already registered") { + t.Fatalf("registration error = %v", err) + } + if registered != nil { + t.Fatalf("registered commands = %#v", registered) + } +} + +func TestPrepareExternalRegistrationRejectsSecondContribution(t *testing.T) { + registered, err := prepareExternalRegistration(nil, []common.Shortcut{{Service: "im", Command: "+second"}}, true) + if err == nil || !strings.Contains(err.Error(), "already registered") { + t.Fatalf("registration error = %v", err) + } + if registered != nil { + t.Fatalf("registered commands = %#v", registered) + } +} From 2959a308e63997a5de7a95a7d193816a8f702c04 Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:24:14 +0800 Subject: [PATCH 04/47] feat(cmd): assemble business command sets --- cmd/build.go | 21 ++++++ cmd/command_sets_test.go | 140 +++++++++++++++++++++++++++++++++++++++ cmd/platform_guards.go | 10 +++ 3 files changed, 171 insertions(+) create mode 100644 cmd/command_sets_test.go diff --git a/cmd/build.go b/cmd/build.go index ae766bfdd5..93ecbfe9a1 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" @@ -55,6 +57,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 +162,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 @@ -199,6 +212,10 @@ func buildInternalWithConfig(ctx context.Context, inv cmdutil.InvocationContext, if cfg == nil { cfg = &buildConfig{} } + externalCommands, commandSetErr := commandhost.CompileSets(cfg.commandSets) + if commandSetErr == nil { + commandSetErr = shortcuts.RegisterExternal(externalCommands) + } // 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 @@ -291,6 +308,10 @@ func buildInternalWithConfig(ctx context.Context, inv cmdutil.InvocationContext, } } shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f) + 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..9c45e715d2 --- /dev/null +++ b/cmd/command_sets_test.go @@ -0,0 +1,140 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "errors" + "os" + "os/exec" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/command" +) + +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, + Tips: []string{"Uses the distribution-specific chat policy."}, + 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.Preview(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"} { + 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 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 !strings.Contains(help.String(), "distribution-specific chat policy") { + t.Fatalf("business tip is missing from help:\n%s", help.String()) + } + 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") + } + default: + t.Fatalf("unknown scenario %q", scenario) + } +} 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: From 25e81826e20e96ad6d7d199a894a2bc498366806 Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:24:52 +0800 Subject: [PATCH 05/47] fix(auth): derive login domains from shortcuts --- cmd/auth/login.go | 11 ++++++++- cmd/auth/login_interactive.go | 43 ++++------------------------------ cmd/auth/login_messages.go | 7 ------ cmd/auth/login_test.go | 44 +++++++++++++++++++++++++++-------- 4 files changed, 48 insertions(+), 57 deletions(-) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 6cbbce2811..c0e6d426dd 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -560,13 +560,22 @@ func allKnownDomains(brand core.LarkBrand) map[string]bool { if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) { continue } - if !registry.HasAuthDomain(sc.Service) { + if !registry.HasAuthDomain(sc.Service) && shortcutHasDeclaredScopes(sc) { domains[sc.Service] = true } } return domains } +func shortcutHasDeclaredScopes(shortcut common.Shortcut) bool { + for _, identity := range []string{"user", "bot"} { + if len(shortcut.DeclaredScopesForIdentity(identity)) > 0 { + return true + } + } + return false +} + // sortedKnownDomains returns all valid domain names sorted alphabetically. func sortedKnownDomains(brand core.LarkBrand) []string { m := allKnownDomains(brand) diff --git a/cmd/auth/login_interactive.go b/cmd/auth/login_interactive.go index 70e01065cb..f1cc527145 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. @@ -33,44 +32,10 @@ type interactiveResult struct { // 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 - 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 - } + known := allKnownDomains("") + domains := make([]domainMeta, 0, len(known)) + for name := range known { + domains = append(domains, buildDomainMeta(name, lang)) } sort.Slice(domains, func(i, j int) bool { 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..22244a3faf 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -11,7 +11,6 @@ import ( "fmt" "io" "net/http" - "slices" "sort" "strings" "testing" @@ -206,8 +205,14 @@ func TestSortedKnownDomains(t *testing.T) { } } -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 +224,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 getDomainMetadata("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) { @@ -279,16 +287,32 @@ func TestGetDomainMetadata_IncludesFromMeta(t *testing.T) { } } -func TestGetDomainMetadata_IncludesShortcutOnlyDomains(t *testing.T) { +func TestGetDomainMetadataIncludesAuthorizableShortcutDomains(t *testing.T) { domains := getDomainMetadata("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 TestGetDomainMetadataMatchesAllKnownDomains(t *testing.T) { + metadata := getDomainMetadata("zh") + known := allKnownDomains("") + if len(metadata) != len(known) { + t.Fatalf("domain metadata count = %d, allKnownDomains count = %d", len(metadata), len(known)) + } + for _, domain := range metadata { + if !known[domain.Name] { + t.Errorf("domain metadata contains %q outside allKnownDomains", domain.Name) } } } From c73cfdd2c3743d4492fe9d0ecd052f118e872cca Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:27:21 +0800 Subject: [PATCH 06/47] test(extension): add business command test runtime --- .../commandtest/business_commands_test.go | 339 ++++++++++++++++++ extension/command/commandtest/commandtest.go | 320 +++++++++++++++++ .../command/commandtest/commandtest_test.go | 112 ++++++ extension/command/errors.go | 14 +- 4 files changed, 778 insertions(+), 7 deletions(-) create mode 100644 extension/command/commandtest/business_commands_test.go create mode 100644 extension/command/commandtest/commandtest.go create mode 100644 extension/command/commandtest/commandtest_test.go diff --git a/extension/command/commandtest/business_commands_test.go b/extension/command/commandtest/business_commands_test.go new file mode 100644 index 0000000000..c2e7bdfb34 --- /dev/null +++ b/extension/command/commandtest/business_commands_test.go @@ -0,0 +1,339 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package commandtest_test + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/larksuite/cli/extension/command" + "github.com/larksuite/cli/extension/command/commandtest" + "github.com/larksuite/cli/internal/commandhost" +) + +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/" + 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.Preview(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)) + return command.Success(data), err + }, + }, + } +} + +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.Preview(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)) + return command.Success(page), err + }, + }, + } +} + +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, + }}, + }, + }}, + }, + Output: command.OutputDefinition{Outcomes: command.OutcomeDefinition{PartialFailure: &command.PartialFailureDefinition{ + ExitCode: 3, + FailedItems: &command.FailedItemDefinition{ + ItemsPath: "/items", IdentityPaths: []string{"/task_id"}, StatePath: "/state", FailedValues: []command.JSONValue{"failed"}, + }, + }}}, + Hooks: command.Hooks[taskAuditArgs, taskAuditData]{ + DryRun: func(_ context.Context, _ command.CommandContext, _ *taskAuditArgs) *command.DryRun { + return command.Preview(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.Success(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 { + return command.Success(data), err + } + for _, task := range tasks { + owner, ownerErr := command.CallJSON[struct { + Name string `json:"name"` + }](ctx, commandContext, command.GET("/open-apis/contact/v3/users/"+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"}) + } + if len(data.Failures) > 0 { + return command.Partial(data), nil + } + 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.Preview(command.GET("/open-apis/im/v1/chats/" + args.ChatID)) + if args.IncludeMembers { + preview.Add(command.GET("/open-apis/im/v1/chats/" + 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/"+args.ChatID)) + if err != nil || !args.IncludeMembers { + return command.Success(data), err + } + if err := command.PreflightScopes(commandContext, "im:chat.members:read"); err != nil { + return command.Success(data), err + } + members, err := command.CallJSON[struct { + Items []string `json:"items"` + }](ctx, commandContext, command.GET("/open-apis/im/v1/chats/"+args.ChatID+"/members")) + data.Members = members.Items + return command.Success(data), err + }, + }, + } +} + +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 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, + }), + ) + recorder.SetPagination(command.PaginationOptions{All: true, MaxPages: 3}) + execution, err := commandtest.Execute(context.Background(), recorder, command.IdentityUser, chatListDefinition(), &chatListArgs{PageSize: 20}) + 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 TestMultiCallCommandReturnsPartialData(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 !execution.Partial || 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..356000d2a1 --- /dev/null +++ b/extension/command/commandtest/commandtest.go @@ -0,0 +1,320 @@ +// 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" + "reflect" + "sync" + "testing" + + "github.com/larksuite/cli/extension/command" +) + +// Response is one scripted OpenAPI response. +type Response struct { + data any + err error +} + +// Respond creates a successful scripted response containing an OpenAPI data object. +func Respond(data any) Response { return Response{data: data} } + +// 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 + 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}, + } +} + +// CommandContext returns a restricted public command context. +func (r *Recorder) CommandContext(identity command.Identity) command.CommandContext { + return r.commandContext(identity, false) +} + +// DryRunContext returns an offline context for invoking a DryRun hook. +func (r *Recorder) DryRunContext(identity command.Identity) command.CommandContext { + return r.commandContext(identity, true) +} + +func (r *Recorder) commandContext(identity command.Identity, dryRun bool) command.CommandContext { + return command.NewCommandContext(command.ContextOptions{ + Identity: identity, + DryRun: dryRun, + CallJSON: r.callJSON, + PreflightScopes: r.preflightScopes, + PaginationOptions: r.paginationOptions, + }) +} + +// Execution is the inspected outcome of one business Execute hook. +type Execution[Data any] struct { + Data Data + Partial bool +} + +// Execute runs Normalize, Validate, and Execute with the restricted test runtime. +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 := command.InspectCommand(command.Define(definition)) + commandContext := recorder.CommandContext(identity) + if declaration.Hooks.Normalize != nil { + if err := declaration.Hooks.Normalize(ctx, commandContext, args); err != nil { + return execution, err + } + } + if declaration.Hooks.Validate != nil { + if err := declaration.Hooks.Validate(ctx, commandContext, 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 { + 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, Partial: result.Outcome == "partial"}, nil +} + +// Preview runs Normalize, Validate, and DryRun with an offline test context. +func Preview[Args any, Data any](ctx context.Context, recorder *Recorder, identity command.Identity, definition command.Definition[Args, Data], args *Args) (*command.DryRun, error) { + declaration := command.InspectCommand(command.Define(definition)) + 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() +} + +// 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() + return cloneRequestViews(r.requests) +} + +// 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) + } + } +} + +func (r *Recorder) callJSON(ctx context.Context, request command.Request) (map[string]any, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + view := command.InspectRequest(request) + r.mu.Lock() + r.requests = append(r.requests, cloneRequestView(view)) + requestNumber := len(r.requests) + if len(r.responses) == 0 { + r.mu.Unlock() + return 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.err != nil { + return nil, response.err + } + data, err := responseDataObject(response.data) + if err != nil { + return nil, fmt.Errorf("scripted response %d: %w", requestNumber, err) + } + if shouldCancel && cancel != nil { + cancel() + } + return data, nil +} + +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 (r *Recorder) paginationOptions() (command.PaginationOptions, error) { + r.mu.Lock() + defer r.mu.Unlock() + return r.pagination, nil +} + +func responseDataObject(value any) (map[string]any, error) { + if value == nil { + return map[string]any{}, nil + } + 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 { + cloned := make([]command.RequestView, len(requests)) + for index, request := range requests { + cloned[index] = cloneRequestView(request) + } + return cloned +} + +func cloneRequestView(request command.RequestView) command.RequestView { + encoded, err := json.Marshal(request) + if err != nil { + return request + } + var cloned command.RequestView + if err := json.Unmarshal(encoded, &cloned); err != nil { + return request + } + return cloned +} diff --git a/extension/command/commandtest/commandtest_test.go b/extension/command/commandtest/commandtest_test.go new file mode 100644 index 0000000000..eb8dd028ef --- /dev/null +++ b/extension/command/commandtest/commandtest_test.go @@ -0,0 +1,112 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package commandtest + +import ( + "context" + "errors" + "reflect" + "testing" + "time" + + "github.com/larksuite/cli/extension/command" +) + +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.Preview(request)) + recorder.AssertScriptConsumed() +} + +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 TestRecorderInjectsCancellationIntoPaginationWait(t *testing.T) { + recorder := New(t, Respond(map[string]any{ + "items": []map[string]any{{"id": "first"}}, + "has_more": true, + "page_token": "next", + })) + recorder.SetPagination(command.PaginationOptions{All: true, MaxPages: 2, Delay: time.Hour}) + recorder.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 TestExecuteRunsPreparationAndReturnsTypedOutcome(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.Partial(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" || !execution.Partial { + t.Fatalf("execution = %#v", execution) + } +} diff --git a/extension/command/errors.go b/extension/command/errors.go index 4ca8457ee6..7f40eead31 100644 --- a/extension/command/errors.go +++ b/extension/command/errors.go @@ -44,13 +44,13 @@ func PaginationInterruptedError(cause error) *errs.NetworkError { // Failure is a stable snapshot suitable for embedding in partial result data. type Failure struct { - Type string `json:"type"` - Subtype string `json:"subtype,omitempty"` - Code int `json:"code,omitempty"` - Message string `json:"message"` - Hint string `json:"hint,omitempty"` - LogID string `json:"log_id,omitempty"` - Retryable bool `json:"retryable,omitempty"` + 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. From 173d8de18af1aedcc4baf1f9da3cdda0cc3cf45d Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:27:48 +0800 Subject: [PATCH 07/47] ci: verify generated Go sources --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f270ba615..dfff5de806 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 ./extension/command/... ./shortcuts/sheets/... + git diff --exit-code - name: Check go.mod tidiness run: | go mod tidy From 5b849b9dbd6fc688d5c25dda0da92973a702590a Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:33:56 +0800 Subject: [PATCH 08/47] test(auth): match help and interactive domains --- cmd/auth/login_test.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index 22244a3faf..f62f00048f 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -317,6 +317,24 @@ func TestGetDomainMetadataMatchesAllKnownDomains(t *testing.T) { } } +func TestAuthLoginHelpMatchesInteractiveDomains(t *testing.T) { + 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") + } + metadata := getDomainMetadata("zh") + names := make([]string, len(metadata)) + for index, domain := range metadata { + names[index] = domain.Name + } + 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") for i := 1; i < len(domains); i++ { From a61b22df2564ef9ee6ae67ed952f6008523d822c Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:35:55 +0800 Subject: [PATCH 09/47] fix(extension): validate command path segments --- shortcuts/common/typed_compiler.go | 17 ++++++++++--- .../common/typed_compiler_invalid_test.go | 24 +++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/shortcuts/common/typed_compiler.go b/shortcuts/common/typed_compiler.go index e0cc7fa065..c17efa9d2f 100644 --- a/shortcuts/common/typed_compiler.go +++ b/shortcuts/common/typed_compiler.go @@ -128,15 +128,26 @@ func normalizeCommandMetadata(metadata CommandMetadata) CommandMetadata { } func validateCommandMetadata(metadata CommandMetadata) error { - if strings.TrimSpace(metadata.Service) == "" { + service := strings.TrimSpace(metadata.Service) + if service == "" { return fmt.Errorf("Metadata.Service is required") } - if strings.TrimSpace(metadata.Command) == "" { + if service != 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 !strings.HasPrefix(metadata.Command, "+") { + 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") } diff --git a/shortcuts/common/typed_compiler_invalid_test.go b/shortcuts/common/typed_compiler_invalid_test.go index 91f4e006c7..f12d4a3e56 100644 --- a/shortcuts/common/typed_compiler_invalid_test.go +++ b/shortcuts/common/typed_compiler_invalid_test.go @@ -38,6 +38,30 @@ func TestParseSchemaTagRejectsInvalidGrammar(t *testing.T) { } } +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 = 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 From c45fb3845e017997efde160a84a910d78e07aa28 Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:43:52 +0800 Subject: [PATCH 10/47] fix(extension): satisfy generator and error guards --- extension/command/internal/gen/main.go | 37 +++++++++++++++++--------- shortcuts/common/typed_external.go | 1 + shortcuts/register.go | 4 +-- 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/extension/command/internal/gen/main.go b/extension/command/internal/gen/main.go index 5402eaf27d..6adacfaee1 100644 --- a/extension/command/internal/gen/main.go +++ b/extension/command/internal/gen/main.go @@ -8,7 +8,6 @@ import ( "bytes" "fmt" "go/format" - "log" "os" "path/filepath" "regexp" @@ -17,39 +16,48 @@ import ( "strings" "github.com/larksuite/cli/internal/registry" + "github.com/larksuite/cli/internal/vfs" "github.com/larksuite/cli/shortcuts" ) var serviceNamePattern = regexp.MustCompile(`^[a-z][a-z0-9]*$`) -func commandDir() string { +func commandDir() (string, error) { _, sourceFile, _, ok := runtime.Caller(0) if !ok { - log.Fatal("command domain generator: cannot resolve source location") + return "", fmt.Errorf("cannot resolve source location") } - return filepath.Join(filepath.Dir(sourceFile), "..", "..") + return filepath.Join(filepath.Dir(sourceFile), "..", ".."), nil } +//nolint:forbidigo // Standalone go-generate process reports to stderr and exits before any CLI command boundary exists. func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "command domain generator:", err) + os.Exit(1) + } +} + +func run() error { seen := make(map[string]struct{}) for _, shortcut := range shortcuts.AllShortcuts() { seen[shortcut.Service] = struct{}{} } if len(seen) == 0 { - log.Fatal("command domain generator: no shortcut domains found") + return fmt.Errorf("no shortcut domains found") } domains := make([]string, 0, len(seen)) for domain := range seen { if !serviceNamePattern.MatchString(domain) { - log.Fatalf("command domain generator: service %q cannot form a stable Go identifier", domain) + return fmt.Errorf("service %q cannot form a stable Go identifier", domain) } for _, lang := range []string{"en", "zh"} { if registry.GetServiceTitle(domain, lang) == "" { - log.Fatalf("command domain generator: service %q has no %s title", domain, lang) + return fmt.Errorf("service %q has no %s title", domain, lang) } if registry.GetServiceDescription(domain, lang) == "" { - log.Fatalf("command domain generator: service %q has no %s description", domain, lang) + return fmt.Errorf("service %q has no %s description", domain, lang) } } domains = append(domains, domain) @@ -75,12 +83,17 @@ const ( formatted, err := format.Source(output.Bytes()) if err != nil { - log.Fatalf("command domain generator: format output: %v", err) + return fmt.Errorf("format output: %w", err) + } + dir, err := commandDir() + if err != nil { + return err } - target := filepath.Join(commandDir(), "domains_gen.go") - if err := os.WriteFile(target, formatted, 0o644); err != nil { - log.Fatalf("command domain generator: write %s: %v", target, err) + target := filepath.Join(dir, "domains_gen.go") + if err := vfs.WriteFile(target, formatted, 0o644); err != nil { + return fmt.Errorf("write %s: %w", target, err) } + return nil } func domainIdentifier(domain string) string { diff --git a/shortcuts/common/typed_external.go b/shortcuts/common/typed_external.go index 44bae4324f..3aaacffa8b 100644 --- a/shortcuts/common/typed_external.go +++ b/shortcuts/common/typed_external.go @@ -1,6 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT +//nolint:forbidigo // External definition diagnostics are intermediate build errors wrapped by the command-set startup guard. package common import ( diff --git a/shortcuts/register.go b/shortcuts/register.go index 3dbdde97c6..453972c7ee 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -127,7 +127,7 @@ func RegisterExternal(commands []common.Shortcut) error { func prepareExternalRegistration(existing, commands []common.Shortcut, alreadyRegistered bool) ([]common.Shortcut, error) { if alreadyRegistered { - return nil, fmt.Errorf("external command set is already registered") + return nil, fmt.Errorf("external command set is already registered") //nolint:forbidigo // Intermediate registration diagnostic wrapped by the command-set startup guard. } cloned := common.CloneShortcuts(commands) paths := make(map[string]struct{}, len(existing)+len(cloned)) @@ -137,7 +137,7 @@ func prepareExternalRegistration(existing, commands []common.Shortcut, alreadyRe for _, shortcut := range cloned { path := shortcut.Service + " " + shortcut.Command if _, duplicate := paths[path]; duplicate { - return nil, fmt.Errorf("external command path %q is already registered", path) + return nil, fmt.Errorf("external command path %q is already registered", path) //nolint:forbidigo // Intermediate registration diagnostic wrapped by the command-set startup guard. } paths[path] = struct{}{} } From b398604ce64873f426b76327b6167025849c8446 Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:46:23 +0800 Subject: [PATCH 11/47] fix(extension): follow generated domain naming contract --- cmd/command_sets_test.go | 6 +++--- .../command/commandtest/business_commands_test.go | 2 +- extension/command/domains_gen.go | 12 ++++++------ extension/command/internal/gen/main.go | 11 +---------- internal/commandhost/compile_test.go | 10 +++++----- 5 files changed, 16 insertions(+), 25 deletions(-) diff --git a/cmd/command_sets_test.go b/cmd/command_sets_test.go index 9c45e715d2..9752ac8435 100644 --- a/cmd/command_sets_test.go +++ b/cmd/command_sets_test.go @@ -73,7 +73,7 @@ func TestCommandSetSubprocess(t *testing.T) { } case "mount": commands := []command.Command{businessCommand("+business-captured", nil)} - option := WithCommandSets(command.Set{Domain: command.ExtendDomain(command.DomainIM), Commands: commands}) + 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") @@ -94,7 +94,7 @@ func TestCommandSetSubprocess(t *testing.T) { t.Fatalf("business tip is missing from help:\n%s", help.String()) } case "atomic": - set := command.Set{Domain: command.ExtendDomain(command.DomainIM), Commands: []command.Command{ + 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()) @@ -117,7 +117,7 @@ func TestCommandSetSubprocess(t *testing.T) { registerRestriction(t, []string{"im/+business-governed"}, nil) root := Build(context.Background(), buildInvocationForTest(t), WithCommandSets(command.Set{ - Domain: command.ExtendDomain(command.DomainIM), + Domain: command.ExtendDomain(command.DomainIm), Commands: []command.Command{businessCommand("+business-governed", &executed)}, }), WithoutStrictMode(), WithoutServiceCommands(), diff --git a/extension/command/commandtest/business_commands_test.go b/extension/command/commandtest/business_commands_test.go index c2e7bdfb34..d839bed5f1 100644 --- a/extension/command/commandtest/business_commands_test.go +++ b/extension/command/commandtest/business_commands_test.go @@ -210,7 +210,7 @@ func memberListDefinition() command.Definition[memberListArgs, memberListData] { 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.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) diff --git a/extension/command/domains_gen.go b/extension/command/domains_gen.go index b33612e8d1..4ed1e05d9b 100644 --- a/extension/command/domains_gen.go +++ b/extension/command/domains_gen.go @@ -22,8 +22,8 @@ const ( DomainDrive DomainName = "drive" // DomainEvent 表示事件订阅域。 DomainEvent DomainName = "event" - // DomainIM 表示消息与群组域。 - DomainIM DomainName = "im" + // DomainIm 表示消息与群组域。 + DomainIm DomainName = "im" // DomainMail 表示邮箱域。 DomainMail DomainName = "mail" // DomainMarkdown 表示Markdown域。 @@ -32,16 +32,16 @@ const ( DomainMinutes DomainName = "minutes" // DomainNote 表示会议纪要域。 DomainNote DomainName = "note" - // DomainOKR 表示OKR域。 - DomainOKR DomainName = "okr" + // DomainOkr 表示OKR域。 + DomainOkr DomainName = "okr" // DomainSheets 表示电子表格域。 DomainSheets DomainName = "sheets" // DomainSlides 表示幻灯片域。 DomainSlides DomainName = "slides" // DomainTask 表示任务域。 DomainTask DomainName = "task" - // DomainVC 表示视频会议域。 - DomainVC DomainName = "vc" + // DomainVc 表示视频会议域。 + DomainVc DomainName = "vc" // DomainWhiteboard 表示画板域。 DomainWhiteboard DomainName = "whiteboard" // DomainWiki 表示知识库域。 diff --git a/extension/command/internal/gen/main.go b/extension/command/internal/gen/main.go index 6adacfaee1..b86dd0ee83 100644 --- a/extension/command/internal/gen/main.go +++ b/extension/command/internal/gen/main.go @@ -97,14 +97,5 @@ const ( } func domainIdentifier(domain string) string { - switch domain { - case "im": - return "IM" - case "okr": - return "OKR" - case "vc": - return "VC" - default: - return strings.ToUpper(domain[:1]) + domain[1:] - } + return strings.ToUpper(domain[:1]) + domain[1:] } diff --git a/internal/commandhost/compile_test.go b/internal/commandhost/compile_test.go index d0fbca283c..87bc717cf3 100644 --- a/internal/commandhost/compile_test.go +++ b/internal/commandhost/compile_test.go @@ -42,7 +42,7 @@ func fixtureCommand(name string) command.Command { func TestCompileSetsCompilesTypedShortcut(t *testing.T) { compiled, err := CompileSets([]command.Set{{ - Domain: command.ExtendDomain(command.DomainIM), Commands: []command.Command{fixtureCommand("+external-fixture")}, + Domain: command.ExtendDomain(command.DomainIm), Commands: []command.Command{fixtureCommand("+external-fixture")}, }}) if err != nil { t.Fatal(err) @@ -56,7 +56,7 @@ func TestCompileSetsCompilesTypedShortcut(t *testing.T) { } func TestCompileSetsIsAtomicAcrossDuplicatePaths(t *testing.T) { - set := command.Set{Domain: command.ExtendDomain(command.DomainIM), Commands: []command.Command{ + set := command.Set{Domain: command.ExtendDomain(command.DomainIm), Commands: []command.Command{ fixtureCommand("+external-duplicate"), fixtureCommand("+external-duplicate"), }} compiled, err := CompileSets([]command.Set{set}) @@ -100,7 +100,7 @@ func TestCompileSetsRejectsSystemFlag(t *testing.T) { return command.Success(fixtureData{}), nil }}, }) - _, err := CompileSets([]command.Set{{Domain: command.ExtendDomain(command.DomainIM), Commands: []command.Command{declaration}}}) + _, 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) } @@ -118,7 +118,7 @@ func TestCompileSetsAddsPaginationFlags(t *testing.T) { return command.Success(command.Page[fixtureData]{}), nil }}, }) - compiled, err := CompileSets([]command.Set{{Domain: command.ExtendDomain(command.DomainIM), Commands: []command.Command{declaration}}}) + compiled, err := CompileSets([]command.Set{{Domain: command.ExtendDomain(command.DomainIm), Commands: []command.Command{declaration}}}) if err != nil { t.Fatal(err) } @@ -172,7 +172,7 @@ func TestExternalDryRunUsesOfflineContext(t *testing.T) { }, }) compiled, err := CompileSets([]command.Set{{ - Domain: command.ExtendDomain(command.DomainIM), Commands: []command.Command{declaration}, + Domain: command.ExtendDomain(command.DomainIm), Commands: []command.Command{declaration}, }}) if err != nil { t.Fatal(err) From 02a2e73b51aedc5034c0f4f5974e24a80e254e55 Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:45:49 +0800 Subject: [PATCH 12/47] fix(command): complete extension runtime contracts --- affordance/content.go | 16 ++ affordance/content_test.go | 15 ++ cmd/auth/login.go | 12 +- cmd/auth/login_test.go | 16 ++ cmd/command_sets_test.go | 39 +++- cmd/schema/schema.go | 88 +++++++++ content_embed.go | 33 +--- extension/command/command_test.go | 26 ++- .../commandtest/business_commands_test.go | 143 +++++++++++++-- extension/command/commandtest/commandtest.go | 167 ++++++++++++++++-- .../command/commandtest/commandtest_test.go | 94 +++++++++- extension/command/context.go | 51 ++---- extension/command/definition.go | 1 + extension/command/host.go | 6 + extension/command/pagination.go | 115 ++++-------- extension/command/testdata/wrapper/main.go | 59 +++++++ extension/command/wrapper_e2e_test.go | 80 +++++++++ extension/platform/README.md | 40 ++--- extension/platform/skillsoverlay.go | 7 +- internal/commandhost/compile.go | 35 +++- internal/commandhost/compile_test.go | 57 ++++++ internal/pagination/walk.go | 127 +++++++++++++ internal/pagination/walk_test.go | 77 ++++++++ shortcuts/common/paginate_into.go | 137 +++++++------- shortcuts/common/runner.go | 15 +- shortcuts/common/runner_jq_test.go | 21 +++ shortcuts/common/typed_compiler.go | 8 + shortcuts/common/typed_definition.go | 1 + shortcuts/common/typed_external_pagination.go | 76 ++++++++ shortcuts/common/typed_schema_export.go | 12 ++ shortcuts/common/types.go | 7 +- skills/content.go | 16 ++ skills/content_test.go | 17 ++ 33 files changed, 1319 insertions(+), 295 deletions(-) create mode 100644 affordance/content.go create mode 100644 affordance/content_test.go create mode 100644 extension/command/testdata/wrapper/main.go create mode 100644 extension/command/wrapper_e2e_test.go create mode 100644 internal/pagination/walk.go create mode 100644 internal/pagination/walk_test.go create mode 100644 shortcuts/common/typed_external_pagination.go create mode 100644 shortcuts/common/typed_schema_export.go create mode 100644 skills/content.go create mode 100644 skills/content_test.go diff --git a/affordance/content.go b/affordance/content.go new file mode 100644 index 0000000000..8048c67de2 --- /dev/null +++ b/affordance/content.go @@ -0,0 +1,16 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package affordance exposes the repository's default embedded command guidance. +package affordance + +import ( + "embed" + "io/fs" +) + +//go:embed *.md +var content embed.FS + +// DefaultFS returns the immutable default affordance tree rooted at domain files. +func DefaultFS() fs.FS { return content } diff --git a/affordance/content_test.go b/affordance/content_test.go new file mode 100644 index 0000000000..a5acc42f75 --- /dev/null +++ b/affordance/content_test.go @@ -0,0 +1,15 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package affordance + +import ( + "io/fs" + "testing" +) + +func TestDefaultFSContainsDomainGuidance(t *testing.T) { + if _, err := fs.ReadFile(DefaultFS(), "im.md"); err != nil { + t.Fatalf("read im.md: %v", err) + } +} diff --git a/cmd/auth/login.go b/cmd/auth/login.go index c0e6d426dd..9454384ac8 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -509,6 +509,10 @@ func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.App // Domains with auth_domain children are automatically expanded to include // their children's scopes. func collectScopesForDomains(domains []string, identity string, brand core.LarkBrand) []string { + return collectScopesForDomainsWithShortcuts(domains, identity, brand, shortcuts.AllShortcuts()) +} + +func collectScopesForDomainsWithShortcuts(domains []string, identity string, brand core.LarkBrand, registered []common.Shortcut) []string { scopeSet := make(map[string]bool) // 1. API scopes from from_meta projects @@ -526,7 +530,7 @@ func collectScopesForDomains(domains []string, identity string, brand core.LarkB } // 3. Shortcut scopes matching by Service (only include shortcuts supporting the identity) - for _, sc := range shortcuts.AllShortcuts() { + for _, sc := range registered { if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) { continue } @@ -550,13 +554,17 @@ func collectScopesForDomains(domains []string, identity string, brand core.LarkB // shortcut services), excluding domains that have auth_domain set (they are // folded into their parent domain). func allKnownDomains(brand core.LarkBrand) map[string]bool { + return allKnownDomainsWithShortcuts(brand, shortcuts.AllShortcuts()) +} + +func allKnownDomainsWithShortcuts(brand core.LarkBrand, registered []common.Shortcut) map[string]bool { domains := make(map[string]bool) for _, p := range registry.ListFromMetaProjects() { if !registry.HasAuthDomain(p) { domains[p] = true } } - for _, sc := range shortcuts.AllShortcuts() { + for _, sc := range registered { if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) { continue } diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index f62f00048f..95a0f928dd 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -11,6 +11,7 @@ import ( "fmt" "io" "net/http" + "slices" "sort" "strings" "testing" @@ -304,6 +305,21 @@ func TestGetDomainMetadataIncludesAuthorizableShortcutDomains(t *testing.T) { } } +func TestExternalShortcutScopesParticipateInAuthDomainResolution(t *testing.T) { + registered := []common.Shortcut{{ + Service: "im", Command: "+business-auth", AuthTypes: []string{"user"}, + UserScopes: []string{"im:business.scope:read"}, + }} + domains := allKnownDomainsWithShortcuts("", registered) + if !domains["im"] { + t.Fatal("external shortcut domain is missing from auth domains") + } + scopes := collectScopesForDomainsWithShortcuts([]string{"im"}, "user", "", registered) + if !slices.Contains(scopes, "im:business.scope:read") { + t.Fatalf("external shortcut scope is missing: %v", scopes) + } +} + func TestGetDomainMetadataMatchesAllKnownDomains(t *testing.T) { metadata := getDomainMetadata("zh") known := allKnownDomains("") diff --git a/cmd/command_sets_test.go b/cmd/command_sets_test.go index 9752ac8435..dc2552652a 100644 --- a/cmd/command_sets_test.go +++ b/cmd/command_sets_test.go @@ -4,6 +4,7 @@ package cmd import ( + "bytes" "context" "errors" "os" @@ -47,7 +48,7 @@ func businessCommand(name string, executed *bool) command.Command { } func TestWithCommandSetsInIsolatedProcesses(t *testing.T) { - for _, scenario := range []string{"official", "mount", "atomic", "governance"} { + for _, scenario := range []string{"official", "mount", "atomic", "governance", "surface"} { t.Run(scenario, func(t *testing.T) { process := exec.Command(os.Args[0], "-test.run=^TestCommandSetSubprocess$", "-test.v") process.Env = append(os.Environ(), "LARK_CLI_COMMAND_SET_SCENARIO="+scenario) @@ -134,6 +135,42 @@ func TestCommandSetSubprocess(t *testing.T) { if executed { t.Fatal("governance denial reached business Execute") } + case "surface": + var stdout, stderr bytes.Buffer + root := Build(context.Background(), buildInvocationForTest(t), + WithIO(strings.NewReader(""), &stdout, &stderr), + WithCommandSets(command.Set{ + Domain: command.ExtendDomain(command.DomainIm), + Commands: []command.Command{businessCommand("+business-surface", nil)}, + }), + WithoutPlugins(), WithoutStrictMode(), WithoutServiceCommands(), + ) + root.SetArgs([]string{"__complete", "im", "+"}) + if _, err := root.ExecuteC(); err != nil { + t.Fatalf("complete external command: %v\nstderr: %s", err, stderr.String()) + } + if !strings.Contains(stdout.String(), "+business-surface") { + t.Fatalf("external command is missing from shell completion: %s", stdout.String()) + } + stdout.Reset() + stderr.Reset() + root.SetArgs([]string{"__complete", "schema", "im", "+business-"}) + if _, err := root.ExecuteC(); err != nil { + t.Fatalf("complete external schema: %v\nstderr: %s", err, stderr.String()) + } + if !strings.Contains(stdout.String(), "+business-surface") { + t.Fatalf("external schema is missing from shell completion: %s", stdout.String()) + } + stdout.Reset() + stderr.Reset() + root.SetArgs([]string{"schema", "im", "+business-surface"}) + if _, err := root.ExecuteC(); err != nil { + t.Fatalf("schema external command: %v\nstderr: %s", err, stderr.String()) + } + if !strings.Contains(stdout.String(), `"name": "im +business-surface"`) || + !strings.Contains(stdout.String(), `"inputSchema"`) || !strings.Contains(stdout.String(), `"outputSchema"`) { + t.Fatalf("external schema = %s", stdout.String()) + } default: t.Fatalf("unknown scenario %q", scenario) } diff --git a/cmd/schema/schema.go b/cmd/schema/schema.go index b4ead42b3b..8b20aaee8c 100644 --- a/cmd/schema/schema.go +++ b/cmd/schema/schema.go @@ -7,6 +7,7 @@ import ( "context" "errors" "io" + "sort" "strings" "github.com/larksuite/cli/errs" @@ -17,6 +18,8 @@ import ( "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/internal/schema" + "github.com/larksuite/cli/shortcuts" + "github.com/larksuite/cli/shortcuts/common" "github.com/spf13/cobra" ) @@ -100,6 +103,7 @@ func completeSchemaPath( mode := f.ResolveStrictMode(cmd.Context()) catalog := projectSchemaCatalog(registry.SchemaCatalog(), visibility) completions, noSpace := catalog.Complete(args, toComplete, registry.FilterForStrictMode(mode)) + completions = mergeSchemaCompletions(completions, shortcutSchemaCompletions(args, toComplete, visibility)) directive := cobra.ShellCompDirectiveNoFileComp if noSpace { directive |= cobra.ShellCompDirectiveNoSpace @@ -135,6 +139,10 @@ func runSchemaCatalog( catalog apicatalog.Catalog, visibility CommandVisibility, ) error { + if contract, ok := resolveShortcutSchema(parts, visibility); ok { + output.PrintJson(out, contract) + return nil + } // Test the source catalog before presentation projection. A distribution // that intentionally conceals every generated method still has metadata; // bare `schema` should render an empty list rather than claim metadata is @@ -164,6 +172,86 @@ func runSchemaCatalog( return nil } +func resolveShortcutSchema(parts []string, visibility CommandVisibility) (any, bool) { + if len(parts) != 2 || !strings.HasPrefix(parts[1], "+") { + return nil, false + } + for _, shortcut := range shortcuts.AllShortcuts() { + if shortcut.Service != parts[0] || shortcut.Command != parts[1] { + continue + } + if visibility != nil && !visibility([]string{shortcut.Service, shortcut.Command}) { + return nil, false + } + return common.ShortcutSchema(shortcut) + } + return nil, false +} + +func shortcutSchemaCompletions(args []string, toComplete string, visibility CommandVisibility) []string { + registered := shortcuts.AllShortcuts() + if len(args) == 0 && strings.Contains(toComplete, ".") { + parts := strings.SplitN(toComplete, ".", 2) + return shortcutCommandCompletions(registered, parts[0], parts[1], parts[0]+".", visibility) + } + if len(args) == 0 { + services := make(map[string]struct{}) + for _, shortcut := range registered { + if !strings.HasPrefix(shortcut.Service, toComplete) || !shortcutSchemaVisible(shortcut, visibility) { + continue + } + if _, ok := common.ShortcutSchema(shortcut); ok { + services[shortcut.Service] = struct{}{} + } + } + result := make([]string, 0, len(services)) + for service := range services { + result = append(result, service) + } + sort.Strings(result) + return result + } + if len(args) == 1 { + return shortcutCommandCompletions(registered, args[0], toComplete, "", visibility) + } + return nil +} + +func shortcutCommandCompletions(registered []common.Shortcut, service, prefix, outputPrefix string, visibility CommandVisibility) []string { + var result []string + for _, shortcut := range registered { + if shortcut.Service != service || !strings.HasPrefix(shortcut.Command, prefix) || !shortcutSchemaVisible(shortcut, visibility) { + continue + } + if _, ok := common.ShortcutSchema(shortcut); ok { + result = append(result, outputPrefix+shortcut.Command+"\t"+shortcut.Description) + } + } + sort.Strings(result) + return result +} + +func shortcutSchemaVisible(shortcut common.Shortcut, visibility CommandVisibility) bool { + return visibility == nil || visibility([]string{shortcut.Service, shortcut.Command}) +} + +func mergeSchemaCompletions(groups ...[]string) []string { + seen := make(map[string]struct{}) + var result []string + for _, group := range groups { + for _, candidate := range group { + name := strings.SplitN(candidate, "\t", 2)[0] + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + result = append(result, candidate) + } + } + sort.Strings(result) + return result +} + // projectSchemaCatalog produces the metadata view corresponding to one final // command surface. It lives in cmd/schema so apicatalog remains a policy-free // navigation module. Resolve, broad listings, and Complete all consume the diff --git a/content_embed.go b/content_embed.go index a31ce63e70..1cdb1942cf 100644 --- a/content_embed.go +++ b/content_embed.go @@ -4,37 +4,16 @@ package main import ( - "embed" - "fmt" - "io/fs" - "os" - + defaultaffordance "github.com/larksuite/cli/affordance" "github.com/larksuite/cli/cmd" + defaultskills "github.com/larksuite/cli/skills" ) -// embeddedContentFS bundles the agent-readable content that must ship in lockstep -// with the binary: each skill's docs (SKILL.md + references/, plus whiteboard's -// routes/ and scenes/) and the per-domain affordance guidance (affordance/*.md). -// Machine-resource skill dirs (assets/, scripts/) are excluded. It's a whitelist — -// a new content type is omitted until added to the embed list. The embed must live -// in this root package because go:embed cannot reach up out of a package's dir. -// -//go:embed skills/*/SKILL.md skills/*/references skills/*/routes skills/*/scenes affordance/*.md -var embeddedContentFS embed.FS - // init wires the embedded content into the CLI. It compiles into `go build .` but // not the single-file preview build (`go build ./main.go`), so that build stays -// self-contained (shipping no embedded content). Assembly failures warn on stderr -// rather than panicking — embedded content is nice-to-have, not load-bearing. +// self-contained (shipping no embedded content). External wrapper distributions +// can import the same default files from the skills and affordance packages. func init() { - if sub, err := fs.Sub(embeddedContentFS, "skills"); err != nil { - fmt.Fprintln(os.Stderr, "warning: skills embed assembly failed, skills commands disabled:", err) - } else { - cmd.SetEmbeddedSkillContent(sub) - } - if sub, err := fs.Sub(embeddedContentFS, "affordance"); err != nil { - fmt.Fprintln(os.Stderr, "warning: affordance embed assembly failed, command guidance disabled:", err) - } else { - cmd.SetEmbeddedAffordanceContent(sub) - } + cmd.SetEmbeddedSkillContent(defaultskills.DefaultFS()) + cmd.SetEmbeddedAffordanceContent(defaultaffordance.DefaultFS()) } diff --git a/extension/command/command_test.go b/extension/command/command_test.go index 098d650bcd..c640a8df8c 100644 --- a/extension/command/command_test.go +++ b/extension/command/command_test.go @@ -118,14 +118,12 @@ func TestCollectPagesUsesHostPolicyAndMetadata(t *testing.T) { var calls []RequestView ctx := NewCommandContext(ContextOptions{ Identity: IdentityUser, - CallJSON: func(_ context.Context, request Request) (map[string]any, error) { - calls = append(calls, InspectRequest(request)) - response := responses[0] - responses = responses[1:] - return response, nil - }, - PaginationOptions: func() (PaginationOptions, error) { - return PaginationOptions{All: true, MaxPages: 10}, nil + CollectPages: func(_ context.Context, request Request, all bool) ([]map[string]any, HostPagination, error) { + if all { + t.Fatal("CollectPages forced full pagination") + } + calls = append(calls, InspectRequest(request), InspectRequest(request.Set("page_token", "next"))) + return responses, HostPagination{Complete: true, Pages: 2}, nil }, }) page, err := CollectPages[contractData](context.Background(), ctx, GET("/open-apis/im/v1/chats")) @@ -145,6 +143,18 @@ func TestCollectPagesUsesHostPolicyAndMetadata(t *testing.T) { } } +func TestPageResultCountsFilteredItems(t *testing.T) { + page := Page[contractData]{ + Items: []contractData{{ID: "one"}, {ID: "two"}}, + meta: &paginationMeta{Complete: true, Pages: 1, Items: 2}, + } + page.Items = page.Items[:1] + result := hostResult(Success(page)) + if result.Pagination == nil || result.Pagination.Items != 1 { + t.Fatalf("filtered pagination = %#v", result.Pagination) + } +} + func TestDryRunPreventsRequestsAndScopeChecks(t *testing.T) { calls := 0 ctx := NewCommandContext(ContextOptions{ diff --git a/extension/command/commandtest/business_commands_test.go b/extension/command/commandtest/business_commands_test.go index d839bed5f1..5f1840980c 100644 --- a/extension/command/commandtest/business_commands_test.go +++ b/extension/command/commandtest/business_commands_test.go @@ -6,7 +6,9 @@ package commandtest_test import ( "context" "errors" + "fmt" "reflect" + "strings" "testing" "github.com/larksuite/cli/extension/command" @@ -39,7 +41,10 @@ func documentGetDefinition() command.Definition[documentGetArgs, documentData] { }, Execute: func(ctx context.Context, commandContext command.CommandContext, args *documentGetArgs) (command.Result[documentData], error) { data, err := command.CallJSON[documentData](ctx, commandContext, request(args)) - return command.Success(data), err + if err != nil { + return command.Result[documentData]{}, err + } + return command.Success(data), nil }, }, } @@ -71,7 +76,10 @@ func chatListDefinition() command.Definition[chatListArgs, command.Page[chatData }, Execute: func(ctx context.Context, commandContext command.CommandContext, args *chatListArgs) (command.Result[command.Page[chatData]], error) { page, err := command.CollectPages[chatData](ctx, commandContext, request(args)) - return command.Success(page), err + if err != nil { + return command.Result[command.Page[chatData]]{}, err + } + return command.Success(page), nil }, }, } @@ -125,7 +133,7 @@ func taskAuditDefinition() command.Definition[taskAuditArgs, taskAuditData] { Execute: func(ctx context.Context, commandContext command.CommandContext, args *taskAuditArgs) (command.Result[taskAuditData], error) { tasks, err := command.CollectAllPages[taskRecord](ctx, commandContext, listRequest) if err != nil { - return command.Success(taskAuditData{}), err + return command.Result[taskAuditData]{}, err } data := taskAuditData{Items: make([]taskAuditItem, 0, len(tasks))} if !args.IncludeOwners { @@ -135,7 +143,7 @@ func taskAuditDefinition() command.Definition[taskAuditArgs, taskAuditData] { return command.Success(data), nil } if err := command.PreflightScopes(commandContext, "contact:user.base:readonly"); err != nil { - return command.Success(data), err + return command.Result[taskAuditData]{}, err } for _, task := range tasks { owner, ownerErr := command.CallJSON[struct { @@ -191,17 +199,23 @@ func memberListDefinition() command.Definition[memberListArgs, memberListData] { }, Execute: func(ctx context.Context, commandContext command.CommandContext, args *memberListArgs) (command.Result[memberListData], error) { data, err := command.CallJSON[memberListData](ctx, commandContext, command.GET("/open-apis/im/v1/chats/"+args.ChatID)) - if err != nil || !args.IncludeMembers { - return command.Success(data), err + if err != nil { + return command.Result[memberListData]{}, err + } + if !args.IncludeMembers { + return command.Success(data), nil } if err := command.PreflightScopes(commandContext, "im:chat.members:read"); err != nil { - return command.Success(data), err + return command.Result[memberListData]{}, err } members, err := command.CallJSON[struct { Items []string `json:"items"` }](ctx, commandContext, command.GET("/open-apis/im/v1/chats/"+args.ChatID+"/members")) + if err != nil { + return command.Result[memberListData]{}, err + } data.Members = members.Items - return command.Success(data), err + return command.Success(data), nil }, }, } @@ -241,6 +255,16 @@ func TestSingleReadAndDryRunUseSameRequest(t *testing.T) { recorder.AssertScriptConsumed() } +func TestSingleReadPreservesTypedAPIError(t *testing.T) { + want := command.InvalidResponseErrorf("upstream response is malformed") + recorder := commandtest.New(t, commandtest.Fail(want)) + _, err := commandtest.Execute(context.Background(), recorder, command.IdentityUser, documentGetDefinition(), &documentGetArgs{DocumentID: "doc_1"}) + if !errors.Is(err, want) { + t.Fatalf("single read error = %v", err) + } + recorder.AssertScriptConsumed() +} + func TestListCommandUsesHostPagination(t *testing.T) { recorder := commandtest.New(t, commandtest.Respond(map[string]any{ @@ -250,8 +274,8 @@ func TestListCommandUsesHostPagination(t *testing.T) { "items": []map[string]any{{"chat_id": "chat_2", "name": "two"}}, "has_more": false, }), ) - recorder.SetPagination(command.PaginationOptions{All: true, MaxPages: 3}) - execution, err := commandtest.Execute(context.Background(), recorder, command.IdentityUser, chatListDefinition(), &chatListArgs{PageSize: 20}) + execution, err := commandtest.RunWithFlags(context.Background(), recorder, command.IdentityUser, + chatListDefinition(), &chatListArgs{PageSize: 20}, "--page-all", "--page-limit=3", "--page-delay=0") if err != nil { t.Fatal(err) } @@ -265,6 +289,105 @@ func TestListCommandUsesHostPagination(t *testing.T) { recorder.AssertScriptConsumed() } +func TestListCommandReadsOnePageByDefault(t *testing.T) { + recorder := commandtest.New(t, commandtest.Respond(map[string]any{ + "items": []map[string]any{{"chat_id": "chat_1", "name": "one"}}, "has_more": true, "page_token": "next", + })) + execution, err := commandtest.Execute(context.Background(), recorder, command.IdentityUser, chatListDefinition(), &chatListArgs{PageSize: 20}) + if err != nil { + t.Fatal(err) + } + if execution.Data.Complete() || execution.Data.Pages() != 1 || execution.Data.NextToken() != "next" { + t.Fatalf("default page complete=%v pages=%d next=%q", execution.Data.Complete(), execution.Data.Pages(), execution.Data.NextToken()) + } + if len(recorder.Requests()) != 1 { + t.Fatalf("default requests = %#v", recorder.Requests()) + } + recorder.AssertScriptConsumed() +} + +func TestListCommandResumesAndStopsAtPageLimit(t *testing.T) { + recorder := commandtest.New(t, + commandtest.Respond(map[string]any{"items": []map[string]any{{"chat_id": "chat_1"}}, "has_more": true, "page_token": "next-1"}), + commandtest.Respond(map[string]any{"items": []map[string]any{{"chat_id": "chat_2"}}, "has_more": true, "page_token": "next-2"}), + ) + recorder.SetPagination(command.PaginationOptions{All: true, MaxPages: 2}) + page, err := command.CollectPages[chatData](context.Background(), recorder.CommandContext(command.IdentityUser), + command.GET("/open-apis/im/v1/chats").Set("page_token", "resume")) + if err != nil { + t.Fatal(err) + } + if page.Complete() || page.Pages() != 2 || page.NextToken() != "next-2" || len(page.Items) != 2 { + t.Fatalf("limited page complete=%v pages=%d next=%q items=%d", page.Complete(), page.Pages(), page.NextToken(), len(page.Items)) + } + requests := recorder.Requests() + if len(requests) != 2 || requests[0].Query["page_token"] != "resume" || requests[1].Query["page_token"] != "next-1" { + t.Fatalf("resume requests = %#v", requests) + } + recorder.AssertScriptConsumed() +} + +func TestCollectAllPagesRejectsInvalidCursors(t *testing.T) { + for _, test := range []struct { + name string + responses []commandtest.Response + }{ + {name: "missing", responses: []commandtest.Response{ + commandtest.Respond(map[string]any{"items": []map[string]any{}, "has_more": true}), + }}, + {name: "repeated", responses: []commandtest.Response{ + commandtest.Respond(map[string]any{"items": []map[string]any{}, "has_more": true, "page_token": "same"}), + commandtest.Respond(map[string]any{"items": []map[string]any{}, "has_more": true, "page_token": "same"}), + }}, + } { + t.Run(test.name, func(t *testing.T) { + recorder := commandtest.New(t, test.responses...) + _, err := command.CollectAllPages[chatData](context.Background(), recorder.CommandContext(command.IdentityUser), command.GET("/open-apis/im/v1/chats")) + if err == nil { + t.Fatal("CollectAllPages() error is nil") + } + recorder.AssertScriptConsumed() + }) + } +} + +func TestCollectAllPagesHardLimitPreventsFollowingWrite(t *testing.T) { + responses := make([]commandtest.Response, 1000) + for index := range responses { + responses[index] = commandtest.Respond(map[string]any{ + "items": []map[string]any{}, "has_more": true, "page_token": fmt.Sprintf("page-%d", index+1), + }) + } + type args struct{} + type data struct{} + definition := command.Definition[args, data]{ + Metadata: command.CommandMetadata{ + Service: "task", Command: "+business-hard-limit", Description: "Test complete read", Risk: command.RiskWrite, + Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{command.IdentityUser: {}}}, + }, + Hooks: command.Hooks[args, data]{ + Execute: func(ctx context.Context, commandContext command.CommandContext, _ *args) (command.Result[data], error) { + if _, err := command.CollectAllPages[taskRecord](ctx, commandContext, command.GET("/open-apis/task/v2/tasks")); err != nil { + return command.Result[data]{}, err + } + if _, err := command.CallJSON[map[string]any](ctx, commandContext, command.POST("/open-apis/task/v2/tasks")); err != nil { + return command.Result[data]{}, err + } + return command.Success(data{}), nil + }, + }, + } + recorder := commandtest.New(t, responses...) + _, err := commandtest.Execute(context.Background(), recorder, command.IdentityUser, definition, &args{}) + if err == nil || !strings.Contains(err.Error(), "hard limit") { + t.Fatalf("hard-limit error = %v", err) + } + if requests := recorder.Requests(); len(requests) != 1000 || requests[len(requests)-1].Method != "GET" { + t.Fatalf("requests after incomplete read = %d, last=%#v", len(requests), requests[len(requests)-1]) + } + recorder.AssertScriptConsumed() +} + func TestMultiCallCommandReturnsPartialData(t *testing.T) { wantFailure := command.InvalidResponseErrorf("owner record is unavailable") recorder := commandtest.New(t, diff --git a/extension/command/commandtest/commandtest.go b/extension/command/commandtest/commandtest.go index 356000d2a1..d897ba2191 100644 --- a/extension/command/commandtest/commandtest.go +++ b/extension/command/commandtest/commandtest.go @@ -10,17 +10,23 @@ import ( "encoding/json" "errors" "fmt" + "io" "reflect" "sync" "testing" + "time" "github.com/larksuite/cli/extension/command" + internalpagination "github.com/larksuite/cli/internal/pagination" + "github.com/spf13/pflag" ) // Response is one scripted OpenAPI response. type Response struct { - data any - err error + data any + err error + expectedMethod string + expectedPath string } // Respond creates a successful scripted response containing an OpenAPI data object. @@ -53,6 +59,15 @@ func New(testing testing.TB, responses ...Response) *Recorder { } } +// ReplyJSON appends an ordered successful response with an expected request method and path. +func (r *Recorder) ReplyJSON(method, path string, data any) *Recorder { + r.testing.Helper() + r.mu.Lock() + r.responses = append(r.responses, Response{data: data, expectedMethod: method, expectedPath: path}) + r.mu.Unlock() + return r +} + // CommandContext returns a restricted public command context. func (r *Recorder) CommandContext(identity command.Identity) command.CommandContext { return r.commandContext(identity, false) @@ -65,11 +80,11 @@ func (r *Recorder) DryRunContext(identity command.Identity) command.CommandConte func (r *Recorder) commandContext(identity command.Identity, dryRun bool) command.CommandContext { return command.NewCommandContext(command.ContextOptions{ - Identity: identity, - DryRun: dryRun, - CallJSON: r.callJSON, - PreflightScopes: r.preflightScopes, - PaginationOptions: r.paginationOptions, + Identity: identity, + DryRun: dryRun, + CallJSON: r.callJSON, + PreflightScopes: r.preflightScopes, + CollectPages: r.collectPages, }) } @@ -99,6 +114,9 @@ func Execute[Args any, Data any](ctx context.Context, recorder *Recorder, identi } result, err := declaration.Hooks.Execute(ctx, commandContext, args) if err != nil { + if result.Outcome != "" || result.Pagination != nil { + return execution, command.InternalErrorf("business Execute returned both Result and error").WithCause(err) + } return execution, err } data, ok := result.Data.(Data) @@ -108,6 +126,39 @@ func Execute[Args any, Data any](ctx context.Context, recorder *Recorder, identi return Execution[Data]{Data: data, Partial: result.Outcome == "partial"}, nil } +// RunWithFlags executes a page-returning command with the framework's standard pagination flags. +func RunWithFlags[Args any, Data any](ctx context.Context, recorder *Recorder, identity command.Identity, definition command.Definition[Args, Data], args *Args, flags ...string) (Execution[Data], error) { + if !command.InspectCommand(command.Define(definition)).PageOutput { + return Execution[Data]{}, command.ValidationErrorf("framework pagination flags require a Page output") + } + options, err := parsePaginationFlags(flags) + if err != nil { + return Execution[Data]{}, err + } + restore := recorder.replacePagination(options) + defer restore() + return Execute(ctx, recorder, identity, definition, args) +} + +func parsePaginationFlags(arguments []string) (command.PaginationOptions, error) { + flags := pflag.NewFlagSet("commandtest pagination", pflag.ContinueOnError) + flags.SetOutput(io.Discard) + pageAll := flags.Bool("page-all", false, "") + pageLimit := flags.Int("page-limit", 10, "") + pageDelay := flags.Int("page-delay", 200, "") + if err := flags.Parse(arguments); err != nil { + return command.PaginationOptions{}, command.ValidationErrorf("parse framework pagination flags: %v", err).WithCause(err) + } + if flags.NArg() != 0 { + return command.PaginationOptions{}, command.ValidationErrorf("unexpected framework pagination argument %q", flags.Arg(0)) + } + return command.PaginationOptions{ + All: *pageAll, + MaxPages: *pageLimit, + Delay: time.Duration(*pageDelay) * time.Millisecond, + }, nil +} + // Preview runs Normalize, Validate, and DryRun with an offline test context. func Preview[Args any, Data any](ctx context.Context, recorder *Recorder, identity command.Identity, definition command.Definition[Args, Data], args *Args) (*command.DryRun, error) { declaration := command.InspectCommand(command.Define(definition)) @@ -122,9 +173,12 @@ func Preview[Args any, Data any](ctx context.Context, recorder *Recorder, identi return nil, err } } - if declaration.Hooks.DryRun == nil { + if declaration.Hooks.DryRun == nil && declaration.Hooks.DryRunE == nil { return nil, errors.New("business command has no DryRun hook") } + if declaration.Hooks.DryRunE != nil { + return declaration.Hooks.DryRunE(ctx, commandContext, args) + } return declaration.Hooks.DryRun(ctx, commandContext, args), nil } @@ -159,6 +213,18 @@ func (r *Recorder) SetPagination(options command.PaginationOptions) { r.mu.Unlock() } +func (r *Recorder) replacePagination(options command.PaginationOptions) func() { + r.mu.Lock() + previous := r.pagination + r.pagination = options + r.mu.Unlock() + return func() { + r.mu.Lock() + r.pagination = previous + r.mu.Unlock() + } +} + // SetScopeError makes every subsequent scope preflight return err after recording it. func (r *Recorder) SetScopeError(err error) { r.mu.Lock() @@ -238,6 +304,12 @@ func (r *Recorder) callJSON(ctx context.Context, request command.Request) (map[s cancel := r.cancel shouldCancel := r.cancelAfterRequest == requestNumber r.mu.Unlock() + if response.expectedMethod != "" && response.expectedMethod != view.Method { + return nil, fmt.Errorf("request %d method = %q, expected %q", requestNumber, view.Method, response.expectedMethod) + } + if response.expectedPath != "" && response.expectedPath != view.Path { + return nil, fmt.Errorf("request %d path = %q, expected %q", requestNumber, view.Path, response.expectedPath) + } if response.err != nil { return nil, response.err @@ -252,17 +324,84 @@ func (r *Recorder) callJSON(ctx context.Context, request command.Request) (map[s return data, nil } -func (r *Recorder) preflightScopes(scopes ...string) error { +func (r *Recorder) collectPages(ctx context.Context, request command.Request, all bool) ([]map[string]any, command.HostPagination, error) { r.mu.Lock() - defer r.mu.Unlock() - r.scopeChecks = append(r.scopeChecks, append([]string(nil), scopes...)) - return r.scopeError + options := r.pagination + r.mu.Unlock() + if all { + options = command.PaginationOptions{All: true, MaxPages: 1000} + } else if !options.All { + options.MaxPages = 1 + } + if options.MaxPages < 1 || options.MaxPages > 1000 { + return nil, command.HostPagination{}, command.ValidationErrorf("pagination page limit must be between 1 and 1000") + } + if options.Delay < 0 || options.Delay > time.Minute { + return nil, command.HostPagination{}, command.ValidationErrorf("pagination delay must be between 0 and 60000 milliseconds") + } + + var pages []map[string]any + state, err := internalpagination.Walk(ctx, internalpagination.Options{ + InitialToken: requestPageToken(command.InspectRequest(request).Query), + MaxPages: options.MaxPages, + Delay: options.Delay, + Fetch: func(ctx context.Context, _ int, token string) (bool, string, error) { + pageRequest := request + if token != "" { + pageRequest = pageRequest.Set("page_token", token) + } + data, err := r.callJSON(ctx, pageRequest) + if err != nil { + return false, "", err + } + pages = append(pages, data) + hasMore, _ := data["has_more"].(bool) + nextToken, _ := data["page_token"].(string) + if nextToken == "" { + nextToken, _ = data["next_page_token"].(string) + } + return hasMore, nextToken, nil + }, + }) + pagination := command.HostPagination{Complete: state.Complete, Pages: state.Pages, NextToken: state.NextToken} + if err == nil { + return pages, pagination, nil + } + var cursorErr *internalpagination.CursorError + if errors.As(err, &cursorErr) { + if cursorErr.Kind == internalpagination.CursorMissing { + return pages, pagination, command.InvalidResponseErrorf("pagination page %d reports has_more=true without a page token", cursorErr.Page) + } + return pages, pagination, command.InvalidResponseErrorf("pagination page %d repeated page token %q", cursorErr.Page, cursorErr.Token) + } + var waitErr *internalpagination.WaitError + if errors.As(err, &waitErr) { + return pages, pagination, command.PaginationInterruptedError(waitErr.Err) + } + return pages, pagination, err } -func (r *Recorder) paginationOptions() (command.PaginationOptions, error) { +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() - return r.pagination, nil + r.scopeChecks = append(r.scopeChecks, append([]string(nil), scopes...)) + return r.scopeError } func responseDataObject(value any) (map[string]any, error) { diff --git a/extension/command/commandtest/commandtest_test.go b/extension/command/commandtest/commandtest_test.go index eb8dd028ef..3539c10751 100644 --- a/extension/command/commandtest/commandtest_test.go +++ b/extension/command/commandtest/commandtest_test.go @@ -7,6 +7,7 @@ import ( "context" "errors" "reflect" + "strings" "testing" "time" @@ -60,13 +61,39 @@ func TestRecorderReturnsScriptedFailuresInOrder(t *testing.T) { recorder.AssertScriptConsumed() } +func TestRecorderReplyJSONChecksRequestInOrder(t *testing.T) { + recorder := New(t). + ReplyJSON("GET", "/open-apis/im/v1/chats/first", map[string]any{"id": "first"}). + ReplyJSON("GET", "/open-apis/im/v1/chats/second", map[string]any{"id": "second"}) + commandContext := recorder.CommandContext(command.IdentityUser) + for _, id := range []string{"first", "second"} { + data, err := command.CallJSON[map[string]any](context.Background(), commandContext, command.GET("/open-apis/im/v1/chats/"+id)) + if err != nil { + t.Fatal(err) + } + if data["id"] != id { + t.Fatalf("response = %#v", data) + } + } + recorder.AssertScriptConsumed() +} + +func TestRecorderReplyJSONRejectsUnexpectedRequest(t *testing.T) { + recorder := New(t).ReplyJSON("POST", "/open-apis/im/v1/chats", map[string]any{}) + _, err := command.CallJSON[map[string]any](context.Background(), recorder.CommandContext(command.IdentityUser), command.GET("/open-apis/im/v1/chats")) + if err == nil || !strings.Contains(err.Error(), "expected") { + t.Fatalf("CallJSON() error = %v", err) + } + recorder.AssertScriptConsumed() +} + func TestRecorderInjectsCancellationIntoPaginationWait(t *testing.T) { recorder := New(t, Respond(map[string]any{ "items": []map[string]any{{"id": "first"}}, "has_more": true, "page_token": "next", })) - recorder.SetPagination(command.PaginationOptions{All: true, MaxPages: 2, Delay: time.Hour}) + recorder.SetPagination(command.PaginationOptions{All: true, MaxPages: 2, Delay: time.Minute}) recorder.CancelAfterRequest(1) ctx := recorder.ExecutionContext(context.Background()) @@ -110,3 +137,68 @@ func TestExecuteRunsPreparationAndReturnsTypedOutcome(t *testing.T) { t.Fatalf("execution = %#v", execution) } } + +func TestExecuteRejectsResultAndErrorTogether(t *testing.T) { + type args struct{} + type data struct{} + sentinel := errors.New("execute failed") + definition := command.Definition[args, data]{ + Metadata: command.CommandMetadata{ + Service: "im", Command: "+test-result-error", Description: "Test result protocol", Risk: command.RiskRead, + Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{command.IdentityUser: {}}}, + }, + Hooks: command.Hooks[args, data]{ + Execute: func(context.Context, command.CommandContext, *args) (command.Result[data], error) { + return command.Success(data{}), sentinel + }, + }, + } + _, err := Execute(context.Background(), New(t), command.IdentityUser, definition, &args{}) + if err == nil || !errors.Is(err, sentinel) || err == sentinel { + t.Fatalf("Execute() error = %v", err) + } +} + +func TestPreviewPropagatesDryRunE(t *testing.T) { + type args struct{} + type data struct{} + sentinel := command.ValidationErrorf("dry-run input is invalid") + definition := command.Definition[args, data]{ + Metadata: command.CommandMetadata{ + Service: "im", Command: "+test-dry-run-error", Description: "Test dry-run error", Risk: command.RiskRead, + Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{command.IdentityUser: {}}}, + }, + Hooks: command.Hooks[args, data]{ + DryRunE: func(context.Context, command.CommandContext, *args) (*command.DryRun, error) { + return nil, sentinel + }, + Execute: func(context.Context, command.CommandContext, *args) (command.Result[data], error) { + return command.Success(data{}), nil + }, + }, + } + _, err := Preview(context.Background(), New(t), command.IdentityUser, definition, &args{}) + if !errors.Is(err, sentinel) { + t.Fatalf("Preview() error = %v", err) + } +} + +func TestRunWithFlagsRejectsNonPageOutput(t *testing.T) { + type args struct{} + type data struct{} + definition := command.Definition[args, data]{ + Metadata: command.CommandMetadata{ + Service: "im", Command: "+test-non-page", Description: "Test non-page flags", Risk: command.RiskRead, + Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{command.IdentityUser: {}}}, + }, + Hooks: command.Hooks[args, data]{ + Execute: func(context.Context, command.CommandContext, *args) (command.Result[data], error) { + return command.Success(data{}), nil + }, + }, + } + _, err := RunWithFlags(context.Background(), New(t), command.IdentityUser, definition, &args{}, "--page-all") + if err == nil { + t.Fatal("RunWithFlags() error is nil") + } +} diff --git a/extension/command/context.go b/extension/command/context.go index 239dc400ed..f7adfbf938 100644 --- a/extension/command/context.go +++ b/extension/command/context.go @@ -12,11 +12,11 @@ import ( // CommandContext is an opaque, invocation-scoped set of safe host capabilities. type CommandContext struct { - identity Identity - dryRun bool - callJSON func(context.Context, Request) (map[string]any, error) - preflightScopes func(...string) error - paginationOptions func() (PaginationOptions, error) + identity Identity + dryRun bool + callJSON func(context.Context, Request) (map[string]any, error) + preflightScopes func(...string) error + collectPages func(context.Context, Request, bool) ([]map[string]any, HostPagination, error) } // PaginationOptions carries host-owned pagination controls to the public helpers. @@ -30,21 +30,21 @@ type PaginationOptions struct { // ContextOptions supplies safe callbacks when a host creates a CommandContext. // It is intended for the lark-cli host adapter and commandtest. type ContextOptions struct { - Identity Identity - DryRun bool - CallJSON func(context.Context, Request) (map[string]any, error) - PreflightScopes func(...string) error - PaginationOptions func() (PaginationOptions, error) + Identity Identity + DryRun bool + CallJSON func(context.Context, Request) (map[string]any, error) + PreflightScopes func(...string) error + CollectPages func(context.Context, Request, bool) ([]map[string]any, HostPagination, error) } // NewCommandContext creates a restricted context from host callbacks. func NewCommandContext(options ContextOptions) CommandContext { return CommandContext{ - identity: options.Identity, - dryRun: options.DryRun, - callJSON: options.CallJSON, - preflightScopes: options.PreflightScopes, - paginationOptions: options.PaginationOptions, + identity: options.Identity, + dryRun: options.DryRun, + callJSON: options.CallJSON, + preflightScopes: options.PreflightScopes, + collectPages: options.CollectPages, } } @@ -89,24 +89,3 @@ func PreflightScopes(command CommandContext, scopes ...string) error { } return command.preflightScopes(scopes...) } - -func (c CommandContext) pageOptions() (PaginationOptions, error) { - if c.paginationOptions == nil { - return PaginationOptions{MaxPages: 1}, nil - } - return c.paginationOptions() -} - -func waitForPage(ctx context.Context, delay time.Duration) error { - if delay <= 0 { - return nil - } - timer := time.NewTimer(delay) - defer timer.Stop() - select { - case <-ctx.Done(): - return PaginationInterruptedError(ctx.Err()) - case <-timer.C: - return nil - } -} diff --git a/extension/command/definition.go b/extension/command/definition.go index 91452ffbb3..b62b27959f 100644 --- a/extension/command/definition.go +++ b/extension/command/definition.go @@ -215,6 +215,7 @@ type Hooks[Args any, Data any] struct { Normalize func(context.Context, CommandContext, *Args) error Validate func(context.Context, CommandContext, *Args) error DryRun func(context.Context, CommandContext, *Args) *DryRun + DryRunE func(context.Context, CommandContext, *Args) (*DryRun, error) Execute func(context.Context, CommandContext, *Args) (Result[Data], error) Renderers map[string]Renderer[Data] } diff --git a/extension/command/host.go b/extension/command/host.go index 863e46b703..f6b90d5131 100644 --- a/extension/command/host.go +++ b/extension/command/host.go @@ -27,6 +27,7 @@ type HostHooks struct { Normalize func(context.Context, CommandContext, any) error Validate func(context.Context, CommandContext, any) error DryRun func(context.Context, CommandContext, any) *DryRun + DryRunE func(context.Context, CommandContext, any) (*DryRun, error) Execute func(context.Context, CommandContext, any) (HostResult, error) Renderers map[string]func(io.Writer, any) error } @@ -88,6 +89,11 @@ func newCommand[Args any, Data any](definition Definition[Args, Data]) Command { return definition.Hooks.DryRun(ctx, command, args.(*Args)) } } + if definition.Hooks.DryRunE != nil { + host.hooks.DryRunE = func(ctx context.Context, command CommandContext, args any) (*DryRun, error) { + return definition.Hooks.DryRunE(ctx, command, args.(*Args)) + } + } if definition.Hooks.Execute != nil { host.hooks.Execute = func(ctx context.Context, command CommandContext, args any) (HostResult, error) { result, err := definition.Hooks.Execute(ctx, command, args.(*Args)) diff --git a/extension/command/pagination.go b/extension/command/pagination.go index 0daf45f677..09ba59ca5b 100644 --- a/extension/command/pagination.go +++ b/extension/command/pagination.go @@ -4,12 +4,11 @@ package command import ( + "bytes" "context" - "fmt" + "encoding/json" ) -const collectAllPagesLimit = 1000 - // Page contains items and host-owned pagination state. type Page[T any] struct { Items []T `json:"items" schema:"required;nonnullable" doc:"items returned by the API"` @@ -36,7 +35,13 @@ func (p Page[T]) Pages() int { return p.meta.Pages } -func (p Page[T]) commandPagination() *paginationMeta { return p.meta } +func (p Page[T]) commandPagination() *paginationMeta { + meta := clonePaginationMeta(p.meta) + if meta != nil { + meta.Items = len(p.Items) + } + return meta +} type paginationMeta struct { Complete bool @@ -62,19 +67,12 @@ type pageEnvelope[T any] struct { // CollectPages fetches one page by default or follows standard pagination flags. func CollectPages[T any](ctx context.Context, command CommandContext, request Request) (Page[T], error) { - options, err := command.pageOptions() - if err != nil { - return Page[T]{}, err - } - if !options.All { - options.MaxPages = 1 - } - return collectPages[T](ctx, command, request, options) + return collectPages[T](ctx, command, request, false) } // CollectAllPages fetches until the endpoint is exhausted and ignores CLI paging flags. func CollectAllPages[T any](ctx context.Context, command CommandContext, request Request) ([]T, error) { - page, err := collectPages[T](ctx, command, request, PaginationOptions{All: true, MaxPages: collectAllPagesLimit}) + page, err := collectPages[T](ctx, command, request, true) if err != nil { return nil, err } @@ -84,81 +82,36 @@ func CollectAllPages[T any](ctx context.Context, command CommandContext, request return page.Items, nil } -func collectPages[T any](ctx context.Context, command CommandContext, request Request, options PaginationOptions) (Page[T], error) { - if options.MaxPages < 1 || options.MaxPages > collectAllPagesLimit { - return Page[T]{}, ValidationErrorf("pagination page limit must be between 1 and %d", collectAllPagesLimit) - } - if options.Delay < 0 { - return Page[T]{}, ValidationErrorf("pagination delay must not be negative") - } - +func collectPages[T any](ctx context.Context, command CommandContext, request Request, all bool) (Page[T], error) { result := Page[T]{meta: &paginationMeta{}} - requestView := InspectRequest(request) - token := queryPageToken(requestView.Query) - seen := make(map[string]struct{}, options.MaxPages) - if token != "" { - seen[token] = struct{}{} + if command.collectPages == nil { + return result, InternalErrorf("command host does not provide pagination") } - - for pageNumber := 1; pageNumber <= options.MaxPages; pageNumber++ { - pageRequest := request - if token != "" { - pageRequest = pageRequest.Set("page_token", token) - } - page, err := CallJSON[pageEnvelope[T]](ctx, command, pageRequest) - if err != nil { - result.meta.NextToken = token - return result, err + pages, pagination, err := command.collectPages(ctx, request, all) + result.meta.Complete = pagination.Complete + result.meta.Pages = pagination.Pages + result.meta.NextToken = pagination.NextToken + for pageNumber, data := range pages { + page, decodeErr := decodePageEnvelope[T](data) + if decodeErr != nil { + return result, InvalidResponseErrorf("decode pagination page %d: %v", pageNumber+1, decodeErr).WithCause(decodeErr) } result.Items = append(result.Items, page.Items...) - result.meta.Pages++ - result.meta.Items = len(result.Items) - - nextToken := page.PageToken - if nextToken == "" { - nextToken = page.NextPageToken - } - if !page.HasMore { - result.meta.Complete = true - result.meta.NextToken = "" - return result, nil - } - if nextToken == "" { - return result, InvalidResponseErrorf("pagination page %d reports has_more=true without a page token", pageNumber) - } - if _, duplicate := seen[nextToken]; duplicate { - return result, InvalidResponseErrorf("pagination page %d repeated page token %q", pageNumber, nextToken) - } - result.meta.NextToken = nextToken - if pageNumber == options.MaxPages { - return result, nil - } - seen[nextToken] = struct{}{} - token = nextToken - if err := waitForPage(ctx, options.Delay); err != nil { - return result, err - } } - - return result, InternalErrorf("pagination finished without a terminal state") + result.meta.Items = len(result.Items) + return result, err } -func queryPageToken(query map[string]any) string { - value, ok := query["page_token"] - if !ok { - return "" +func decodePageEnvelope[T any](data map[string]any) (pageEnvelope[T], error) { + var page pageEnvelope[T] + encoded, err := json.Marshal(data) + if err != nil { + return page, err } - switch typed := value.(type) { - case string: - return typed - case []string: - if len(typed) > 0 { - return typed[0] - } - case []any: - if len(typed) > 0 { - return fmt.Sprint(typed[0]) - } + decoder := json.NewDecoder(bytes.NewReader(encoded)) + decoder.UseNumber() + if err := decoder.Decode(&page); err != nil { + return page, err } - return "" + return page, nil } diff --git a/extension/command/testdata/wrapper/main.go b/extension/command/testdata/wrapper/main.go new file mode 100644 index 0000000000..14c8f282a6 --- /dev/null +++ b/extension/command/testdata/wrapper/main.go @@ -0,0 +1,59 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package main + +import ( + "context" + "os" + + defaultaffordance "github.com/larksuite/cli/affordance" + "github.com/larksuite/cli/cmd" + "github.com/larksuite/cli/extension/command" + defaultskills "github.com/larksuite/cli/skills" + + _ "github.com/larksuite/cli/extension/credential/env" +) + +type readArgs struct { + ID string `flag:"id" schema:"required;minLength=1" doc:"resource identifier"` +} + +type readData struct { + ID string `json:"id" schema:"required" doc:"resource identifier"` +} + +var readCommand = command.Define(command.Definition[readArgs, readData]{ + Metadata: command.CommandMetadata{ + Service: "im", Command: "+wrapper-read", Description: "Read one wrapper resource", Risk: command.RiskRead, + Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{ + command.IdentityUser: {RequiredScopes: []string{"im:chat:read"}}, + }}, + }, + Hooks: command.Hooks[readArgs, readData]{ + DryRunE: func(_ context.Context, _ command.CommandContext, args *readArgs) (*command.DryRun, error) { + return command.Preview(command.GET("/open-apis/im/v1/chats/" + args.ID)), nil + }, + Execute: func(ctx context.Context, commandContext command.CommandContext, args *readArgs) (command.Result[readData], error) { + data, err := command.CallJSON[readData](ctx, commandContext, command.GET("/open-apis/im/v1/chats/"+args.ID)) + if err != nil { + return command.Result[readData]{}, err + } + return command.Success(data), nil + }, + }, +}) + +func main() { + cmd.SetEmbeddedSkillContent(defaultskills.DefaultFS()) + cmd.SetEmbeddedAffordanceContent(defaultaffordance.DefaultFS()) + os.Exit(cmd.ExecuteWithOptions( + cmd.WithCommandSets(command.Set{ + Domain: command.ExtendDomain(command.DomainIm), + Commands: []command.Command{readCommand}, + }), + cmd.WithoutPlugins(), + cmd.WithoutStrictMode(), + cmd.WithoutServiceCommands(), + )) +} diff --git a/extension/command/wrapper_e2e_test.go b/extension/command/wrapper_e2e_test.go new file mode 100644 index 0000000000..e5d763fac4 --- /dev/null +++ b/extension/command/wrapper_e2e_test.go @@ -0,0 +1,80 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package command_test + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestExternalWrapperCommandSurface(t *testing.T) { + packageDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + binary := filepath.Join(t.TempDir(), "business-cli") + build := exec.Command("go", "build", "-o", binary, "./testdata/wrapper") + build.Dir = packageDir + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build wrapper: %v\n%s", err, output) + } + + configDir := t.TempDir() + baseEnv := withoutEnvironment(os.Environ(), + "LARKSUITE_CLI_CONFIG_DIR", "LARKSUITE_CLI_APP_ID", "LARKSUITE_CLI_APP_SECRET", + "LARKSUITE_CLI_USER_ACCESS_TOKEN", "LARKSUITE_CLI_PROFILE", + ) + run := func(args ...string) string { + t.Helper() + process := exec.Command(binary, args...) + process.Env = append(baseEnv, + "LARKSUITE_CLI_CONFIG_DIR="+configDir, + "LARKSUITE_CLI_APP_ID=wrapper_test_app", + "LARKSUITE_CLI_APP_SECRET=wrapper_test_secret", + "LARKSUITE_CLI_USER_ACCESS_TOKEN=expired_wrapper_token", + "LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1", + "LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1", + ) + output, err := process.CombinedOutput() + if err != nil { + t.Fatalf("wrapper %v: %v\n%s", args, err, output) + } + return string(output) + } + + dryRun := run("im", "+wrapper-read", "--id", "chat_1", "--as", "user", "--dry-run") + if !strings.Contains(dryRun, `"dry_run": true`) || !strings.Contains(dryRun, "/open-apis/im/v1/chats/chat_1") { + t.Fatalf("wrapper dry-run = %s", dryRun) + } + schema := run("schema", "im", "+wrapper-read") + if !strings.Contains(schema, `"name": "im +wrapper-read"`) || !strings.Contains(schema, `"outputSchema"`) { + t.Fatalf("wrapper schema = %s", schema) + } + completion := run("__complete", "im", "+wrap") + if !strings.Contains(completion, "+wrapper-read") { + t.Fatalf("wrapper completion = %s", completion) + } + skills := run("skills", "list") + if !strings.Contains(skills, "lark-doc") { + t.Fatalf("wrapper skills = %s", skills) + } +} + +func withoutEnvironment(environment []string, names ...string) []string { + blocked := make(map[string]struct{}, len(names)) + for _, name := range names { + blocked[name] = struct{}{} + } + result := make([]string, 0, len(environment)) + for _, entry := range environment { + name, _, _ := strings.Cut(entry, "=") + if _, ok := blocked[name]; !ok { + result = append(result, entry) + } + } + return result +} diff --git a/extension/platform/README.md b/extension/platform/README.md index 68856fcc7e..23a05c64df 100644 --- a/extension/platform/README.md +++ b/extension/platform/README.md @@ -57,47 +57,37 @@ You should see `audit` in the plugin list. That is sufficient for a hook-only plugin such as the audit observer. A wrapper main does not compile lark-cli's repository-root `content_embed.go`, -so distribution content is a separate, explicit host choice. +so distribution content remains an explicit host choice. The repository +defaults are importable from `github.com/larksuite/cli/skills` and +`github.com/larksuite/cli/affordance`. ### Ship skills and command guidance -If the distribution exposes embedded skills or customizes them with -`EmbeddedSkills`, copy or generate both content trees under the wrapper -package and wire both: +If the distribution exposes the repository's embedded skills or customizes +them with `EmbeddedSkills`, wire the default content before execution: ```go package main import ( - "embed" - "io/fs" - "os" + "os" - _ "github.com/me/myplugin" + _ "github.com/me/myplugin" - "github.com/larksuite/cli/cmd" + defaultaffordance "github.com/larksuite/cli/affordance" + "github.com/larksuite/cli/cmd" + defaultskills "github.com/larksuite/cli/skills" ) -//go:embed skills affordance -var distributionContent embed.FS - func main() { - skillTree, err := fs.Sub(distributionContent, "skills") - if err != nil { - panic(err) - } - affordanceTree, err := fs.Sub(distributionContent, "affordance") - if err != nil { - panic(err) - } - cmd.SetEmbeddedSkillContent(skillTree) - cmd.SetEmbeddedAffordanceContent(affordanceTree) - os.Exit(cmd.Execute()) + cmd.SetEmbeddedSkillContent(defaultskills.DefaultFS()) + cmd.SetEmbeddedAffordanceContent(defaultaffordance.DefaultFS()) + os.Exit(cmd.Execute()) } ``` -`go:embed` only reads files in the package being compiled; it cannot reach -into the replaced `github.com/larksuite/cli` module. Each +Custom distributions may instead copy or generate both content trees under +the wrapper package and wire their own `fs.FS` values. Each `skills//` must contain `SKILL.md`. The `affordance/*.md` files are the structured source for command help and canonical skill references; ship the ones for the domains your distribution retains. Without diff --git a/extension/platform/skillsoverlay.go b/extension/platform/skillsoverlay.go index aa466397af..02d7816b4a 100644 --- a/extension/platform/skillsoverlay.go +++ b/extension/platform/skillsoverlay.go @@ -15,7 +15,8 @@ import "io/fs" // Allow -> Remove -> Overlay, a same-named skill resolving to Overlay. // The repository's root binary provides its base from content_embed.go; // an external wrapper main has no implicit CLI default and must call -// cmd.SetEmbeddedSkillContent before Execute if it relies on that base. +// cmd.SetEmbeddedSkillContent before Execute if it relies on that base. The +// repository default is available from skills.DefaultFS. // // Skills are addressed by exact name (a directory carrying SKILL.md, // e.g. "lark-doc"), not by command path and not by glob — the skill @@ -61,8 +62,8 @@ type SkillsOverlay struct { // Base replaces the host-provided base skill tree instead of layering // over it. nil keeps whatever base the host wired with - // cmd.SetEmbeddedSkillContent; it does not import the repository - // binary's default into an external wrapper main. Every top-level + // cmd.SetEmbeddedSkillContent; it does not select the repository + // binary's default for an external wrapper main. Every top-level // entry must be a valid skill directory containing SKILL.md. Most // integrators leave Base nil and use Remove/Overlay so unchanged // host-provided skills need no copy inside the plugin. diff --git a/internal/commandhost/compile.go b/internal/commandhost/compile.go index bf9b1d940f..861281177a 100644 --- a/internal/commandhost/compile.go +++ b/internal/commandhost/compile.go @@ -90,6 +90,9 @@ func validateDomain(domain command.HostDomain, existing map[string]struct{}) err } func compileCommand(definition command.HostDefinition) (common.Shortcut, error) { + if definition.Hooks.DryRun != nil && definition.Hooks.DryRunE != nil { + return common.Shortcut{}, fmt.Errorf("Hooks.DryRun and Hooks.DryRunE cannot both be set") + } metadata := convertMetadata(definition.Metadata) input, err := convertInput(definition.Input) if err != nil { @@ -214,15 +217,29 @@ func convertOutput(output command.OutputDefinition) (common.OutputDefinition, er } func convertHooks(hooks command.HostHooks) common.ErasedHooks { + dryRun := adaptDryRunHook(hooks.DryRun) + if hooks.DryRunE != nil { + dryRun = adaptDryRunErrorHook(hooks.DryRunE) + } return common.ErasedHooks{ Normalize: adaptHook(hooks.Normalize), Validate: adaptHook(hooks.Validate), - DryRun: adaptDryRunHook(hooks.DryRun), + DryRun: dryRun, Execute: adaptExecuteHook(hooks.Execute), Renderers: cloneRenderers(hooks.Renderers), } } +func adaptDryRunErrorHook(hook func(context.Context, command.CommandContext, any) (*command.DryRun, error)) func(context.Context, common.CommandContext, any) (*common.DryRunAPI, error) { + return func(ctx context.Context, host common.CommandContext, args any) (*common.DryRunAPI, error) { + preview, err := hook(ctx, publicContext(host), args) + if err != nil { + return nil, err + } + return convertDryRun(preview) + } +} + func adaptHook(hook func(context.Context, command.CommandContext, any) error) func(context.Context, common.CommandContext, any) error { if hook == nil { return nil @@ -279,9 +296,19 @@ func publicContext(host common.CommandContext) command.CommandContext { return common.DoTypedAPIJSON(ctx, host, view.Method, view.Path, queryParams(view.Query), view.Body) }, PreflightScopes: host.RequireConditionalScopes, - PaginationOptions: func() (command.PaginationOptions, error) { - options, err := host.PaginationOptions() - return command.PaginationOptions{All: options.All, MaxPages: options.MaxPages, Delay: options.Delay}, err + CollectPages: func(ctx context.Context, request command.Request, all bool) ([]map[string]any, command.HostPagination, error) { + view := command.InspectRequest(request) + if err := command.ValidateRequestView(view); err != nil { + return nil, command.HostPagination{}, err + } + collection, err := common.CollectCommandPages(ctx, host, common.PageRequest{ + Method: view.Method, Path: view.Path, Params: view.Query, Body: view.Body, + }, all) + pagination := command.HostPagination{ + Complete: collection.Complete, Pages: collection.Pages, + NextToken: collection.NextToken, + } + return collection.Data, pagination, err }, }) } diff --git a/internal/commandhost/compile_test.go b/internal/commandhost/compile_test.go index 87bc717cf3..55f7b6332c 100644 --- a/internal/commandhost/compile_test.go +++ b/internal/commandhost/compile_test.go @@ -5,6 +5,7 @@ package commandhost import ( "context" + "errors" "strings" "sync/atomic" "testing" @@ -106,6 +107,30 @@ func TestCompileSetsRejectsSystemFlag(t *testing.T) { } } +func TestCompileSetsRejectsBothDryRunHooks(t *testing.T) { + definition := command.Define(command.Definition[fixtureArgs, fixtureData]{ + Metadata: command.CommandMetadata{ + Service: "im", Command: "+external-dry-run-conflict", Description: "Dry-run conflict", Risk: command.RiskRead, + Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{command.IdentityUser: {}}}, + }, + Hooks: command.Hooks[fixtureArgs, fixtureData]{ + DryRun: func(context.Context, command.CommandContext, *fixtureArgs) *command.DryRun { + return command.NewDryRun() + }, + DryRunE: func(context.Context, command.CommandContext, *fixtureArgs) (*command.DryRun, error) { + return command.NewDryRun(), nil + }, + Execute: func(context.Context, command.CommandContext, *fixtureArgs) (command.Result[fixtureData], error) { + return command.Success(fixtureData{}), nil + }, + }, + }) + _, err := CompileSets([]command.Set{{Domain: command.ExtendDomain(command.DomainIm), Commands: []command.Command{definition}}}) + if err == nil || !strings.Contains(err.Error(), "cannot both be set") { + t.Fatalf("CompileSets() error = %v", err) + } +} + func TestCompileSetsAddsPaginationFlags(t *testing.T) { declaration := command.Define(command.Definition[fixtureArgs, command.Page[fixtureData]]{ Metadata: command.CommandMetadata{ @@ -204,3 +229,35 @@ func TestExternalDryRunUsesOfflineContext(t *testing.T) { t.Fatalf("dry-run output = %s", stdout.String()) } } + +func TestExternalDryRunEPropagatesError(t *testing.T) { + sentinel := command.ValidationErrorf("dry-run input is invalid") + declaration := command.Define(command.Definition[fixtureArgs, fixtureData]{ + Metadata: command.CommandMetadata{ + Service: "im", Command: "+external-dry-run-error", Description: "Offline preview error", Risk: command.RiskRead, + Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{command.IdentityUser: {}}}, + }, + Hooks: command.Hooks[fixtureArgs, fixtureData]{ + DryRunE: func(context.Context, command.CommandContext, *fixtureArgs) (*command.DryRun, error) { + return nil, sentinel + }, + Execute: func(context.Context, command.CommandContext, *fixtureArgs) (command.Result[fixtureData], error) { + return command.Success(fixtureData{}), nil + }, + }, + }) + compiled, err := CompileSets([]command.Set{{Domain: command.ExtendDomain(command.DomainIm), Commands: []command.Command{declaration}}}) + if err != nil { + t.Fatal(err) + } + factory, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{}) + root := &cobra.Command{Use: "lark-cli", SilenceErrors: true, SilenceUsage: true} + service := &cobra.Command{Use: "im"} + root.AddCommand(service) + compiled[0].Mount(service, factory) + root.SetArgs([]string{"im", "+external-dry-run-error", "--id", "chat_1", "--as", "user", "--dry-run"}) + _, err = root.ExecuteC() + if !errors.Is(err, sentinel) { + t.Fatalf("dry-run error = %v", err) + } +} diff --git a/internal/pagination/walk.go b/internal/pagination/walk.go new file mode 100644 index 0000000000..5bf9d0491b --- /dev/null +++ b/internal/pagination/walk.go @@ -0,0 +1,127 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package pagination owns the bounded cursor walk shared by built-in and +// externally assembled commands. +package pagination + +import ( + "context" + "fmt" + "time" +) + +// CursorErrorKind identifies an invalid cursor transition returned by an API. +type CursorErrorKind uint8 + +const ( + // CursorMissing means the API reported another page without a cursor. + CursorMissing CursorErrorKind = iota + 1 + // CursorRepeated means the API returned a cursor already observed by the walk. + CursorRepeated +) + +// CursorError reports an invalid cursor transition. +type CursorError struct { + Kind CursorErrorKind + Page int + Token string +} + +func (e *CursorError) Error() string { + switch e.Kind { + case CursorMissing: + return fmt.Sprintf("pagination page %d reports more pages without a page token", e.Page) + case CursorRepeated: + return fmt.Sprintf("pagination page %d repeated page token %q", e.Page, e.Token) + default: + return fmt.Sprintf("pagination page %d returned an invalid cursor", e.Page) + } +} + +// WaitError reports cancellation or failure while delaying between pages. +type WaitError struct{ Err error } + +func (e *WaitError) Error() string { return e.Err.Error() } +func (e *WaitError) Unwrap() error { return e.Err } + +// State describes the completed portion of a cursor walk. +type State struct { + Complete bool + Pages int + NextToken string +} + +// Fetch obtains and consumes one page, then returns its cursor state. +type Fetch func(ctx context.Context, pageNumber int, pageToken string) (hasMore bool, nextToken string, err error) + +// Options configures one bounded cursor walk. +type Options struct { + InitialToken string + MaxPages int + Delay time.Duration + Fetch Fetch + Wait func(context.Context, time.Duration) error +} + +// Walk follows page tokens until exhaustion or MaxPages is reached. +func Walk(ctx context.Context, options Options) (State, error) { + state := State{NextToken: options.InitialToken} + seen := make(map[string]struct{}, options.MaxPages) + if options.InitialToken != "" { + seen[options.InitialToken] = struct{}{} + } + token := options.InitialToken + wait := options.Wait + if wait == nil { + wait = WaitContext + } + + for pageNumber := 1; pageNumber <= options.MaxPages; pageNumber++ { + hasMore, nextToken, err := options.Fetch(ctx, pageNumber, token) + if err != nil { + state.NextToken = token + return state, err + } + state.Pages++ + if !hasMore { + state.Complete = true + state.NextToken = "" + return state, nil + } + if nextToken == "" { + return state, &CursorError{Kind: CursorMissing, Page: pageNumber} + } + if _, duplicate := seen[nextToken]; duplicate { + return state, &CursorError{Kind: CursorRepeated, Page: pageNumber, Token: nextToken} + } + state.NextToken = nextToken + if pageNumber == options.MaxPages { + return state, nil + } + seen[nextToken] = struct{}{} + token = nextToken + if options.Delay > 0 { + if err := wait(ctx, options.Delay); err != nil { + return state, &WaitError{Err: err} + } + } + } + + return state, fmt.Errorf("pagination exhausted its page budget without producing a terminal result") +} + +// WaitContext waits for one inter-page delay and observes cancellation. +func WaitContext(ctx context.Context, delay time.Duration) error { + if delay <= 0 { + return nil + } + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} 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/shortcuts/common/paginate_into.go b/shortcuts/common/paginate_into.go index ac71e03df2..95e7686ea0 100644 --- a/shortcuts/common/paginate_into.go +++ b/shortcuts/common/paginate_into.go @@ -13,6 +13,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output" + internalpagination "github.com/larksuite/cli/internal/pagination" ) // PageRequest describes one paginated API walk. Pagination controls are not @@ -58,79 +59,69 @@ func paginateInto[T any](runtime *RuntimeContext, request PageRequest, dst PageA if err != nil { return meta, err } - - pageToken := pageTokenParam(request.Params) - seen := make(map[string]struct{}) - if pageToken != "" { - seen[pageToken] = struct{}{} - } - - // maxPages is always in [1, pageLimitMaximum]. Keeping the bound in the - // loop statement makes finite execution a structural invariant, independent - // of cursor quality and of any future exit-condition changes below. - for pageNumber := 1; pageNumber <= policy.maxPages; pageNumber++ { - params := clonePageParams(request.Params) - if pageToken != "" { - params["page_token"] = pageToken - } - if policy.showProgress { - fmt.Fprintf(runtime.IO().ErrOut, "[page %d] fetching...\n", pageNumber) - } - - data, err := runtime.CallAPITyped(request.Method, request.Path, params, request.Body) - if err != nil { - meta.NextToken = pageToken - return meta, err - } - page, err := decodePageData[T](data, pageNumber) - if err != nil { - meta.NextToken = pageToken - return meta, err - } - if err := dst.AddPage(page); err != nil { - meta.NextToken = pageToken - if _, ok := errs.ProblemOf(err); ok { - return meta, err + ctx := runtime.Ctx() + if ctx == nil { + ctx = context.Background() + } + state, walkErr := internalpagination.Walk(ctx, internalpagination.Options{ + InitialToken: pageTokenParam(request.Params), + MaxPages: policy.maxPages, + Delay: policy.pageDelay, + Wait: wait, + Fetch: func(_ context.Context, pageNumber int, pageToken string) (bool, string, error) { + params := clonePageParams(request.Params) + if pageToken != "" { + params["page_token"] = pageToken + } + if policy.showProgress { + fmt.Fprintf(runtime.IO().ErrOut, "[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 := runtime.CallAPITyped(request.Method, request.Path, params, request.Body) + if err != nil { + return false, "", err + } + page, err := decodePageData[T](data, pageNumber) + if err != nil { + return false, "", err } - if err := wait(ctx, policy.pageDelay); err != nil { - return meta, paginationWaitError(err) + if err := dst.AddPage(page); err != nil { + if _, ok := errs.ProblemOf(err); ok { + return false, "", err + } + return false, "", errs.NewInternalError(errs.SubtypeUnknown, + "accumulate pagination page %d: %v", pageNumber, err). + WithCause(err) } + hasMore, nextPageToken := PaginationMeta(data) + return hasMore, nextPageToken, nil + }, + }) + meta.Complete = state.Complete + meta.Pages = state.Pages + meta.NextToken = state.NextToken + if walkErr == nil { + return meta, nil + } + return meta, paginationWalkError(walkErr) +} + +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) } - - return meta, errs.NewInternalError(errs.SubtypeUnknown, - "pagination exhausted its page budget without producing a terminal result") + var waitErr *internalpagination.WaitError + if errors.As(walkErr, &waitErr) { + return paginationWaitError(waitErr.Err) + } + if _, ok := errs.ProblemOf(walkErr); ok { + return walkErr + } + return errs.NewInternalError(errs.SubtypeUnknown, "paginate: %v", walkErr).WithCause(walkErr) } type paginationPolicy struct { @@ -174,17 +165,7 @@ func paginationProgressEnabled(runtime *RuntimeContext) bool { } func waitPageDelay(ctx context.Context, delay time.Duration) error { - if delay <= 0 { - return nil - } - timer := time.NewTimer(delay) - defer timer.Stop() - select { - case <-ctx.Done(): - return ctx.Err() - case <-timer.C: - return nil - } + return internalpagination.WaitContext(ctx, delay) } func paginationWaitError(err error) error { diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index a2401b05dc..8033c57eca 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -1397,11 +1397,22 @@ func validateEnumFlags(rctx *RuntimeContext, flags []Flag) error { // handleShortcutDryRun renders a shortcut plan without sending its API requests. func handleShortcutDryRun(f *cmdutil.Factory, rctx *RuntimeContext, s *Shortcut) error { - if s.DryRun == nil { + if s.DryRun == nil && s.DryRunE == nil { return ValidationErrorf("--dry-run is not supported for %s %s", s.Service, s.Command). WithParam("--dry-run") } - dryResult := s.DryRun(rctx.ctx, rctx) + var ( + dryResult *DryRunAPI + err error + ) + if s.DryRunE != nil { + dryResult, err = s.DryRunE(rctx.ctx, rctx) + } else { + dryResult = s.DryRun(rctx.ctx, rctx) + } + if err != nil { + return err + } if dryResult != nil { // Same data.context contract as the service/api dry-run paths. dryResult.Context(rctx.Config.AppID, rctx.UserOpenId()) diff --git a/shortcuts/common/runner_jq_test.go b/shortcuts/common/runner_jq_test.go index 3f20fcbb27..ff34cb57aa 100644 --- a/shortcuts/common/runner_jq_test.go +++ b/shortcuts/common/runner_jq_test.go @@ -339,6 +339,27 @@ func TestRunShortcut_DryRunJSONUsesEnvelope(t *testing.T) { } } +func TestRunShortcut_DryRunEReturnsTypedError(t *testing.T) { + sentinel := errs.NewValidationError(errs.SubtypeInvalidArgument, "dry-run input is invalid") + s := &Shortcut{ + Service: "test", Command: "test-shortcut", AuthTypes: []string{"bot"}, + DryRunE: func(context.Context, *RuntimeContext) (*DryRunAPI, error) { + return nil, sentinel + }, + Execute: func(context.Context, *RuntimeContext) error { + t.Fatal("Execute should not run in dry-run") + return nil + }, + } + f := newTestFactory() + cmd := newTestShortcutCmd(s, f) + cmd.Flags().Set("dry-run", "true") + cmd.Flags().Set("as", "bot") + if err := runShortcut(cmd, f, s, false); !errors.Is(err, sentinel) { + t.Fatalf("runShortcut() error = %v", err) + } +} + func TestRunShortcut_DryRunWithJq(t *testing.T) { s := &Shortcut{ Service: "test", diff --git a/shortcuts/common/typed_compiler.go b/shortcuts/common/typed_compiler.go index c17efa9d2f..467a6c5740 100644 --- a/shortcuts/common/typed_compiler.go +++ b/shortcuts/common/typed_compiler.go @@ -40,6 +40,9 @@ func compileDefinition[Args any, Data any](definition Definition[Args, Data]) (* if definition.Hooks.Execute == nil { return nil, fmt.Errorf("Hooks.Execute is required") } + if definition.Hooks.DryRun != nil && definition.Hooks.DryRunE != nil { + return nil, fmt.Errorf("Hooks.DryRun and Hooks.DryRunE cannot both be set") + } return compileDefinitionParts( definition.Metadata, definition.Input, @@ -247,6 +250,11 @@ func adaptHooks[Args any, Data any](hooks Hooks[Args, Data]) compiledHooks { return hooks.DryRun(ctx, cc, args.(*Args)), nil } } + if hooks.DryRunE != nil { + adapted.dryRun = func(ctx context.Context, cc CommandContext, args any) (*DryRunAPI, error) { + return hooks.DryRunE(ctx, cc, args.(*Args)) + } + } adapted.execute = func(ctx context.Context, cc CommandContext, args any) (compiledResult, error) { result, err := hooks.Execute(ctx, cc, args.(*Args)) return compiledResult{data: result.Data, outcome: result.Outcome, meta: result.Meta}, err diff --git a/shortcuts/common/typed_definition.go b/shortcuts/common/typed_definition.go index 35dec36273..35e0465147 100644 --- a/shortcuts/common/typed_definition.go +++ b/shortcuts/common/typed_definition.go @@ -168,6 +168,7 @@ type Hooks[Args any, Data any] struct { Normalize func(context.Context, CommandContext, *Args) error Validate func(context.Context, CommandContext, *Args) error DryRun func(context.Context, CommandContext, *Args) *DryRunAPI + DryRunE func(context.Context, CommandContext, *Args) (*DryRunAPI, error) Execute func(context.Context, CommandContext, *Args) (Result[Data], error) Renderers map[string]Renderer[Data] } diff --git a/shortcuts/common/typed_external_pagination.go b/shortcuts/common/typed_external_pagination.go new file mode 100644 index 0000000000..98ff2a93bd --- /dev/null +++ b/shortcuts/common/typed_external_pagination.go @@ -0,0 +1,76 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "context" + "time" + + "github.com/larksuite/cli/errs" + internalpagination "github.com/larksuite/cli/internal/pagination" +) + +// CommandPageCollection is the host projection used by the public command adapter. +type CommandPageCollection struct { + Data []map[string]any + Complete bool + Pages int + NextToken string +} + +// CollectCommandPages uses the shared cursor walker for an externally declared command. +func CollectCommandPages(ctx context.Context, command CommandContext, request PageRequest, all bool) (CommandPageCollection, error) { + policy, err := commandPagePolicy(command, all) + if err != nil { + return CommandPageCollection{}, err + } + collection := CommandPageCollection{} + state, walkErr := internalpagination.Walk(ctx, internalpagination.Options{ + InitialToken: pageTokenParam(request.Params), + MaxPages: policy.maxPages, + Delay: policy.pageDelay, + Fetch: func(ctx context.Context, _ int, pageToken string) (bool, string, error) { + params := clonePageParams(request.Params) + if pageToken != "" { + params["page_token"] = pageToken + } + data, err := CallTypedAPI(ctx, command, request.Method, request.Path, params, request.Body) + if err != nil { + return false, "", err + } + collection.Data = append(collection.Data, data) + hasMore, nextToken := PaginationMeta(data) + return hasMore, nextToken, nil + }, + }) + collection.Complete = state.Complete + collection.Pages = state.Pages + collection.NextToken = state.NextToken + if walkErr != nil { + return collection, paginationWalkError(walkErr) + } + return collection, nil +} + +func commandPagePolicy(command CommandContext, all bool) (paginationPolicy, error) { + if all { + return paginationPolicy{maxPages: pageLimitMaximum}, nil + } + options, err := command.PaginationOptions() + if err != nil { + return paginationPolicy{}, err + } + if !options.All { + options.MaxPages = 1 + } + if options.MaxPages < 1 || options.MaxPages > pageLimitMaximum { + return paginationPolicy{}, errs.NewValidationError(errs.SubtypeInvalidArgument, + "pagination page limit must be between 1 and %d", pageLimitMaximum) + } + if options.Delay < 0 || options.Delay > time.Duration(pageDelayMaximum)*time.Millisecond { + return paginationPolicy{}, errs.NewValidationError(errs.SubtypeInvalidArgument, + "pagination delay must be between 0 and %d milliseconds", pageDelayMaximum) + } + return paginationPolicy{maxPages: options.MaxPages, pageDelay: options.Delay}, nil +} diff --git a/shortcuts/common/typed_schema_export.go b/shortcuts/common/typed_schema_export.go new file mode 100644 index 0000000000..f34ecc996b --- /dev/null +++ b/shortcuts/common/typed_schema_export.go @@ -0,0 +1,12 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +// ShortcutSchema returns the immutable schema contract of a Typed Shortcut. +func ShortcutSchema(shortcut Shortcut) (any, bool) { + if shortcut.typed == nil { + return nil, false + } + return shortcut.typed.contract, true +} diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index b787527b40..15f56b53f3 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -63,9 +63,10 @@ type Shortcut struct { // used to satisfy a Cobra Required flag; alternatives such as "A or legacy B" // are a business constraint and must be validated as such. Normalize FlagNormalizer - DryRun func(ctx context.Context, runtime *RuntimeContext) *DryRunAPI // optional: framework prints & returns when --dry-run is set - Validate func(ctx context.Context, runtime *RuntimeContext) error // optional pre-execution validation - Execute func(ctx context.Context, runtime *RuntimeContext) error // main logic + DryRun func(ctx context.Context, runtime *RuntimeContext) *DryRunAPI // optional: framework prints & returns when --dry-run is set + DryRunE func(ctx context.Context, runtime *RuntimeContext) (*DryRunAPI, error) // optional error-capable dry-run; takes precedence over DryRun + Validate func(ctx context.Context, runtime *RuntimeContext) error // optional pre-execution validation + Execute func(ctx context.Context, runtime *RuntimeContext) error // main logic // OnInvoke, when non-nil, runs from the command's cobra PreRunE — before // cobra validates required flags — so its side effect fires even when the diff --git a/skills/content.go b/skills/content.go new file mode 100644 index 0000000000..1a72f63b1e --- /dev/null +++ b/skills/content.go @@ -0,0 +1,16 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package skills exposes the repository's default embedded skill content. +package skills + +import ( + "embed" + "io/fs" +) + +//go:embed */SKILL.md */references */routes */scenes +var content embed.FS + +// DefaultFS returns the immutable default skill tree rooted at skill names. +func DefaultFS() fs.FS { return content } diff --git a/skills/content_test.go b/skills/content_test.go new file mode 100644 index 0000000000..fc621a7bd3 --- /dev/null +++ b/skills/content_test.go @@ -0,0 +1,17 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package skills + +import ( + "io/fs" + "testing" +) + +func TestDefaultFSContainsSkillAndReference(t *testing.T) { + for _, path := range []string{"lark-doc/SKILL.md", "lark-doc/references/lark-doc-fetch.md"} { + if _, err := fs.ReadFile(DefaultFS(), path); err != nil { + t.Fatalf("read %s: %v", path, err) + } + } +} From 2e14a13c6de18c05884fecd937f59945625c1c9d Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:57:23 +0800 Subject: [PATCH 13/47] test(command): cover public extension surface --- extension/command/command_test.go | 43 ++++++++++++++++++++++++++++ internal/commandhost/compile.go | 11 ------- internal/commandhost/compile_test.go | 2 +- 3 files changed, 44 insertions(+), 12 deletions(-) diff --git a/extension/command/command_test.go b/extension/command/command_test.go index c640a8df8c..436117cf1d 100644 --- a/extension/command/command_test.go +++ b/extension/command/command_test.go @@ -83,6 +83,18 @@ func TestDefineCopiesNestedJSONValues(t *testing.T) { } } +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"), @@ -110,6 +122,34 @@ func TestRequestMethodsAndSameOriginValidation(t *testing.T) { } } +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"}, @@ -169,6 +209,9 @@ func TestDryRunPreventsRequestsAndScopeChecks(t *testing.T) { return 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") } diff --git a/internal/commandhost/compile.go b/internal/commandhost/compile.go index 861281177a..c1a8bccf83 100644 --- a/internal/commandhost/compile.go +++ b/internal/commandhost/compile.go @@ -8,7 +8,6 @@ import ( "context" "fmt" "io" - "sort" "strings" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" @@ -422,13 +421,3 @@ func convertShape(shape command.ValueShape) (common.ValueShape, error) { return nil, fmt.Errorf("unsupported public shape %T", shape) } } - -// SortedReservedRoots returns the host namespaces used by validation tests. -func SortedReservedRoots() []string { - result := make([]string, 0, len(reservedRootNames)) - for name := range reservedRootNames { - result = append(result, name) - } - sort.Strings(result) - return result -} diff --git a/internal/commandhost/compile_test.go b/internal/commandhost/compile_test.go index 55f7b6332c..f0485a2de9 100644 --- a/internal/commandhost/compile_test.go +++ b/internal/commandhost/compile_test.go @@ -73,7 +73,7 @@ func TestCompileSetsRejectsUnsupportedAndUnknownDomains(t *testing.T) { want string }{ {name: "reserved new domain", domain: command.NewDomain("auth", command.Title("en", "Auth")), want: "reserved"}, - {name: "unsupported new domain", domain: command.NewDomain("business"), want: "not supported in V1"}, + {name: "unsupported new domain", domain: command.NewDomain("business", command.Description("en", "Business commands")), want: "not supported in V1"}, {name: "unknown extension", domain: command.ExtendDomain(command.DomainName("missing")), want: "does not exist"}, } for _, test := range tests { From 1c206e8060e5ee7144af12c94fefb1edb6357893 Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:03:26 +0800 Subject: [PATCH 14/47] fix(command): remove unused typed runtime APIs --- shortcuts/common/typed_api.go | 39 ------------------------- shortcuts/common/typed_compiler_test.go | 18 ++++++++++++ 2 files changed, 18 insertions(+), 39 deletions(-) diff --git a/shortcuts/common/typed_api.go b/shortcuts/common/typed_api.go index ac21aac87b..61ecbfc723 100644 --- a/shortcuts/common/typed_api.go +++ b/shortcuts/common/typed_api.go @@ -5,7 +5,6 @@ package common import ( "context" - "net/http" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" @@ -72,44 +71,6 @@ func CallTypedAPI(ctx context.Context, command CommandContext, method, apiPath s return ClassifyAPIResponseWith(response, typedClassifyContext(command)) } -// DoTypedAPIStream executes a finite streaming HTTP response through the -// restricted CommandContext. Successful response-body ownership belongs to the -// caller; APIClient.DoStream closes HTTP error bodies and couples request -// cancellation to closing a successful body. -func DoTypedAPIStream(ctx context.Context, command CommandContext, req *larkcore.ApiReq, options ...client.Option) (*http.Response, error) { - apiClient, err := command.APIClient() - if err != nil { - return nil, typedOrInternal(err) - } - base := []client.Option{client.WithHeaders(cmdutil.BaseSecurityHeaders())} - if headers := cmdutil.ShortcutHeaders(ctx); headers != nil { - base = append(base, client.WithHeaders(headers)) - } - response, err := apiClient.DoStream(ctx, req, core.Identity(command.Identity()), append(base, options...)...) - if err != nil { - return nil, typedOrInternal(err) - } - return response, nil -} - -// CallTypedRawAPI mirrors RuntimeContext.RawAPI for Typed hooks that must -// inspect the complete legacy API envelope themselves. -func CallTypedRawAPI(ctx context.Context, command CommandContext, method, apiPath string, params map[string]interface{}, data any) (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) - } - result, err := apiClient.CallAPI(ctx, request) - if err != nil { - return nil, typedOrInternal(err) - } - return result, nil -} - func typedClassifyContext(command CommandContext) errclass.ClassifyContext { config := command.Config() classify := errclass.ClassifyContext{Brand: string(config.Brand), AppID: config.AppID, Identity: string(command.Identity())} diff --git a/shortcuts/common/typed_compiler_test.go b/shortcuts/common/typed_compiler_test.go index ed18725e60..a3f78f63cb 100644 --- a/shortcuts/common/typed_compiler_test.go +++ b/shortcuts/common/typed_compiler_test.go @@ -115,6 +115,24 @@ func TestDefineCompilesTypedContract(t *testing.T) { } } +func TestValueShapeClosedSet(t *testing.T) { + shapes := []ValueShape{ + StringShape{}, + BooleanShape{}, + IntegerShape{}, + NumberShape{}, + NullShape{}, + ConstShape{}, + ArrayShape{}, + ObjectShape{}, + OneOfShape{}, + anyJSONShape{}, + } + for _, shape := range shapes { + shape.valueShape() + } +} + func TestDefineClonesTipsAndRejectsBlankTips(t *testing.T) { definition := validCompilerDefinition() definition.Metadata.Tips = []string{"first tip", " second tip "} From d2f226abdc71dfc15d91465cfabad129a817e3d1 Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:50:07 +0800 Subject: [PATCH 15/47] fix(command): address follow-up review findings --- cmd/auth/auth.go | 15 ++- cmd/auth/login.go | 33 +++++-- cmd/auth/login_interactive.go | 16 ++- cmd/auth/login_test.go | 1 + cmd/build.go | 11 ++- cmd/command_sets_test.go | 42 +++++++- cmd/error_auth_hint.go | 32 +----- cmd/error_presenter_test.go | 2 + cmd/root_test.go | 17 ++++ cmd/schema/schema.go | 54 ++++++++-- cmd/schema/schema_test.go | 29 ++++++ extension/command/command_test.go | 44 +++++++++ .../commandtest/business_commands_test.go | 42 +++++++- extension/command/commandtest/commandtest.go | 34 +++++-- .../command/commandtest/commandtest_test.go | 20 ++++ extension/command/errors.go | 2 +- extension/command/host.go | 37 +++++-- internal/cmdmeta/meta.go | 27 +++++ internal/commandhost/compile.go | 26 ++++- internal/commandhost/compile_test.go | 28 ++++++ shortcuts/common/runner.go | 38 +++---- shortcuts/common/runner_botinfo_test.go | 14 +++ shortcuts/common/runner_jq_test.go | 7 +- shortcuts/common/typed_binder.go | 33 +++++-- shortcuts/common/typed_compile_args.go | 15 ++- shortcuts/common/typed_compile_contract.go | 98 +++++++++++++++---- shortcuts/common/typed_compile_data.go | 3 + .../common/typed_compiler_invalid_test.go | 61 ++++++++++++ shortcuts/common/typed_external.go | 15 ++- shortcuts/common/typed_map_binder.go | 22 +++-- shortcuts/common/typed_map_binder_test.go | 40 +++++++- shortcuts/common/typed_runner_test.go | 38 ++++++- shortcuts/register.go | 51 +++------- shortcuts/register_external_test.go | 29 +++--- 34 files changed, 775 insertions(+), 201 deletions(-) diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go index 9d5c8204a1..4e83be3376 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -19,20 +19,27 @@ 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. func NewCmdAuthWithRecovery(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Command { - return newCmdAuth(f, projector) + return newCmdAuth(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 +56,7 @@ func newCmdAuth(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Comman } cmdutil.DisableAuthCheck(cmd) - cmd.AddCommand(NewCmdAuthLogin(f, nil)) + cmd.AddCommand(newCmdAuthLoginWithShortcuts(f, nil, registered)) cmd.AddCommand(NewCmdAuthLogout(f, nil)) cmd.AddCommand(newCmdAuthStatus(f, nil, projector)) cmd.AddCommand(NewCmdAuthScopes(f, nil)) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 9454384ac8..9614048482 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -37,13 +37,18 @@ type LoginOptions struct { Exclude []string NoWait bool DeviceCode string + shortcuts []common.Shortcut } var pollDeviceToken = larkauth.PollDeviceToken // NewCmdAuthLogin creates the auth login subcommand. func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command { - opts := &LoginOptions{Factory: f} + return newCmdAuthLoginWithShortcuts(f, runF, shortcuts.AllShortcuts()) +} + +func newCmdAuthLoginWithShortcuts(f *cmdutil.Factory, runF func(*LoginOptions) error, registered []common.Shortcut) *cobra.Command { + opts := &LoginOptions{Factory: f, shortcuts: common.CloneShortcuts(registered)} cmd := &cobra.Command{ Use: "login", @@ -79,7 +84,7 @@ to generate QR codes (supports ASCII and PNG formats).`, helpBrand = cfg.Brand } } - available := sortedKnownDomains(helpBrand) + available := sortedKnownDomainsWithShortcuts(helpBrand, opts.shortcuts) 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,7 +94,7 @@ 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 completeDomainWithShortcuts(toComplete, helpBrand, opts.shortcuts), cobra.ShellCompDirectiveNoFileComp }) return cmd @@ -97,7 +102,11 @@ to generate QR codes (supports ASCII and PNG formats).`, // completeDomain returns completions for comma-separated domain values. func completeDomain(toComplete string) []string { - allDomains := registry.ListFromMetaProjects() + return completeDomainWithShortcuts(toComplete, "", nil) +} + +func completeDomainWithShortcuts(toComplete string, brand core.LarkBrand, registered []common.Shortcut) []string { + allDomains := sortedKnownDomainsWithShortcuts(brand, registered) parts := strings.Split(toComplete, ",") prefix := parts[len(parts)-1] base := strings.Join(parts[:len(parts)-1], ",") @@ -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 = sortedKnownDomainsWithShortcuts(config.Brand, opts.shortcuts) break } } // Validate domain names and suggest corrections for unknown ones if len(selectedDomains) > 0 { - knownDomains := allKnownDomains(config.Brand) + knownDomains := allKnownDomainsWithShortcuts(config.Brand, opts.shortcuts) 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 := runInteractiveLoginWithShortcuts(f.IOStreams, lang.Base(), msg, config.Brand, opts.shortcuts) 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 = collectScopesForDomainsWithShortcuts(selectedDomains, "user", config.Brand, opts.shortcuts) } else { // --recommend without --domain: all domains - candidateScopes = collectScopesForDomains(sortedKnownDomains(config.Brand), "user", config.Brand) + candidateScopes = collectScopesForDomainsWithShortcuts(sortedKnownDomainsWithShortcuts(config.Brand, opts.shortcuts), "user", config.Brand, opts.shortcuts) } // Filter to auto-approve scopes if --recommend or interactive "common" @@ -586,7 +595,11 @@ func shortcutHasDeclaredScopes(shortcut common.Shortcut) bool { // sortedKnownDomains returns all valid domain names sorted alphabetically. func sortedKnownDomains(brand core.LarkBrand) []string { - m := allKnownDomains(brand) + return sortedKnownDomainsWithShortcuts(brand, shortcuts.AllShortcuts()) +} + +func sortedKnownDomainsWithShortcuts(brand core.LarkBrand, registered []common.Shortcut) []string { + m := allKnownDomainsWithShortcuts(brand, registered) domains := make([]string, 0, len(m)) for d := range m { domains = append(domains, d) diff --git a/cmd/auth/login_interactive.go b/cmd/auth/login_interactive.go index f1cc527145..27a1a4a2e3 100644 --- a/cmd/auth/login_interactive.go +++ b/cmd/auth/login_interactive.go @@ -15,6 +15,8 @@ import ( "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/registry" + "github.com/larksuite/cli/shortcuts" + "github.com/larksuite/cli/shortcuts/common" ) // domainMeta describes a domain for the interactive selector. @@ -32,7 +34,11 @@ type interactiveResult struct { // getDomainMetadata returns metadata for all known domains, sorted by name. func getDomainMetadata(lang string) []domainMeta { - known := allKnownDomains("") + return getDomainMetadataWithShortcuts(lang, shortcuts.AllShortcuts()) +} + +func getDomainMetadataWithShortcuts(lang string, registered []common.Shortcut) []domainMeta { + known := allKnownDomainsWithShortcuts("", registered) domains := make([]domainMeta, 0, len(known)) for name := range known { domains = append(domains, buildDomainMeta(name, lang)) @@ -68,7 +74,11 @@ func buildDomainMeta(name, lang string) domainMeta { // 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) + return runInteractiveLoginWithShortcuts(ios, lang, msg, brand, shortcuts.AllShortcuts()) +} + +func runInteractiveLoginWithShortcuts(ios *cmdutil.IOStreams, lang string, msg *loginMsg, brand core.LarkBrand, registered []common.Shortcut) (*interactiveResult, error) { + allDomains := getDomainMetadataWithShortcuts(lang, registered) // Build multi-select options options := make([]huh.Option[string], len(allDomains)) @@ -127,7 +137,7 @@ func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, bra } // Compute scope summary - scopes := collectScopesForDomains(selectedDomains, "user", brand) + scopes := collectScopesForDomainsWithShortcuts(selectedDomains, "user", brand, registered) if permLevel == "common" { scopes = registry.FilterAutoApproveScopes(scopes) } diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index 95a0f928dd..7fffe60d28 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -334,6 +334,7 @@ func TestGetDomainMetadataMatchesAllKnownDomains(t *testing.T) { } func TestAuthLoginHelpMatchesInteractiveDomains(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") diff --git a/cmd/build.go b/cmd/build.go index 93ecbfe9a1..d7e2f40cec 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -213,8 +213,9 @@ func buildInternalWithConfig(ctx context.Context, inv cmdutil.InvocationContext, cfg = &buildConfig{} } externalCommands, commandSetErr := commandhost.CompileSets(cfg.commandSets) + registeredShortcuts := shortcuts.AllShortcuts() if commandSetErr == nil { - commandSetErr = shortcuts.RegisterExternal(externalCommands) + registeredShortcuts, commandSetErr = shortcuts.AllShortcutsWithExternal(externalCommands) } // Default streams when WithIO is not supplied so the root command's // SetIn/Out/Err calls below don't deref nil. NewDefault also normalizes @@ -288,14 +289,14 @@ 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)) rootCmd.AddCommand(api.NewCmdApiWithContext(ctx, f, nil)) - rootCmd.AddCommand(schema.NewCmdSchemaWithVisibility(f, func(path []string) bool { + rootCmd.AddCommand(schema.NewCmdSchemaWithVisibilityAndShortcuts(f, func(path []string) bool { return runtime.surface.CanReference(surface.CommandID(strings.Join(path, "/"))) - }, nil)) + }, registeredShortcuts, nil)) rootCmd.AddCommand(completion.NewCmdCompletion(f)) rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f)) rootCmd.AddCommand(cmdevent.NewCmdEvents(f)) @@ -307,7 +308,7 @@ 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) diff --git a/cmd/command_sets_test.go b/cmd/command_sets_test.go index dc2552652a..099a50bb9a 100644 --- a/cmd/command_sets_test.go +++ b/cmd/command_sets_test.go @@ -6,6 +6,7 @@ package cmd import ( "bytes" "context" + "encoding/json" "errors" "os" "os/exec" @@ -14,6 +15,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/command" + "github.com/larksuite/cli/extension/platform" ) type businessArgs struct { @@ -60,6 +62,34 @@ func TestWithCommandSetsInIsolatedProcesses(t *testing.T) { } } +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 == "" { @@ -167,8 +197,16 @@ func TestCommandSetSubprocess(t *testing.T) { if _, err := root.ExecuteC(); err != nil { t.Fatalf("schema external command: %v\nstderr: %s", err, stderr.String()) } - if !strings.Contains(stdout.String(), `"name": "im +business-surface"`) || - !strings.Contains(stdout.String(), `"inputSchema"`) || !strings.Contains(stdout.String(), `"outputSchema"`) { + var schema struct { + Name string `json:"name"` + InputSchema json.RawMessage `json:"inputSchema"` + OutputSchema json.RawMessage `json:"outputSchema"` + } + if err := json.Unmarshal(stdout.Bytes(), &schema); err != nil { + t.Fatalf("decode external schema: %v\n%s", err, stdout.String()) + } + if schema.Name != "im +business-surface" || len(schema.InputSchema) == 0 || len(schema.OutputSchema) == 0 || + string(schema.InputSchema) == "null" || string(schema.OutputSchema) == "null" { t.Fatalf("external schema = %s", stdout.String()) } default: 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/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/cmd/schema/schema.go b/cmd/schema/schema.go index 8b20aaee8c..27a2aa99a3 100644 --- a/cmd/schema/schema.go +++ b/cmd/schema/schema.go @@ -47,7 +47,7 @@ type SchemaOptions struct { // NewCmdSchema creates the schema command. If runF is non-nil it is called instead of the default runner (test hook). func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Command { - return NewCmdSchemaWithVisibility(f, nil, runF) + return NewCmdSchemaWithVisibilityAndShortcuts(f, nil, shortcuts.AllShortcuts(), runF) } // NewCmdSchemaWithVisibility creates the schema command projected through one @@ -58,8 +58,19 @@ func NewCmdSchemaWithVisibility( f *cmdutil.Factory, visibility CommandVisibility, runF func(*SchemaOptions) error, +) *cobra.Command { + return NewCmdSchemaWithVisibilityAndShortcuts(f, visibility, shortcuts.AllShortcuts(), runF) +} + +// NewCmdSchemaWithVisibilityAndShortcuts creates schema commands from one build-local shortcut snapshot. +func NewCmdSchemaWithVisibilityAndShortcuts( + f *cmdutil.Factory, + visibility CommandVisibility, + registered []common.Shortcut, + runF func(*SchemaOptions) error, ) *cobra.Command { opts := &SchemaOptions{Factory: f} + registered = common.CloneShortcuts(registered) cmd := &cobra.Command{ Use: "schema [path | service resource method]", @@ -71,7 +82,7 @@ func NewCmdSchemaWithVisibility( if runF != nil { return runF(opts) } - return schemaRunWithVisibility(opts, visibility) + return schemaRunWithVisibilityAndShortcuts(opts, visibility, registered) }, } cmdutil.DisableAuthCheck(cmd) @@ -86,7 +97,7 @@ func NewCmdSchemaWithVisibility( _ = cmd.Flags().MarkHidden("json") _ = cmd.Flags().MarkHidden("as") - cmd.ValidArgsFunction = completeSchemaPath(f, visibility) + cmd.ValidArgsFunction = completeSchemaPath(f, visibility, registered) cmdutil.SetRisk(cmd, cmdutil.RiskRead) return cmd @@ -98,12 +109,13 @@ func NewCmdSchemaWithVisibility( func completeSchemaPath( f *cmdutil.Factory, visibility CommandVisibility, + registered []common.Shortcut, ) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { mode := f.ResolveStrictMode(cmd.Context()) catalog := projectSchemaCatalog(registry.SchemaCatalog(), visibility) completions, noSpace := catalog.Complete(args, toComplete, registry.FilterForStrictMode(mode)) - completions = mergeSchemaCompletions(completions, shortcutSchemaCompletions(args, toComplete, visibility)) + completions = mergeSchemaCompletions(completions, shortcutSchemaCompletionsFrom(registered, args, toComplete, visibility)) directive := cobra.ShellCompDirectiveNoFileComp if noSpace { directive |= cobra.ShellCompDirectiveNoSpace @@ -113,9 +125,13 @@ func completeSchemaPath( } func schemaRunWithVisibility(opts *SchemaOptions, visibility CommandVisibility) error { + return schemaRunWithVisibilityAndShortcuts(opts, visibility, shortcuts.AllShortcuts()) +} + +func schemaRunWithVisibilityAndShortcuts(opts *SchemaOptions, visibility CommandVisibility, registered []common.Shortcut) error { out := opts.Factory.IOStreams.Out mode := opts.Factory.ResolveStrictMode(opts.Ctx) - return runSchemaWithVisibility(out, apicatalog.ParsePath(opts.Args), mode, visibility) + return runSchemaCatalogWithShortcuts(out, apicatalog.ParsePath(opts.Args), mode, registry.SchemaCatalog(), visibility, registered) } // runSchemaWithVisibility resolves the path through the schema catalog and renders the @@ -139,7 +155,18 @@ func runSchemaCatalog( catalog apicatalog.Catalog, visibility CommandVisibility, ) error { - if contract, ok := resolveShortcutSchema(parts, visibility); ok { + return runSchemaCatalogWithShortcuts(out, parts, mode, catalog, visibility, shortcuts.AllShortcuts()) +} + +func runSchemaCatalogWithShortcuts( + out io.Writer, + parts []string, + mode core.StrictMode, + catalog apicatalog.Catalog, + visibility CommandVisibility, + registered []common.Shortcut, +) error { + if contract, ok := resolveShortcutSchemaFrom(registered, parts, visibility); ok { output.PrintJson(out, contract) return nil } @@ -173,14 +200,18 @@ func runSchemaCatalog( } func resolveShortcutSchema(parts []string, visibility CommandVisibility) (any, bool) { + return resolveShortcutSchemaFrom(shortcuts.AllShortcuts(), parts, visibility) +} + +func resolveShortcutSchemaFrom(registered []common.Shortcut, parts []string, visibility CommandVisibility) (any, bool) { if len(parts) != 2 || !strings.HasPrefix(parts[1], "+") { return nil, false } - for _, shortcut := range shortcuts.AllShortcuts() { + for _, shortcut := range registered { if shortcut.Service != parts[0] || shortcut.Command != parts[1] { continue } - if visibility != nil && !visibility([]string{shortcut.Service, shortcut.Command}) { + if !shortcutSchemaVisible(shortcut, visibility) { return nil, false } return common.ShortcutSchema(shortcut) @@ -189,7 +220,10 @@ func resolveShortcutSchema(parts []string, visibility CommandVisibility) (any, b } func shortcutSchemaCompletions(args []string, toComplete string, visibility CommandVisibility) []string { - registered := shortcuts.AllShortcuts() + return shortcutSchemaCompletionsFrom(shortcuts.AllShortcuts(), args, toComplete, visibility) +} + +func shortcutSchemaCompletionsFrom(registered []common.Shortcut, args []string, toComplete string, visibility CommandVisibility) []string { if len(args) == 0 && strings.Contains(toComplete, ".") { parts := strings.SplitN(toComplete, ".", 2) return shortcutCommandCompletions(registered, parts[0], parts[1], parts[0]+".", visibility) @@ -232,7 +266,7 @@ func shortcutCommandCompletions(registered []common.Shortcut, service, prefix, o } func shortcutSchemaVisible(shortcut common.Shortcut, visibility CommandVisibility) bool { - return visibility == nil || visibility([]string{shortcut.Service, shortcut.Command}) + return !shortcut.Hidden && (visibility == nil || visibility([]string{shortcut.Service, shortcut.Command})) } func mergeSchemaCompletions(groups ...[]string) []string { diff --git a/cmd/schema/schema_test.go b/cmd/schema/schema_test.go index 653b92c5be..28faca1689 100644 --- a/cmd/schema/schema_test.go +++ b/cmd/schema/schema_test.go @@ -5,6 +5,7 @@ package schema import ( "bytes" + "context" "encoding/json" "errors" "reflect" @@ -16,6 +17,7 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/meta" + "github.com/larksuite/cli/shortcuts/common" ) func TestSchemaCmd_FlagParsing(t *testing.T) { @@ -36,6 +38,33 @@ func TestSchemaCmd_FlagParsing(t *testing.T) { } } +func TestHiddenShortcutIsExcludedFromSchemaDiscovery(t *testing.T) { + type args struct { + Value string `flag:"value" schema:"required" doc:"fixture value"` + } + type data struct { + OK bool `json:"ok" schema:"required" doc:"success state"` + } + hidden := common.Define(common.Definition[args, data]{ + Metadata: common.CommandMetadata{ + Service: "hidden-fixture", Command: "+hidden-schema", Description: "Hidden schema fixture", Risk: common.RiskRead, + Authorization: common.AuthorizationDefinition{Identities: map[common.Identity]common.IdentityAuthorization{common.IdentityUser: {}}}, + }, + Hooks: common.Hooks[args, data]{Execute: func(context.Context, common.CommandContext, *args) (common.Result[data], error) { + return common.Success(data{OK: true}), nil + }}, + }) + hidden.Hidden = true + registered := []common.Shortcut{hidden} + + if schema, ok := resolveShortcutSchemaFrom(registered, []string{hidden.Service, hidden.Command}, nil); ok || schema != nil { + t.Fatalf("hidden shortcut schema = %#v, visible = %v", schema, ok) + } + if completions := shortcutSchemaCompletionsFrom(registered, []string{hidden.Service}, "+hidden", nil); len(completions) != 0 { + t.Fatalf("hidden shortcut completions = %#v", completions) + } +} + func TestSchemaCmd_OutputFlagsAcceptedForCompat(t *testing.T) { // Agents are habituated to --format/--json/--as from api/service commands. // schema must accept them without erroring and always emit the JSON envelope — diff --git a/extension/command/command_test.go b/extension/command/command_test.go index 436117cf1d..ee9435ee90 100644 --- a/extension/command/command_test.go +++ b/extension/command/command_test.go @@ -5,13 +5,17 @@ package command import ( "context" + "errors" "go/parser" "go/token" + "io" "path/filepath" "reflect" "strconv" "strings" "testing" + + "github.com/larksuite/cli/errs" ) type contractArgs struct { @@ -52,6 +56,46 @@ func TestDefineCopiesMutableMetadata(t *testing.T) { } } +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() }, + DryRunE: func(context.Context, CommandContext, *contractArgs) (*DryRun, error) { return NewDryRun(), nil }, + Execute: func(context.Context, CommandContext, *contractArgs) (Result[contractData], error) { + return Success(contractData{}), nil + }, + Renderers: map[string]Renderer[contractData]{"pretty": 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.DryRunE(context.Background(), commandContext, wrong) + assertInternal("DryRunE", err) + _, 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"} diff --git a/extension/command/commandtest/business_commands_test.go b/extension/command/commandtest/business_commands_test.go index 5f1840980c..86c5610d26 100644 --- a/extension/command/commandtest/business_commands_test.go +++ b/extension/command/commandtest/business_commands_test.go @@ -11,6 +11,7 @@ import ( "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" @@ -143,7 +144,11 @@ func taskAuditDefinition() command.Definition[taskAuditArgs, taskAuditData] { return command.Success(data), nil } if err := command.PreflightScopes(commandContext, "contact:user.base:readonly"); err != nil { - return command.Result[taskAuditData]{}, err + 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.Partial(data), nil } for _, task := range tasks { owner, ownerErr := command.CallJSON[struct { @@ -382,12 +387,47 @@ func TestCollectAllPagesHardLimitPreventsFollowingWrite(t *testing.T) { 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) != 1000 || requests[len(requests)-1].Method != "GET" { t.Fatalf("requests after incomplete read = %d, last=%#v", len(requests), requests[len(requests)-1]) } recorder.AssertScriptConsumed() } +func TestBestEffortScopeFailureReturnsPartialTasks(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 !execution.Partial || 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 TestMultiCallCommandReturnsPartialData(t *testing.T) { wantFailure := command.InvalidResponseErrorf("owner record is unavailable") recorder := commandtest.New(t, diff --git a/extension/command/commandtest/commandtest.go b/extension/command/commandtest/commandtest.go index d897ba2191..7b361ca510 100644 --- a/extension/command/commandtest/commandtest.go +++ b/extension/command/commandtest/commandtest.go @@ -236,7 +236,11 @@ func (r *Recorder) SetScopeError(err error) { func (r *Recorder) Requests() []command.RequestView { r.mu.Lock() defer r.mu.Unlock() - return cloneRequestViews(r.requests) + cloned, err := cloneRequestViews(r.requests) + if err != nil { + r.testing.Errorf("clone recorded requests: %v", err) + } + return cloned } // ScopeChecks returns copied scope preflights in execution order. @@ -292,8 +296,12 @@ func (r *Recorder) callJSON(ctx context.Context, request command.Request) (map[s return 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, cloneRequestView(view)) + r.requests = append(r.requests, cloned) requestNumber := len(r.requests) if len(r.responses) == 0 { r.mu.Unlock() @@ -438,22 +446,28 @@ func comparableRequest(request command.RequestView) (string, error) { return string(encoded), nil } -func cloneRequestViews(requests []command.RequestView) []command.RequestView { +func cloneRequestViews(requests []command.RequestView) ([]command.RequestView, error) { cloned := make([]command.RequestView, len(requests)) for index, request := range requests { - cloned[index] = cloneRequestView(request) + value, err := cloneRequestView(request) + if err != nil { + return cloned, fmt.Errorf("request %d: %w", index+1, err) + } + cloned[index] = value } - return cloned + return cloned, nil } -func cloneRequestView(request command.RequestView) command.RequestView { +func cloneRequestView(request command.RequestView) (command.RequestView, error) { encoded, err := json.Marshal(request) if err != nil { - return request + return request, fmt.Errorf("encode recorded request: %w", err) } + decoder := json.NewDecoder(bytes.NewReader(encoded)) + decoder.UseNumber() var cloned command.RequestView - if err := json.Unmarshal(encoded, &cloned); err != nil { - return request + if err := decoder.Decode(&cloned); err != nil { + return request, fmt.Errorf("decode recorded request: %w", err) } - return cloned + return cloned, nil } diff --git a/extension/command/commandtest/commandtest_test.go b/extension/command/commandtest/commandtest_test.go index 3539c10751..a93e8cb119 100644 --- a/extension/command/commandtest/commandtest_test.go +++ b/extension/command/commandtest/commandtest_test.go @@ -5,6 +5,7 @@ package commandtest import ( "context" + "encoding/json" "errors" "reflect" "strings" @@ -42,6 +43,25 @@ func TestRecorderScriptsRequestsScopesAndDryRun(t *testing.T) { 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"})) diff --git a/extension/command/errors.go b/extension/command/errors.go index 7f40eead31..0704d7e133 100644 --- a/extension/command/errors.go +++ b/extension/command/errors.go @@ -28,7 +28,7 @@ func InternalErrorf(format string, args ...any) *errs.InternalError { // PaginationLimitError reports an incomplete all-pages read with a resume token. func PaginationLimitError(pages int, nextToken string) *errs.InternalError { - return errs.NewInternalError(errs.SubtypeInvalidResponse, + 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) } diff --git a/extension/command/host.go b/extension/command/host.go index f6b90d5131..096f2f3cdd 100644 --- a/extension/command/host.go +++ b/extension/command/host.go @@ -76,27 +76,47 @@ func newCommand[Args any, Data any](definition Definition[Args, Data]) Command { } if definition.Hooks.Normalize != nil { host.hooks.Normalize = func(ctx context.Context, command CommandContext, args any) error { - return definition.Hooks.Normalize(ctx, command, args.(*Args)) + typed, ok := args.(*Args) + if !ok { + return InternalErrorf("Normalize received %T, expected %T", args, (*Args)(nil)) + } + return definition.Hooks.Normalize(ctx, command, typed) } } if definition.Hooks.Validate != nil { host.hooks.Validate = func(ctx context.Context, command CommandContext, args any) error { - return definition.Hooks.Validate(ctx, command, args.(*Args)) + typed, ok := args.(*Args) + if !ok { + return InternalErrorf("Validate received %T, expected %T", args, (*Args)(nil)) + } + return definition.Hooks.Validate(ctx, command, typed) } } if definition.Hooks.DryRun != nil { host.hooks.DryRun = func(ctx context.Context, command CommandContext, args any) *DryRun { - return definition.Hooks.DryRun(ctx, command, args.(*Args)) + typed, ok := args.(*Args) + if !ok { + return nil + } + return definition.Hooks.DryRun(ctx, command, typed) } } if definition.Hooks.DryRunE != nil { host.hooks.DryRunE = func(ctx context.Context, command CommandContext, args any) (*DryRun, error) { - return definition.Hooks.DryRunE(ctx, command, args.(*Args)) + typed, ok := args.(*Args) + if !ok { + return nil, InternalErrorf("DryRunE received %T, expected %T", args, (*Args)(nil)) + } + return definition.Hooks.DryRunE(ctx, command, typed) } } if definition.Hooks.Execute != nil { host.hooks.Execute = func(ctx context.Context, command CommandContext, args any) (HostResult, error) { - result, err := definition.Hooks.Execute(ctx, command, args.(*Args)) + typed, ok := args.(*Args) + if !ok { + return HostResult{}, InternalErrorf("Execute received %T, expected %T", args, (*Args)(nil)) + } + result, err := definition.Hooks.Execute(ctx, command, typed) return hostResult(result), err } } @@ -105,7 +125,12 @@ func newCommand[Args any, Data any](definition Definition[Args, Data]) Command { for name, renderer := range definition.Hooks.Renderers { typedRenderer := renderer host.hooks.Renderers[name] = func(writer io.Writer, data any) error { - return typedRenderer(writer, data.(Data)) + typed, ok := data.(Data) + if !ok { + var expected Data + return InternalErrorf("renderer received %T, expected %T", data, expected) + } + return typedRenderer(writer, typed) } } } 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/commandhost/compile.go b/internal/commandhost/compile.go index c1a8bccf83..0a966d48ae 100644 --- a/internal/commandhost/compile.go +++ b/internal/commandhost/compile.go @@ -8,6 +8,7 @@ import ( "context" "fmt" "io" + "reflect" "strings" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" @@ -315,15 +316,20 @@ func publicContext(host common.CommandContext) command.CommandContext { func queryParams(query map[string]any) larkcore.QueryParams { params := make(larkcore.QueryParams, len(query)) for name, value := range query { + value = derefQueryValue(value) switch typed := value.(type) { case nil: continue case []string: params[name] = append([]string(nil), typed...) case []any: - values := make([]string, len(typed)) - for index, item := range typed { - values[index] = fmt.Sprint(item) + values := make([]string, 0, len(typed)) + for _, item := range typed { + item = derefQueryValue(item) + if item == nil { + continue + } + values = append(values, fmt.Sprint(item)) } params[name] = values default: @@ -333,6 +339,20 @@ func queryParams(query map[string]any) larkcore.QueryParams { return params } +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 diff --git a/internal/commandhost/compile_test.go b/internal/commandhost/compile_test.go index f0485a2de9..8e966d3076 100644 --- a/internal/commandhost/compile_test.go +++ b/internal/commandhost/compile_test.go @@ -10,6 +10,7 @@ import ( "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/core" @@ -56,6 +57,25 @@ func TestCompileSetsCompilesTypedShortcut(t *testing.T) { } } +func TestQueryParamsOmitsTypedNilAndDereferencesValues(t *testing.T) { + value := "chat_1" + var missing *string + params := queryParams(map[string]any{ + "missing": missing, + "value": &value, + "items": []any{missing, &value, 20}, + }) + 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) + } +} + func TestCompileSetsIsAtomicAcrossDuplicatePaths(t *testing.T) { set := command.Set{Domain: command.ExtendDomain(command.DomainIm), Commands: []command.Command{ fixtureCommand("+external-duplicate"), fixtureCommand("+external-duplicate"), @@ -222,6 +242,10 @@ func TestExternalDryRunUsesOfflineContext(t *testing.T) { 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) } @@ -260,4 +284,8 @@ func TestExternalDryRunEPropagatesError(t *testing.T) { 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) + } } diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 8033c57eca..652b1f5443 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -146,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)") } @@ -977,6 +980,10 @@ 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) @@ -1004,11 +1011,11 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f func runTypedMountedShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bool) error { dryRun, _ := cmd.Flags().GetBool("dry-run") if dryRun { - config, err := f.Config() + as, err := resolveShortcutIdentity(cmd, f, s) if err != nil { return err } - as, err := resolveDryRunIdentity(cmd, f, s, config) + config, err := f.Config() if err != nil { return err } @@ -1038,27 +1045,6 @@ func runTypedMountedShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut } return runTypedShortcut(f, rctx, s) } - -func resolveDryRunIdentity(cmd *cobra.Command, f *cmdutil.Factory, shortcut *Shortcut, config *core.CliConfig) (core.Identity, error) { - requested, _ := cmd.Flags().GetString("as") - identity := core.Identity(requested) - if !cmd.Flags().Changed("as") || identity == "" || identity == core.AsAuto { - identity = config.DefaultAs - if identity == "" || identity == core.AsAuto { - if len(shortcut.AuthTypes) == 1 { - identity = core.Identity(shortcut.AuthTypes[0]) - } else { - identity = core.AsBot - } - } - } - f.IdentityAutoDetected = false - if err := f.CheckIdentity(identity, shortcut.AuthTypes); err != nil { - return "", err - } - return identity, nil -} - func installTypedAnnotations(cmd *cobra.Command, command *compiledCommand) { for _, field := range command.fields { if field.cli.Deprecated != "" { @@ -1093,9 +1079,6 @@ func installTypedAnnotations(cmd *cobra.Command, command *compiledCommand) { } } -// runShortcut is the execution pipeline for a declarative shortcut. -// Each step is a clear phase: identity → config → scopes → runtime → -// canonical validation → execute. func runShortcutFlagSchema(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) (bool, error) { if s.PrintFlagSchema == nil { return false, nil @@ -1120,6 +1103,9 @@ func runShortcutFlagSchema(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) 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 { as, err := resolveShortcutIdentity(cmd, f, s) if err != nil { 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/runner_jq_test.go b/shortcuts/common/runner_jq_test.go index ff34cb57aa..155be34c03 100644 --- a/shortcuts/common/runner_jq_test.go +++ b/shortcuts/common/runner_jq_test.go @@ -355,9 +355,14 @@ func TestRunShortcut_DryRunEReturnsTypedError(t *testing.T) { cmd := newTestShortcutCmd(s, f) cmd.Flags().Set("dry-run", "true") cmd.Flags().Set("as", "bot") - if err := runShortcut(cmd, f, s, false); !errors.Is(err, sentinel) { + err := runShortcut(cmd, f, s, false) + if !errors.Is(err, sentinel) { t.Fatalf("runShortcut() error = %v", err) } + var validation *errs.ValidationError + if !errors.As(err, &validation) || validation.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("runShortcut() typed error = %#v", err) + } } func TestRunShortcut_DryRunWithJq(t *testing.T) { diff --git a/shortcuts/common/typed_binder.go b/shortcuts/common/typed_binder.go index 6850f4fe98..6b53fc11e2 100644 --- a/shortcuts/common/typed_binder.go +++ b/shortcuts/common/typed_binder.go @@ -75,6 +75,8 @@ func readCompiledField(runtime *RuntimeContext, field compiledInputField) (any, return nil, false, err } value, set := canonicalRaw, canonicalSet + sourceName := field.name + sourceSet := canonicalSet for _, alias := range field.cli.Aliases { if alias.Mode != AliasIndependent { continue @@ -92,30 +94,34 @@ func readCompiledField(runtime *RuntimeContext, field compiledInputField) (any, } switch alias.Conflict { case AliasCanonicalWins: - if !canonicalSet { + if !sourceSet { value = aliasRaw set = true + sourceName, sourceSet = alias.Name, true } case AliasErrorIfBoth: - if canonicalSet { - return nil, false, typedFieldValidation(field, "cannot be used together with --%s", alias.Name) + 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 AliasTrimmedEqualOrError: - if canonicalSet { - if strings.TrimSpace(fmt.Sprint(canonicalRaw)) != strings.TrimSpace(fmt.Sprint(aliasRaw)) { + 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)", - field.name, alias.Name, field.name, alias.Name).WithParam("--" + alias.Name) + 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", - field.name, alias.Name).WithParam("--" + alias.Name) + sourceName, alias.Name).WithParam("--" + alias.Name) } - value = strings.TrimSpace(fmt.Sprint(canonicalRaw)) + value = strings.TrimSpace(fmt.Sprint(value)) } else { value, set = aliasRaw, true + sourceName, sourceSet = alias.Name, true } } } @@ -228,6 +234,11 @@ func convertReflectValue(raw any, target reflect.Type) (any, error) { } 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) } @@ -341,8 +352,14 @@ func validateCompiledValue(value any, field compiledInputField) error { case ArrayShape: 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) } diff --git a/shortcuts/common/typed_compile_args.go b/shortcuts/common/typed_compile_args.go index 7fc5f64dbb..1aac222e5f 100644 --- a/shortcuts/common/typed_compile_args.go +++ b/shortcuts/common/typed_compile_args.go @@ -148,7 +148,12 @@ func collectArgFields(t reflect.Type, parentIndex []int, insideInline bool, out return fmt.Errorf("Args field %s (--%s): %w", field.Name, flagName, err) } var shape ValueShape - if supplement, ok := supplements[flagName]; !ok || supplement.Shape == nil { + 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) if err != nil { return fmt.Errorf("Args field %s (--%s): %w", field.Name, flagName, err) @@ -303,8 +308,8 @@ func validateInputCLI(field *compiledInputField) error { if kind != reflect.Slice && kind != reflect.Array { return fmt.Errorf("encoding repeated requires an array or slice") } - if indirectType(field.valueType).Elem().Kind() == reflect.Struct || indirectType(field.valueType).Elem().Kind() == reflect.Map { - return fmt.Errorf("encoding repeated only supports scalar arrays") + 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") @@ -382,6 +387,10 @@ type schemaTag struct { 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 == "" { diff --git a/shortcuts/common/typed_compile_contract.go b/shortcuts/common/typed_compile_contract.go index 477e57929b..b8c4a66d08 100644 --- a/shortcuts/common/typed_compile_contract.go +++ b/shortcuts/common/typed_compile_contract.go @@ -186,23 +186,51 @@ func resolveShapePointer(shape ValueShape, pointer string) (ValueShape, error) { if !valid { return nil, fmt.Errorf("segment %q has invalid RFC 6901 escaping", encoded) } - object, ok := shapeAsObject(current) - if !ok { - return nil, fmt.Errorf("segment %q traverses non-object shape", name) + var err error + current, err = resolveShapeField(current, name) + if err != nil { + return nil, err } - found := false - for _, field := range object.Fields { + } + return current, nil +} + +func resolveShapeField(shape ValueShape, name string) (ValueShape, error) { + switch value := shape.(type) { + case ObjectShape: + for _, field := range value.Fields { if field.Name == name { - current = field.Shape - found = true - break + return field.Shape, nil } } - if !found { - return nil, fmt.Errorf("field %q does not exist", name) + return nil, fmt.Errorf("field %q does not exist", name) + case OneOfShape: + var resolved []ValueShape + for _, variant := range value.Variants { + if _, null := variant.(NullShape); 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 []ValueShape) (ValueShape, error) { + switch len(shapes) { + case 0: + return nil, fmt.Errorf("shape has no applicable variant") + case 1: + return shapes[0], nil + default: + return OneOfShape{Variants: shapes}, nil } - return current, nil } func shapeAsObject(shape ValueShape) (ObjectShape, bool) { @@ -210,11 +238,24 @@ func shapeAsObject(shape ValueShape) (ObjectShape, bool) { return object, true } if one, ok := shape.(OneOfShape); ok { + var combined ObjectShape + found := false for _, variant := range one.Variants { - if object, ok := variant.(ObjectShape); ok { - return object, true + if _, null := variant.(NullShape); null { + continue + } + object, ok := shapeAsObject(variant) + if !ok { + return ObjectShape{}, false + } + if !found { + combined = object + found = true + } else if object.AdditionalProperties { + combined.AdditionalProperties = true } } + return combined, found } return ObjectShape{}, false } @@ -223,11 +264,28 @@ func unwrapArray(shape ValueShape) (ArrayShape, bool) { return array, true } if one, ok := shape.(OneOfShape); ok { + var combined ArrayShape + var items []ValueShape + found := false for _, variant := range one.Variants { - if array, ok := variant.(ArrayShape); ok { - return array, true + if _, null := variant.(NullShape); null { + continue } + array, ok := unwrapArray(variant) + if !ok { + return ArrayShape{}, false + } + if !found { + combined = array + found = true + } + items = append(items, array.Items) } + if !found { + return ArrayShape{}, false + } + combined.Items, _ = combineResolvedShapes(items) + return combined, true } return ArrayShape{}, false } @@ -246,11 +304,17 @@ func shapeHasType(shape ValueShape, want string) bool { case ObjectShape: return want == "object" case OneOfShape: + found := false for _, variant := range value.Variants { - if shapeHasType(variant, want) { - return true + if _, null := variant.(NullShape); null { + continue + } + found = true + if !shapeHasType(variant, want) { + return false } } + return found } return false } diff --git a/shortcuts/common/typed_compile_data.go b/shortcuts/common/typed_compile_data.go index de3761e3b9..3646921c4d 100644 --- a/shortcuts/common/typed_compile_data.go +++ b/shortcuts/common/typed_compile_data.go @@ -130,6 +130,9 @@ func shapeForType(t reflect.Type, schema schemaTag, input bool) (ValueShape, err 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") } diff --git a/shortcuts/common/typed_compiler_invalid_test.go b/shortcuts/common/typed_compiler_invalid_test.go index f12d4a3e56..a32fc238c5 100644 --- a/shortcuts/common/typed_compiler_invalid_test.go +++ b/shortcuts/common/typed_compiler_invalid_test.go @@ -10,6 +10,19 @@ import ( "testing" ) +func TestCompileErasedDefinitionConvertsNewArgsPanicToError(t *testing.T) { + _, err := CompileErasedDefinition(ErasedDefinition{ + ArgsType: reflect.TypeFor[compilerArgs](), + DataType: reflect.TypeFor[compilerData](), + Hooks: ErasedHooks{NewArgs: func() any { + panic("constructor failure") + }}, + }) + if err == nil || !strings.Contains(err.Error(), "Hooks.NewArgs panicked: constructor failure") { + t.Fatalf("CompileErasedDefinition() error = %v", err) + } +} + func TestParseSchemaTagRejectsInvalidGrammar(t *testing.T) { tests := []struct { name, tag, want string @@ -139,6 +152,15 @@ func TestCompileInputRejectsInvalidFieldContracts(t *testing.T) { {"oneOf includes unrepresentable variant", reflect.TypeFor[struct { Value string `flag:"value" schema:"optional" doc:"value"` }](), InputDefinition{Fields: []InputField{{Name: "value", Shape: OneOfShape{Variants: []ValueShape{StringShape{}, IntegerShape{}}}}}}, "incompatible with Go type"}, + {"explicit shape with schema constraints", reflect.TypeFor[struct { + Value string `flag:"value" schema:"optional;minLength=1" doc:"value"` + }](), InputDefinition{Fields: []InputField{{Name: "value", Shape: StringShape{}}}}, "conflicts with schema constraints"}, + {"repeated non-string elements", reflect.TypeFor[struct { + Values []int `flag:"values" schema:"optional" cli:"encoding=repeated" doc:"values"` + }](), InputDefinition{}, "only supports string arrays"}, + {"byte slice inference", reflect.TypeFor[struct { + Value []byte `flag:"value" schema:"optional;nonnullable" cli:"encoding=json" doc:"value"` + }](), InputDefinition{}, "requires an explicit Shape"}, {"alias missing conflict", reflect.TypeFor[struct { Value string `flag:"value" schema:"optional" doc:"value"` }](), InputDefinition{Fields: []InputField{{Name: "value", CLI: CLIInput{Aliases: []FlagAlias{{Name: "old", Mode: AliasIndependent}}}}}}, "must declare"}, @@ -204,3 +226,42 @@ func TestValidateShapeRejectsMalformedExplicitShapes(t *testing.T) { } } } + +func TestValidateOutputChecksEveryOneOfVariant(t *testing.T) { + itemWithStringPath := ObjectShape{Fields: []ValueField{{Name: "path", Description: "artifact path", Required: true, Shape: StringShape{}}}} + itemWithIntegerPath := ObjectShape{Fields: []ValueField{{Name: "path", Description: "artifact path", Required: true, Shape: IntegerShape{}}}} + items := func(item ValueShape) ValueField { + return ValueField{Name: "items", Description: "items", Required: true, Shape: ArrayShape{Items: item}} + } + + t.Run("missing path in variant", func(t *testing.T) { + valid := ObjectShape{Fields: []ValueField{items(itemWithStringPath)}} + invalid := ObjectShape{Fields: []ValueField{{Name: "other", Description: "other", Required: true, Shape: StringShape{}}}} + for _, variants := range [][]ValueShape{{valid, invalid}, {invalid, valid}} { + shape := OneOfShape{Variants: variants} + output := OutputDefinition{Outcomes: OutcomeDefinition{PartialFailure: &PartialFailureDefinition{ + ExitCode: 1, + FailedItems: &FailedItemDefinition{ + ItemsPath: "/items", + StatePath: "/path", + FailedValues: []JSONValue{"failed"}, + }, + }}} + if err := validateOutput(output, shape); err == nil || !strings.Contains(err.Error(), "does not exist") { + t.Fatalf("variants %T/%T error = %v", variants[0], variants[1], err) + } + } + }) + + t.Run("incompatible path type in variant", func(t *testing.T) { + valid := ObjectShape{Fields: []ValueField{items(itemWithStringPath)}} + invalid := ObjectShape{Fields: []ValueField{items(itemWithIntegerPath)}} + for _, variants := range [][]ValueShape{{valid, invalid}, {invalid, valid}} { + shape := OneOfShape{Variants: variants} + output := OutputDefinition{Artifacts: []ArtifactDefinition{{Name: "artifact", ItemsPath: "/items", PathField: "/path"}}} + if err := validateOutput(output, shape); err == nil || !strings.Contains(err.Error(), "must identify a string") { + t.Fatalf("variants %T/%T error = %v", variants[0], variants[1], err) + } + } + }) +} diff --git a/shortcuts/common/typed_external.go b/shortcuts/common/typed_external.go index 3aaacffa8b..3df748c35a 100644 --- a/shortcuts/common/typed_external.go +++ b/shortcuts/common/typed_external.go @@ -47,7 +47,10 @@ func CompileErasedDefinition(definition ErasedDefinition) (Shortcut, error) { if definition.Hooks.NewArgs == nil { return Shortcut{}, fmt.Errorf("Hooks.NewArgs is required") } - newArgs := definition.Hooks.NewArgs() + 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) } @@ -82,6 +85,16 @@ func CompileErasedDefinition(definition ErasedDefinition) (Shortcut, error) { 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 adaptErasedHooks(hooks ErasedHooks) compiledHooks { adapted := compiledHooks{ newArgs: hooks.NewArgs, diff --git a/shortcuts/common/typed_map_binder.go b/shortcuts/common/typed_map_binder.go index 0410ba53c4..add4baff23 100644 --- a/shortcuts/common/typed_map_binder.go +++ b/shortcuts/common/typed_map_binder.go @@ -23,6 +23,8 @@ func bindTypedMap(command *compiledCommand, values map[string]any) (*boundArgs, known[field.name] = struct{}{} canonical, canonicalSet := values[field.name] value, set := canonical, canonicalSet + sourceName := field.name + sourceSet := canonicalSet for _, alias := range field.cli.Aliases { known[alias.Name] = struct{}{} aliasValue, aliasSet := values[alias.Name] @@ -35,22 +37,26 @@ func bindTypedMap(command *compiledCommand, values map[string]any) (*boundArgs, case AliasIndependent: switch alias.Conflict { case AliasCanonicalWins: - if !canonicalSet { + if !sourceSet { value, set = aliasValue, true + sourceName, sourceSet = alias.Name, true } case AliasErrorIfBoth: - if canonicalSet { - return nil, typedFieldValidation(field, "cannot be used together with --%s", alias.Name) + if sourceSet { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, + "--%s cannot be used together with --%s", sourceName, alias.Name).WithParam("--" + alias.Name) } value, set = aliasValue, true + sourceName, sourceSet = alias.Name, true case AliasTrimmedEqualOrError: - if canonicalSet { - if strings.TrimSpace(fmt.Sprint(canonical)) != strings.TrimSpace(fmt.Sprint(aliasValue)) { - return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s and --%s are both set with different values", field.name, alias.Name).WithParam("--" + alias.Name) + if sourceSet { + if strings.TrimSpace(fmt.Sprint(value)) != strings.TrimSpace(fmt.Sprint(aliasValue)) { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s and --%s are both set with different values", sourceName, alias.Name).WithParam("--" + alias.Name) } - value = strings.TrimSpace(fmt.Sprint(canonical)) + value = strings.TrimSpace(fmt.Sprint(value)) } else { value, set = aliasValue, true + sourceName, sourceSet = alias.Name, true } } } @@ -78,7 +84,7 @@ func bindTypedMap(command *compiledCommand, values map[string]any) (*boundArgs, } for name := range values { if _, ok := known[name]; !ok { - return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown parameter %q", name).WithParam(name) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown parameter %q", name).WithParam("--" + name) } } if err := validateCompiledRelations(command, args, provided, StageSourcePreRun); err != nil { diff --git a/shortcuts/common/typed_map_binder_test.go b/shortcuts/common/typed_map_binder_test.go index d284b9d4a2..ad540ff1e4 100644 --- a/shortcuts/common/typed_map_binder_test.go +++ b/shortcuts/common/typed_map_binder_test.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "fmt" + "reflect" "strings" "sync" "testing" @@ -23,10 +24,14 @@ type aliasBinderData struct { } func aliasBinderCommand(t *testing.T, alias FlagAlias) *compiledCommand { + return aliasBinderCommandWithAliases(t, []FlagAlias{alias}) +} + +func aliasBinderCommandWithAliases(t *testing.T, aliases []FlagAlias) *compiledCommand { t.Helper() definition := Definition[aliasBinderArgs, aliasBinderData]{ Metadata: CommandMetadata{Service: "fixture", Command: "+alias", Description: "alias fixture", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, - Input: InputDefinition{Fields: []InputField{{Name: "value", CLI: CLIInput{Aliases: []FlagAlias{alias}}}}}, + Input: InputDefinition{Fields: []InputField{{Name: "value", CLI: CLIInput{Aliases: aliases}}}}, Hooks: Hooks[aliasBinderArgs, aliasBinderData]{Execute: func(context.Context, CommandContext, *aliasBinderArgs) (Result[aliasBinderData], error) { return Success(aliasBinderData{OK: true}), nil }}, @@ -109,6 +114,18 @@ func TestBindTypedMapAliasPolicies(t *testing.T) { }) } +func TestBindTypedMapRejectsMultipleIndependentAliases(t *testing.T) { + command := aliasBinderCommandWithAliases(t, []FlagAlias{ + {Name: "old", Mode: AliasIndependent, Conflict: AliasErrorIfBoth}, + {Name: "older", Mode: AliasIndependent, Conflict: AliasErrorIfBoth}, + }) + _, err := bindTypedMap(command, map[string]any{"old": "first", "older": "second"}) + var validation *errs.ValidationError + if !errors.As(err, &validation) || validation.Param != "--older" { + t.Fatalf("error = %#v", err) + } +} + func TestBindTypedMapCreatesIndependentArgsConcurrently(t *testing.T) { command, err := compileDefinition(validCompilerDefinition()) if err != nil { @@ -291,7 +308,7 @@ func TestBindTypedMapRejectsUnknownAndNestedInvalidValues(t *testing.T) { } _, err = bindTypedMap(command, map[string]any{"token": "tok", "unknown": true}) var validation *errs.ValidationError - if !errors.As(err, &validation) || validation.Param != "unknown" { + if !errors.As(err, &validation) || validation.Param != "--unknown" { t.Fatalf("unknown error = %#v", err) } @@ -300,3 +317,22 @@ func TestBindTypedMapRejectsUnknownAndNestedInvalidValues(t *testing.T) { t.Fatalf("nested error = %#v", err) } } + +func TestConvertReflectValueRejectsNegativeUnsignedInput(t *testing.T) { + _, err := convertReflectValue(int64(-1), reflect.TypeFor[uint64]()) + if err == nil || !strings.Contains(err.Error(), "cannot be represented") { + t.Fatalf("error = %v", err) + } +} + +func TestValidateCompiledValueHandlesNilArrayPointer(t *testing.T) { + var values *[]string + field := compiledInputField{ + name: "values", + shape: ArrayShape{Items: StringShape{}}, + } + var validation *errs.ValidationError + if err := validateCompiledValue(values, field); !errors.As(err, &validation) { + t.Fatalf("error = %#v", err) + } +} diff --git a/shortcuts/common/typed_runner_test.go b/shortcuts/common/typed_runner_test.go index 120cf48ad1..0a2cee8e2c 100644 --- a/shortcuts/common/typed_runner_test.go +++ b/shortcuts/common/typed_runner_test.go @@ -331,10 +331,10 @@ func TestTypedRunnerResolvesStdinAndRejectsUnknownJSON(t *testing.T) { t.Fatalf("Payload = %#v", captured.Payload) } - _, _, err = runTypedFixture(t, typedRunnerDefinition(nil, false), "", "--token", "x", "--payload", `{\"name\":\"ok\",\"extra\":1}`) + _, _, err = runTypedFixture(t, typedRunnerDefinition(nil, false), "", "--token", "x", "--payload", `{"name":"ok","extra":1}`) problem, ok := errs.ProblemOf(err) var validation *errs.ValidationError - if !ok || problem.Category != errs.CategoryValidation || !errors.As(err, &validation) || validation.Param != "--payload" { + if !ok || problem.Category != errs.CategoryValidation || !errors.As(err, &validation) || validation.Param != "--payload" || !strings.Contains(problem.Message, "unknown field") { t.Fatalf("error = %#v, problem = %#v", err, problem) } } @@ -361,6 +361,40 @@ func TestTypedRunnerAliasConflictAndRequiredStructuralError(t *testing.T) { if !ok || problem.Message != "--token is required" { t.Fatalf("missing required error = %v, problem = %#v", err, problem) } + + definition := typedRunnerDefinition(nil, false) + definition.Input.Fields[0].CLI.Aliases = append(definition.Input.Fields[0].CLI.Aliases, + FlagAlias{Name: "older-token", Mode: AliasIndependent, Conflict: AliasErrorIfBoth}, + ) + _, _, err = runTypedFixture(t, definition, "", "--legacy-token", "a", "--older-token", "b") + problem, ok = errs.ProblemOf(err) + if !ok || !errors.As(err, &validation) || validation.Param != "--older-token" { + t.Fatalf("multiple alias error = %v, problem = %#v", err, problem) + } +} + +func TestTypedRunnerDryRunUsesProductionStrictIdentity(t *testing.T) { + definition := typedRunnerDefinition(nil, false) + definition.Metadata.Authorization.Identities[IdentityBot] = IdentityAuthorization{} + var identity Identity + definition.Hooks.DryRun = func(_ context.Context, command CommandContext, _ *typedRunnerArgs) *DryRunAPI { + identity = command.Identity() + return NewDryRunAPI() + } + factory, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "typed-app", AppSecret: "typed-secret", Brand: core.BrandFeishu, SupportedIdentities: 1, + }) + root := &cobra.Command{Use: "lark-cli", SilenceUsage: true, SilenceErrors: true} + service := &cobra.Command{Use: "fixture"} + root.AddCommand(service) + Define(definition).Mount(service, factory) + root.SetArgs([]string{"fixture", "+typed", "--token", "value", "--dry-run"}) + if _, err := root.ExecuteC(); err != nil { + t.Fatal(err) + } + if identity != IdentityUser { + t.Fatalf("dry-run identity = %q, want %q", identity, IdentityUser) + } } func TestTypedRunnerEmitsResultLevelPartialWithoutFailedItems(t *testing.T) { diff --git a/shortcuts/register.go b/shortcuts/register.go index 453972c7ee..59c10eb80a 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -7,7 +7,6 @@ import ( "context" "fmt" "slices" - "sync" "github.com/larksuite/cli/shortcuts/okr" "github.com/spf13/cobra" @@ -67,8 +66,6 @@ func IsShortcutServiceAvailable(service string, brand core.LarkBrand) bool { // allShortcuts aggregates shortcuts from all domain packages. var allShortcuts []common.Shortcut -var shortcutRegistryMu sync.RWMutex -var externalRegistered bool func init() { allShortcuts = append(allShortcuts, apps.Shortcuts()...) @@ -102,46 +99,25 @@ func init() { // //go:noinline func AllShortcuts() []common.Shortcut { - shortcutRegistryMu.RLock() - defer shortcutRegistryMu.RUnlock() return common.CloneShortcuts(allShortcuts) } -// RegisterExternal atomically adds one build's compiled business commands. -// The process supports one explicit external contribution because V1 mounts one -// command tree per process. A second registration fails instead of replacing it. -func RegisterExternal(commands []common.Shortcut) error { - if len(commands) == 0 { - return nil - } - shortcutRegistryMu.Lock() - defer shortcutRegistryMu.Unlock() - cloned, err := prepareExternalRegistration(allShortcuts, commands, externalRegistered) - if err != nil { - return err - } - allShortcuts = append(allShortcuts, cloned...) - externalRegistered = true - return nil -} - -func prepareExternalRegistration(existing, commands []common.Shortcut, alreadyRegistered bool) ([]common.Shortcut, error) { - if alreadyRegistered { - return nil, fmt.Errorf("external command set is already registered") //nolint:forbidigo // Intermediate registration diagnostic wrapped by the command-set startup guard. - } - cloned := common.CloneShortcuts(commands) - paths := make(map[string]struct{}, len(existing)+len(cloned)) - for _, shortcut := range existing { +// AllShortcutsWithExternal returns one isolated shortcut snapshot after validating external path collisions. +func AllShortcutsWithExternal(commands []common.Shortcut) ([]common.Shortcut, error) { + registered := AllShortcuts() + external := common.CloneShortcuts(commands) + paths := make(map[string]struct{}, len(registered)+len(external)) + for _, shortcut := range registered { paths[shortcut.Service+" "+shortcut.Command] = struct{}{} } - for _, shortcut := range cloned { + for _, shortcut := range external { path := shortcut.Service + " " + shortcut.Command if _, duplicate := paths[path]; duplicate { - return nil, fmt.Errorf("external command path %q is already registered", path) //nolint:forbidigo // Intermediate registration diagnostic wrapped by the command-set startup guard. + return nil, fmt.Errorf("external command path %q is already registered", path) //nolint:forbidigo // Intermediate build diagnostic wrapped by the command-set startup guard. } paths[path] = struct{}{} } - return cloned, nil + return append(registered, external...), nil } // RegisterShortcuts registers all +shortcut commands on the program. @@ -150,9 +126,12 @@ func RegisterShortcuts(program *cobra.Command, f *cmdutil.Factory) { } func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f *cmdutil.Factory) { - shortcutRegistryMu.RLock() - registered := common.CloneShortcuts(allShortcuts) - shortcutRegistryMu.RUnlock() + RegisterShortcutSnapshotWithContext(ctx, program, f, AllShortcuts()) +} + +// RegisterShortcutSnapshotWithContext mounts one build-local shortcut snapshot. +func RegisterShortcutSnapshotWithContext(ctx context.Context, program *cobra.Command, f *cmdutil.Factory, registered []common.Shortcut) { + registered = common.CloneShortcuts(registered) // Factory.Config may be nil in tests that pass a zero-value factory. var brand core.LarkBrand if f != nil && f.Config != nil { diff --git a/shortcuts/register_external_test.go b/shortcuts/register_external_test.go index f341780762..eeb136dfe2 100644 --- a/shortcuts/register_external_test.go +++ b/shortcuts/register_external_test.go @@ -10,32 +10,33 @@ import ( "github.com/larksuite/cli/shortcuts/common" ) -func TestPrepareExternalRegistrationCopiesInput(t *testing.T) { +func TestAllShortcutsWithExternalCopiesInput(t *testing.T) { commands := []common.Shortcut{{ - Service: "im", Command: "+external-copy", Scopes: []string{"im:chat:read"}, + Service: "external-fixture", Command: "+external-copy", Scopes: []string{"im:chat:read"}, Flags: []common.Flag{{Name: "id", Enum: []string{"one"}}}, }} - registered, err := prepareExternalRegistration(nil, commands, false) + registered, err := AllShortcutsWithExternal(commands) if err != nil { t.Fatal(err) } + external := registered[len(registered)-1] commands[0].Scopes[0] = "mutated" commands[0].Flags[0].Enum[0] = "mutated" - if got := registered[0].Scopes[0]; got != "im:chat:read" { + if got := external.Scopes[0]; got != "im:chat:read" { t.Fatalf("registered scope = %q", got) } - if got := registered[0].Flags[0].Enum[0]; got != "one" { + if got := external.Flags[0].Enum[0]; got != "one" { t.Fatalf("registered enum = %q", got) } } -func TestPrepareExternalRegistrationRejectsWholeContribution(t *testing.T) { - existing := []common.Shortcut{{Service: "im", Command: "+existing"}} +func TestAllShortcutsWithExternalRejectsWholeContribution(t *testing.T) { + existing := AllShortcuts()[0] commands := []common.Shortcut{ - {Service: "im", Command: "+new"}, - {Service: "im", Command: "+existing"}, + {Service: "external-fixture", Command: "+new"}, + {Service: existing.Service, Command: existing.Command}, } - registered, err := prepareExternalRegistration(existing, commands, false) + registered, err := AllShortcutsWithExternal(commands) if err == nil || !strings.Contains(err.Error(), "already registered") { t.Fatalf("registration error = %v", err) } @@ -44,8 +45,12 @@ func TestPrepareExternalRegistrationRejectsWholeContribution(t *testing.T) { } } -func TestPrepareExternalRegistrationRejectsSecondContribution(t *testing.T) { - registered, err := prepareExternalRegistration(nil, []common.Shortcut{{Service: "im", Command: "+second"}}, true) +func TestAllShortcutsWithExternalRejectsDuplicateExternalPath(t *testing.T) { + commands := []common.Shortcut{ + {Service: "external-fixture", Command: "+duplicate"}, + {Service: "external-fixture", Command: "+duplicate"}, + } + registered, err := AllShortcutsWithExternal(commands) if err == nil || !strings.Contains(err.Error(), "already registered") { t.Fatalf("registration error = %v", err) } From 42765e8f746dee5e5dcfb66f16f447f235dad7e7 Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:52:15 +0800 Subject: [PATCH 16/47] fix(command): enforce extension v1 contracts --- cmd/auth/login.go | 2 +- cmd/auth/login_brand_filter_test.go | 23 +++++ cmd/auth/login_interactive.go | 8 +- cmd/auth/login_test.go | 13 ++- cmd/schema/schema.go | 61 ++++++++---- cmd/schema/schema_test.go | 37 ++++++- extension/command/command_test.go | 121 ++++++++++++++++++++++ extension/command/definition.go | 2 - extension/command/host.go | 143 ++++++++++++++++++++++---- extension/command/output.go | 21 +--- extension/command/pagination.go | 6 ++ extension/command/request.go | 21 +--- internal/commandhost/compile.go | 48 ++++----- internal/commandhost/compile_test.go | 36 ++++++- shortcuts/common/clone.go | 144 ++++++++++++++++++++++++--- shortcuts/common/clone_test.go | 23 ++++- shortcuts/common/typed_api.go | 13 +-- shortcuts/common/typed_api_test.go | 27 +++++ shortcuts/common/typed_runner.go | 2 +- 19 files changed, 606 insertions(+), 145 deletions(-) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 9614048482..164388a483 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -102,7 +102,7 @@ to generate QR codes (supports ASCII and PNG formats).`, // completeDomain returns completions for comma-separated domain values. func completeDomain(toComplete string) []string { - return completeDomainWithShortcuts(toComplete, "", nil) + return completeDomainWithShortcuts(toComplete, "", shortcuts.AllShortcuts()) } func completeDomainWithShortcuts(toComplete string, brand core.LarkBrand, registered []common.Shortcut) []string { diff --git a/cmd/auth/login_brand_filter_test.go b/cmd/auth/login_brand_filter_test.go index b8eae24e53..0e110bf7f1 100644 --- a/cmd/auth/login_brand_filter_test.go +++ b/cmd/auth/login_brand_filter_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/shortcuts" ) func TestBrandFilter_AppsExcludedOnLark(t *testing.T) { @@ -30,3 +31,25 @@ func TestBrandFilter_AppsExcludedOnLark(t *testing.T) { 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 := getDomainMetadataWithShortcuts("en", core.BrandFeishu, registered) + if !containsDomainMetadata(feishuDomains, "apps") { + t.Fatal("apps domain is missing for Feishu interactive login") + } + + larkDomains := getDomainMetadataWithShortcuts("en", core.BrandLark, registered) + 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 27a1a4a2e3..29e11b86d7 100644 --- a/cmd/auth/login_interactive.go +++ b/cmd/auth/login_interactive.go @@ -34,11 +34,11 @@ type interactiveResult struct { // getDomainMetadata returns metadata for all known domains, sorted by name. func getDomainMetadata(lang string) []domainMeta { - return getDomainMetadataWithShortcuts(lang, shortcuts.AllShortcuts()) + return getDomainMetadataWithShortcuts(lang, "", shortcuts.AllShortcuts()) } -func getDomainMetadataWithShortcuts(lang string, registered []common.Shortcut) []domainMeta { - known := allKnownDomainsWithShortcuts("", registered) +func getDomainMetadataWithShortcuts(lang string, brand core.LarkBrand, registered []common.Shortcut) []domainMeta { + known := allKnownDomainsWithShortcuts(brand, registered) domains := make([]domainMeta, 0, len(known)) for name := range known { domains = append(domains, buildDomainMeta(name, lang)) @@ -78,7 +78,7 @@ func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, bra } func runInteractiveLoginWithShortcuts(ios *cmdutil.IOStreams, lang string, msg *loginMsg, brand core.LarkBrand, registered []common.Shortcut) (*interactiveResult, error) { - allDomains := getDomainMetadataWithShortcuts(lang, registered) + allDomains := getDomainMetadataWithShortcuts(lang, brand, registered) // Build multi-select options options := make([]huh.Option[string], len(allDomains)) diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index 7fffe60d28..78c32b9805 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -11,6 +11,7 @@ import ( "fmt" "io" "net/http" + "reflect" "slices" "sort" "strings" @@ -136,8 +137,8 @@ func TestShortcutSupportsIdentity_BotOnly(t *testing.T) { } func TestCompleteDomain(t *testing.T) { - projects := registry.ListFromMetaProjects() - if len(projects) == 0 { + want := sortedKnownDomains("") + if len(want) == 0 { t.Skip("no from_meta data available") } @@ -146,9 +147,11 @@ func TestCompleteDomain(t *testing.T) { 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("completeDomain() = %v, want %v", completions, want) + } + if !slices.Contains(completeDomain("not"), "note") { + t.Error("completeDomain() omitted shortcut-only note domain") } // Complete with partial prefix diff --git a/cmd/schema/schema.go b/cmd/schema/schema.go index 27a2aa99a3..0da87743bc 100644 --- a/cmd/schema/schema.go +++ b/cmd/schema/schema.go @@ -7,6 +7,7 @@ import ( "context" "errors" "io" + "slices" "sort" "strings" @@ -115,7 +116,7 @@ func completeSchemaPath( mode := f.ResolveStrictMode(cmd.Context()) catalog := projectSchemaCatalog(registry.SchemaCatalog(), visibility) completions, noSpace := catalog.Complete(args, toComplete, registry.FilterForStrictMode(mode)) - completions = mergeSchemaCompletions(completions, shortcutSchemaCompletionsFrom(registered, args, toComplete, visibility)) + completions = mergeSchemaCompletions(completions, shortcutSchemaCompletionsFrom(registered, args, toComplete, visibility, mode)) directive := cobra.ShellCompDirectiveNoFileComp if noSpace { directive |= cobra.ShellCompDirectiveNoSpace @@ -166,7 +167,7 @@ func runSchemaCatalogWithShortcuts( visibility CommandVisibility, registered []common.Shortcut, ) error { - if contract, ok := resolveShortcutSchemaFrom(registered, parts, visibility); ok { + if contract, ok := resolveShortcutSchemaFrom(registered, parts, visibility, mode); ok { output.PrintJson(out, contract) return nil } @@ -199,11 +200,12 @@ func runSchemaCatalogWithShortcuts( return nil } -func resolveShortcutSchema(parts []string, visibility CommandVisibility) (any, bool) { - return resolveShortcutSchemaFrom(shortcuts.AllShortcuts(), parts, visibility) -} - -func resolveShortcutSchemaFrom(registered []common.Shortcut, parts []string, visibility CommandVisibility) (any, bool) { +func resolveShortcutSchemaFrom( + registered []common.Shortcut, + parts []string, + visibility CommandVisibility, + mode core.StrictMode, +) (any, bool) { if len(parts) != 2 || !strings.HasPrefix(parts[1], "+") { return nil, false } @@ -211,7 +213,7 @@ func resolveShortcutSchemaFrom(registered []common.Shortcut, parts []string, vis if shortcut.Service != parts[0] || shortcut.Command != parts[1] { continue } - if !shortcutSchemaVisible(shortcut, visibility) { + if !shortcutSchemaVisible(shortcut, visibility, mode) { return nil, false } return common.ShortcutSchema(shortcut) @@ -219,19 +221,21 @@ func resolveShortcutSchemaFrom(registered []common.Shortcut, parts []string, vis return nil, false } -func shortcutSchemaCompletions(args []string, toComplete string, visibility CommandVisibility) []string { - return shortcutSchemaCompletionsFrom(shortcuts.AllShortcuts(), args, toComplete, visibility) -} - -func shortcutSchemaCompletionsFrom(registered []common.Shortcut, args []string, toComplete string, visibility CommandVisibility) []string { +func shortcutSchemaCompletionsFrom( + registered []common.Shortcut, + args []string, + toComplete string, + visibility CommandVisibility, + mode core.StrictMode, +) []string { if len(args) == 0 && strings.Contains(toComplete, ".") { parts := strings.SplitN(toComplete, ".", 2) - return shortcutCommandCompletions(registered, parts[0], parts[1], parts[0]+".", visibility) + return shortcutCommandCompletions(registered, parts[0], parts[1], parts[0]+".", visibility, mode) } if len(args) == 0 { services := make(map[string]struct{}) for _, shortcut := range registered { - if !strings.HasPrefix(shortcut.Service, toComplete) || !shortcutSchemaVisible(shortcut, visibility) { + if !strings.HasPrefix(shortcut.Service, toComplete) || !shortcutSchemaVisible(shortcut, visibility, mode) { continue } if _, ok := common.ShortcutSchema(shortcut); ok { @@ -246,15 +250,22 @@ func shortcutSchemaCompletionsFrom(registered []common.Shortcut, args []string, return result } if len(args) == 1 { - return shortcutCommandCompletions(registered, args[0], toComplete, "", visibility) + return shortcutCommandCompletions(registered, args[0], toComplete, "", visibility, mode) } return nil } -func shortcutCommandCompletions(registered []common.Shortcut, service, prefix, outputPrefix string, visibility CommandVisibility) []string { +func shortcutCommandCompletions( + registered []common.Shortcut, + service string, + prefix string, + outputPrefix string, + visibility CommandVisibility, + mode core.StrictMode, +) []string { var result []string for _, shortcut := range registered { - if shortcut.Service != service || !strings.HasPrefix(shortcut.Command, prefix) || !shortcutSchemaVisible(shortcut, visibility) { + if shortcut.Service != service || !strings.HasPrefix(shortcut.Command, prefix) || !shortcutSchemaVisible(shortcut, visibility, mode) { continue } if _, ok := common.ShortcutSchema(shortcut); ok { @@ -265,8 +276,18 @@ func shortcutCommandCompletions(registered []common.Shortcut, service, prefix, o return result } -func shortcutSchemaVisible(shortcut common.Shortcut, visibility CommandVisibility) bool { - return !shortcut.Hidden && (visibility == nil || visibility([]string{shortcut.Service, shortcut.Command})) +func shortcutSchemaVisible(shortcut common.Shortcut, visibility CommandVisibility, mode core.StrictMode) bool { + if shortcut.Hidden || (visibility != nil && !visibility([]string{shortcut.Service, shortcut.Command})) { + return false + } + if !mode.IsActive() { + return true + } + identities := shortcut.AuthTypes + if len(identities) == 0 { + identities = []string{string(core.AsUser)} + } + return slices.Contains(identities, string(mode.ForcedIdentity())) } func mergeSchemaCompletions(groups ...[]string) []string { diff --git a/cmd/schema/schema_test.go b/cmd/schema/schema_test.go index 28faca1689..2907429245 100644 --- a/cmd/schema/schema_test.go +++ b/cmd/schema/schema_test.go @@ -57,14 +57,47 @@ func TestHiddenShortcutIsExcludedFromSchemaDiscovery(t *testing.T) { hidden.Hidden = true registered := []common.Shortcut{hidden} - if schema, ok := resolveShortcutSchemaFrom(registered, []string{hidden.Service, hidden.Command}, nil); ok || schema != nil { + if schema, ok := resolveShortcutSchemaFrom(registered, []string{hidden.Service, hidden.Command}, nil, core.StrictModeOff); ok || schema != nil { t.Fatalf("hidden shortcut schema = %#v, visible = %v", schema, ok) } - if completions := shortcutSchemaCompletionsFrom(registered, []string{hidden.Service}, "+hidden", nil); len(completions) != 0 { + if completions := shortcutSchemaCompletionsFrom(registered, []string{hidden.Service}, "+hidden", nil, core.StrictModeOff); len(completions) != 0 { t.Fatalf("hidden shortcut completions = %#v", completions) } } +func TestShortcutSchemaDiscoveryHonorsStrictMode(t *testing.T) { + type args struct { + Value string `flag:"value" schema:"required" doc:"fixture value"` + } + type data struct { + OK bool `json:"ok" schema:"required" doc:"success state"` + } + userOnly := common.Define(common.Definition[args, data]{ + Metadata: common.CommandMetadata{ + Service: "strict-fixture", Command: "+user-schema", Description: "User schema fixture", Risk: common.RiskRead, + Authorization: common.AuthorizationDefinition{Identities: map[common.Identity]common.IdentityAuthorization{common.IdentityUser: {}}}, + }, + Hooks: common.Hooks[args, data]{Execute: func(context.Context, common.CommandContext, *args) (common.Result[data], error) { + return common.Success(data{OK: true}), nil + }}, + }) + registered := []common.Shortcut{userOnly} + path := []string{userOnly.Service, userOnly.Command} + + if schema, ok := resolveShortcutSchemaFrom(registered, path, nil, core.StrictModeBot); ok || schema != nil { + t.Fatalf("bot strict mode schema = %#v, visible = %v", schema, ok) + } + if completions := shortcutSchemaCompletionsFrom(registered, []string{userOnly.Service}, "+user", nil, core.StrictModeBot); len(completions) != 0 { + t.Fatalf("bot strict mode completions = %#v", completions) + } + if schema, ok := resolveShortcutSchemaFrom(registered, path, nil, core.StrictModeUser); !ok || schema == nil { + t.Fatalf("user strict mode schema = %#v, visible = %v", schema, ok) + } + if completions := shortcutSchemaCompletionsFrom(registered, []string{userOnly.Service}, "+user", nil, core.StrictModeUser); len(completions) != 1 { + t.Fatalf("user strict mode completions = %#v", completions) + } +} + func TestSchemaCmd_OutputFlagsAcceptedForCompat(t *testing.T) { // Agents are habituated to --format/--json/--as from api/service commands. // schema must accept them without erroring and always emit the JSON envelope — diff --git a/extension/command/command_test.go b/extension/command/command_test.go index ee9435ee90..03f2465dba 100644 --- a/extension/command/command_test.go +++ b/extension/command/command_test.go @@ -127,6 +127,120 @@ func TestDefineCopiesNestedJSONValues(t *testing.T) { } } +func TestDefineCopiesShapePointersAndTypedJSONContainers(t *testing.T) { + minLength := 1 + maxLength := 64 + minItems := 1 + maxItems := 20 + minimum := int64(0) + maximum := 100.0 + failedValues := 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}, + }}}, + Output: OutputDefinition{ + Data: DataDefinition{ + Shape: ArrayShape{ + Items: IntegerShape{Minimum: &minimum}, MinItems: &minItems, MaxItems: &maxItems, + }, + Overrides: []DataField{{Path: "/score", Shape: NumberShape{Maximum: &maximum}}}, + }, + Outcomes: OutcomeDefinition{PartialFailure: &PartialFailureDefinition{ + ExitCode: 2, + FailedItems: &FailedItemDefinition{ + ItemsPath: "/items", IdentityPaths: []string{"/id"}, FailedValues: []JSONValue{failedValues}, + }, + }}, + }, + 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 + failedValues["ids"][0] = "mutated" + + first := InspectCommand(declaration) + assertCopiedDefinitionValues(t, first) + *first.Input.Fields[0].Shape.(StringShape).MinLength = 9 + first.Output.Outcomes.PartialFailure.FailedItems.FailedValues[0].(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) + } + failed := definition.Output.Outcomes.PartialFailure.FailedItems.FailedValues[0].(map[string][]string) + if failed["ids"][0] != "original" { + t.Fatalf("failed values = %#v", failed) + } +} + +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() @@ -252,6 +366,10 @@ func TestDryRunPreventsRequestsAndScopeChecks(t *testing.T) { 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()) @@ -262,6 +380,9 @@ func TestDryRunPreventsRequestsAndScopeChecks(t *testing.T) { 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) } diff --git a/extension/command/definition.go b/extension/command/definition.go index b62b27959f..d76e1d0e1f 100644 --- a/extension/command/definition.go +++ b/extension/command/definition.go @@ -146,8 +146,6 @@ type ValueSource string const ( // SourceFlag accepts a literal flag value. SourceFlag ValueSource = "flag" - // SourceFile accepts @relative-path input. - SourceFile ValueSource = "file" // SourceStdin accepts a single dash and reads standard input. SourceStdin ValueSource = "stdin" ) diff --git a/extension/command/host.go b/extension/command/host.go index 096f2f3cdd..19655583d0 100644 --- a/extension/command/host.go +++ b/extension/command/host.go @@ -235,7 +235,6 @@ func cloneOutputDefinition(output OutputDefinition) OutputDefinition { for index := range output.Data.Overrides { output.Data.Overrides[index].Shape = cloneValueShape(output.Data.Overrides[index].Shape) } - output.Artifacts = append([]ArtifactDefinition(nil), output.Artifacts...) if output.Outcomes.PartialFailure != nil { partial := *output.Outcomes.PartialFailure if partial.FailedItems != nil { @@ -258,15 +257,21 @@ func cloneValueShape(shape ValueShape) ValueShape { 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 @@ -275,6 +280,8 @@ func cloneValueShape(shape ValueShape) ValueShape { 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...) @@ -294,29 +301,121 @@ func cloneValueShape(shape ValueShape) ValueShape { } } +func cloneScalarPointer[T any](value *T) *T { + if value == nil { + return nil + } + cloned := *value + return &cloned +} + func cloneJSONValue(value any) any { - switch typed := value.(type) { - case map[string]any: - cloned := make(map[string]any, len(typed)) - for key, item := range typed { - cloned[key] = cloneJSONValue(item) - } - return cloned - case []any: - cloned := make([]any, len(typed)) - for index, item := range typed { - cloned[index] = cloneJSONValue(item) - } - return cloned - case []string: - return append([]string(nil), typed...) - case []int: - return append([]int(nil), typed...) - case []int64: - return append([]int64(nil), typed...) - case []float64: - return append([]float64(nil), typed...) + 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 index e8f86971ef..1b0c5fe5e5 100644 --- a/extension/command/output.go +++ b/extension/command/output.go @@ -3,13 +3,12 @@ package command -// OutputDefinition declares result formats, partial outcomes, and file receipts. +// OutputDefinition declares result formats and partial outcomes. type OutputDefinition struct { - Data DataDefinition - Outcomes OutcomeDefinition - Artifacts []ArtifactDefinition - Meta ResultMetaDefinition - Mode OutputMode + Data DataDefinition + Outcomes OutcomeDefinition + Meta ResultMetaDefinition + Mode OutputMode DisableHTMLEscaping bool } @@ -40,16 +39,6 @@ type FailedItemDefinition struct { FailedValues []JSONValue `json:"failed_values,omitempty"` } -// ArtifactDefinition identifies file receipts already present in Data. -type ArtifactDefinition struct { - Name string `json:"name"` - ItemsPath string `json:"items_path"` - Optional bool `json:"optional,omitempty"` - PathField string `json:"path_field"` - MediaTypeField string `json:"media_type_field,omitempty"` - SizeField string `json:"size_field,omitempty"` -} - // OutputMode selects the framework output behavior. type OutputMode string diff --git a/extension/command/pagination.go b/extension/command/pagination.go index 09ba59ca5b..a74b2a5087 100644 --- a/extension/command/pagination.go +++ b/extension/command/pagination.go @@ -84,6 +84,12 @@ func CollectAllPages[T any](ctx context.Context, command CommandContext, request func collectPages[T any](ctx context.Context, command CommandContext, request Request, all bool) (Page[T], error) { result := Page[T]{meta: &paginationMeta{}} + if err := validateRequest(request); err != nil { + return result, err + } + 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") } diff --git a/extension/command/request.go b/extension/command/request.go index 2a00b2fe20..bf37ac92ff 100644 --- a/extension/command/request.go +++ b/extension/command/request.go @@ -41,7 +41,7 @@ func newRequest(method, apiPath string) Request { // 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] = value + r.query[name] = cloneJSONValue(value) return r } @@ -53,7 +53,7 @@ func (r Request) Params(params map[string]any) Request { // Body sets the JSON request body and returns a copied request. func (r Request) Body(body any) Request { - r.body = body + r.body = cloneJSONValue(body) return r } @@ -78,7 +78,7 @@ func InspectRequest(request Request) RequestView { Method: request.method, Path: request.path, Query: cloneAnyMap(request.query), - Body: request.body, + Body: cloneJSONValue(request.body), Description: request.description, } } @@ -123,20 +123,7 @@ func cloneAnyMap(input map[string]any) map[string]any { } result := make(map[string]any, len(input)) for key, value := range input { - result[key] = cloneQueryValue(value) + result[key] = cloneJSONValue(value) } return result } - -func cloneQueryValue(value any) any { - switch typed := value.(type) { - case []string: - return append([]string(nil), typed...) - case []int: - return append([]int(nil), typed...) - case []any: - return append([]any(nil), typed...) - default: - return value - } -} diff --git a/internal/commandhost/compile.go b/internal/commandhost/compile.go index 0a966d48ae..8f0b90b669 100644 --- a/internal/commandhost/compile.go +++ b/internal/commandhost/compile.go @@ -159,6 +159,9 @@ func convertInput(input command.InputDefinition) (common.InputDefinition, error) } sources := make([]common.ValueSource, len(field.CLI.ValueSources)) for sourceIndex, source := range field.CLI.ValueSources { + if source != command.SourceFlag && source != command.SourceStdin { + return common.InputDefinition{}, fmt.Errorf("Input.Fields[%d].CLI.ValueSources[%d]: source %q is not supported in V1", index, sourceIndex, source) + } sources[sourceIndex] = common.ValueSource(source) } converted.Fields[index] = common.InputField{ @@ -193,13 +196,6 @@ func convertOutput(output command.OutputDefinition) (common.OutputDefinition, er Data: common.DataDefinition{Shape: dataShape, Overrides: dataOverrides}, Meta: common.ResultMetaDefinition{Count: output.Meta.Count, Pagination: output.Meta.Pagination}, Mode: common.OutputMode(output.Mode), DisableHTMLEscaping: output.DisableHTMLEscaping, - Artifacts: make([]common.ArtifactDefinition, len(output.Artifacts)), - } - for index, artifact := range output.Artifacts { - converted.Artifacts[index] = common.ArtifactDefinition{ - Name: artifact.Name, ItemsPath: artifact.ItemsPath, Optional: artifact.Optional, - PathField: artifact.PathField, MediaTypeField: artifact.MediaTypeField, SizeField: artifact.SizeField, - } } if output.Outcomes.PartialFailure != nil { partial := output.Outcomes.PartialFailure @@ -316,29 +312,33 @@ func publicContext(host common.CommandContext) command.CommandContext { func queryParams(query map[string]any) larkcore.QueryParams { params := make(larkcore.QueryParams, len(query)) for name, value := range query { - value = derefQueryValue(value) - switch typed := value.(type) { - case nil: - continue - case []string: - params[name] = append([]string(nil), typed...) - case []any: - values := make([]string, 0, len(typed)) - for _, item := range typed { - item = derefQueryValue(item) - if item == nil { - continue - } - values = append(values, fmt.Sprint(item)) - } + values := queryValues(value) + if len(values) > 0 { params[name] = values - default: - params[name] = []string{fmt.Sprint(value)} } } return params } +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 diff --git a/internal/commandhost/compile_test.go b/internal/commandhost/compile_test.go index 8e966d3076..6761fad09e 100644 --- a/internal/commandhost/compile_test.go +++ b/internal/commandhost/compile_test.go @@ -60,10 +60,13 @@ func TestCompileSetsCompilesTypedShortcut(t *testing.T) { 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}, + "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"]) @@ -74,6 +77,12 @@ func TestQueryParamsOmitsTypedNilAndDereferencesValues(t *testing.T) { 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) { @@ -127,6 +136,27 @@ func TestCompileSetsRejectsSystemFlag(t *testing.T) { } } +func TestCompileSetsRejectsFileInputSource(t *testing.T) { + declaration := command.Define(command.Definition[fixtureArgs, fixtureData]{ + Metadata: command.CommandMetadata{ + Service: "im", Command: "+external-file-input", Description: "File input", 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: []command.ValueSource{command.ValueSource("file")}}, + }}}, + Hooks: command.Hooks[fixtureArgs, fixtureData]{Execute: func(context.Context, command.CommandContext, *fixtureArgs) (command.Result[fixtureData], error) { + return command.Success(fixtureData{}), nil + }}, + }) + _, err := CompileSets([]command.Set{{Domain: command.ExtendDomain(command.DomainIm), Commands: []command.Command{declaration}}}) + if err == nil || !strings.Contains(err.Error(), "source \"file\" is not supported in V1") { + t.Fatalf("CompileSets() error = %v", err) + } +} + func TestCompileSetsRejectsBothDryRunHooks(t *testing.T) { definition := command.Define(command.Definition[fixtureArgs, fixtureData]{ Metadata: command.CommandMetadata{ diff --git a/shortcuts/common/clone.go b/shortcuts/common/clone.go index ab7e8705c4..df2bd7ab28 100644 --- a/shortcuts/common/clone.go +++ b/shortcuts/common/clone.go @@ -3,7 +3,10 @@ package common -import "io" +import ( + "io" + "reflect" +) // CloneShortcut copies mutable declaration and compiled-contract data. // Function values and values captured by business closures remain shared. @@ -91,6 +94,9 @@ func cloneCommonOutput(output OutputDefinition) OutputDefinition { failed := *partial.FailedItems failed.IdentityPaths = append([]string(nil), failed.IdentityPaths...) failed.FailedValues = append([]JSONValue(nil), failed.FailedValues...) + for index := range failed.FailedValues { + failed.FailedValues[index] = cloneJSONValue(failed.FailedValues[index]) + } partial.FailedItems = &failed } output.Outcomes.PartialFailure = &partial @@ -104,15 +110,21 @@ func cloneCommonShape(shape ValueShape) ValueShape { 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, anyJSONShape: return typed @@ -121,6 +133,8 @@ func cloneCommonShape(shape ValueShape) ValueShape { return typed case ArrayShape: typed.Items = cloneCommonShape(typed.Items) + typed.MinItems = cloneScalarPointer(typed.MinItems) + typed.MaxItems = cloneScalarPointer(typed.MaxItems) return typed case ObjectShape: typed.Fields = append([]ValueField(nil), typed.Fields...) @@ -140,23 +154,121 @@ func cloneCommonShape(shape ValueShape) ValueShape { } } +func cloneScalarPointer[T any](value *T) *T { + if value == nil { + return nil + } + cloned := *value + return &cloned +} + func cloneJSONValue(value any) any { - switch typed := value.(type) { - case map[string]any: - cloned := make(map[string]any, len(typed)) - for key, item := range typed { - cloned[key] = cloneJSONValue(item) - } - return cloned - case []any: - cloned := make([]any, len(typed)) - for index, item := range typed { - cloned[index] = cloneJSONValue(item) - } - return cloned - case []string: - return append([]string(nil), typed...) + 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 index 6f77959f41..9538f8be34 100644 --- a/shortcuts/common/clone_test.go +++ b/shortcuts/common/clone_test.go @@ -28,12 +28,26 @@ func TestCloneShortcutCopiesCompiledContract(t *testing.T) { return Success(cloneData{}), nil }}, }) + minLength := 1 + shape := original.typed.fields[0].shape.(StringShape) + shape.MinLength = &minLength + original.typed.fields[0].shape = shape + failedValues := map[string][]string{"ids": {"original"}} + original.typed.output.Outcomes.PartialFailure = &PartialFailureDefinition{ + ExitCode: 2, + FailedItems: &FailedItemDefinition{ + ItemsPath: "/items", IdentityPaths: []string{"/id"}, FailedValues: []JSONValue{failedValues}, + }, + } cloned := CloneShortcut(original) original.UserScopes[0] = "mutated" original.Flags[0].Enum[0] = "mutated" original.typed.metadata.Authorization.Identities[IdentityUser] = IdentityAuthorization{RequiredScopes: []string{"mutated"}} - original.typed.fields[0].shape.(StringShape).Enum[0] = "mutated" + originalShape := original.typed.fields[0].shape.(StringShape) + originalShape.Enum[0] = "mutated" + *originalShape.MinLength = 2 + failedValues["ids"][0] = "mutated" if got := cloned.UserScopes[0]; got != "im:chat:read" { t.Fatalf("cloned user scope = %q", got) @@ -47,6 +61,13 @@ func TestCloneShortcutCopiesCompiledContract(t *testing.T) { if got := cloned.typed.fields[0].shape.(StringShape).Enum[0]; got != "one" { t.Fatalf("cloned typed enum = %q", got) } + if got := *cloned.typed.fields[0].shape.(StringShape).MinLength; got != 1 { + t.Fatalf("cloned minimum length = %d", got) + } + failed := cloned.typed.output.Outcomes.PartialFailure.FailedItems.FailedValues[0].(map[string][]string) + if got := failed["ids"][0]; got != "original" { + t.Fatalf("cloned failed value = %q", got) + } } func TestExternalFlagNamespaceRejectsEverySystemFlag(t *testing.T) { diff --git a/shortcuts/common/typed_api.go b/shortcuts/common/typed_api.go index 61ecbfc723..1bb1d3900d 100644 --- a/shortcuts/common/typed_api.go +++ b/shortcuts/common/typed_api.go @@ -17,9 +17,7 @@ import ( // DoTypedAPIJSON executes and classifies one JSON API request through the // restricted CommandContext. It preserves the legacy RuntimeContext typed API // classification while keeping hooks independent of RuntimeContext flags and -// output methods. It also carries a header-only log_id in returned data so a -// hook that detects a malformed success payload can attach the request ID to -// its own typed invalid-response error. +// output methods. Successful response data is returned without host metadata. func DoTypedAPIJSON(ctx context.Context, command CommandContext, method, apiPath string, query larkcore.QueryParams, body any) (map[string]any, error) { return DoTypedAPIJSONWithOptions(ctx, command, method, apiPath, query, body) } @@ -43,14 +41,7 @@ func DoTypedAPIJSONWithOptions(ctx context.Context, command CommandContext, meth if err != nil { return nil, typedOrInternal(err) } - data, err := ClassifyAPIResponseWith(response, typedClassifyContext(command)) - if data == nil { - data = map[string]any{} - } - if logID := response.Header.Get("x-tt-logid"); logID != "" { - data["log_id"] = logID - } - return data, err + return ClassifyAPIResponseWith(response, typedClassifyContext(command)) } // CallTypedAPI preserves RuntimeContext.CallAPITyped's raw request semantics diff --git a/shortcuts/common/typed_api_test.go b/shortcuts/common/typed_api_test.go index 939e4df342..8b30a1b9de 100644 --- a/shortcuts/common/typed_api_test.go +++ b/shortcuts/common/typed_api_test.go @@ -4,9 +4,12 @@ package common import ( + "context" + "net/http" "testing" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" "github.com/spf13/cobra" ) @@ -27,3 +30,27 @@ func TestTypedClassifyContextPreservesCommandPath(t *testing.T) { t.Fatalf("classify context = %#v", classify) } } + +func TestDoTypedAPIJSONPreservesSuccessData(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 := DoTypedAPIJSON(context.Background(), typedCommandContext{runtime: runtime}, "GET", "/open-apis/x/y", nil, nil) + 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_runner.go b/shortcuts/common/typed_runner.go index 092582cecb..e664007318 100644 --- a/shortcuts/common/typed_runner.go +++ b/shortcuts/common/typed_runner.go @@ -106,7 +106,7 @@ func validateTypedStdinInputs(runtime *RuntimeContext, command *compiledCommand) } } if len(selected) > 1 { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "at most one parameter may read stdin in one invocation; use @file for the others").WithParam("--" + selected[1]) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "at most one parameter may read stdin in one invocation; provide the other values through their remaining declared sources").WithParam("--" + selected[1]) } return nil } From 3c1aaede11f30546d39a8850a76be158f1c72f48 Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:01:00 +0800 Subject: [PATCH 17/47] fix(command): remove unreachable compatibility wrappers --- cmd/auth/auth.go | 6 ------ cmd/auth/login_interactive.go | 5 ----- cmd/schema/schema.go | 30 ------------------------------ 3 files changed, 41 deletions(-) diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go index 4e83be3376..8998d986e2 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -28,12 +28,6 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command { return newCmdAuth(f, nil, shortcuts.AllShortcuts()) } -// NewCmdAuthWithRecovery creates the auth command with a build-local recovery -// presenter while preserving NewCmdAuth's established function signature. -func NewCmdAuthWithRecovery(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Command { - return newCmdAuth(f, projector, shortcuts.AllShortcuts()) -} - // 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) diff --git a/cmd/auth/login_interactive.go b/cmd/auth/login_interactive.go index 29e11b86d7..4d2b238761 100644 --- a/cmd/auth/login_interactive.go +++ b/cmd/auth/login_interactive.go @@ -72,11 +72,6 @@ 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) { - return runInteractiveLoginWithShortcuts(ios, lang, msg, brand, shortcuts.AllShortcuts()) -} - func runInteractiveLoginWithShortcuts(ios *cmdutil.IOStreams, lang string, msg *loginMsg, brand core.LarkBrand, registered []common.Shortcut) (*interactiveResult, error) { allDomains := getDomainMetadataWithShortcuts(lang, brand, registered) diff --git a/cmd/schema/schema.go b/cmd/schema/schema.go index 0da87743bc..128fbcacfb 100644 --- a/cmd/schema/schema.go +++ b/cmd/schema/schema.go @@ -51,18 +51,6 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co return NewCmdSchemaWithVisibilityAndShortcuts(f, nil, shortcuts.AllShortcuts(), runF) } -// NewCmdSchemaWithVisibility creates the schema command projected through one -// build-local command surface. Existing callers should use NewCmdSchema; the -// root builder uses this form so schema execution and completion share the -// exact presentation plan captured by that Cobra tree. -func NewCmdSchemaWithVisibility( - f *cmdutil.Factory, - visibility CommandVisibility, - runF func(*SchemaOptions) error, -) *cobra.Command { - return NewCmdSchemaWithVisibilityAndShortcuts(f, visibility, shortcuts.AllShortcuts(), runF) -} - // NewCmdSchemaWithVisibilityAndShortcuts creates schema commands from one build-local shortcut snapshot. func NewCmdSchemaWithVisibilityAndShortcuts( f *cmdutil.Factory, @@ -125,30 +113,12 @@ func completeSchemaPath( } } -func schemaRunWithVisibility(opts *SchemaOptions, visibility CommandVisibility) error { - return schemaRunWithVisibilityAndShortcuts(opts, visibility, shortcuts.AllShortcuts()) -} - func schemaRunWithVisibilityAndShortcuts(opts *SchemaOptions, visibility CommandVisibility, registered []common.Shortcut) error { out := opts.Factory.IOStreams.Out mode := opts.Factory.ResolveStrictMode(opts.Ctx) return runSchemaCatalogWithShortcuts(out, apicatalog.ParsePath(opts.Args), mode, registry.SchemaCatalog(), visibility, registered) } -// runSchemaWithVisibility resolves the path through the schema catalog and renders the -// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and -// schema owns rendering (Envelope/Envelopes); this adapter only chooses the -// output shape — a single resolved method renders as one envelope object, -// anything broader as an array — and maps resolve failures to hints. -func runSchemaWithVisibility( - out io.Writer, - parts []string, - mode core.StrictMode, - visibility CommandVisibility, -) error { - return runSchemaCatalog(out, parts, mode, registry.SchemaCatalog(), visibility) -} - func runSchemaCatalog( out io.Writer, parts []string, From 96063c95569afff6b6427e96fe784dbcd11a126a Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Wed, 12 Aug 2026 11:33:01 +0800 Subject: [PATCH 18/47] fix(auth): keep scope-less domains addressable via --domain, matching main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the declared-scope filter from allKnownDomains to the interactive selector only. On main, a scope-less shortcut domain (event) passes --domain validation and fails later with "no matching scopes found"; the previous unified filter changed that to "unknown domain" and dropped it from the --help list. The interactive picker still hides scope-less domains — selecting one can only fail. --- cmd/auth/login.go | 32 +++++++++++++++++++++++++++- cmd/auth/login_interactive.go | 4 ++++ cmd/auth/login_test.go | 39 +++++++++++++++++++++++++++-------- 3 files changed, 65 insertions(+), 10 deletions(-) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 164388a483..7773b0b701 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -577,7 +577,11 @@ func allKnownDomainsWithShortcuts(brand core.LarkBrand, registered []common.Shor if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) { continue } - if !registry.HasAuthDomain(sc.Service) && shortcutHasDeclaredScopes(sc) { + // 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 getDomainMetadataWithShortcuts). + if !registry.HasAuthDomain(sc.Service) { domains[sc.Service] = true } } @@ -593,6 +597,32 @@ func shortcutHasDeclaredScopes(shortcut common.Shortcut) bool { 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 scopelessShortcutOnlyDomains(registered []common.Shortcut) 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 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 { return sortedKnownDomainsWithShortcuts(brand, shortcuts.AllShortcuts()) diff --git a/cmd/auth/login_interactive.go b/cmd/auth/login_interactive.go index 4d2b238761..a92e1be55f 100644 --- a/cmd/auth/login_interactive.go +++ b/cmd/auth/login_interactive.go @@ -39,8 +39,12 @@ func getDomainMetadata(lang string) []domainMeta { func getDomainMetadataWithShortcuts(lang string, brand core.LarkBrand, registered []common.Shortcut) []domainMeta { known := allKnownDomainsWithShortcuts(brand, registered) + scopeless := scopelessShortcutOnlyDomains(registered) domains := make([]domainMeta, 0, len(known)) for name := range known { + if scopeless[name] { + continue + } domains = append(domains, buildDomainMeta(name, lang)) } diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index 78c32b9805..8b7b78f509 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -323,20 +323,45 @@ func TestExternalShortcutScopesParticipateInAuthDomainResolution(t *testing.T) { } } -func TestGetDomainMetadataMatchesAllKnownDomains(t *testing.T) { +// 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 := getDomainMetadata("zh") known := allKnownDomains("") - if len(metadata) != len(known) { - t.Fatalf("domain metadata count = %d, allKnownDomains count = %d", len(metadata), len(known)) + scopeless := scopelessShortcutOnlyDomains(shortcuts.AllShortcuts()) + 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) + } } } -func TestAuthLoginHelpMatchesInteractiveDomains(t *testing.T) { +// 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 := allKnownDomains("") + if !known["event"] { + t.Fatal("event must remain in allKnownDomains to match main behavior") + } + if scopes := collectScopesForDomains([]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) @@ -344,11 +369,7 @@ func TestAuthLoginHelpMatchesInteractiveDomains(t *testing.T) { if domainFlag == nil { t.Fatal("auth login --domain flag is missing") } - metadata := getDomainMetadata("zh") - names := make([]string, len(metadata)) - for index, domain := range metadata { - names[index] = domain.Name - } + names := sortedKnownDomains("") want := "available: " + strings.Join(names, ", ") + ", all" if !strings.Contains(domainFlag.Usage, want) { t.Fatalf("domain help = %q, want %q", domainFlag.Usage, want) From 3e03ca6f6e6244cdc5ff29365480568c3d91277c Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Wed, 12 Aug 2026 11:33:01 +0800 Subject: [PATCH 19/47] feat(command): add PathSegment for user-provided path values Business code concatenates IDs into request paths but had no public escape helper (internal/validate.EncodePathSegment is unreachable from extension/command). Mirror its url.PathEscape semantics, use it in the business command examples, and pin the traversal defense: the validator decodes percent-encoding before the canonical check, so both raw and escaped dot sequences fail same-origin validation. --- extension/command/command_test.go | 33 +++++++++++++++++++ .../commandtest/business_commands_test.go | 12 +++---- extension/command/request.go | 7 ++++ 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/extension/command/command_test.go b/extension/command/command_test.go index 03f2465dba..06768928fd 100644 --- a/extension/command/command_test.go +++ b/extension/command/command_test.go @@ -412,3 +412,36 @@ func TestPublicPackageHasNoForbiddenImports(t *testing.T) { } } } + +// 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 + "../../secret": "..%2F..%2Fsecret", + "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) + } +} diff --git a/extension/command/commandtest/business_commands_test.go b/extension/command/commandtest/business_commands_test.go index 86c5610d26..74fb8cf204 100644 --- a/extension/command/commandtest/business_commands_test.go +++ b/extension/command/commandtest/business_commands_test.go @@ -27,7 +27,7 @@ type documentData struct { func documentGetDefinition() command.Definition[documentGetArgs, documentData] { request := func(args *documentGetArgs) command.Request { - return command.GET("/open-apis/docx/v1/documents/" + args.DocumentID + "/raw_content") + return command.GET("/open-apis/docx/v1/documents/" + command.PathSegment(args.DocumentID) + "/raw_content") } return command.Definition[documentGetArgs, documentData]{ Metadata: command.CommandMetadata{ @@ -153,7 +153,7 @@ func taskAuditDefinition() command.Definition[taskAuditArgs, taskAuditData] { for _, task := range tasks { owner, ownerErr := command.CallJSON[struct { Name string `json:"name"` - }](ctx, commandContext, command.GET("/open-apis/contact/v3/users/"+task.OwnerID)) + }](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)) @@ -196,14 +196,14 @@ func memberListDefinition() command.Definition[memberListArgs, memberListData] { }, Hooks: command.Hooks[memberListArgs, memberListData]{ DryRun: func(_ context.Context, _ command.CommandContext, args *memberListArgs) *command.DryRun { - preview := command.Preview(command.GET("/open-apis/im/v1/chats/" + args.ChatID)) + preview := command.Preview(command.GET("/open-apis/im/v1/chats/" + command.PathSegment(args.ChatID))) if args.IncludeMembers { - preview.Add(command.GET("/open-apis/im/v1/chats/" + args.ChatID + "/members")) + 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/"+args.ChatID)) + 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 } @@ -215,7 +215,7 @@ func memberListDefinition() command.Definition[memberListArgs, memberListData] { } members, err := command.CallJSON[struct { Items []string `json:"items"` - }](ctx, commandContext, command.GET("/open-apis/im/v1/chats/"+args.ChatID+"/members")) + }](ctx, commandContext, command.GET("/open-apis/im/v1/chats/"+command.PathSegment(args.ChatID)+"/members")) if err != nil { return command.Result[memberListData]{}, err } diff --git a/extension/command/request.go b/extension/command/request.go index bf37ac92ff..ce7ff0bdea 100644 --- a/extension/command/request.go +++ b/extension/command/request.go @@ -34,6 +34,13 @@ 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)} } From 6a04c8a7c1c2e5f06550b1479b4654f3b0f4991b Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Wed, 12 Aug 2026 11:47:14 +0800 Subject: [PATCH 20/47] docs(command): add runnable chat-brief distribution example Mirror the audit-observer precedent: a buildable wrapper main under examples/ showing WithCommandSets against the real distribution shape (plugins, strict mode, and service commands stay enabled). Covers the single-read command (Validate, shared DryRun request, CallJSON, PathSegment, Tips) and a Page[T] list command whose pagination flags come from the compiler. The testdata/wrapper fixture stays test-only. Verified offline: --help renders tag-driven parameters, +chat-brief-list exposes --page-all/--page-limit/--page-delay, and --dry-run previews the request with a fake env token and no network access. --- extension/command/examples/chat-brief/main.go | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 extension/command/examples/chat-brief/main.go diff --git a/extension/command/examples/chat-brief/main.go b/extension/command/examples/chat-brief/main.go new file mode 100644 index 0000000000..a665255fcd --- /dev/null +++ b/extension/command/examples/chat-brief/main.go @@ -0,0 +1,157 @@ +// 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 # Tips + flags from tags +// ./chat-brief-cli im +chat-brief --chat-id oc_xxx --dry-run # offline request preview +// ./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 +package main + +import ( + "context" + "os" + "strings" + + defaultaffordance "github.com/larksuite/cli/affordance" + "github.com/larksuite/cli/cmd" + "github.com/larksuite/cli/extension/command" + defaultskills "github.com/larksuite/cli/skills" + + _ "github.com/larksuite/cli/extension/credential/env" // 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) +} + +var chatBrief = command.Define(command.Definition[chatBriefArgs, chatBriefData]{ + Metadata: command.CommandMetadata{ + Service: "im", + Command: "+chat-brief", + Description: "Get a concise chat projection", + Risk: command.RiskRead, + Tips: []string{ + "Example: chat-brief-cli im +chat-brief --chat-id oc_xxx", + }, + 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.Preview(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 + }, + }, +}) + +type chatListArgs struct { + PageSize int `flag:"page-size" schema:"optional;default=20;minimum=1;maximum=100" doc:"items per page"` +} + +type chatItem struct { + ChatID string `json:"chat_id" schema:"required" doc:"chat ID"` + Name string `json:"name" schema:"required" doc:"chat name"` +} + +func chatListRequest(args *chatListArgs) command.Request { + return command.GET("/open-apis/im/v1/chats").Set("page_size", args.PageSize) +} + +// chatList declares Page[T] as its Data, so the compiler installs the +// framework pagination flags; the Args stay free of paging fields. +var chatList = command.Define(command.Definition[chatListArgs, command.Page[chatItem]]{ + Metadata: command.CommandMetadata{ + Service: "im", + Command: "+chat-brief-list", + Description: "List visible chats", + Risk: command.RiskRead, + Tips: []string{ + "Example: chat-brief-cli im +chat-brief-list --page-all --page-limit 2", + }, + 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.Preview(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 + }, + }, +}) + +func main() { + // A wrapper main has no implicit embedded content; reuse the repository + // defaults so official command guidance stays available. + cmd.SetEmbeddedSkillContent(defaultskills.DefaultFS()) + cmd.SetEmbeddedAffordanceContent(defaultaffordance.DefaultFS()) + os.Exit(cmd.ExecuteWithOptions( + cmd.WithCommandSets(command.Set{ + Domain: command.ExtendDomain(command.DomainIm), + Commands: []command.Command{chatBrief, chatList}, + }), + )) +} From 2421cfd7d6ccab32287193381e126cd772a63f9a Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:20:23 +0800 Subject: [PATCH 21/47] fix(command): normalize page envelopes and bound CollectAllPages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pagination contract fixes from the extension design (owner plan §8.3): Page decoding accepted only a literal "items" array, so endpoints that spell their list field differently (drive uses files, some responses use records) walked every page while decoding nothing — CollectAllPages then returned an empty set marked complete, and downstream writes ran against it. Each page now normalizes its single top-level array field into Page.Items; zero or multiple array fields fail closed with a typed invalid-response error. CollectAllPages previously reused the user-facing --page-limit maximum (1000) as its walk bound. A complete-set collection holds every page in memory before the workflow's writes run, so it now uses the design's dedicated workflow bound of 100 pages. --- extension/command/command_test.go | 47 ++++++++++++++++ extension/command/pagination.go | 55 ++++++++++++------- shortcuts/common/typed_external_pagination.go | 9 ++- .../common/typed_external_pagination_test.go | 26 +++++++++ 4 files changed, 115 insertions(+), 22 deletions(-) create mode 100644 shortcuts/common/typed_external_pagination_test.go diff --git a/extension/command/command_test.go b/extension/command/command_test.go index 06768928fd..ce8b9ef9e6 100644 --- a/extension/command/command_test.go +++ b/extension/command/command_test.go @@ -445,3 +445,50 @@ func TestPathSegmentNeutralizesSeparatorsAndTraversal(t *testing.T) { 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/pagination.go b/extension/command/pagination.go index a74b2a5087..21daceb7b7 100644 --- a/extension/command/pagination.go +++ b/extension/command/pagination.go @@ -7,6 +7,8 @@ import ( "bytes" "context" "encoding/json" + "sort" + "strings" ) // Page contains items and host-owned pagination state. @@ -58,11 +60,36 @@ func clonePaginationMeta(meta *paginationMeta) *paginationMeta { return © } -type pageEnvelope[T any] struct { - Items []T `json:"items"` - HasMore bool `json:"has_more"` - PageToken string `json:"page_token"` - NextPageToken string `json:"next_page_token"` +// 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. @@ -98,26 +125,12 @@ func collectPages[T any](ctx context.Context, command CommandContext, request Re result.meta.Pages = pagination.Pages result.meta.NextToken = pagination.NextToken for pageNumber, data := range pages { - page, decodeErr := decodePageEnvelope[T](data) + 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, page.Items...) + result.Items = append(result.Items, items...) } result.meta.Items = len(result.Items) return result, err } - -func decodePageEnvelope[T any](data map[string]any) (pageEnvelope[T], error) { - var page pageEnvelope[T] - encoded, err := json.Marshal(data) - if err != nil { - return page, err - } - decoder := json.NewDecoder(bytes.NewReader(encoded)) - decoder.UseNumber() - if err := decoder.Decode(&page); err != nil { - return page, err - } - return page, nil -} diff --git a/shortcuts/common/typed_external_pagination.go b/shortcuts/common/typed_external_pagination.go index 98ff2a93bd..eb5796106e 100644 --- a/shortcuts/common/typed_external_pagination.go +++ b/shortcuts/common/typed_external_pagination.go @@ -53,9 +53,16 @@ func CollectCommandPages(ctx context.Context, command CommandContext, request Pa return collection, nil } +// collectAllHardPageBound caps CollectAllPages workflows. It is deliberately +// tighter than the user-facing --page-limit maximum (1000): a complete-set +// collection holds every page in memory before the workflow'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). +const collectAllHardPageBound = 100 + func commandPagePolicy(command CommandContext, all bool) (paginationPolicy, error) { if all { - return paginationPolicy{maxPages: pageLimitMaximum}, nil + return paginationPolicy{maxPages: collectAllHardPageBound}, nil } options, err := command.PaginationOptions() if err != 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..4f50a9c72c --- /dev/null +++ b/shortcuts/common/typed_external_pagination_test.go @@ -0,0 +1,26 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import "testing" + +// 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(CommandContext(nil), true) + if err != nil { + t.Fatal(err) + } + if policy.maxPages != collectAllHardPageBound { + t.Fatalf("collect-all maxPages = %d, want %d", policy.maxPages, collectAllHardPageBound) + } + if collectAllHardPageBound >= pageLimitMaximum { + t.Fatalf("workflow bound (%d) must stay below the user-facing --page-limit maximum (%d)", + collectAllHardPageBound, pageLimitMaximum) + } + if collectAllHardPageBound != 100 { + t.Fatalf("workflow bound = %d, want the Phase 0 value 100", collectAllHardPageBound) + } +} From 9dd833e65d9a5c57db3b194c1a21187588eb0cbb Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:20:23 +0800 Subject: [PATCH 22/47] feat(commandhost): note bounded repetition in Page dry-run previews A Page[T] command's dry-run can only show the first request; fabricating response-dependent page tokens is forbidden. Append the bounded-repeat explanation to the previewed request (preserving any business description), matching the design's dry-run contract. Also give the example's list command a --page-token resume flag seeded into the request, documenting the resume convention: the framework owns --page-all/--page-limit/--page-delay while the starting cursor is a business-declared input, independent of --page-all. --- extension/command/examples/chat-brief/main.go | 11 ++++- internal/commandhost/compile.go | 30 +++++++++---- internal/commandhost/compile_test.go | 44 +++++++++++++++++++ 3 files changed, 74 insertions(+), 11 deletions(-) diff --git a/extension/command/examples/chat-brief/main.go b/extension/command/examples/chat-brief/main.go index a665255fcd..fb2b99ec91 100644 --- a/extension/command/examples/chat-brief/main.go +++ b/extension/command/examples/chat-brief/main.go @@ -100,7 +100,8 @@ var chatBrief = command.Define(command.Definition[chatBriefArgs, chatBriefData]{ }) type chatListArgs struct { - PageSize int `flag:"page-size" schema:"optional;default=20;minimum=1;maximum=100" doc:"items per page"` + 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 { @@ -108,8 +109,14 @@ type chatItem struct { 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 { - return command.GET("/open-apis/im/v1/chats").Set("page_size", args.PageSize) + 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 } // chatList declares Page[T] as its Data, so the compiler installs the diff --git a/internal/commandhost/compile.go b/internal/commandhost/compile.go index 8f0b90b669..8deef7862c 100644 --- a/internal/commandhost/compile.go +++ b/internal/commandhost/compile.go @@ -102,7 +102,7 @@ func compileCommand(definition command.HostDefinition) (common.Shortcut, error) if err != nil { return common.Shortcut{}, err } - hooks := convertHooks(definition.Hooks) + hooks := convertHooks(definition.Hooks, definition.PageOutput) hooks.NewArgs = definition.NewArgs return common.CompileErasedDefinition(common.ErasedDefinition{ Metadata: metadata, @@ -212,10 +212,10 @@ func convertOutput(output command.OutputDefinition) (common.OutputDefinition, er return converted, nil } -func convertHooks(hooks command.HostHooks) common.ErasedHooks { - dryRun := adaptDryRunHook(hooks.DryRun) +func convertHooks(hooks command.HostHooks, pageOutput bool) common.ErasedHooks { + dryRun := adaptDryRunHook(hooks.DryRun, pageOutput) if hooks.DryRunE != nil { - dryRun = adaptDryRunErrorHook(hooks.DryRunE) + dryRun = adaptDryRunErrorHook(hooks.DryRunE, pageOutput) } return common.ErasedHooks{ Normalize: adaptHook(hooks.Normalize), @@ -226,13 +226,13 @@ func convertHooks(hooks command.HostHooks) common.ErasedHooks { } } -func adaptDryRunErrorHook(hook func(context.Context, command.CommandContext, any) (*command.DryRun, error)) func(context.Context, common.CommandContext, any) (*common.DryRunAPI, error) { +func adaptDryRunErrorHook(hook func(context.Context, command.CommandContext, any) (*command.DryRun, error), pageOutput bool) func(context.Context, common.CommandContext, any) (*common.DryRunAPI, error) { return func(ctx context.Context, host common.CommandContext, args any) (*common.DryRunAPI, error) { preview, err := hook(ctx, publicContext(host), args) if err != nil { return nil, err } - return convertDryRun(preview) + return convertDryRun(preview, pageOutput) } } @@ -245,13 +245,13 @@ func adaptHook(hook func(context.Context, command.CommandContext, any) error) fu } } -func adaptDryRunHook(hook func(context.Context, command.CommandContext, any) *command.DryRun) func(context.Context, common.CommandContext, any) (*common.DryRunAPI, error) { +func adaptDryRunHook(hook func(context.Context, command.CommandContext, any) *command.DryRun, pageOutput bool) func(context.Context, common.CommandContext, any) (*common.DryRunAPI, error) { if hook == nil { return nil } return func(ctx context.Context, host common.CommandContext, args any) (*common.DryRunAPI, error) { preview := hook(ctx, publicContext(host), args) - return convertDryRun(preview) + return convertDryRun(preview, pageOutput) } } @@ -353,7 +353,12 @@ func derefQueryValue(value any) any { return reflected.Interface() } -func convertDryRun(preview *command.DryRun) (*common.DryRunAPI, error) { +// pageAllRepeatNote is the bounded-repeat explanation a Page[T] command's +// dry-run carries: only the first request is previewable, and the preview +// must not fabricate response-dependent page tokens. +const pageAllRepeatNote = "with --page-all, repeats with the returned page_token until exhaustion or --page-limit" + +func convertDryRun(preview *command.DryRun, pageOutput bool) (*common.DryRunAPI, error) { if preview == nil { return nil, nil } @@ -388,6 +393,13 @@ func convertDryRun(preview *command.DryRun) (*common.DryRunAPI, error) { converted.Desc(request.Description) } } + if pageOutput && len(view.Requests) > 0 { + note := pageAllRepeatNote + if last := view.Requests[len(view.Requests)-1].Description; last != "" { + note = last + "; " + note + } + converted.Desc(note) + } return converted, nil } diff --git a/internal/commandhost/compile_test.go b/internal/commandhost/compile_test.go index 6761fad09e..b042dd8a40 100644 --- a/internal/commandhost/compile_test.go +++ b/internal/commandhost/compile_test.go @@ -319,3 +319,47 @@ func TestExternalDryRunEPropagatesError(t *testing.T) { t.Fatalf("dry-run typed error = %#v", err) } } + +// A Page[T] command's dry-run can only show the first request; the preview +// must say the walk repeats instead of fabricating response-dependent +// page tokens. +func TestExternalPageDryRunNotesBoundedRepetition(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.Preview(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, "repeats with the returned page_token until exhaustion or --page-limit") { + t.Fatalf("dry-run output lacks the bounded-repeat note:\n%s", output) + } + if !strings.Contains(output, "list visible chats; ") { + t.Fatalf("business description was not preserved before the note:\n%s", output) + } +} From 1770b1705b4ab98a1e68e0b27fd8401fb6f1b014 Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:35:00 +0800 Subject: [PATCH 23/47] docs(command): generate domain constants with English comments The generator emitted Chinese titles while the rest of the public extension packages document in English. Switch the generator to the "en" service title and regenerate. The generator already rejects a domain missing either locale, so the switch keeps its own guard. --- extension/command/domains_gen.go | 40 +++++++++++++------------- extension/command/internal/gen/main.go | 2 +- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/extension/command/domains_gen.go b/extension/command/domains_gen.go index 4ed1e05d9b..913126e2dc 100644 --- a/extension/command/domains_gen.go +++ b/extension/command/domains_gen.go @@ -6,44 +6,44 @@ package command const ( - // DomainApplication 表示应用管理域。 + // DomainApplication is the Application domain. DomainApplication DomainName = "application" - // DomainApps 表示应用域。 + // DomainApps is the Apps domain. DomainApps DomainName = "apps" - // DomainBase 表示多维表格域。 + // DomainBase is the Base domain. DomainBase DomainName = "base" - // DomainCalendar 表示日历域。 + // DomainCalendar is the Calendar domain. DomainCalendar DomainName = "calendar" - // DomainContact 表示通讯录域。 + // DomainContact is the Contacts domain. DomainContact DomainName = "contact" - // DomainDocs 表示文档域。 + // DomainDocs is the Docs domain. DomainDocs DomainName = "docs" - // DomainDrive 表示云空间域。 + // DomainDrive is the Drive domain. DomainDrive DomainName = "drive" - // DomainEvent 表示事件订阅域。 + // DomainEvent is the Event domain. DomainEvent DomainName = "event" - // DomainIm 表示消息与群组域。 + // DomainIm is the Messenger domain. DomainIm DomainName = "im" - // DomainMail 表示邮箱域。 + // DomainMail is the Mail domain. DomainMail DomainName = "mail" - // DomainMarkdown 表示Markdown域。 + // DomainMarkdown is the Markdown domain. DomainMarkdown DomainName = "markdown" - // DomainMinutes 表示妙记域。 + // DomainMinutes is the Minutes domain. DomainMinutes DomainName = "minutes" - // DomainNote 表示会议纪要域。 + // DomainNote is the Note domain. DomainNote DomainName = "note" - // DomainOkr 表示OKR域。 + // DomainOkr is the OKR domain. DomainOkr DomainName = "okr" - // DomainSheets 表示电子表格域。 + // DomainSheets is the Sheets domain. DomainSheets DomainName = "sheets" - // DomainSlides 表示幻灯片域。 + // DomainSlides is the Slides domain. DomainSlides DomainName = "slides" - // DomainTask 表示任务域。 + // DomainTask is the Task domain. DomainTask DomainName = "task" - // DomainVc 表示视频会议域。 + // DomainVc is the VC domain. DomainVc DomainName = "vc" - // DomainWhiteboard 表示画板域。 + // DomainWhiteboard is the Whiteboard domain. DomainWhiteboard DomainName = "whiteboard" - // DomainWiki 表示知识库域。 + // DomainWiki is the Wiki domain. DomainWiki DomainName = "wiki" ) diff --git a/extension/command/internal/gen/main.go b/extension/command/internal/gen/main.go index b86dd0ee83..b5fb5aaf29 100644 --- a/extension/command/internal/gen/main.go +++ b/extension/command/internal/gen/main.go @@ -76,7 +76,7 @@ const ( `) for _, domain := range domains { identifier := domainIdentifier(domain) - fmt.Fprintf(&output, "\t// Domain%s 表示%s域。\n", identifier, registry.GetServiceTitle(domain, "zh")) + fmt.Fprintf(&output, "\t// Domain%s is the %s domain.\n", identifier, registry.GetServiceTitle(domain, "en")) fmt.Fprintf(&output, "\tDomain%s DomainName = %q\n", identifier, domain) } output.WriteString(")\n") From 292f1ae97eb2cefacfda91149aa7a58bbde7ddbb Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:35:07 +0800 Subject: [PATCH 24/47] docs(command): mark the host adapter read surface The Host* types, InspectCommand, InspectDomain and CloneSets exist for lark-cli's host adapter, not for business commands, but nothing said so at the symbols themselves. They cannot move to a subpackage: a Command holds its declaration unexported, so a sibling package has no way to reach it, and moving the wire types to internal/ would cycle back through CommandMetadata and CommandContext. Also correct HostPagination, which is not adapter-only -- ContextOptions and commandtest both carry it. --- extension/command/host.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/extension/command/host.go b/extension/command/host.go index 19655583d0..bca82fff26 100644 --- a/extension/command/host.go +++ b/extension/command/host.go @@ -39,7 +39,9 @@ type HostResult struct { Pagination *HostPagination } -// HostPagination is the copied pagination metadata consumed by lark-cli's host adapter. +// 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 @@ -170,7 +172,8 @@ func InspectDomain(domain Domain) HostDomain { return HostDomain{Name: domain.name, IsNew: domain.kind == domainNew} } -// CloneSets copies set slices and immutable command declarations for BuildOption capture. +// 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 { From 3cd9c9bafdedc7c5f278552a9aef77c46b8b53ce Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:35:14 +0800 Subject: [PATCH 25/47] refactor(command): type CommandMetadata.Service as DomainName A set already declares its domain through ExtendDomain(DomainIm), yet every command repeated the same domain as a bare string that only the host compiler checked. Typing the field points authors at the generated enum and forces an explicit conversion when the value comes from a string variable. This does not make a mistyped literal a compile error -- an untyped constant still converts to DomainName -- so the mismatch check in CompileSets stays the actual net. The example and the wrapper fixture now declare command.DomainIm. The chat-brief example was also not gofmt-clean, which the CI format gate would have caught. --- extension/command/definition.go | 8 +++++++- extension/command/examples/chat-brief/main.go | 6 +++--- extension/command/testdata/wrapper/main.go | 2 +- internal/commandhost/compile.go | 6 +++--- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/extension/command/definition.go b/extension/command/definition.go index d76e1d0e1f..e7b5d3dfaf 100644 --- a/extension/command/definition.go +++ b/extension/command/definition.go @@ -2,6 +2,12 @@ // SPDX-License-Identifier: MIT // Package command defines the public contract for build-time command extensions. +// +// Business command authors use Definition, Define, and the CommandContext +// helpers. 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 ( @@ -22,7 +28,7 @@ type Definition[Args any, Data any] struct { // CommandMetadata describes the command name, help, risk, and authorization. type CommandMetadata struct { - Service string + Service DomainName Command string Description string Risk Risk diff --git a/extension/command/examples/chat-brief/main.go b/extension/command/examples/chat-brief/main.go index fb2b99ec91..acd22c4a88 100644 --- a/extension/command/examples/chat-brief/main.go +++ b/extension/command/examples/chat-brief/main.go @@ -56,13 +56,13 @@ type chatWire struct { // 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)). + return command.GET("/open-apis/im/v1/chats/"+command.PathSegment(args.ChatID)). Set("user_id_type", args.IDType) } var chatBrief = command.Define(command.Definition[chatBriefArgs, chatBriefData]{ Metadata: command.CommandMetadata{ - Service: "im", + Service: command.DomainIm, Command: "+chat-brief", Description: "Get a concise chat projection", Risk: command.RiskRead, @@ -123,7 +123,7 @@ func chatListRequest(args *chatListArgs) command.Request { // framework pagination flags; the Args stay free of paging fields. var chatList = command.Define(command.Definition[chatListArgs, command.Page[chatItem]]{ Metadata: command.CommandMetadata{ - Service: "im", + Service: command.DomainIm, Command: "+chat-brief-list", Description: "List visible chats", Risk: command.RiskRead, diff --git a/extension/command/testdata/wrapper/main.go b/extension/command/testdata/wrapper/main.go index 14c8f282a6..143325685e 100644 --- a/extension/command/testdata/wrapper/main.go +++ b/extension/command/testdata/wrapper/main.go @@ -25,7 +25,7 @@ type readData struct { var readCommand = command.Define(command.Definition[readArgs, readData]{ Metadata: command.CommandMetadata{ - Service: "im", Command: "+wrapper-read", Description: "Read one wrapper resource", Risk: command.RiskRead, + 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"}}, }}, diff --git a/internal/commandhost/compile.go b/internal/commandhost/compile.go index 8deef7862c..01ca38a2b4 100644 --- a/internal/commandhost/compile.go +++ b/internal/commandhost/compile.go @@ -49,11 +49,11 @@ func CompileSets(sets []command.Set) ([]common.Shortcut, error) { } for commandIndex, declaration := range set.Commands { definition := command.InspectCommand(declaration) - if definition.Metadata.Service != domain.Name { + 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 := definition.Metadata.Service + " " + definition.Metadata.Command + 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) @@ -137,7 +137,7 @@ func convertMetadata(metadata command.CommandMetadata) common.CommandMetadata { identityOrder[index] = common.Identity(identity) } return common.CommandMetadata{ - Service: metadata.Service, Command: metadata.Command, Description: metadata.Description, + Service: string(metadata.Service), Command: metadata.Command, Description: metadata.Description, Risk: common.Risk(metadata.Risk), Hidden: metadata.Hidden, Tips: append([]string(nil), metadata.Tips...), Authorization: common.AuthorizationDefinition{Identities: identities, IdentityOrder: identityOrder}, } From 1d450cdcb6ff545deeb5f2330fcb351fe5b58df5 Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:45:49 +0800 Subject: [PATCH 26/47] refactor(command): extract the command-set assembly steps buildInternalWithConfig is an orchestrator, and the business command sets were compiled inline inside it. Move that step to resolveShortcutSnapshot so the entry point reads as one call and the built-in/external merge has a name. newCommand carried six near-identical blocks that each nil-checked a hook and wrapped it in the same type assertion. Split them into one binder per hook shape; Normalize and Validate now share bindArgsHook since their signatures match. The behaviour is unchanged: an undeclared hook still erases to nil, and an empty renderer map still yields nil. --- cmd/build.go | 19 ++++-- extension/command/host.go | 126 +++++++++++++++++++++++--------------- 2 files changed, 90 insertions(+), 55 deletions(-) diff --git a/cmd/build.go b/cmd/build.go index d7e2f40cec..aa325093f7 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -38,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" ) @@ -205,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. @@ -212,11 +225,7 @@ func buildInternalWithConfig(ctx context.Context, inv cmdutil.InvocationContext, if cfg == nil { cfg = &buildConfig{} } - externalCommands, commandSetErr := commandhost.CompileSets(cfg.commandSets) - registeredShortcuts := shortcuts.AllShortcuts() - if commandSetErr == nil { - registeredShortcuts, commandSetErr = shortcuts.AllShortcutsWithExternal(externalCommands) - } + 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 diff --git a/extension/command/host.go b/extension/command/host.go index bca82fff26..e7d979db70 100644 --- a/extension/command/host.go +++ b/extension/command/host.go @@ -67,76 +67,102 @@ type hostDefinition struct { } func newCommand[Args any, Data any](definition Definition[Args, Data]) Command { - host := hostDefinition{ + 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), + DryRunE: bindDryRunErrorHook(hooks.DryRunE), + Execute: bindExecuteHook(hooks.Execute), + Renderers: bindRenderers(hooks.Renderers), } - if definition.Hooks.Normalize != nil { - host.hooks.Normalize = func(ctx context.Context, command CommandContext, args any) error { - typed, ok := args.(*Args) - if !ok { - return InternalErrorf("Normalize received %T, expected %T", args, (*Args)(nil)) - } - return definition.Hooks.Normalize(ctx, command, typed) - } +} + +func bindArgsHook[Args any](hook func(context.Context, CommandContext, *Args) error, name string) func(context.Context, CommandContext, any) error { + if hook == nil { + return nil } - if definition.Hooks.Validate != nil { - host.hooks.Validate = func(ctx context.Context, command CommandContext, args any) error { - typed, ok := args.(*Args) - if !ok { - return InternalErrorf("Validate received %T, expected %T", args, (*Args)(nil)) - } - return definition.Hooks.Validate(ctx, command, typed) + 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) } - if definition.Hooks.DryRun != nil { - host.hooks.DryRun = func(ctx context.Context, command CommandContext, args any) *DryRun { - typed, ok := args.(*Args) - if !ok { - return nil - } - return definition.Hooks.DryRun(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) } - if definition.Hooks.DryRunE != nil { - host.hooks.DryRunE = func(ctx context.Context, command CommandContext, args any) (*DryRun, error) { - typed, ok := args.(*Args) - if !ok { - return nil, InternalErrorf("DryRunE received %T, expected %T", args, (*Args)(nil)) - } - return definition.Hooks.DryRunE(ctx, command, typed) +} + +func bindDryRunErrorHook[Args any](hook func(context.Context, CommandContext, *Args) (*DryRun, error)) func(context.Context, CommandContext, any) (*DryRun, error) { + if hook == nil { + return nil + } + return func(ctx context.Context, command CommandContext, args any) (*DryRun, error) { + typed, ok := args.(*Args) + if !ok { + return nil, InternalErrorf("DryRunE received %T, expected %T", args, (*Args)(nil)) } + return hook(ctx, command, typed) } - if definition.Hooks.Execute != nil { - host.hooks.Execute = 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 := definition.Hooks.Execute(ctx, command, typed) - return hostResult(result), err +} + +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 + } +} + +func bindRenderers[Data any](renderers map[string]Renderer[Data]) map[string]func(io.Writer, any) error { + if len(renderers) == 0 { + return nil } - if len(definition.Hooks.Renderers) > 0 { - host.hooks.Renderers = make(map[string]func(io.Writer, any) error, len(definition.Hooks.Renderers)) - for name, renderer := range definition.Hooks.Renderers { - typedRenderer := renderer - host.hooks.Renderers[name] = 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 typedRenderer(writer, typed) + bound := make(map[string]func(io.Writer, any) error, len(renderers)) + for name, renderer := range renderers { + bound[name] = 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) } } - return Command{definition: host} + return bound } func hostResult[Data any](result Result[Data]) HostResult { From f6d0583d087a8b37d416a019eacc63e706290027 Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:35:16 +0800 Subject: [PATCH 27/47] perf(shortcuts): stop re-cloning an already-isolated snapshot AllShortcuts deep-copies because a Shortcut carries slice fields whose backing arrays a shallow copy would share: an external distribution mutating registered[0].Flags[0] would corrupt the process-global list. That copy is worth its ~165us over 500+ shortcuts. Paying it four times per startup is not. auth, schema and the mount path each cloned the snapshot again, but they receive it from AllShortcutsWithExternal with no third-party code in between, and nothing in this repository mutates a shortcut element -- mountDeclarative takes a value receiver and only replaces slice headers. Drop those three copies and document the boundary on AllShortcuts so the next reader does not reintroduce them. Startup drops from four full clones to one. Benchmarks pin the remaining cost so a regression points at a new clone rather than at growth in the shortcut set. --- cmd/auth/login.go | 2 +- cmd/schema/schema.go | 1 - shortcuts/register.go | 11 +++++++-- shortcuts/register_snapshot_bench_test.go | 30 +++++++++++++++++++++++ 4 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 shortcuts/register_snapshot_bench_test.go diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 7773b0b701..449de8d14b 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -48,7 +48,7 @@ func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra. } func newCmdAuthLoginWithShortcuts(f *cmdutil.Factory, runF func(*LoginOptions) error, registered []common.Shortcut) *cobra.Command { - opts := &LoginOptions{Factory: f, shortcuts: common.CloneShortcuts(registered)} + opts := &LoginOptions{Factory: f, shortcuts: registered} cmd := &cobra.Command{ Use: "login", diff --git a/cmd/schema/schema.go b/cmd/schema/schema.go index 128fbcacfb..12aec19999 100644 --- a/cmd/schema/schema.go +++ b/cmd/schema/schema.go @@ -59,7 +59,6 @@ func NewCmdSchemaWithVisibilityAndShortcuts( runF func(*SchemaOptions) error, ) *cobra.Command { opts := &SchemaOptions{Factory: f} - registered = common.CloneShortcuts(registered) cmd := &cobra.Command{ Use: "schema [path | service resource method]", diff --git a/shortcuts/register.go b/shortcuts/register.go index 59c10eb80a..2d1aabd36a 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -95,7 +95,15 @@ func init() { allShortcuts = append(allShortcuts, okr.Shortcuts()...) } -// AllShortcuts returns a copy of all registered shortcuts (for dump-shortcuts). +// AllShortcuts returns an isolated copy of all registered shortcuts. +// +// This is the isolation boundary, and the only place that needs to deep-copy: +// the package global is filled once by init and never written again, but a +// Shortcut carries slice fields whose backing arrays a shallow copy would still +// share, so an external distribution mutating an element (registered[0].Flags[0]) +// would corrupt the global for the whole process. Callers inside this repository +// receive an already-isolated snapshot and must not clone it again -- the copy +// costs ~165us over 500+ shortcuts, which lands on every CLI startup. // //go:noinline func AllShortcuts() []common.Shortcut { @@ -131,7 +139,6 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f // RegisterShortcutSnapshotWithContext mounts one build-local shortcut snapshot. func RegisterShortcutSnapshotWithContext(ctx context.Context, program *cobra.Command, f *cmdutil.Factory, registered []common.Shortcut) { - registered = common.CloneShortcuts(registered) // Factory.Config may be nil in tests that pass a zero-value factory. var brand core.LarkBrand if f != nil && f.Config != nil { diff --git a/shortcuts/register_snapshot_bench_test.go b/shortcuts/register_snapshot_bench_test.go new file mode 100644 index 0000000000..7efcf0145c --- /dev/null +++ b/shortcuts/register_snapshot_bench_test.go @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package shortcuts + +import "testing" + +// BenchmarkAllShortcuts pins the cost of the one deep copy every CLI startup +// pays. AllShortcuts is the isolation boundary for external distributions, so +// this allocation is deliberate -- but callers inside this repository receive +// an already-isolated snapshot, and re-cloning it multiplies this number by +// the number of consumers. If this benchmark regresses, look for a new clone +// on the build path before assuming the shortcut set simply grew. +func BenchmarkAllShortcuts(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = AllShortcuts() + } +} + +// BenchmarkAllShortcutsWithExternal covers the snapshot the command tree is +// actually built from, so the external-command path is measured too. +func BenchmarkAllShortcutsWithExternal(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + if _, err := AllShortcutsWithExternal(nil); err != nil { + b.Fatal(err) + } + } +} From a0d75d57e8e9a2df761d58fa62f5f7becd4c3301 Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:45:42 +0800 Subject: [PATCH 28/47] fix(command): align the commandtest page bound and escape wrapper paths Two review findings, both of which let a business command pass its tests and then misbehave in production. The commandtest recorder walked 1000 pages for a complete-set collection while the host adapter stops at 100, so a command tested against 300 pages of fixtures would fail its first real --page-all run with PaginationLimitError. The bound now lives in internal/pagination, which both sides already import, and the hard-limit test scripts itself from that constant instead of restating 1000 -- the literal was what let the two drift apart. The testdata wrapper concatenated args.ID straight into the request path, contradicting PathSegment's own documented rule and the chat-brief example. ValidateRequestView does not cover this: "abc/other-users-file" cleans to itself, so an unescaped separator silently retargets the request. Since testdata is what an integrator copies first, route both call sites through one readRequest helper, mirroring chat-brief. The e2e assertion could not have caught it either -- PathSegment("chat_1") is "chat_1", so the check passed with or without the call. It now sends "chat/1" and asserts %2F reaches the wire; removing PathSegment fails it. --- .../commandtest/business_commands_test.go | 8 ++++++-- extension/command/commandtest/commandtest.go | 4 +++- extension/command/testdata/wrapper/main.go | 10 ++++++++-- extension/command/wrapper_e2e_test.go | 11 +++++++++++ internal/pagination/walk.go | 11 +++++++++++ shortcuts/common/typed_external_pagination.go | 9 +-------- .../common/typed_external_pagination_test.go | 18 +++++++++++------- 7 files changed, 51 insertions(+), 20 deletions(-) diff --git a/extension/command/commandtest/business_commands_test.go b/extension/command/commandtest/business_commands_test.go index 74fb8cf204..d324234885 100644 --- a/extension/command/commandtest/business_commands_test.go +++ b/extension/command/commandtest/business_commands_test.go @@ -15,6 +15,7 @@ import ( "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 { @@ -357,7 +358,10 @@ func TestCollectAllPagesRejectsInvalidCursors(t *testing.T) { } func TestCollectAllPagesHardLimitPreventsFollowingWrite(t *testing.T) { - responses := make([]commandtest.Response, 1000) + // 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), @@ -391,7 +395,7 @@ func TestCollectAllPagesHardLimitPreventsFollowingWrite(t *testing.T) { if !errors.As(err, &internal) || internal.Subtype != errs.SubtypeQuotaExceeded { t.Fatalf("hard-limit typed error = %#v", err) } - if requests := recorder.Requests(); len(requests) != 1000 || requests[len(requests)-1].Method != "GET" { + 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() diff --git a/extension/command/commandtest/commandtest.go b/extension/command/commandtest/commandtest.go index 7b361ca510..85176fe951 100644 --- a/extension/command/commandtest/commandtest.go +++ b/extension/command/commandtest/commandtest.go @@ -337,7 +337,9 @@ func (r *Recorder) collectPages(ctx context.Context, request command.Request, al options := r.pagination r.mu.Unlock() if all { - options = command.PaginationOptions{All: true, MaxPages: 1000} + // 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 } diff --git a/extension/command/testdata/wrapper/main.go b/extension/command/testdata/wrapper/main.go index 143325685e..b167097a21 100644 --- a/extension/command/testdata/wrapper/main.go +++ b/extension/command/testdata/wrapper/main.go @@ -23,6 +23,12 @@ type readData struct { ID string `json:"id" schema:"required" doc:"resource identifier"` } +// readRequest is shared by DryRunE 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, @@ -32,10 +38,10 @@ var readCommand = command.Define(command.Definition[readArgs, readData]{ }, Hooks: command.Hooks[readArgs, readData]{ DryRunE: func(_ context.Context, _ command.CommandContext, args *readArgs) (*command.DryRun, error) { - return command.Preview(command.GET("/open-apis/im/v1/chats/" + args.ID)), nil + return command.Preview(readRequest(args)), nil }, Execute: func(ctx context.Context, commandContext command.CommandContext, args *readArgs) (command.Result[readData], error) { - data, err := command.CallJSON[readData](ctx, commandContext, command.GET("/open-apis/im/v1/chats/"+args.ID)) + data, err := command.CallJSON[readData](ctx, commandContext, readRequest(args)) if err != nil { return command.Result[readData]{}, err } diff --git a/extension/command/wrapper_e2e_test.go b/extension/command/wrapper_e2e_test.go index e5d763fac4..5774935c5e 100644 --- a/extension/command/wrapper_e2e_test.go +++ b/extension/command/wrapper_e2e_test.go @@ -50,6 +50,17 @@ func TestExternalWrapperCommandSurface(t *testing.T) { if !strings.Contains(dryRun, `"dry_run": true`) || !strings.Contains(dryRun, "/open-apis/im/v1/chats/chat_1") { t.Fatalf("wrapper dry-run = %s", dryRun) } + // 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) + } schema := run("schema", "im", "+wrapper-read") if !strings.Contains(schema, `"name": "im +wrapper-read"`) || !strings.Contains(schema, `"outputSchema"`) { t.Fatalf("wrapper schema = %s", schema) diff --git a/internal/pagination/walk.go b/internal/pagination/walk.go index 5bf9d0491b..39c074664c 100644 --- a/internal/pagination/walk.go +++ b/internal/pagination/walk.go @@ -11,6 +11,17 @@ import ( "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 diff --git a/shortcuts/common/typed_external_pagination.go b/shortcuts/common/typed_external_pagination.go index eb5796106e..a9ecac4408 100644 --- a/shortcuts/common/typed_external_pagination.go +++ b/shortcuts/common/typed_external_pagination.go @@ -53,16 +53,9 @@ func CollectCommandPages(ctx context.Context, command CommandContext, request Pa return collection, nil } -// collectAllHardPageBound caps CollectAllPages workflows. It is deliberately -// tighter than the user-facing --page-limit maximum (1000): a complete-set -// collection holds every page in memory before the workflow'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). -const collectAllHardPageBound = 100 - func commandPagePolicy(command CommandContext, all bool) (paginationPolicy, error) { if all { - return paginationPolicy{maxPages: collectAllHardPageBound}, nil + return paginationPolicy{maxPages: internalpagination.CollectAllHardPageBound}, nil } options, err := command.PaginationOptions() if err != nil { diff --git a/shortcuts/common/typed_external_pagination_test.go b/shortcuts/common/typed_external_pagination_test.go index 4f50a9c72c..34f2424747 100644 --- a/shortcuts/common/typed_external_pagination_test.go +++ b/shortcuts/common/typed_external_pagination_test.go @@ -3,7 +3,11 @@ package common -import "testing" +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 @@ -13,14 +17,14 @@ func TestCollectAllPolicyUsesWorkflowHardBound(t *testing.T) { if err != nil { t.Fatal(err) } - if policy.maxPages != collectAllHardPageBound { - t.Fatalf("collect-all maxPages = %d, want %d", policy.maxPages, collectAllHardPageBound) + if policy.maxPages != internalpagination.CollectAllHardPageBound { + t.Fatalf("collect-all maxPages = %d, want %d", policy.maxPages, internalpagination.CollectAllHardPageBound) } - if collectAllHardPageBound >= pageLimitMaximum { + if internalpagination.CollectAllHardPageBound >= pageLimitMaximum { t.Fatalf("workflow bound (%d) must stay below the user-facing --page-limit maximum (%d)", - collectAllHardPageBound, pageLimitMaximum) + internalpagination.CollectAllHardPageBound, pageLimitMaximum) } - if collectAllHardPageBound != 100 { - t.Fatalf("workflow bound = %d, want the Phase 0 value 100", collectAllHardPageBound) + if internalpagination.CollectAllHardPageBound != 100 { + t.Fatalf("workflow bound = %d, want the Phase 0 value 100", internalpagination.CollectAllHardPageBound) } } From 30ee9b220bf86f12b75fc3484da9dce159a6ed7f Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:26:32 +0800 Subject: [PATCH 29/47] fix(command): deny network to the pre-confirmation hooks and four review findings Normalize and Validate run before the high-risk confirmation gate, and both received the full CommandContext, so a high-risk business command could POST or DELETE from Validate and leave remote side effects behind before the user was ever asked to confirm. Moving the gate earlier would contradict the documented hook order and would also make --dry-run require --yes. The design already forbids this from the other side -- Validate is specified as parameter checking that issues no request -- so enforce that instead: Normalize and Validate get a context whose CallJSON and CollectPages refuse, while PreflightScopes stays available. The guard sits in CommandContext rather than in the wiring, so a future adapter that wires the callbacks anyway still cannot reach the API. commandtest mirrors it, otherwise a command would pass its tests and fail only in production. Page.Items now starts non-nil. It is declared required;nonnullable, but a zero-item collection encoded as {"items":null}, which a caller generating types from the published schema would reject. NewCmdAuthWithRecovery and NewCmdSchemaWithVisibility are restored as wrappers. Both were dropped for shortcut-aware variants, and both are reachable from outside this module: CommandVisibility is an ordinary exported func type, and *recovery.Projector cannot be named by an outside caller but can be passed as nil. A signature test now pins them. The path-traversal fixture said "../../secret", which the deterministic gate rejects as a generic credential assignment -- the reason CI is currently red. The filename carries no meaning; it is now "../../outside". --- cmd/auth/auth.go | 9 ++ cmd/exported_constructors_test.go | 35 ++++++++ cmd/schema/schema.go | 8 ++ extension/command/command_test.go | 12 +-- extension/command/commandtest/commandtest.go | 17 +++- extension/command/context.go | 17 +++- extension/command/pagination.go | 8 +- extension/command/pagination_json_test.go | 53 ++++++++++++ internal/commandhost/compile.go | 15 +++- internal/commandhost/input_stage_test.go | 90 ++++++++++++++++++++ 10 files changed, 252 insertions(+), 12 deletions(-) create mode 100644 cmd/exported_constructors_test.go create mode 100644 extension/command/pagination_json_test.go create mode 100644 internal/commandhost/input_stage_test.go diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go index 8998d986e2..01ad9d8635 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -28,6 +28,15 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command { return newCmdAuth(f, nil, shortcuts.AllShortcuts()) } +// NewCmdAuthWithRecovery creates the auth command with a build-local recovery +// 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 NewCmdAuthWithRecoveryAndShortcuts(f, projector, shortcuts.AllShortcuts()) +} + // 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) diff --git a/cmd/exported_constructors_test.go b/cmd/exported_constructors_test.go new file mode 100644 index 0000000000..3c4ebba346 --- /dev/null +++ b/cmd/exported_constructors_test.go @@ -0,0 +1,35 @@ +// 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/larksuite/cli/internal/recovery" + "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. +// +// Written as assignments rather than calls so the assertion is the signature +// itself: a later parameter addition fails here before it reaches anyone +// downstream. +func TestPreExistingExportedConstructorsKeepTheirSignatures(t *testing.T) { + var ( + _ func(*cmdutil.Factory) *cobra.Command = auth.NewCmdAuth + _ func(*cmdutil.Factory, *recovery.Projector) *cobra.Command = auth.NewCmdAuthWithRecovery + _ func(*cmdutil.Factory, func(*auth.LoginOptions) error) *cobra.Command = auth.NewCmdAuthLogin + _ func(*cmdutil.Factory, func(*schema.SchemaOptions) error) *cobra.Command = schema.NewCmdSchema + ) + var _ func(*cmdutil.Factory, schema.CommandVisibility, func(*schema.SchemaOptions) error) *cobra.Command = schema.NewCmdSchemaWithVisibility +} diff --git a/cmd/schema/schema.go b/cmd/schema/schema.go index 12aec19999..564152c37a 100644 --- a/cmd/schema/schema.go +++ b/cmd/schema/schema.go @@ -51,6 +51,14 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co return NewCmdSchemaWithVisibilityAndShortcuts(f, nil, shortcuts.AllShortcuts(), runF) } +// NewCmdSchemaWithVisibility creates the schema command projected through one +// visibility predicate, resolving shortcuts from the registered set. Retained at +// its established signature: CommandVisibility is an ordinary exported function +// type, so callers outside this module can and do construct one. +func NewCmdSchemaWithVisibility(f *cmdutil.Factory, visibility CommandVisibility, runF func(*SchemaOptions) error) *cobra.Command { + return NewCmdSchemaWithVisibilityAndShortcuts(f, visibility, shortcuts.AllShortcuts(), runF) +} + // NewCmdSchemaWithVisibilityAndShortcuts creates schema commands from one build-local shortcut snapshot. func NewCmdSchemaWithVisibilityAndShortcuts( f *cmdutil.Factory, diff --git a/extension/command/command_test.go b/extension/command/command_test.go index ce8b9ef9e6..a4d6e8aa3f 100644 --- a/extension/command/command_test.go +++ b/extension/command/command_test.go @@ -418,12 +418,12 @@ func TestPublicPackageHasNoForbiddenImports(t *testing.T) { // 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 - "../../secret": "..%2F..%2Fsecret", - "a?x=1": "a%3Fx=1", - "a#frag": "a%23frag", + "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 { diff --git a/extension/command/commandtest/commandtest.go b/extension/command/commandtest/commandtest.go index 85176fe951..0cad0f8eb2 100644 --- a/extension/command/commandtest/commandtest.go +++ b/extension/command/commandtest/commandtest.go @@ -78,6 +78,18 @@ func (r *Recorder) DryRunContext(identity command.Identity) command.CommandConte 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, @@ -99,13 +111,14 @@ func Execute[Args any, Data any](ctx context.Context, recorder *Recorder, identi var execution Execution[Data] declaration := command.InspectCommand(command.Define(definition)) commandContext := recorder.CommandContext(identity) + inputContext := recorder.InputStageContext(identity) if declaration.Hooks.Normalize != nil { - if err := declaration.Hooks.Normalize(ctx, commandContext, args); err != 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, commandContext, args); err != nil { + if err := declaration.Hooks.Validate(ctx, inputContext, args); err != nil { return execution, err } } diff --git a/extension/command/context.go b/extension/command/context.go index f7adfbf938..c75c0e7919 100644 --- a/extension/command/context.go +++ b/extension/command/context.go @@ -14,6 +14,7 @@ import ( type CommandContext struct { identity Identity dryRun bool + inputStage bool callJSON func(context.Context, Request) (map[string]any, error) preflightScopes func(...string) error collectPages func(context.Context, Request, bool) ([]map[string]any, HostPagination, error) @@ -30,8 +31,16 @@ type PaginationOptions struct { // ContextOptions supplies safe callbacks when a host creates a CommandContext. // It is intended for the lark-cli host adapter and commandtest. type ContextOptions struct { - Identity Identity - DryRun bool + 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) PreflightScopes func(...string) error CollectPages func(context.Context, Request, bool) ([]map[string]any, HostPagination, error) @@ -42,6 +51,7 @@ func NewCommandContext(options ContextOptions) CommandContext { return CommandContext{ identity: options.Identity, dryRun: options.DryRun, + inputStage: options.InputStage, callJSON: options.CallJSON, preflightScopes: options.PreflightScopes, collectPages: options.CollectPages, @@ -57,6 +67,9 @@ func CallJSON[T any](ctx context.Context, command CommandContext, request Reques 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") } diff --git a/extension/command/pagination.go b/extension/command/pagination.go index 21daceb7b7..da302f6c49 100644 --- a/extension/command/pagination.go +++ b/extension/command/pagination.go @@ -110,10 +110,16 @@ func CollectAllPages[T any](ctx context.Context, command CommandContext, request } func collectPages[T any](ctx context.Context, command CommandContext, request Request, all bool) (Page[T], error) { - result := Page[T]{meta: &paginationMeta{}} + // 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") } 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/internal/commandhost/compile.go b/internal/commandhost/compile.go index 01ca38a2b4..a5708bf06a 100644 --- a/internal/commandhost/compile.go +++ b/internal/commandhost/compile.go @@ -241,7 +241,7 @@ func adaptHook(hook func(context.Context, command.CommandContext, any) error) fu return nil } return func(ctx context.Context, host common.CommandContext, args any) error { - return hook(ctx, publicContext(host), args) + return hook(ctx, inputStageContext(host), args) } } @@ -283,6 +283,19 @@ func cloneRenderers(renderers map[string]func(io.Writer, any) error) map[string] 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 common.CommandContext) command.CommandContext { + return command.NewCommandContext(command.ContextOptions{ + Identity: command.Identity(host.Identity()), + DryRun: host.IsDryRun(), + InputStage: true, + PreflightScopes: host.RequireConditionalScopes, + }) +} + func publicContext(host common.CommandContext) command.CommandContext { return command.NewCommandContext(command.ContextOptions{ Identity: command.Identity(host.Identity()), diff --git a/internal/commandhost/input_stage_test.go b/internal/commandhost/input_stage_test.go new file mode 100644 index 0000000000..6562930ad3 --- /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/shortcuts/common" +) + +// 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 { + common.CommandContext +} + +func (stubHost) Identity() common.Identity { return common.Identity("user") } +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) + } +} From def56b73c4f1264807d7f9a2bce11263582006a5 Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:13:49 +0800 Subject: [PATCH 30/47] test(cmd): exercise the retained constructors instead of naming them The compatibility wrappers restored for outside callers are unreachable from inside this repository by construction, so the incremental dead-code gate rejected them. A signature-only assertion did not help: taking a function value and discarding it leaves the body unreachable, and it proved nothing about whether the wrapper still builds a working command. Call each one and assert the command it returns. NewCmdAuthWithRecovery is called with a nil projector, which is the exact call an outside module can make and the reason the wrapper has to keep compiling. Verified with the same deadcode version CI runs: neither function is reported, and no other function in this branch's files is either. --- cmd/exported_constructors_test.go | 43 ++++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/cmd/exported_constructors_test.go b/cmd/exported_constructors_test.go index 3c4ebba346..31952fef15 100644 --- a/cmd/exported_constructors_test.go +++ b/cmd/exported_constructors_test.go @@ -9,7 +9,6 @@ import ( "github.com/larksuite/cli/cmd/auth" "github.com/larksuite/cli/cmd/schema" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/recovery" "github.com/spf13/cobra" ) @@ -21,15 +20,35 @@ import ( // *recovery.Projector is internal -- an outside caller cannot name the type but // can still pass nil for it. // -// Written as assignments rather than calls so the assertion is the signature -// itself: a later parameter addition fails here before it reaches anyone -// downstream. -func TestPreExistingExportedConstructorsKeepTheirSignatures(t *testing.T) { - var ( - _ func(*cmdutil.Factory) *cobra.Command = auth.NewCmdAuth - _ func(*cmdutil.Factory, *recovery.Projector) *cobra.Command = auth.NewCmdAuthWithRecovery - _ func(*cmdutil.Factory, func(*auth.LoginOptions) error) *cobra.Command = auth.NewCmdAuthLogin - _ func(*cmdutil.Factory, func(*schema.SchemaOptions) error) *cobra.Command = schema.NewCmdSchema - ) - var _ func(*cmdutil.Factory, schema.CommandVisibility, func(*schema.SchemaOptions) error) *cobra.Command = schema.NewCmdSchemaWithVisibility +// 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]") } From 74d49e8999f635e26d9bf0e73bb5ce09acb1d5d0 Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:19:46 +0800 Subject: [PATCH 31/47] docs(command): document the hook contract and pin dry-run note idempotency Hooks is the first type a business author reads and carried no field documentation, so the rules lived only in the design doc: which of DryRun and DryRunE to set, that setting both fails to compile, that Execute owns the API call and must not write stdout, and that Normalize and Validate run before the confirmation gate and therefore get no network. The choice between DryRun and DryRunE is not old-versus-new -- neither is legacy. It follows from whether building the preview can fail, which is now what the field docs say. Also pin the dry-run note as idempotent. convertDryRun writes the bounded-repeat note into the projection it builds, never back into the hook's *DryRun, and DryRunAPI.Desc assigns rather than appends, so a hook that caches and returns the same preview cannot accumulate the note. Both properties were true and neither was tested. --- extension/command/definition.go | 35 ++++++++++-- internal/commandhost/dryrun_note_test.go | 69 ++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 internal/commandhost/dryrun_note_test.go diff --git a/extension/command/definition.go b/extension/command/definition.go index e7b5d3dfaf..e5200bcb18 100644 --- a/extension/command/definition.go +++ b/extension/command/definition.go @@ -216,11 +216,38 @@ const ( // 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 func(context.Context, CommandContext, *Args) error - DryRun func(context.Context, CommandContext, *Args) *DryRun - DryRunE func(context.Context, CommandContext, *Args) (*DryRun, error) - Execute func(context.Context, CommandContext, *Args) (Result[Data], error) + + // 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. Set this or DryRunE, never both -- compiling + // a command that sets both fails. + // + // Choose by whether building the preview can fail: DryRun when the requests + // follow from Args alone, DryRunE when constructing them may error. + DryRun func(context.Context, CommandContext, *Args) *DryRun + + // DryRunE is DryRun with an error channel. See DryRun for the choice. + DryRunE func(context.Context, CommandContext, *Args) (*DryRun, error) + + // Execute carries the business logic and returns Success or Partial. 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) + + // Renderers customize --format pretty, keyed by format name. table, CSV and + // NDJSON are rendered by the framework and need no entry here. Renderers map[string]Renderer[Data] } diff --git a/internal/commandhost/dryrun_note_test.go b/internal/commandhost/dryrun_note_test.go new file mode 100644 index 0000000000..b880744109 --- /dev/null +++ b/internal/commandhost/dryrun_note_test.go @@ -0,0 +1,69 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package commandhost + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/extension/command" +) + +// convertDryRun writes the bounded-repeat note into the projection it builds, +// never back into the DryRun the business hook returned. A hook may cache and +// return the same *DryRun on every invocation, so converting twice must not +// accumulate the note: the preview would then claim the command pages more +// times than it does. +func TestConvertDryRunNoteDoesNotAccumulateAcrossCalls(t *testing.T) { + preview := command.Preview(command.GET("/open-apis/im/v1/chats")) + + first := convertedDryRunJSON(t, preview) + second := convertedDryRunJSON(t, preview) + + if first != second { + t.Fatalf("second conversion differs:\nfirst: %s\nsecond: %s", first, second) + } + if got := strings.Count(second, pageAllRepeatNote); got != 1 { + t.Fatalf("repeat note appears %d times, want 1:\n%s", got, second) + } +} + +// The same holds when the hook supplied its own description: the note is joined +// onto a copy, so the business description must survive unchanged and must not +// grow a second note on the next conversion. +func TestConvertDryRunKeepsBusinessDescriptionIntact(t *testing.T) { + preview := command.Preview(command.GET("/open-apis/im/v1/chats").Desc("lists the first page")) + + first := convertedDryRunJSON(t, preview) + second := convertedDryRunJSON(t, preview) + + if first != second { + t.Fatalf("second conversion differs:\nfirst: %s\nsecond: %s", first, second) + } + if got := strings.Count(second, "lists the first page"); got != 1 { + t.Fatalf("business description appears %d times, want 1:\n%s", got, second) + } + + view := command.InspectDryRun(preview) + if got := view.Requests[len(view.Requests)-1].Description; got != "lists the first page" { + t.Fatalf("business description was rewritten to %q", got) + } +} + +func convertedDryRunJSON(t *testing.T, preview *command.DryRun) string { + t.Helper() + converted, err := convertDryRun(preview, true) + if err != nil { + t.Fatal(err) + } + if converted == nil { + t.Fatal("convertDryRun returned nil") + } + encoded, err := json.Marshal(converted) + if err != nil { + t.Fatal(err) + } + return string(encoded) +} From c962b0bf5d54d1fc96b76038bc95e1d5cc47756b Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:02:55 +0800 Subject: [PATCH 32/47] refactor(command): settle the dry-run constructor, tips, and domain enum Three narrowings of the V1 business-command contract, none of which has a published compatibility surface: extension/command does not exist on main. Preview and NewDryRun were the same constructor twice -- one empty, one seeded with requests. Fold them into a variadic NewDryRun. Every existing NewDryRun() call keeps compiling, and the domain word in the contract is now spelled one way. The type DryRun already owns that identifier in this package, so naming the constructor DryRun outright cannot compile. Drop Metadata.Tips. It was pure passthrough into common.Shortcut.Tips and nothing in the execution path read it, so business commands lose only the ability to declare help tips; the repository's own typed shortcuts keep theirs. The mount test asserted a tip reached the rendered help as proof that metadata survives the extension -> commandhost -> common.Shortcut -> help conversion; it now asserts the risk line, which travels the same path. Hand-write the domain enumeration and delete the generator. Generating from shortcuts.AllShortcuts silently omitted approval, attendance and mindnotes: all three are published under `lark-cli --help` and served by typed and raw API commands, they just own no shortcut. The enum is now the 23 domains the CLI actually exposes. Those three would otherwise have been constants that compile and always fail, because CompileSets derived its mountable domains from the same shortcut list. It now reads the service registry, and shortcuts/register.go already creates a domain command group on demand when no built-in occupies it, so a business command can mount under a shortcut-less domain. --- cmd/command_sets_test.go | 7 +- extension/command/command_test.go | 7 +- .../commandtest/business_commands_test.go | 8 +- .../command/commandtest/commandtest_test.go | 2 +- extension/command/definition.go | 1 - extension/command/domain.go | 2 +- .../command/{domains_gen.go => domains.go} | 14 ++- extension/command/dryrun.go | 8 +- extension/command/examples/chat-brief/main.go | 12 +-- extension/command/generate.go | 6 -- extension/command/host.go | 1 - extension/command/internal/gen/main.go | 101 ------------------ extension/command/testdata/wrapper/main.go | 2 +- internal/commandhost/compile.go | 19 +++- internal/commandhost/compile_test.go | 31 +++++- internal/commandhost/dryrun_note_test.go | 4 +- internal/registry/service_desc.go | 14 +++ 17 files changed, 88 insertions(+), 151 deletions(-) rename extension/command/{domains_gen.go => domains.go} (68%) delete mode 100644 extension/command/generate.go delete mode 100644 extension/command/internal/gen/main.go diff --git a/cmd/command_sets_test.go b/cmd/command_sets_test.go index 099a50bb9a..82bfe6165f 100644 --- a/cmd/command_sets_test.go +++ b/cmd/command_sets_test.go @@ -30,14 +30,13 @@ 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, - Tips: []string{"Uses the distribution-specific chat policy."}, 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.Preview(command.GET("/open-apis/im/v1/chats/" + args.ChatID)) + 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 { @@ -121,8 +120,8 @@ func TestCommandSetSubprocess(t *testing.T) { if err := leaf.Help(); err != nil { t.Fatal(err) } - if !strings.Contains(help.String(), "distribution-specific chat policy") { - t.Fatalf("business tip is missing from help:\n%s", help.String()) + 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{ diff --git a/extension/command/command_test.go b/extension/command/command_test.go index a4d6e8aa3f..1c023a377d 100644 --- a/extension/command/command_test.go +++ b/extension/command/command_test.go @@ -28,10 +28,9 @@ type contractData struct { func TestDefineCopiesMutableMetadata(t *testing.T) { scopes := []string{"im:chat:read"} - tips := []string{"Example"} definition := Definition[contractArgs, contractData]{ Metadata: CommandMetadata{ - Service: "im", Command: "+contract-copy", Description: "Copy test", Risk: RiskRead, Tips: tips, + Service: "im", Command: "+contract-copy", Description: "Copy test", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{ IdentityUser: {RequiredScopes: scopes}, }}, @@ -44,16 +43,12 @@ func TestDefineCopiesMutableMetadata(t *testing.T) { } declared := Define(definition) scopes[0] = "changed" - tips[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) } - if !reflect.DeepEqual(host.Metadata.Tips, []string{"Example"}) { - t.Fatalf("tips = %#v", host.Metadata.Tips) - } } func TestHostHooksRejectMismatchedErasedValues(t *testing.T) { diff --git a/extension/command/commandtest/business_commands_test.go b/extension/command/commandtest/business_commands_test.go index d324234885..8c080f14dc 100644 --- a/extension/command/commandtest/business_commands_test.go +++ b/extension/command/commandtest/business_commands_test.go @@ -39,7 +39,7 @@ func documentGetDefinition() command.Definition[documentGetArgs, documentData] { }, Hooks: command.Hooks[documentGetArgs, documentData]{ DryRun: func(_ context.Context, _ command.CommandContext, args *documentGetArgs) *command.DryRun { - return command.Preview(request(args)) + 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)) @@ -74,7 +74,7 @@ func chatListDefinition() command.Definition[chatListArgs, command.Page[chatData }, Hooks: command.Hooks[chatListArgs, command.Page[chatData]]{ DryRun: func(_ context.Context, _ command.CommandContext, args *chatListArgs) *command.DryRun { - return command.Preview(request(args)) + 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)) @@ -130,7 +130,7 @@ func taskAuditDefinition() command.Definition[taskAuditArgs, taskAuditData] { }}}, Hooks: command.Hooks[taskAuditArgs, taskAuditData]{ DryRun: func(_ context.Context, _ command.CommandContext, _ *taskAuditArgs) *command.DryRun { - return command.Preview(listRequest).Desc("Owner requests depend on task owner identifiers returned by the list call.") + 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) @@ -197,7 +197,7 @@ func memberListDefinition() command.Definition[memberListArgs, memberListData] { }, Hooks: command.Hooks[memberListArgs, memberListData]{ DryRun: func(_ context.Context, _ command.CommandContext, args *memberListArgs) *command.DryRun { - preview := command.Preview(command.GET("/open-apis/im/v1/chats/" + command.PathSegment(args.ChatID))) + 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")) } diff --git a/extension/command/commandtest/commandtest_test.go b/extension/command/commandtest/commandtest_test.go index a93e8cb119..de2e48338c 100644 --- a/extension/command/commandtest/commandtest_test.go +++ b/extension/command/commandtest/commandtest_test.go @@ -39,7 +39,7 @@ func TestRecorderScriptsRequestsScopesAndDryRun(t *testing.T) { if got := recorder.ScopeChecks(); !reflect.DeepEqual(got, [][]string{{"im:chat:read"}}) { t.Fatalf("scope checks = %#v", got) } - recorder.AssertDryRunMatches(command.Preview(request)) + recorder.AssertDryRunMatches(command.NewDryRun(request)) recorder.AssertScriptConsumed() } diff --git a/extension/command/definition.go b/extension/command/definition.go index e5200bcb18..adbcd150fc 100644 --- a/extension/command/definition.go +++ b/extension/command/definition.go @@ -33,7 +33,6 @@ type CommandMetadata struct { Description string Risk Risk Hidden bool - Tips []string Authorization AuthorizationDefinition } diff --git a/extension/command/domain.go b/extension/command/domain.go index 48e7d14052..bcb3e4cf06 100644 --- a/extension/command/domain.go +++ b/extension/command/domain.go @@ -3,7 +3,7 @@ package command -// DomainName is a generated name of an existing shortcut domain. +// DomainName is the name of an existing Lark business domain. type DomainName string type domainKind uint8 diff --git a/extension/command/domains_gen.go b/extension/command/domains.go similarity index 68% rename from extension/command/domains_gen.go rename to extension/command/domains.go index 913126e2dc..51a57b7f5b 100644 --- a/extension/command/domains_gen.go +++ b/extension/command/domains.go @@ -1,15 +1,23 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -// Code generated from shortcuts.AllShortcuts; DO NOT EDIT. - 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. @@ -28,6 +36,8 @@ const ( 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. diff --git a/extension/command/dryrun.go b/extension/command/dryrun.go index 5852e15ca8..c17e5814b1 100644 --- a/extension/command/dryrun.go +++ b/extension/command/dryrun.go @@ -9,11 +9,9 @@ type DryRun struct { requests []Request } -// NewDryRun creates an empty dry-run request list. -func NewDryRun() *DryRun { return &DryRun{} } - -// Preview creates a dry-run request list from shared Request values. -func Preview(requests ...Request) *DryRun { +// 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...)} } diff --git a/extension/command/examples/chat-brief/main.go b/extension/command/examples/chat-brief/main.go index acd22c4a88..3ec1f3a7a1 100644 --- a/extension/command/examples/chat-brief/main.go +++ b/extension/command/examples/chat-brief/main.go @@ -15,7 +15,7 @@ // // cd extension/command/examples/chat-brief // go build -o chat-brief-cli . -// ./chat-brief-cli im +chat-brief --help # Tips + flags from tags +// ./chat-brief-cli im +chat-brief --help # description + flags from tags // ./chat-brief-cli im +chat-brief --chat-id oc_xxx --dry-run # offline request preview // ./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 @@ -66,9 +66,6 @@ var chatBrief = command.Define(command.Definition[chatBriefArgs, chatBriefData]{ Command: "+chat-brief", Description: "Get a concise chat projection", Risk: command.RiskRead, - Tips: []string{ - "Example: chat-brief-cli im +chat-brief --chat-id oc_xxx", - }, Authorization: command.AuthorizationDefinition{ Identities: map[command.Identity]command.IdentityAuthorization{ command.IdentityUser: {RequiredScopes: []string{"im:chat:read"}}, @@ -83,7 +80,7 @@ var chatBrief = command.Define(command.Definition[chatBriefArgs, chatBriefData]{ return nil }, DryRun: func(_ context.Context, _ command.CommandContext, args *chatBriefArgs) *command.DryRun { - return command.Preview(chatRequest(args)) + 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)) @@ -127,9 +124,6 @@ var chatList = command.Define(command.Definition[chatListArgs, command.Page[chat Command: "+chat-brief-list", Description: "List visible chats", Risk: command.RiskRead, - Tips: []string{ - "Example: chat-brief-cli im +chat-brief-list --page-all --page-limit 2", - }, Authorization: command.AuthorizationDefinition{ Identities: map[command.Identity]command.IdentityAuthorization{ command.IdentityUser: {RequiredScopes: []string{"im:chat:read"}}, @@ -138,7 +132,7 @@ var chatList = command.Define(command.Definition[chatListArgs, command.Page[chat }, Hooks: command.Hooks[chatListArgs, command.Page[chatItem]]{ DryRun: func(_ context.Context, _ command.CommandContext, args *chatListArgs) *command.DryRun { - return command.Preview(chatListRequest(args)) + 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)) diff --git a/extension/command/generate.go b/extension/command/generate.go deleted file mode 100644 index ffb75c0812..0000000000 --- a/extension/command/generate.go +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package command - -//go:generate go run ./internal/gen diff --git a/extension/command/host.go b/extension/command/host.go index e7d979db70..d4d6ed5a76 100644 --- a/extension/command/host.go +++ b/extension/command/host.go @@ -225,7 +225,6 @@ func cloneHostHooks(hooks HostHooks) HostHooks { } func cloneMetadata(metadata CommandMetadata) CommandMetadata { - metadata.Tips = append([]string(nil), metadata.Tips...) 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 { diff --git a/extension/command/internal/gen/main.go b/extension/command/internal/gen/main.go deleted file mode 100644 index b5fb5aaf29..0000000000 --- a/extension/command/internal/gen/main.go +++ /dev/null @@ -1,101 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -// Command gen regenerates the public existing-domain enumeration. -package main - -import ( - "bytes" - "fmt" - "go/format" - "os" - "path/filepath" - "regexp" - "runtime" - "sort" - "strings" - - "github.com/larksuite/cli/internal/registry" - "github.com/larksuite/cli/internal/vfs" - "github.com/larksuite/cli/shortcuts" -) - -var serviceNamePattern = regexp.MustCompile(`^[a-z][a-z0-9]*$`) - -func commandDir() (string, error) { - _, sourceFile, _, ok := runtime.Caller(0) - if !ok { - return "", fmt.Errorf("cannot resolve source location") - } - return filepath.Join(filepath.Dir(sourceFile), "..", ".."), nil -} - -//nolint:forbidigo // Standalone go-generate process reports to stderr and exits before any CLI command boundary exists. -func main() { - if err := run(); err != nil { - fmt.Fprintln(os.Stderr, "command domain generator:", err) - os.Exit(1) - } -} - -func run() error { - seen := make(map[string]struct{}) - for _, shortcut := range shortcuts.AllShortcuts() { - seen[shortcut.Service] = struct{}{} - } - if len(seen) == 0 { - return fmt.Errorf("no shortcut domains found") - } - - domains := make([]string, 0, len(seen)) - for domain := range seen { - if !serviceNamePattern.MatchString(domain) { - return fmt.Errorf("service %q cannot form a stable Go identifier", domain) - } - for _, lang := range []string{"en", "zh"} { - if registry.GetServiceTitle(domain, lang) == "" { - return fmt.Errorf("service %q has no %s title", domain, lang) - } - if registry.GetServiceDescription(domain, lang) == "" { - return fmt.Errorf("service %q has no %s description", domain, lang) - } - } - domains = append(domains, domain) - } - sort.Strings(domains) - - var output bytes.Buffer - output.WriteString(`// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -// Code generated from shortcuts.AllShortcuts; DO NOT EDIT. - -package command - -const ( -`) - for _, domain := range domains { - identifier := domainIdentifier(domain) - fmt.Fprintf(&output, "\t// Domain%s is the %s domain.\n", identifier, registry.GetServiceTitle(domain, "en")) - fmt.Fprintf(&output, "\tDomain%s DomainName = %q\n", identifier, domain) - } - output.WriteString(")\n") - - formatted, err := format.Source(output.Bytes()) - if err != nil { - return fmt.Errorf("format output: %w", err) - } - dir, err := commandDir() - if err != nil { - return err - } - target := filepath.Join(dir, "domains_gen.go") - if err := vfs.WriteFile(target, formatted, 0o644); err != nil { - return fmt.Errorf("write %s: %w", target, err) - } - return nil -} - -func domainIdentifier(domain string) string { - return strings.ToUpper(domain[:1]) + domain[1:] -} diff --git a/extension/command/testdata/wrapper/main.go b/extension/command/testdata/wrapper/main.go index b167097a21..16d70725db 100644 --- a/extension/command/testdata/wrapper/main.go +++ b/extension/command/testdata/wrapper/main.go @@ -38,7 +38,7 @@ var readCommand = command.Define(command.Definition[readArgs, readData]{ }, Hooks: command.Hooks[readArgs, readData]{ DryRunE: func(_ context.Context, _ command.CommandContext, args *readArgs) (*command.DryRun, error) { - return command.Preview(readRequest(args)), nil + return command.NewDryRun(readRequest(args)), nil }, Execute: func(ctx context.Context, commandContext command.CommandContext, args *readArgs) (command.Result[readData], error) { data, err := command.CallJSON[readData](ctx, commandContext, readRequest(args)) diff --git a/internal/commandhost/compile.go b/internal/commandhost/compile.go index a5708bf06a..c23728f584 100644 --- a/internal/commandhost/compile.go +++ b/internal/commandhost/compile.go @@ -14,6 +14,7 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" "github.com/larksuite/cli/extension/command" + "github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/shortcuts" "github.com/larksuite/cli/shortcuts/common" ) @@ -31,12 +32,11 @@ func CompileSets(sets []command.Set) ([]common.Shortcut, error) { } builtins := shortcuts.AllShortcuts() - existingDomains := make(map[string]struct{}) paths := make(map[string]string, len(builtins)) for _, shortcut := range builtins { - existingDomains[shortcut.Service] = struct{}{} paths[shortcut.Service+" "+shortcut.Command] = "built-in command" } + existingDomains := businessDomains() compiled := make([]common.Shortcut, 0) for setIndex, set := range sets { @@ -69,6 +69,19 @@ func CompileSets(sets []command.Set) ([]common.Shortcut, error) { return compiled, nil } +// 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 { @@ -138,7 +151,7 @@ func convertMetadata(metadata command.CommandMetadata) common.CommandMetadata { } return common.CommandMetadata{ Service: string(metadata.Service), Command: metadata.Command, Description: metadata.Description, - Risk: common.Risk(metadata.Risk), Hidden: metadata.Hidden, Tips: append([]string(nil), metadata.Tips...), + Risk: common.Risk(metadata.Risk), Hidden: metadata.Hidden, Authorization: common.AuthorizationDefinition{Identities: identities, IdentityOrder: identityOrder}, } } diff --git a/internal/commandhost/compile_test.go b/internal/commandhost/compile_test.go index b042dd8a40..d45d2bbe24 100644 --- a/internal/commandhost/compile_test.go +++ b/internal/commandhost/compile_test.go @@ -27,11 +27,15 @@ type fixtureData struct { } 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: "im", Command: name, Description: "Fixture command", Risk: command.RiskRead, + Service: service, Command: name, Description: "Fixture command", Risk: command.RiskRead, Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{ - command.IdentityUser: {RequiredScopes: []string{"im:chat:read"}}, + command.IdentityUser: {RequiredScopes: []string{string(service) + ":read"}}, }}, }, Hooks: command.Hooks[fixtureArgs, fixtureData]{ @@ -57,6 +61,25 @@ func TestCompileSetsCompilesTypedShortcut(t *testing.T) { } } +// 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 @@ -238,7 +261,7 @@ func TestExternalDryRunUsesOfflineContext(t *testing.T) { 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.Preview(request) + return command.NewDryRun(request) }, Execute: func(context.Context, command.CommandContext, *fixtureArgs) (command.Result[fixtureData], error) { executed = true @@ -333,7 +356,7 @@ func TestExternalPageDryRunNotesBoundedRepetition(t *testing.T) { }, Hooks: command.Hooks[fixtureArgs, command.Page[fixtureData]]{ DryRun: func(_ context.Context, _ command.CommandContext, _ *fixtureArgs) *command.DryRun { - return command.Preview(command.GET("/open-apis/im/v1/chats").Desc("list visible chats")) + 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 diff --git a/internal/commandhost/dryrun_note_test.go b/internal/commandhost/dryrun_note_test.go index b880744109..0ed5c17540 100644 --- a/internal/commandhost/dryrun_note_test.go +++ b/internal/commandhost/dryrun_note_test.go @@ -17,7 +17,7 @@ import ( // accumulate the note: the preview would then claim the command pages more // times than it does. func TestConvertDryRunNoteDoesNotAccumulateAcrossCalls(t *testing.T) { - preview := command.Preview(command.GET("/open-apis/im/v1/chats")) + preview := command.NewDryRun(command.GET("/open-apis/im/v1/chats")) first := convertedDryRunJSON(t, preview) second := convertedDryRunJSON(t, preview) @@ -34,7 +34,7 @@ func TestConvertDryRunNoteDoesNotAccumulateAcrossCalls(t *testing.T) { // onto a copy, so the business description must survive unchanged and must not // grow a second note on the next conversion. func TestConvertDryRunKeepsBusinessDescriptionIntact(t *testing.T) { - preview := command.Preview(command.GET("/open-apis/im/v1/chats").Desc("lists the first page")) + preview := command.NewDryRun(command.GET("/open-apis/im/v1/chats").Desc("lists the first page")) first := convertedDryRunJSON(t, preview) second := convertedDryRunJSON(t, preview) 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] From 66643d03904e8a389735e600ef34c1c869121bdb Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:07:30 +0800 Subject: [PATCH 33/47] ci: stop generating extension/command The domain enumeration is hand-written now and extension/command holds no go:generate directive, so the path was a no-op that still read as if the package carried generated files. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dfff5de806..2013dcea21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: fi - name: Check generated Go files run: | - go generate ./extension/command/... ./shortcuts/sheets/... + go generate ./shortcuts/sheets/... git diff --exit-code - name: Check go.mod tidiness run: | From b6b6bc580215457635360345ea0e55dd9fb5c1bb Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:19:30 +0800 Subject: [PATCH 34/47] refactor(command): drop DryRunE and let the preview render like a built-in DryRunE has no counterpart in the shipped CLI: `git show main:shortcuts/common/types.go` has no such field, it arrived with the typed-shortcut framework this branch imported, and no shortcut in the repository sets one. Business commands get the single DryRun hook that built-in shortcuts have. Validate already runs before it and owns the error channel, so a preview that cannot be built still fails there with a typed error -- which is what the repointed tests now assert, end to end through --dry-run and through commandtest.Preview. Also stop appending the bounded-repeat note. convertDryRun added "with --page-all, repeats with the returned page_token until exhaustion or --page-limit" to every Page[T] preview, so an external command's dry-run carried a sentence its author never wrote. Built-in paginated shortcuts say this themselves when they want it (im_chat_members_list.go calls dry.Desc), and external commands now do the same: the framework renders the description it was given and nothing else. The dry-run context keeps refusing requests -- runner.go does the same for built-ins, so removing that would be the divergence, not the alignment. Only the word changes: "offline" was our own vocabulary for what the rest of the CLI calls dry-run. --- extension/command/command_test.go | 5 +- extension/command/commandtest/commandtest.go | 9 +-- .../command/commandtest/commandtest_test.go | 10 ++- extension/command/definition.go | 10 +-- extension/command/examples/chat-brief/main.go | 2 +- extension/command/host.go | 15 ---- extension/command/testdata/wrapper/main.go | 6 +- internal/commandhost/compile.go | 41 ++--------- internal/commandhost/compile_test.go | 56 ++++++--------- internal/commandhost/dryrun_note_test.go | 69 ------------------- 10 files changed, 43 insertions(+), 180 deletions(-) delete mode 100644 internal/commandhost/dryrun_note_test.go diff --git a/extension/command/command_test.go b/extension/command/command_test.go index 1c023a377d..d014d0f9cb 100644 --- a/extension/command/command_test.go +++ b/extension/command/command_test.go @@ -61,7 +61,6 @@ func TestHostHooksRejectMismatchedErasedValues(t *testing.T) { 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() }, - DryRunE: func(context.Context, CommandContext, *contractArgs) (*DryRun, error) { return NewDryRun(), nil }, Execute: func(context.Context, CommandContext, *contractArgs) (Result[contractData], error) { return Success(contractData{}), nil }, @@ -84,9 +83,7 @@ func TestHostHooksRejectMismatchedErasedValues(t *testing.T) { if dryRun := host.Hooks.DryRun(context.Background(), commandContext, wrong); dryRun != nil { t.Fatalf("DryRun = %#v", dryRun) } - _, err := host.Hooks.DryRunE(context.Background(), commandContext, wrong) - assertInternal("DryRunE", err) - _, err = host.Hooks.Execute(context.Background(), commandContext, wrong) + _, err := host.Hooks.Execute(context.Background(), commandContext, wrong) assertInternal("Execute", err) assertInternal("renderer", host.Hooks.Renderers["pretty"](io.Discard, wrong)) } diff --git a/extension/command/commandtest/commandtest.go b/extension/command/commandtest/commandtest.go index 0cad0f8eb2..4e5a310d0f 100644 --- a/extension/command/commandtest/commandtest.go +++ b/extension/command/commandtest/commandtest.go @@ -73,7 +73,7 @@ func (r *Recorder) CommandContext(identity command.Identity) command.CommandCont return r.commandContext(identity, false) } -// DryRunContext returns an offline context for invoking a DryRun hook. +// 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) } @@ -172,7 +172,7 @@ func parsePaginationFlags(arguments []string) (command.PaginationOptions, error) }, nil } -// Preview runs Normalize, Validate, and DryRun with an offline test context. +// 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 := command.InspectCommand(command.Define(definition)) commandContext := recorder.DryRunContext(identity) @@ -186,12 +186,9 @@ func Preview[Args any, Data any](ctx context.Context, recorder *Recorder, identi return nil, err } } - if declaration.Hooks.DryRun == nil && declaration.Hooks.DryRunE == nil { + if declaration.Hooks.DryRun == nil { return nil, errors.New("business command has no DryRun hook") } - if declaration.Hooks.DryRunE != nil { - return declaration.Hooks.DryRunE(ctx, commandContext, args) - } return declaration.Hooks.DryRun(ctx, commandContext, args), nil } diff --git a/extension/command/commandtest/commandtest_test.go b/extension/command/commandtest/commandtest_test.go index de2e48338c..7bf32fd054 100644 --- a/extension/command/commandtest/commandtest_test.go +++ b/extension/command/commandtest/commandtest_test.go @@ -179,7 +179,8 @@ func TestExecuteRejectsResultAndErrorTogether(t *testing.T) { } } -func TestPreviewPropagatesDryRunE(t *testing.T) { +// 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") @@ -189,8 +190,11 @@ func TestPreviewPropagatesDryRunE(t *testing.T) { Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{command.IdentityUser: {}}}, }, Hooks: command.Hooks[args, data]{ - DryRunE: func(context.Context, command.CommandContext, *args) (*command.DryRun, error) { - return nil, sentinel + 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 diff --git a/extension/command/definition.go b/extension/command/definition.go index adbcd150fc..3192e00bcf 100644 --- a/extension/command/definition.go +++ b/extension/command/definition.go @@ -230,16 +230,10 @@ type Hooks[Args any, Data any] struct { Validate func(context.Context, CommandContext, *Args) error // DryRun returns the requests the command would send, which the framework - // prints instead of executing. Set this or DryRunE, never both -- compiling - // a command that sets both fails. - // - // Choose by whether building the preview can fail: DryRun when the requests - // follow from Args alone, DryRunE when constructing them may error. + // 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 - // DryRunE is DryRun with an error channel. See DryRun for the choice. - DryRunE func(context.Context, CommandContext, *Args) (*DryRun, error) - // Execute carries the business logic and returns Success or Partial. 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. diff --git a/extension/command/examples/chat-brief/main.go b/extension/command/examples/chat-brief/main.go index 3ec1f3a7a1..fcecc6c0e9 100644 --- a/extension/command/examples/chat-brief/main.go +++ b/extension/command/examples/chat-brief/main.go @@ -16,7 +16,7 @@ // 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 # offline request preview +// ./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 diff --git a/extension/command/host.go b/extension/command/host.go index d4d6ed5a76..4afbddb8ca 100644 --- a/extension/command/host.go +++ b/extension/command/host.go @@ -27,7 +27,6 @@ type HostHooks struct { Normalize func(context.Context, CommandContext, any) error Validate func(context.Context, CommandContext, any) error DryRun func(context.Context, CommandContext, any) *DryRun - DryRunE func(context.Context, CommandContext, any) (*DryRun, error) Execute func(context.Context, CommandContext, any) (HostResult, error) Renderers map[string]func(io.Writer, any) error } @@ -88,7 +87,6 @@ func bindHooks[Args any, Data any](hooks Hooks[Args, Data]) HostHooks { Normalize: bindArgsHook(hooks.Normalize, "Normalize"), Validate: bindArgsHook(hooks.Validate, "Validate"), DryRun: bindDryRunHook(hooks.DryRun), - DryRunE: bindDryRunErrorHook(hooks.DryRunE), Execute: bindExecuteHook(hooks.Execute), Renderers: bindRenderers(hooks.Renderers), } @@ -120,19 +118,6 @@ func bindDryRunHook[Args any](hook func(context.Context, CommandContext, *Args) } } -func bindDryRunErrorHook[Args any](hook func(context.Context, CommandContext, *Args) (*DryRun, error)) func(context.Context, CommandContext, any) (*DryRun, error) { - if hook == nil { - return nil - } - return func(ctx context.Context, command CommandContext, args any) (*DryRun, error) { - typed, ok := args.(*Args) - if !ok { - return nil, InternalErrorf("DryRunE received %T, expected %T", args, (*Args)(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 diff --git a/extension/command/testdata/wrapper/main.go b/extension/command/testdata/wrapper/main.go index 16d70725db..3e10ab7ba9 100644 --- a/extension/command/testdata/wrapper/main.go +++ b/extension/command/testdata/wrapper/main.go @@ -23,7 +23,7 @@ type readData struct { ID string `json:"id" schema:"required" doc:"resource identifier"` } -// readRequest is shared by DryRunE and Execute so the preview cannot drift from +// 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)) @@ -37,8 +37,8 @@ var readCommand = command.Define(command.Definition[readArgs, readData]{ }}, }, Hooks: command.Hooks[readArgs, readData]{ - DryRunE: func(_ context.Context, _ command.CommandContext, args *readArgs) (*command.DryRun, error) { - return command.NewDryRun(readRequest(args)), nil + 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)) diff --git a/internal/commandhost/compile.go b/internal/commandhost/compile.go index c23728f584..d9ca622a1a 100644 --- a/internal/commandhost/compile.go +++ b/internal/commandhost/compile.go @@ -103,9 +103,6 @@ func validateDomain(domain command.HostDomain, existing map[string]struct{}) err } func compileCommand(definition command.HostDefinition) (common.Shortcut, error) { - if definition.Hooks.DryRun != nil && definition.Hooks.DryRunE != nil { - return common.Shortcut{}, fmt.Errorf("Hooks.DryRun and Hooks.DryRunE cannot both be set") - } metadata := convertMetadata(definition.Metadata) input, err := convertInput(definition.Input) if err != nil { @@ -115,7 +112,7 @@ func compileCommand(definition command.HostDefinition) (common.Shortcut, error) if err != nil { return common.Shortcut{}, err } - hooks := convertHooks(definition.Hooks, definition.PageOutput) + hooks := convertHooks(definition.Hooks) hooks.NewArgs = definition.NewArgs return common.CompileErasedDefinition(common.ErasedDefinition{ Metadata: metadata, @@ -225,30 +222,16 @@ func convertOutput(output command.OutputDefinition) (common.OutputDefinition, er return converted, nil } -func convertHooks(hooks command.HostHooks, pageOutput bool) common.ErasedHooks { - dryRun := adaptDryRunHook(hooks.DryRun, pageOutput) - if hooks.DryRunE != nil { - dryRun = adaptDryRunErrorHook(hooks.DryRunE, pageOutput) - } +func convertHooks(hooks command.HostHooks) common.ErasedHooks { return common.ErasedHooks{ Normalize: adaptHook(hooks.Normalize), Validate: adaptHook(hooks.Validate), - DryRun: dryRun, + DryRun: adaptDryRunHook(hooks.DryRun), Execute: adaptExecuteHook(hooks.Execute), Renderers: cloneRenderers(hooks.Renderers), } } -func adaptDryRunErrorHook(hook func(context.Context, command.CommandContext, any) (*command.DryRun, error), pageOutput bool) func(context.Context, common.CommandContext, any) (*common.DryRunAPI, error) { - return func(ctx context.Context, host common.CommandContext, args any) (*common.DryRunAPI, error) { - preview, err := hook(ctx, publicContext(host), args) - if err != nil { - return nil, err - } - return convertDryRun(preview, pageOutput) - } -} - func adaptHook(hook func(context.Context, command.CommandContext, any) error) func(context.Context, common.CommandContext, any) error { if hook == nil { return nil @@ -258,13 +241,13 @@ func adaptHook(hook func(context.Context, command.CommandContext, any) error) fu } } -func adaptDryRunHook(hook func(context.Context, command.CommandContext, any) *command.DryRun, pageOutput bool) func(context.Context, common.CommandContext, any) (*common.DryRunAPI, error) { +func adaptDryRunHook(hook func(context.Context, command.CommandContext, any) *command.DryRun) func(context.Context, common.CommandContext, any) (*common.DryRunAPI, error) { if hook == nil { return nil } return func(ctx context.Context, host common.CommandContext, args any) (*common.DryRunAPI, error) { preview := hook(ctx, publicContext(host), args) - return convertDryRun(preview, pageOutput) + return convertDryRun(preview) } } @@ -379,12 +362,7 @@ func derefQueryValue(value any) any { return reflected.Interface() } -// pageAllRepeatNote is the bounded-repeat explanation a Page[T] command's -// dry-run carries: only the first request is previewable, and the preview -// must not fabricate response-dependent page tokens. -const pageAllRepeatNote = "with --page-all, repeats with the returned page_token until exhaustion or --page-limit" - -func convertDryRun(preview *command.DryRun, pageOutput bool) (*common.DryRunAPI, error) { +func convertDryRun(preview *command.DryRun) (*common.DryRunAPI, error) { if preview == nil { return nil, nil } @@ -419,13 +397,6 @@ func convertDryRun(preview *command.DryRun, pageOutput bool) (*common.DryRunAPI, converted.Desc(request.Description) } } - if pageOutput && len(view.Requests) > 0 { - note := pageAllRepeatNote - if last := view.Requests[len(view.Requests)-1].Description; last != "" { - note = last + "; " + note - } - converted.Desc(note) - } return converted, nil } diff --git a/internal/commandhost/compile_test.go b/internal/commandhost/compile_test.go index d45d2bbe24..8f9c93f108 100644 --- a/internal/commandhost/compile_test.go +++ b/internal/commandhost/compile_test.go @@ -180,30 +180,6 @@ func TestCompileSetsRejectsFileInputSource(t *testing.T) { } } -func TestCompileSetsRejectsBothDryRunHooks(t *testing.T) { - definition := command.Define(command.Definition[fixtureArgs, fixtureData]{ - Metadata: command.CommandMetadata{ - Service: "im", Command: "+external-dry-run-conflict", Description: "Dry-run conflict", Risk: command.RiskRead, - Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{command.IdentityUser: {}}}, - }, - Hooks: command.Hooks[fixtureArgs, fixtureData]{ - DryRun: func(context.Context, command.CommandContext, *fixtureArgs) *command.DryRun { - return command.NewDryRun() - }, - DryRunE: func(context.Context, command.CommandContext, *fixtureArgs) (*command.DryRun, error) { - return command.NewDryRun(), nil - }, - Execute: func(context.Context, command.CommandContext, *fixtureArgs) (command.Result[fixtureData], error) { - return command.Success(fixtureData{}), nil - }, - }, - }) - _, err := CompileSets([]command.Set{{Domain: command.ExtendDomain(command.DomainIm), Commands: []command.Command{definition}}}) - if err == nil || !strings.Contains(err.Error(), "cannot both be set") { - t.Fatalf("CompileSets() error = %v", err) - } -} - func TestCompileSetsAddsPaginationFlags(t *testing.T) { declaration := command.Define(command.Definition[fixtureArgs, command.Page[fixtureData]]{ Metadata: command.CommandMetadata{ @@ -240,14 +216,14 @@ func (r *countingTokenResolver) ResolveToken(context.Context, credential.TokenSp return &credential.TokenResult{Token: "unexpected-token"}, nil } -func TestExternalDryRunUsesOfflineContext(t *testing.T) { +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-offline", Description: "Offline preview", Risk: command.RiskRead, + 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"}, @@ -282,7 +258,7 @@ func TestExternalDryRunUsesOfflineContext(t *testing.T) { service := &cobra.Command{Use: "im"} root.AddCommand(service) compiled[0].Mount(service, factory) - root.SetArgs([]string{"im", "+external-offline", "--id", "chat_1", "--as", "user", "--dry-run"}) + root.SetArgs([]string{"im", "+external-preview", "--id", "chat_1", "--as", "user", "--dry-run"}) if _, err := root.ExecuteC(); err != nil { t.Fatal(err) } @@ -307,16 +283,21 @@ func TestExternalDryRunUsesOfflineContext(t *testing.T) { } } -func TestExternalDryRunEPropagatesError(t *testing.T) { +// 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: "Offline preview error", Risk: command.RiskRead, + 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]{ - DryRunE: func(context.Context, command.CommandContext, *fixtureArgs) (*command.DryRun, error) { - return nil, sentinel + 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 @@ -346,7 +327,10 @@ func TestExternalDryRunEPropagatesError(t *testing.T) { // A Page[T] command's dry-run can only show the first request; the preview // must say the walk repeats instead of fabricating response-dependent // page tokens. -func TestExternalPageDryRunNotesBoundedRepetition(t *testing.T) { +// 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, @@ -379,10 +363,10 @@ func TestExternalPageDryRunNotesBoundedRepetition(t *testing.T) { t.Fatal(err) } output := stdout.String() - if !strings.Contains(output, "repeats with the returned page_token until exhaustion or --page-limit") { - t.Fatalf("dry-run output lacks the bounded-repeat note:\n%s", output) + if !strings.Contains(output, "list visible chats") { + t.Fatalf("business description is missing from the preview:\n%s", output) } - if !strings.Contains(output, "list visible chats; ") { - t.Fatalf("business description was not preserved before the note:\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) } } diff --git a/internal/commandhost/dryrun_note_test.go b/internal/commandhost/dryrun_note_test.go deleted file mode 100644 index 0ed5c17540..0000000000 --- a/internal/commandhost/dryrun_note_test.go +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package commandhost - -import ( - "encoding/json" - "strings" - "testing" - - "github.com/larksuite/cli/extension/command" -) - -// convertDryRun writes the bounded-repeat note into the projection it builds, -// never back into the DryRun the business hook returned. A hook may cache and -// return the same *DryRun on every invocation, so converting twice must not -// accumulate the note: the preview would then claim the command pages more -// times than it does. -func TestConvertDryRunNoteDoesNotAccumulateAcrossCalls(t *testing.T) { - preview := command.NewDryRun(command.GET("/open-apis/im/v1/chats")) - - first := convertedDryRunJSON(t, preview) - second := convertedDryRunJSON(t, preview) - - if first != second { - t.Fatalf("second conversion differs:\nfirst: %s\nsecond: %s", first, second) - } - if got := strings.Count(second, pageAllRepeatNote); got != 1 { - t.Fatalf("repeat note appears %d times, want 1:\n%s", got, second) - } -} - -// The same holds when the hook supplied its own description: the note is joined -// onto a copy, so the business description must survive unchanged and must not -// grow a second note on the next conversion. -func TestConvertDryRunKeepsBusinessDescriptionIntact(t *testing.T) { - preview := command.NewDryRun(command.GET("/open-apis/im/v1/chats").Desc("lists the first page")) - - first := convertedDryRunJSON(t, preview) - second := convertedDryRunJSON(t, preview) - - if first != second { - t.Fatalf("second conversion differs:\nfirst: %s\nsecond: %s", first, second) - } - if got := strings.Count(second, "lists the first page"); got != 1 { - t.Fatalf("business description appears %d times, want 1:\n%s", got, second) - } - - view := command.InspectDryRun(preview) - if got := view.Requests[len(view.Requests)-1].Description; got != "lists the first page" { - t.Fatalf("business description was rewritten to %q", got) - } -} - -func convertedDryRunJSON(t *testing.T, preview *command.DryRun) string { - t.Helper() - converted, err := convertDryRun(preview, true) - if err != nil { - t.Fatal(err) - } - if converted == nil { - t.Fatal("convertDryRun returned nil") - } - encoded, err := json.Marshal(converted) - if err != nil { - t.Fatal(err) - } - return string(encoded) -} From 4698b2aaccc3ee51dbdfee7b584a00899c75dbf6 Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:28:19 +0800 Subject: [PATCH 35/47] refactor(command): drop the partial-failure outcome from the contract Business commands now return Success only. Partial, OutcomeDefinition, PartialFailureDefinition and FailedItemDefinition leave the public surface along with Execution.Partial and the host adapter's receipt conversion. Result keeps its outcome field. It is no longer a choice -- Success is the only value -- but it is also how the host tells a returned Result apart from the zero value that accompanies an error, which is the check commandtest.Execute makes before reporting "returned both Result and error". Collapsing it to nothing would delete that signal. The exemplar commands that returned Partial keep their scenarios: a best-effort scope failure still marks every item failed and appends the snapshot, and the multi-call audit still records the owner it could not resolve. That information lives in the command's own Data (Items[].State plus Failures), not in the outcome, so the tests assert the same facts and only the outcome assertion is gone. The deep-copy test moved its nested JSON exemplar from FailedValues to InputDefault.Value, keeping cloneJSONValue covered. shortcuts/common still defines PartialFailure for built-in typed shortcuts. That is the imported framework, untouched here. --- extension/command/command_test.go | 22 ++++------ .../commandtest/business_commands_test.go | 19 +++----- extension/command/commandtest/commandtest.go | 5 +-- .../command/commandtest/commandtest_test.go | 6 +-- extension/command/definition.go | 2 +- extension/command/host.go | 13 ------ extension/command/output.go | 44 ++++--------------- internal/commandhost/compile.go | 12 ----- 8 files changed, 29 insertions(+), 94 deletions(-) diff --git a/extension/command/command_test.go b/extension/command/command_test.go index d014d0f9cb..7b99ae1882 100644 --- a/extension/command/command_test.go +++ b/extension/command/command_test.go @@ -126,14 +126,16 @@ func TestDefineCopiesShapePointersAndTypedJSONContainers(t *testing.T) { maxItems := 20 minimum := int64(0) maximum := 100.0 - failedValues := map[string][]string{"ids": {"original"}} + 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}, + Name: "id", + Shape: StringShape{MinLength: &minLength, MaxLength: &maxLength}, + Default: InputDefault{Set: true, Value: defaultValue}, }}}, Output: OutputDefinition{ Data: DataDefinition{ @@ -142,12 +144,6 @@ func TestDefineCopiesShapePointersAndTypedJSONContainers(t *testing.T) { }, Overrides: []DataField{{Path: "/score", Shape: NumberShape{Maximum: &maximum}}}, }, - Outcomes: OutcomeDefinition{PartialFailure: &PartialFailureDefinition{ - ExitCode: 2, - FailedItems: &FailedItemDefinition{ - ItemsPath: "/items", IdentityPaths: []string{"/id"}, FailedValues: []JSONValue{failedValues}, - }, - }}, }, Hooks: Hooks[contractArgs, contractData]{Execute: func(context.Context, CommandContext, *contractArgs) (Result[contractData], error) { return Success(contractData{}), nil @@ -160,12 +156,12 @@ func TestDefineCopiesShapePointersAndTypedJSONContainers(t *testing.T) { maxItems = 10 minimum = 1 maximum = 50 - failedValues["ids"][0] = "mutated" + defaultValue["ids"][0] = "mutated" first := InspectCommand(declaration) assertCopiedDefinitionValues(t, first) *first.Input.Fields[0].Shape.(StringShape).MinLength = 9 - first.Output.Outcomes.PartialFailure.FailedItems.FailedValues[0].(map[string][]string)["ids"][0] = "inspected" + first.Input.Fields[0].Default.Value.(map[string][]string)["ids"][0] = "inspected" second := InspectCommand(declaration) assertCopiedDefinitionValues(t, second) @@ -186,9 +182,9 @@ func assertCopiedDefinitionValues(t *testing.T, definition HostDefinition) { if *numberShape.Maximum != 100 { t.Fatalf("number constraints = %#v", numberShape) } - failed := definition.Output.Outcomes.PartialFailure.FailedItems.FailedValues[0].(map[string][]string) - if failed["ids"][0] != "original" { - t.Fatalf("failed values = %#v", failed) + defaultValue := definition.Input.Fields[0].Default.Value.(map[string][]string) + if defaultValue["ids"][0] != "original" { + t.Fatalf("default value = %#v", defaultValue) } } diff --git a/extension/command/commandtest/business_commands_test.go b/extension/command/commandtest/business_commands_test.go index 8c080f14dc..92b12cf9e9 100644 --- a/extension/command/commandtest/business_commands_test.go +++ b/extension/command/commandtest/business_commands_test.go @@ -122,12 +122,6 @@ func taskAuditDefinition() command.Definition[taskAuditArgs, taskAuditData] { }, }}, }, - Output: command.OutputDefinition{Outcomes: command.OutcomeDefinition{PartialFailure: &command.PartialFailureDefinition{ - ExitCode: 3, - FailedItems: &command.FailedItemDefinition{ - ItemsPath: "/items", IdentityPaths: []string{"/task_id"}, StatePath: "/state", FailedValues: []command.JSONValue{"failed"}, - }, - }}}, 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.") @@ -149,7 +143,7 @@ func taskAuditDefinition() command.Definition[taskAuditArgs, taskAuditData] { data.Items = append(data.Items, taskAuditItem{TaskID: task.TaskID, State: "failed"}) } data.Failures = append(data.Failures, command.SnapshotFailure(err)) - return command.Partial(data), nil + return command.Success(data), nil } for _, task := range tasks { owner, ownerErr := command.CallJSON[struct { @@ -162,9 +156,6 @@ func taskAuditDefinition() command.Definition[taskAuditArgs, taskAuditData] { } data.Items = append(data.Items, taskAuditItem{TaskID: task.TaskID, OwnerName: owner.Name, State: "success"}) } - if len(data.Failures) > 0 { - return command.Partial(data), nil - } return command.Success(data), nil }, }, @@ -401,7 +392,7 @@ func TestCollectAllPagesHardLimitPreventsFollowingWrite(t *testing.T) { recorder.AssertScriptConsumed() } -func TestBestEffortScopeFailureReturnsPartialTasks(t *testing.T) { +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{ @@ -415,7 +406,7 @@ func TestBestEffortScopeFailureReturnsPartialTasks(t *testing.T) { if err != nil { t.Fatal(err) } - if !execution.Partial || len(execution.Data.Items) != 2 || len(execution.Data.Failures) != 1 { + if len(execution.Data.Items) != 2 || len(execution.Data.Failures) != 1 { t.Fatalf("execution = %#v", execution) } for _, item := range execution.Data.Items { @@ -432,7 +423,7 @@ func TestBestEffortScopeFailureReturnsPartialTasks(t *testing.T) { recorder.AssertScriptConsumed() } -func TestMultiCallCommandReturnsPartialData(t *testing.T) { +func TestMultiCallCommandRecordsTheFailedOwner(t *testing.T) { wantFailure := command.InvalidResponseErrorf("owner record is unavailable") recorder := commandtest.New(t, commandtest.Respond(map[string]any{ @@ -448,7 +439,7 @@ func TestMultiCallCommandReturnsPartialData(t *testing.T) { if err != nil { t.Fatal(err) } - if !execution.Partial || len(execution.Data.Items) != 2 || len(execution.Data.Failures) != 1 { + 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" { diff --git a/extension/command/commandtest/commandtest.go b/extension/command/commandtest/commandtest.go index 4e5a310d0f..9c1aba8058 100644 --- a/extension/command/commandtest/commandtest.go +++ b/extension/command/commandtest/commandtest.go @@ -102,8 +102,7 @@ func (r *Recorder) commandContext(identity command.Identity, dryRun bool) comman // Execution is the inspected outcome of one business Execute hook. type Execution[Data any] struct { - Data Data - Partial bool + Data Data } // Execute runs Normalize, Validate, and Execute with the restricted test runtime. @@ -136,7 +135,7 @@ func Execute[Args any, Data any](ctx context.Context, recorder *Recorder, identi if !ok { return execution, fmt.Errorf("business Execute returned %T, expected %T", result.Data, execution.Data) } - return Execution[Data]{Data: data, Partial: result.Outcome == "partial"}, nil + return Execution[Data]{Data: data}, nil } // RunWithFlags executes a page-returning command with the framework's standard pagination flags. diff --git a/extension/command/commandtest/commandtest_test.go b/extension/command/commandtest/commandtest_test.go index 7bf32fd054..370c7be607 100644 --- a/extension/command/commandtest/commandtest_test.go +++ b/extension/command/commandtest/commandtest_test.go @@ -126,7 +126,7 @@ func TestRecorderInjectsCancellationIntoPaginationWait(t *testing.T) { recorder.AssertScriptConsumed() } -func TestExecuteRunsPreparationAndReturnsTypedOutcome(t *testing.T) { +func TestExecuteRunsPreparationAndReturnsTypedData(t *testing.T) { type args struct { ID string `flag:"id" schema:"required" doc:"identifier"` } @@ -144,7 +144,7 @@ func TestExecuteRunsPreparationAndReturnsTypedOutcome(t *testing.T) { return nil }, Execute: func(_ context.Context, _ command.CommandContext, args *args) (command.Result[data], error) { - return command.Partial(data{ID: args.ID}), nil + return command.Success(data{ID: args.ID}), nil }, }, } @@ -153,7 +153,7 @@ func TestExecuteRunsPreparationAndReturnsTypedOutcome(t *testing.T) { if err != nil { t.Fatal(err) } - if execution.Data.ID != "normalized-one" || !execution.Partial { + if execution.Data.ID != "normalized-one" { t.Fatalf("execution = %#v", execution) } } diff --git a/extension/command/definition.go b/extension/command/definition.go index 3192e00bcf..1a1ab3a1cf 100644 --- a/extension/command/definition.go +++ b/extension/command/definition.go @@ -234,7 +234,7 @@ type Hooks[Args any, Data any] struct { // 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 or Partial. It is + // 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) diff --git a/extension/command/host.go b/extension/command/host.go index 4afbddb8ca..50f7a2e913 100644 --- a/extension/command/host.go +++ b/extension/command/host.go @@ -248,19 +248,6 @@ func cloneOutputDefinition(output OutputDefinition) OutputDefinition { for index := range output.Data.Overrides { output.Data.Overrides[index].Shape = cloneValueShape(output.Data.Overrides[index].Shape) } - if output.Outcomes.PartialFailure != nil { - partial := *output.Outcomes.PartialFailure - if partial.FailedItems != nil { - failed := *partial.FailedItems - failed.IdentityPaths = append([]string(nil), failed.IdentityPaths...) - failed.FailedValues = append([]JSONValue(nil), failed.FailedValues...) - for index := range failed.FailedValues { - failed.FailedValues[index] = cloneJSONValue(failed.FailedValues[index]) - } - partial.FailedItems = &failed - } - output.Outcomes.PartialFailure = &partial - } return output } diff --git a/extension/command/output.go b/extension/command/output.go index 1b0c5fe5e5..097a6bf1cd 100644 --- a/extension/command/output.go +++ b/extension/command/output.go @@ -3,12 +3,11 @@ package command -// OutputDefinition declares result formats and partial outcomes. +// OutputDefinition declares result formats. type OutputDefinition struct { - Data DataDefinition - Outcomes OutcomeDefinition - Meta ResultMetaDefinition - Mode OutputMode + Data DataDefinition + Meta ResultMetaDefinition + Mode OutputMode DisableHTMLEscaping bool } @@ -19,26 +18,6 @@ type ResultMetaDefinition struct { Pagination bool } -// OutcomeDefinition declares optional non-success outcomes. -type OutcomeDefinition struct { - PartialFailure *PartialFailureDefinition -} - -// PartialFailureDefinition declares the exit code and optional failed-item receipt. -type PartialFailureDefinition struct { - ExitCode int - FailedItems *FailedItemDefinition -} - -// FailedItemDefinition identifies failed records in a partial result. -type FailedItemDefinition struct { - ItemsPath string `json:"items_path"` - IdentityPaths []string `json:"identity_paths"` - AllItems bool `json:"all_items,omitempty"` - StatePath string `json:"state_path,omitempty"` - FailedValues []JSONValue `json:"failed_values,omitempty"` -} - // OutputMode selects the framework output behavior. type OutputMode string @@ -51,12 +30,12 @@ const ( type outcomeKind string -const ( - outcomeSuccess outcomeKind = "success" - outcomePartial outcomeKind = "partial" -) +// 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 or Partial. +// Result is an opaque command result created with Success. type Result[Data any] struct { data Data outcome outcomeKind @@ -68,11 +47,6 @@ func Success[Data any](data Data) Result[Data] { return resultWithOutcome(data, outcomeSuccess) } -// Partial creates a partial result whose completed operations remain in Data. -func Partial[Data any](data Data) Result[Data] { - return resultWithOutcome(data, outcomePartial) -} - 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 { diff --git a/internal/commandhost/compile.go b/internal/commandhost/compile.go index d9ca622a1a..59cbd3da56 100644 --- a/internal/commandhost/compile.go +++ b/internal/commandhost/compile.go @@ -207,18 +207,6 @@ func convertOutput(output command.OutputDefinition) (common.OutputDefinition, er Meta: common.ResultMetaDefinition{Count: output.Meta.Count, Pagination: output.Meta.Pagination}, Mode: common.OutputMode(output.Mode), DisableHTMLEscaping: output.DisableHTMLEscaping, } - if output.Outcomes.PartialFailure != nil { - partial := output.Outcomes.PartialFailure - convertedPartial := &common.PartialFailureDefinition{ExitCode: partial.ExitCode} - if partial.FailedItems != nil { - failed := partial.FailedItems - convertedPartial.FailedItems = &common.FailedItemDefinition{ - ItemsPath: failed.ItemsPath, IdentityPaths: append([]string(nil), failed.IdentityPaths...), AllItems: failed.AllItems, - StatePath: failed.StatePath, FailedValues: append([]common.JSONValue(nil), failed.FailedValues...), - } - } - converted.Outcomes.PartialFailure = convertedPartial - } return converted, nil } From b9ed2162ad1ca4f96dceacb9dec09724f62b4d7a Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:40:05 +0800 Subject: [PATCH 36/47] refactor(shortcuts): walk pages once for built-in and external commands Commit 4d0c6ea61 added internal/pagination, moved PaginateInto onto it, and then wrote a second caller for externally declared commands. Both assembled the same Walk options, cloned the same params, read the same cursor and mapped the same walk error; only the policy source, the call path and the accumulator ever differed. Those three now parameterize one pageWalk. PaginateInto keeps calling through the RuntimeContext and keeps its per-page progress line; external commands keep CallTypedAPI, the walker's context and their undecoded pages, which the public contract needs because it decodes them into its own Page[T]. Behavior is unchanged on both sides -- the external walk still leaves Wait nil, which internal/pagination fills with WaitContext, so --page-delay works exactly as before. pageWalk is deliberately generic-free so one struct serves both callers; the typed half of the built-in path moved to addDecodedPage. --- shortcuts/common/paginate_into.go | 98 ++++++++++++++----- shortcuts/common/typed_external_pagination.go | 35 +++---- 2 files changed, 86 insertions(+), 47 deletions(-) diff --git a/shortcuts/common/paginate_into.go b/shortcuts/common/paginate_into.go index 95e7686ea0..91467b02ae 100644 --- a/shortcuts/common/paginate_into.go +++ b/shortcuts/common/paginate_into.go @@ -9,6 +9,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "time" "github.com/larksuite/cli/errs" @@ -63,47 +64,92 @@ func paginateInto[T any](runtime *RuntimeContext, request PageRequest, dst PageA 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 +} + +// 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 +} + +func (w pageWalk) run(ctx context.Context) (internalpagination.State, error) { state, walkErr := internalpagination.Walk(ctx, internalpagination.Options{ - InitialToken: pageTokenParam(request.Params), - MaxPages: policy.maxPages, - Delay: policy.pageDelay, - Wait: wait, - Fetch: func(_ context.Context, pageNumber int, pageToken string) (bool, string, error) { - params := clonePageParams(request.Params) + 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 != "" { - params["page_token"] = pageToken + page.Params["page_token"] = pageToken } - if policy.showProgress { - fmt.Fprintf(runtime.IO().ErrOut, "[page %d] fetching...\n", pageNumber) + if w.progress != nil { + fmt.Fprintf(w.progress, "[page %d] fetching...\n", pageNumber) } - data, err := runtime.CallAPITyped(request.Method, request.Path, params, request.Body) + data, err := w.fetch(ctx, page) if err != nil { return false, "", err } - page, err := decodePageData[T](data, pageNumber) - if err != nil { + if err := w.accumulate(data, pageNumber); err != nil { return false, "", err } - if err := dst.AddPage(page); err != nil { - if _, ok := errs.ProblemOf(err); ok { - return false, "", err - } - return false, "", errs.NewInternalError(errs.SubtypeUnknown, - "accumulate pagination page %d: %v", pageNumber, err). - WithCause(err) - } hasMore, nextPageToken := PaginationMeta(data) return hasMore, nextPageToken, nil }, }) - meta.Complete = state.Complete - meta.Pages = state.Pages - meta.NextToken = state.NextToken - if walkErr == nil { - return meta, nil + 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 meta, paginationWalkError(walkErr) + return nil } func paginationWalkError(walkErr error) error { diff --git a/shortcuts/common/typed_external_pagination.go b/shortcuts/common/typed_external_pagination.go index a9ecac4408..948caa30cc 100644 --- a/shortcuts/common/typed_external_pagination.go +++ b/shortcuts/common/typed_external_pagination.go @@ -19,38 +19,31 @@ type CommandPageCollection struct { NextToken string } -// CollectCommandPages uses the shared cursor walker for an externally declared command. +// CollectCommandPages walks pages for an externally declared command. It runs +// the same pageWalk built-in shortcuts use; only the policy source, the call +// path and the accumulator differ. Pages stay undecoded here because the public +// command contract decodes them into its own Page[T]. func CollectCommandPages(ctx context.Context, command CommandContext, request PageRequest, all bool) (CommandPageCollection, error) { policy, err := commandPagePolicy(command, all) if err != nil { return CommandPageCollection{}, err } collection := CommandPageCollection{} - state, walkErr := internalpagination.Walk(ctx, internalpagination.Options{ - InitialToken: pageTokenParam(request.Params), - MaxPages: policy.maxPages, - Delay: policy.pageDelay, - Fetch: func(ctx context.Context, _ int, pageToken string) (bool, string, error) { - params := clonePageParams(request.Params) - if pageToken != "" { - params["page_token"] = pageToken - } - data, err := CallTypedAPI(ctx, command, request.Method, request.Path, params, request.Body) - if err != nil { - return false, "", err - } + state, walkErr := pageWalk{ + policy: policy, + request: request, + fetch: func(ctx context.Context, page PageRequest) (map[string]interface{}, error) { + return CallTypedAPI(ctx, command, page.Method, page.Path, page.Params, page.Body) + }, + accumulate: func(data map[string]interface{}, _ int) error { collection.Data = append(collection.Data, data) - hasMore, nextToken := PaginationMeta(data) - return hasMore, nextToken, nil + return nil }, - }) + }.run(ctx) collection.Complete = state.Complete collection.Pages = state.Pages collection.NextToken = state.NextToken - if walkErr != nil { - return collection, paginationWalkError(walkErr) - } - return collection, nil + return collection, walkErr } func commandPagePolicy(command CommandContext, all bool) (paginationPolicy, error) { From ce56f509eec160567908830e5234560202b42a41 Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:55:23 +0800 Subject: [PATCH 37/47] refactor(shortcuts): make the external page walk PaginateInto's twin CollectCommandPages now differs from PaginateInto only where the context type forces it. It takes the same PageAccumulator, decodes each page into T through the same addDecodedPage, returns the same *output.PaginationMeta and reads the same state out of the same walk, in the same order. What is left is what the interface cannot supply. An externally declared command compiles in the business module, so it holds a CommandContext rather than a *RuntimeContext: the context arrives as a parameter because the interface carries none, the call goes through CallTypedAPI, and there is no progress line because deciding to print one needs StderrIsTerminal, JqExpr and Format, none of which the interface exposes. The all parameter stays. It is the complete-set policy CollectAllPages depends on -- collect to exhaustion under the hard page bound instead of obeying --page-all and --page-limit -- and PaginateInto has no way to express it, since resolvePaginationPolicy only ever reads flags. Dropping it would quietly turn a command that must see the whole set into one a user can truncate with --page-limit 1. CommandPageCollection is gone with it: pages accumulate in commandhost's own accumulator, the way every built-in shortcut already accumulates its own. One consequence of sharing the decode: a page whose response carries no data object is now an error on this path too, as it always was for built-ins. --- internal/commandhost/compile.go | 21 ++++++--- shortcuts/common/typed_external_pagination.go | 44 +++++++++---------- 2 files changed, 38 insertions(+), 27 deletions(-) diff --git a/internal/commandhost/compile.go b/internal/commandhost/compile.go index 59cbd3da56..087f73a150 100644 --- a/internal/commandhost/compile.go +++ b/internal/commandhost/compile.go @@ -280,6 +280,16 @@ func inputStageContext(host common.CommandContext) command.CommandContext { }) } +// 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 common.CommandContext) command.CommandContext { return command.NewCommandContext(command.ContextOptions{ Identity: command.Identity(host.Identity()), @@ -294,14 +304,15 @@ func publicContext(host common.CommandContext) command.CommandContext { if err := command.ValidateRequestView(view); err != nil { return nil, command.HostPagination{}, err } - collection, err := common.CollectCommandPages(ctx, host, common.PageRequest{ + pages := &commandPages{} + meta, err := common.CollectCommandPages(ctx, host, common.PageRequest{ Method: view.Method, Path: view.Path, Params: view.Query, Body: view.Body, - }, all) + }, all, pages) pagination := command.HostPagination{ - Complete: collection.Complete, Pages: collection.Pages, - NextToken: collection.NextToken, + Complete: meta.Complete, Pages: meta.Pages, + NextToken: meta.NextToken, } - return collection.Data, pagination, err + return pages.data, pagination, err }, }) } diff --git a/shortcuts/common/typed_external_pagination.go b/shortcuts/common/typed_external_pagination.go index 948caa30cc..ce6bb6cdcc 100644 --- a/shortcuts/common/typed_external_pagination.go +++ b/shortcuts/common/typed_external_pagination.go @@ -8,42 +8,42 @@ import ( "time" "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/output" internalpagination "github.com/larksuite/cli/internal/pagination" ) -// CommandPageCollection is the host projection used by the public command adapter. -type CommandPageCollection struct { - Data []map[string]any - Complete bool - Pages int - NextToken string -} - -// CollectCommandPages walks pages for an externally declared command. It runs -// the same pageWalk built-in shortcuts use; only the policy source, the call -// path and the accumulator differ. Pages stay undecoded here because the public -// command contract decodes them into its own Page[T]. -func CollectCommandPages(ctx context.Context, command CommandContext, request PageRequest, all bool) (CommandPageCollection, error) { +// CollectCommandPages 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 CollectCommandPages[T any](ctx context.Context, command CommandContext, request PageRequest, all bool, dst PageAccumulator[T]) (*output.PaginationMeta, error) { + meta := &output.PaginationMeta{} policy, err := commandPagePolicy(command, all) if err != nil { - return CommandPageCollection{}, err + return meta, err } - collection := CommandPageCollection{} state, walkErr := pageWalk{ policy: policy, request: request, fetch: func(ctx context.Context, page PageRequest) (map[string]interface{}, error) { return CallTypedAPI(ctx, command, page.Method, page.Path, page.Params, page.Body) }, - accumulate: func(data map[string]interface{}, _ int) error { - collection.Data = append(collection.Data, data) - return nil + accumulate: func(data map[string]interface{}, pageNumber int) error { + return addDecodedPage(data, pageNumber, dst) }, }.run(ctx) - collection.Complete = state.Complete - collection.Pages = state.Pages - collection.NextToken = state.NextToken - return collection, walkErr + meta.Complete = state.Complete + meta.Pages = state.Pages + meta.NextToken = state.NextToken + return meta, walkErr } func commandPagePolicy(command CommandContext, all bool) (paginationPolicy, error) { From bb92ab453ca2b42353ce1ebb439f41e5b7ff62a2 Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:05:42 +0800 Subject: [PATCH 38/47] fix(shortcuts): reject recursive Data and Args types during compilation shapeForType and compileStructShape called each other without recording the Go types already being walked, so a self-referential type recursed forever. The walk ran during command registration and ended in a stack overflow -- a fatal runtime error rather than a panic, so no recover boundary could contain it and one extension command took the whole CLI down before --help, schema, or any unrelated command could run. That also broke CompileErasedDefinition's documented promise to compile without panic. Thread the struct types open on the current recursion path through both functions and return a compile error on a repeat visit, pointing at the explicit Shape escape hatch. Membership is scoped to the path, not the whole walk, so a type reused as a sibling or at another depth stays legal. Covers self-reference through a slice and through a pointer, mutual recursion through two types, the JSON-encoded Args path, and the public CompileErasedDefinition contract. --- shortcuts/common/typed_compile_args.go | 2 +- shortcuts/common/typed_compile_data.go | 31 ++++- .../common/typed_compile_recursion_test.go | 131 ++++++++++++++++++ 3 files changed, 157 insertions(+), 7 deletions(-) create mode 100644 shortcuts/common/typed_compile_recursion_test.go diff --git a/shortcuts/common/typed_compile_args.go b/shortcuts/common/typed_compile_args.go index 1aac222e5f..3267e57511 100644 --- a/shortcuts/common/typed_compile_args.go +++ b/shortcuts/common/typed_compile_args.go @@ -154,7 +154,7 @@ func collectArgFields(t reflect.Type, parentIndex []int, insideInline bool, out 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) + 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) } diff --git a/shortcuts/common/typed_compile_data.go b/shortcuts/common/typed_compile_data.go index 3646921c4d..e1829631c0 100644 --- a/shortcuts/common/typed_compile_data.go +++ b/shortcuts/common/typed_compile_data.go @@ -39,7 +39,7 @@ func compileData(dataType reflect.Type, definition DataDefinition) (ValueShape, 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") + shape, err := compileStructShape(dataType, false, "Data", map[reflect.Type]struct{}{}) if err != nil { return nil, err } @@ -57,7 +57,10 @@ func compileData(dataType reflect.Type, definition DataDefinition) (ValueShape, return shape, nil } -func shapeForType(t reflect.Type, schema schemaTag, input bool) (ValueShape, error) { +// 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{}) (ValueShape, error) { baseType := t for baseType.Kind() == reflect.Pointer { baseType = baseType.Elem() @@ -137,7 +140,7 @@ func shapeForType(t reflect.Type, schema schemaTag, input bool) (ValueShape, err return nil, fmt.Errorf("array field has incompatible schema constraint") } elementSchema := schemaTag{required: true} - elementShape, err := shapeForType(baseType.Elem(), elementSchema, input) + elementShape, err := shapeForType(baseType.Elem(), elementSchema, input, active) if err != nil { return nil, fmt.Errorf("array item: %w", err) } @@ -149,7 +152,7 @@ func shapeForType(t reflect.Type, schema schemaTag, input bool) (ValueShape, err 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()) + object, err := compileStructShape(baseType, input, baseType.String(), active) if err != nil { return nil, err } @@ -167,7 +170,23 @@ func shapeForType(t reflect.Type, schema schemaTag, input bool) (ValueShape, err return shape, nil } -func compileStructShape(t reflect.Type, input bool, path string) (ObjectShape, error) { +// 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{}) (ObjectShape, error) { + if _, cyclic := active[t]; cyclic { + return ObjectShape{}, fmt.Errorf("recursive type %s requires an explicit Shape", t) + } + active[t] = struct{}{} + defer delete(active, t) + shape := ObjectShape{} seen := make(map[string]string) for i := 0; i < t.NumField(); i++ { @@ -221,7 +240,7 @@ func compileStructShape(t reflect.Type, input bool, path string) (ObjectShape, e if input && description == "" { return ObjectShape{}, fmt.Errorf("%s field %s (%s): description is required via doc", path, field.Name, name) } - fieldShape, err := shapeForType(field.Type, schema, input) + fieldShape, err := shapeForType(field.Type, schema, input, active) if err != nil { return ObjectShape{}, fmt.Errorf("%s field %s (%s): %w", path, field.Name, name, err) } diff --git a/shortcuts/common/typed_compile_recursion_test.go b/shortcuts/common/typed_compile_recursion_test.go new file mode 100644 index 0000000000..3798a0669e --- /dev/null +++ b/shortcuts/common/typed_compile_recursion_test.go @@ -0,0 +1,131 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "context" + "reflect" + "strings" + "testing" +) + +// 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, DataDefinition{}) + 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, DataDefinition{}); 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, InputDefinition{}) + if err == nil || !strings.Contains(err.Error(), "recursive type common.recursiveSlice") { + t.Fatalf("error = %v, want containing recursive type diagnostic", err) + } +} + +// TestCompileErasedDefinitionRejectsRecursiveDataWithoutCrashing 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 TestCompileErasedDefinitionRejectsRecursiveDataWithoutCrashing(t *testing.T) { + type recursionArgs struct { + Name string `flag:"name" schema:"optional" doc:"a name"` + } + _, err := CompileErasedDefinition(ErasedDefinition{ + Metadata: CommandMetadata{ + Service: "probe", + Command: "+tree", + Description: "probe", + Risk: RiskRead, + Authorization: AuthorizationDefinition{ + Identities: map[Identity]IdentityAuthorization{ + IdentityUser: {RequiredScopes: []string{"probe:read"}}, + }, + }, + }, + ArgsType: reflect.TypeFor[recursionArgs](), + DataType: reflect.TypeFor[recursiveSlice](), + Hooks: ErasedHooks{ + NewArgs: func() any { return &recursionArgs{} }, + Execute: func(context.Context, CommandContext, any) (ErasedResult, error) { + return ErasedResult{Data: recursiveSlice{}}, nil + }, + }, + }) + if err == nil || !strings.Contains(err.Error(), "recursive type common.recursiveSlice") { + t.Fatalf("CompileErasedDefinition() error = %v, want containing recursive type diagnostic", err) + } +} From e235a5919c89889b9b46eb4437178ff3f41f2d0c Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:05:50 +0800 Subject: [PATCH 39/47] fix(command): share one result protocol between the host and commandtest commandtest.Execute only checked that Data carried the expected type. Generic erasure leaves a correctly typed zero Data behind, so a business command that returned Result{} instead of Success(data) passed the type assertion and the test reported success. Production rejects the same result, which left extension authors with green tests and a command that failed on every real invocation -- exactly the guarantee commandtest exists to provide. Add ValidateHostResult in extension/command and call it from both commandtest.Execute and the host adapter's execute hook, so the two surfaces cannot drift. It rejects an empty or unsupported outcome and mirrors the pagination receipt checks the host already applies: declared Page output, pages of at least one, non-negative items, and next-token state consistent with completeness. RunWithFlags is covered because it delegates to Execute. --- extension/command/commandtest/commandtest.go | 7 ++ .../commandtest/result_protocol_test.go | 87 +++++++++++++ extension/command/result_protocol.go | 57 +++++++++ extension/command/result_protocol_test.go | 117 ++++++++++++++++++ internal/commandhost/compile.go | 18 ++- 5 files changed, 282 insertions(+), 4 deletions(-) create mode 100644 extension/command/commandtest/result_protocol_test.go create mode 100644 extension/command/result_protocol.go create mode 100644 extension/command/result_protocol_test.go diff --git a/extension/command/commandtest/commandtest.go b/extension/command/commandtest/commandtest.go index 9c1aba8058..c4974863a0 100644 --- a/extension/command/commandtest/commandtest.go +++ b/extension/command/commandtest/commandtest.go @@ -131,6 +131,13 @@ func Execute[Args any, Data any](ctx context.Context, recorder *Recorder, identi } 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) 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/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/internal/commandhost/compile.go b/internal/commandhost/compile.go index 087f73a150..9d59a8c480 100644 --- a/internal/commandhost/compile.go +++ b/internal/commandhost/compile.go @@ -112,7 +112,7 @@ func compileCommand(definition command.HostDefinition) (common.Shortcut, error) if err != nil { return common.Shortcut{}, err } - hooks := convertHooks(definition.Hooks) + hooks := convertHooks(definition) hooks.NewArgs = definition.NewArgs return common.CompileErasedDefinition(common.ErasedDefinition{ Metadata: metadata, @@ -210,12 +210,13 @@ func convertOutput(output command.OutputDefinition) (common.OutputDefinition, er return converted, nil } -func convertHooks(hooks command.HostHooks) common.ErasedHooks { +func convertHooks(definition command.HostDefinition) common.ErasedHooks { + hooks := definition.Hooks return common.ErasedHooks{ Normalize: adaptHook(hooks.Normalize), Validate: adaptHook(hooks.Validate), DryRun: adaptDryRunHook(hooks.DryRun), - Execute: adaptExecuteHook(hooks.Execute), + Execute: adaptExecuteHook(definition), Renderers: cloneRenderers(hooks.Renderers), } } @@ -239,12 +240,21 @@ func adaptDryRunHook(hook func(context.Context, command.CommandContext, any) *co } } -func adaptExecuteHook(hook func(context.Context, command.CommandContext, any) (command.HostResult, error)) func(context.Context, common.CommandContext, any) (common.ErasedResult, error) { +func adaptExecuteHook(definition command.HostDefinition) func(context.Context, common.CommandContext, any) (common.ErasedResult, error) { + hook := definition.Hooks.Execute if hook == nil { return nil } return func(ctx context.Context, host common.CommandContext, args any) (common.ErasedResult, 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 common.ErasedResult{}, invalid + } + } converted := common.ErasedResult{Data: result.Data, Outcome: common.OutcomeKind(result.Outcome)} if result.Pagination != nil { converted.Meta = &common.ResultMeta{Pagination: &common.ResultPaginationMeta{ From ee931a519e22ab4024898eb466af48de5eaabf16 Mon Sep 17 00:00:00 2001 From: liangshuo-1 <266696938+liangshuo-1@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:49:07 +0800 Subject: [PATCH 40/47] feat(command): expose reusable download capabilities --- cmd/root.go | 8 +- cmd/root_context_test.go | 30 ++ extension/command/commandtest/commandtest.go | 199 +++++++++++- .../command/commandtest/commandtest_test.go | 83 +++++ extension/command/context.go | 6 + extension/command/definition.go | 5 +- extension/command/dryrun.go | 18 +- extension/command/file.go | 171 ++++++++++ extension/command/file_test.go | 148 +++++++++ extension/command/testdata/wrapper/main.go | 59 +++- extension/command/wrapper_e2e_test.go | 5 + extension/download/README.md | 67 ++++ {internal => extension}/download/download.go | 29 +- .../download/download_test.go | 0 .../download/exact_length.go | 0 .../download/exact_length_test.go | 0 .../download/idle_timeout.go | 0 .../download/idle_timeout_test.go | 0 extension/download/public_contract_test.go | 51 +++ {internal => extension}/download/response.go | 0 .../download/response_test.go | 0 {internal => extension}/download/source.go | 22 +- internal/cmdutil/dryrun.go | 44 ++- internal/cmdutil/dryrun_test.go | 27 ++ internal/commandhost/compile.go | 20 ++ internal/commandhost/download.go | 143 +++++++++ internal/commandhost/download_test.go | 294 ++++++++++++++++++ .../transport.go | 37 +-- .../transport_test.go | 55 +++- .../im/im_messages_resources_download.go | 11 +- 30 files changed, 1459 insertions(+), 73 deletions(-) create mode 100644 cmd/root_context_test.go create mode 100644 extension/command/file.go create mode 100644 extension/command/file_test.go create mode 100644 extension/download/README.md rename {internal => extension}/download/download.go (95%) rename {internal => extension}/download/download_test.go (100%) rename {internal => extension}/download/exact_length.go (100%) rename {internal => extension}/download/exact_length_test.go (100%) rename {internal => extension}/download/idle_timeout.go (100%) rename {internal => extension}/download/idle_timeout_test.go (100%) create mode 100644 extension/download/public_contract_test.go rename {internal => extension}/download/response.go (100%) rename {internal => extension}/download/response_test.go (100%) rename {internal => extension}/download/source.go (80%) create mode 100644 internal/commandhost/download.go create mode 100644 internal/commandhost/download_test.go rename internal/{download => downloadtransport}/transport.go (81%) rename internal/{download => downloadtransport}/transport_test.go (77%) 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/extension/command/commandtest/commandtest.go b/extension/command/commandtest/commandtest.go index c4974863a0..2ff99a0ae0 100644 --- a/extension/command/commandtest/commandtest.go +++ b/extension/command/commandtest/commandtest.go @@ -24,14 +24,26 @@ import ( // 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} } @@ -42,6 +54,9 @@ type Recorder struct { mu sync.Mutex responses []Response requests []command.RequestView + urls []string + files []RecordedFile + operations int scopeChecks [][]string scopeError error pagination command.PaginationOptions @@ -68,6 +83,32 @@ func (r *Recorder) ReplyJSON(method, path string, data any) *Recorder { 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) @@ -95,6 +136,8 @@ func (r *Recorder) commandContext(identity command.Identity, dryRun bool) comman Identity: identity, DryRun: dryRun, CallJSON: r.callJSON, + Download: r.download, + DownloadURL: r.downloadURL, PreflightScopes: r.preflightScopes, CollectPages: r.collectPages, }) @@ -105,6 +148,15 @@ 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. 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] @@ -259,6 +311,25 @@ func (r *Recorder) Requests() []command.RequestView { 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() @@ -305,12 +376,91 @@ func (r *Recorder) AssertDryRunMatches(dryRun *command.DryRun) { 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) { - if err := ctx.Err(); err != nil { + 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 { @@ -318,10 +468,11 @@ func (r *Recorder) callJSON(ctx context.Context, request command.Request) (map[s } r.mu.Lock() r.requests = append(r.requests, cloned) - requestNumber := len(r.requests) + r.operations++ + requestNumber := r.operations if len(r.responses) == 0 { r.mu.Unlock() - return nil, fmt.Errorf("request %d has no scripted response", requestNumber) + return Response{}, 0, nil, fmt.Errorf("request %d has no scripted response", requestNumber) } response := r.responses[0] r.responses = r.responses[1:] @@ -329,23 +480,45 @@ func (r *Recorder) callJSON(ctx context.Context, request command.Request) (map[s shouldCancel := r.cancelAfterRequest == requestNumber r.mu.Unlock() if response.expectedMethod != "" && response.expectedMethod != view.Method { - return nil, fmt.Errorf("request %d method = %q, expected %q", requestNumber, view.Method, response.expectedMethod) + 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 nil, fmt.Errorf("request %d path = %q, expected %q", requestNumber, view.Path, response.expectedPath) + 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 +} - if response.err != nil { - return nil, response.err +func (r *Recorder) nextURLResponse(ctx context.Context, rawURL string) (Response, int, func(), error) { + if err := ctx.Err(); err != nil { + return Response{}, 0, nil, err } - data, err := responseDataObject(response.data) - if err != nil { - return nil, fmt.Errorf("scripted response %d: %w", requestNumber, 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) } - if shouldCancel && cancel != nil { - cancel() + 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) } - return data, nil + 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) { diff --git a/extension/command/commandtest/commandtest_test.go b/extension/command/commandtest/commandtest_test.go index 370c7be607..093420ee56 100644 --- a/extension/command/commandtest/commandtest_test.go +++ b/extension/command/commandtest/commandtest_test.go @@ -13,6 +13,7 @@ import ( "time" "github.com/larksuite/cli/extension/command" + "github.com/larksuite/cli/extension/download" ) func TestRecorderScriptsRequestsScopesAndDryRun(t *testing.T) { @@ -43,6 +44,88 @@ func TestRecorderScriptsRequestsScopesAndDryRun(t *testing.T) { 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 + Output string + } + type descriptor struct { + DownloadURL string `json:"download_url"` + } + type data struct { + ID string + Artifact command.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) diff --git a/extension/command/context.go b/extension/command/context.go index c75c0e7919..029d87c4b9 100644 --- a/extension/command/context.go +++ b/extension/command/context.go @@ -16,6 +16,8 @@ type CommandContext struct { 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) } @@ -42,6 +44,8 @@ type ContextOptions struct { 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) } @@ -53,6 +57,8 @@ func NewCommandContext(options ContextOptions) CommandContext { dryRun: options.DryRun, inputStage: options.InputStage, callJSON: options.CallJSON, + download: options.Download, + downloadURL: options.DownloadURL, preflightScopes: options.PreflightScopes, collectPages: options.CollectPages, } diff --git a/extension/command/definition.go b/extension/command/definition.go index 1a1ab3a1cf..f380e69479 100644 --- a/extension/command/definition.go +++ b/extension/command/definition.go @@ -3,8 +3,9 @@ // Package command defines the public contract for build-time command extensions. // -// Business command authors use Definition, Define, and the CommandContext -// helpers. The Host* types, InspectCommand, InspectDomain and CloneSets are the +// 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. diff --git a/extension/command/dryrun.go b/extension/command/dryrun.go index c17e5814b1..76d79f6642 100644 --- a/extension/command/dryrun.go +++ b/extension/command/dryrun.go @@ -7,6 +7,17 @@ package command 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 @@ -83,6 +94,7 @@ func (d *DryRun) Desc(description string) *DryRun { type DryRunView struct { Description string Requests []RequestView + Files []FileIntent } // InspectDryRun returns a copied dry-run projection for host adapters and tests. @@ -90,7 +102,11 @@ func InspectDryRun(dryRun *DryRun) DryRunView { if dryRun == nil { return DryRunView{} } - view := DryRunView{Description: dryRun.description, Requests: make([]RequestView, len(dryRun.requests))} + 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) } 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/testdata/wrapper/main.go b/extension/command/testdata/wrapper/main.go index 3e10ab7ba9..50c0e2cb7b 100644 --- a/extension/command/testdata/wrapper/main.go +++ b/extension/command/testdata/wrapper/main.go @@ -10,6 +10,7 @@ import ( defaultaffordance "github.com/larksuite/cli/affordance" "github.com/larksuite/cli/cmd" "github.com/larksuite/cli/extension/command" + "github.com/larksuite/cli/extension/download" defaultskills "github.com/larksuite/cli/skills" _ "github.com/larksuite/cli/extension/credential/env" @@ -50,14 +51,64 @@ var readCommand = command.Define(command.Definition[readArgs, readData]{ }, }) +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 + }, + }, +}) + func main() { cmd.SetEmbeddedSkillContent(defaultskills.DefaultFS()) cmd.SetEmbeddedAffordanceContent(defaultaffordance.DefaultFS()) os.Exit(cmd.ExecuteWithOptions( - cmd.WithCommandSets(command.Set{ - Domain: command.ExtendDomain(command.DomainIm), - Commands: []command.Command{readCommand}, - }), + cmd.WithCommandSets( + command.Set{Domain: command.ExtendDomain(command.DomainIm), Commands: []command.Command{readCommand}}, + 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 index 5774935c5e..ea013be5ac 100644 --- a/extension/command/wrapper_e2e_test.go +++ b/extension/command/wrapper_e2e_test.go @@ -50,6 +50,11 @@ func TestExternalWrapperCommandSurface(t *testing.T) { 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 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/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/commandhost/compile.go b/internal/commandhost/compile.go index 9d59a8c480..387157f9f3 100644 --- a/internal/commandhost/compile.go +++ b/internal/commandhost/compile.go @@ -14,6 +14,7 @@ import ( 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/registry" "github.com/larksuite/cli/shortcuts" "github.com/larksuite/cli/shortcuts/common" @@ -308,6 +309,12 @@ func publicContext(host common.CommandContext) command.CommandContext { view := command.InspectRequest(request) return common.DoTypedAPIJSON(ctx, host, view.Method, view.Path, queryParams(view.Query), view.Body) }, + 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) @@ -406,6 +413,19 @@ func convertDryRun(preview *command.DryRun) (*common.DryRunAPI, error) { 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/download.go b/internal/commandhost/download.go new file mode 100644 index 0000000000..54c31c86bb --- /dev/null +++ b/internal/commandhost/download.go @@ -0,0 +1,143 @@ +// 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/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 common.CommandContext, 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 common.CommandContext, 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 common.CommandContext, 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) + } + if target.IfExists == command.IfExistsFail { + 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") + saved, err := fileIO.Save(target.Name, fileio.SaveOptions{ + ContentType: contentType, ContentLength: stream.ContentLength, + }, 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 common.CommandContext, 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 common.CommandContext) (*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..c64c41b3ea --- /dev/null +++ b/internal/commandhost/download_test.go @@ -0,0 +1,294 @@ +// 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 +} 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/shortcuts/im/im_messages_resources_download.go b/shortcuts/im/im_messages_resources_download.go index 31cedc8ed6..f8b992208a 100644 --- a/shortcuts/im/im_messages_resources_download.go +++ b/shortcuts/im/im_messages_resources_download.go @@ -11,8 +11,9 @@ import ( "time" "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/download" "github.com/larksuite/cli/extension/fileio" - "github.com/larksuite/cli/internal/download" + "github.com/larksuite/cli/internal/downloadtransport" "github.com/larksuite/cli/shortcuts/common" ) @@ -231,12 +232,12 @@ func parseContentDispositionFilename(header string) string { } func imResourceDownloadSource(runtime *common.RuntimeContext, messageID, fileKey, fileType string) download.Source { - oapi := download.NewOAPI(runtime.DoAPIStream) + oapi := downloadtransport.NewOAPI(runtime.DoAPIStream) transport := oapi.Get( "/open-apis/im/v1/messages/:message_id/resources/:file_key", - download.PathParam("message_id", messageID), - download.PathParam("file_key", fileKey), - download.Query("type", fileType), + downloadtransport.PathParam("message_id", messageID), + downloadtransport.PathParam("file_key", fileKey), + downloadtransport.Query("type", fileType), ) // A message resource key pins the attachment bytes. return download.ImmutableSource(transport) From 6aca8f561738f51bc043d43c11eace8fb3bdc1ad Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:48:30 +0800 Subject: [PATCH 41/47] test(command): make the chat-brief example testable and cover its hooks The example declared both commands as inline Definition literals handed straight to Define. Define erases the type parameters and returns an opaque Command that cannot hand its Definition back, while commandtest.Execute takes the Definition -- so the shape the example demonstrated could not be unit tested at all. Neither shipped example had a test file, so nobody had walked the copy-the-example-then-add-a-test path. Lift both declarations into Definition-returning functions, the shape the repository's own commandtest suites already use, and keep the compiled Commands as package vars so main is unchanged. The configuration bodies are untouched. Add the tests that shape exists for: the single-read projection and its Validate rejection, plus the Page[T] contract for default single-page reads and a --page-all walk through RunWithFlags. All four run offline through the commandtest recorder. --- extension/command/examples/chat-brief/main.go | 126 ++++++++++-------- .../command/examples/chat-brief/main_test.go | 110 +++++++++++++++ 2 files changed, 180 insertions(+), 56 deletions(-) create mode 100644 extension/command/examples/chat-brief/main_test.go diff --git a/extension/command/examples/chat-brief/main.go b/extension/command/examples/chat-brief/main.go index fcecc6c0e9..fda1556047 100644 --- a/extension/command/examples/chat-brief/main.go +++ b/extension/command/examples/chat-brief/main.go @@ -20,6 +20,7 @@ // ./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 ( @@ -60,41 +61,49 @@ func chatRequest(args *chatBriefArgs) command.Request { Set("user_id_type", args.IDType) } -var chatBrief = command.Define(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"}}, +// 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 + 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"` @@ -116,33 +125,38 @@ func chatListRequest(args *chatListArgs) command.Request { return request } -// chatList declares Page[T] as its Data, so the compiler installs the -// framework pagination flags; the Args stay free of paging fields. -var chatList = command.Define(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"}}, +// 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 + 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()) func main() { // A wrapper main has no implicit embedded content; reuse the repository 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() +} From 644b8ee74b830c872f0fb789cc8f2c458af68bda Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:23:27 +0800 Subject: [PATCH 42/47] test(commandtest): cover the ordered URL download script Recorder.ReplyURL shipped without a caller, so the incremental dead code gate flagged it as new unreachable code and blocked the branch. The existing URL download test uses the unordered RespondFile constructor, which never exercises the URL assertion ReplyURL exists for. Mirror the ReplyJSON pair: one test walks two scripted URLs in order and checks the recorded source URLs, content types, and artifacts; the other points DownloadURL at an unscripted URL and expects the mismatch error. --- .../command/commandtest/commandtest_test.go | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/extension/command/commandtest/commandtest_test.go b/extension/command/commandtest/commandtest_test.go index 093420ee56..c9dd096767 100644 --- a/extension/command/commandtest/commandtest_test.go +++ b/extension/command/commandtest/commandtest_test.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "reflect" + "strconv" "strings" "testing" "time" @@ -190,6 +191,47 @@ func TestRecorderReplyJSONRejectsUnexpectedRequest(t *testing.T) { 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"}}, From cfdc8a73ed92cd1fd629caae8f102cdb18fc930e Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:08:47 +0800 Subject: [PATCH 43/47] feat(command): accept @file input for external commands V1 rejected the file value source, leaving external commands with inline flags and stdin only. A process has one stdin, so a command whose body is too large or too quoted for the shell -- an XML document update is the case that surfaced this -- had no second way to receive it, and the caller had to fall back to shell escaping. Nothing downstream was missing: resolveInputFlags already resolves @path and the @@ escape through the invocation's FileIO, help renders the "@file" affordance, and legacyInputSources maps the source onto the compiled flag. The gap was the public constant and the host allow-list. Export SourceFile and let it through compilation. Unknown sources still fail the same way, which the rewritten host test now pins alongside the compiled flag actually carrying both extra sources -- silently dropping a declared source is the regression worth catching. Give the wrapper fixture a command whose content flag declares all three sources, and drive it end to end: @file and stdin both reach the request body, and the help text advertises them. --- extension/command/definition.go | 8 +++- extension/command/testdata/wrapper/main.go | 47 +++++++++++++++++++++- extension/command/wrapper_e2e_test.go | 33 ++++++++++++++- internal/commandhost/compile.go | 2 +- internal/commandhost/compile_test.go | 37 ++++++++++++++--- 5 files changed, 118 insertions(+), 9 deletions(-) diff --git a/extension/command/definition.go b/extension/command/definition.go index f380e69479..83de9170ed 100644 --- a/extension/command/definition.go +++ b/extension/command/definition.go @@ -152,7 +152,13 @@ type ValueSource string const ( // SourceFlag accepts a literal flag value. SourceFlag ValueSource = "flag" - // SourceStdin accepts a single dash and reads standard input. + // 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" ) diff --git a/extension/command/testdata/wrapper/main.go b/extension/command/testdata/wrapper/main.go index 50c0e2cb7b..dc4fb02085 100644 --- a/extension/command/testdata/wrapper/main.go +++ b/extension/command/testdata/wrapper/main.go @@ -101,12 +101,57 @@ var backupCommand = command.Define(command.Definition[backupArgs, backupData]{ }, }) +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() { cmd.SetEmbeddedSkillContent(defaultskills.DefaultFS()) cmd.SetEmbeddedAffordanceContent(defaultaffordance.DefaultFS()) os.Exit(cmd.ExecuteWithOptions( cmd.WithCommandSets( - command.Set{Domain: command.ExtendDomain(command.DomainIm), Commands: []command.Command{readCommand}}, + 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(), diff --git a/extension/command/wrapper_e2e_test.go b/extension/command/wrapper_e2e_test.go index ea013be5ac..816a196797 100644 --- a/extension/command/wrapper_e2e_test.go +++ b/extension/command/wrapper_e2e_test.go @@ -4,6 +4,7 @@ package command_test import ( + "io" "os" "os/exec" "path/filepath" @@ -28,9 +29,13 @@ func TestExternalWrapperCommandSurface(t *testing.T) { "LARKSUITE_CLI_CONFIG_DIR", "LARKSUITE_CLI_APP_ID", "LARKSUITE_CLI_APP_SECRET", "LARKSUITE_CLI_USER_ACCESS_TOKEN", "LARKSUITE_CLI_PROFILE", ) - run := func(args ...string) string { + // @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", @@ -45,6 +50,10 @@ func TestExternalWrapperCommandSurface(t *testing.T) { } 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") { @@ -66,6 +75,28 @@ func TestExternalWrapperCommandSurface(t *testing.T) { 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) + } + schema := run("schema", "im", "+wrapper-read") if !strings.Contains(schema, `"name": "im +wrapper-read"`) || !strings.Contains(schema, `"outputSchema"`) { t.Fatalf("wrapper schema = %s", schema) diff --git a/internal/commandhost/compile.go b/internal/commandhost/compile.go index 387157f9f3..0b93498763 100644 --- a/internal/commandhost/compile.go +++ b/internal/commandhost/compile.go @@ -170,7 +170,7 @@ func convertInput(input command.InputDefinition) (common.InputDefinition, error) } sources := make([]common.ValueSource, len(field.CLI.ValueSources)) for sourceIndex, source := range field.CLI.ValueSources { - if source != command.SourceFlag && source != command.SourceStdin { + if source != command.SourceFlag && source != command.SourceFile && source != command.SourceStdin { return common.InputDefinition{}, fmt.Errorf("Input.Fields[%d].CLI.ValueSources[%d]: source %q is not supported in V1", index, sourceIndex, source) } sources[sourceIndex] = common.ValueSource(source) diff --git a/internal/commandhost/compile_test.go b/internal/commandhost/compile_test.go index 8f9c93f108..5ecdc896e7 100644 --- a/internal/commandhost/compile_test.go +++ b/internal/commandhost/compile_test.go @@ -6,6 +6,7 @@ package commandhost import ( "context" "errors" + "slices" "strings" "sync/atomic" "testing" @@ -15,6 +16,7 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" + "github.com/larksuite/cli/shortcuts/common" "github.com/spf13/cobra" ) @@ -159,23 +161,48 @@ func TestCompileSetsRejectsSystemFlag(t *testing.T) { } } -func TestCompileSetsRejectsFileInputSource(t *testing.T) { - declaration := command.Define(command.Definition[fixtureArgs, fixtureData]{ +func inputSourceDeclaration(name string, sources ...command.ValueSource) command.Command { + return command.Define(command.Definition[fixtureArgs, fixtureData]{ Metadata: command.CommandMetadata{ - Service: "im", Command: "+external-file-input", Description: "File input", Risk: command.RiskRead, + 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: []command.ValueSource{command.ValueSource("file")}}, + 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(), "source \"file\" is not supported in V1") { + if err == nil || !strings.Contains(err.Error(), "source \"clipboard\" is not supported in V1") { t.Fatalf("CompileSets() error = %v", err) } } From 4bf72fe18d63fb0de10f2e05c356f0c1b0d6903f Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:33:07 +0800 Subject: [PATCH 44/47] revert(content): drop the default content exports from the extension surface Exporting the repository's embedded skills and affordance trees turned two content directories into Go packages, because go:embed cannot reach up out of a package directory and the repository root is package main. That cost two things: a .go file living inside an authored-content tree, where a future content type is silently omitted until someone edits the glob, and an embed directive per tree where the root previously covered both in one. The need it served does not exist. The distribution driving this work ships its own skills and does not consume the official set, and a wrapper that does want them can supply a tree through cmd.SetEmbeddedSkillContent, which is what extension/platform already documents. Restore content_embed.go, the SkillsOverlay comments, and the platform README to their main state, and delete the two exporting packages. The example and fixture wrappers now ship no embedded content, which is what a wrapper that does not compile the repository root actually gets; the e2e assertion that depended on inheriting lark-doc goes with it. --- affordance/content.go | 16 -------- affordance/content_test.go | 15 ------- content_embed.go | 33 ++++++++++++--- extension/command/examples/chat-brief/main.go | 9 ++--- extension/command/testdata/wrapper/main.go | 4 -- extension/command/wrapper_e2e_test.go | 4 -- extension/platform/README.md | 40 ++++++++++++------- extension/platform/skillsoverlay.go | 7 ++-- skills/content.go | 16 -------- skills/content_test.go | 17 -------- 10 files changed, 58 insertions(+), 103 deletions(-) delete mode 100644 affordance/content.go delete mode 100644 affordance/content_test.go delete mode 100644 skills/content.go delete mode 100644 skills/content_test.go diff --git a/affordance/content.go b/affordance/content.go deleted file mode 100644 index 8048c67de2..0000000000 --- a/affordance/content.go +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -// Package affordance exposes the repository's default embedded command guidance. -package affordance - -import ( - "embed" - "io/fs" -) - -//go:embed *.md -var content embed.FS - -// DefaultFS returns the immutable default affordance tree rooted at domain files. -func DefaultFS() fs.FS { return content } diff --git a/affordance/content_test.go b/affordance/content_test.go deleted file mode 100644 index a5acc42f75..0000000000 --- a/affordance/content_test.go +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package affordance - -import ( - "io/fs" - "testing" -) - -func TestDefaultFSContainsDomainGuidance(t *testing.T) { - if _, err := fs.ReadFile(DefaultFS(), "im.md"); err != nil { - t.Fatalf("read im.md: %v", err) - } -} diff --git a/content_embed.go b/content_embed.go index 1cdb1942cf..a31ce63e70 100644 --- a/content_embed.go +++ b/content_embed.go @@ -4,16 +4,37 @@ package main import ( - defaultaffordance "github.com/larksuite/cli/affordance" + "embed" + "fmt" + "io/fs" + "os" + "github.com/larksuite/cli/cmd" - defaultskills "github.com/larksuite/cli/skills" ) +// embeddedContentFS bundles the agent-readable content that must ship in lockstep +// with the binary: each skill's docs (SKILL.md + references/, plus whiteboard's +// routes/ and scenes/) and the per-domain affordance guidance (affordance/*.md). +// Machine-resource skill dirs (assets/, scripts/) are excluded. It's a whitelist — +// a new content type is omitted until added to the embed list. The embed must live +// in this root package because go:embed cannot reach up out of a package's dir. +// +//go:embed skills/*/SKILL.md skills/*/references skills/*/routes skills/*/scenes affordance/*.md +var embeddedContentFS embed.FS + // init wires the embedded content into the CLI. It compiles into `go build .` but // not the single-file preview build (`go build ./main.go`), so that build stays -// self-contained (shipping no embedded content). External wrapper distributions -// can import the same default files from the skills and affordance packages. +// self-contained (shipping no embedded content). Assembly failures warn on stderr +// rather than panicking — embedded content is nice-to-have, not load-bearing. func init() { - cmd.SetEmbeddedSkillContent(defaultskills.DefaultFS()) - cmd.SetEmbeddedAffordanceContent(defaultaffordance.DefaultFS()) + if sub, err := fs.Sub(embeddedContentFS, "skills"); err != nil { + fmt.Fprintln(os.Stderr, "warning: skills embed assembly failed, skills commands disabled:", err) + } else { + cmd.SetEmbeddedSkillContent(sub) + } + if sub, err := fs.Sub(embeddedContentFS, "affordance"); err != nil { + fmt.Fprintln(os.Stderr, "warning: affordance embed assembly failed, command guidance disabled:", err) + } else { + cmd.SetEmbeddedAffordanceContent(sub) + } } diff --git a/extension/command/examples/chat-brief/main.go b/extension/command/examples/chat-brief/main.go index fda1556047..23f210d65a 100644 --- a/extension/command/examples/chat-brief/main.go +++ b/extension/command/examples/chat-brief/main.go @@ -28,10 +28,8 @@ import ( "os" "strings" - defaultaffordance "github.com/larksuite/cli/affordance" "github.com/larksuite/cli/cmd" "github.com/larksuite/cli/extension/command" - defaultskills "github.com/larksuite/cli/skills" _ "github.com/larksuite/cli/extension/credential/env" // activate env credential provider ) @@ -158,11 +156,10 @@ func chatListDefinition() command.Definition[chatListArgs, command.Page[chatItem 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() { - // A wrapper main has no implicit embedded content; reuse the repository - // defaults so official command guidance stays available. - cmd.SetEmbeddedSkillContent(defaultskills.DefaultFS()) - cmd.SetEmbeddedAffordanceContent(defaultaffordance.DefaultFS()) os.Exit(cmd.ExecuteWithOptions( cmd.WithCommandSets(command.Set{ Domain: command.ExtendDomain(command.DomainIm), diff --git a/extension/command/testdata/wrapper/main.go b/extension/command/testdata/wrapper/main.go index dc4fb02085..c136dfe5f2 100644 --- a/extension/command/testdata/wrapper/main.go +++ b/extension/command/testdata/wrapper/main.go @@ -7,11 +7,9 @@ import ( "context" "os" - defaultaffordance "github.com/larksuite/cli/affordance" "github.com/larksuite/cli/cmd" "github.com/larksuite/cli/extension/command" "github.com/larksuite/cli/extension/download" - defaultskills "github.com/larksuite/cli/skills" _ "github.com/larksuite/cli/extension/credential/env" ) @@ -147,8 +145,6 @@ var noteCommand = command.Define(command.Definition[noteArgs, noteData]{ }) func main() { - cmd.SetEmbeddedSkillContent(defaultskills.DefaultFS()) - cmd.SetEmbeddedAffordanceContent(defaultaffordance.DefaultFS()) os.Exit(cmd.ExecuteWithOptions( cmd.WithCommandSets( command.Set{Domain: command.ExtendDomain(command.DomainIm), Commands: []command.Command{readCommand, noteCommand}}, diff --git a/extension/command/wrapper_e2e_test.go b/extension/command/wrapper_e2e_test.go index 816a196797..c7bbb72cc8 100644 --- a/extension/command/wrapper_e2e_test.go +++ b/extension/command/wrapper_e2e_test.go @@ -105,10 +105,6 @@ func TestExternalWrapperCommandSurface(t *testing.T) { if !strings.Contains(completion, "+wrapper-read") { t.Fatalf("wrapper completion = %s", completion) } - skills := run("skills", "list") - if !strings.Contains(skills, "lark-doc") { - t.Fatalf("wrapper skills = %s", skills) - } } func withoutEnvironment(environment []string, names ...string) []string { diff --git a/extension/platform/README.md b/extension/platform/README.md index 23a05c64df..68856fcc7e 100644 --- a/extension/platform/README.md +++ b/extension/platform/README.md @@ -57,37 +57,47 @@ You should see `audit` in the plugin list. That is sufficient for a hook-only plugin such as the audit observer. A wrapper main does not compile lark-cli's repository-root `content_embed.go`, -so distribution content remains an explicit host choice. The repository -defaults are importable from `github.com/larksuite/cli/skills` and -`github.com/larksuite/cli/affordance`. +so distribution content is a separate, explicit host choice. ### Ship skills and command guidance -If the distribution exposes the repository's embedded skills or customizes -them with `EmbeddedSkills`, wire the default content before execution: +If the distribution exposes embedded skills or customizes them with +`EmbeddedSkills`, copy or generate both content trees under the wrapper +package and wire both: ```go package main import ( - "os" + "embed" + "io/fs" + "os" - _ "github.com/me/myplugin" + _ "github.com/me/myplugin" - defaultaffordance "github.com/larksuite/cli/affordance" - "github.com/larksuite/cli/cmd" - defaultskills "github.com/larksuite/cli/skills" + "github.com/larksuite/cli/cmd" ) +//go:embed skills affordance +var distributionContent embed.FS + func main() { - cmd.SetEmbeddedSkillContent(defaultskills.DefaultFS()) - cmd.SetEmbeddedAffordanceContent(defaultaffordance.DefaultFS()) - os.Exit(cmd.Execute()) + skillTree, err := fs.Sub(distributionContent, "skills") + if err != nil { + panic(err) + } + affordanceTree, err := fs.Sub(distributionContent, "affordance") + if err != nil { + panic(err) + } + cmd.SetEmbeddedSkillContent(skillTree) + cmd.SetEmbeddedAffordanceContent(affordanceTree) + os.Exit(cmd.Execute()) } ``` -Custom distributions may instead copy or generate both content trees under -the wrapper package and wire their own `fs.FS` values. Each +`go:embed` only reads files in the package being compiled; it cannot reach +into the replaced `github.com/larksuite/cli` module. Each `skills//` must contain `SKILL.md`. The `affordance/*.md` files are the structured source for command help and canonical skill references; ship the ones for the domains your distribution retains. Without diff --git a/extension/platform/skillsoverlay.go b/extension/platform/skillsoverlay.go index 02d7816b4a..aa466397af 100644 --- a/extension/platform/skillsoverlay.go +++ b/extension/platform/skillsoverlay.go @@ -15,8 +15,7 @@ import "io/fs" // Allow -> Remove -> Overlay, a same-named skill resolving to Overlay. // The repository's root binary provides its base from content_embed.go; // an external wrapper main has no implicit CLI default and must call -// cmd.SetEmbeddedSkillContent before Execute if it relies on that base. The -// repository default is available from skills.DefaultFS. +// cmd.SetEmbeddedSkillContent before Execute if it relies on that base. // // Skills are addressed by exact name (a directory carrying SKILL.md, // e.g. "lark-doc"), not by command path and not by glob — the skill @@ -62,8 +61,8 @@ type SkillsOverlay struct { // Base replaces the host-provided base skill tree instead of layering // over it. nil keeps whatever base the host wired with - // cmd.SetEmbeddedSkillContent; it does not select the repository - // binary's default for an external wrapper main. Every top-level + // cmd.SetEmbeddedSkillContent; it does not import the repository + // binary's default into an external wrapper main. Every top-level // entry must be a valid skill directory containing SKILL.md. Most // integrators leave Base nil and use Remove/Overlay so unchanged // host-provided skills need no copy inside the plugin. diff --git a/skills/content.go b/skills/content.go deleted file mode 100644 index 1a72f63b1e..0000000000 --- a/skills/content.go +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -// Package skills exposes the repository's default embedded skill content. -package skills - -import ( - "embed" - "io/fs" -) - -//go:embed */SKILL.md */references */routes */scenes -var content embed.FS - -// DefaultFS returns the immutable default skill tree rooted at skill names. -func DefaultFS() fs.FS { return content } diff --git a/skills/content_test.go b/skills/content_test.go deleted file mode 100644 index fc621a7bd3..0000000000 --- a/skills/content_test.go +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package skills - -import ( - "io/fs" - "testing" -) - -func TestDefaultFSContainsSkillAndReference(t *testing.T) { - for _, path := range []string{"lark-doc/SKILL.md", "lark-doc/references/lark-doc-fetch.md"} { - if _, err := fs.ReadFile(DefaultFS(), path); err != nil { - t.Fatalf("read %s: %v", path, err) - } - } -} From 6bee2a7d8486f752aa26f1fb07c6c0206354138a Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:55:13 +0800 Subject: [PATCH 45/47] fix(command): close the review findings on API surface and storage commit Five findings from the extension-v1 review, each verified by a test that fails against the previous implementation. Source compatibility of auth.LoginOptions. The shortcut snapshot was an unexported field on a struct that appears in the exported runF signature, which ends positional literals for every caller outside this module. It becomes a closure capture plus an explicit authLoginRun parameter, and the six domain helpers collapse into domainResolver methods, removing five xxxWithShortcuts twins that only tests reached. common.Shortcut.DryRunE goes too: it was a new exported field with no production caller. The unexported typed field stays, so Shortcut itself is still not positional-literal compatible -- that is a deliberate remaining gap, since relocating it would need a global mutable map or a wider signature change. One canonical wire projection. queryValues stringified and dropped nils for the live call while the dry-run preview and the pagination walk forwarded raw values, so a preview could describe a request the runtime would never send. canonicalQuery is now the only projection and all three consumers derive from it. Dry-run output for numeric parameters therefore reads "20" instead of 20, matching what the query string actually carries. No-clobber as a storage guarantee. IfExistsFail checked existence, downloaded, then committed with a rename that replaces unconditionally, so a target created during the transfer was silently overwritten. The commit step is now an optional ExclusiveFileIO capability: content lands in a temp file and is published with Link, which refuses an existing target and never exposes a partial file. A provider without the capability is refused rather than served a guarantee it cannot keep. V1 public surface. Removes NewDomain and its options (host compilation rejected them), HostDomain.IsNew, reservedRootNames, and the unproducible ResultMetaDefinition.Count; narrows Hooks.Renderers to a single PrettyRenderer, since pretty was the only key the compiler accepted; and demotes the generic authoring layer in shortcuts/common to unexported, as no production code outside that package used it. commandtest runs the production compiler. Execute, RunWithFlags and Preview now share compileForTest, so a wrong tag, Shape or relation fails in the unit test instead of at CLI startup. Applying this surfaced two long-standing contract violations in the package's own fixture. --- cmd/auth/auth.go | 2 +- cmd/auth/auth_test.go | 2 +- cmd/auth/login.go | 84 ++++---- cmd/auth/login_brand_filter_test.go | 12 +- cmd/auth/login_interactive.go | 20 +- cmd/auth/login_test.go | 188 ++++++++++++++---- cmd/schema/schema_test.go | 38 ++-- extension/command/command_test.go | 2 +- extension/command/commandtest/commandtest.go | 28 ++- .../command/commandtest/commandtest_test.go | 58 +++++- extension/command/definition.go | 14 +- extension/command/domain.go | 41 +--- extension/command/host.go | 28 +-- extension/command/output.go | 6 +- extension/fileio/types.go | 18 ++ internal/commandhost/compile.go | 84 +++++--- internal/commandhost/compile_test.go | 59 +++++- internal/commandhost/download.go | 30 ++- internal/commandhost/download_test.go | 30 +++ internal/vfs/default.go | 1 + internal/vfs/fs.go | 6 + internal/vfs/localfileio/atomicwrite.go | 57 ++++++ internal/vfs/localfileio/atomicwrite_test.go | 68 +++++++ internal/vfs/localfileio/localfileio.go | 23 +++ internal/vfs/osfs.go | 1 + shortcuts/common/clone_test.go | 4 +- shortcuts/common/runner.go | 15 +- shortcuts/common/runner_jq_test.go | 26 --- shortcuts/common/typed_authorization_test.go | 6 +- shortcuts/common/typed_compile_output.go | 2 +- shortcuts/common/typed_compiler.go | 14 +- shortcuts/common/typed_compiler_test.go | 92 ++++----- shortcuts/common/typed_definition.go | 11 +- .../common/typed_flag_collisions_test.go | 36 ++-- shortcuts/common/typed_flag_schema_test.go | 10 +- shortcuts/common/typed_map_binder_test.go | 28 +-- shortcuts/common/typed_runner_test.go | 38 ++-- shortcuts/common/types.go | 7 +- 38 files changed, 814 insertions(+), 375 deletions(-) diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go index 01ad9d8635..b626d43824 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -59,7 +59,7 @@ func newCmdAuth(f *cmdutil.Factory, projector *recovery.Projector, registered [] } cmdutil.DisableAuthCheck(cmd) - cmd.AddCommand(newCmdAuthLoginWithShortcuts(f, nil, registered)) + 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 449de8d14b..99dbb0b965 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -37,18 +37,22 @@ type LoginOptions struct { Exclude []string NoWait bool DeviceCode string - shortcuts []common.Shortcut } var pollDeviceToken = larkauth.PollDeviceToken // NewCmdAuthLogin creates the auth login subcommand. func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command { - return newCmdAuthLoginWithShortcuts(f, runF, shortcuts.AllShortcuts()) + return newCmdAuthLogin(f, runF, shortcuts.AllShortcuts()) } -func newCmdAuthLoginWithShortcuts(f *cmdutil.Factory, runF func(*LoginOptions) error, registered []common.Shortcut) *cobra.Command { - opts := &LoginOptions{Factory: f, shortcuts: registered} +// 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", @@ -70,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"}) @@ -84,7 +88,7 @@ to generate QR codes (supports ASCII and PNG formats).`, helpBrand = cfg.Brand } } - available := sortedKnownDomainsWithShortcuts(helpBrand, opts.shortcuts) + 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, @@ -94,19 +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 completeDomainWithShortcuts(toComplete, helpBrand, opts.shortcuts), cobra.ShellCompDirectiveNoFileComp + return resolver.complete(toComplete, helpBrand), cobra.ShellCompDirectiveNoFileComp }) return cmd } -// completeDomain returns completions for comma-separated domain values. -func completeDomain(toComplete string) []string { - return completeDomainWithShortcuts(toComplete, "", shortcuts.AllShortcuts()) -} - -func completeDomainWithShortcuts(toComplete string, brand core.LarkBrand, registered []common.Shortcut) []string { - allDomains := sortedKnownDomainsWithShortcuts(brand, registered) +// 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], ",") @@ -125,7 +125,7 @@ func completeDomainWithShortcuts(toComplete string, brand core.LarkBrand, regist } // 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() @@ -160,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 = sortedKnownDomainsWithShortcuts(config.Brand, opts.shortcuts) + selectedDomains = resolver.sorted(config.Brand) break } } // Validate domain names and suggest corrections for unknown ones if len(selectedDomains) > 0 { - knownDomains := allKnownDomainsWithShortcuts(config.Brand, opts.shortcuts) + knownDomains := resolver.allKnown(config.Brand) for _, d := range selectedDomains { if !knownDomains[d] { if suggestion := suggestDomain(d, knownDomains); suggestion != "" { @@ -191,7 +191,7 @@ func authLoginRun(opts *LoginOptions) error { if !hasAnyOption { if !opts.JSON && f.IOStreams.IsTerminal { - result, err := runInteractiveLoginWithShortcuts(f.IOStreams, lang.Base(), msg, config.Brand, opts.shortcuts) + result, err := runInteractiveLogin(f.IOStreams, lang.Base(), msg, config.Brand, resolver) if err != nil { return err } @@ -229,10 +229,10 @@ func authLoginRun(opts *LoginOptions) error { if len(selectedDomains) > 0 || opts.Recommend { var candidateScopes []string if len(selectedDomains) > 0 { - candidateScopes = collectScopesForDomainsWithShortcuts(selectedDomains, "user", config.Brand, opts.shortcuts) + candidateScopes = resolver.scopesFor(selectedDomains, "user", config.Brand) } else { // --recommend without --domain: all domains - candidateScopes = collectScopesForDomainsWithShortcuts(sortedKnownDomainsWithShortcuts(config.Brand, opts.shortcuts), "user", config.Brand, opts.shortcuts) + candidateScopes = resolver.scopesFor(resolver.sorted(config.Brand), "user", config.Brand) } // Filter to auto-approve scopes if --recommend or interactive "common" @@ -517,11 +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 { - return collectScopesForDomainsWithShortcuts(domains, identity, brand, shortcuts.AllShortcuts()) +// 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 collectScopesForDomainsWithShortcuts(domains []string, identity string, brand core.LarkBrand, registered []common.Shortcut) []string { +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 @@ -539,7 +553,7 @@ func collectScopesForDomainsWithShortcuts(domains []string, identity string, bra } // 3. Shortcut scopes matching by Service (only include shortcuts supporting the identity) - for _, sc := range registered { + for _, sc := range r.registered { if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) { continue } @@ -562,25 +576,21 @@ func collectScopesForDomainsWithShortcuts(domains []string, identity string, bra // 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 { - return allKnownDomainsWithShortcuts(brand, shortcuts.AllShortcuts()) -} - -func allKnownDomainsWithShortcuts(brand core.LarkBrand, registered []common.Shortcut) 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 registered { + 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 getDomainMetadataWithShortcuts). + // selector hides it (see domainResolver.metadata). if !registry.HasAuthDomain(sc.Service) { domains[sc.Service] = true } @@ -601,14 +611,14 @@ func shortcutHasDeclaredScopes(shortcut common.Shortcut) bool { // 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 scopelessShortcutOnlyDomains(registered []common.Shortcut) map[string]bool { +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 registered { + for _, sc := range r.registered { seen[sc.Service] = true if shortcutHasDeclaredScopes(sc) { hasScopes[sc.Service] = true @@ -624,12 +634,8 @@ func scopelessShortcutOnlyDomains(registered []common.Shortcut) map[string]bool } // sortedKnownDomains returns all valid domain names sorted alphabetically. -func sortedKnownDomains(brand core.LarkBrand) []string { - return sortedKnownDomainsWithShortcuts(brand, shortcuts.AllShortcuts()) -} - -func sortedKnownDomainsWithShortcuts(brand core.LarkBrand, registered []common.Shortcut) []string { - m := allKnownDomainsWithShortcuts(brand, registered) +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 0e110bf7f1..e64d570607 100644 --- a/cmd/auth/login_brand_filter_test.go +++ b/cmd/auth/login_brand_filter_test.go @@ -11,22 +11,22 @@ import ( ) 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) } @@ -34,12 +34,12 @@ func TestBrandFilter_AppsExcludedOnLark(t *testing.T) { func TestInteractiveDomainMetadataUsesActiveBrand(t *testing.T) { registered := shortcuts.AllShortcuts() - feishuDomains := getDomainMetadataWithShortcuts("en", core.BrandFeishu, registered) + feishuDomains := newDomainResolver(registered).metadata("en", core.BrandFeishu) if !containsDomainMetadata(feishuDomains, "apps") { t.Fatal("apps domain is missing for Feishu interactive login") } - larkDomains := getDomainMetadataWithShortcuts("en", core.BrandLark, registered) + larkDomains := newDomainResolver(registered).metadata("en", core.BrandLark) if containsDomainMetadata(larkDomains, "apps") { t.Fatal("apps domain is present for Lark interactive login") } diff --git a/cmd/auth/login_interactive.go b/cmd/auth/login_interactive.go index a92e1be55f..998152762e 100644 --- a/cmd/auth/login_interactive.go +++ b/cmd/auth/login_interactive.go @@ -15,8 +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" - "github.com/larksuite/cli/shortcuts/common" ) // domainMeta describes a domain for the interactive selector. @@ -32,14 +30,10 @@ type interactiveResult struct { ScopeLevel string // "common" or "all" } -// getDomainMetadata returns metadata for all known domains, sorted by name. -func getDomainMetadata(lang string) []domainMeta { - return getDomainMetadataWithShortcuts(lang, "", shortcuts.AllShortcuts()) -} - -func getDomainMetadataWithShortcuts(lang string, brand core.LarkBrand, registered []common.Shortcut) []domainMeta { - known := allKnownDomainsWithShortcuts(brand, registered) - scopeless := scopelessShortcutOnlyDomains(registered) +// 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] { @@ -76,8 +70,8 @@ func buildDomainMeta(name, lang string) domainMeta { return dm } -func runInteractiveLoginWithShortcuts(ios *cmdutil.IOStreams, lang string, msg *loginMsg, brand core.LarkBrand, registered []common.Shortcut) (*interactiveResult, error) { - allDomains := getDomainMetadataWithShortcuts(lang, brand, registered) +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)) @@ -136,7 +130,7 @@ func runInteractiveLoginWithShortcuts(ios *cmdutil.IOStreams, lang string, msg * } // Compute scope summary - scopes := collectScopesForDomainsWithShortcuts(selectedDomains, "user", brand, registered) + scopes := resolver.scopesFor(selectedDomains, "user", brand) if permLevel == "common" { scopes = registry.FilterAutoApproveScopes(scopes) } diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index 8b7b78f509..917bbacd10 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -17,8 +17,10 @@ import ( "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" @@ -35,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, @@ -137,25 +154,25 @@ func TestShortcutSupportsIdentity_BotOnly(t *testing.T) { } func TestCompleteDomain(t *testing.T) { - want := sortedKnownDomains("") + 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") } if !reflect.DeepEqual(completions, want) { - t.Errorf("completeDomain() = %v, want %v", completions, want) + t.Errorf("complete() = %v, want %v", completions, want) } - if !slices.Contains(completeDomain("not"), "note") { - t.Error("completeDomain() omitted shortcut-only note domain") + 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) @@ -170,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) @@ -179,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") } @@ -193,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") } @@ -203,7 +220,7 @@ 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)) } @@ -229,7 +246,7 @@ func TestShortcutDomainsHaveDescriptions(t *testing.T) { } func TestGetDomainMetadataIncludesNote(t *testing.T) { - for _, domain := range getDomainMetadata("zh") { + for _, domain := range builtinResolver().metadata("zh", "") { if domain.Name == "note" { return } @@ -243,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") } @@ -270,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 @@ -292,7 +309,7 @@ func TestGetDomainMetadata_IncludesFromMeta(t *testing.T) { } func TestGetDomainMetadataIncludesAuthorizableShortcutDomains(t *testing.T) { - domains := getDomainMetadata("zh") + domains := builtinResolver().metadata("zh", "") nameSet := make(map[string]bool) for _, dm := range domains { nameSet[dm.Name] = true @@ -313,24 +330,111 @@ func TestExternalShortcutScopesParticipateInAuthDomainResolution(t *testing.T) { Service: "im", Command: "+business-auth", AuthTypes: []string{"user"}, UserScopes: []string{"im:business.scope:read"}, }} - domains := allKnownDomainsWithShortcuts("", registered) + domains := newDomainResolver(registered).allKnown("") if !domains["im"] { t.Fatal("external shortcut domain is missing from auth domains") } - scopes := collectScopesForDomainsWithShortcuts([]string{"im"}, "user", "", registered) + 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 := getDomainMetadata("zh") - known := allKnownDomains("") - scopeless := scopelessShortcutOnlyDomains(shortcuts.AllShortcuts()) + 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") } @@ -352,11 +456,11 @@ func TestGetDomainMetadataMatchesAllKnownDomainsMinusScopeless(t *testing.T) { // passes domain validation and fails later on scope resolution, not with // "unknown domain". func TestScopelessDomainStaysAddressableViaDomainFlag(t *testing.T) { - known := allKnownDomains("") + known := builtinResolver().allKnown("") if !known["event"] { t.Fatal("event must remain in allKnownDomains to match main behavior") } - if scopes := collectScopesForDomains([]string{"event"}, "user", ""); len(scopes) != 0 { + if scopes := builtinResolver().scopesFor([]string{"event"}, "user", ""); len(scopes) != 0 { t.Fatalf("event scopes = %v, want none", scopes) } } @@ -369,7 +473,7 @@ func TestAuthLoginHelpMatchesKnownDomains(t *testing.T) { if domainFlag == nil { t.Fatal("auth login --domain flag is missing") } - names := sortedKnownDomains("") + 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) @@ -377,7 +481,7 @@ func TestAuthLoginHelpMatchesKnownDomains(t *testing.T) { } 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) @@ -386,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) @@ -448,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") } @@ -497,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) } @@ -849,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") } @@ -966,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) } @@ -981,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) } @@ -1054,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") } @@ -1107,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") } @@ -1175,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") } @@ -1210,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) } @@ -1296,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) } @@ -1352,7 +1456,7 @@ func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t * Ctx: ctx, Scope: "im:message:send", JSON: true, - }) + }, builtinResolver()) if err == nil { t.Fatal("expected error") } @@ -1389,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") } @@ -1425,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") @@ -1434,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)") } @@ -1444,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 { @@ -1454,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/schema/schema_test.go b/cmd/schema/schema_test.go index 2907429245..64cf5a5ecd 100644 --- a/cmd/schema/schema_test.go +++ b/cmd/schema/schema_test.go @@ -13,8 +13,10 @@ import ( "testing" "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/command" "github.com/larksuite/cli/internal/apicatalog" "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/commandhost" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/meta" "github.com/larksuite/cli/shortcuts/common" @@ -38,6 +40,18 @@ func TestSchemaCmd_FlagParsing(t *testing.T) { } } +// mustCompileFixture compiles a business declaration through the production +// compiler. Schema discovery only sees typed shortcuts, and going through the +// host compiler keeps these fixtures on the same path a real distribution takes. +func mustCompileFixture[Args any, Data any](t *testing.T, definition command.Definition[Args, Data]) common.Shortcut { + t.Helper() + shortcut, err := commandhost.CompileDeclaration(command.Define(definition)) + if err != nil { + t.Fatalf("compile fixture: %v", err) + } + return shortcut +} + func TestHiddenShortcutIsExcludedFromSchemaDiscovery(t *testing.T) { type args struct { Value string `flag:"value" schema:"required" doc:"fixture value"` @@ -45,13 +59,13 @@ func TestHiddenShortcutIsExcludedFromSchemaDiscovery(t *testing.T) { type data struct { OK bool `json:"ok" schema:"required" doc:"success state"` } - hidden := common.Define(common.Definition[args, data]{ - Metadata: common.CommandMetadata{ - Service: "hidden-fixture", Command: "+hidden-schema", Description: "Hidden schema fixture", Risk: common.RiskRead, - Authorization: common.AuthorizationDefinition{Identities: map[common.Identity]common.IdentityAuthorization{common.IdentityUser: {}}}, + hidden := mustCompileFixture(t, command.Definition[args, data]{ + Metadata: command.CommandMetadata{ + Service: "hidden-fixture", Command: "+hidden-schema", Description: "Hidden schema fixture", Risk: command.RiskRead, + Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{command.IdentityUser: {}}}, }, - Hooks: common.Hooks[args, data]{Execute: func(context.Context, common.CommandContext, *args) (common.Result[data], error) { - return common.Success(data{OK: true}), nil + Hooks: command.Hooks[args, data]{Execute: func(context.Context, command.CommandContext, *args) (command.Result[data], error) { + return command.Success(data{OK: true}), nil }}, }) hidden.Hidden = true @@ -72,13 +86,13 @@ func TestShortcutSchemaDiscoveryHonorsStrictMode(t *testing.T) { type data struct { OK bool `json:"ok" schema:"required" doc:"success state"` } - userOnly := common.Define(common.Definition[args, data]{ - Metadata: common.CommandMetadata{ - Service: "strict-fixture", Command: "+user-schema", Description: "User schema fixture", Risk: common.RiskRead, - Authorization: common.AuthorizationDefinition{Identities: map[common.Identity]common.IdentityAuthorization{common.IdentityUser: {}}}, + userOnly := mustCompileFixture(t, command.Definition[args, data]{ + Metadata: command.CommandMetadata{ + Service: "strict-fixture", Command: "+user-schema", Description: "User schema fixture", Risk: command.RiskRead, + Authorization: command.AuthorizationDefinition{Identities: map[command.Identity]command.IdentityAuthorization{command.IdentityUser: {}}}, }, - Hooks: common.Hooks[args, data]{Execute: func(context.Context, common.CommandContext, *args) (common.Result[data], error) { - return common.Success(data{OK: true}), nil + Hooks: command.Hooks[args, data]{Execute: func(context.Context, command.CommandContext, *args) (command.Result[data], error) { + return command.Success(data{OK: true}), nil }}, }) registered := []common.Shortcut{userOnly} diff --git a/extension/command/command_test.go b/extension/command/command_test.go index 7b99ae1882..b766c27dc1 100644 --- a/extension/command/command_test.go +++ b/extension/command/command_test.go @@ -64,7 +64,7 @@ func TestHostHooksRejectMismatchedErasedValues(t *testing.T) { Execute: func(context.Context, CommandContext, *contractArgs) (Result[contractData], error) { return Success(contractData{}), nil }, - Renderers: map[string]Renderer[contractData]{"pretty": func(io.Writer, contractData) error { return nil }}, + PrettyRenderer: func(io.Writer, contractData) error { return nil }, }, }) host := InspectCommand(declaration) diff --git a/extension/command/commandtest/commandtest.go b/extension/command/commandtest/commandtest.go index 2ff99a0ae0..e551d7f348 100644 --- a/extension/command/commandtest/commandtest.go +++ b/extension/command/commandtest/commandtest.go @@ -17,6 +17,7 @@ import ( "time" "github.com/larksuite/cli/extension/command" + "github.com/larksuite/cli/internal/commandhost" internalpagination "github.com/larksuite/cli/internal/pagination" "github.com/spf13/pflag" ) @@ -158,9 +159,23 @@ type RecordedFile struct { } // 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 := command.InspectCommand(command.Define(definition)) + declaration, err := compileForTest(definition) + if err != nil { + return execution, err + } commandContext := recorder.CommandContext(identity) inputContext := recorder.InputStageContext(identity) if declaration.Hooks.Normalize != nil { @@ -199,7 +214,11 @@ func Execute[Args any, Data any](ctx context.Context, recorder *Recorder, identi // RunWithFlags executes a page-returning command with the framework's standard pagination flags. func RunWithFlags[Args any, Data any](ctx context.Context, recorder *Recorder, identity command.Identity, definition command.Definition[Args, Data], args *Args, flags ...string) (Execution[Data], error) { - if !command.InspectCommand(command.Define(definition)).PageOutput { + 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) @@ -232,7 +251,10 @@ func parsePaginationFlags(arguments []string) (command.PaginationOptions, error) // 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 := command.InspectCommand(command.Define(definition)) + 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 { diff --git a/extension/command/commandtest/commandtest_test.go b/extension/command/commandtest/commandtest_test.go index c9dd096767..3cf02ca497 100644 --- a/extension/command/commandtest/commandtest_test.go +++ b/extension/command/commandtest/commandtest_test.go @@ -68,15 +68,15 @@ func TestRecorderScriptsFileDownloadAndMatchesDryRunIntent(t *testing.T) { func TestBusinessShortcutComposesOAPIAndURLDownload(t *testing.T) { type args struct { - ID string - Output string + 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 - Artifact command.Artifact + 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 { @@ -351,3 +351,53 @@ func TestRunWithFlagsRejectsNonPageOutput(t *testing.T) { 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/definition.go b/extension/command/definition.go index 83de9170ed..1dc4defa57 100644 --- a/extension/command/definition.go +++ b/extension/command/definition.go @@ -246,14 +246,22 @@ type Hooks[Args any, Data any] struct { // the framework owns the envelope, format and exit code. Execute func(context.Context, CommandContext, *Args) (Result[Data], error) - // Renderers customize --format pretty, keyed by format name. table, CSV and - // NDJSON are rendered by the framework and need no entry here. - Renderers map[string]Renderer[Data] + // 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 diff --git a/extension/command/domain.go b/extension/command/domain.go index bcb3e4cf06..1fc4f6ea5b 100644 --- a/extension/command/domain.go +++ b/extension/command/domain.go @@ -8,52 +8,21 @@ type DomainName string type domainKind uint8 -const ( - domainExtended domainKind = iota + 1 - domainNew -) +const domainExtended domainKind = iota + 1 // Domain is an opaque declaration of where a command set is mounted. type Domain struct { - kind domainKind - name string - options []DomainOption + kind domainKind + name string } -// DomainOption is an opaque property declaration reserved for future new domains. -type DomainOption struct { - kind domainOptionKind - lang string - value string -} - -type domainOptionKind uint8 - -const ( - domainTitle domainOptionKind = iota + 1 - domainDescription -) - // 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)} } -// NewDomain fixes the future new-domain API shape. V1 host compilation rejects it. -func NewDomain(name string, opts ...DomainOption) Domain { - return Domain{kind: domainNew, name: name, options: append([]DomainOption(nil), opts...)} -} - -// Title declares one localized title for a future new domain. -func Title(lang, s string) DomainOption { - return DomainOption{kind: domainTitle, lang: lang, value: s} -} - -// Description declares one localized description for a future new domain. -func Description(lang, s string) DomainOption { - return DomainOption{kind: domainDescription, lang: lang, value: s} -} - // Set groups commands mounted into one domain. type Set struct { _ struct{} diff --git a/extension/command/host.go b/extension/command/host.go index 50f7a2e913..484c576901 100644 --- a/extension/command/host.go +++ b/extension/command/host.go @@ -50,8 +50,7 @@ type HostPagination struct { // HostDomain is the copied domain declaration consumed by lark-cli's host adapter. type HostDomain struct { - Name string - IsNew bool + Name string } type hostDefinition struct { @@ -88,7 +87,7 @@ func bindHooks[Args any, Data any](hooks Hooks[Args, Data]) HostHooks { Validate: bindArgsHook(hooks.Validate, "Validate"), DryRun: bindDryRunHook(hooks.DryRun), Execute: bindExecuteHook(hooks.Execute), - Renderers: bindRenderers(hooks.Renderers), + Renderers: bindPrettyRenderer(hooks.PrettyRenderer), } } @@ -132,22 +131,22 @@ func bindExecuteHook[Args any, Data any](hook func(context.Context, CommandConte } } -func bindRenderers[Data any](renderers map[string]Renderer[Data]) map[string]func(io.Writer, any) error { - if len(renderers) == 0 { +// 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 } - bound := make(map[string]func(io.Writer, any) error, len(renderers)) - for name, renderer := range renderers { - bound[name] = func(writer io.Writer, data any) error { + 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) - } + }, } - return bound } func hostResult[Data any](result Result[Data]) HostResult { @@ -180,7 +179,7 @@ func InspectCommand(command Command) HostDefinition { // InspectDomain returns a copied declaration for lark-cli's host adapter. func InspectDomain(domain Domain) HostDomain { - return HostDomain{Name: domain.name, IsNew: domain.kind == domainNew} + return HostDomain{Name: domain.name} } // CloneSets copies set slices and immutable command declarations for BuildOption @@ -188,16 +187,11 @@ func InspectDomain(domain Domain) HostDomain { func CloneSets(sets []Set) []Set { cloned := make([]Set, len(sets)) for index, set := range sets { - cloned[index] = Set{Domain: cloneDomain(set.Domain), Commands: append([]Command(nil), set.Commands...)} + cloned[index] = Set{Domain: set.Domain, Commands: append([]Command(nil), set.Commands...)} } return cloned } -func cloneDomain(domain Domain) Domain { - domain.options = append([]DomainOption(nil), domain.options...) - return domain -} - func cloneHostHooks(hooks HostHooks) HostHooks { cloned := hooks if len(hooks.Renderers) > 0 { diff --git a/extension/command/output.go b/extension/command/output.go index 097a6bf1cd..309457ef8f 100644 --- a/extension/command/output.go +++ b/extension/command/output.go @@ -13,8 +13,12 @@ type OutputDefinition struct { } // 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 { - Count bool Pagination bool } 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/commandhost/compile.go b/internal/commandhost/compile.go index 0b93498763..aa3685b027 100644 --- a/internal/commandhost/compile.go +++ b/internal/commandhost/compile.go @@ -20,11 +20,6 @@ import ( "github.com/larksuite/cli/shortcuts/common" ) -var reservedRootNames = map[string]struct{}{ - "api": {}, "auth": {}, "completion": {}, "config": {}, "doctor": {}, "event": {}, - "help": {}, "profile": {}, "schema": {}, "skills": {}, "update": {}, "whoami": {}, -} - // CompileSets validates and compiles a complete external contribution without registration. func CompileSets(sets []command.Set) ([]common.Shortcut, error) { sets = command.CloneSets(sets) @@ -70,6 +65,28 @@ func CompileSets(sets []command.Set) ([]common.Shortcut, error) { 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 @@ -88,15 +105,6 @@ func validateDomain(domain command.HostDomain, existing map[string]struct{}) err if name == "" || name != domain.Name { return fmt.Errorf("domain name must be non-empty and trimmed") } - if domain.IsNew { - if _, reserved := reservedRootNames[name]; reserved { - return fmt.Errorf("new domain %q conflicts with a reserved host namespace", name) - } - if _, occupied := existing[name]; occupied { - return fmt.Errorf("new domain %q conflicts with an existing domain", name) - } - return fmt.Errorf("NewDomain(%q) is not supported in V1", name) - } if _, ok := existing[name]; !ok { return fmt.Errorf("ExtendDomain target %q does not exist", name) } @@ -205,7 +213,7 @@ func convertOutput(output command.OutputDefinition) (common.OutputDefinition, er } converted := common.OutputDefinition{ Data: common.DataDefinition{Shape: dataShape, Overrides: dataOverrides}, - Meta: common.ResultMetaDefinition{Count: output.Meta.Count, Pagination: output.Meta.Pagination}, + Meta: common.ResultMetaDefinition{Pagination: output.Meta.Pagination}, Mode: common.OutputMode(output.Mode), DisableHTMLEscaping: output.DisableHTMLEscaping, } return converted, nil @@ -323,7 +331,7 @@ func publicContext(host common.CommandContext) command.CommandContext { } pages := &commandPages{} meta, err := common.CollectCommandPages(ctx, host, common.PageRequest{ - Method: view.Method, Path: view.Path, Params: view.Query, Body: view.Body, + Method: view.Method, Path: view.Path, Params: projectedQuery(view.Query), Body: view.Body, }, all, pages) pagination := command.HostPagination{ Complete: meta.Complete, Pages: meta.Pages, @@ -334,15 +342,43 @@ func publicContext(host common.CommandContext) command.CommandContext { }) } -func queryParams(query map[string]any) larkcore.QueryParams { - params := make(larkcore.QueryParams, len(query)) +// 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 { - values := queryValues(value) - if len(values) > 0 { - params[name] = values + 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 params + return projected } func queryValues(value any) []string { @@ -403,8 +439,8 @@ func convertDryRun(preview *command.DryRun) (*common.DryRunAPI, error) { case "DELETE": converted.DELETE(request.Path) } - if len(request.Query) > 0 { - converted.Params(request.Query) + if params := projectedQuery(request.Query); len(params) > 0 { + converted.Params(params) } if request.Body != nil { converted.Body(request.Body) diff --git a/internal/commandhost/compile_test.go b/internal/commandhost/compile_test.go index 5ecdc896e7..be9d5ae8a1 100644 --- a/internal/commandhost/compile_test.go +++ b/internal/commandhost/compile_test.go @@ -6,6 +6,7 @@ package commandhost import ( "context" "errors" + "reflect" "slices" "strings" "sync/atomic" @@ -126,8 +127,10 @@ func TestCompileSetsRejectsUnsupportedAndUnknownDomains(t *testing.T) { domain command.Domain want string }{ - {name: "reserved new domain", domain: command.NewDomain("auth", command.Title("en", "Auth")), want: "reserved"}, - {name: "unsupported new domain", domain: command.NewDomain("business", command.Description("en", "Business commands")), want: "not supported in V1"}, + // 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 { @@ -351,9 +354,6 @@ func TestExternalDryRunSurfacesValidateError(t *testing.T) { } } -// A Page[T] command's dry-run can only show the first request; the preview -// must say the walk repeats instead of fabricating response-dependent -// page tokens. // 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. @@ -397,3 +397,52 @@ func TestExternalPageDryRunRendersOnlyTheBusinessDescription(t *testing.T) { 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 index 54c31c86bb..4c25793a4b 100644 --- a/internal/commandhost/download.go +++ b/internal/commandhost/download.go @@ -76,7 +76,22 @@ func downloadToFile(ctx context.Context, host common.CommandContext, transport d 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). @@ -104,9 +119,18 @@ func downloadToFile(ctx context.Context, host common.CommandContext, transport d defer stream.Body.Close() contentType := stream.Header.Get("Content-Type") - saved, err := fileIO.Save(target.Name, fileio.SaveOptions{ - ContentType: contentType, ContentLength: stream.ContentLength, - }, stream.Body) + 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) } diff --git a/internal/commandhost/download_test.go b/internal/commandhost/download_test.go index c64c41b3ea..a66035303f 100644 --- a/internal/commandhost/download_test.go +++ b/internal/commandhost/download_test.go @@ -292,3 +292,33 @@ func factoryTestRegistry(t *testing.T, factory *cmdutil.Factory) *httpmock.Regis } 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/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_test.go b/shortcuts/common/clone_test.go index 9538f8be34..f599d95d7f 100644 --- a/shortcuts/common/clone_test.go +++ b/shortcuts/common/clone_test.go @@ -17,14 +17,14 @@ type cloneData struct { } func TestCloneShortcutCopiesCompiledContract(t *testing.T) { - original := Define(Definition[cloneArgs, cloneData]{ + original := defineTypedShortcut(typedDefinition[cloneArgs, cloneData]{ Metadata: CommandMetadata{ Service: "im", Command: "+clone", Description: "Clone", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{ IdentityUser: {RequiredScopes: []string{"im:chat:read"}}, }}, }, - Hooks: Hooks[cloneArgs, cloneData]{Execute: func(context.Context, CommandContext, *cloneArgs) (Result[cloneData], error) { + Hooks: typedHooks[cloneArgs, cloneData]{Execute: func(context.Context, CommandContext, *cloneArgs) (Result[cloneData], error) { return Success(cloneData{}), nil }}, }) diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 652b1f5443..783e123b8e 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -1383,22 +1383,11 @@ func validateEnumFlags(rctx *RuntimeContext, flags []Flag) error { // handleShortcutDryRun renders a shortcut plan without sending its API requests. func handleShortcutDryRun(f *cmdutil.Factory, rctx *RuntimeContext, s *Shortcut) error { - if s.DryRun == nil && s.DryRunE == nil { + if s.DryRun == nil { return ValidationErrorf("--dry-run is not supported for %s %s", s.Service, s.Command). WithParam("--dry-run") } - var ( - dryResult *DryRunAPI - err error - ) - if s.DryRunE != nil { - dryResult, err = s.DryRunE(rctx.ctx, rctx) - } else { - dryResult = s.DryRun(rctx.ctx, rctx) - } - if err != nil { - return err - } + dryResult := s.DryRun(rctx.ctx, rctx) if dryResult != nil { // Same data.context contract as the service/api dry-run paths. dryResult.Context(rctx.Config.AppID, rctx.UserOpenId()) diff --git a/shortcuts/common/runner_jq_test.go b/shortcuts/common/runner_jq_test.go index 155be34c03..3f20fcbb27 100644 --- a/shortcuts/common/runner_jq_test.go +++ b/shortcuts/common/runner_jq_test.go @@ -339,32 +339,6 @@ func TestRunShortcut_DryRunJSONUsesEnvelope(t *testing.T) { } } -func TestRunShortcut_DryRunEReturnsTypedError(t *testing.T) { - sentinel := errs.NewValidationError(errs.SubtypeInvalidArgument, "dry-run input is invalid") - s := &Shortcut{ - Service: "test", Command: "test-shortcut", AuthTypes: []string{"bot"}, - DryRunE: func(context.Context, *RuntimeContext) (*DryRunAPI, error) { - return nil, sentinel - }, - Execute: func(context.Context, *RuntimeContext) error { - t.Fatal("Execute should not run in dry-run") - return nil - }, - } - f := newTestFactory() - cmd := newTestShortcutCmd(s, f) - cmd.Flags().Set("dry-run", "true") - cmd.Flags().Set("as", "bot") - err := runShortcut(cmd, f, s, false) - if !errors.Is(err, sentinel) { - t.Fatalf("runShortcut() error = %v", err) - } - var validation *errs.ValidationError - if !errors.As(err, &validation) || validation.Subtype != errs.SubtypeInvalidArgument { - t.Fatalf("runShortcut() typed error = %#v", err) - } -} - func TestRunShortcut_DryRunWithJq(t *testing.T) { s := &Shortcut{ Service: "test", diff --git a/shortcuts/common/typed_authorization_test.go b/shortcuts/common/typed_authorization_test.go index 45ccbd6491..ea9af9cf4f 100644 --- a/shortcuts/common/typed_authorization_test.go +++ b/shortcuts/common/typed_authorization_test.go @@ -21,9 +21,9 @@ func typedAuthorizationContext(t *testing.T, scopes string) typedCommandContext return typedAuthorizationContextFor(t, validCompilerDefinition(), core.AsUser, scopes) } -func typedAuthorizationContextFor(t *testing.T, definition Definition[compilerArgs, compilerData], identity core.Identity, scopes string) typedCommandContext { +func typedAuthorizationContextFor(t *testing.T, definition typedDefinition[compilerArgs, compilerData], identity core.Identity, scopes string) typedCommandContext { t.Helper() - command := Define(definition).typed + command := defineTypedShortcut(definition).typed factory := &cmdutil.Factory{Credential: credential.NewCredentialProvider(nil, nil, &scopeCheckTokenResolver{ result: &credential.TokenResult{Token: "token", Scopes: scopes}, }, nil)} @@ -97,7 +97,7 @@ func TestTypedAuthorizationHelpUsesCompiledDiscoveryFacts(t *testing.T) { factory, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{}) service := &cobra.Command{Use: "fixture"} - Define(definition).Mount(service, factory) + defineTypedShortcut(definition).Mount(service, factory) command, _, err := service.Find([]string{"+compile"}) if err != nil { t.Fatal(err) diff --git a/shortcuts/common/typed_compile_output.go b/shortcuts/common/typed_compile_output.go index c465142b48..0d9a417200 100644 --- a/shortcuts/common/typed_compile_output.go +++ b/shortcuts/common/typed_compile_output.go @@ -34,7 +34,7 @@ func validateOutputHooks(definition OutputDefinition, renderers map[string]Rende // adapting Args/Data hooks or exposing the private compiled hook type. type RendererMarker struct{ isNil bool } -func rendererMarkers[Data any](renderers map[string]Renderer[Data]) map[string]RendererMarker { +func rendererMarkers[Data any](renderers map[string]typedRenderer[Data]) map[string]RendererMarker { markers := make(map[string]RendererMarker, len(renderers)) for name, renderer := range renderers { markers[name] = RendererMarker{isNil: renderer == nil} diff --git a/shortcuts/common/typed_compiler.go b/shortcuts/common/typed_compiler.go index 467a6c5740..1ffe5920db 100644 --- a/shortcuts/common/typed_compiler.go +++ b/shortcuts/common/typed_compiler.go @@ -16,7 +16,7 @@ import ( // Define compiles a Typed Shortcut definition. Invalid definitions are // programmer errors and panic during registration; no partial legacy fallback // is returned. -func Define[Args any, Data any](definition Definition[Args, Data]) Shortcut { +func defineTypedShortcut[Args any, Data any](definition typedDefinition[Args, Data]) Shortcut { compiled, err := compileDefinition(definition) if err != nil { service := strings.TrimSpace(definition.Metadata.Service) @@ -36,13 +36,10 @@ func Define[Args any, Data any](definition Definition[Args, Data]) Shortcut { return shortcut } -func compileDefinition[Args any, Data any](definition Definition[Args, Data]) (*compiledCommand, error) { +func compileDefinition[Args any, Data any](definition typedDefinition[Args, Data]) (*compiledCommand, error) { if definition.Hooks.Execute == nil { return nil, fmt.Errorf("Hooks.Execute is required") } - if definition.Hooks.DryRun != nil && definition.Hooks.DryRunE != nil { - return nil, fmt.Errorf("Hooks.DryRun and Hooks.DryRunE cannot both be set") - } return compileDefinitionParts( definition.Metadata, definition.Input, @@ -233,7 +230,7 @@ func validateScopeList(scopes []string, path string) error { return nil } -func adaptHooks[Args any, Data any](hooks Hooks[Args, Data]) compiledHooks { +func adaptHooks[Args any, Data any](hooks typedHooks[Args, Data]) compiledHooks { adapted := compiledHooks{newArgs: func() any { return new(Args) }} if hooks.Normalize != nil { adapted.normalize = func(ctx context.Context, cc CommandContext, args any) error { @@ -250,11 +247,6 @@ func adaptHooks[Args any, Data any](hooks Hooks[Args, Data]) compiledHooks { return hooks.DryRun(ctx, cc, args.(*Args)), nil } } - if hooks.DryRunE != nil { - adapted.dryRun = func(ctx context.Context, cc CommandContext, args any) (*DryRunAPI, error) { - return hooks.DryRunE(ctx, cc, args.(*Args)) - } - } adapted.execute = func(ctx context.Context, cc CommandContext, args any) (compiledResult, error) { result, err := hooks.Execute(ctx, cc, args.(*Args)) return compiledResult{data: result.Data, outcome: result.Outcome, meta: result.Meta}, err diff --git a/shortcuts/common/typed_compiler_test.go b/shortcuts/common/typed_compiler_test.go index a3f78f63cb..57104a07b5 100644 --- a/shortcuts/common/typed_compiler_test.go +++ b/shortcuts/common/typed_compiler_test.go @@ -47,8 +47,8 @@ type compilerData struct { Next *string `json:"next,omitempty" schema:"optional;nullable" doc:"next page token"` } -func validCompilerDefinition() Definition[compilerArgs, compilerData] { - return Definition[compilerArgs, compilerData]{ +func validCompilerDefinition() typedDefinition[compilerArgs, compilerData] { + return typedDefinition[compilerArgs, compilerData]{ Metadata: CommandMetadata{ Service: "fixture", Command: "+compile", Description: "Compile a fixture", Risk: RiskWrite, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{ @@ -69,16 +69,16 @@ func validCompilerDefinition() Definition[compilerArgs, compilerData] { }}, Mode: OutputFixedJSON, }, - Hooks: Hooks[compilerArgs, compilerData]{Execute: func(context.Context, CommandContext, *compilerArgs) (Result[compilerData], error) { + Hooks: typedHooks[compilerArgs, compilerData]{Execute: func(context.Context, CommandContext, *compilerArgs) (Result[compilerData], error) { return Success(compilerData{}), nil }}, } } func TestDefineCompilesTypedContract(t *testing.T) { - shortcut := Define(validCompilerDefinition()) + shortcut := defineTypedShortcut(validCompilerDefinition()) if shortcut.typed == nil { - t.Fatal("Define() did not attach compiled contract") + t.Fatal("defineTypedShortcut() did not attach compiled contract") } if shortcut.Service != "fixture" || shortcut.Command != "+compile" || shortcut.Risk != "write" { t.Fatalf("Shortcut metadata = %#v", shortcut) @@ -136,7 +136,7 @@ func TestValueShapeClosedSet(t *testing.T) { func TestDefineClonesTipsAndRejectsBlankTips(t *testing.T) { definition := validCompilerDefinition() definition.Metadata.Tips = []string{"first tip", " second tip "} - shortcut := Define(definition) + shortcut := defineTypedShortcut(definition) definition.Metadata.Tips[0] = "mutated" want := []string{"first tip", " second tip "} if got := shortcut.Tips; !reflect.DeepEqual(got, want) { @@ -156,7 +156,7 @@ func TestDefineClonesTipsAndRejectsBlankTips(t *testing.T) { func TestCompiledTypedSchemaContract(t *testing.T) { definition := validCompilerDefinition() definition.Output.Meta = ResultMetaDefinition{Count: true, Pagination: true} - contract := Define(definition).typed.contract + 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) } @@ -205,7 +205,7 @@ func TestCompiledTypedSchemaContract(t *testing.T) { func TestCompileOutputAcceptsResultLevelPartial(t *testing.T) { definition := validCompilerDefinition() definition.Output.Outcomes.PartialFailure.FailedItems = nil - shortcut := Define(definition) + shortcut := defineTypedShortcut(definition) partial := shortcut.typed.contract.Meta.Outcomes.PartialFailure if !partial.Supported || partial.ExitCode != 3 || partial.FailedItems != nil { t.Fatalf("result-level partial contract = %#v", partial) @@ -214,13 +214,13 @@ func TestCompileOutputAcceptsResultLevelPartial(t *testing.T) { func TestCompiledSchemaRecordsJSONHTMLEscapingPolicy(t *testing.T) { definition := validCompilerDefinition() - contract := Define(definition).typed.contract + 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 = Define(definition).typed.contract + 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) } @@ -229,7 +229,7 @@ func TestCompiledSchemaRecordsJSONHTMLEscapingPolicy(t *testing.T) { func TestCompileOutputDerivesExecutableGenericFormats(t *testing.T) { definition := validCompilerDefinition() definition.Output.Mode = OutputGeneric - definition.Hooks.Renderers = map[string]Renderer[compilerData]{"pretty": func(io.Writer, compilerData) error { return nil }} + 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) @@ -248,14 +248,14 @@ func TestCompileOutputDerivesExecutableGenericFormats(t *testing.T) { } func TestCompileOutputRecordsCompatibilityFallbacks(t *testing.T) { - fixed := Define(validCompilerDefinition()).typed.contract.Meta.Formats + 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 = OutputGeneric - generic := Define(definition).typed.contract.Meta.Formats + 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) } @@ -268,14 +268,14 @@ func TestDefinePreservesCollectionDefaultForCobraAndMapBinder(t *testing.T) { type data struct { OK bool `json:"ok" schema:"required" doc:"success state"` } - definition := Definition[args, data]{ + definition := typedDefinition[args, data]{ Metadata: CommandMetadata{Service: "fixture", Command: "+collection-default", Description: "collection default", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, Input: InputDefinition{Fields: []InputField{{Name: "values", Default: InputDefault{Set: true, Value: []string{"a", "b"}}}}}, - Hooks: Hooks[args, data]{Execute: func(context.Context, CommandContext, *args) (Result[data], error) { + Hooks: typedHooks[args, data]{Execute: func(context.Context, CommandContext, *args) (Result[data], error) { return Success(data{OK: true}), nil }}, } - shortcut := Define(definition) + shortcut := defineTypedShortcut(definition) if got := shortcut.Flags[0].Default; got != `["a","b"]` { t.Fatalf("legacy default = %q", got) } @@ -295,14 +295,14 @@ func TestDefinePanicIncludesCommandAndFieldContext(t *testing.T) { type data struct { OK bool `json:"ok" schema:"required" doc:"success state"` } - definition := Definition[badArgs, data]{ + definition := typedDefinition[badArgs, data]{ Metadata: validCompilerDefinition().Metadata, - Hooks: Hooks[badArgs, data]{Execute: func(context.Context, CommandContext, *badArgs) (Result[data], error) { return Success(data{}), nil }}, + Hooks: typedHooks[badArgs, data]{Execute: func(context.Context, CommandContext, *badArgs) (Result[data], error) { return Success(data{}), nil }}, } defer func() { panicValue := recover() if panicValue == nil { - t.Fatal("Define() did not panic") + t.Fatal("defineTypedShortcut() did not panic") } message := panicValue.(string) for _, want := range []string{"typed shortcut fixture +compile", "Args field Token", "--token", "description is required"} { @@ -311,72 +311,72 @@ func TestDefinePanicIncludesCommandAndFieldContext(t *testing.T) { } } }() - _ = Define(definition) + _ = defineTypedShortcut(definition) } func TestCompileDefinitionRejectsInvalidContracts(t *testing.T) { tests := []struct { name string - mutate func(*Definition[compilerArgs, compilerData]) + mutate func(*typedDefinition[compilerArgs, compilerData]) want string }{ - {"missing service", func(d *Definition[compilerArgs, compilerData]) { d.Metadata.Service = "" }, "Metadata.Service is required"}, - {"unknown risk", func(d *Definition[compilerArgs, compilerData]) { d.Metadata.Risk = Risk("delete") }, "Metadata.Risk"}, - {"unknown relation param", func(d *Definition[compilerArgs, compilerData]) { d.Input.Relations[0].Params[1] = "missing" }, "unknown param --missing"}, - {"unknown conditional param", func(d *Definition[compilerArgs, compilerData]) { + {"missing service", func(d *typedDefinition[compilerArgs, compilerData]) { d.Metadata.Service = "" }, "Metadata.Service is required"}, + {"unknown risk", func(d *typedDefinition[compilerArgs, compilerData]) { d.Metadata.Risk = Risk("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[IdentityUser] auth.ConditionalScopes[0].Params = []string{"missing"} d.Metadata.Authorization.Identities[IdentityUser] = auth }, "unknown param --missing"}, - {"scope both required and conditional", func(d *Definition[compilerArgs, compilerData]) { + {"scope both required and conditional", func(d *typedDefinition[compilerArgs, compilerData]) { auth := d.Metadata.Authorization.Identities[IdentityUser] auth.ConditionalScopes[0].Scopes = []string{"fixture:write"} d.Metadata.Authorization.Identities[IdentityUser] = auth }, "already always required"}, - {"invalid conditional requirement", func(d *Definition[compilerArgs, compilerData]) { + {"invalid conditional requirement", func(d *typedDefinition[compilerArgs, compilerData]) { auth := d.Metadata.Authorization.Identities[IdentityUser] auth.ConditionalScopes[0].Requirement = ScopeRequirement("sometimes") d.Metadata.Authorization.Identities[IdentityUser] = auth }, "Requirement \"sometimes\" is invalid"}, - {"conditional params without when", func(d *Definition[compilerArgs, compilerData]) { + {"conditional params without when", func(d *typedDefinition[compilerArgs, compilerData]) { auth := d.Metadata.Authorization.Identities[IdentityUser] auth.ConditionalScopes[0].When = "" d.Metadata.Authorization.Identities[IdentityUser] = auth }, "Params requires agent-readable When text"}, - {"hidden conditional param", func(d *Definition[compilerArgs, compilerData]) { + {"hidden conditional param", func(d *typedDefinition[compilerArgs, compilerData]) { d.Input.Fields = append(d.Input.Fields, InputField{Name: "payload", CLI: CLIInput{Hidden: true}}) }, "references hidden param --payload"}, - {"missing execute", func(d *Definition[compilerArgs, compilerData]) { d.Hooks.Execute = nil }, "Hooks.Execute is required"}, - {"invalid partial path", func(d *Definition[compilerArgs, compilerData]) { + {"missing execute", func(d *typedDefinition[compilerArgs, compilerData]) { d.Hooks.Execute = nil }, "Hooks.Execute is required"}, + {"invalid partial path", func(d *typedDefinition[compilerArgs, compilerData]) { d.Output.Outcomes.PartialFailure.FailedItems.ItemsPath = "/missing" }, "field \"missing\" does not exist"}, - {"invalid pointer escaping", func(d *Definition[compilerArgs, compilerData]) { + {"invalid pointer escaping", func(d *typedDefinition[compilerArgs, compilerData]) { d.Output.Outcomes.PartialFailure.FailedItems.ItemsPath = "/items/~2" }, "invalid RFC 6901 escaping"}, - {"all-items state conflict", func(d *Definition[compilerArgs, compilerData]) { + {"all-items state conflict", func(d *typedDefinition[compilerArgs, compilerData]) { d.Output.Outcomes.PartialFailure.FailedItems.AllItems = true }, "AllItems conflicts"}, - {"missing failure discriminator", func(d *Definition[compilerArgs, compilerData]) { + {"missing failure discriminator", func(d *typedDefinition[compilerArgs, compilerData]) { d.Output.Outcomes.PartialFailure.FailedItems.StatePath = "" d.Output.Outcomes.PartialFailure.FailedItems.FailedValues = nil }, "requires AllItems"}, - {"failure discriminator outside state enum", func(d *Definition[compilerArgs, compilerData]) { + {"failure discriminator outside state enum", func(d *typedDefinition[compilerArgs, compilerData]) { d.Output.Outcomes.PartialFailure.FailedItems.FailedValues = []JSONValue{"unknown"} }, "must be one of: ok, failed"}, - {"artifact path field required", func(d *Definition[compilerArgs, compilerData]) { + {"artifact path field required", func(d *typedDefinition[compilerArgs, compilerData]) { d.Output.Artifacts = []ArtifactDefinition{{Name: "items", ItemsPath: "/items"}} }, "PathField is required"}, - {"nil renderer", func(d *Definition[compilerArgs, compilerData]) { - d.Hooks.Renderers = map[string]Renderer[compilerData]{"pretty": nil} + {"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 *Definition[compilerArgs, compilerData]) { + {"table renderer", func(d *typedDefinition[compilerArgs, compilerData]) { d.Output.Mode = OutputGeneric - d.Hooks.Renderers = map[string]Renderer[compilerData]{"table": func(io.Writer, compilerData) error { return nil }} + 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 *Definition[compilerArgs, compilerData]) { - d.Hooks.Renderers = map[string]Renderer[compilerData]{"pretty": func(io.Writer, compilerData) error { return nil }} + {"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 *Definition[compilerArgs, compilerData]) { + {"invalid output mode", func(d *typedDefinition[compilerArgs, compilerData]) { d.Output.Mode = OutputMode("yaml") }, "Output.Mode \"yaml\" is invalid"}, } @@ -400,14 +400,14 @@ func TestCompileInputAcceptsExplicitShapeForCustomJSONType(t *testing.T) { OK bool `json:"ok" schema:"required" doc:"success state"` } shape := ObjectShape{Fields: []ValueField{{Name: "value", Description: "custom value", Required: true, Shape: StringShape{}}}} - definition := Definition[args, data]{ + definition := typedDefinition[args, data]{ Metadata: CommandMetadata{Service: "fixture", Command: "+custom-json", Description: "custom JSON input", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, Input: InputDefinition{Fields: []InputField{{Name: "payload", Shape: shape}}}, - Hooks: Hooks[args, data]{Execute: func(context.Context, CommandContext, *args) (Result[data], error) { + Hooks: typedHooks[args, data]{Execute: func(context.Context, CommandContext, *args) (Result[data], error) { return Success(data{OK: true}), nil }}, } - shortcut := Define(definition) + shortcut := defineTypedShortcut(definition) bound, err := bindTypedMap(shortcut.typed, map[string]any{"payload": map[string]any{"value": "x"}}) if err != nil { t.Fatal(err) diff --git a/shortcuts/common/typed_definition.go b/shortcuts/common/typed_definition.go index 35e0465147..67b87d2946 100644 --- a/shortcuts/common/typed_definition.go +++ b/shortcuts/common/typed_definition.go @@ -18,11 +18,11 @@ type JSONValue = any // Definition is the single source of truth for a Typed Shortcut. // See TYPED_SHORTCUTS.md for the framework contract and migration guide. -type Definition[Args any, Data any] struct { +type typedDefinition[Args any, Data any] struct { Metadata CommandMetadata Input InputDefinition Output OutputDefinition - Hooks Hooks[Args, Data] + Hooks typedHooks[Args, Data] } type CommandMetadata struct { @@ -164,16 +164,15 @@ const ( StageAfterPrepare RelationStage = "after_prepare" ) -type Hooks[Args any, Data any] struct { +type typedHooks[Args any, Data any] struct { Normalize func(context.Context, CommandContext, *Args) error Validate func(context.Context, CommandContext, *Args) error DryRun func(context.Context, CommandContext, *Args) *DryRunAPI - DryRunE func(context.Context, CommandContext, *Args) (*DryRunAPI, error) Execute func(context.Context, CommandContext, *Args) (Result[Data], error) - Renderers map[string]Renderer[Data] + Renderers map[string]typedRenderer[Data] } -type Renderer[Data any] func(io.Writer, Data) error +type typedRenderer[Data any] func(io.Writer, Data) error // CommandContext exposes only runtime capabilities available to Typed hooks. type CommandContext interface { diff --git a/shortcuts/common/typed_flag_collisions_test.go b/shortcuts/common/typed_flag_collisions_test.go index e5ed5dc63e..b45b57d9d1 100644 --- a/shortcuts/common/typed_flag_collisions_test.go +++ b/shortcuts/common/typed_flag_collisions_test.go @@ -66,14 +66,14 @@ type collisionPrintOnlyArgs struct { PrintSchema bool `flag:"print-schema" schema:"optional" doc:"business schema switch"` } -func collisionDefinition[Args any](risk Risk, input InputDefinition) Definition[Args, collisionData] { - return Definition[Args, collisionData]{ +func collisionDefinition[Args any](risk Risk, input InputDefinition) typedDefinition[Args, collisionData] { + return typedDefinition[Args, collisionData]{ Metadata: CommandMetadata{ Service: "fixture", Command: "+collision", Description: "collision fixture", Risk: risk, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}, }, Input: input, - Hooks: Hooks[Args, collisionData]{Execute: func(context.Context, CommandContext, *Args) (Result[collisionData], error) { + Hooks: typedHooks[Args, collisionData]{Execute: func(context.Context, CommandContext, *Args) (Result[collisionData], error) { return Success(collisionData{OK: true}), nil }}, } @@ -105,14 +105,22 @@ func TestDefineRejectsActiveFrameworkFlagCollisions(t *testing.T) { run func() want string }{ - {name: "dry-run", run: func() { _ = Define(collisionDefinition[collisionDryRunArgs](RiskRead, InputDefinition{})) }, want: "framework dry-run execution"}, - {name: "as", run: func() { _ = Define(collisionDefinition[collisionAsArgs](RiskRead, InputDefinition{})) }, want: "framework identity selection"}, - {name: "jq", run: func() { _ = Define(collisionDefinition[collisionJQArgs](RiskRead, InputDefinition{})) }, want: "framework output filtering"}, - {name: "profile", run: func() { _ = Define(collisionDefinition[collisionProfileArgs](RiskRead, InputDefinition{})) }, want: "inherited profile selection"}, - {name: "help", run: func() { _ = Define(collisionDefinition[collisionHelpArgs](RiskRead, InputDefinition{})) }, want: "Cobra help"}, - {name: "high-risk yes", run: func() { _ = Define(collisionDefinition[collisionYesArgs](RiskHighRiskWrite, InputDefinition{})) }, want: "framework high-risk confirmation"}, - {name: "print-schema when introspection is active", run: func() { _ = Define(collisionDefinition[collisionPrintSchemaArgs](RiskRead, InputDefinition{})) }, want: "framework complex-input introspection"}, - {name: "flag-name when introspection is active", run: func() { _ = Define(collisionDefinition[collisionFlagNameArgs](RiskRead, InputDefinition{})) }, want: "framework complex-input introspection"}, + {name: "dry-run", run: func() { _ = defineTypedShortcut(collisionDefinition[collisionDryRunArgs](RiskRead, InputDefinition{})) }, want: "framework dry-run execution"}, + {name: "as", run: func() { _ = defineTypedShortcut(collisionDefinition[collisionAsArgs](RiskRead, InputDefinition{})) }, want: "framework identity selection"}, + {name: "jq", run: func() { _ = defineTypedShortcut(collisionDefinition[collisionJQArgs](RiskRead, InputDefinition{})) }, want: "framework output filtering"}, + {name: "profile", run: func() { + _ = defineTypedShortcut(collisionDefinition[collisionProfileArgs](RiskRead, InputDefinition{})) + }, want: "inherited profile selection"}, + {name: "help", run: func() { _ = defineTypedShortcut(collisionDefinition[collisionHelpArgs](RiskRead, InputDefinition{})) }, want: "Cobra help"}, + {name: "high-risk yes", run: func() { + _ = defineTypedShortcut(collisionDefinition[collisionYesArgs](RiskHighRiskWrite, InputDefinition{})) + }, want: "framework high-risk confirmation"}, + {name: "print-schema when introspection is active", run: func() { + _ = defineTypedShortcut(collisionDefinition[collisionPrintSchemaArgs](RiskRead, InputDefinition{})) + }, want: "framework complex-input introspection"}, + {name: "flag-name when introspection is active", run: func() { + _ = defineTypedShortcut(collisionDefinition[collisionFlagNameArgs](RiskRead, InputDefinition{})) + }, want: "framework complex-input introspection"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -122,7 +130,7 @@ func TestDefineRejectsActiveFrameworkFlagCollisions(t *testing.T) { } func TestDefinePreservesExistingBusinessFlagMeanings(t *testing.T) { - shortcut := Define(collisionDefinition[collisionAllowedArgs](RiskWrite, InputDefinition{})) + shortcut := defineTypedShortcut(collisionDefinition[collisionAllowedArgs](RiskWrite, InputDefinition{})) for _, name := range []string{"json", "format", "yes", "version"} { found := false for _, flag := range shortcut.Flags { @@ -162,11 +170,11 @@ func TestDefineRejectsNormalizeAliasThatWouldCollideAfterMount(t *testing.T) { definition := collisionDefinition[collisionAliasArgs](RiskRead, InputDefinition{Fields: []InputField{{ Name: "value", CLI: CLIInput{Aliases: []FlagAlias{{Name: "format", Mode: AliasNormalize}}}, }}}) - requireTypedCollisionPanic(t, func() { _ = Define(definition) }, "Args field Value", "normalize alias --format", "output format") + requireTypedCollisionPanic(t, func() { _ = defineTypedShortcut(definition) }, "Args field Value", "normalize alias --format", "output format") } func TestMountRejectsSchemaCollisionAddedAfterDefine(t *testing.T) { - shortcut := Define(collisionDefinition[collisionPrintOnlyArgs](RiskRead, InputDefinition{})) + shortcut := defineTypedShortcut(collisionDefinition[collisionPrintOnlyArgs](RiskRead, InputDefinition{})) shortcut.PrintFlagSchema = func(string) ([]byte, error) { return []byte(`{}`), nil } factory, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{}) parent := &cobra.Command{Use: "fixture"} diff --git a/shortcuts/common/typed_flag_schema_test.go b/shortcuts/common/typed_flag_schema_test.go index 35a0e72d2b..6f2165d5da 100644 --- a/shortcuts/common/typed_flag_schema_test.go +++ b/shortcuts/common/typed_flag_schema_test.go @@ -48,7 +48,7 @@ func TestTypedFlagSchemaListsAndPrintsCompositeInputsBeforeExecution(t *testing. called = true return Success(compilerData{}), nil } - shortcut := Define(definition) + shortcut := defineTypedShortcut(definition) listing, err := runTypedFlagSchema(t, shortcut, "--print-schema") if err != nil { @@ -107,7 +107,7 @@ func TestIsCompositeValueShape(t *testing.T) { } func TestTypedFlagSchemaUnknownFlagIsTypedValidationError(t *testing.T) { - _, err := runTypedFlagSchema(t, Define(validCompilerDefinition()), "--print-schema", "--flag-name", "missing") + _, err := runTypedFlagSchema(t, defineTypedShortcut(validCompilerDefinition()), "--print-schema", "--flag-name", "missing") var validation *errs.ValidationError problem, ok := errs.ProblemOf(err) if !ok || !errors.As(err, &validation) || problem.Subtype != errs.SubtypeInvalidArgument || validation.Param != "--flag-name" { @@ -125,9 +125,9 @@ func TestTypedFlagSchemaNotRegisteredForScalarInputs(t *testing.T) { type data struct { OK bool `json:"ok" schema:"required" doc:"success state"` } - shortcut := Define(Definition[args, data]{ + shortcut := defineTypedShortcut(typedDefinition[args, data]{ Metadata: CommandMetadata{Service: "fixture", Command: "+scalar", Description: "scalar fixture", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, - Hooks: Hooks[args, data]{Execute: func(context.Context, CommandContext, *args) (Result[data], error) { + Hooks: typedHooks[args, data]{Execute: func(context.Context, CommandContext, *args) (Result[data], error) { return Success(data{OK: true}), nil }}, }) @@ -148,7 +148,7 @@ func TestTypedFlagSchemaNotRegisteredForScalarInputs(t *testing.T) { } func TestTypedFlagSchemaAllowsCompatibilityOverride(t *testing.T) { - shortcut := Define(validCompilerDefinition()) + shortcut := defineTypedShortcut(validCompilerDefinition()) shortcut.PrintFlagSchema = func(flagName string) ([]byte, error) { return []byte(`{"source":"legacy","flag":"` + flagName + `"}`), nil } diff --git a/shortcuts/common/typed_map_binder_test.go b/shortcuts/common/typed_map_binder_test.go index ad540ff1e4..7916c808ab 100644 --- a/shortcuts/common/typed_map_binder_test.go +++ b/shortcuts/common/typed_map_binder_test.go @@ -29,10 +29,10 @@ func aliasBinderCommand(t *testing.T, alias FlagAlias) *compiledCommand { func aliasBinderCommandWithAliases(t *testing.T, aliases []FlagAlias) *compiledCommand { t.Helper() - definition := Definition[aliasBinderArgs, aliasBinderData]{ + definition := typedDefinition[aliasBinderArgs, aliasBinderData]{ Metadata: CommandMetadata{Service: "fixture", Command: "+alias", Description: "alias fixture", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, Input: InputDefinition{Fields: []InputField{{Name: "value", CLI: CLIInput{Aliases: aliases}}}}, - Hooks: Hooks[aliasBinderArgs, aliasBinderData]{Execute: func(context.Context, CommandContext, *aliasBinderArgs) (Result[aliasBinderData], error) { + Hooks: typedHooks[aliasBinderArgs, aliasBinderData]{Execute: func(context.Context, CommandContext, *aliasBinderArgs) (Result[aliasBinderData], error) { return Success(aliasBinderData{OK: true}), nil }}, } @@ -164,10 +164,10 @@ func TestBindTypedMapPresenceNonZeroUsesProvidedValue(t *testing.T) { First Provided[string] `flag:"first" schema:"optional" doc:"first value"` Second Provided[string] `flag:"second" schema:"optional" doc:"second value"` } - definition := Definition[args, aliasBinderData]{ + definition := typedDefinition[args, aliasBinderData]{ Metadata: CommandMetadata{Service: "fixture", Command: "+presence", Description: "presence fixture", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, Input: InputDefinition{Relations: []Relation{{Kind: RelationExactlyOne, Params: []string{"first", "second"}, Presence: PresenceNonZero, Stage: StageAfterPrepare}}}, - Hooks: Hooks[args, aliasBinderData]{Execute: func(context.Context, CommandContext, *args) (Result[aliasBinderData], error) { + Hooks: typedHooks[args, aliasBinderData]{Execute: func(context.Context, CommandContext, *args) (Result[aliasBinderData], error) { return Success(aliasBinderData{OK: true}), nil }}, } @@ -196,10 +196,10 @@ func TestBindTypedMapAcceptsStructuredRawJSONValue(t *testing.T) { Payload json.RawMessage `flag:"payload" schema:"required" cli:"encoding=json" doc:"payload"` } shape := ObjectShape{Fields: []ValueField{{Name: "name", Description: "name", Required: true, Shape: StringShape{}}}} - definition := Definition[args, aliasBinderData]{ + definition := typedDefinition[args, aliasBinderData]{ Metadata: CommandMetadata{Service: "fixture", Command: "+raw-json", Description: "raw JSON fixture", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, Input: InputDefinition{Fields: []InputField{{Name: "payload", Shape: shape}}}, - Hooks: Hooks[args, aliasBinderData]{Execute: func(context.Context, CommandContext, *args) (Result[aliasBinderData], error) { + Hooks: typedHooks[args, aliasBinderData]{Execute: func(context.Context, CommandContext, *args) (Result[aliasBinderData], error) { return Success(aliasBinderData{OK: true}), nil }}, } @@ -220,10 +220,10 @@ func TestBindTypedMapRejectsNullOutsideExplicitShape(t *testing.T) { type args struct { Payload map[string]string `flag:"payload" schema:"optional" cli:"encoding=json" doc:"payload"` } - definition := Definition[args, aliasBinderData]{ + definition := typedDefinition[args, aliasBinderData]{ Metadata: CommandMetadata{Service: "fixture", Command: "+null", Description: "null fixture", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, Input: InputDefinition{Fields: []InputField{{Name: "payload", Shape: ObjectShape{AdditionalProperties: true, AdditionalPropertiesShape: StringShape{}}}}}, - Hooks: Hooks[args, aliasBinderData]{Execute: func(context.Context, CommandContext, *args) (Result[aliasBinderData], error) { + Hooks: typedHooks[args, aliasBinderData]{Execute: func(context.Context, CommandContext, *args) (Result[aliasBinderData], error) { return Success(aliasBinderData{OK: true}), nil }}, } @@ -242,9 +242,9 @@ func TestBindTypedMapEnforcesNumberEnum(t *testing.T) { type args struct { Ratio float64 `flag:"ratio" schema:"required;enum=0.5|1.5" doc:"ratio"` } - definition := Definition[args, aliasBinderData]{ + definition := typedDefinition[args, aliasBinderData]{ Metadata: CommandMetadata{Service: "fixture", Command: "+number-enum", Description: "number enum fixture", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, - Hooks: Hooks[args, aliasBinderData]{Execute: func(context.Context, CommandContext, *args) (Result[aliasBinderData], error) { + Hooks: typedHooks[args, aliasBinderData]{Execute: func(context.Context, CommandContext, *args) (Result[aliasBinderData], error) { return Success(aliasBinderData{OK: true}), nil }}, } @@ -263,9 +263,9 @@ func TestBindTypedMapPreservesLargeIntegerEnum(t *testing.T) { type args struct { Sequence int64 `flag:"sequence" schema:"required;enum=9007199254740993" doc:"sequence"` } - definition := Definition[args, aliasBinderData]{ + definition := typedDefinition[args, aliasBinderData]{ Metadata: CommandMetadata{Service: "fixture", Command: "+large-integer", Description: "large integer fixture", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, - Hooks: Hooks[args, aliasBinderData]{Execute: func(context.Context, CommandContext, *args) (Result[aliasBinderData], error) { + Hooks: typedHooks[args, aliasBinderData]{Execute: func(context.Context, CommandContext, *args) (Result[aliasBinderData], error) { return Success(aliasBinderData{OK: true}), nil }}, } @@ -282,9 +282,9 @@ func TestBindTypedMapRejectsWrongFixedArrayLength(t *testing.T) { type args struct { Values [2]string `flag:"values" schema:"required" cli:"encoding=repeated" doc:"two values"` } - definition := Definition[args, aliasBinderData]{ + definition := typedDefinition[args, aliasBinderData]{ Metadata: CommandMetadata{Service: "fixture", Command: "+array", Description: "array fixture", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, - Hooks: Hooks[args, aliasBinderData]{Execute: func(context.Context, CommandContext, *args) (Result[aliasBinderData], error) { + Hooks: typedHooks[args, aliasBinderData]{Execute: func(context.Context, CommandContext, *args) (Result[aliasBinderData], error) { return Success(aliasBinderData{OK: true}), nil }}, } diff --git a/shortcuts/common/typed_runner_test.go b/shortcuts/common/typed_runner_test.go index 0a2cee8e2c..f52d59facc 100644 --- a/shortcuts/common/typed_runner_test.go +++ b/shortcuts/common/typed_runner_test.go @@ -43,16 +43,16 @@ type typedRunnerData struct { Items []typedRunnerItem `json:"items" schema:"required;nonnullable" doc:"item outcomes"` } -func typedRunnerDefinition(capture func(*typedRunnerArgs), partial bool) Definition[typedRunnerArgs, typedRunnerData] { +func typedRunnerDefinition(capture func(*typedRunnerArgs), partial bool) typedDefinition[typedRunnerArgs, typedRunnerData] { outputDefinition := OutputDefinition{} if partial { outputDefinition.Outcomes.PartialFailure = &PartialFailureDefinition{ExitCode: 9, FailedItems: &FailedItemDefinition{ItemsPath: "/items", StatePath: "/state", FailedValues: []JSONValue{"failed"}}} } - return Definition[typedRunnerArgs, typedRunnerData]{ + return typedDefinition[typedRunnerArgs, typedRunnerData]{ Metadata: CommandMetadata{Service: "fixture", Command: "+typed", Description: "Run typed fixture", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, Input: InputDefinition{Fields: []InputField{{Name: "token", CLI: CLIInput{Aliases: []FlagAlias{{Name: "legacy-token", Mode: AliasIndependent, Conflict: AliasTrimmedEqualOrError, Hidden: true, Deprecated: true}}}}}}, Output: outputDefinition, - Hooks: Hooks[typedRunnerArgs, typedRunnerData]{ + Hooks: typedHooks[typedRunnerArgs, typedRunnerData]{ Normalize: func(_ context.Context, _ CommandContext, args *typedRunnerArgs) error { args.Prepared = strings.ToUpper(args.Token) return nil @@ -71,14 +71,14 @@ func typedRunnerDefinition(capture func(*typedRunnerArgs), partial bool) Definit } } -func runTypedFixture(t *testing.T, definition Definition[typedRunnerArgs, typedRunnerData], stdin string, args ...string) (string, string, error) { +func runTypedFixture(t *testing.T, definition typedDefinition[typedRunnerArgs, typedRunnerData], stdin string, args ...string) (string, string, error) { t.Helper() factory, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "typed-app", AppSecret: "typed-secret", Brand: core.BrandFeishu}) factory.IOStreams.In = strings.NewReader(stdin) root := &cobra.Command{Use: "lark-cli", SilenceUsage: true, SilenceErrors: true} service := &cobra.Command{Use: "fixture"} root.AddCommand(service) - Define(definition).Mount(service, factory) + defineTypedShortcut(definition).Mount(service, factory) root.SetArgs(append([]string{"fixture", "+typed", "--as", "user"}, args...)) _, err := root.ExecuteC() return stdout.String(), stderr.String(), err @@ -92,16 +92,16 @@ func TestTypedHelpSummarizesDeepJSONWithoutExpandingShape(t *testing.T) { OK bool `json:"ok" schema:"required" doc:"success state"` } deepShape := ObjectShape{Fields: []ValueField{{Name: "level_one", Description: "level one", Required: true, Shape: ObjectShape{Fields: []ValueField{{Name: "secret_depth_field", Description: "deep field", Required: true, Shape: StringShape{}}}}}}} - definition := Definition[args, data]{ + definition := typedDefinition[args, data]{ Metadata: CommandMetadata{Service: "fixture", Command: "+deep-json", Description: "deep JSON fixture", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, Input: InputDefinition{Fields: []InputField{{Name: "properties", Shape: deepShape}}}, - Hooks: Hooks[args, data]{Execute: func(context.Context, CommandContext, *args) (Result[data], error) { + Hooks: typedHooks[args, data]{Execute: func(context.Context, CommandContext, *args) (Result[data], error) { return Success(data{OK: true}), nil }}, } factory, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{}) service := &cobra.Command{Use: "fixture"} - Define(definition).Mount(service, factory) + defineTypedShortcut(definition).Mount(service, factory) command, _, err := service.Find([]string{"+deep-json"}) if err != nil { t.Fatal(err) @@ -130,15 +130,15 @@ func TestTypedHelpSupportsCommandWithoutBusinessParameters(t *testing.T) { type data struct { OK bool `json:"ok" schema:"required" doc:"success state"` } - definition := Definition[args, data]{ + definition := typedDefinition[args, data]{ Metadata: CommandMetadata{Service: "fixture", Command: "+no-input", Description: "no input fixture", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, - Hooks: Hooks[args, data]{Execute: func(context.Context, CommandContext, *args) (Result[data], error) { + Hooks: typedHooks[args, data]{Execute: func(context.Context, CommandContext, *args) (Result[data], error) { return Success(data{OK: true}), nil }}, } factory, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{}) service := &cobra.Command{Use: "fixture"} - shortcut := Define(definition) + shortcut := defineTypedShortcut(definition) shortcut.Mount(service, factory) command, _, err := service.Find([]string{"+no-input"}) if err != nil { @@ -175,7 +175,7 @@ func TestTypedMountRejectsPostMountContractMutation(t *testing.T) { root := &cobra.Command{Use: "lark-cli"} service := &cobra.Command{Use: "fixture"} root.AddCommand(service) - shortcut := Define(typedRunnerDefinition(nil, false)) + shortcut := defineTypedShortcut(typedRunnerDefinition(nil, false)) shortcut.PostMount = tt.mutate defer func() { value := recover() @@ -191,7 +191,7 @@ func TestTypedMountRejectsPostMountContractMutation(t *testing.T) { func TestTypedMountAllowsNoOpPostMount(t *testing.T) { factory, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{}) service := &cobra.Command{Use: "fixture"} - shortcut := Define(typedRunnerDefinition(nil, false)) + shortcut := defineTypedShortcut(typedRunnerDefinition(nil, false)) shortcut.PostMount = func(*cobra.Command) {} shortcut.Mount(service, factory) } @@ -203,7 +203,7 @@ func TestTypedRunnerInstallsGroupedHelpFromCompiledFacts(t *testing.T) { root.AddCommand(service) definition := typedRunnerDefinition(nil, true) definition.Output.Meta = ResultMetaDefinition{Count: true, Pagination: true} - Define(definition).Mount(service, factory) + defineTypedShortcut(definition).Mount(service, factory) cmd, _, err := root.Find([]string{"fixture", "+typed"}) if err != nil { t.Fatal(err) @@ -251,7 +251,7 @@ func TestTypedHelpPaginationSummaryMatchesExecutableOutputPaths(t *testing.T) { definition.Output.Mode = test.mode definition.Output.Meta.Pagination = true if test.pretty { - definition.Hooks.Renderers = map[string]Renderer[typedRunnerData]{"pretty": func(io.Writer, typedRunnerData) error { return nil }} + definition.Hooks.Renderers = map[string]typedRenderer[typedRunnerData]{"pretty": func(io.Writer, typedRunnerData) error { return nil }} } compiled, err := compileDefinition(definition) if err != nil { @@ -387,7 +387,7 @@ func TestTypedRunnerDryRunUsesProductionStrictIdentity(t *testing.T) { root := &cobra.Command{Use: "lark-cli", SilenceUsage: true, SilenceErrors: true} service := &cobra.Command{Use: "fixture"} root.AddCommand(service) - Define(definition).Mount(service, factory) + defineTypedShortcut(definition).Mount(service, factory) root.SetArgs([]string{"fixture", "+typed", "--token", "value", "--dry-run"}) if _, err := root.ExecuteC(); err != nil { t.Fatal(err) @@ -403,7 +403,7 @@ func TestTypedRunnerEmitsResultLevelPartialWithoutFailedItems(t *testing.T) { definition.Hooks.Execute = func(_ context.Context, _ CommandContext, args *typedRunnerArgs) (Result[typedRunnerData], error) { return Partial(typedRunnerData{Token: args.Token, Prepared: "follow-up write failed"}), nil } - definition.Hooks.Renderers = map[string]Renderer[typedRunnerData]{"pretty": func(w io.Writer, _ typedRunnerData) error { + definition.Hooks.Renderers = map[string]typedRenderer[typedRunnerData]{"pretty": func(w io.Writer, _ typedRunnerData) error { _, err := io.WriteString(w, "partial pretty must not run") return err }} @@ -468,7 +468,7 @@ func TestTypedRunnerEmitsPaginationMetaForSuccessPretty(t *testing.T) { pagination := &ResultPaginationMeta{Complete: false, Pages: 2, Items: 1, NextToken: "resume-token"} return Success(typedRunnerData{Token: args.Token, Items: []typedRunnerItem{{State: "failed"}}}).WithMeta(PaginationResultMeta(pagination)), nil } - definition.Hooks.Renderers = map[string]Renderer[typedRunnerData]{"pretty": func(w io.Writer, data typedRunnerData) error { + definition.Hooks.Renderers = map[string]typedRenderer[typedRunnerData]{"pretty": func(w io.Writer, data typedRunnerData) error { _, err := fmt.Fprintf(w, "token=%s\n", data.Token) return err }} @@ -534,7 +534,7 @@ func TestTypedRunnerGenericPrettyCompatibilityAndOptIn(t *testing.T) { t.Fatalf("generic pretty fallback: stdout = %q, stderr = %q, error = %v", stdout, stderr, err) } - definition.Hooks.Renderers = map[string]Renderer[typedRunnerData]{"pretty": func(w io.Writer, data typedRunnerData) error { + definition.Hooks.Renderers = map[string]typedRenderer[typedRunnerData]{"pretty": func(w io.Writer, data typedRunnerData) error { _, err := fmt.Fprintf(w, "prepared=%s\n", data.Prepared) return err }} diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index 15f56b53f3..b787527b40 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -63,10 +63,9 @@ type Shortcut struct { // used to satisfy a Cobra Required flag; alternatives such as "A or legacy B" // are a business constraint and must be validated as such. Normalize FlagNormalizer - DryRun func(ctx context.Context, runtime *RuntimeContext) *DryRunAPI // optional: framework prints & returns when --dry-run is set - DryRunE func(ctx context.Context, runtime *RuntimeContext) (*DryRunAPI, error) // optional error-capable dry-run; takes precedence over DryRun - Validate func(ctx context.Context, runtime *RuntimeContext) error // optional pre-execution validation - Execute func(ctx context.Context, runtime *RuntimeContext) error // main logic + DryRun func(ctx context.Context, runtime *RuntimeContext) *DryRunAPI // optional: framework prints & returns when --dry-run is set + Validate func(ctx context.Context, runtime *RuntimeContext) error // optional pre-execution validation + Execute func(ctx context.Context, runtime *RuntimeContext) error // main logic // OnInvoke, when non-nil, runs from the command's cobra PreRunE — before // cobra validates required flags — so its side effect fires even when the From f450d4fb5a88965e6e846e8d76f97b5a3ee6d4e0 Mon Sep 17 00:00:00 2001 From: liangshuo-1 <266696938+liangshuo-1@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:10:34 +0800 Subject: [PATCH 46/47] refactor(command): keep a single authoring contract --- cmd/schema/schema.go | 7 +- internal/commandbridge/bridge.go | 71 ++++ internal/commandhost/compile.go | 200 ++--------- internal/commandhost/compile_test.go | 56 ++- internal/commandhost/download.go | 11 +- internal/commandhost/input_stage_test.go | 6 +- shortcuts/common/clone.go | 70 ++-- shortcuts/common/clone_test.go | 38 +- shortcuts/common/runner.go | 14 +- shortcuts/common/typed_api.go | 23 +- shortcuts/common/typed_api_test.go | 5 +- shortcuts/common/typed_authorization_test.go | 12 +- shortcuts/common/typed_binder.go | 76 ++-- .../common/typed_binder_benchmark_test.go | 6 +- shortcuts/common/typed_compile_args.go | 85 ++--- shortcuts/common/typed_compile_contract.go | 210 +++-------- shortcuts/common/typed_compile_data.go | 118 +++--- shortcuts/common/typed_compile_output.go | 20 +- .../common/typed_compile_recursion_test.go | 34 +- shortcuts/common/typed_compiler.go | 143 ++------ .../common/typed_compiler_invalid_test.go | 107 ++---- shortcuts/common/typed_compiler_test.go | 215 ++++------- shortcuts/common/typed_contract.go | 30 +- shortcuts/common/typed_definition.go | 238 +++--------- shortcuts/common/typed_external.go | 81 ++--- shortcuts/common/typed_external_pagination.go | 9 +- .../common/typed_external_pagination_test.go | 2 +- shortcuts/common/typed_flag_collisions.go | 8 +- .../common/typed_flag_collisions_test.go | 42 ++- shortcuts/common/typed_flag_schema.go | 8 +- shortcuts/common/typed_flag_schema_test.go | 30 +- shortcuts/common/typed_help.go | 57 ++- shortcuts/common/typed_help_render.go | 10 +- shortcuts/common/typed_help_render_test.go | 16 +- shortcuts/common/typed_map_binder.go | 104 ------ shortcuts/common/typed_map_binder_test.go | 338 ------------------ shortcuts/common/typed_output.go | 120 +------ shortcuts/common/typed_public_surface_test.go | 135 +++++++ shortcuts/common/typed_result_protocol.go | 245 +------------ .../common/typed_result_protocol_test.go | 141 ++------ shortcuts/common/typed_runner.go | 37 +- shortcuts/common/typed_runner_test.go | 218 +++-------- shortcuts/common/typed_schema.go | 133 ++++--- shortcuts/common/typed_schema_export.go | 7 +- shortcuts/common/typed_shape.go | 140 ++++++-- shortcuts/common/typed_test_authoring_test.go | 129 +++++++ shortcuts/common/types.go | 5 +- shortcuts/register.go | 5 +- 48 files changed, 1349 insertions(+), 2466 deletions(-) create mode 100644 internal/commandbridge/bridge.go delete mode 100644 shortcuts/common/typed_map_binder.go delete mode 100644 shortcuts/common/typed_map_binder_test.go create mode 100644 shortcuts/common/typed_public_surface_test.go create mode 100644 shortcuts/common/typed_test_authoring_test.go diff --git a/cmd/schema/schema.go b/cmd/schema/schema.go index 564152c37a..5032780faf 100644 --- a/cmd/schema/schema.go +++ b/cmd/schema/schema.go @@ -14,6 +14,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/apicatalog" "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/commandbridge" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/meta" "github.com/larksuite/cli/internal/output" @@ -193,7 +194,7 @@ func resolveShortcutSchemaFrom( if !shortcutSchemaVisible(shortcut, visibility, mode) { return nil, false } - return common.ShortcutSchema(shortcut) + return common.ShortcutSchema(shortcut, commandbridge.Access{}) } return nil, false } @@ -215,7 +216,7 @@ func shortcutSchemaCompletionsFrom( if !strings.HasPrefix(shortcut.Service, toComplete) || !shortcutSchemaVisible(shortcut, visibility, mode) { continue } - if _, ok := common.ShortcutSchema(shortcut); ok { + if _, ok := common.ShortcutSchema(shortcut, commandbridge.Access{}); ok { services[shortcut.Service] = struct{}{} } } @@ -245,7 +246,7 @@ func shortcutCommandCompletions( if shortcut.Service != service || !strings.HasPrefix(shortcut.Command, prefix) || !shortcutSchemaVisible(shortcut, visibility, mode) { continue } - if _, ok := common.ShortcutSchema(shortcut); ok { + if _, ok := common.ShortcutSchema(shortcut, commandbridge.Access{}); ok { result = append(result, outputPrefix+shortcut.Command+"\t"+shortcut.Description) } } 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 index aa3685b027..3db7d2191a 100644 --- a/internal/commandhost/compile.go +++ b/internal/commandhost/compile.go @@ -15,6 +15,7 @@ import ( "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" @@ -112,116 +113,22 @@ func validateDomain(domain command.HostDomain, existing map[string]struct{}) err } func compileCommand(definition command.HostDefinition) (common.Shortcut, error) { - metadata := convertMetadata(definition.Metadata) - input, err := convertInput(definition.Input) - if err != nil { - return common.Shortcut{}, err - } - output, err := convertOutput(definition.Output) - if err != nil { - return common.Shortcut{}, err - } hooks := convertHooks(definition) hooks.NewArgs = definition.NewArgs - return common.CompileErasedDefinition(common.ErasedDefinition{ - Metadata: metadata, - Input: input, - Output: output, + 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, - }) -} - -func convertMetadata(metadata command.CommandMetadata) common.CommandMetadata { - identities := make(map[common.Identity]common.IdentityAuthorization, len(metadata.Authorization.Identities)) - for identity, authorization := range metadata.Authorization.Identities { - conditional := make([]common.ConditionalScope, len(authorization.ConditionalScopes)) - for index, scope := range authorization.ConditionalScopes { - conditional[index] = common.ConditionalScope{ - Scopes: append([]string(nil), scope.Scopes...), - When: scope.When, - Params: append([]string(nil), scope.Params...), - Requirement: common.ScopeRequirement(scope.Requirement), - } - } - identities[common.Identity(identity)] = common.IdentityAuthorization{ - RequiredScopes: append([]string(nil), authorization.RequiredScopes...), - ConditionalScopes: conditional, - } - } - identityOrder := make([]common.Identity, len(metadata.Authorization.IdentityOrder)) - for index, identity := range metadata.Authorization.IdentityOrder { - identityOrder[index] = common.Identity(identity) - } - return common.CommandMetadata{ - Service: string(metadata.Service), Command: metadata.Command, Description: metadata.Description, - Risk: common.Risk(metadata.Risk), Hidden: metadata.Hidden, - Authorization: common.AuthorizationDefinition{Identities: identities, IdentityOrder: identityOrder}, - } + }, commandbridge.Access{}) } -func convertInput(input command.InputDefinition) (common.InputDefinition, error) { - converted := common.InputDefinition{Fields: make([]common.InputField, len(input.Fields)), Relations: make([]common.Relation, len(input.Relations))} - for index, field := range input.Fields { - shape, err := convertShape(field.Shape) - if err != nil { - return common.InputDefinition{}, fmt.Errorf("Input.Fields[%d].Shape: %w", index, err) - } - aliases := make([]common.FlagAlias, len(field.CLI.Aliases)) - for aliasIndex, alias := range field.CLI.Aliases { - aliases[aliasIndex] = common.FlagAlias{ - Name: alias.Name, Mode: common.FlagAliasMode(alias.Mode), Conflict: common.AliasConflictPolicy(alias.Conflict), - Hidden: alias.Hidden, Deprecated: alias.Deprecated, - } - } - sources := make([]common.ValueSource, len(field.CLI.ValueSources)) - for sourceIndex, source := range field.CLI.ValueSources { - if source != command.SourceFlag && source != command.SourceFile && source != command.SourceStdin { - return common.InputDefinition{}, fmt.Errorf("Input.Fields[%d].CLI.ValueSources[%d]: source %q is not supported in V1", index, sourceIndex, source) - } - sources[sourceIndex] = common.ValueSource(source) - } - converted.Fields[index] = common.InputField{ - Name: field.Name, Description: field.Description, Shape: shape, - Default: common.InputDefault{Set: field.Default.Set, Value: field.Default.Value}, - CLI: common.CLIInput{Aliases: aliases, ValueSources: sources, Encoding: common.CLIEncoding(field.CLI.Encoding), Hidden: field.CLI.Hidden, Deprecated: field.CLI.Deprecated}, - } - } - for index, relation := range input.Relations { - converted.Relations[index] = common.Relation{ - Kind: common.RelationKind(relation.Kind), Params: append([]string(nil), relation.Params...), - Presence: common.PresenceMode(relation.Presence), Stage: common.RelationStage(relation.Stage), - } - } - return converted, nil -} - -func convertOutput(output command.OutputDefinition) (common.OutputDefinition, error) { - dataShape, err := convertShape(output.Data.Shape) - if err != nil { - return common.OutputDefinition{}, fmt.Errorf("Output.Data.Shape: %w", err) - } - dataOverrides := make([]common.DataField, len(output.Data.Overrides)) - for index, override := range output.Data.Overrides { - shape, shapeErr := convertShape(override.Shape) - if shapeErr != nil { - return common.OutputDefinition{}, fmt.Errorf("Output.Data.Overrides[%d].Shape: %w", index, shapeErr) - } - dataOverrides[index] = common.DataField{Path: override.Path, Description: override.Description, Shape: shape} - } - converted := common.OutputDefinition{ - Data: common.DataDefinition{Shape: dataShape, Overrides: dataOverrides}, - Meta: common.ResultMetaDefinition{Pagination: output.Meta.Pagination}, - Mode: common.OutputMode(output.Mode), DisableHTMLEscaping: output.DisableHTMLEscaping, - } - return converted, nil -} - -func convertHooks(definition command.HostDefinition) common.ErasedHooks { +func convertHooks(definition command.HostDefinition) commandbridge.Hooks { hooks := definition.Hooks - return common.ErasedHooks{ + return commandbridge.Hooks{ Normalize: adaptHook(hooks.Normalize), Validate: adaptHook(hooks.Validate), DryRun: adaptDryRunHook(hooks.DryRun), @@ -230,48 +137,40 @@ func convertHooks(definition command.HostDefinition) common.ErasedHooks { } } -func adaptHook(hook func(context.Context, command.CommandContext, any) error) func(context.Context, common.CommandContext, any) error { +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 common.CommandContext, args any) error { + 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, common.CommandContext, any) (*common.DryRunAPI, error) { +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 common.CommandContext, args any) (*common.DryRunAPI, error) { - preview := hook(ctx, publicContext(host), args) - return convertDryRun(preview) + 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, common.CommandContext, any) (common.ErasedResult, error) { +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 common.CommandContext, args any) (common.ErasedResult, error) { + 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 common.ErasedResult{}, invalid + return commandbridge.Result{}, invalid } } - converted := common.ErasedResult{Data: result.Data, Outcome: common.OutcomeKind(result.Outcome)} - if result.Pagination != nil { - converted.Meta = &common.ResultMeta{Pagination: &common.ResultPaginationMeta{ - Complete: result.Pagination.Complete, Pages: result.Pagination.Pages, - Items: result.Pagination.Items, NextToken: result.Pagination.NextToken, - }} - } - return converted, err + return commandbridge.Result{Data: result.Data, Outcome: result.Outcome, Pagination: result.Pagination}, err } } @@ -290,9 +189,9 @@ func cloneRenderers(renderers map[string]func(io.Writer, any) error) map[string] // 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 common.CommandContext) command.CommandContext { +func inputStageContext(host commandbridge.RuntimeContext) command.CommandContext { return command.NewCommandContext(command.ContextOptions{ - Identity: command.Identity(host.Identity()), + Identity: host.Identity(), DryRun: host.IsDryRun(), InputStage: true, PreflightScopes: host.RequireConditionalScopes, @@ -309,13 +208,13 @@ func (c *commandPages) AddPage(page map[string]any) error { return nil } -func publicContext(host common.CommandContext) command.CommandContext { +func publicContext(host commandbridge.RuntimeContext) command.CommandContext { return command.NewCommandContext(command.ContextOptions{ - Identity: command.Identity(host.Identity()), + Identity: host.Identity(), DryRun: host.IsDryRun(), CallJSON: func(ctx context.Context, request command.Request) (map[string]any, error) { view := command.InspectRequest(request) - return common.DoTypedAPIJSON(ctx, host, view.Method, view.Path, queryParams(view.Query), view.Body) + 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) @@ -330,9 +229,9 @@ func publicContext(host common.CommandContext) command.CommandContext { return nil, command.HostPagination{}, err } pages := &commandPages{} - meta, err := common.CollectCommandPages(ctx, host, common.PageRequest{ + meta, err := common.CollectHostedPages(ctx, host, common.PageRequest{ Method: view.Method, Path: view.Path, Params: projectedQuery(view.Query), Body: view.Body, - }, all, pages) + }, all, pages, commandbridge.Access{}) pagination := command.HostPagination{ Complete: meta.Complete, Pages: meta.Pages, NextToken: meta.NextToken, @@ -464,54 +363,3 @@ func convertDryRun(preview *command.DryRun) (*common.DryRunAPI, error) { } return converted, nil } - -func convertShape(shape command.ValueShape) (common.ValueShape, error) { - switch typed := shape.(type) { - case nil: - return nil, nil - case command.StringShape: - return common.StringShape{Enum: append([]string(nil), typed.Enum...), Format: typed.Format, MinLength: typed.MinLength, MaxLength: typed.MaxLength}, nil - case command.BooleanShape: - return common.BooleanShape{Enum: append([]bool(nil), typed.Enum...)}, nil - case command.IntegerShape: - return common.IntegerShape{Enum: append([]int64(nil), typed.Enum...), Minimum: typed.Minimum, Maximum: typed.Maximum}, nil - case command.NumberShape: - return common.NumberShape{Enum: append([]float64(nil), typed.Enum...), Minimum: typed.Minimum, Maximum: typed.Maximum}, nil - case command.NullShape: - return common.NullShape{}, nil - case command.ConstShape: - return common.ConstShape{Value: typed.Value}, nil - case command.ArrayShape: - items, err := convertShape(typed.Items) - if err != nil { - return nil, err - } - return common.ArrayShape{Items: items, MinItems: typed.MinItems, MaxItems: typed.MaxItems}, nil - case command.ObjectShape: - fields := make([]common.ValueField, len(typed.Fields)) - for index, field := range typed.Fields { - fieldShape, err := convertShape(field.Shape) - if err != nil { - return nil, fmt.Errorf("field %q: %w", field.Name, err) - } - fields[index] = common.ValueField{Name: field.Name, Description: field.Description, Required: field.Required, Shape: fieldShape} - } - additional, err := convertShape(typed.AdditionalPropertiesShape) - if err != nil { - return nil, err - } - return common.ObjectShape{Fields: fields, AdditionalProperties: typed.AdditionalProperties, AdditionalPropertiesShape: additional}, nil - case command.OneOfShape: - variants := make([]common.ValueShape, len(typed.Variants)) - for index, variant := range typed.Variants { - converted, err := convertShape(variant) - if err != nil { - return nil, fmt.Errorf("variant %d: %w", index, err) - } - variants[index] = converted - } - return common.OneOfShape{Variants: variants}, nil - default: - return nil, fmt.Errorf("unsupported public shape %T", shape) - } -} diff --git a/internal/commandhost/compile_test.go b/internal/commandhost/compile_test.go index be9d5ae8a1..6441363b03 100644 --- a/internal/commandhost/compile_test.go +++ b/internal/commandhost/compile_test.go @@ -5,6 +5,7 @@ package commandhost import ( "context" + "encoding/json" "errors" "reflect" "slices" @@ -15,6 +16,7 @@ import ( "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" @@ -64,6 +66,58 @@ func TestCompileSetsCompilesTypedShortcut(t *testing.T) { } } +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) { @@ -205,7 +259,7 @@ func TestCompileSetsCarriesFileInputSource(t *testing.T) { 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(), "source \"clipboard\" is not supported in V1") { + if err == nil || !strings.Contains(err.Error(), "unknown value source \"clipboard\"") { t.Fatalf("CompileSets() error = %v", err) } } diff --git a/internal/commandhost/download.go b/internal/commandhost/download.go index 4c25793a4b..797f631f1e 100644 --- a/internal/commandhost/download.go +++ b/internal/commandhost/download.go @@ -18,6 +18,7 @@ import ( 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" @@ -29,7 +30,7 @@ import ( // 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 common.CommandContext, request command.Request, target command.FileTarget, options command.DownloadOptions) (command.Artifact, error) { +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 @@ -50,7 +51,7 @@ func downloadCommand(ctx context.Context, host common.CommandContext, request co return downloadToFile(ctx, host, transport, target, options) } -func downloadURLCommand(ctx context.Context, host common.CommandContext, rawURL string, target command.FileTarget, options command.DownloadOptions) (command.Artifact, error) { +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) @@ -67,7 +68,7 @@ func downloadURLCommand(ctx context.Context, host common.CommandContext, rawURL return downloadToFile(ctx, host, downloadtransport.URL(safeClient, rawURL), target, options) } -func downloadToFile(ctx context.Context, host common.CommandContext, transport download.Transport, target command.FileTarget, options command.DownloadOptions) (command.Artifact, error) { +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") @@ -143,7 +144,7 @@ func downloadToFile(ctx context.Context, host common.CommandContext, transport d }, nil } -func doCommandAPIStream(ctx context.Context, host common.CommandContext, request *larkcore.ApiReq, options ...client.Option) (*http.Response, error) { +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 @@ -155,7 +156,7 @@ func doCommandAPIStream(ctx context.Context, host common.CommandContext, request return apiClient.DoStream(ctx, request, core.Identity(host.Identity()), append(base, options...)...) } -func commandAPIClient(host common.CommandContext) (*client.APIClient, error) { +func commandAPIClient(host commandbridge.RuntimeContext) (*client.APIClient, error) { apiClient, err := host.APIClient() if err != nil { if _, typed := errs.ProblemOf(err); typed { diff --git a/internal/commandhost/input_stage_test.go b/internal/commandhost/input_stage_test.go index 6562930ad3..1f81c0a090 100644 --- a/internal/commandhost/input_stage_test.go +++ b/internal/commandhost/input_stage_test.go @@ -9,17 +9,17 @@ import ( "testing" "github.com/larksuite/cli/extension/command" - "github.com/larksuite/cli/shortcuts/common" + "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 { - common.CommandContext + commandbridge.RuntimeContext } -func (stubHost) Identity() common.Identity { return common.Identity("user") } +func (stubHost) Identity() command.Identity { return command.IdentityUser } func (stubHost) IsDryRun() bool { return false } func (stubHost) RequireConditionalScopes(...string) error { return nil } diff --git a/shortcuts/common/clone.go b/shortcuts/common/clone.go index df2bd7ab28..fa2259a80b 100644 --- a/shortcuts/common/clone.go +++ b/shortcuts/common/clone.go @@ -6,11 +6,14 @@ 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. +// cloneShortcut copies mutable declaration and compiled-contract data. // Function values and values captured by business closures remain shared. -func CloneShortcut(shortcut Shortcut) Shortcut { +func cloneShortcut(shortcut Shortcut) Shortcut { cloned := shortcut cloned.Scopes = append([]string(nil), shortcut.Scopes...) cloned.UserScopes = append([]string(nil), shortcut.UserScopes...) @@ -34,11 +37,11 @@ func CloneShortcut(shortcut Shortcut) Shortcut { return cloned } -// CloneShortcuts copies a shortcut slice and each mutable declaration. -func CloneShortcuts(shortcuts []Shortcut) []Shortcut { +// 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) + cloned[index] = cloneShortcut(shortcut) } return cloned } @@ -56,8 +59,8 @@ func cloneCompiledCommand(command *compiledCommand) *compiledCommand { 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([]FlagAlias(nil), field.cli.Aliases...) - cloned.fields[index].cli.ValueSources = append([]ValueSource(nil), field.cli.ValueSources...) + 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 { @@ -81,70 +84,63 @@ func cloneCompiledCommand(command *compiledCommand) *compiledCommand { return &cloned } -func cloneCommonOutput(output OutputDefinition) OutputDefinition { - output.Data.Shape = cloneCommonShape(output.Data.Shape) - output.Data.Overrides = append([]DataField(nil), output.Data.Overrides...) +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 = cloneCommonShape(output.Data.Overrides[index].Shape) - } - output.Artifacts = append([]ArtifactDefinition(nil), output.Artifacts...) - if output.Outcomes.PartialFailure != nil { - partial := *output.Outcomes.PartialFailure - if partial.FailedItems != nil { - failed := *partial.FailedItems - failed.IdentityPaths = append([]string(nil), failed.IdentityPaths...) - failed.FailedValues = append([]JSONValue(nil), failed.FailedValues...) - for index := range failed.FailedValues { - failed.FailedValues[index] = cloneJSONValue(failed.FailedValues[index]) - } - partial.FailedItems = &failed - } - output.Outcomes.PartialFailure = &partial + output.Data.Overrides[index].Shape = cloneAuthoringShape(output.Data.Overrides[index].Shape) } return output } -func cloneCommonShape(shape ValueShape) ValueShape { +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 StringShape: + case typedStringShape: typed.Enum = append([]string(nil), typed.Enum...) typed.MinLength = cloneScalarPointer(typed.MinLength) typed.MaxLength = cloneScalarPointer(typed.MaxLength) return typed - case BooleanShape: + case typedBooleanShape: typed.Enum = append([]bool(nil), typed.Enum...) return typed - case IntegerShape: + case typedIntegerShape: typed.Enum = append([]int64(nil), typed.Enum...) typed.Minimum = cloneScalarPointer(typed.Minimum) typed.Maximum = cloneScalarPointer(typed.Maximum) return typed - case NumberShape: + case typedNumberShape: typed.Enum = append([]float64(nil), typed.Enum...) typed.Minimum = cloneScalarPointer(typed.Minimum) typed.Maximum = cloneScalarPointer(typed.Maximum) return typed - case NullShape, anyJSONShape: + case typedNullShape, anyJSONShape: return typed - case ConstShape: + case typedConstShape: typed.Value = cloneJSONValue(typed.Value) return typed - case ArrayShape: + case typedArrayShape: typed.Items = cloneCommonShape(typed.Items) typed.MinItems = cloneScalarPointer(typed.MinItems) typed.MaxItems = cloneScalarPointer(typed.MaxItems) return typed - case ObjectShape: - typed.Fields = append([]ValueField(nil), typed.Fields...) + 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 OneOfShape: - typed.Variants = append([]ValueShape(nil), typed.Variants...) + case typedOneOfShape: + typed.Variants = append([]typedValueShape(nil), typed.Variants...) for index := range typed.Variants { typed.Variants[index] = cloneCommonShape(typed.Variants[index]) } diff --git a/shortcuts/common/clone_test.go b/shortcuts/common/clone_test.go index f599d95d7f..f93f7956cd 100644 --- a/shortcuts/common/clone_test.go +++ b/shortcuts/common/clone_test.go @@ -18,36 +18,28 @@ type cloneData struct { func TestCloneShortcutCopiesCompiledContract(t *testing.T) { original := defineTypedShortcut(typedDefinition[cloneArgs, cloneData]{ - Metadata: CommandMetadata{ - Service: "im", Command: "+clone", Description: "Clone", Risk: RiskRead, - Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{ - IdentityUser: {RequiredScopes: []string{"im:chat:read"}}, + 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, CommandContext, *cloneArgs) (Result[cloneData], error) { - return Success(cloneData{}), nil + 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.(StringShape) + shape := original.typed.fields[0].shape.(typedStringShape) shape.MinLength = &minLength original.typed.fields[0].shape = shape - failedValues := map[string][]string{"ids": {"original"}} - original.typed.output.Outcomes.PartialFailure = &PartialFailureDefinition{ - ExitCode: 2, - FailedItems: &FailedItemDefinition{ - ItemsPath: "/items", IdentityPaths: []string{"/id"}, FailedValues: []JSONValue{failedValues}, - }, - } - cloned := CloneShortcut(original) + cloned := cloneShortcut(original) original.UserScopes[0] = "mutated" original.Flags[0].Enum[0] = "mutated" - original.typed.metadata.Authorization.Identities[IdentityUser] = IdentityAuthorization{RequiredScopes: []string{"mutated"}} - originalShape := original.typed.fields[0].shape.(StringShape) + original.typed.metadata.Authorization.Identities[typedIdentityUser] = typedIdentityAuthorization{RequiredScopes: []string{"mutated"}} + originalShape := original.typed.fields[0].shape.(typedStringShape) originalShape.Enum[0] = "mutated" *originalShape.MinLength = 2 - failedValues["ids"][0] = "mutated" if got := cloned.UserScopes[0]; got != "im:chat:read" { t.Fatalf("cloned user scope = %q", got) @@ -55,19 +47,15 @@ func TestCloneShortcutCopiesCompiledContract(t *testing.T) { if got := cloned.Flags[0].Enum[0]; got != "one" { t.Fatalf("cloned flag enum = %q", got) } - if got := cloned.typed.metadata.Authorization.Identities[IdentityUser].RequiredScopes[0]; got != "im:chat:read" { + 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.(StringShape).Enum[0]; got != "one" { + 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.(StringShape).MinLength; got != 1 { + if got := *cloned.typed.fields[0].shape.(typedStringShape).MinLength; got != 1 { t.Fatalf("cloned minimum length = %d", got) } - failed := cloned.typed.output.Outcomes.PartialFailure.FailedItems.FailedValues[0].(map[string][]string) - if got := failed["ids"][0]; got != "original" { - t.Fatalf("cloned failed value = %q", got) - } } func TestExternalFlagNamespaceRejectsEverySystemFlag(t *testing.T) { diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 783e123b8e..1c6f5ce5b1 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -925,7 +925,7 @@ func (s Shortcut) MountWithContext(ctx context.Context, parent *cobra.Command, f 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, Risk(shortcut.Risk)); err != 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)) } } @@ -1051,14 +1051,14 @@ func installTypedAnnotations(cmd *cobra.Command, command *compiledCommand) { _ = cmd.Flags().MarkDeprecated(field.name, field.cli.Deprecated) } for _, alias := range field.cli.Aliases { - if alias.Mode != AliasIndependent || !alias.Deprecated { + if alias.Mode != typedAliasIndependent || !alias.Deprecated { continue } _ = cmd.Flags().MarkDeprecated(alias.Name, "use --"+field.name+" instead") } } for _, relation := range command.relations { - if relation.stage != StageSourcePreRun || relation.presence != PresenceExplicit { + if relation.stage != typedStageSourcePreRun || relation.presence != typedPresenceExplicit { continue } names := make([]string, 0, len(relation.fields)) @@ -1066,14 +1066,14 @@ func installTypedAnnotations(cmd *cobra.Command, command *compiledCommand) { names = append(names, command.fields[index].name) } switch relation.kind { - case RelationExactlyOne: + case typedRelationExactlyOne: cmd.MarkFlagsOneRequired(names...) cmd.MarkFlagsMutuallyExclusive(names...) - case RelationAtLeastOne: + case typedRelationAtLeastOne: cmd.MarkFlagsOneRequired(names...) - case RelationCoOccur: + case typedRelationCoOccur: cmd.MarkFlagsRequiredTogether(names...) - case RelationConflicts: + case typedRelationConflicts: cmd.MarkFlagsMutuallyExclusive(names...) } } diff --git a/shortcuts/common/typed_api.go b/shortcuts/common/typed_api.go index 1bb1d3900d..f304e5fc35 100644 --- a/shortcuts/common/typed_api.go +++ b/shortcuts/common/typed_api.go @@ -10,21 +10,18 @@ import ( "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" ) -// DoTypedAPIJSON executes and classifies one JSON API request through the -// restricted CommandContext. It preserves the legacy RuntimeContext typed API -// classification while keeping hooks independent of RuntimeContext flags and -// output methods. Successful response data is returned without host metadata. -func DoTypedAPIJSON(ctx context.Context, command CommandContext, method, apiPath string, query larkcore.QueryParams, body any) (map[string]any, error) { - return DoTypedAPIJSONWithOptions(ctx, command, method, apiPath, query, body) +// 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) } -// DoTypedAPIJSONWithOptions is DoTypedAPIJSON with SDK request options, used by -// multipart/form-data callers that must opt into file upload handling. -func DoTypedAPIJSONWithOptions(ctx context.Context, command CommandContext, method, apiPath string, query larkcore.QueryParams, body any, requestOptions ...larkcore.RequestOptionFunc) (map[string]any, error) { +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) @@ -44,9 +41,9 @@ func DoTypedAPIJSONWithOptions(ctx context.Context, command CommandContext, meth return ClassifyAPIResponseWith(response, typedClassifyContext(command)) } -// CallTypedAPI preserves RuntimeContext.CallAPITyped's raw request semantics -// for Typed hooks whose request params are represented as loose query maps. -func CallTypedAPI(ctx context.Context, command CommandContext, method, apiPath string, params map[string]interface{}, data any) (map[string]interface{}, error) { +// 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) @@ -62,7 +59,7 @@ func CallTypedAPI(ctx context.Context, command CommandContext, method, apiPath s return ClassifyAPIResponseWith(response, typedClassifyContext(command)) } -func typedClassifyContext(command CommandContext) errclass.ClassifyContext { +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 { diff --git a/shortcuts/common/typed_api_test.go b/shortcuts/common/typed_api_test.go index 8b30a1b9de..aa40d34fbf 100644 --- a/shortcuts/common/typed_api_test.go +++ b/shortcuts/common/typed_api_test.go @@ -8,6 +8,7 @@ import ( "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" @@ -31,7 +32,7 @@ func TestTypedClassifyContextPreservesCommandPath(t *testing.T) { } } -func TestDoTypedAPIJSONPreservesSuccessData(t *testing.T) { +func TestDoHostedAPIJSONPreservesSuccessData(t *testing.T) { runtime, registry := newCallAPITypedRuntime(t) registry.Register(&httpmock.Stub{ Method: "GET", @@ -46,7 +47,7 @@ func TestDoTypedAPIJSONPreservesSuccessData(t *testing.T) { }, }) - data, err := DoTypedAPIJSON(context.Background(), typedCommandContext{runtime: runtime}, "GET", "/open-apis/x/y", nil, nil) + data, err := DoHostedAPIJSON(context.Background(), typedCommandContext{runtime: runtime}, "GET", "/open-apis/x/y", nil, nil, commandbridge.Access{}) if err != nil { t.Fatal(err) } diff --git a/shortcuts/common/typed_authorization_test.go b/shortcuts/common/typed_authorization_test.go index ea9af9cf4f..02f5873378 100644 --- a/shortcuts/common/typed_authorization_test.go +++ b/shortcuts/common/typed_authorization_test.go @@ -73,8 +73,8 @@ func TestRequireConditionalScopesDefersWhenTokenMetadataUnavailable(t *testing.T func TestRequireConditionalScopesUsesSelectedIdentityContract(t *testing.T) { definition := validCompilerDefinition() - definition.Metadata.Authorization.Identities[IdentityBot] = IdentityAuthorization{ - ConditionalScopes: []ConditionalScope{{Scopes: []string{"fixture:bot-read"}, When: "the bot lookup path runs"}}, + 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") { @@ -89,11 +89,11 @@ func TestRequireConditionalScopesUsesSelectedIdentityContract(t *testing.T) { func TestTypedAuthorizationHelpUsesCompiledDiscoveryFacts(t *testing.T) { definition := validCompilerDefinition() - authorization := definition.Metadata.Authorization.Identities[IdentityUser] - authorization.ConditionalScopes = append(authorization.ConditionalScopes, ConditionalScope{ - Scopes: []string{"fixture:enrich"}, When: "optional detail enrichment runs", Requirement: ScopeBestEffort, + 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[IdentityUser] = authorization + definition.Metadata.Authorization.Identities[typedIdentityUser] = authorization factory, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{}) service := &cobra.Command{Use: "fixture"} diff --git a/shortcuts/common/typed_binder.go b/shortcuts/common/typed_binder.go index 6b53fc11e2..d86091b17d 100644 --- a/shortcuts/common/typed_binder.go +++ b/shortcuts/common/typed_binder.go @@ -58,7 +58,7 @@ func bindTypedArgs(runtime *RuntimeContext, command *compiledCommand) (*boundArg 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, StageSourcePreRun); err != nil { + if err := validateCompiledRelations(command, args, provided, typedStageSourcePreRun); err != nil { return nil, err } return &boundArgs{value: args, provided: provided}, nil @@ -78,7 +78,7 @@ func readCompiledField(runtime *RuntimeContext, field compiledInputField) (any, sourceName := field.name sourceSet := canonicalSet for _, alias := range field.cli.Aliases { - if alias.Mode != AliasIndependent { + if alias.Mode != typedAliasIndependent { continue } aliasFlag := runtime.Cmd.Flags().Lookup(alias.Name) @@ -93,20 +93,20 @@ func readCompiledField(runtime *RuntimeContext, field compiledInputField) (any, return nil, false, err } switch alias.Conflict { - case AliasCanonicalWins: + case typedAliasCanonicalWins: if !sourceSet { value = aliasRaw set = true sourceName, sourceSet = alias.Name, true } - case AliasErrorIfBoth: + 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 AliasTrimmedEqualOrError: + case typedAliasTrimmedEqualOrError: if sourceSet { if strings.TrimSpace(fmt.Sprint(value)) != strings.TrimSpace(fmt.Sprint(aliasRaw)) { if alias.Deprecated { @@ -130,7 +130,7 @@ func readCompiledField(runtime *RuntimeContext, field compiledInputField) (any, func readPFlagValue(runtime *RuntimeContext, name string, field compiledInputField) (any, error) { t := indirectType(field.valueType) - if field.cli.Encoding == EncodingJSON { + if field.cli.Encoding == typedEncodingJSON { return runtime.Str(name), nil } switch t.Kind() { @@ -144,9 +144,9 @@ func readPFlagValue(runtime *RuntimeContext, name string, field compiledInputFie return runtime.Cmd.Flags().GetFloat64(name) case reflect.Slice, reflect.Array: switch field.cli.Encoding { - case EncodingRepeated: + case typedEncodingRepeated: return runtime.Cmd.Flags().GetStringArray(name) - case EncodingCommaOrRepeated: + case typedEncodingCommaOrRepeated: if isIntegerKind(t.Elem().Kind()) { return runtime.Cmd.Flags().GetIntSlice(name) } @@ -157,7 +157,7 @@ func readPFlagValue(runtime *RuntimeContext, name string, field compiledInputFie } func decodeCompiledValue(raw any, field compiledInputField) (any, error) { - if field.cli.Encoding == EncodingJSON { + if field.cli.Encoding == typedEncodingJSON { text, ok := raw.(string) if !ok { encoded, err := json.Marshal(raw) @@ -300,16 +300,16 @@ func validateCompiledValue(value any, field compiledInputField) error { return nil } shape := field.shape - if one, ok := shape.(OneOfShape); ok { + if one, ok := shape.(typedOneOfShape); ok { for _, variant := range one.Variants { - if _, null := variant.(NullShape); !null { + if _, null := variant.(typedNullShape); !null { shape = variant break } } } switch constraint := shape.(type) { - case StringShape: + case typedStringShape: text := reflect.ValueOf(value) for text.Kind() == reflect.Pointer { text = text.Elem() @@ -327,7 +327,7 @@ func validateCompiledValue(value any, field compiledInputField) error { if len(constraint.Enum) > 0 && !slices.Contains(constraint.Enum, text.String()) { return typedFieldValidation(field, "must be one of: %s", strings.Join(constraint.Enum, ", ")) } - case IntegerShape: + case typedIntegerShape: number, err := numericFloat(value) if err != nil { break @@ -338,7 +338,7 @@ func validateCompiledValue(value any, field compiledInputField) error { if constraint.Maximum != nil && number > float64(*constraint.Maximum) { return typedFieldValidation(field, "must be at most %d", *constraint.Maximum) } - case NumberShape: + case typedNumberShape: number, err := numericFloat(value) if err != nil { break @@ -349,7 +349,7 @@ func validateCompiledValue(value any, field compiledInputField) error { if constraint.Maximum != nil && number > *constraint.Maximum { return typedFieldValidation(field, "must be at most %v", *constraint.Maximum) } - case ArrayShape: + case typedArrayShape: v := reflect.ValueOf(value) for v.Kind() == reflect.Pointer { if v.IsNil() { @@ -381,23 +381,23 @@ func validateCompiledValue(value any, field compiledInputField) error { return nil } -func validateJSONValueAgainstShape(value any, shape ValueShape, path string) error { +func validateJSONValueAgainstShape(value any, shape typedValueShape, path string) error { switch constraint := shape.(type) { case anyJSONShape: return nil - case OneOfShape: + 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 NullShape: + case typedNullShape: if value != nil { return fmt.Errorf("%s must be null", path) } return nil - case ConstShape: + case typedConstShape: expectedJSON, err := json.Marshal(constraint.Value) if err != nil { return fmt.Errorf("%s has invalid const: %w", path, err) @@ -410,7 +410,7 @@ func validateJSONValueAgainstShape(value any, shape ValueShape, path string) err return fmt.Errorf("%s must equal %v", path, constraint.Value) } return nil - case StringShape: + case typedStringShape: text, ok := value.(string) if !ok { return fmt.Errorf("%s must be a string", path) @@ -426,7 +426,7 @@ func validateJSONValueAgainstShape(value any, shape ValueShape, path string) err return fmt.Errorf("%s must be one of: %s", path, strings.Join(constraint.Enum, ", ")) } return nil - case BooleanShape: + case typedBooleanShape: boolean, ok := value.(bool) if !ok { return fmt.Errorf("%s must be a boolean", path) @@ -435,7 +435,7 @@ func validateJSONValueAgainstShape(value any, shape ValueShape, path string) err return fmt.Errorf("%s has an unsupported boolean value", path) } return nil - case IntegerShape: + case typedIntegerShape: number, ok := validationInteger(value) if !ok { return fmt.Errorf("%s must be an integer", path) @@ -450,7 +450,7 @@ func validateJSONValueAgainstShape(value any, shape ValueShape, path string) err return fmt.Errorf("%s has an unsupported integer value", path) } return nil - case NumberShape: + case typedNumberShape: number, ok := validationNumber(value) if !ok { return fmt.Errorf("%s must be a number", path) @@ -465,7 +465,7 @@ func validateJSONValueAgainstShape(value any, shape ValueShape, path string) err return fmt.Errorf("%s must be at most %v", path, *constraint.Maximum) } return nil - case ArrayShape: + case typedArrayShape: items, ok := value.([]any) if !ok { return fmt.Errorf("%s must be an array", path) @@ -482,12 +482,12 @@ func validateJSONValueAgainstShape(value any, shape ValueShape, path string) err } } return nil - case ObjectShape: + case typedObjectShape: object, ok := value.(map[string]any) if !ok { return fmt.Errorf("%s must be an object", path) } - fields := make(map[string]ValueField, len(constraint.Fields)) + fields := make(map[string]typedValueField, len(constraint.Fields)) for _, field := range constraint.Fields { fields[field.Name] = field if field.Required { @@ -556,7 +556,7 @@ func validationNumber(value any) (float64, bool) { } } -func validateCompiledRelations(command *compiledCommand, args any, provided []bool, stage RelationStage) error { +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 { @@ -567,7 +567,7 @@ func validateCompiledRelations(command *compiledCommand, args any, provided []bo for i, fieldIndex := range relation.fields { field := command.fields[fieldIndex] names[i] = "--" + field.name - if relation.presence == PresenceExplicit { + if relation.presence == typedPresenceExplicit { present[i] = provided[fieldIndex] } else { present[i] = compiledFieldIsNonZero(root, field) @@ -581,29 +581,29 @@ func validateCompiledRelations(command *compiledCommand, args any, provided []bo } var invalid bool switch relation.kind { - case RelationExactlyOne: + case typedRelationExactlyOne: invalid = count != 1 - case RelationAtLeastOne: + case typedRelationAtLeastOne: invalid = count == 0 - case RelationCoOccur: + case typedRelationCoOccur: invalid = count != 0 && count != len(present) - case RelationRequires: + case typedRelationRequires: invalid = present[0] && !present[1] - case RelationConflicts: + case typedRelationConflicts: invalid = count > 1 } if invalid { param := names[0] switch relation.kind { - case RelationExactlyOne: + case typedRelationExactlyOne: return errs.NewValidationError(errs.SubtypeInvalidArgument, "provide exactly one of %s", strings.Join(names, " or ")).WithParam(param) - case RelationAtLeastOne: + case typedRelationAtLeastOne: return errs.NewValidationError(errs.SubtypeInvalidArgument, "provide at least one of %s", strings.Join(names, " or ")).WithParam(param) - case RelationCoOccur: + case typedRelationCoOccur: return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s must be provided together", strings.Join(names, " and ")).WithParam(param) - case RelationRequires: + case typedRelationRequires: return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s requires %s", names[0], names[1]).WithParam(param) - case RelationConflicts: + case typedRelationConflicts: return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s cannot be used together", strings.Join(names, " and ")).WithParam(param) } } diff --git a/shortcuts/common/typed_binder_benchmark_test.go b/shortcuts/common/typed_binder_benchmark_test.go index 3accaca63f..acf570d6f3 100644 --- a/shortcuts/common/typed_binder_benchmark_test.go +++ b/shortcuts/common/typed_binder_benchmark_test.go @@ -6,10 +6,12 @@ package common import ( "reflect" "testing" + + "github.com/larksuite/cli/extension/command" ) type binderBenchmarkArgs struct { - Value Provided[int] + Value command.Provided[int] } func BenchmarkTypedBinderIndexedAssignment(b *testing.B) { @@ -27,7 +29,7 @@ func BenchmarkTypedBinderDirectAssignment(b *testing.B) { b.ReportAllocs() for i := 0; i < b.N; i++ { args := binderBenchmarkArgs{} - args.Value = Provided[int]{Value: 42, Set: true} + 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 index 3267e57511..8b972f7a1e 100644 --- a/shortcuts/common/typed_compile_args.go +++ b/shortcuts/common/typed_compile_args.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -//nolint:forbidigo // Compiler diagnostics are registration-time programmer errors consumed by Define's panic boundary, not command-facing failures. +//nolint:forbidigo // Compiler diagnostics are build-time declaration errors wrapped by the command-set startup guard. package common import ( @@ -19,15 +19,13 @@ var ( aliasNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) ) -var providedPkgPath = reflect.TypeFor[Provided[any]]().PkgPath() - const extensionCommandPkgPath = "github.com/larksuite/cli/extension/command" -func compileInput(argsType reflect.Type, definition InputDefinition) ([]compiledInputField, map[string]int, error) { +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]InputField, len(definition.Fields)) + 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) @@ -79,7 +77,7 @@ func compileInput(argsType reflect.Type, definition InputDefinition) ([]compiled return fields, fieldByName, nil } -func collectArgFields(t reflect.Type, parentIndex []int, insideInline bool, out *[]compiledInputField, seenGo map[string]struct{}, supplements map[string]InputField) error { +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() { @@ -147,7 +145,7 @@ func collectArgFields(t reflect.Type, parentIndex []int, insideInline bool, out if err != nil { return fmt.Errorf("Args field %s (--%s): %w", field.Name, flagName, err) } - var shape ValueShape + var shape typedValueShape supplement, hasSupplement := supplements[flagName] if hasSupplement && supplement.Shape != nil { if schema.nullable != nil || schemaHasShapeConstraints(schema) { @@ -187,7 +185,8 @@ func hasAnyTag(field reflect.StructField, names ...string) bool { } func unwrapProvided(t reflect.Type) (reflect.Type, []int, bool, error) { - if t.Kind() != reflect.Struct || (t.PkgPath() != providedPkgPath && t.PkgPath() != extensionCommandPkgPath) || !strings.HasPrefix(t.Name(), "Provided[") { + 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") @@ -201,7 +200,7 @@ func unwrapProvided(t reflect.Type) (reflect.Type, []int, bool, error) { return value.Type, value.Index, true, nil } -func mergeInputSupplement(field *compiledInputField, supplement InputField) error { +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") @@ -212,13 +211,17 @@ func mergeInputSupplement(field *compiledInputField, supplement InputField) erro if shapeHasConstraints(field.shape) || field.nullable != nil { return fmt.Errorf("Shape conflicts with schema constraints or nullable declaration") } - if err := validateShape(supplement.Shape, "InputField.Shape"); err != nil { + shape, err := lowerAuthoringShape(supplement.Shape) + if err != nil { + return err + } + if err := validateShape(shape, "InputField.Shape"); err != nil { return err } - if !shapeCompatibleWithType(supplement.Shape, field.valueType) { + if !shapeCompatibleWithType(shape, field.valueType) { return fmt.Errorf("InputField.Shape %T is incompatible with Go type %s", supplement.Shape, field.valueType) } - field.shape = supplement.Shape + field.shape = shape field.shapeExplicit = true } if supplement.Default.Set { @@ -234,13 +237,13 @@ func mergeInputSupplement(field *compiledInputField, supplement InputField) erro if len(field.cli.Aliases) > 0 { return fmt.Errorf("CLI.Aliases is declared by both cli tag and InputField.CLI") } - field.cli.Aliases = append([]FlagAlias(nil), supplement.CLI.Aliases...) + 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([]ValueSource(nil), supplement.CLI.ValueSources...) + field.cli.ValueSources = append([]typedValueSource(nil), supplement.CLI.ValueSources...) } if supplement.CLI.Encoding != "" { if field.cli.Encoding != "" { @@ -275,9 +278,9 @@ func validateInputCLI(field *compiledInputField) error { return fmt.Errorf("default: %w", err) } } - seenSources := make(map[ValueSource]struct{}) + seenSources := make(map[typedValueSource]struct{}) for _, source := range field.cli.ValueSources { - if source != SourceFlag && source != SourceFile && source != SourceStdin { + if source != typedSourceFlag && source != typedSourceFile && source != typedSourceStdin { return fmt.Errorf("unknown value source %q", source) } if _, duplicate := seenSources[source]; duplicate { @@ -286,10 +289,10 @@ func validateInputCLI(field *compiledInputField) error { seenSources[source] = struct{}{} } if len(field.cli.ValueSources) > 0 { - if _, ok := seenSources[SourceFlag]; !ok { + if _, ok := seenSources[typedSourceFlag]; !ok { return fmt.Errorf("ValueSources must include flag") } - if (len(seenSources) > 1) && indirectKind(field.valueType) != reflect.String && field.cli.Encoding != EncodingJSON { + 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") } } @@ -304,7 +307,7 @@ func validateInputCLI(field *compiledInputField) error { if kind == reflect.Slice || kind == reflect.Array || kind == reflect.Struct || kind == reflect.Map || kind == reflect.Interface { return fmt.Errorf("complex input requires encoding") } - case EncodingRepeated: + case typedEncodingRepeated: if kind != reflect.Slice && kind != reflect.Array { return fmt.Errorf("encoding repeated requires an array or slice") } @@ -314,7 +317,7 @@ func validateInputCLI(field *compiledInputField) error { if field.nullable != nil { return fmt.Errorf("encoding repeated does not allow nullable/nonnullable") } - case EncodingCommaOrRepeated: + case typedEncodingCommaOrRepeated: if kind != reflect.Slice && kind != reflect.Array { return fmt.Errorf("encoding comma_or_repeated requires an array or slice") } @@ -325,7 +328,7 @@ func validateInputCLI(field *compiledInputField) error { if field.nullable != nil { return fmt.Errorf("encoding comma_or_repeated does not allow nullable/nonnullable") } - case EncodingJSON: + 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") } @@ -348,17 +351,17 @@ func validateInputCLI(field *compiledInputField) error { } seenAliases[alias.Name] = struct{}{} switch alias.Mode { - case AliasNormalize: + 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 AliasIndependent: + case typedAliasIndependent: switch alias.Conflict { - case AliasCanonicalWins, AliasErrorIfBoth: - case AliasTrimmedEqualOrError: + 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) } @@ -376,7 +379,7 @@ type schemaTag struct { required bool optional bool nullable *bool - defaultValue InputDefault + defaultValue typedInputDefault enum []string format string minLength *int @@ -437,7 +440,7 @@ func parseSchemaTag(raw string, valueType reflect.Type, input bool) (schemaTag, if err := json.Unmarshal([]byte(value), &decoded); err != nil { return result, fmt.Errorf("schema default is not valid JSON: %w", err) } - result.defaultValue = InputDefault{Set: true, Value: decoded} + result.defaultValue = typedInputDefault{Set: true, Value: decoded} case "enum": if !hasValue || value == "" { return result, fmt.Errorf("schema enum requires at least one value") @@ -509,8 +512,8 @@ func parseSchemaTag(raw string, valueType reflect.Type, input bool) (schemaTag, return result, nil } -func parseCLITag(raw string) (CLIInput, error) { - var result CLIInput +func parseCLITag(raw string) (typedCLIInput, error) { + var result typedCLIInput if raw == "" { return result, nil } @@ -527,10 +530,10 @@ func parseCLITag(raw string) (CLIInput, error) { switch key { case "sources": for _, source := range strings.Split(value, "|") { - result.ValueSources = append(result.ValueSources, ValueSource(source)) + result.ValueSources = append(result.ValueSources, typedValueSource(source)) } case "encoding": - result.Encoding = CLIEncoding(value) + result.Encoding = typedCLIEncoding(value) default: return result, fmt.Errorf("unknown cli token %q", key) } @@ -579,7 +582,7 @@ func isNilCapable(t reflect.Type) bool { return false } } -func shapeCompatibleWithType(shape ValueShape, target reflect.Type) bool { +func shapeCompatibleWithType(shape typedValueShape, target reflect.Type) bool { base := indirectType(target) if base == jsonRawMessageType || base.Kind() == reflect.Interface { return true @@ -587,28 +590,28 @@ func shapeCompatibleWithType(shape ValueShape, target reflect.Type) bool { switch value := shape.(type) { case anyJSONShape: return true - case OneOfShape: + case typedOneOfShape: for _, variant := range value.Variants { if !shapeCompatibleWithType(variant, target) { return false } } return true - case NullShape: + case typedNullShape: return isNilCapable(target) - case ConstShape: + case typedConstShape: return valueAssignableTo(value.Value, target) == nil - case StringShape: + case typedStringShape: return base.Kind() == reflect.String - case BooleanShape: + case typedBooleanShape: return base.Kind() == reflect.Bool - case IntegerShape: + case typedIntegerShape: return isIntegerKind(base.Kind()) - case NumberShape: + case typedNumberShape: return base.Kind() == reflect.Float32 || base.Kind() == reflect.Float64 - case ArrayShape: + case typedArrayShape: return base.Kind() == reflect.Slice || base.Kind() == reflect.Array - case ObjectShape: + case typedObjectShape: return base.Kind() == reflect.Struct || base.Kind() == reflect.Map || base.Kind() == reflect.Interface default: return false diff --git a/shortcuts/common/typed_compile_contract.go b/shortcuts/common/typed_compile_contract.go index b8c4a66d08..85a281c5d9 100644 --- a/shortcuts/common/typed_compile_contract.go +++ b/shortcuts/common/typed_compile_contract.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -//nolint:forbidigo // Compiler diagnostics are registration-time programmer errors consumed by Define's panic boundary, not command-facing failures. +//nolint:forbidigo // Compiler diagnostics are build-time declaration errors wrapped by the command-set startup guard. package common import ( @@ -10,15 +10,15 @@ import ( "strings" ) -func compileRelations(definitions []Relation, fieldByName map[string]int) ([]compiledRelation, error) { +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 RelationExactlyOne, RelationAtLeastOne, RelationCoOccur, RelationConflicts: - case RelationRequires: + case typedRelationExactlyOne, typedRelationAtLeastOne, typedRelationCoOccur, typedRelationConflicts: + case typedRelationRequires: exact = 2 default: return nil, fmt.Errorf("Input.Relations[%d].Kind %q is invalid", i, definition.Kind) @@ -26,10 +26,10 @@ func compileRelations(definitions []Relation, fieldByName map[string]int) ([]com 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 != PresenceExplicit && definition.Presence != PresenceNonZero { + if definition.Presence != typedPresenceExplicit && definition.Presence != typedPresenceNonZero { return nil, fmt.Errorf("Input.Relations[%d].Presence %q is invalid", i, definition.Presence) } - if definition.Stage != StageSourcePreRun && definition.Stage != StageAfterPrepare { + 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} @@ -56,7 +56,7 @@ func compileRelations(definitions []Relation, fieldByName map[string]int) ([]com return result, nil } -func compileAuthorization(definition AuthorizationDefinition, fields []compiledInputField, fieldByName map[string]int) error { +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) @@ -85,95 +85,39 @@ func compileAuthorization(definition AuthorizationDefinition, fields []compiledI return nil } -func validateOutput(definition OutputDefinition, dataShape ValueShape) error { +func validateOutput(definition typedOutputDefinition, dataShape typedValueShape) error { switch definition.Mode { - case OutputGeneric, OutputFixedJSON: + case typedOutputGeneric, typedOutputFixedJSON: default: return fmt.Errorf("Output.Mode %q is invalid", definition.Mode) } - partial := definition.Outcomes.PartialFailure - if partial != nil { - if partial.ExitCode <= 0 { - return fmt.Errorf("Output.Outcomes.PartialFailure.ExitCode must be non-zero") - } - if failed := partial.FailedItems; failed != nil { - itemsShape, err := resolveShapePointer(dataShape, failed.ItemsPath) - if err != nil { - return fmt.Errorf("Output partial failed items path %q: %w", failed.ItemsPath, err) - } - array, ok := unwrapArray(itemsShape) - if !ok { - return fmt.Errorf("Output partial failed items path %q must identify an array", failed.ItemsPath) - } - for _, path := range failed.IdentityPaths { - if _, err := resolveShapePointer(array.Items, path); err != nil { - return fmt.Errorf("Output partial identity path %q: %w", path, err) - } - } - if failed.AllItems { - if failed.StatePath != "" || len(failed.FailedValues) > 0 { - return fmt.Errorf("Output partial FailedItems.AllItems conflicts with StatePath/FailedValues") - } - } else { - if failed.StatePath == "" || len(failed.FailedValues) == 0 { - return fmt.Errorf("Output partial FailedItems requires AllItems or StatePath with FailedValues") - } - stateShape, err := resolveShapePointer(array.Items, failed.StatePath) - if err != nil { - return fmt.Errorf("Output partial state path %q: %w", failed.StatePath, err) - } - for i, value := range failed.FailedValues { - if err := valueCompatibleWithShape(value, stateShape); err != nil { - return fmt.Errorf("Output partial FailedValues[%d]: %w", i, err) - } - } - } - } - } - artifactNames := make(map[string]struct{}) - for i, artifact := range definition.Artifacts { - if strings.TrimSpace(artifact.Name) == "" { - return fmt.Errorf("Output.Artifacts[%d].Name is required", i) - } - if _, duplicate := artifactNames[artifact.Name]; duplicate { - return fmt.Errorf("Output.Artifacts contains duplicate name %q", artifact.Name) - } - artifactNames[artifact.Name] = struct{}{} - if artifact.PathField == "" { - return fmt.Errorf("Output.Artifacts[%d].PathField is required", i) - } - items, err := resolveShapePointer(dataShape, artifact.ItemsPath) - if err != nil { - return fmt.Errorf("Output.Artifacts[%d].ItemsPath %q: %w", i, artifact.ItemsPath, err) - } - itemShape := items - if array, ok := unwrapArray(items); ok { - itemShape = array.Items - } - for label, path := range map[string]string{"PathField": artifact.PathField, "MediaTypeField": artifact.MediaTypeField, "SizeField": artifact.SizeField} { - if path == "" { - continue - } - resolved, err := resolveShapePointer(itemShape, path) - if err != nil { - return fmt.Errorf("Output.Artifacts[%d].%s %q: %w", i, label, path, err) - } - switch label { - case "PathField", "MediaTypeField": - if !shapeHasType(resolved, "string") { - return fmt.Errorf("Output.Artifacts[%d].%s %q must identify a string", i, label, path) - } - case "SizeField": - if !shapeHasType(resolved, "integer") { - return fmt.Errorf("Output.Artifacts[%d].SizeField %q must identify an integer", i, path) - } - } + 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 nil + return builder.String(), true } -func resolveShapePointer(shape ValueShape, pointer string) (ValueShape, error) { +func resolveShapePointer(shape typedValueShape, pointer string) (typedValueShape, error) { if pointer == "" { return shape, nil } @@ -195,19 +139,19 @@ func resolveShapePointer(shape ValueShape, pointer string) (ValueShape, error) { return current, nil } -func resolveShapeField(shape ValueShape, name string) (ValueShape, error) { +func resolveShapeField(shape typedValueShape, name string) (typedValueShape, error) { switch value := shape.(type) { - case ObjectShape: + 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 OneOfShape: - var resolved []ValueShape + case typedOneOfShape: + var resolved []typedValueShape for _, variant := range value.Variants { - if _, null := variant.(NullShape); null { + if _, null := variant.(typedNullShape); null { continue } field, err := resolveShapeField(variant, name) @@ -222,31 +166,31 @@ func resolveShapeField(shape ValueShape, name string) (ValueShape, error) { } } -func combineResolvedShapes(shapes []ValueShape) (ValueShape, error) { +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 OneOfShape{Variants: shapes}, nil + return typedOneOfShape{Variants: shapes}, nil } } -func shapeAsObject(shape ValueShape) (ObjectShape, bool) { - if object, ok := shape.(ObjectShape); ok { +func shapeAsObject(shape typedValueShape) (typedObjectShape, bool) { + if object, ok := shape.(typedObjectShape); ok { return object, true } - if one, ok := shape.(OneOfShape); ok { - var combined ObjectShape + if one, ok := shape.(typedOneOfShape); ok { + var combined typedObjectShape found := false for _, variant := range one.Variants { - if _, null := variant.(NullShape); null { + if _, null := variant.(typedNullShape); null { continue } object, ok := shapeAsObject(variant) if !ok { - return ObjectShape{}, false + return typedObjectShape{}, false } if !found { combined = object @@ -257,69 +201,9 @@ func shapeAsObject(shape ValueShape) (ObjectShape, bool) { } return combined, found } - return ObjectShape{}, false -} -func unwrapArray(shape ValueShape) (ArrayShape, bool) { - if array, ok := shape.(ArrayShape); ok { - return array, true - } - if one, ok := shape.(OneOfShape); ok { - var combined ArrayShape - var items []ValueShape - found := false - for _, variant := range one.Variants { - if _, null := variant.(NullShape); null { - continue - } - array, ok := unwrapArray(variant) - if !ok { - return ArrayShape{}, false - } - if !found { - combined = array - found = true - } - items = append(items, array.Items) - } - if !found { - return ArrayShape{}, false - } - combined.Items, _ = combineResolvedShapes(items) - return combined, true - } - return ArrayShape{}, false -} -func shapeHasType(shape ValueShape, want string) bool { - switch value := shape.(type) { - case StringShape: - return want == "string" - case IntegerShape: - return want == "integer" - case NumberShape: - return want == "number" - case BooleanShape: - return want == "boolean" - case ArrayShape: - return want == "array" - case ObjectShape: - return want == "object" - case OneOfShape: - found := false - for _, variant := range value.Variants { - if _, null := variant.(NullShape); null { - continue - } - found = true - if !shapeHasType(variant, want) { - return false - } - } - return found - } - return false + return typedObjectShape{}, false } - -func valueCompatibleWithShape(value any, shape ValueShape) error { +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) diff --git a/shortcuts/common/typed_compile_data.go b/shortcuts/common/typed_compile_data.go index e1829631c0..20c1edc49d 100644 --- a/shortcuts/common/typed_compile_data.go +++ b/shortcuts/common/typed_compile_data.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -//nolint:forbidigo // Compiler diagnostics are registration-time programmer errors consumed by Define's panic boundary, not command-facing failures. +//nolint:forbidigo // Compiler diagnostics are build-time declaration errors wrapped by the command-set startup guard. package common import ( @@ -20,15 +20,19 @@ var ( textMarshalerType = reflect.TypeFor[encoding.TextMarshaler]() ) -func compileData(dataType reflect.Type, definition DataDefinition) (ValueShape, error) { +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 { - if err := validateShape(definition.Shape, "Output.Data.Shape"); err != 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 definition.Shape, nil + return shape, nil } if dataType.Kind() == reflect.Interface && dataType.NumMethod() == 0 { if len(definition.Overrides) > 0 { @@ -60,21 +64,21 @@ func compileData(dataType reflect.Type, definition DataDefinition) (ValueShape, // 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{}) (ValueShape, error) { +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 ValueShape + var shape typedValueShape switch baseType.Kind() { case reflect.String: - stringShape := StringShape{Format: schema.format, MinLength: schema.minLength, MaxLength: schema.maxLength} + 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 := BooleanShape{} + booleanShape := typedBooleanShape{} for _, raw := range schema.enum { v, err := parseBool(raw) if err != nil { @@ -90,7 +94,7 @@ func shapeForType(t reflect.Type, schema schemaTag, input bool, active map[refle 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 := IntegerShape{} + integerShape := typedIntegerShape{} if schema.minimum != nil { v := int64(*schema.minimum) if float64(v) != *schema.minimum { @@ -117,7 +121,7 @@ func shapeForType(t reflect.Type, schema schemaTag, input bool, active map[refle } shape = integerShape case reflect.Float32, reflect.Float64: - numberShape := NumberShape{Minimum: schema.minimum, Maximum: schema.maximum} + numberShape := typedNumberShape{Minimum: schema.minimum, Maximum: schema.maximum} for _, raw := range schema.enum { v, err := parseFiniteFloatBits(raw, baseType.Bits()) if err != nil { @@ -144,7 +148,7 @@ func shapeForType(t reflect.Type, schema schemaTag, input bool, active map[refle if err != nil { return nil, fmt.Errorf("array item: %w", err) } - shape = ArrayShape{Items: elementShape, MinItems: schema.minItems, MaxItems: schema.maxItems} + 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) @@ -165,7 +169,7 @@ func shapeForType(t reflect.Type, schema schemaTag, input bool, active map[refle return nil, fmt.Errorf("Go type %s cannot be mapped to a ValueShape", t) } if schema.nullable != nil && *schema.nullable { - shape = OneOfShape{Variants: []ValueShape{shape, NullShape{}}} + shape = typedOneOfShape{Variants: []typedValueShape{shape, typedNullShape{}}} } return shape, nil } @@ -180,14 +184,14 @@ func shapeForType(t reflect.Type, schema schemaTag, input bool, active map[refle // 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{}) (ObjectShape, error) { +func compileStructShape(t reflect.Type, input bool, path string, active map[reflect.Type]struct{}) (typedObjectShape, error) { if _, cyclic := active[t]; cyclic { - return ObjectShape{}, fmt.Errorf("recursive type %s requires an explicit Shape", t) + return typedObjectShape{}, fmt.Errorf("recursive type %s requires an explicit Shape", t) } active[t] = struct{}{} defer delete(active, t) - shape := ObjectShape{} + shape := typedObjectShape{} seen := make(map[string]string) for i := 0; i < t.NumField(); i++ { field := t.Field(i) @@ -196,7 +200,7 @@ func compileStructShape(t reflect.Type, input bool, path string, active map[refl } rawJSON, ok := field.Tag.Lookup("json") if !ok { - return ObjectShape{}, fmt.Errorf("%s field %s must declare json tag", path, field.Name) + return typedObjectShape{}, fmt.Errorf("%s field %s must declare json tag", path, field.Name) } parts := strings.Split(rawJSON, ",") name := parts[0] @@ -204,7 +208,7 @@ func compileStructShape(t reflect.Type, input bool, path string, active map[refl continue } if name == "" { - return ObjectShape{}, fmt.Errorf("%s field %s json tag must explicitly name the field", path, field.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:] { @@ -213,61 +217,61 @@ func compileStructShape(t reflect.Type, input bool, path string, active map[refl omitempty = true case "": default: - return ObjectShape{}, fmt.Errorf("%s field %s has unsupported json option %q", path, field.Name, option) + return typedObjectShape{}, fmt.Errorf("%s field %s has unsupported json option %q", path, field.Name, option) } } if previous, exists := seen[name]; exists { - return ObjectShape{}, fmt.Errorf("%s field %s JSON name %q duplicates field %s", path, field.Name, name, previous) + 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 ObjectShape{}, fmt.Errorf("%s field %s (%s): %w", path, field.Name, name, err) + return typedObjectShape{}, fmt.Errorf("%s field %s (%s): %w", path, field.Name, name, err) } if !input && schema.defaultValue.Set { - return ObjectShape{}, fmt.Errorf("%s field %s (%s): Data field cannot declare default", path, field.Name, name) + return typedObjectShape{}, fmt.Errorf("%s field %s (%s): Data field cannot declare default", path, field.Name, name) } if schema.required && omitempty { - return ObjectShape{}, fmt.Errorf("%s field %s (%s): required Data field cannot use omitempty", path, field.Name, name) + return typedObjectShape{}, fmt.Errorf("%s field %s (%s): required Data field cannot use omitempty", path, field.Name, name) } if schema.optional && !omitempty { - return ObjectShape{}, fmt.Errorf("%s field %s (%s): optional Data field must use omitempty", path, field.Name, name) + 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 ObjectShape{}, fmt.Errorf("%s field %s (%s): nil-capable field must declare nullable or nonnullable", path, field.Name, name) + 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 ObjectShape{}, fmt.Errorf("%s field %s (%s): description is required via doc", path, field.Name, name) + 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 ObjectShape{}, fmt.Errorf("%s field %s (%s): %w", path, field.Name, name, err) + return typedObjectShape{}, fmt.Errorf("%s field %s (%s): %w", path, field.Name, name, err) } - shape.Fields = append(shape.Fields, ValueField{Name: name, Description: description, Required: schema.required, Shape: fieldShape}) + shape.Fields = append(shape.Fields, typedValueField{Name: name, Description: description, Required: schema.required, Shape: fieldShape}) } return shape, nil } -func validateShape(shape ValueShape, path string) error { +func validateShape(shape typedValueShape, path string) error { if shape == nil { return fmt.Errorf("%s is nil", path) } switch value := shape.(type) { case anyJSONShape: - case StringShape: + 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 BooleanShape: - case IntegerShape: + case typedBooleanShape: + case typedIntegerShape: if value.Minimum != nil && value.Maximum != nil && *value.Minimum > *value.Maximum { return fmt.Errorf("%s minimum exceeds maximum", path) } - case NumberShape: + 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) @@ -276,12 +280,12 @@ func validateShape(shape ValueShape, path string) error { if value.Minimum != nil && value.Maximum != nil && *value.Minimum > *value.Maximum { return fmt.Errorf("%s minimum exceeds maximum", path) } - case NullShape: - case ConstShape: + 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 ArrayShape: + case typedArrayShape: if value.Items == nil { return fmt.Errorf("%s.Items is required", path) } @@ -292,7 +296,7 @@ func validateShape(shape ValueShape, path string) error { return fmt.Errorf("%s minItems exceeds maxItems", path) } return validateShape(value.Items, path+".Items") - case ObjectShape: + case typedObjectShape: seen := make(map[string]struct{}) for i := range value.Fields { field := &value.Fields[i] @@ -316,7 +320,7 @@ func validateShape(shape ValueShape, path string) error { if value.AdditionalPropertiesShape != nil { return validateShape(value.AdditionalPropertiesShape, path+".AdditionalPropertiesShape") } - case OneOfShape: + case typedOneOfShape: if len(value.Variants) < 2 { return fmt.Errorf("%s oneOf requires at least two variants", path) } @@ -331,7 +335,7 @@ func validateShape(shape ValueShape, path string) error { return nil } -func applyDataOverride(root *ObjectShape, override DataField) error { +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") @@ -344,7 +348,7 @@ func applyDataOverride(root *ObjectShape, override DataField) error { } parts[i] = decoded } - return mutateObjectField(root, parts, func(field *ValueField) error { + 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") @@ -355,16 +359,20 @@ func applyDataOverride(root *ObjectShape, override DataField) error { if shapeHasConstraints(field.Shape) { return fmt.Errorf("Shape conflicts with schema constraints") } - if err := validateShape(override.Shape, "DataField.Shape"); err != nil { + shape, err := lowerAuthoringShape(override.Shape) + if err != nil { + return err + } + if err := validateShape(shape, "DataField.Shape"); err != nil { return err } - field.Shape = override.Shape + field.Shape = shape } return nil }) } -func mutateObjectField(object *ObjectShape, parts []string, mutate func(*ValueField) error) error { +func mutateObjectField(object *typedObjectShape, parts []string, mutate func(*typedValueField) error) error { name := parts[0] for i := range object.Fields { field := &object.Fields[i] @@ -375,13 +383,13 @@ func mutateObjectField(object *ObjectShape, parts []string, mutate func(*ValueFi return mutate(field) } switch nested := field.Shape.(type) { - case ObjectShape: + case typedObjectShape: err := mutateObjectField(&nested, parts[1:], mutate) field.Shape = nested return err - case OneOfShape: + case typedOneOfShape: for variantIndex, variant := range nested.Variants { - if nestedObject, ok := variant.(ObjectShape); ok { + if nestedObject, ok := variant.(typedObjectShape); ok { err := mutateObjectField(&nestedObject, parts[1:], mutate) nested.Variants[variantIndex] = nestedObject field.Shape = nested @@ -404,31 +412,31 @@ func pointerFloats(values ...*float64) []float64 { return result } -func shapeHasConstraints(shape ValueShape) bool { +func shapeHasConstraints(shape typedValueShape) bool { switch value := shape.(type) { - case StringShape: + case typedStringShape: return len(value.Enum) > 0 || value.Format != "" || value.MinLength != nil || value.MaxLength != nil - case BooleanShape: + case typedBooleanShape: return len(value.Enum) > 0 - case IntegerShape: + case typedIntegerShape: return len(value.Enum) > 0 || value.Minimum != nil || value.Maximum != nil - case NumberShape: + case typedNumberShape: return len(value.Enum) > 0 || value.Minimum != nil || value.Maximum != nil - case ArrayShape: + case typedArrayShape: return value.MinItems != nil || value.MaxItems != nil - case OneOfShape: + case typedOneOfShape: return true default: return false } } -func shapeExplicitlyNullable(shape ValueShape) bool { - one, ok := shape.(OneOfShape) +func shapeExplicitlyNullable(shape typedValueShape) bool { + one, ok := shape.(typedOneOfShape) if !ok { return false } for _, variant := range one.Variants { - if _, ok := variant.(NullShape); ok { + if _, ok := variant.(typedNullShape); ok { return true } } diff --git a/shortcuts/common/typed_compile_output.go b/shortcuts/common/typed_compile_output.go index 0d9a417200..8dfd3f1a5a 100644 --- a/shortcuts/common/typed_compile_output.go +++ b/shortcuts/common/typed_compile_output.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -//nolint:forbidigo // Registration-time compiler diagnostics are programmer errors surfaced through Define's panic boundary. +//nolint:forbidigo // Compiler diagnostics are build-time declaration errors wrapped by the command-set startup guard. package common import ( @@ -9,7 +9,7 @@ import ( "sort" ) -func validateOutputHooks(definition OutputDefinition, renderers map[string]RendererMarker) error { +func validateOutputHooks(definition typedOutputDefinition, renderers map[string]rendererMarker) error { rendererNames := make([]string, 0, len(renderers)) for name := range renderers { rendererNames = append(rendererNames, name) @@ -23,21 +23,13 @@ func validateOutputHooks(definition OutputDefinition, renderers map[string]Rende 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 == OutputFixedJSON { + 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 generic compiler inspect nil renderer values without -// adapting Args/Data hooks or exposing the private compiled hook type. -type RendererMarker struct{ isNil bool } - -func rendererMarkers[Data any](renderers map[string]typedRenderer[Data]) map[string]RendererMarker { - markers := make(map[string]RendererMarker, len(renderers)) - for name, renderer := range renderers { - markers[name] = RendererMarker{isNil: renderer == nil} - } - return markers -} +// 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 index 3798a0669e..b43499b469 100644 --- a/shortcuts/common/typed_compile_recursion_test.go +++ b/shortcuts/common/typed_compile_recursion_test.go @@ -8,6 +8,8 @@ import ( "reflect" "strings" "testing" + + "github.com/larksuite/cli/internal/commandbridge" ) // recursiveSlice refers back to itself through a slice field. @@ -59,7 +61,7 @@ func TestCompileDataRejectsRecursiveTypes(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := compileData(tt.typ, DataDefinition{}) + _, err := compileData(tt.typ, typedDataDefinition{}) if err == nil || !strings.Contains(err.Error(), tt.want) { t.Fatalf("error = %v, want containing %q", err, tt.want) } @@ -80,7 +82,7 @@ func TestCompileDataAllowsRepeatedNonRecursiveTypes(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if _, err := compileData(tt.typ, DataDefinition{}); err != nil { + if _, err := compileData(tt.typ, typedDataDefinition{}); err != nil { t.Fatalf("compileData() error = %v, want nil", err) } }) @@ -91,41 +93,41 @@ func TestCompileInputRejectsRecursiveJSONTypes(t *testing.T) { args := reflect.TypeFor[struct { Tree recursiveSlice `flag:"tree" schema:"required" cli:"encoding=json" doc:"tree payload"` }]() - _, _, err := compileInput(args, InputDefinition{}) + _, _, 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) } } -// TestCompileErasedDefinitionRejectsRecursiveDataWithoutCrashing pins the +// 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 TestCompileErasedDefinitionRejectsRecursiveDataWithoutCrashing(t *testing.T) { +func TestCompileCommandDefinitionRejectsRecursiveDataWithoutCrashing(t *testing.T) { type recursionArgs struct { Name string `flag:"name" schema:"optional" doc:"a name"` } - _, err := CompileErasedDefinition(ErasedDefinition{ - Metadata: CommandMetadata{ + _, err := CompileCommandDefinition(commandbridge.Definition{ + Metadata: typedCommandMetadata{ Service: "probe", Command: "+tree", Description: "probe", - Risk: RiskRead, - Authorization: AuthorizationDefinition{ - Identities: map[Identity]IdentityAuthorization{ - IdentityUser: {RequiredScopes: []string{"probe:read"}}, + Risk: typedRiskRead, + Authorization: typedAuthorizationDefinition{ + Identities: map[typedIdentity]typedIdentityAuthorization{ + typedIdentityUser: {RequiredScopes: []string{"probe:read"}}, }, }, }, ArgsType: reflect.TypeFor[recursionArgs](), DataType: reflect.TypeFor[recursiveSlice](), - Hooks: ErasedHooks{ + Hooks: commandbridge.Hooks{ NewArgs: func() any { return &recursionArgs{} }, - Execute: func(context.Context, CommandContext, any) (ErasedResult, error) { - return ErasedResult{Data: recursiveSlice{}}, nil + 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("CompileErasedDefinition() error = %v, want containing recursive type diagnostic", err) + 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 index 1ffe5920db..ebaf3aa9a3 100644 --- a/shortcuts/common/typed_compiler.go +++ b/shortcuts/common/typed_compiler.go @@ -1,65 +1,24 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -//nolint:forbidigo // Compiler diagnostics are registration-time programmer errors converted to contextual Define panics, not command-facing failures. +//nolint:forbidigo // Compiler diagnostics are build-time declaration errors wrapped by the command-set startup guard. package common import ( - "context" "encoding/json" "fmt" - "io" "reflect" "strings" ) -// Define compiles a Typed Shortcut definition. Invalid definitions are -// programmer errors and panic during registration; no partial legacy fallback -// is returned. -func defineTypedShortcut[Args any, Data any](definition typedDefinition[Args, Data]) Shortcut { - compiled, err := compileDefinition(definition) - if err != nil { - service := strings.TrimSpace(definition.Metadata.Service) - command := strings.TrimSpace(definition.Metadata.Command) - if service == "" { - service = "" - } - if command == "" { - command = "" - } - panic(fmt.Sprintf("typed shortcut %s %s: %v", service, command, err)) - } - shortcut := shortcutFromCompiled(compiled) - if err := validateTypedFlagMountPlan(compiled, shortcut.PrintFlagSchema != nil, Risk(shortcut.Risk)); err != nil { - panic(fmt.Sprintf("typed shortcut %s %s: %v", compiled.metadata.Service, compiled.metadata.Command, err)) - } - return shortcut -} - -func compileDefinition[Args any, Data any](definition typedDefinition[Args, Data]) (*compiledCommand, error) { - if definition.Hooks.Execute == nil { - return nil, fmt.Errorf("Hooks.Execute is required") - } - return compileDefinitionParts( - definition.Metadata, - definition.Input, - definition.Output, - reflect.TypeFor[Args](), - reflect.TypeFor[Data](), - adaptHooks(definition.Hooks), - rendererMarkers(definition.Hooks.Renderers), - false, - ) -} - func compileDefinitionParts( - metadata CommandMetadata, - input InputDefinition, - output OutputDefinition, + metadata typedCommandMetadata, + input typedInputDefinition, + output typedOutputDefinition, argsType reflect.Type, dataType reflect.Type, hooks compiledHooks, - renderers map[string]RendererMarker, + renderers map[string]rendererMarker, pageOutput bool, ) (*compiledCommand, error) { metadata = normalizeCommandMetadata(metadata) @@ -106,33 +65,32 @@ func compileDefinitionParts( return command, nil } -func normalizeCommandMetadata(metadata CommandMetadata) CommandMetadata { - identities := make(map[Identity]IdentityAuthorization, len(metadata.Authorization.Identities)) +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([]ConditionalScope(nil), authorization.ConditionalScopes...) + 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 = ScopeRequired + conditional.Requirement = typedScopeRequired } } identities[identity] = authorization } metadata.Authorization.Identities = identities - metadata.Authorization.IdentityOrder = append([]Identity(nil), metadata.Authorization.IdentityOrder...) - metadata.Tips = append([]string(nil), metadata.Tips...) + metadata.Authorization.IdentityOrder = append([]typedIdentity(nil), metadata.Authorization.IdentityOrder...) return metadata } -func validateCommandMetadata(metadata CommandMetadata) error { - service := strings.TrimSpace(metadata.Service) +func validateCommandMetadata(metadata typedCommandMetadata) error { + service := strings.TrimSpace(string(metadata.Service)) if service == "" { return fmt.Errorf("Metadata.Service is required") } - if service != metadata.Service || strings.ContainsAny(service, " \t\r\n/") { + 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) @@ -151,13 +109,8 @@ func validateCommandMetadata(metadata CommandMetadata) error { if strings.TrimSpace(metadata.Description) == "" { return fmt.Errorf("Metadata.Description is required") } - for i, tip := range metadata.Tips { - if strings.TrimSpace(tip) == "" { - return fmt.Errorf("Metadata.Tips[%d] must not be blank", i) - } - } switch metadata.Risk { - case RiskRead, RiskWrite, RiskHighRiskWrite: + case typedRiskRead, typedRiskWrite, typedRiskHighRiskWrite: default: return fmt.Errorf("Metadata.Risk %q is invalid", metadata.Risk) } @@ -165,7 +118,7 @@ func validateCommandMetadata(metadata CommandMetadata) error { return fmt.Errorf("Metadata.Authorization.Identities must declare at least one identity") } for identity, auth := range metadata.Authorization.Identities { - if identity != IdentityUser && identity != IdentityBot { + 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 { @@ -192,7 +145,7 @@ func validateCommandMetadata(metadata CommandMetadata) error { return fmt.Errorf("%s.When must be trimmed", path) } switch conditional.Requirement { - case ScopeRequired, ScopeBestEffort: + case typedScopeRequired, typedScopeBestEffort: default: return fmt.Errorf("%s.Requirement %q is invalid", path, conditional.Requirement) } @@ -202,7 +155,7 @@ func validateCommandMetadata(metadata CommandMetadata) error { 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[Identity]struct{}, len(metadata.Authorization.IdentityOrder)) + 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) @@ -230,52 +183,20 @@ func validateScopeList(scopes []string, path string) error { return nil } -func adaptHooks[Args any, Data any](hooks typedHooks[Args, Data]) compiledHooks { - adapted := compiledHooks{newArgs: func() any { return new(Args) }} - if hooks.Normalize != nil { - adapted.normalize = func(ctx context.Context, cc CommandContext, args any) error { - return hooks.Normalize(ctx, cc, args.(*Args)) - } - } - if hooks.Validate != nil { - adapted.validate = func(ctx context.Context, cc CommandContext, args any) error { - return hooks.Validate(ctx, cc, args.(*Args)) - } - } - if hooks.DryRun != nil { - adapted.dryRun = func(ctx context.Context, cc CommandContext, args any) (*DryRunAPI, error) { - return hooks.DryRun(ctx, cc, args.(*Args)), nil - } - } - adapted.execute = func(ctx context.Context, cc CommandContext, args any) (compiledResult, error) { - result, err := hooks.Execute(ctx, cc, args.(*Args)) - return compiledResult{data: result.Data, outcome: result.Outcome, meta: result.Meta}, err - } - if len(hooks.Renderers) > 0 { - adapted.renderers = make(map[string]func(io.Writer, any) error, len(hooks.Renderers)) - for name, renderer := range hooks.Renderers { - r := renderer - adapted.renderers[name] = func(w io.Writer, data any) error { return r(w, data.(Data)) } - } - } - return adapted -} - func shortcutFromCompiled(compiled *compiledCommand) Shortcut { metadata := compiled.metadata shortcut := Shortcut{ - Service: metadata.Service, + Service: string(metadata.Service), Command: metadata.Command, Description: metadata.Description, Risk: string(metadata.Risk), Hidden: metadata.Hidden, - Tips: append([]string(nil), metadata.Tips...), typed: compiled, } identities := make([]string, 0, len(metadata.Authorization.Identities)) identityOrder := metadata.Authorization.IdentityOrder if len(identityOrder) == 0 { - identityOrder = []Identity{IdentityUser, IdentityBot} + identityOrder = []typedIdentity{typedIdentityUser, typedIdentityBot} } for _, identity := range identityOrder { if auth, ok := metadata.Authorization.Identities[identity]; ok { @@ -283,10 +204,10 @@ func shortcutFromCompiled(compiled *compiledCommand) Shortcut { scopes := append([]string(nil), auth.RequiredScopes...) conditional := flattenConditionalScopes(auth.ConditionalScopes) switch identity { - case IdentityUser: + case typedIdentityUser: shortcut.UserScopes = scopes shortcut.ConditionalUserScopes = conditional - case IdentityBot: + case typedIdentityBot: shortcut.BotScopes = scopes shortcut.ConditionalBotScopes = conditional } @@ -298,7 +219,7 @@ func shortcutFromCompiled(compiled *compiledCommand) Shortcut { return shortcut } -func flattenConditionalScopes(definitions []ConditionalScope) []string { +func flattenConditionalScopes(definitions []typedConditionalScope) []string { seen := make(map[string]struct{}) var result []string for _, definition := range definitions { @@ -332,11 +253,11 @@ func legacyFlagsFromCompiled(fields []compiledInputField) []Flag { } } for _, alias := range field.cli.Aliases { - if alias.Mode == AliasNormalize { + if alias.Mode == typedAliasNormalize { flag.Aliases = append(flag.Aliases, alias.Name) } } - if stringShape, ok := field.shape.(StringShape); ok { + if stringShape, ok := field.shape.(typedStringShape); ok { flag.Enum = append([]string(nil), stringShape.Enum...) } if hasIndependentAlias(field.cli.Aliases) { @@ -344,7 +265,7 @@ func legacyFlagsFromCompiled(fields []compiledInputField) []Flag { } flags = append(flags, flag) for _, alias := range field.cli.Aliases { - if alias.Mode != AliasIndependent { + if alias.Mode != typedAliasIndependent { continue } aliasFlag := flag @@ -360,9 +281,9 @@ func legacyFlagsFromCompiled(fields []compiledInputField) []Flag { return flags } -func hasIndependentAlias(aliases []FlagAlias) bool { +func hasIndependentAlias(aliases []typedFlagAlias) bool { for _, alias := range aliases { - if alias.Mode == AliasIndependent { + if alias.Mode == typedAliasIndependent { return true } } @@ -383,11 +304,11 @@ func legacyFlagType(field compiledInputField) string { return "float64" case reflect.Slice, reflect.Array: switch field.cli.Encoding { - case EncodingRepeated: + case typedEncodingRepeated: if t.Elem().Kind() == reflect.String { return "string_array" } - case EncodingCommaOrRepeated: + case typedEncodingCommaOrRepeated: if isIntegerKind(t.Elem().Kind()) { return "int_array" } @@ -397,13 +318,13 @@ func legacyFlagType(field compiledInputField) string { return "string" } -func legacyInputSources(sources []ValueSource) []string { +func legacyInputSources(sources []typedValueSource) []string { var result []string for _, source := range sources { switch source { - case SourceFile: + case typedSourceFile: result = append(result, File) - case SourceStdin: + case typedSourceStdin: result = append(result, Stdin) } } diff --git a/shortcuts/common/typed_compiler_invalid_test.go b/shortcuts/common/typed_compiler_invalid_test.go index a32fc238c5..d20aa1ea22 100644 --- a/shortcuts/common/typed_compiler_invalid_test.go +++ b/shortcuts/common/typed_compiler_invalid_test.go @@ -4,22 +4,28 @@ package common import ( + "context" "math" "reflect" "strings" "testing" + + "github.com/larksuite/cli/extension/command" + "github.com/larksuite/cli/internal/commandbridge" ) -func TestCompileErasedDefinitionConvertsNewArgsPanicToError(t *testing.T) { - _, err := CompileErasedDefinition(ErasedDefinition{ +func TestCompileCommandDefinitionConvertsNewArgsPanicToError(t *testing.T) { + _, err := CompileCommandDefinition(commandbridge.Definition{ ArgsType: reflect.TypeFor[compilerArgs](), DataType: reflect.TypeFor[compilerData](), - Hooks: ErasedHooks{NewArgs: func() any { + 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("CompileErasedDefinition() error = %v", err) + t.Fatalf("CompileCommandDefinition() error = %v", err) } } @@ -66,7 +72,7 @@ func TestCompileDefinitionRejectsInvalidCommandSegments(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { definition := validCompilerDefinition() - definition.Metadata.Service = test.service + 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) @@ -117,56 +123,56 @@ func TestCompileInputRejectsInvalidFieldContracts(t *testing.T) { tests := []struct { name string typ reflect.Type - input InputDefinition + input typedInputDefinition want string }{ - {"not struct", reflect.TypeFor[string](), InputDefinition{}, "Args must"}, - {"missing marker", reflect.TypeFor[struct{ Value string }](), InputDefinition{}, "exactly one"}, + {"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"` - }](), InputDefinition{}, "unexported"}, + }](), typedInputDefinition{}, "unexported"}, {"both markers", reflect.TypeFor[struct { Value string `flag:"value" arg:"local"` - }](), InputDefinition{}, "exactly one"}, + }](), typedInputDefinition{}, "exactly one"}, {"unknown arg", reflect.TypeFor[struct { Value string `arg:"derived"` - }](), InputDefinition{}, "unknown arg mode"}, + }](), typedInputDefinition{}, "unknown arg mode"}, {"complex missing encoding", reflect.TypeFor[struct { Values []string `flag:"values" schema:"optional" doc:"values"` - }](), InputDefinition{}, "explicitly declare CLI encoding"}, + }](), 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"` - }](), InputDefinition{}, "requires exactly 2 items"}, + }](), typedInputDefinition{}, "requires exactly 2 items"}, {"json nil unspecified", reflect.TypeFor[struct { Values []string `flag:"values" schema:"optional" cli:"encoding=json" doc:"values"` - }](), InputDefinition{}, "must declare nullable"}, + }](), typedInputDefinition{}, "must declare nullable"}, {"file on int", reflect.TypeFor[struct { Value int `flag:"value" schema:"optional" cli:"sources=flag|file" doc:"value"` - }](), InputDefinition{}, "file/stdin"}, + }](), typedInputDefinition{}, "file/stdin"}, {"unknown supplement", reflect.TypeFor[struct { Value string `flag:"value" schema:"optional" doc:"value"` - }](), InputDefinition{Fields: []InputField{{Name: "other"}}}, "unknown flag"}, + }](), typedInputDefinition{Fields: []typedInputField{{Name: "other"}}}, "unknown flag"}, {"description conflict", reflect.TypeFor[struct { Value string `flag:"value" schema:"optional" doc:"value"` - }](), InputDefinition{Fields: []InputField{{Name: "value", Description: "again"}}}, "both doc"}, + }](), typedInputDefinition{Fields: []typedInputField{{Name: "value", Description: "again"}}}, "both doc"}, {"oneOf includes unrepresentable variant", reflect.TypeFor[struct { Value string `flag:"value" schema:"optional" doc:"value"` - }](), InputDefinition{Fields: []InputField{{Name: "value", Shape: OneOfShape{Variants: []ValueShape{StringShape{}, IntegerShape{}}}}}}, "incompatible with Go type"}, + }](), 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"` - }](), InputDefinition{Fields: []InputField{{Name: "value", Shape: StringShape{}}}}, "conflicts with schema constraints"}, + }](), 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"` - }](), InputDefinition{}, "only supports string arrays"}, + }](), typedInputDefinition{}, "only supports string arrays"}, {"byte slice inference", reflect.TypeFor[struct { Value []byte `flag:"value" schema:"optional;nonnullable" cli:"encoding=json" doc:"value"` - }](), InputDefinition{}, "requires an explicit Shape"}, + }](), typedInputDefinition{}, "requires an explicit Shape"}, {"alias missing conflict", reflect.TypeFor[struct { Value string `flag:"value" schema:"optional" doc:"value"` - }](), InputDefinition{Fields: []InputField{{Name: "value", CLI: CLIInput{Aliases: []FlagAlias{{Name: "old", Mode: AliasIndependent}}}}}}, "must declare"}, + }](), 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"` - }](), InputDefinition{Fields: []InputField{{Name: "value", CLI: CLIInput{Aliases: []FlagAlias{{Name: "old", Mode: AliasNormalize, Deprecated: true}}}}}}, "must use independent mode"}, + }](), 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) { @@ -202,7 +208,7 @@ func TestCompileDataRejectsJSONContractDrift(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := compileData(tt.typ, DataDefinition{}) + _, err := compileData(tt.typ, typedDataDefinition{}) if err == nil || !strings.Contains(err.Error(), tt.want) { t.Fatalf("error = %v, want containing %q", err, tt.want) } @@ -212,56 +218,17 @@ func TestCompileDataRejectsJSONContractDrift(t *testing.T) { func TestValidateShapeRejectsMalformedExplicitShapes(t *testing.T) { for _, tt := range []struct { - shape ValueShape + shape typedValueShape want string }{ - {OneOfShape{Variants: []ValueShape{StringShape{}}}, "at least two"}, - {ArrayShape{}, "Items is required"}, - {ObjectShape{Fields: []ValueField{{Name: "x", Shape: StringShape{}}}}, "Description is required"}, - {ObjectShape{AdditionalPropertiesShape: StringShape{}}, "requires AdditionalProperties"}, - {NumberShape{Enum: []float64{math.Inf(1)}}, "must be finite"}, + {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) } } } - -func TestValidateOutputChecksEveryOneOfVariant(t *testing.T) { - itemWithStringPath := ObjectShape{Fields: []ValueField{{Name: "path", Description: "artifact path", Required: true, Shape: StringShape{}}}} - itemWithIntegerPath := ObjectShape{Fields: []ValueField{{Name: "path", Description: "artifact path", Required: true, Shape: IntegerShape{}}}} - items := func(item ValueShape) ValueField { - return ValueField{Name: "items", Description: "items", Required: true, Shape: ArrayShape{Items: item}} - } - - t.Run("missing path in variant", func(t *testing.T) { - valid := ObjectShape{Fields: []ValueField{items(itemWithStringPath)}} - invalid := ObjectShape{Fields: []ValueField{{Name: "other", Description: "other", Required: true, Shape: StringShape{}}}} - for _, variants := range [][]ValueShape{{valid, invalid}, {invalid, valid}} { - shape := OneOfShape{Variants: variants} - output := OutputDefinition{Outcomes: OutcomeDefinition{PartialFailure: &PartialFailureDefinition{ - ExitCode: 1, - FailedItems: &FailedItemDefinition{ - ItemsPath: "/items", - StatePath: "/path", - FailedValues: []JSONValue{"failed"}, - }, - }}} - if err := validateOutput(output, shape); err == nil || !strings.Contains(err.Error(), "does not exist") { - t.Fatalf("variants %T/%T error = %v", variants[0], variants[1], err) - } - } - }) - - t.Run("incompatible path type in variant", func(t *testing.T) { - valid := ObjectShape{Fields: []ValueField{items(itemWithStringPath)}} - invalid := ObjectShape{Fields: []ValueField{items(itemWithIntegerPath)}} - for _, variants := range [][]ValueShape{{valid, invalid}, {invalid, valid}} { - shape := OneOfShape{Variants: variants} - output := OutputDefinition{Artifacts: []ArtifactDefinition{{Name: "artifact", ItemsPath: "/items", PathField: "/path"}}} - if err := validateOutput(output, shape); err == nil || !strings.Contains(err.Error(), "must identify a string") { - t.Fatalf("variants %T/%T error = %v", variants[0], variants[1], err) - } - } - }) -} diff --git a/shortcuts/common/typed_compiler_test.go b/shortcuts/common/typed_compiler_test.go index 57104a07b5..b41b86c18f 100644 --- a/shortcuts/common/typed_compiler_test.go +++ b/shortcuts/common/typed_compiler_test.go @@ -10,6 +10,8 @@ import ( "reflect" "strings" "testing" + + "github.com/larksuite/cli/extension/command" ) type CompilerInlineArgs struct { @@ -29,9 +31,9 @@ func (value compilerCustomJSON) MarshalJSON() ([]byte, error) { } type compilerArgs struct { - Token string `flag:"token" schema:"required;minLength=1" doc:"target token"` - Limit 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"` + 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"` } @@ -49,28 +51,22 @@ type compilerData struct { func validCompilerDefinition() typedDefinition[compilerArgs, compilerData] { return typedDefinition[compilerArgs, compilerData]{ - Metadata: CommandMetadata{ - Service: "fixture", Command: "+compile", Description: "Compile a fixture", Risk: RiskWrite, - Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{ - IdentityUser: { + Metadata: typedCommandMetadata{ + Service: "fixture", Command: "+compile", Description: "Compile a fixture", Risk: typedRiskWrite, + Authorization: typedAuthorizationDefinition{Identities: map[typedIdentity]typedIdentityAuthorization{ + typedIdentityUser: { RequiredScopes: []string{"fixture:write"}, - ConditionalScopes: []ConditionalScope{{Scopes: []string{"fixture:read"}, When: "--payload selects the read path", Params: []string{"payload"}}}, + ConditionalScopes: []typedConditionalScope{{Scopes: []string{"fixture:read"}, When: "--payload selects the read path", Params: []string{"payload"}}}, }, }}, }, - Input: InputDefinition{ - Fields: []InputField{{Name: "token", CLI: CLIInput{Aliases: []FlagAlias{{Name: "legacy-token", Mode: AliasIndependent, Conflict: AliasTrimmedEqualOrError, Hidden: true}}}}}, - Relations: []Relation{{Kind: RelationRequires, Params: []string{"payload", "token"}, Presence: PresenceExplicit, Stage: StageSourcePreRun}}, - }, - Output: OutputDefinition{ - Outcomes: OutcomeDefinition{PartialFailure: &PartialFailureDefinition{ - ExitCode: 3, - FailedItems: &FailedItemDefinition{ItemsPath: "/items", IdentityPaths: []string{"/id"}, StatePath: "/state", FailedValues: []JSONValue{"failed"}}, - }}, - Mode: OutputFixedJSON, + 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}}, }, - Hooks: typedHooks[compilerArgs, compilerData]{Execute: func(context.Context, CommandContext, *compilerArgs) (Result[compilerData], error) { - return Success(compilerData{}), nil + Output: typedOutputDefinition{Mode: typedOutputFixedJSON}, + Hooks: typedHooks[compilerArgs, compilerData]{Execute: func(context.Context, typedRuntimeContext, *compilerArgs) (typedResult[compilerData], error) { + return typedSuccess(compilerData{}), nil }}, } } @@ -92,8 +88,8 @@ func TestDefineCompilesTypedContract(t *testing.T) { 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[IdentityUser].ConditionalScopes[0] - if conditional.Requirement != ScopeRequired || conditional.When == "" || !equalStrings(conditional.Params, []string{"payload"}) { + 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 { @@ -104,58 +100,38 @@ func TestDefineCompilesTypedContract(t *testing.T) { t.Fatalf("compiled limit = %#v", limit) } payload := shortcut.typed.fields[shortcut.typed.fieldByName["payload"]] - if payload.cli.Encoding != EncodingJSON || len(payload.cli.ValueSources) != 3 { + 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.(ObjectShape); !ok { + if _, ok := shortcut.typed.dataShape.(typedObjectShape); !ok { t.Fatalf("data shape = %T, want ObjectShape", shortcut.typed.dataShape) } } func TestValueShapeClosedSet(t *testing.T) { - shapes := []ValueShape{ - StringShape{}, - BooleanShape{}, - IntegerShape{}, - NumberShape{}, - NullShape{}, - ConstShape{}, - ArrayShape{}, - ObjectShape{}, - OneOfShape{}, + shapes := []typedValueShape{ + typedStringShape{}, + typedBooleanShape{}, + typedIntegerShape{}, + typedNumberShape{}, + typedNullShape{}, + typedConstShape{}, + typedArrayShape{}, + typedObjectShape{}, + typedOneOfShape{}, anyJSONShape{}, } for _, shape := range shapes { - shape.valueShape() - } -} - -func TestDefineClonesTipsAndRejectsBlankTips(t *testing.T) { - definition := validCompilerDefinition() - definition.Metadata.Tips = []string{"first tip", " second tip "} - shortcut := defineTypedShortcut(definition) - definition.Metadata.Tips[0] = "mutated" - want := []string{"first tip", " second tip "} - if got := shortcut.Tips; !reflect.DeepEqual(got, want) { - t.Fatalf("Shortcut.Tips = %#v, want %#v", got, want) - } - if got := shortcut.typed.metadata.Tips; !reflect.DeepEqual(got, want) { - t.Fatalf("compiled Metadata.Tips = %#v, want %#v", got, want) - } - - definition = validCompilerDefinition() - definition.Metadata.Tips = []string{" \t "} - if _, err := compileDefinition(definition); err == nil || !strings.Contains(err.Error(), "Metadata.Tips[0]") { - t.Fatalf("compileDefinition() error = %v, want blank Tip rejection", err) + shape.typedValueShape() } } func TestCompiledTypedSchemaContract(t *testing.T) { definition := validCompilerDefinition() - definition.Output.Meta = ResultMetaDefinition{Count: true, Pagination: true} + 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) @@ -170,23 +146,19 @@ func TestCompiledTypedSchemaContract(t *testing.T) { 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 != RiskWrite || !equalStrings(contract.Meta.AccessTokens, []string{"user"}) { + 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[IdentityUser].ConditionalScopes[0] - if conditional.When != "--payload selects the read path" || conditional.Requirement != ScopeRequired || !equalStrings(conditional.Params, []string{"payload"}) { + 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 || contract.Meta.Outcomes.PartialFailure.ExitCode != 3 { - t.Fatalf("partial outcome = %#v", contract.Meta.Outcomes.PartialFailure) + 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") } - count := contract.Meta.ResultMeta.Properties["count"] - if count.Type != "integer" || count.Minimum == nil || *count.Minimum != 0 { - t.Fatalf("result meta count = %#v", count) - } 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) @@ -202,16 +174,6 @@ func TestCompiledTypedSchemaContract(t *testing.T) { } } -func TestCompileOutputAcceptsResultLevelPartial(t *testing.T) { - definition := validCompilerDefinition() - definition.Output.Outcomes.PartialFailure.FailedItems = nil - shortcut := defineTypedShortcut(definition) - partial := shortcut.typed.contract.Meta.Outcomes.PartialFailure - if !partial.Supported || partial.ExitCode != 3 || partial.FailedItems != nil { - t.Fatalf("result-level partial contract = %#v", partial) - } -} - func TestCompiledSchemaRecordsJSONHTMLEscapingPolicy(t *testing.T) { definition := validCompilerDefinition() contract := defineTypedShortcut(definition).typed.contract @@ -228,7 +190,7 @@ func TestCompiledSchemaRecordsJSONHTMLEscapingPolicy(t *testing.T) { func TestCompileOutputDerivesExecutableGenericFormats(t *testing.T) { definition := validCompilerDefinition() - definition.Output.Mode = OutputGeneric + 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 { @@ -254,14 +216,14 @@ func TestCompileOutputRecordsCompatibilityFallbacks(t *testing.T) { } definition := validCompilerDefinition() - definition.Output.Mode = OutputGeneric + 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 TestDefinePreservesCollectionDefaultForCobraAndMapBinder(t *testing.T) { +func TestDefinePreservesCollectionDefaultForCobra(t *testing.T) { type args struct { Values []string `flag:"values" schema:"optional" cli:"encoding=repeated" doc:"values"` } @@ -269,23 +231,16 @@ func TestDefinePreservesCollectionDefaultForCobraAndMapBinder(t *testing.T) { OK bool `json:"ok" schema:"required" doc:"success state"` } definition := typedDefinition[args, data]{ - Metadata: CommandMetadata{Service: "fixture", Command: "+collection-default", Description: "collection default", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, - Input: InputDefinition{Fields: []InputField{{Name: "values", Default: InputDefault{Set: true, Value: []string{"a", "b"}}}}}, - Hooks: typedHooks[args, data]{Execute: func(context.Context, CommandContext, *args) (Result[data], error) { - return Success(data{OK: true}), nil + 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) } - bound, err := bindTypedMap(shortcut.typed, nil) - if err != nil { - t.Fatal(err) - } - if got := bound.value.(*args).Values; !reflect.DeepEqual(got, []string{"a", "b"}) { - t.Fatalf("bound default = %#v", got) - } } func TestDefinePanicIncludesCommandAndFieldContext(t *testing.T) { @@ -297,7 +252,9 @@ func TestDefinePanicIncludesCommandAndFieldContext(t *testing.T) { } definition := typedDefinition[badArgs, data]{ Metadata: validCompilerDefinition().Metadata, - Hooks: typedHooks[badArgs, data]{Execute: func(context.Context, CommandContext, *badArgs) (Result[data], error) { return Success(data{}), nil }}, + Hooks: typedHooks[badArgs, data]{Execute: func(context.Context, typedRuntimeContext, *badArgs) (typedResult[data], error) { + return typedSuccess(data{}), nil + }}, } defer func() { panicValue := recover() @@ -321,63 +278,44 @@ func TestCompileDefinitionRejectsInvalidContracts(t *testing.T) { 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 = Risk("delete") }, "Metadata.Risk"}, + {"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[IdentityUser] + auth := d.Metadata.Authorization.Identities[typedIdentityUser] auth.ConditionalScopes[0].Params = []string{"missing"} - d.Metadata.Authorization.Identities[IdentityUser] = auth + d.Metadata.Authorization.Identities[typedIdentityUser] = auth }, "unknown param --missing"}, {"scope both required and conditional", func(d *typedDefinition[compilerArgs, compilerData]) { - auth := d.Metadata.Authorization.Identities[IdentityUser] + auth := d.Metadata.Authorization.Identities[typedIdentityUser] auth.ConditionalScopes[0].Scopes = []string{"fixture:write"} - d.Metadata.Authorization.Identities[IdentityUser] = auth + d.Metadata.Authorization.Identities[typedIdentityUser] = auth }, "already always required"}, {"invalid conditional requirement", func(d *typedDefinition[compilerArgs, compilerData]) { - auth := d.Metadata.Authorization.Identities[IdentityUser] - auth.ConditionalScopes[0].Requirement = ScopeRequirement("sometimes") - d.Metadata.Authorization.Identities[IdentityUser] = auth + 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[IdentityUser] + auth := d.Metadata.Authorization.Identities[typedIdentityUser] auth.ConditionalScopes[0].When = "" - d.Metadata.Authorization.Identities[IdentityUser] = auth + 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, InputField{Name: "payload", CLI: CLIInput{Hidden: true}}) + 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"}, - {"invalid partial path", func(d *typedDefinition[compilerArgs, compilerData]) { - d.Output.Outcomes.PartialFailure.FailedItems.ItemsPath = "/missing" - }, "field \"missing\" does not exist"}, - {"invalid pointer escaping", func(d *typedDefinition[compilerArgs, compilerData]) { - d.Output.Outcomes.PartialFailure.FailedItems.ItemsPath = "/items/~2" - }, "invalid RFC 6901 escaping"}, - {"all-items state conflict", func(d *typedDefinition[compilerArgs, compilerData]) { - d.Output.Outcomes.PartialFailure.FailedItems.AllItems = true - }, "AllItems conflicts"}, - {"missing failure discriminator", func(d *typedDefinition[compilerArgs, compilerData]) { - d.Output.Outcomes.PartialFailure.FailedItems.StatePath = "" - d.Output.Outcomes.PartialFailure.FailedItems.FailedValues = nil - }, "requires AllItems"}, - {"failure discriminator outside state enum", func(d *typedDefinition[compilerArgs, compilerData]) { - d.Output.Outcomes.PartialFailure.FailedItems.FailedValues = []JSONValue{"unknown"} - }, "must be one of: ok, failed"}, - {"artifact path field required", func(d *typedDefinition[compilerArgs, compilerData]) { - d.Output.Artifacts = []ArtifactDefinition{{Name: "items", ItemsPath: "/items"}} - }, "PathField 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 = OutputGeneric + 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 = OutputMode("yaml") + d.Output.Mode = typedOutputMode("yaml") }, "Output.Mode \"yaml\" is invalid"}, } for _, tt := range tests { @@ -399,31 +337,32 @@ func TestCompileInputAcceptsExplicitShapeForCustomJSONType(t *testing.T) { type data struct { OK bool `json:"ok" schema:"required" doc:"success state"` } - shape := ObjectShape{Fields: []ValueField{{Name: "value", Description: "custom value", Required: true, Shape: StringShape{}}}} + shape := command.ObjectShape{Fields: []command.ValueField{{Name: "value", Description: "custom value", Required: true, Shape: command.StringShape{}}}} definition := typedDefinition[args, data]{ - Metadata: CommandMetadata{Service: "fixture", Command: "+custom-json", Description: "custom JSON input", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, - Input: InputDefinition{Fields: []InputField{{Name: "payload", Shape: shape}}}, - Hooks: typedHooks[args, data]{Execute: func(context.Context, CommandContext, *args) (Result[data], error) { - return Success(data{OK: true}), nil + 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) - bound, err := bindTypedMap(shortcut.typed, map[string]any{"payload": map[string]any{"value": "x"}}) + field := shortcut.typed.fields[shortcut.typed.fieldByName["payload"]] + value, err := decodeCompiledValue(`{"value":"x"}`, field) if err != nil { t.Fatal(err) } - if got := bound.value.(*args).Payload.Value; got != "x" { + if got := value.(compilerCustomJSON).Value; got != "x" { t.Fatalf("payload value = %q", got) } } func TestCompileDataAcceptsCompleteExplicitShapeForDynamicData(t *testing.T) { - shape := ObjectShape{AdditionalProperties: true, AdditionalPropertiesShape: StringShape{}} - compiled, err := compileData(reflect.TypeFor[map[string]any](), DataDefinition{Shape: shape}) + 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.(ObjectShape) + object, ok := compiled.(typedObjectShape) if !ok || !object.AdditionalProperties || object.AdditionalPropertiesShape == nil { t.Fatalf("compiled shape = %#v", compiled) } @@ -437,7 +376,7 @@ func TestCompileDataAcceptsCompleteExplicitShapeForDynamicData(t *testing.T) { } func TestCompileDataAcceptsAnyForLegacyJSONPassthrough(t *testing.T) { - compiled, err := compileData(reflect.TypeFor[any](), DataDefinition{}) + compiled, err := compileData(reflect.TypeFor[any](), typedDataDefinition{}) if err != nil { t.Fatal(err) } @@ -452,7 +391,7 @@ func TestCompileDataAcceptsAnyForLegacyJSONPassthrough(t *testing.T) { t.Fatalf("value %#v rejected: %v", value, err) } } - if _, err := compileData(reflect.TypeFor[any](), DataDefinition{Overrides: []DataField{{Path: "/value"}}}); err == nil || !strings.Contains(err.Error(), "Overrides require struct Data") { + 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) } } @@ -461,7 +400,7 @@ func TestCompileDataOverrideRejectsInvalidJSONPointerEscaping(t *testing.T) { type data struct { Value string `json:"value" schema:"required" doc:"value"` } - _, err := compileData(reflectType[data](), DataDefinition{Overrides: []DataField{{Path: "/value~2", Description: "override"}}}) + _, 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) } @@ -474,7 +413,7 @@ func TestCompileDataOverrideMutatesNestedShape(t *testing.T) { type data struct { Nested nested `json:"nested" schema:"required" doc:"nested result"` } - shape, err := compileData(reflectType[data](), DataDefinition{Overrides: []DataField{{Path: "/nested/value", Description: "overridden value"}}}) + shape, err := compileData(reflectType[data](), typedDataDefinition{Overrides: []typedDataField{{Path: "/nested/value", Description: "overridden value"}}}) if err != nil { t.Fatalf("compileData() error = %v", err) } @@ -483,8 +422,8 @@ func TestCompileDataOverrideMutatesNestedShape(t *testing.T) { t.Fatal(err) } _ = nestedShape - root := shape.(ObjectShape) - child := root.Fields[0].Shape.(ObjectShape) + root := shape.(typedObjectShape) + child := root.Fields[0].Shape.(typedObjectShape) if got := child.Fields[0].Description; got != "overridden value" { t.Fatalf("nested description = %q", got) } diff --git a/shortcuts/common/typed_contract.go b/shortcuts/common/typed_contract.go index b2ea347015..0da2019c75 100644 --- a/shortcuts/common/typed_contract.go +++ b/shortcuts/common/typed_contract.go @@ -10,14 +10,14 @@ import ( ) type compiledCommand struct { - metadata CommandMetadata + metadata typedCommandMetadata argsType reflect.Type dataType reflect.Type fields []compiledInputField fieldByName map[string]int relations []compiledRelation - dataShape ValueShape - output OutputDefinition + dataShape typedValueShape + output typedOutputDefinition contract typedSchemaContract hooks compiledHooks pageOutput bool @@ -33,30 +33,30 @@ type compiledInputField struct { required bool nullable *bool description string - shape ValueShape + shape typedValueShape shapeExplicit bool - defaultValue InputDefault - cli CLIInput + defaultValue typedInputDefault + cli typedCLIInput } type compiledRelation struct { - kind RelationKind + kind typedRelationKind fields []int - presence PresenceMode - stage RelationStage + presence typedPresenceMode + stage typedRelationStage } type compiledResult struct { data any - outcome OutcomeKind - meta *ResultMeta + outcome typedOutcomeKind + meta *typedResultMeta } type compiledHooks struct { newArgs func() any - normalize func(context.Context, CommandContext, any) error - validate func(context.Context, CommandContext, any) error - dryRun func(context.Context, CommandContext, any) (*DryRunAPI, error) - execute func(context.Context, CommandContext, any) (compiledResult, error) + 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 index 67b87d2946..b9bf3e59f7 100644 --- a/shortcuts/common/typed_definition.go +++ b/shortcuts/common/typed_definition.go @@ -4,200 +4,72 @@ package common import ( - "context" - "io" - "time" - - "github.com/larksuite/cli/extension/fileio" - "github.com/larksuite/cli/internal/client" - "github.com/larksuite/cli/internal/core" -) - -// JSONValue is a value representable by JSON encoding. -type JSONValue = any - -// Definition is the single source of truth for a Typed Shortcut. -// See TYPED_SHORTCUTS.md for the framework contract and migration guide. -type typedDefinition[Args any, Data any] struct { - Metadata CommandMetadata - Input InputDefinition - Output OutputDefinition - Hooks typedHooks[Args, Data] -} - -type CommandMetadata struct { - Service string - Command string - Description string - Risk Risk - Hidden bool - Tips []string - Authorization AuthorizationDefinition -} - -type Identity string -type Risk string - -const ( - IdentityUser Identity = "user" - IdentityBot Identity = "bot" - - RiskRead Risk = "read" - RiskWrite Risk = "write" - RiskHighRiskWrite Risk = "high-risk-write" + "github.com/larksuite/cli/extension/command" + "github.com/larksuite/cli/internal/commandbridge" ) -type AuthorizationDefinition struct { - Identities map[Identity]IdentityAuthorization - IdentityOrder []Identity // optional CLI compatibility order; must contain each declared identity exactly once -} - -type IdentityAuthorization struct { - RequiredScopes []string `json:"required_scopes"` - ConditionalScopes []ConditionalScope `json:"conditional_scopes"` -} - -type ConditionalScope struct { - Scopes []string `json:"scopes"` - When string `json:"when,omitempty"` - Params []string `json:"params,omitempty"` - Requirement ScopeRequirement `json:"requirement"` -} - -type ScopeRequirement string - -const ( - ScopeRequired ScopeRequirement = "required" - ScopeBestEffort ScopeRequirement = "best_effort" -) - -type InputDefinition struct { - Fields []InputField - Relations []Relation -} - -type InputField struct { - Name string - Description string - Shape ValueShape - Default InputDefault - CLI CLIInput -} - -type InputDefault struct { - Set bool - Value JSONValue -} - -type CLIInput struct { - Aliases []FlagAlias - ValueSources []ValueSource - Encoding CLIEncoding - Hidden bool // compatibility-only primary flags omitted from default Help - Deprecated string // optional Cobra deprecation message for a primary flag -} - -type FlagAlias struct { - Name string - Mode FlagAliasMode - Conflict AliasConflictPolicy - Hidden bool - Deprecated bool -} - -type FlagAliasMode string -type AliasConflictPolicy string +// 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 ( - AliasNormalize FlagAliasMode = "normalize" - AliasIndependent FlagAliasMode = "independent" + typedIdentityUser = command.IdentityUser + typedIdentityBot = command.IdentityBot - AliasCanonicalWins AliasConflictPolicy = "canonical_wins" - AliasErrorIfBoth AliasConflictPolicy = "error_if_both" - AliasTrimmedEqualOrError AliasConflictPolicy = "trimmed_equal_or_error" -) + typedRiskRead = command.RiskRead + typedRiskWrite = command.RiskWrite + typedRiskHighRiskWrite = command.RiskHighRiskWrite -type ValueSource string + typedScopeRequired = command.ScopeRequired + typedScopeBestEffort = command.ScopeBestEffort -const ( - SourceFlag ValueSource = "flag" - SourceFile ValueSource = "file" - SourceStdin ValueSource = "stdin" -) + typedAliasNormalize = command.AliasNormalize + typedAliasIndependent = command.AliasIndependent -type CLIEncoding string + typedAliasCanonicalWins = command.AliasCanonicalWins + typedAliasErrorIfBoth = command.AliasErrorIfBoth + typedAliasTrimmedEqualOrError = command.AliasTrimmedEqualOrError -const ( - EncodingRepeated CLIEncoding = "repeated" - EncodingCommaOrRepeated CLIEncoding = "comma_or_repeated" - EncodingJSON CLIEncoding = "json" -) + typedSourceFlag = command.SourceFlag + typedSourceFile = command.SourceFile + typedSourceStdin = command.SourceStdin -// Provided preserves whether the caller explicitly supplied a value. -type Provided[T any] struct { - Value T - Set bool -} + typedEncodingRepeated = command.EncodingRepeated + typedEncodingCommaOrRepeated = command.EncodingCommaOrRepeated + typedEncodingJSON = command.EncodingJSON -type Relation struct { - Kind RelationKind `json:"kind"` - Params []string `json:"params"` - Presence PresenceMode `json:"presence"` - Stage RelationStage `json:"stage"` -} + typedRelationExactlyOne = command.RelationExactlyOne + typedRelationAtLeastOne = command.RelationAtLeastOne + typedRelationCoOccur = command.RelationCoOccur + typedRelationRequires = command.RelationRequires + typedRelationConflicts = command.RelationConflicts -type RelationKind string -type PresenceMode string -type RelationStage string + typedPresenceExplicit = command.PresenceExplicit + typedPresenceNonZero = command.PresenceNonZero -const ( - RelationExactlyOne RelationKind = "exactly_one" - RelationAtLeastOne RelationKind = "at_least_one" - RelationCoOccur RelationKind = "co_occur" - RelationRequires RelationKind = "requires" - RelationConflicts RelationKind = "conflicts" - - PresenceExplicit PresenceMode = "explicit" - PresenceNonZero PresenceMode = "non_zero" - - StageSourcePreRun RelationStage = "source_pre_run" - StageAfterPrepare RelationStage = "after_prepare" + typedStageSourcePreRun = command.StageSourcePreRun + typedStageAfterPrepare = command.StageAfterPrepare ) - -type typedHooks[Args any, Data any] struct { - Normalize func(context.Context, CommandContext, *Args) error - Validate func(context.Context, CommandContext, *Args) error - DryRun func(context.Context, CommandContext, *Args) *DryRunAPI - Execute func(context.Context, CommandContext, *Args) (Result[Data], error) - Renderers map[string]typedRenderer[Data] -} - -type typedRenderer[Data any] func(io.Writer, Data) error - -// CommandContext exposes only runtime capabilities available to Typed hooks. -type CommandContext interface { - Identity() 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() (PaginationOptions, error) - - // RequireConditionalScopes checks scopes that the Definition declares as - // path-dependent for the selected identity. Domain code calls it only after - // it has determined that the path requiring those scopes will execute. - RequireConditionalScopes(scopes ...string) error -} - -// PaginationOptions reports the standard pagination flags for one invocation. -type PaginationOptions struct { - All bool - MaxPages int - Delay time.Duration -} diff --git a/shortcuts/common/typed_external.go b/shortcuts/common/typed_external.go index 3df748c35a..7d259a20d8 100644 --- a/shortcuts/common/typed_external.go +++ b/shortcuts/common/typed_external.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -//nolint:forbidigo // External definition diagnostics are intermediate build errors wrapped by the command-set startup guard. +//nolint:forbidigo // Definition diagnostics are build-time errors wrapped by the command-set startup guard. package common import ( @@ -9,38 +9,15 @@ import ( "fmt" "io" "reflect" -) - -// ErasedDefinition is the internal host form used to compile an external typed command. -type ErasedDefinition struct { - Metadata CommandMetadata - Input InputDefinition - Output OutputDefinition - ArgsType reflect.Type - DataType reflect.Type - Hooks ErasedHooks - PageOutput bool -} -// ErasedHooks adapts public generic hooks without exposing RuntimeContext. -type ErasedHooks struct { - NewArgs func() any - Normalize func(context.Context, CommandContext, any) error - Validate func(context.Context, CommandContext, any) error - DryRun func(context.Context, CommandContext, any) (*DryRunAPI, error) - Execute func(context.Context, CommandContext, any) (ErasedResult, error) - Renderers map[string]func(io.Writer, any) error -} - -// ErasedResult is the internal non-generic result used by the host adapter. -type ErasedResult struct { - Data any - Outcome OutcomeKind - Meta *ResultMeta -} + "github.com/larksuite/cli/internal/commandbridge" +) -// CompileErasedDefinition compiles one public command declaration without panic. -func CompileErasedDefinition(definition ErasedDefinition) (Shortcut, error) { +// 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") } @@ -65,8 +42,8 @@ func CompileErasedDefinition(definition ErasedDefinition) (Shortcut, error) { output, definition.ArgsType, definition.DataType, - adaptErasedHooks(definition.Hooks), - erasedRendererMarkers(definition.Hooks.Renderers), + adaptBridgeHooks(definition.Hooks), + bridgeRendererMarkers(definition.Hooks.Renderers), definition.PageOutput, ) if err != nil { @@ -79,7 +56,7 @@ func CompileErasedDefinition(definition ErasedDefinition) (Shortcut, error) { if definition.PageOutput { shortcut.Flags = append(shortcut.Flags, PageAllFlags()...) } - if err := validateTypedFlagMountPlan(compiled, shortcut.PrintFlagSchema != nil, Risk(shortcut.Risk)); err != nil { + if err := validateTypedFlagMountPlan(compiled, shortcut.PrintFlagSchema != nil, typedRisk(shortcut.Risk)); err != nil { return Shortcut{}, err } return shortcut, nil @@ -95,27 +72,47 @@ func probeNewArgs(newArgs func() any) (result any, err error) { return newArgs(), nil } -func adaptErasedHooks(hooks ErasedHooks) compiledHooks { +func adaptBridgeHooks(hooks commandbridge.Hooks) compiledHooks { adapted := compiledHooks{ newArgs: hooks.NewArgs, normalize: hooks.Normalize, validate: hooks.Validate, - dryRun: hooks.DryRun, 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, command CommandContext, args any) (compiledResult, error) { - result, err := hooks.Execute(ctx, command, args) - return compiledResult{data: result.Data, outcome: result.Outcome, meta: result.Meta}, err + 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 erasedRendererMarkers(renderers map[string]func(io.Writer, any) error) map[string]RendererMarker { - markers := make(map[string]RendererMarker, len(renderers)) +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} + markers[name] = rendererMarker{isNil: renderer == nil} } return markers } diff --git a/shortcuts/common/typed_external_pagination.go b/shortcuts/common/typed_external_pagination.go index ce6bb6cdcc..93a5f88dce 100644 --- a/shortcuts/common/typed_external_pagination.go +++ b/shortcuts/common/typed_external_pagination.go @@ -8,11 +8,12 @@ import ( "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" ) -// CollectCommandPages is PaginateInto for an externally declared command. Such +// 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 @@ -24,7 +25,7 @@ import ( // 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 CollectCommandPages[T any](ctx context.Context, command CommandContext, request PageRequest, all bool, dst PageAccumulator[T]) (*output.PaginationMeta, error) { +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 { @@ -34,7 +35,7 @@ func CollectCommandPages[T any](ctx context.Context, command CommandContext, req policy: policy, request: request, fetch: func(ctx context.Context, page PageRequest) (map[string]interface{}, error) { - return CallTypedAPI(ctx, command, page.Method, page.Path, page.Params, page.Body) + 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) @@ -46,7 +47,7 @@ func CollectCommandPages[T any](ctx context.Context, command CommandContext, req return meta, walkErr } -func commandPagePolicy(command CommandContext, all bool) (paginationPolicy, error) { +func commandPagePolicy(command typedRuntimeContext, all bool) (paginationPolicy, error) { if all { return paginationPolicy{maxPages: internalpagination.CollectAllHardPageBound}, nil } diff --git a/shortcuts/common/typed_external_pagination_test.go b/shortcuts/common/typed_external_pagination_test.go index 34f2424747..33ef02007b 100644 --- a/shortcuts/common/typed_external_pagination_test.go +++ b/shortcuts/common/typed_external_pagination_test.go @@ -13,7 +13,7 @@ import ( // 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(CommandContext(nil), true) + policy, err := commandPagePolicy(typedRuntimeContext(nil), true) if err != nil { t.Fatal(err) } diff --git a/shortcuts/common/typed_flag_collisions.go b/shortcuts/common/typed_flag_collisions.go index 60f293ef30..2ec316cede 100644 --- a/shortcuts/common/typed_flag_collisions.go +++ b/shortcuts/common/typed_flag_collisions.go @@ -10,7 +10,7 @@ import "fmt" // 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 Risk) error { +func validateTypedFlagMountPlan(command *compiledCommand, hasFlagSchema bool, mountedRisk typedRisk) error { flags := legacyFlagsFromCompiled(command.fields) view := Shortcut{Flags: flags} @@ -21,7 +21,7 @@ func validateTypedFlagMountPlan(command *compiledCommand, hasFlagSchema bool, mo "jq": "framework output filtering", "profile": "inherited profile selection", } - if mountedRisk == RiskHighRiskWrite { + if mountedRisk == typedRiskHighRiskWrite { primaryConflicts["yes"] = "framework high-risk confirmation" } if hasFlagSchema { @@ -43,7 +43,7 @@ func validateTypedFlagMountPlan(command *compiledCommand, hasFlagSchema bool, mo if !shortcutDeclaresJSONFlag(&view) && shortcutFormatSupportsJSON(&view) { aliasConflicts["json"] = "framework JSON output shorthand" } - if mountedRisk == RiskHighRiskWrite { + if mountedRisk == typedRiskHighRiskWrite { aliasConflicts["yes"] = "framework high-risk confirmation" } if hasFlagSchema { @@ -58,7 +58,7 @@ func validateTypedFlagMountPlan(command *compiledCommand, hasFlagSchema bool, mo for _, alias := range field.cli.Aliases { conflicts := primaryConflicts kind := "independent alias" - if alias.Mode == AliasNormalize { + if alias.Mode == typedAliasNormalize { conflicts = aliasConflicts kind = "normalize alias" } diff --git a/shortcuts/common/typed_flag_collisions_test.go b/shortcuts/common/typed_flag_collisions_test.go index b45b57d9d1..83b4cf0429 100644 --- a/shortcuts/common/typed_flag_collisions_test.go +++ b/shortcuts/common/typed_flag_collisions_test.go @@ -66,15 +66,15 @@ type collisionPrintOnlyArgs struct { PrintSchema bool `flag:"print-schema" schema:"optional" doc:"business schema switch"` } -func collisionDefinition[Args any](risk Risk, input InputDefinition) typedDefinition[Args, collisionData] { +func collisionDefinition[Args any](risk typedRisk, input typedInputDefinition) typedDefinition[Args, collisionData] { return typedDefinition[Args, collisionData]{ - Metadata: CommandMetadata{ + Metadata: typedCommandMetadata{ Service: "fixture", Command: "+collision", Description: "collision fixture", Risk: risk, - Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}, + Authorization: typedAuthorizationDefinition{Identities: map[typedIdentity]typedIdentityAuthorization{typedIdentityUser: {}}}, }, Input: input, - Hooks: typedHooks[Args, collisionData]{Execute: func(context.Context, CommandContext, *Args) (Result[collisionData], error) { - return Success(collisionData{OK: true}), nil + Hooks: typedHooks[Args, collisionData]{Execute: func(context.Context, typedRuntimeContext, *Args) (typedResult[collisionData], error) { + return typedSuccess(collisionData{OK: true}), nil }}, } } @@ -105,21 +105,29 @@ func TestDefineRejectsActiveFrameworkFlagCollisions(t *testing.T) { run func() want string }{ - {name: "dry-run", run: func() { _ = defineTypedShortcut(collisionDefinition[collisionDryRunArgs](RiskRead, InputDefinition{})) }, want: "framework dry-run execution"}, - {name: "as", run: func() { _ = defineTypedShortcut(collisionDefinition[collisionAsArgs](RiskRead, InputDefinition{})) }, want: "framework identity selection"}, - {name: "jq", run: func() { _ = defineTypedShortcut(collisionDefinition[collisionJQArgs](RiskRead, InputDefinition{})) }, want: "framework output filtering"}, + {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](RiskRead, InputDefinition{})) + _ = defineTypedShortcut(collisionDefinition[collisionProfileArgs](typedRiskRead, typedInputDefinition{})) }, want: "inherited profile selection"}, - {name: "help", run: func() { _ = defineTypedShortcut(collisionDefinition[collisionHelpArgs](RiskRead, InputDefinition{})) }, want: "Cobra help"}, + {name: "help", run: func() { + _ = defineTypedShortcut(collisionDefinition[collisionHelpArgs](typedRiskRead, typedInputDefinition{})) + }, want: "Cobra help"}, {name: "high-risk yes", run: func() { - _ = defineTypedShortcut(collisionDefinition[collisionYesArgs](RiskHighRiskWrite, InputDefinition{})) + _ = defineTypedShortcut(collisionDefinition[collisionYesArgs](typedRiskHighRiskWrite, typedInputDefinition{})) }, want: "framework high-risk confirmation"}, {name: "print-schema when introspection is active", run: func() { - _ = defineTypedShortcut(collisionDefinition[collisionPrintSchemaArgs](RiskRead, InputDefinition{})) + _ = defineTypedShortcut(collisionDefinition[collisionPrintSchemaArgs](typedRiskRead, typedInputDefinition{})) }, want: "framework complex-input introspection"}, {name: "flag-name when introspection is active", run: func() { - _ = defineTypedShortcut(collisionDefinition[collisionFlagNameArgs](RiskRead, InputDefinition{})) + _ = defineTypedShortcut(collisionDefinition[collisionFlagNameArgs](typedRiskRead, typedInputDefinition{})) }, want: "framework complex-input introspection"}, } for _, test := range tests { @@ -130,7 +138,7 @@ func TestDefineRejectsActiveFrameworkFlagCollisions(t *testing.T) { } func TestDefinePreservesExistingBusinessFlagMeanings(t *testing.T) { - shortcut := defineTypedShortcut(collisionDefinition[collisionAllowedArgs](RiskWrite, InputDefinition{})) + shortcut := defineTypedShortcut(collisionDefinition[collisionAllowedArgs](typedRiskWrite, typedInputDefinition{})) for _, name := range []string{"json", "format", "yes", "version"} { found := false for _, flag := range shortcut.Flags { @@ -167,14 +175,14 @@ func TestDefinePreservesExistingBusinessFlagMeanings(t *testing.T) { } func TestDefineRejectsNormalizeAliasThatWouldCollideAfterMount(t *testing.T) { - definition := collisionDefinition[collisionAliasArgs](RiskRead, InputDefinition{Fields: []InputField{{ - Name: "value", CLI: CLIInput{Aliases: []FlagAlias{{Name: "format", Mode: AliasNormalize}}}, + 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](RiskRead, InputDefinition{})) + 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"} diff --git a/shortcuts/common/typed_flag_schema.go b/shortcuts/common/typed_flag_schema.go index 4fd0d5fd69..6c5dad4ac9 100644 --- a/shortcuts/common/typed_flag_schema.go +++ b/shortcuts/common/typed_flag_schema.go @@ -16,7 +16,7 @@ import ( 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 != EncodingJSON || !isCompositeValueShape(field.shape) { + if field.cli.Hidden || field.cli.Encoding != typedEncodingJSON || !isCompositeValueShape(field.shape) { continue } node := schemaNodeFromShape(field.shape) @@ -55,11 +55,11 @@ func typedFlagSchemaPrinter(command *compiledCommand) func(string) ([]byte, erro } } -func isCompositeValueShape(shape ValueShape) bool { +func isCompositeValueShape(shape typedValueShape) bool { switch value := shape.(type) { - case ObjectShape, ArrayShape: + case typedObjectShape, typedArrayShape: return true - case OneOfShape: + case typedOneOfShape: for _, variant := range value.Variants { if isCompositeValueShape(variant) { return true diff --git a/shortcuts/common/typed_flag_schema_test.go b/shortcuts/common/typed_flag_schema_test.go index 6f2165d5da..29aadbc16d 100644 --- a/shortcuts/common/typed_flag_schema_test.go +++ b/shortcuts/common/typed_flag_schema_test.go @@ -32,21 +32,21 @@ func runTypedFlagSchema(t *testing.T, shortcut Shortcut, args ...string) (string func TestTypedFlagSchemaListsAndPrintsCompositeInputsBeforeExecution(t *testing.T) { definition := validCompilerDefinition() - definition.Input.Relations = append(definition.Input.Relations, Relation{ - Kind: RelationExactlyOne, Params: []string{"token", "labels"}, Presence: PresenceExplicit, Stage: StageSourcePreRun, + definition.Input.Relations = append(definition.Input.Relations, typedRelation{ + Kind: typedRelationExactlyOne, Params: []string{"token", "labels"}, Presence: typedPresenceExplicit, Stage: typedStageSourcePreRun, }) called := false - definition.Hooks.Normalize = func(context.Context, CommandContext, *compilerArgs) error { + definition.Hooks.Normalize = func(context.Context, typedRuntimeContext, *compilerArgs) error { called = true return nil } - definition.Hooks.Validate = func(context.Context, CommandContext, *compilerArgs) error { + definition.Hooks.Validate = func(context.Context, typedRuntimeContext, *compilerArgs) error { called = true return nil } - definition.Hooks.Execute = func(context.Context, CommandContext, *compilerArgs) (Result[compilerData], error) { + definition.Hooks.Execute = func(context.Context, typedRuntimeContext, *compilerArgs) (typedResult[compilerData], error) { called = true - return Success(compilerData{}), nil + return typedSuccess(compilerData{}), nil } shortcut := defineTypedShortcut(definition) @@ -89,14 +89,14 @@ func TestTypedFlagSchemaListsAndPrintsCompositeInputsBeforeExecution(t *testing. func TestIsCompositeValueShape(t *testing.T) { for _, test := range []struct { name string - shape ValueShape + shape typedValueShape want bool }{ - {name: "object", shape: ObjectShape{}, want: true}, - {name: "array", shape: ArrayShape{Items: StringShape{}}, want: true}, - {name: "nullable object", shape: OneOfShape{Variants: []ValueShape{NullShape{}, ObjectShape{}}}, want: true}, - {name: "scalar one-of", shape: OneOfShape{Variants: []ValueShape{StringShape{}, NullShape{}}}, want: false}, - {name: "string", shape: StringShape{}, want: false}, + {name: "object", shape: typedObjectShape{}, want: true}, + {name: "array", shape: typedArrayShape{Items: typedStringShape{}}, want: true}, + {name: "nullable object", shape: typedOneOfShape{Variants: []typedValueShape{typedNullShape{}, typedObjectShape{}}}, want: true}, + {name: "scalar one-of", shape: typedOneOfShape{Variants: []typedValueShape{typedStringShape{}, typedNullShape{}}}, want: false}, + {name: "string", shape: typedStringShape{}, want: false}, } { t.Run(test.name, func(t *testing.T) { if got := isCompositeValueShape(test.shape); got != test.want { @@ -126,9 +126,9 @@ func TestTypedFlagSchemaNotRegisteredForScalarInputs(t *testing.T) { OK bool `json:"ok" schema:"required" doc:"success state"` } shortcut := defineTypedShortcut(typedDefinition[args, data]{ - Metadata: CommandMetadata{Service: "fixture", Command: "+scalar", Description: "scalar fixture", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, - Hooks: typedHooks[args, data]{Execute: func(context.Context, CommandContext, *args) (Result[data], error) { - return Success(data{OK: true}), nil + Metadata: typedCommandMetadata{Service: "fixture", Command: "+scalar", Description: "scalar fixture", Risk: typedRiskRead, Authorization: typedAuthorizationDefinition{Identities: map[typedIdentity]typedIdentityAuthorization{typedIdentityUser: {}}}}, + Hooks: typedHooks[args, data]{Execute: func(context.Context, typedRuntimeContext, *args) (typedResult[data], error) { + return typedSuccess(data{OK: true}), nil }}, }) if shortcut.PrintFlagSchema != nil { diff --git a/shortcuts/common/typed_help.go b/shortcuts/common/typed_help.go index ba2ed764b3..98cbec1c7b 100644 --- a/shortcuts/common/typed_help.go +++ b/shortcuts/common/typed_help.go @@ -34,7 +34,7 @@ func typedHelpFacts(command *compiledCommand) typedCommandHelpFacts { } for _, source := range field.cli.ValueSources { fact.Sources = append(fact.Sources, string(source)) - if source == SourceStdin { + if source == typedSourceStdin { stdinParameters++ } } @@ -59,27 +59,18 @@ func typedHelpFacts(command *compiledCommand) typedCommandHelpFacts { params = append(params, command.fields[index].name) } if len(visibleFields) != len(relation.fields) { - if len(visibleFields) == 1 && (relation.kind == RelationExactlyOne || relation.kind == RelationAtLeastOne) { + if len(visibleFields) == 1 && (relation.kind == typedRelationExactlyOne || relation.kind == typedRelationAtLeastOne) { parameter := &facts.Parameters[parameterIndex[visibleFields[0]]] parameter.Required = true - parameter.Explicit = relation.presence == PresenceExplicit + parameter.Explicit = relation.presence == typedPresenceExplicit } continue } facts.Constraints = append(facts.Constraints, typedConstraintHelpFact{Kind: string(relation.kind), Params: params, Presence: string(relation.presence)}) } - if command.output.Artifacts != nil { - facts.Output = append(facts.Output, typedOutputHelpFact{Text: "writes local artifacts described in the JSON result"}) - } - if command.output.Outcomes.PartialFailure != nil { - facts.Output = append(facts.Output, typedOutputHelpFact{Text: "may return a partial-failure result with per-item failures"}) - } - if command.output.Meta.Count { - facts.Output = append(facts.Output, typedOutputHelpFact{Text: "JSON and jq envelopes may include meta.count"}) - } if command.output.Meta.Pagination { text := "pagination metadata reports completion, pages, items, and a resume token when incomplete" - if command.output.Mode != OutputFixedJSON { + if command.output.Mode != typedOutputFixedJSON { summaryFormats := "table" if command.hooks.renderers["pretty"] != nil { summaryFormats = "pretty/table" @@ -90,7 +81,7 @@ func typedHelpFacts(command *compiledCommand) typedCommandHelpFacts { } identityOrder := command.metadata.Authorization.IdentityOrder if len(identityOrder) == 0 { - identityOrder = []Identity{IdentityUser, IdentityBot} + identityOrder = []typedIdentity{typedIdentityUser, typedIdentityBot} } for _, identity := range identityOrder { authorization, ok := command.metadata.Authorization.Identities[identity] @@ -109,21 +100,21 @@ func typedHelpFacts(command *compiledCommand) typedCommandHelpFacts { return facts } -func helpType(shape ValueShape, encoding CLIEncoding) string { - if encoding == EncodingJSON { +func helpType(shape typedValueShape, encoding typedCLIEncoding) string { + if encoding == typedEncodingJSON { return "json" } shape = nonNullableShape(shape) switch value := shape.(type) { - case BooleanShape: + case typedBooleanShape: return "boolean" - case IntegerShape: + case typedIntegerShape: return "integer" - case NumberShape: + case typedNumberShape: return "number" - case StringShape: + case typedStringShape: return "string" - case ArrayShape: + case typedArrayShape: item := helpType(value.Items, "") if item == "boolean" { item = "bool" @@ -132,9 +123,9 @@ func helpType(shape ValueShape, encoding CLIEncoding) string { return "array" } return item + "[]" - case ObjectShape, OneOfShape: + case typedObjectShape, typedOneOfShape: return "json" - case ConstShape: + case typedConstShape: switch value.Value.(type) { case bool: return "boolean" @@ -143,42 +134,42 @@ func helpType(shape ValueShape, encoding CLIEncoding) string { default: return "value" } - case NullShape: + case typedNullShape: return "null" default: return "value" } } -func nonNullableShape(shape ValueShape) ValueShape { - if oneOf, ok := shape.(OneOfShape); ok && len(oneOf.Variants) == 2 { - if _, null := oneOf.Variants[0].(NullShape); null { +func nonNullableShape(shape typedValueShape) typedValueShape { + if oneOf, ok := shape.(typedOneOfShape); ok && len(oneOf.Variants) == 2 { + if _, null := oneOf.Variants[0].(typedNullShape); null { return oneOf.Variants[1] } - if _, null := oneOf.Variants[1].(NullShape); null { + if _, null := oneOf.Variants[1].(typedNullShape); null { return oneOf.Variants[0] } } return shape } -func applyShapeToHelpFact(fact *typedParameterHelpFact, shape ValueShape) { +func applyShapeToHelpFact(fact *typedParameterHelpFact, shape typedValueShape) { shape = nonNullableShape(shape) switch value := shape.(type) { - case StringShape: + case typedStringShape: fact.Enum = append([]string{}, value.Enum...) fact.Format, fact.MinLength, fact.MaxLength = value.Format, cloneInt(value.MinLength), cloneInt(value.MaxLength) - case IntegerShape: + case typedIntegerShape: for _, item := range value.Enum { fact.Enum = append(fact.Enum, fmt.Sprint(item)) } fact.Minimum, fact.Maximum = int64AsFloat(value.Minimum), int64AsFloat(value.Maximum) - case NumberShape: + case typedNumberShape: for _, item := range value.Enum { fact.Enum = append(fact.Enum, fmt.Sprintf("%g", item)) } fact.Minimum, fact.Maximum = cloneFloat(value.Minimum), cloneFloat(value.Maximum) - case ArrayShape: + case typedArrayShape: fact.MinItems, fact.MaxItems = cloneInt(value.MinItems), cloneInt(value.MaxItems) } } diff --git a/shortcuts/common/typed_help_render.go b/shortcuts/common/typed_help_render.go index 36ddec2cb9..94839117d2 100644 --- a/shortcuts/common/typed_help_render.go +++ b/shortcuts/common/typed_help_render.go @@ -70,7 +70,7 @@ type typedConditionalScopeHelpFact struct { Scopes []string When string Params []string - Requirement ScopeRequirement + Requirement typedScopeRequirement } // typedHelpFlagRef selects a registered Cobra flag for a system section. @@ -302,7 +302,7 @@ func writeTypedAuthorizationHelp(b *strings.Builder, identities []typedAuthoriza fmt.Fprintf(b, " %s\n", scope) } } - for _, requirement := range []ScopeRequirement{ScopeRequired, ScopeBestEffort} { + for _, requirement := range []typedScopeRequirement{typedScopeRequired, typedScopeBestEffort} { var conditional []typedConditionalScopeHelpFact for _, scope := range identity.ConditionalScopes { if scope.Requirement == requirement { @@ -313,7 +313,7 @@ func writeTypedAuthorizationHelp(b *strings.Builder, identities []typedAuthoriza continue } heading := "Conditionally required:" - if requirement == ScopeBestEffort { + if requirement == typedScopeBestEffort { heading = "Optional capability:" } fmt.Fprintf(b, " %s\n", heading) @@ -427,14 +427,14 @@ func typedHelpConstraintText(fact typedConstraintHelpFact) string { } case "conflicts": text = "conflicting parameters: " + joined - case string(RelationCoOccur): + case string(typedRelationCoOccur): text = "all or none of: " + joined case "same_value": text = "must have the same value: " + joined default: text = strings.ReplaceAll(fact.Kind, "_", " ") + ": " + joined } - if fact.Presence == string(PresenceNonZero) { + if fact.Presence == string(typedPresenceNonZero) { text += " (using non-zero values)" } return text diff --git a/shortcuts/common/typed_help_render_test.go b/shortcuts/common/typed_help_render_test.go index ab8430409f..d004a80150 100644 --- a/shortcuts/common/typed_help_render_test.go +++ b/shortcuts/common/typed_help_render_test.go @@ -29,8 +29,8 @@ func TestInstallTypedGroupedUsage(t *testing.T) { }, Constraints: []typedConstraintHelpFact{{Kind: "exactly_one", Params: []string{"token", "labels"}, Presence: "provided"}}, Authorization: []typedAuthorizationHelpFact{{Identity: "user", RequiredScopes: []string{"fixture:write"}, ConditionalScopes: []typedConditionalScopeHelpFact{ - {Scopes: []string{"fixture:read"}, When: "--labels requests lookup", Params: []string{"labels"}, Requirement: ScopeRequired}, - {Scopes: []string{"fixture:enrich"}, When: "detail enrichment is available", Requirement: ScopeBestEffort}, + {Scopes: []string{"fixture:read"}, When: "--labels requests lookup", Params: []string{"labels"}, Requirement: typedScopeRequired}, + {Scopes: []string{"fixture:enrich"}, When: "detail enrichment is available", Requirement: typedScopeBestEffort}, }}}, Execution: []typedHelpFlagRef{{Name: "dry-run"}}, OutputFlags: []typedHelpFlagRef{{Name: "format"}}, @@ -74,18 +74,18 @@ func TestInstallTypedGroupedUsage(t *testing.T) { func TestConstraintTextKinds(t *testing.T) { tests := map[string]string{ - string(RelationExactlyOne): "exactly one of: --a, --b", - string(RelationAtLeastOne): "at least one of: --a, --b", - string(RelationRequires): "--a requires --b", - string(RelationConflicts): "conflicting parameters: --a, --b", - string(RelationCoOccur): "all or none of: --a, --b", + string(typedRelationExactlyOne): "exactly one of: --a, --b", + string(typedRelationAtLeastOne): "at least one of: --a, --b", + string(typedRelationRequires): "--a requires --b", + string(typedRelationConflicts): "conflicting parameters: --a, --b", + string(typedRelationCoOccur): "all or none of: --a, --b", } for kind, want := range tests { if got := typedHelpConstraintText(typedConstraintHelpFact{Kind: kind, Params: []string{"a", "b"}}); got != want { t.Errorf("%s = %q, want %q", kind, got, want) } } - if got := typedHelpConstraintText(typedConstraintHelpFact{Kind: string(RelationExactlyOne), Params: []string{"a", "b"}, Presence: string(PresenceNonZero)}); !strings.Contains(got, "using non-zero values") { + if got := typedHelpConstraintText(typedConstraintHelpFact{Kind: string(typedRelationExactlyOne), Params: []string{"a", "b"}, Presence: string(typedPresenceNonZero)}); !strings.Contains(got, "using non-zero values") { t.Errorf("nonzero = %q", got) } } diff --git a/shortcuts/common/typed_map_binder.go b/shortcuts/common/typed_map_binder.go deleted file mode 100644 index add4baff23..0000000000 --- a/shortcuts/common/typed_map_binder.go +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package common - -import ( - "fmt" - "reflect" - "strings" - - "github.com/larksuite/cli/errs" -) - -// bindTypedMap binds already-resolved values used by batch/internal callers. -// Unlike the CLI entry point it never interprets @file or stdin markers: map -// values are final content unless a caller explicitly runs source resolution. -func bindTypedMap(command *compiledCommand, values map[string]any) (*boundArgs, error) { - args := command.hooks.newArgs() - root := reflect.ValueOf(args).Elem() - provided := make([]bool, len(command.fields)) - known := make(map[string]struct{}, len(command.fields)) - for i, field := range command.fields { - known[field.name] = struct{}{} - canonical, canonicalSet := values[field.name] - value, set := canonical, canonicalSet - sourceName := field.name - sourceSet := canonicalSet - for _, alias := range field.cli.Aliases { - known[alias.Name] = struct{}{} - aliasValue, aliasSet := values[alias.Name] - if !aliasSet { - continue - } - switch alias.Mode { - case AliasNormalize: - value, set = aliasValue, true - case AliasIndependent: - switch alias.Conflict { - case AliasCanonicalWins: - if !sourceSet { - value, set = aliasValue, true - sourceName, sourceSet = alias.Name, true - } - case AliasErrorIfBoth: - if sourceSet { - return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, - "--%s cannot be used together with --%s", sourceName, alias.Name).WithParam("--" + alias.Name) - } - value, set = aliasValue, true - sourceName, sourceSet = alias.Name, true - case AliasTrimmedEqualOrError: - if sourceSet { - if strings.TrimSpace(fmt.Sprint(value)) != strings.TrimSpace(fmt.Sprint(aliasValue)) { - return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s and --%s are both set with different values", sourceName, alias.Name).WithParam("--" + alias.Name) - } - value = strings.TrimSpace(fmt.Sprint(value)) - } else { - value, set = aliasValue, true - sourceName, sourceSet = alias.Name, true - } - } - } - } - provided[i] = set - if !set && field.defaultValue.Set { - value = field.defaultValue.Value - } - if !set && !field.defaultValue.Set { - if field.required { - return nil, typedRequiredFieldValidation(field) - } - continue - } - decoded, err := decodeCompiledMapValue(value, field) - if err != nil { - return nil, typedFieldValidation(field, "%v", err).WithCause(err) - } - if err := validateCompiledValue(decoded, field); err != nil { - return nil, err - } - if err := assignCompiledField(root, field, decoded, set); err != nil { - return nil, errs.NewInternalError(errs.SubtypeUnknown, "failed to bind map value %s: %v", field.name, err).WithCause(err) - } - } - for name := range values { - if _, ok := known[name]; !ok { - return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown parameter %q", name).WithParam("--" + name) - } - } - if err := validateCompiledRelations(command, args, provided, StageSourcePreRun); err != nil { - return nil, err - } - return &boundArgs{value: args, provided: provided}, nil -} - -func decodeCompiledMapValue(value any, field compiledInputField) (any, error) { - if field.cli.Encoding == EncodingJSON { - if text, ok := value.(string); ok { - return decodeCompiledValue(text, field) - } - return convertReflectValue(value, field.valueType) - } - return convertReflectValue(value, field.valueType) -} diff --git a/shortcuts/common/typed_map_binder_test.go b/shortcuts/common/typed_map_binder_test.go deleted file mode 100644 index 7916c808ab..0000000000 --- a/shortcuts/common/typed_map_binder_test.go +++ /dev/null @@ -1,338 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package common - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "reflect" - "strings" - "sync" - "testing" - - "github.com/larksuite/cli/errs" -) - -type aliasBinderArgs struct { - Value string `flag:"value" schema:"optional" doc:"fixture value"` -} -type aliasBinderData struct { - OK bool `json:"ok" schema:"required" doc:"success state"` -} - -func aliasBinderCommand(t *testing.T, alias FlagAlias) *compiledCommand { - return aliasBinderCommandWithAliases(t, []FlagAlias{alias}) -} - -func aliasBinderCommandWithAliases(t *testing.T, aliases []FlagAlias) *compiledCommand { - t.Helper() - definition := typedDefinition[aliasBinderArgs, aliasBinderData]{ - Metadata: CommandMetadata{Service: "fixture", Command: "+alias", Description: "alias fixture", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, - Input: InputDefinition{Fields: []InputField{{Name: "value", CLI: CLIInput{Aliases: aliases}}}}, - Hooks: typedHooks[aliasBinderArgs, aliasBinderData]{Execute: func(context.Context, CommandContext, *aliasBinderArgs) (Result[aliasBinderData], error) { - return Success(aliasBinderData{OK: true}), nil - }}, - } - command, err := compileDefinition(definition) - if err != nil { - t.Fatal(err) - } - return command -} - -func TestBindTypedMapBindsFinalValuesDefaultsAndPresence(t *testing.T) { - command, err := compileDefinition(validCompilerDefinition()) - if err != nil { - t.Fatal(err) - } - bound, err := bindTypedMap(command, map[string]any{ - "token": "tok", "payload": map[string]any{"mode": "fast"}, "labels": []string{"a", "b"}, "limit": 1, - }) - if err != nil { - t.Fatal(err) - } - args := bound.value.(*compilerArgs) - if args.Token != "tok" || args.Payload.Mode != "fast" { - t.Fatalf("Args = %#v", args) - } - if args.Limit != (Provided[int]{Value: 1, Set: true}) { - t.Fatalf("Limit = %#v", args.Limit) - } - if got := args.Labels; len(got) != 2 || got[1] != "b" { - t.Fatalf("Labels = %#v", got) - } - - bound, err = bindTypedMap(command, map[string]any{"token": "tok"}) - if err != nil { - t.Fatal(err) - } - if got := bound.value.(*compilerArgs).Limit; got != (Provided[int]{Value: 20, Set: false}) { - t.Fatalf("default Limit = %#v", got) - } -} - -func TestBindTypedMapRequiredMessageUsesLegacyCLIForm(t *testing.T) { - command, err := compileDefinition(validCompilerDefinition()) - if err != nil { - t.Fatal(err) - } - _, err = bindTypedMap(command, map[string]any{}) - problem, ok := errs.ProblemOf(err) - if !ok || problem.Message != "--token is required" { - t.Fatalf("error = %v, problem = %#v", err, problem) - } -} - -func TestBindTypedMapAliasPolicies(t *testing.T) { - t.Run("normalize", func(t *testing.T) { - bound, err := bindTypedMap(aliasBinderCommand(t, FlagAlias{Name: "old", Mode: AliasNormalize}), map[string]any{"old": "alias"}) - if err != nil || bound.value.(*aliasBinderArgs).Value != "alias" { - t.Fatalf("bound = %#v, err = %v", bound, err) - } - }) - t.Run("canonical wins", func(t *testing.T) { - bound, err := bindTypedMap(aliasBinderCommand(t, FlagAlias{Name: "old", Mode: AliasIndependent, Conflict: AliasCanonicalWins}), map[string]any{"value": "canonical", "old": "alias"}) - if err != nil || bound.value.(*aliasBinderArgs).Value != "canonical" { - t.Fatalf("bound = %#v, err = %v", bound, err) - } - }) - t.Run("error if both", func(t *testing.T) { - _, err := bindTypedMap(aliasBinderCommand(t, FlagAlias{Name: "old", Mode: AliasIndependent, Conflict: AliasErrorIfBoth}), map[string]any{"value": "canonical", "old": "alias"}) - var validation *errs.ValidationError - if !errors.As(err, &validation) { - t.Fatalf("error = %#v", err) - } - }) - t.Run("trimmed equal", func(t *testing.T) { - bound, err := bindTypedMap(aliasBinderCommand(t, FlagAlias{Name: "old", Mode: AliasIndependent, Conflict: AliasTrimmedEqualOrError}), map[string]any{"value": " same ", "old": "same"}) - if err != nil || bound.value.(*aliasBinderArgs).Value != "same" { - t.Fatalf("bound = %#v, err = %v", bound, err) - } - }) -} - -func TestBindTypedMapRejectsMultipleIndependentAliases(t *testing.T) { - command := aliasBinderCommandWithAliases(t, []FlagAlias{ - {Name: "old", Mode: AliasIndependent, Conflict: AliasErrorIfBoth}, - {Name: "older", Mode: AliasIndependent, Conflict: AliasErrorIfBoth}, - }) - _, err := bindTypedMap(command, map[string]any{"old": "first", "older": "second"}) - var validation *errs.ValidationError - if !errors.As(err, &validation) || validation.Param != "--older" { - t.Fatalf("error = %#v", err) - } -} - -func TestBindTypedMapCreatesIndependentArgsConcurrently(t *testing.T) { - command, err := compileDefinition(validCompilerDefinition()) - if err != nil { - t.Fatal(err) - } - const workers = 32 - results := make([]*compilerArgs, workers) - var wg sync.WaitGroup - for i := 0; i < workers; i++ { - wg.Add(1) - go func(index int) { - defer wg.Done() - bound, bindErr := bindTypedMap(command, map[string]any{"token": fmt.Sprintf("token-%d", index)}) - if bindErr != nil { - t.Errorf("bind %d: %v", index, bindErr) - return - } - results[index] = bound.value.(*compilerArgs) - }(i) - } - wg.Wait() - seen := make(map[*compilerArgs]struct{}, workers) - for i, result := range results { - if result == nil || result.Token != fmt.Sprintf("token-%d", i) { - t.Fatalf("result[%d] = %#v", i, result) - } - if _, duplicate := seen[result]; duplicate { - t.Fatalf("Args pointer reused at %d", i) - } - seen[result] = struct{}{} - } -} - -func TestBindTypedMapPresenceNonZeroUsesProvidedValue(t *testing.T) { - type args struct { - First Provided[string] `flag:"first" schema:"optional" doc:"first value"` - Second Provided[string] `flag:"second" schema:"optional" doc:"second value"` - } - definition := typedDefinition[args, aliasBinderData]{ - Metadata: CommandMetadata{Service: "fixture", Command: "+presence", Description: "presence fixture", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, - Input: InputDefinition{Relations: []Relation{{Kind: RelationExactlyOne, Params: []string{"first", "second"}, Presence: PresenceNonZero, Stage: StageAfterPrepare}}}, - Hooks: typedHooks[args, aliasBinderData]{Execute: func(context.Context, CommandContext, *args) (Result[aliasBinderData], error) { - return Success(aliasBinderData{OK: true}), nil - }}, - } - command, err := compileDefinition(definition) - if err != nil { - t.Fatal(err) - } - bound, err := bindTypedMap(command, map[string]any{"first": "", "second": "value"}) - if err != nil { - t.Fatal(err) - } - if err := validateCompiledRelations(command, bound.value, bound.provided, StageAfterPrepare); err != nil { - t.Fatalf("explicit empty Provided value must remain non-zero absent: %v", err) - } - bound, err = bindTypedMap(command, map[string]any{"first": "", "second": ""}) - if err != nil { - t.Fatal(err) - } - if err := validateCompiledRelations(command, bound.value, bound.provided, StageAfterPrepare); err == nil { - t.Fatal("two explicit empty Provided values unexpectedly satisfied non-zero exactly-one") - } -} - -func TestBindTypedMapAcceptsStructuredRawJSONValue(t *testing.T) { - type args struct { - Payload json.RawMessage `flag:"payload" schema:"required" cli:"encoding=json" doc:"payload"` - } - shape := ObjectShape{Fields: []ValueField{{Name: "name", Description: "name", Required: true, Shape: StringShape{}}}} - definition := typedDefinition[args, aliasBinderData]{ - Metadata: CommandMetadata{Service: "fixture", Command: "+raw-json", Description: "raw JSON fixture", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, - Input: InputDefinition{Fields: []InputField{{Name: "payload", Shape: shape}}}, - Hooks: typedHooks[args, aliasBinderData]{Execute: func(context.Context, CommandContext, *args) (Result[aliasBinderData], error) { - return Success(aliasBinderData{OK: true}), nil - }}, - } - command, err := compileDefinition(definition) - if err != nil { - t.Fatal(err) - } - bound, err := bindTypedMap(command, map[string]any{"payload": map[string]any{"name": "fixture"}}) - if err != nil { - t.Fatal(err) - } - if got := string(bound.value.(*args).Payload); got != `{"name":"fixture"}` { - t.Fatalf("payload = %q", got) - } -} - -func TestBindTypedMapRejectsNullOutsideExplicitShape(t *testing.T) { - type args struct { - Payload map[string]string `flag:"payload" schema:"optional" cli:"encoding=json" doc:"payload"` - } - definition := typedDefinition[args, aliasBinderData]{ - Metadata: CommandMetadata{Service: "fixture", Command: "+null", Description: "null fixture", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, - Input: InputDefinition{Fields: []InputField{{Name: "payload", Shape: ObjectShape{AdditionalProperties: true, AdditionalPropertiesShape: StringShape{}}}}}, - Hooks: typedHooks[args, aliasBinderData]{Execute: func(context.Context, CommandContext, *args) (Result[aliasBinderData], error) { - return Success(aliasBinderData{OK: true}), nil - }}, - } - command, err := compileDefinition(definition) - if err != nil { - t.Fatal(err) - } - _, err = bindTypedMap(command, map[string]any{"payload": nil}) - var validation *errs.ValidationError - if !errors.As(err, &validation) || validation.Param != "--payload" { - t.Fatalf("error = %#v", err) - } -} - -func TestBindTypedMapEnforcesNumberEnum(t *testing.T) { - type args struct { - Ratio float64 `flag:"ratio" schema:"required;enum=0.5|1.5" doc:"ratio"` - } - definition := typedDefinition[args, aliasBinderData]{ - Metadata: CommandMetadata{Service: "fixture", Command: "+number-enum", Description: "number enum fixture", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, - Hooks: typedHooks[args, aliasBinderData]{Execute: func(context.Context, CommandContext, *args) (Result[aliasBinderData], error) { - return Success(aliasBinderData{OK: true}), nil - }}, - } - command, err := compileDefinition(definition) - if err != nil { - t.Fatal(err) - } - _, err = bindTypedMap(command, map[string]any{"ratio": 2.5}) - var validation *errs.ValidationError - if !errors.As(err, &validation) || validation.Param != "--ratio" || !strings.Contains(err.Error(), "unsupported number value") { - t.Fatalf("error = %#v", err) - } -} - -func TestBindTypedMapPreservesLargeIntegerEnum(t *testing.T) { - type args struct { - Sequence int64 `flag:"sequence" schema:"required;enum=9007199254740993" doc:"sequence"` - } - definition := typedDefinition[args, aliasBinderData]{ - Metadata: CommandMetadata{Service: "fixture", Command: "+large-integer", Description: "large integer fixture", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, - Hooks: typedHooks[args, aliasBinderData]{Execute: func(context.Context, CommandContext, *args) (Result[aliasBinderData], error) { - return Success(aliasBinderData{OK: true}), nil - }}, - } - command, err := compileDefinition(definition) - if err != nil { - t.Fatal(err) - } - if _, err := bindTypedMap(command, map[string]any{"sequence": int64(9007199254740993)}); err != nil { - t.Fatalf("valid large integer enum rejected: %v", err) - } -} - -func TestBindTypedMapRejectsWrongFixedArrayLength(t *testing.T) { - type args struct { - Values [2]string `flag:"values" schema:"required" cli:"encoding=repeated" doc:"two values"` - } - definition := typedDefinition[args, aliasBinderData]{ - Metadata: CommandMetadata{Service: "fixture", Command: "+array", Description: "array fixture", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, - Hooks: typedHooks[args, aliasBinderData]{Execute: func(context.Context, CommandContext, *args) (Result[aliasBinderData], error) { - return Success(aliasBinderData{OK: true}), nil - }}, - } - command, err := compileDefinition(definition) - if err != nil { - t.Fatal(err) - } - for _, values := range [][]string{{"one"}, {"one", "two", "three"}} { - _, err := bindTypedMap(command, map[string]any{"values": values}) - var validation *errs.ValidationError - if !errors.As(err, &validation) || validation.Param != "--values" || !strings.Contains(err.Error(), "expected exactly 2 items") { - t.Fatalf("values=%v error=%#v", values, err) - } - } -} - -func TestBindTypedMapRejectsUnknownAndNestedInvalidValues(t *testing.T) { - command, err := compileDefinition(validCompilerDefinition()) - if err != nil { - t.Fatal(err) - } - _, err = bindTypedMap(command, map[string]any{"token": "tok", "unknown": true}) - var validation *errs.ValidationError - if !errors.As(err, &validation) || validation.Param != "--unknown" { - t.Fatalf("unknown error = %#v", err) - } - - _, err = bindTypedMap(command, map[string]any{"token": "tok", "payload": map[string]any{"mode": "unsupported"}}) - if !errors.As(err, &validation) || validation.Param != "--payload" { - t.Fatalf("nested error = %#v", err) - } -} - -func TestConvertReflectValueRejectsNegativeUnsignedInput(t *testing.T) { - _, err := convertReflectValue(int64(-1), reflect.TypeFor[uint64]()) - if err == nil || !strings.Contains(err.Error(), "cannot be represented") { - t.Fatalf("error = %v", err) - } -} - -func TestValidateCompiledValueHandlesNilArrayPointer(t *testing.T) { - var values *[]string - field := compiledInputField{ - name: "values", - shape: ArrayShape{Items: StringShape{}}, - } - var validation *errs.ValidationError - if err := validateCompiledValue(values, field); !errors.As(err, &validation) { - t.Fatalf("error = %#v", err) - } -} diff --git a/shortcuts/common/typed_output.go b/shortcuts/common/typed_output.go index af267de186..12a004e18e 100644 --- a/shortcuts/common/typed_output.go +++ b/shortcuts/common/typed_output.go @@ -3,114 +3,28 @@ package common -import "github.com/larksuite/cli/internal/output" - -type OutputDefinition struct { - Data DataDefinition - Outcomes OutcomeDefinition - Artifacts []ArtifactDefinition - Meta ResultMetaDefinition - Mode OutputMode - - // DisableHTMLEscaping preserves literal <, >, and & characters in JSON - // envelopes and jq JSON output. It does not enable bare stdout output or - // bypass content-safety scanning. - DisableHTMLEscaping bool -} - -// ResultMetaDefinition declares which standard envelope metadata a command may -// return. It is deliberately narrower than output.Meta: rollback and arbitrary -// metadata are not part of the Typed Result contract. -type ResultMetaDefinition struct { - Count bool - Pagination bool -} - -// ResultPaginationMeta reuses the standard output envelope pagination contract. -// The name avoids colliding with the existing PaginationMeta response helper. -type ResultPaginationMeta = output.PaginationMeta - -// ResultMeta carries optional standard envelope metadata for one Result. -// Count is a pointer so the runner can distinguish an omitted count from an -// explicitly supplied zero while preserving output.Meta's existing JSON rules. -type ResultMeta struct { - Count *int - Pagination *ResultPaginationMeta -} - -type Result[Data any] struct { - Data Data - Outcome OutcomeKind - Meta *ResultMeta -} - -type OutcomeKind string - -const ( - OutcomeSuccess OutcomeKind = "success" - OutcomePartial OutcomeKind = "partial" +import ( + "github.com/larksuite/cli/extension/command" + "github.com/larksuite/cli/internal/output" ) -func Success[Data any](data Data) Result[Data] { - return Result[Data]{Data: data, Outcome: OutcomeSuccess} -} - -func Partial[Data any](data Data) Result[Data] { - return Result[Data]{Data: data, Outcome: OutcomePartial} -} - -// WithMeta attaches standard envelope metadata to a Result. -func (result Result[Data]) WithMeta(meta ResultMeta) Result[Data] { - result.Meta = &meta - return result -} - -// CountMeta constructs count metadata while preserving an explicit zero. -func CountMeta(count int) ResultMeta { - return ResultMeta{Count: &count} -} - -// PaginationResultMeta constructs pagination metadata. -func PaginationResultMeta(pagination *ResultPaginationMeta) ResultMeta { - return ResultMeta{Pagination: pagination} -} - -type OutcomeDefinition struct{ PartialFailure *PartialFailureDefinition } -type PartialFailureDefinition struct { - ExitCode int - // FailedItems declares an item-ledger receipt. Leave it nil for a - // result-level partial failure whose recovery state lives directly in Data. - FailedItems *FailedItemDefinition -} -type FailedItemDefinition struct { - ItemsPath string `json:"items_path"` - IdentityPaths []string `json:"identity_paths"` - AllItems bool `json:"all_items,omitempty"` - StatePath string `json:"state_path,omitempty"` - FailedValues []JSONValue `json:"failed_values,omitempty"` -} +type typedOutputDefinition = command.OutputDefinition +type typedResultMetaDefinition = command.ResultMetaDefinition +type typedOutputMode = command.OutputMode +type typedResultPaginationMeta = output.PaginationMeta -// ArtifactDefinition identifies file receipts in Data. It does not write, -// stat, or enforce overwrite policy for the referenced files. -type ArtifactDefinition struct { - Name string `json:"name"` - ItemsPath string `json:"items_path"` - // Optional allows ItemsPath to be absent or null when an invocation - // legitimately produces no file. Any present receipt is still validated. - Optional bool `json:"optional,omitempty"` - PathField string `json:"path_field"` - MediaTypeField string `json:"media_type_field,omitempty"` - SizeField string `json:"size_field,omitempty"` +// typedResultMeta is the runner-owned receipt projected from the opaque public +// Result. V1 can produce pagination only; count, partial outcomes, and artifact +// receipt declarations were unreachable from extension/command and are not +// carried by the private runtime model. +type typedResultMeta struct { + Pagination *typedResultPaginationMeta } -// OutputMode selects one of the output paths the Typed runner actually -// executes. Generic delegates record formats to the framework formatter and -// uses an optional pretty renderer. FixedJSON preserves Legacy Out/OutRaw -// behavior: --format remains accepted, but successful output is always a JSON -// envelope. -type OutputMode string +type typedOutcomeKind string const ( - OutputGeneric OutputMode = "" - OutputFixedJSON OutputMode = "fixed_json" + typedOutcomeSuccess typedOutcomeKind = "success" + typedOutputGeneric typedOutputMode = command.OutputGeneric + typedOutputFixedJSON typedOutputMode = command.OutputFixedJSON ) diff --git a/shortcuts/common/typed_public_surface_test.go b/shortcuts/common/typed_public_surface_test.go new file mode 100644 index 0000000000..95b975a0dc --- /dev/null +++ b/shortcuts/common/typed_public_surface_test.go @@ -0,0 +1,135 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "strings" + "testing" +) + +// Business authors use extension/command. common owns the existing runner and +// may expose only a token-gated internal handshake; a second model or compiler +// here would create another compatibility contract for the same command. +func TestCommonDoesNotExportSecondCommandAuthoringSurface(t *testing.T) { + forbidden := map[string]struct{}{ + "JSONValue": {}, "Definition": {}, "Define": {}, "Hooks": {}, "Renderer": {}, + "CommandMetadata": {}, "Identity": {}, "Risk": {}, "AuthorizationDefinition": {}, + "IdentityAuthorization": {}, "ConditionalScope": {}, "ScopeRequirement": {}, + "InputDefinition": {}, "InputField": {}, "InputDefault": {}, "CLIInput": {}, + "FlagAlias": {}, "FlagAliasMode": {}, "AliasConflictPolicy": {}, "ValueSource": {}, + "CLIEncoding": {}, "Provided": {}, "Relation": {}, "RelationKind": {}, + "PresenceMode": {}, "RelationStage": {}, "ValueShape": {}, "StringShape": {}, + "BooleanShape": {}, "IntegerShape": {}, "NumberShape": {}, "NullShape": {}, + "ConstShape": {}, "ArrayShape": {}, "ObjectShape": {}, "ValueField": {}, + "OneOfShape": {}, "DataDefinition": {}, "DataField": {}, "OutputDefinition": {}, + "ResultMetaDefinition": {}, "Result": {}, "Success": {}, "Partial": {}, + "OutcomeDefinition": {}, "PartialFailureDefinition": {}, "FailedItemDefinition": {}, + "ArtifactDefinition": {}, "CommandContext": {}, "PaginationOptions": {}, + "ErasedDefinition": {}, "ErasedHooks": {}, "ErasedResult": {}, + "CompileErasedDefinition": {}, "DoTypedAPIJSON": {}, "DoTypedAPIJSONWithOptions": {}, + "CallTypedAPI": {}, "CollectCommandPages": {}, "CloneShortcut": {}, "CloneShortcuts": {}, + } + forEachCommonProductionDeclaration(t, func(file, name string, exported bool, _ *ast.FieldList) { + if !exported { + return + } + if _, duplicate := forbidden[name]; duplicate { + t.Errorf("%s exports duplicate command symbol %s; use extension/command", file, name) + } + }) +} + +func TestCommonCommandBridgeRemainsSealedAndNarrow(t *testing.T) { + allowed := map[string]bool{ + "CompileCommandDefinition": false, + "DoHostedAPIJSON": false, + "CallHostedAPI": false, + "CollectHostedPages": false, + "ShortcutSchema": false, + "CloneHostedShortcuts": false, + } + forEachCommonProductionDeclaration(t, func(file, name string, exported bool, params *ast.FieldList) { + if !exported { + return + } + _, audited := allowed[name] + bridgeFile := strings.HasPrefix(filepath.Base(file), "typed_") || filepath.Base(file) == "clone.go" + if bridgeFile && !audited { + t.Errorf("%s exports unexpected command bridge symbol %s", file, name) + return + } + if !audited { + return + } + if !usesCommandBridgeAccess(params) { + t.Errorf("%s bridge function %s is callable without internal commandbridge access", file, name) + } + allowed[name] = true + }) + for name, found := range allowed { + if !found { + t.Errorf("audited command bridge function %s is missing", name) + } + } +} + +func forEachCommonProductionDeclaration(t *testing.T, visit func(file, name string, exported bool, params *ast.FieldList)) { + t.Helper() + 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, 0) + if err != nil { + t.Fatal(err) + } + for _, declaration := range parsed.Decls { + switch value := declaration.(type) { + case *ast.FuncDecl: + if value.Recv == nil { + visit(file, value.Name.Name, value.Name.IsExported(), value.Type.Params) + } + case *ast.GenDecl: + for _, spec := range value.Specs { + switch item := spec.(type) { + case *ast.TypeSpec: + visit(file, item.Name.Name, item.Name.IsExported(), nil) + case *ast.ValueSpec: + for _, name := range item.Names { + visit(file, name.Name, name.IsExported(), nil) + } + } + } + } + } + } +} + +func usesCommandBridgeAccess(fields *ast.FieldList) bool { + if fields == nil { + return false + } + found := false + ast.Inspect(fields, func(node ast.Node) bool { + selector, ok := node.(*ast.SelectorExpr) + if !ok || selector.Sel.Name != "Access" { + return true + } + qualifier, ok := selector.X.(*ast.Ident) + if ok && qualifier.Name == "commandbridge" { + found = true + return false + } + return true + }) + return found +} diff --git a/shortcuts/common/typed_result_protocol.go b/shortcuts/common/typed_result_protocol.go index c7d9e9c911..3f739925de 100644 --- a/shortcuts/common/typed_result_protocol.go +++ b/shortcuts/common/typed_result_protocol.go @@ -3,251 +3,44 @@ package common -import ( - "encoding/json" - "reflect" - "strconv" - "strings" +import "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/errs" -) - -// validateTypedResultProtocol checks only the local Outcome/Artifact receipts -// declared by OutputDefinition. It deliberately does not validate Data against -// the complete output schema on every invocation. +// validateTypedResultProtocol validates the only V1 runner-owned receipt: +// pagination metadata. Partial outcomes, count metadata, and artifact-schema +// receipts had no public producer and deliberately do not exist in this model. func validateTypedResultProtocol(command *compiledCommand, result compiledResult) error { - if result.outcome != OutcomeSuccess && result.outcome != OutcomePartial { + if result.outcome != typedOutcomeSuccess { return nil } - if err := validateTypedResultMeta(command.output.Meta, result.meta); err != nil { - return err - } - if result.outcome != OutcomePartial && len(command.output.Artifacts) == 0 { - return nil - } - encoded, err := json.Marshal(result.data) - if err != nil { - return errs.NewInternalError(errs.SubtypeInvalidResponse, "typed result cannot be inspected for its declared output protocol").WithCause(err) - } - var data any - if err := json.Unmarshal(encoded, &data); err != nil { - return errs.NewInternalError(errs.SubtypeInvalidResponse, "typed result cannot be decoded for its declared output protocol").WithCause(err) - } - if result.outcome == OutcomePartial { - if err := validatePartialReceipt(command.output.Outcomes.PartialFailure, data); err != nil { - return err - } - } - for _, artifact := range command.output.Artifacts { - if err := validateArtifactReceipt(artifact, data); err != nil { - return err - } - } - return nil + return validateTypedResultMeta(command.output.Meta, result.meta) } -func validateTypedResultMeta(definition ResultMetaDefinition, meta *ResultMeta) error { +func validateTypedResultMeta(definition typedResultMetaDefinition, meta *typedResultMeta) error { if meta == nil { return nil } - if meta.Count == nil && meta.Pagination == nil { + if meta.Pagination == nil { return resultProtocolError("typed Result Meta is empty") } - if meta.Count != nil { - if !definition.Count { - return resultProtocolError("typed Result returned undeclared meta.count") - } - if *meta.Count < 0 { - return resultProtocolError("typed Result meta.count must be non-negative") - } - } - if meta.Pagination != nil { - if !definition.Pagination { - return resultProtocolError("typed Result returned undeclared meta.pagination") - } - pagination := meta.Pagination - if pagination.Pages < 1 { - return resultProtocolError("typed Result meta.pagination.pages must be at least 1") - } - if pagination.Items < 0 { - return resultProtocolError("typed Result meta.pagination.items must be non-negative") - } - if pagination.Complete && pagination.NextToken != "" { - return resultProtocolError("typed Result complete pagination must not include next_token") - } - if !pagination.Complete && pagination.NextToken == "" { - return resultProtocolError("typed Result incomplete pagination must include next_token") - } - } - return nil -} - -func validatePartialReceipt(definition *PartialFailureDefinition, data any) error { - if definition == nil { - return errs.NewInternalError(errs.SubtypeUnknown, "typed Partial result has no compiled partial-failure contract") - } - if definition.FailedItems == nil { - return nil - } - failed := definition.FailedItems - value, ok := jsonPointerValue(data, failed.ItemsPath) - if !ok { - return resultProtocolError("partial failed-items path %q is missing from Data", failed.ItemsPath) - } - items, ok := value.([]any) - if !ok { - return resultProtocolError("partial failed-items path %q is not an array", failed.ItemsPath) - } - if len(items) == 0 { - return resultProtocolError("Partial result contains no failed items at %q", failed.ItemsPath) + if !definition.Pagination { + return resultProtocolError("typed Result returned undeclared meta.pagination") } - matched := 0 - for index, item := range items { - for _, identityPath := range failed.IdentityPaths { - if _, ok := jsonPointerValue(item, identityPath); !ok { - return resultProtocolError("partial failed item %d is missing identity path %q", index, identityPath) - } - } - if failed.AllItems { - matched++ - continue - } - state, ok := jsonPointerValue(item, failed.StatePath) - if !ok { - return resultProtocolError("partial failed item %d is missing state path %q", index, failed.StatePath) - } - for _, expected := range failed.FailedValues { - if reflect.DeepEqual(state, normalizedJSONValue(expected)) { - matched++ - break - } - } + pagination := meta.Pagination + if pagination.Pages < 1 { + return resultProtocolError("typed Result meta.pagination.pages must be at least 1") } - if matched == 0 { - return resultProtocolError("Partial result has no item matching the declared failed values") + if pagination.Items < 0 { + return resultProtocolError("typed Result meta.pagination.items must be non-negative") } - return nil -} - -func validateArtifactReceipt(definition ArtifactDefinition, data any) error { - value, ok := jsonPointerValue(data, definition.ItemsPath) - if !ok || value == nil { - if definition.Optional { - return nil - } - return resultProtocolError("artifact %q items path %q is missing from Data", definition.Name, definition.ItemsPath) + if pagination.Complete && pagination.NextToken != "" { + return resultProtocolError("typed Result complete pagination must not include next_token") } - items := []any{value} - if array, ok := value.([]any); ok { - items = array - } - for index, item := range items { - pathValue, ok := jsonPointerValue(item, definition.PathField) - if !ok { - return resultProtocolError("artifact %q item %d is missing path field %q", definition.Name, index, definition.PathField) - } - path, ok := pathValue.(string) - if !ok || path == "" { - return resultProtocolError("artifact %q item %d has an invalid path receipt", definition.Name, index) - } - if definition.SizeField != "" { - sizeValue, ok := jsonPointerValue(item, definition.SizeField) - if !ok { - return resultProtocolError("artifact %q item %d is missing size field %q", definition.Name, index, definition.SizeField) - } - size, ok := jsonInteger(sizeValue) - if !ok || size < 0 { - return resultProtocolError("artifact %q item %d has an invalid size receipt", definition.Name, index) - } - } - if definition.MediaTypeField != "" { - mediaType, ok := jsonPointerValue(item, definition.MediaTypeField) - if !ok { - return resultProtocolError("artifact %q item %d is missing media type field %q", definition.Name, index, definition.MediaTypeField) - } - if _, ok := mediaType.(string); !ok { - return resultProtocolError("artifact %q item %d has a non-string media type receipt", definition.Name, index) - } - } + if !pagination.Complete && pagination.NextToken == "" { + return resultProtocolError("typed Result incomplete pagination must include next_token") } return nil } -func jsonPointerValue(value any, pointer string) (any, bool) { - if pointer == "" { - return value, true - } - if !strings.HasPrefix(pointer, "/") { - return nil, false - } - current := value - for _, encoded := range strings.Split(strings.TrimPrefix(pointer, "/"), "/") { - segment, ok := decodeJSONPointerSegment(encoded) - if !ok { - return nil, false - } - switch container := current.(type) { - case map[string]any: - current, ok = container[segment] - if !ok { - return nil, false - } - case []any: - index, err := strconv.Atoi(segment) - if err != nil || index < 0 || index >= len(container) { - return nil, false - } - current = container[index] - default: - return nil, false - } - } - return current, true -} - -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 normalizedJSONValue(value any) any { - encoded, err := json.Marshal(value) - if err != nil { - return value - } - var normalized any - if err := json.Unmarshal(encoded, &normalized); err != nil { - return value - } - return normalized -} - -func jsonInteger(value any) (int64, bool) { - number, ok := value.(float64) - if !ok || number != float64(int64(number)) { - return 0, false - } - return int64(number), true -} - func resultProtocolError(format string, args ...any) error { return errs.NewInternalError(errs.SubtypeUnknown, format, args...) } diff --git a/shortcuts/common/typed_result_protocol_test.go b/shortcuts/common/typed_result_protocol_test.go index b5144822ae..c604fcfcb8 100644 --- a/shortcuts/common/typed_result_protocol_test.go +++ b/shortcuts/common/typed_result_protocol_test.go @@ -6,150 +6,49 @@ package common import ( "strings" "testing" - - "github.com/larksuite/cli/errs" ) -type protocolArtifact struct { - Path string `json:"path"` - Size int64 `json:"size"` - MediaType string `json:"media_type"` -} -type protocolData struct { - Artifacts []protocolArtifact `json:"artifacts"` - Failures []compilerItem `json:"failures"` -} - -func TestValidateTypedResultProtocolArtifactAndPartial(t *testing.T) { - command := &compiledCommand{output: OutputDefinition{ - Artifacts: []ArtifactDefinition{{Name: "files", ItemsPath: "/artifacts", PathField: "/path", SizeField: "/size", MediaTypeField: "/media_type"}}, - Outcomes: OutcomeDefinition{PartialFailure: &PartialFailureDefinition{ExitCode: 7, FailedItems: &FailedItemDefinition{ItemsPath: "/failures", IdentityPaths: []string{"/id"}, StatePath: "/state", FailedValues: []JSONValue{"failed"}}}}, - }} - result := compiledResult{outcome: OutcomePartial, data: protocolData{ - Artifacts: []protocolArtifact{{Path: "artifacts/artifact.bin", Size: 3, MediaType: "application/octet-stream"}}, - Failures: []compilerItem{{ID: "item-1", State: "failed"}}, - }} - if err := validateTypedResultProtocol(command, result); err != nil { - t.Fatal(err) - } -} - func TestValidateTypedResultMetaContract(t *testing.T) { - count := 2 - validComplete := &ResultPaginationMeta{Complete: true, Pages: 1, Items: 0} - validIncomplete := &ResultPaginationMeta{Complete: false, Pages: 2, Items: 3, NextToken: "next"} - for _, meta := range []*ResultMeta{ - {Count: &count}, + validIncomplete := &typedResultPaginationMeta{Pages: 2, Items: 3, NextToken: "next"} + validComplete := &typedResultPaginationMeta{Complete: true, Pages: 1, Items: 0} + for _, meta := range []*typedResultMeta{ + {Pagination: validIncomplete}, {Pagination: validComplete}, - {Count: &count, Pagination: validIncomplete}, } { - if err := validateTypedResultMeta(ResultMetaDefinition{Count: true, Pagination: true}, meta); err != nil { - t.Fatalf("valid meta %#v: %v", meta, err) + if err := validateTypedResultMeta(typedResultMetaDefinition{Pagination: true}, meta); err != nil { + t.Fatalf("valid meta %#v rejected: %v", meta, err) } } - negative := -1 tests := []struct { name string - definition ResultMetaDefinition - meta *ResultMeta + definition typedResultMetaDefinition + meta *typedResultMeta want string }{ - {name: "empty", definition: ResultMetaDefinition{Count: true}, meta: &ResultMeta{}, want: "Meta is empty"}, - {name: "undeclared count", meta: &ResultMeta{Count: &count}, want: "undeclared meta.count"}, - {name: "negative count", definition: ResultMetaDefinition{Count: true}, meta: &ResultMeta{Count: &negative}, want: "count must be non-negative"}, - {name: "undeclared pagination", meta: &ResultMeta{Pagination: validComplete}, want: "undeclared meta.pagination"}, - {name: "zero pages", definition: ResultMetaDefinition{Pagination: true}, meta: &ResultMeta{Pagination: &ResultPaginationMeta{Complete: true}}, want: "pages must be at least 1"}, - {name: "negative items", definition: ResultMetaDefinition{Pagination: true}, meta: &ResultMeta{Pagination: &ResultPaginationMeta{Complete: true, Pages: 1, Items: -1}}, want: "items must be non-negative"}, - {name: "complete with token", definition: ResultMetaDefinition{Pagination: true}, meta: &ResultMeta{Pagination: &ResultPaginationMeta{Complete: true, Pages: 1, NextToken: "next"}}, want: "must not include next_token"}, - {name: "incomplete without token", definition: ResultMetaDefinition{Pagination: true}, meta: &ResultMeta{Pagination: &ResultPaginationMeta{Pages: 1}}, want: "must include next_token"}, + {name: "empty", definition: typedResultMetaDefinition{Pagination: true}, meta: &typedResultMeta{}, want: "Meta is empty"}, + {name: "undeclared", meta: &typedResultMeta{Pagination: validIncomplete}, want: "undeclared meta.pagination"}, + {name: "zero pages", definition: typedResultMetaDefinition{Pagination: true}, meta: &typedResultMeta{Pagination: &typedResultPaginationMeta{}}, want: "pages must be at least 1"}, + {name: "negative items", definition: typedResultMetaDefinition{Pagination: true}, meta: &typedResultMeta{Pagination: &typedResultPaginationMeta{Pages: 1, Items: -1, NextToken: "next"}}, want: "items must be non-negative"}, + {name: "complete token", definition: typedResultMetaDefinition{Pagination: true}, meta: &typedResultMeta{Pagination: &typedResultPaginationMeta{Complete: true, Pages: 1, NextToken: "next"}}, want: "must not include next_token"}, + {name: "incomplete missing token", definition: typedResultMetaDefinition{Pagination: true}, meta: &typedResultMeta{Pagination: &typedResultPaginationMeta{Pages: 1}}, want: "must include next_token"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { err := validateTypedResultMeta(test.definition, test.meta) - problem, ok := errs.ProblemOf(err) - if !ok || problem.Category != errs.CategoryInternal || !strings.Contains(problem.Message, test.want) { - t.Fatalf("error = %#v, problem = %#v, want containing %q", err, problem, test.want) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want containing %q", err, test.want) } }) } } func TestOutputMetaFromTypedClonesPagination(t *testing.T) { - count := 4 - pagination := &ResultPaginationMeta{Complete: false, Pages: 1, Items: 4, NextToken: "next"} - meta := &ResultMeta{Count: &count, Pagination: pagination} + pagination := &typedResultPaginationMeta{Complete: false, Pages: 2, Items: 7, NextToken: "next"} + meta := &typedResultMeta{Pagination: pagination} converted := outputMetaFromTyped(meta) - count = 9 pagination.NextToken = "mutated" - if converted.Count != 4 || converted.Pagination == nil || converted.Pagination.NextToken != "next" { - t.Fatalf("converted meta was mutated through caller pointers: %#v", converted) - } -} - -func TestValidateTypedResultProtocolRejectsInvalidArtifactReceipt(t *testing.T) { - command := &compiledCommand{output: OutputDefinition{Artifacts: []ArtifactDefinition{{Name: "files", ItemsPath: "/artifacts", PathField: "/path", SizeField: "/size"}}}} - result := compiledResult{outcome: OutcomeSuccess, data: protocolData{Artifacts: []protocolArtifact{{Path: "", Size: -1}}}} - err := validateTypedResultProtocol(command, result) - problem, ok := errs.ProblemOf(err) - if !ok || problem.Category != errs.CategoryInternal || !strings.Contains(problem.Message, "invalid path receipt") { - t.Fatalf("error = %#v, problem = %#v", err, problem) - } -} - -func TestValidateTypedResultProtocolOptionalArtifact(t *testing.T) { - command := &compiledCommand{output: OutputDefinition{Artifacts: []ArtifactDefinition{{Name: "file", ItemsPath: "/artifact", Optional: true, PathField: "/path", SizeField: "/size", MediaTypeField: "/media_type"}}}} - for _, data := range []any{map[string]any{}, map[string]any{"artifact": nil}} { - if err := validateTypedResultProtocol(command, compiledResult{outcome: OutcomeSuccess, data: data}); err != nil { - t.Fatalf("optional artifact data %#v: %v", data, err) - } - } - valid := map[string]any{"artifact": map[string]any{"path": "file.bin", "size": 3, "media_type": "application/octet-stream"}} - if err := validateTypedResultProtocol(command, compiledResult{outcome: OutcomeSuccess, data: valid}); err != nil { - t.Fatalf("present valid optional artifact: %v", err) - } - for _, test := range []struct { - name string - data map[string]any - want string - }{ - {name: "path", data: map[string]any{"artifact": map[string]any{"path": "", "size": 3, "media_type": "application/octet-stream"}}, want: "invalid path receipt"}, - {name: "size", data: map[string]any{"artifact": map[string]any{"path": "file.bin", "size": -1, "media_type": "application/octet-stream"}}, want: "invalid size receipt"}, - {name: "media type", data: map[string]any{"artifact": map[string]any{"path": "file.bin", "size": 3, "media_type": 7}}, want: "non-string media type receipt"}, - } { - t.Run(test.name, func(t *testing.T) { - err := validateTypedResultProtocol(command, compiledResult{outcome: OutcomeSuccess, data: test.data}) - if err == nil || !strings.Contains(err.Error(), test.want) { - t.Fatalf("error = %v, want containing %q", err, test.want) - } - }) - } -} - -func TestValidateTypedResultProtocolRequiredArtifactRejectsMissingReceipt(t *testing.T) { - command := &compiledCommand{output: OutputDefinition{Artifacts: []ArtifactDefinition{{Name: "file", ItemsPath: "/artifact", PathField: "/path"}}}} - for _, data := range []any{map[string]any{}, map[string]any{"artifact": nil}} { - err := validateTypedResultProtocol(command, compiledResult{outcome: OutcomeSuccess, data: data}) - problem, ok := errs.ProblemOf(err) - if !ok || problem.Category != errs.CategoryInternal || !strings.Contains(problem.Message, "is missing from Data") { - t.Fatalf("required artifact data %#v: error = %#v, problem = %#v", data, err, problem) - } - } -} - -func TestValidateTypedResultProtocolAcceptsResultLevelPartial(t *testing.T) { - command := &compiledCommand{output: OutputDefinition{Outcomes: OutcomeDefinition{PartialFailure: &PartialFailureDefinition{ExitCode: 7}}}} - data := map[string]any{"resource_id": "resource-1", "reason": "follow-up write failed"} - if err := validateTypedResultProtocol(command, compiledResult{outcome: OutcomePartial, data: data}); err != nil { - t.Fatal(err) - } -} - -func TestValidateTypedResultProtocolRejectsEmptyPartial(t *testing.T) { - command := &compiledCommand{output: OutputDefinition{Outcomes: OutcomeDefinition{PartialFailure: &PartialFailureDefinition{ExitCode: 7, FailedItems: &FailedItemDefinition{ItemsPath: "/failures", AllItems: true}}}}} - err := validateTypedResultProtocol(command, compiledResult{outcome: OutcomePartial, data: protocolData{Failures: []compilerItem{}}}) - problem, ok := errs.ProblemOf(err) - if !ok || problem.Category != errs.CategoryInternal || !strings.Contains(problem.Message, "no failed items") { - t.Fatalf("error = %#v, problem = %#v", err, problem) + if converted.Pagination == nil || converted.Pagination.NextToken != "next" { + t.Fatalf("converted meta = %#v", converted) } } diff --git a/shortcuts/common/typed_runner.go b/shortcuts/common/typed_runner.go index e664007318..f17fe0e92c 100644 --- a/shortcuts/common/typed_runner.go +++ b/shortcuts/common/typed_runner.go @@ -36,7 +36,7 @@ func runTypedShortcut(cmdFactory *cmdutil.Factory, runtime *RuntimeContext, shor return attributeAliasValidationError(runtime, err) } } - if err := validateCompiledRelations(command, bound.value, bound.provided, StageAfterPrepare); err != nil { + if err := validateCompiledRelations(command, bound.value, bound.provided, typedStageAfterPrepare); err != nil { return err } if command.hooks.validate != nil { @@ -57,7 +57,7 @@ func runTypedShortcut(cmdFactory *cmdutil.Factory, runtime *RuntimeContext, shor } return cmdutil.WriteDryRun(preview, cmdutil.DryRunOutputOptions{Format: runtime.Format, JqExpr: runtime.JqExpr, CommandPath: runtime.Cmd.CommandPath(), Identity: runtime.As(), Out: cmdFactory.IOStreams.Out, ErrOut: cmdFactory.IOStreams.ErrOut}) } - if shortcut.Risk == string(RiskHighRiskWrite) && !runtime.Bool("yes") { + if shortcut.Risk == string(typedRiskHighRiskWrite) && !runtime.Bool("yes") { return cmdutil.RequireConfirmation(shortcut.Service + " " + shortcut.Command) } result, err := command.hooks.execute(runtime.ctx, commandContext, bound.value) @@ -78,7 +78,7 @@ func validateTypedStdinInputs(runtime *RuntimeContext, command *compiledCommand) for _, field := range command.fields { supportsStdin := false for _, source := range field.cli.ValueSources { - if source == SourceStdin { + if source == typedSourceStdin { supportsStdin = true break } @@ -122,39 +122,26 @@ func emitTypedResult(runtime *RuntimeContext, command *compiledCommand, result c } } format := runtime.Format - if command.output.Mode == OutputFixedJSON { + if command.output.Mode == typedOutputFixedJSON { // Compatibility for Legacy hooks that used RuntimeContext.Out: the // injected --format flag existed but output was always JSON. format = "" } options := output.EmitOptions{Format: format, Raw: command.output.DisableHTMLEscaping, JQ: runtime.JqExpr, Pretty: pretty, Meta: outputMetaFromTyped(result.meta)} switch result.outcome { - case OutcomeSuccess: + case typedOutcomeSuccess: runtime.handleEmitterError(runtime.newEmitter().Success(result.data, options)) return runtime.outputErr - case OutcomePartial: - partial := command.output.Outcomes.PartialFailure - if partial == nil { - return errs.NewInternalError(errs.SubtypeUnknown, "typed Execute returned Partial but Output does not declare partial failure") - } - runtime.handleEmitterError(runtime.newEmitter().PartialFailure(result.data, options)) - if runtime.outputErr != nil { - return runtime.outputErr - } - return output.PartialFailure(partial.ExitCode) default: return errs.NewInternalError(errs.SubtypeUnknown, "typed Execute returned invalid Outcome %q", result.outcome) } } -func outputMetaFromTyped(meta *ResultMeta) *output.Meta { +func outputMetaFromTyped(meta *typedResultMeta) *output.Meta { if meta == nil { return nil } converted := &output.Meta{} - if meta.Count != nil { - converted.Count = *meta.Count - } if meta.Pagination != nil { pagination := *meta.Pagination converted.Pagination = &pagination @@ -167,7 +154,7 @@ type typedCommandContext struct { command *compiledCommand } -func (c typedCommandContext) Identity() Identity { return Identity(c.runtime.As()) } +func (c typedCommandContext) Identity() typedIdentity { return typedIdentity(c.runtime.As()) } func (c typedCommandContext) Config() core.CliConfig { return *c.runtime.Config } func (c typedCommandContext) APIClient() (*client.APIClient, error) { return c.runtime.getAPIClient() } func (c typedCommandContext) FileIO() fileio.FileIO { return c.runtime.FileIO() } @@ -180,7 +167,7 @@ func (c typedCommandContext) InputResolvedFromSource(param string) bool { return false } for _, alias := range c.command.fields[fieldIndex].cli.Aliases { - if alias.Mode == AliasIndependent && c.runtime.InputResolvedFromSource(alias.Name) { + if alias.Mode == typedAliasIndependent && c.runtime.InputResolvedFromSource(alias.Name) { return true } } @@ -196,12 +183,12 @@ func (c typedCommandContext) StartSpinner(label string) func() { } func (c typedCommandContext) PresentError(err error) error { return c.runtime.PresentError(err) } func (c typedCommandContext) IsDryRun() bool { return c.runtime != nil && c.runtime.Bool("dry-run") } -func (c typedCommandContext) PaginationOptions() (PaginationOptions, error) { +func (c typedCommandContext) PaginationOptions() (typedPaginationOptions, error) { values, err := pageAllValues(c.runtime) if err != nil { - return PaginationOptions{}, err + return typedPaginationOptions{}, err } - return PaginationOptions{All: values.enabled, MaxPages: values.maxPages, Delay: values.delay}, nil + return typedPaginationOptions{All: values.enabled, MaxPages: values.maxPages, Delay: values.delay}, nil } func (c typedCommandContext) typedCommandPath() string { if c.runtime == nil || c.runtime.Cmd == nil { @@ -236,4 +223,4 @@ func (c typedCommandContext) RequireConditionalScopes(scopes ...string) error { return c.runtime.EnsureScopes(requested) } -var _ CommandContext = typedCommandContext{} +var _ typedRuntimeContext = typedCommandContext{} diff --git a/shortcuts/common/typed_runner_test.go b/shortcuts/common/typed_runner_test.go index f52d59facc..6a0b2b7375 100644 --- a/shortcuts/common/typed_runner_test.go +++ b/shortcuts/common/typed_runner_test.go @@ -13,6 +13,7 @@ import ( "testing" "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/command" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" @@ -23,12 +24,12 @@ type typedRunnerPayload struct { Name string `json:"name" schema:"required" doc:"payload name"` } type typedRunnerArgs struct { - Token string `flag:"token" schema:"required;minLength=1" doc:"target token"` - Count Provided[int] `flag:"count" schema:"optional;default=7;minimum=0" doc:"item count"` - Enabled Provided[bool] `flag:"enabled" schema:"optional;default=true" doc:"enabled state"` - Payload *typedRunnerPayload `flag:"payload" schema:"optional;nullable" cli:"sources=flag|stdin;encoding=json" doc:"JSON payload"` - Template *typedRunnerPayload `flag:"template" schema:"optional;nullable" cli:"sources=flag|stdin;encoding=json" doc:"JSON template"` - Prepared string `arg:"local"` + Token string `flag:"token" schema:"required;minLength=1" doc:"target token"` + Count command.Provided[int] `flag:"count" schema:"optional;default=7;minimum=0" doc:"item count"` + Enabled command.Provided[bool] `flag:"enabled" schema:"optional;default=true" doc:"enabled state"` + Payload *typedRunnerPayload `flag:"payload" schema:"optional;nullable" cli:"sources=flag|stdin;encoding=json" doc:"JSON payload"` + Template *typedRunnerPayload `flag:"template" schema:"optional;nullable" cli:"sources=flag|stdin;encoding=json" doc:"JSON template"` + Prepared string `arg:"local"` } type typedRunnerItem struct { State string `json:"state" schema:"required" doc:"item state"` @@ -44,28 +45,21 @@ type typedRunnerData struct { } func typedRunnerDefinition(capture func(*typedRunnerArgs), partial bool) typedDefinition[typedRunnerArgs, typedRunnerData] { - outputDefinition := OutputDefinition{} - if partial { - outputDefinition.Outcomes.PartialFailure = &PartialFailureDefinition{ExitCode: 9, FailedItems: &FailedItemDefinition{ItemsPath: "/items", StatePath: "/state", FailedValues: []JSONValue{"failed"}}} - } + _ = partial // retained in fixture call sites while unreachable partial tests are removed return typedDefinition[typedRunnerArgs, typedRunnerData]{ - Metadata: CommandMetadata{Service: "fixture", Command: "+typed", Description: "Run typed fixture", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, - Input: InputDefinition{Fields: []InputField{{Name: "token", CLI: CLIInput{Aliases: []FlagAlias{{Name: "legacy-token", Mode: AliasIndependent, Conflict: AliasTrimmedEqualOrError, Hidden: true, Deprecated: true}}}}}}, - Output: outputDefinition, + Metadata: typedCommandMetadata{Service: "fixture", Command: "+typed", Description: "Run typed fixture", Risk: typedRiskRead, Authorization: typedAuthorizationDefinition{Identities: map[typedIdentity]typedIdentityAuthorization{typedIdentityUser: {}}}}, + Input: typedInputDefinition{Fields: []typedInputField{{Name: "token", CLI: typedCLIInput{Aliases: []typedFlagAlias{{Name: "legacy-token", Mode: typedAliasIndependent, Conflict: typedAliasTrimmedEqualOrError, Hidden: true, Deprecated: true}}}}}}, Hooks: typedHooks[typedRunnerArgs, typedRunnerData]{ - Normalize: func(_ context.Context, _ CommandContext, args *typedRunnerArgs) error { + Normalize: func(_ context.Context, _ typedRuntimeContext, args *typedRunnerArgs) error { args.Prepared = strings.ToUpper(args.Token) return nil }, - Execute: func(_ context.Context, _ CommandContext, args *typedRunnerArgs) (Result[typedRunnerData], error) { + Execute: func(_ context.Context, _ typedRuntimeContext, args *typedRunnerArgs) (typedResult[typedRunnerData], error) { if capture != nil { capture(args) } data := typedRunnerData{Token: args.Token, Count: args.Count.Value, CountSet: args.Count.Set, Enabled: args.Enabled.Value, Prepared: args.Prepared, Items: []typedRunnerItem{{State: "failed"}}} - if partial { - return Partial(data), nil - } - return Success(data), nil + return typedSuccess(data), nil }, }, } @@ -91,12 +85,12 @@ func TestTypedHelpSummarizesDeepJSONWithoutExpandingShape(t *testing.T) { type data struct { OK bool `json:"ok" schema:"required" doc:"success state"` } - deepShape := ObjectShape{Fields: []ValueField{{Name: "level_one", Description: "level one", Required: true, Shape: ObjectShape{Fields: []ValueField{{Name: "secret_depth_field", Description: "deep field", Required: true, Shape: StringShape{}}}}}}} + deepShape := command.ObjectShape{Fields: []command.ValueField{{Name: "level_one", Description: "level one", Required: true, Shape: command.ObjectShape{Fields: []command.ValueField{{Name: "secret_depth_field", Description: "deep field", Required: true, Shape: command.StringShape{}}}}}}} definition := typedDefinition[args, data]{ - Metadata: CommandMetadata{Service: "fixture", Command: "+deep-json", Description: "deep JSON fixture", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, - Input: InputDefinition{Fields: []InputField{{Name: "properties", Shape: deepShape}}}, - Hooks: typedHooks[args, data]{Execute: func(context.Context, CommandContext, *args) (Result[data], error) { - return Success(data{OK: true}), nil + Metadata: typedCommandMetadata{Service: "fixture", Command: "+deep-json", Description: "deep JSON fixture", Risk: typedRiskRead, Authorization: typedAuthorizationDefinition{Identities: map[typedIdentity]typedIdentityAuthorization{typedIdentityUser: {}}}}, + Input: typedInputDefinition{Fields: []typedInputField{{Name: "properties", Shape: deepShape}}}, + Hooks: typedHooks[args, data]{Execute: func(context.Context, typedRuntimeContext, *args) (typedResult[data], error) { + return typedSuccess(data{OK: true}), nil }}, } factory, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{}) @@ -131,9 +125,9 @@ func TestTypedHelpSupportsCommandWithoutBusinessParameters(t *testing.T) { OK bool `json:"ok" schema:"required" doc:"success state"` } definition := typedDefinition[args, data]{ - Metadata: CommandMetadata{Service: "fixture", Command: "+no-input", Description: "no input fixture", Risk: RiskRead, Authorization: AuthorizationDefinition{Identities: map[Identity]IdentityAuthorization{IdentityUser: {}}}}, - Hooks: typedHooks[args, data]{Execute: func(context.Context, CommandContext, *args) (Result[data], error) { - return Success(data{OK: true}), nil + Metadata: typedCommandMetadata{Service: "fixture", Command: "+no-input", Description: "no input fixture", Risk: typedRiskRead, Authorization: typedAuthorizationDefinition{Identities: map[typedIdentity]typedIdentityAuthorization{typedIdentityUser: {}}}}, + Hooks: typedHooks[args, data]{Execute: func(context.Context, typedRuntimeContext, *args) (typedResult[data], error) { + return typedSuccess(data{OK: true}), nil }}, } factory, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{}) @@ -201,8 +195,8 @@ func TestTypedRunnerInstallsGroupedHelpFromCompiledFacts(t *testing.T) { root := &cobra.Command{Use: "lark-cli"} service := &cobra.Command{Use: "fixture"} root.AddCommand(service) - definition := typedRunnerDefinition(nil, true) - definition.Output.Meta = ResultMetaDefinition{Count: true, Pagination: true} + definition := typedRunnerDefinition(nil, false) + definition.Output.Meta = typedResultMetaDefinition{Pagination: true} defineTypedShortcut(definition).Mount(service, factory) cmd, _, err := root.Find([]string{"fixture", "+typed"}) if err != nil { @@ -221,8 +215,7 @@ func TestTypedRunnerInstallsGroupedHelpFromCompiledFacts(t *testing.T) { "default: 7", "minimum: 0", "accepts inline JSON or stdin with -", "Constraints:\n at most one parameter may read stdin in one invocation", "Execution:\n --as ", "--dry-run", - "Output:\n --format ", "partial-failure result", - "JSON and jq envelopes may include meta.count", "pagination metadata reports completion, pages, items", + "Output:\n --format ", "pagination metadata reports completion, pages, items", } { if !strings.Contains(got, want) { t.Errorf("help missing %q:\n%s", want, got) @@ -236,14 +229,14 @@ func TestTypedRunnerInstallsGroupedHelpFromCompiledFacts(t *testing.T) { func TestTypedHelpPaginationSummaryMatchesExecutableOutputPaths(t *testing.T) { tests := []struct { name string - mode OutputMode + mode typedOutputMode pretty bool want string mustNotMatch string }{ {name: "generic table only", want: "pagination metadata reports completion, pages, items, and a resume token when incomplete; successful table output appends a pagination summary", mustNotMatch: "pretty/table"}, {name: "generic pretty and table", pretty: true, want: "pagination metadata reports completion, pages, items, and a resume token when incomplete; successful pretty/table output appends a pagination summary"}, - {name: "fixed JSON", mode: OutputFixedJSON, want: "pagination metadata reports completion, pages, items, and a resume token when incomplete", mustNotMatch: "appends a pagination summary"}, + {name: "fixed JSON", mode: typedOutputFixedJSON, want: "pagination metadata reports completion, pages, items, and a resume token when incomplete", mustNotMatch: "appends a pagination summary"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -316,7 +309,7 @@ func TestTypedRunnerBindsDefaultsPresenceAliasAndNormalize(t *testing.T) { if err != nil { t.Fatal(err) } - if defaults.Count != (Provided[int]{Value: 7, Set: false}) || defaults.Enabled != (Provided[bool]{Value: true, Set: false}) { + if defaults.Count != (command.Provided[int]{Value: 7, Set: false}) || defaults.Enabled != (command.Provided[bool]{Value: true, Set: false}) { t.Fatalf("defaults = count %#v enabled %#v", defaults.Count, defaults.Enabled) } } @@ -364,7 +357,7 @@ func TestTypedRunnerAliasConflictAndRequiredStructuralError(t *testing.T) { definition := typedRunnerDefinition(nil, false) definition.Input.Fields[0].CLI.Aliases = append(definition.Input.Fields[0].CLI.Aliases, - FlagAlias{Name: "older-token", Mode: AliasIndependent, Conflict: AliasErrorIfBoth}, + typedFlagAlias{Name: "older-token", Mode: typedAliasIndependent, Conflict: typedAliasErrorIfBoth}, ) _, _, err = runTypedFixture(t, definition, "", "--legacy-token", "a", "--older-token", "b") problem, ok = errs.ProblemOf(err) @@ -375,9 +368,9 @@ func TestTypedRunnerAliasConflictAndRequiredStructuralError(t *testing.T) { func TestTypedRunnerDryRunUsesProductionStrictIdentity(t *testing.T) { definition := typedRunnerDefinition(nil, false) - definition.Metadata.Authorization.Identities[IdentityBot] = IdentityAuthorization{} - var identity Identity - definition.Hooks.DryRun = func(_ context.Context, command CommandContext, _ *typedRunnerArgs) *DryRunAPI { + definition.Metadata.Authorization.Identities[typedIdentityBot] = typedIdentityAuthorization{} + var identity typedIdentity + definition.Hooks.DryRun = func(_ context.Context, command typedRuntimeContext, _ *typedRunnerArgs) *DryRunAPI { identity = command.Identity() return NewDryRunAPI() } @@ -392,81 +385,17 @@ func TestTypedRunnerDryRunUsesProductionStrictIdentity(t *testing.T) { if _, err := root.ExecuteC(); err != nil { t.Fatal(err) } - if identity != IdentityUser { - t.Fatalf("dry-run identity = %q, want %q", identity, IdentityUser) - } -} - -func TestTypedRunnerEmitsResultLevelPartialWithoutFailedItems(t *testing.T) { - definition := typedRunnerDefinition(nil, true) - definition.Output.Outcomes.PartialFailure.FailedItems = nil - definition.Hooks.Execute = func(_ context.Context, _ CommandContext, args *typedRunnerArgs) (Result[typedRunnerData], error) { - return Partial(typedRunnerData{Token: args.Token, Prepared: "follow-up write failed"}), nil - } - definition.Hooks.Renderers = map[string]typedRenderer[typedRunnerData]{"pretty": func(w io.Writer, _ typedRunnerData) error { - _, err := io.WriteString(w, "partial pretty must not run") - return err - }} - stdout, stderr, err := runTypedFixture(t, definition, "", "--token", "resource-1", "--format", "pretty") - if output.ExitCodeOf(err) != 9 || stderr != "" { - t.Fatalf("error = %v, exit = %d, stderr = %q", err, output.ExitCodeOf(err), stderr) - } - var envelope struct { - OK bool `json:"ok"` - Data typedRunnerData `json:"data"` - } - if unmarshalErr := json.Unmarshal([]byte(stdout), &envelope); unmarshalErr != nil { - t.Fatalf("stdout = %q: %v", stdout, unmarshalErr) - } - if strings.Contains(stdout, "partial pretty must not run") { - t.Fatalf("partial result used a pretty renderer: %q", stdout) - } - if envelope.OK || envelope.Data.Token != "resource-1" || envelope.Data.Prepared != "follow-up write failed" { - t.Fatalf("envelope = %#v", envelope) - } -} - -func TestTypedRunnerRejectsInvalidPartialReceiptBeforeWritingStdout(t *testing.T) { - definition := typedRunnerDefinition(nil, true) - definition.Hooks.Execute = func(_ context.Context, _ CommandContext, args *typedRunnerArgs) (Result[typedRunnerData], error) { - return Partial(typedRunnerData{Token: args.Token, Items: []typedRunnerItem{}}), nil - } - stdout, _, err := runTypedFixture(t, definition, "", "--token", "x") - problem, ok := errs.ProblemOf(err) - if !ok || problem.Category != errs.CategoryInternal || stdout != "" { - t.Fatalf("stdout = %q, error = %#v, problem = %#v", stdout, err, problem) - } -} - -func TestTypedRunnerEmitsCountMetaForSuccessJSONAndJQ(t *testing.T) { - definition := typedRunnerDefinition(nil, false) - definition.Output.Meta.Count = true - definition.Hooks.Execute = func(_ context.Context, _ CommandContext, args *typedRunnerArgs) (Result[typedRunnerData], error) { - return Success(typedRunnerData{Token: args.Token, Items: []typedRunnerItem{}}).WithMeta(CountMeta(3)), nil - } - stdout, stderr, err := runTypedFixture(t, definition, "", "--token", "x") - if err != nil || stderr != "" { - t.Fatalf("stdout = %q, stderr = %q, error = %v", stdout, stderr, err) - } - var envelope struct { - Meta output.Meta `json:"meta"` - } - if err := json.Unmarshal([]byte(stdout), &envelope); err != nil || envelope.Meta.Count != 3 { - t.Fatalf("stdout = %q, envelope = %#v, error = %v", stdout, envelope, err) - } - - stdout, stderr, err = runTypedFixture(t, definition, "", "--token", "x", "--jq", ".meta.count") - if err != nil || stderr != "" || strings.TrimSpace(stdout) != "3" { - t.Fatalf("jq stdout = %q, stderr = %q, error = %v", stdout, stderr, err) + if identity != typedIdentityUser { + t.Fatalf("dry-run identity = %q, want %q", identity, typedIdentityUser) } } func TestTypedRunnerEmitsPaginationMetaForSuccessPretty(t *testing.T) { definition := typedRunnerDefinition(nil, false) definition.Output.Meta.Pagination = true - definition.Hooks.Execute = func(_ context.Context, _ CommandContext, args *typedRunnerArgs) (Result[typedRunnerData], error) { - pagination := &ResultPaginationMeta{Complete: false, Pages: 2, Items: 1, NextToken: "resume-token"} - return Success(typedRunnerData{Token: args.Token, Items: []typedRunnerItem{{State: "failed"}}}).WithMeta(PaginationResultMeta(pagination)), nil + definition.Hooks.Execute = func(_ context.Context, _ typedRuntimeContext, args *typedRunnerArgs) (typedResult[typedRunnerData], error) { + pagination := &typedResultPaginationMeta{Complete: false, Pages: 2, Items: 1, NextToken: "resume-token"} + return typedSuccess(typedRunnerData{Token: args.Token, Items: []typedRunnerItem{{State: "failed"}}}).WithMeta(typedPaginationResultMeta(pagination)), nil } definition.Hooks.Renderers = map[string]typedRenderer[typedRunnerData]{"pretty": func(w io.Writer, data typedRunnerData) error { _, err := fmt.Fprintf(w, "token=%s\n", data.Token) @@ -488,32 +417,6 @@ func TestTypedRunnerEmitsPaginationMetaForSuccessPretty(t *testing.T) { } } -func TestTypedRunnerEmitsPaginationMetaForPartialJSONAndJQ(t *testing.T) { - definition := typedRunnerDefinition(nil, true) - definition.Output.Meta.Pagination = true - definition.Hooks.Execute = func(_ context.Context, _ CommandContext, args *typedRunnerArgs) (Result[typedRunnerData], error) { - pagination := &ResultPaginationMeta{Complete: false, Pages: 1, Items: 1, NextToken: "partial-token"} - data := typedRunnerData{Token: args.Token, Items: []typedRunnerItem{{State: "failed"}}} - return Partial(data).WithMeta(PaginationResultMeta(pagination)), nil - } - stdout, stderr, err := runTypedFixture(t, definition, "", "--token", "x") - if output.ExitCodeOf(err) != 9 || stderr != "" { - t.Fatalf("stdout = %q, stderr = %q, error = %v", stdout, stderr, err) - } - var envelope struct { - OK bool `json:"ok"` - Meta output.Meta `json:"meta"` - } - if decodeErr := json.Unmarshal([]byte(stdout), &envelope); decodeErr != nil || envelope.OK || envelope.Meta.Pagination == nil || envelope.Meta.Pagination.NextToken != "partial-token" { - t.Fatalf("stdout = %q, envelope = %#v, decode error = %v", stdout, envelope, decodeErr) - } - - stdout, stderr, err = runTypedFixture(t, definition, "", "--token", "x", "--jq", ".meta.pagination.next_token") - if output.ExitCodeOf(err) != 9 || stderr != "" || strings.TrimSpace(stdout) != "partial-token" { - t.Fatalf("jq stdout = %q, stderr = %q, error = %v", stdout, stderr, err) - } -} - func TestTypedRunnerTableUsesFrameworkFormatter(t *testing.T) { stdout, _, err := runTypedFixture(t, typedRunnerDefinition(nil, false), "", "--token", "x", "--format", "table") if err != nil { @@ -546,7 +449,7 @@ func TestTypedRunnerGenericPrettyCompatibilityAndOptIn(t *testing.T) { func TestTypedRunnerFixedJSONPreservesIgnoredFormatFlags(t *testing.T) { definition := typedRunnerDefinition(nil, false) - definition.Output.Mode = OutputFixedJSON + definition.Output.Mode = typedOutputFixedJSON for _, format := range []string{"json", "pretty", "table", "ndjson", "csv"} { t.Run(format, func(t *testing.T) { stdout, stderr, err := runTypedFixture(t, definition, "", "--token", "x", "--format", format) @@ -590,50 +493,11 @@ func TestTypedRunnerJSONHTMLEscapingPolicy(t *testing.T) { } } -func TestTypedRunnerPartialUsesUnescapedJSONPolicy(t *testing.T) { - const markup = "A&B" - definition := typedRunnerDefinition(nil, true) - definition.Output.DisableHTMLEscaping = true - stdout, _, err := runTypedFixture(t, definition, "", "--token", markup) - if output.ExitCodeOf(err) != 9 { - t.Fatalf("error = %v, exit = %d", err, output.ExitCodeOf(err)) - } - if !strings.Contains(stdout, markup) || strings.Contains(stdout, `\u003c`) { - t.Fatalf("partial JSON did not preserve markup: %q", stdout) - } - if !json.Valid([]byte(stdout)) { - t.Fatalf("partial output is not valid JSON: %q", stdout) - } -} - -func TestTypedRunnerPartialUsesDeclaredExitCodeAndSingleEnvelope(t *testing.T) { - stdout, stderr, err := runTypedFixture(t, typedRunnerDefinition(nil, true), "", "--token", "x") - if output.ExitCodeOf(err) != 9 { - t.Fatalf("error = %v, exit = %d", err, output.ExitCodeOf(err)) - } - if stderr != "" { - t.Fatalf("stderr = %q", stderr) - } - var envelope struct { - OK bool `json:"ok"` - Data typedRunnerData `json:"data"` - } - if unmarshalErr := json.Unmarshal([]byte(stdout), &envelope); unmarshalErr != nil { - t.Fatalf("stdout = %q: %v", stdout, unmarshalErr) - } - if envelope.OK || len(envelope.Data.Items) != 1 { - t.Fatalf("envelope = %#v", envelope) - } - if strings.Count(strings.TrimSpace(stdout), "\n{\"") > 0 { - t.Fatalf("stdout contains more than one envelope: %q", stdout) - } -} - func TestTypedRunnerRejectsResultAndErrorTogether(t *testing.T) { definition := typedRunnerDefinition(nil, false) sentinel := errs.NewValidationError(errs.SubtypeFailedPrecondition, "fixture unavailable") - definition.Hooks.Execute = func(context.Context, CommandContext, *typedRunnerArgs) (Result[typedRunnerData], error) { - return Success(typedRunnerData{}), sentinel + definition.Hooks.Execute = func(context.Context, typedRuntimeContext, *typedRunnerArgs) (typedResult[typedRunnerData], error) { + return typedSuccess(typedRunnerData{}), sentinel } _, _, err := runTypedFixture(t, definition, "", "--token", "x") problem, ok := errs.ProblemOf(err) @@ -645,8 +509,8 @@ func TestTypedRunnerRejectsResultAndErrorTogether(t *testing.T) { func TestTypedRunnerExecuteErrorPassesThrough(t *testing.T) { definition := typedRunnerDefinition(nil, false) sentinel := errs.NewValidationError(errs.SubtypeFailedPrecondition, "fixture unavailable") - definition.Hooks.Execute = func(context.Context, CommandContext, *typedRunnerArgs) (Result[typedRunnerData], error) { - return Result[typedRunnerData]{}, sentinel + definition.Hooks.Execute = func(context.Context, typedRuntimeContext, *typedRunnerArgs) (typedResult[typedRunnerData], error) { + return typedResult[typedRunnerData]{}, sentinel } _, _, err := runTypedFixture(t, definition, "", "--token", "x") if err != sentinel { diff --git a/shortcuts/common/typed_schema.go b/shortcuts/common/typed_schema.go index 48e3d3668d..d1176ae714 100644 --- a/shortcuts/common/typed_schema.go +++ b/shortcuts/common/typed_schema.go @@ -5,9 +5,8 @@ package common import "fmt" -// typedSchemaContract is intentionally private during migration. Compiler and -// snapshot tests consume it now; public cmd/schema registration happens only -// after all shortcuts are migrated. +// typedSchemaContract is the private machine contract derived from the single +// public extension/command declaration. type typedSchemaContract struct { Name string `json:"name"` Description string `json:"description"` @@ -23,9 +22,9 @@ type typedSchemaNode struct { Hidden bool `json:"hidden,omitempty"` Deprecated string `json:"deprecated,omitempty"` Aliases *[]typedSchemaAlias `json:"aliases,omitempty"` - ValueSources []ValueSource `json:"value_sources,omitempty"` - Enum []JSONValue `json:"enum,omitempty"` - Default *JSONValue `json:"default,omitempty"` + ValueSources []typedValueSource `json:"value_sources,omitempty"` + Enum []typedJSONValue `json:"enum,omitempty"` + Default *typedJSONValue `json:"default,omitempty"` Format string `json:"format,omitempty"` Minimum *float64 `json:"minimum,omitempty"` Maximum *float64 `json:"maximum,omitempty"` @@ -37,49 +36,49 @@ type typedSchemaNode struct { Properties map[string]typedSchemaNode `json:"properties,omitempty"` Items *typedSchemaNode `json:"items,omitempty"` OneOf []typedSchemaNode `json:"oneOf,omitempty"` - Const *JSONValue `json:"const,omitempty"` - AdditionalProperties *JSONValue `json:"additionalProperties,omitempty"` + Const *typedJSONValue `json:"const,omitempty"` + AdditionalProperties *typedJSONValue `json:"additionalProperties,omitempty"` } type typedSchemaAlias struct { - Name string `json:"name"` - Flag string `json:"flag"` - Mode FlagAliasMode `json:"mode"` - Conflict AliasConflictPolicy `json:"conflict,omitempty"` - Hidden bool `json:"hidden,omitempty"` - Deprecated bool `json:"deprecated,omitempty"` + Name string `json:"name"` + Flag string `json:"flag"` + Mode typedFlagAliasMode `json:"mode"` + Conflict typedAliasConflictPolicy `json:"conflict,omitempty"` + Hidden bool `json:"hidden,omitempty"` + Deprecated bool `json:"deprecated,omitempty"` } type typedSchemaMeta struct { EnvelopeVersion string `json:"envelope_version"` AccessTokens []string `json:"access_tokens"` Danger bool `json:"danger"` - Risk Risk `json:"risk"` + Risk typedRisk `json:"risk"` Authorization typedSchemaAuthorization `json:"authorization"` CLI typedSchemaCLI `json:"cli"` - Relations []Relation `json:"relations"` + Relations []typedRelation `json:"relations"` Formats []typedSchemaFormat `json:"formats"` Outcomes typedSchemaOutcomes `json:"outcomes"` ResultMeta *typedSchemaNode `json:"result_meta,omitempty"` - Artifacts []ArtifactDefinition `json:"artifacts"` + Artifacts []any `json:"artifacts"` } type typedSchemaAuthorization struct { - Identities map[Identity]IdentityAuthorization `json:"identities"` + Identities map[typedIdentity]typedIdentityAuthorization `json:"identities"` } type typedSchemaCLI struct { Flags map[string]typedSchemaSystemFlag `json:"flags"` Constraints []typedSchemaCLIConstraint `json:"constraints"` } type typedSchemaSystemFlag struct { - Flag string `json:"flag"` - Short string `json:"short,omitempty"` - Role string `json:"role"` - Type string `json:"type"` - Default *JSONValue `json:"default,omitempty"` - Enum []string `json:"enum,omitempty"` - AliasFor string `json:"alias_for,omitempty"` - Omitted string `json:"omitted,omitempty"` + Flag string `json:"flag"` + Short string `json:"short,omitempty"` + Role string `json:"role"` + Type string `json:"type"` + Default *typedJSONValue `json:"default,omitempty"` + Enum []string `json:"enum,omitempty"` + AliasFor string `json:"alias_for,omitempty"` + Omitted string `json:"omitted,omitempty"` } type typedSchemaCLIConstraint struct { Kind string `json:"kind"` @@ -101,17 +100,17 @@ type typedSchemaOutcomes struct { PartialFailure typedSchemaOutcome `json:"partial_failure"` } type typedSchemaOutcome struct { - Supported bool `json:"supported"` - EnvelopeOK bool `json:"envelope_ok"` - ExitCode int `json:"exit_code"` - Stdout string `json:"stdout,omitempty"` - FailedItems *FailedItemDefinition `json:"failed_items,omitempty"` + Supported bool `json:"supported"` + EnvelopeOK bool `json:"envelope_ok"` + ExitCode int `json:"exit_code"` + Stdout string `json:"stdout,omitempty"` + FailedItems any `json:"failed_items,omitempty"` } func buildTypedSchemaContract(command *compiledCommand) typedSchemaContract { required := []string{} input := typedSchemaNode{Type: "object", Required: &required, Properties: make(map[string]typedSchemaNode)} - closed := JSONValue(false) + closed := typedJSONValue(false) input.AdditionalProperties = &closed for _, field := range command.fields { node := schemaNodeFromShape(field.shape) @@ -124,9 +123,9 @@ func buildTypedSchemaContract(command *compiledCommand) typedSchemaContract { for _, alias := range field.cli.Aliases { *node.Aliases = append(*node.Aliases, typedSchemaAlias{Name: alias.Name, Flag: "--" + alias.Name, Mode: alias.Mode, Conflict: alias.Conflict, Hidden: alias.Hidden, Deprecated: alias.Deprecated}) } - node.ValueSources = append([]ValueSource(nil), field.cli.ValueSources...) + node.ValueSources = append([]typedValueSource(nil), field.cli.ValueSources...) if len(node.ValueSources) == 0 { - node.ValueSources = []ValueSource{SourceFlag} + node.ValueSources = []typedValueSource{typedSourceFlag} } if field.defaultValue.Set { value := field.defaultValue.Value @@ -138,49 +137,41 @@ func buildTypedSchemaContract(command *compiledCommand) typedSchemaContract { } } schemaFormats := typedOutputFormats(command) - authorizationIdentities := make(map[Identity]IdentityAuthorization, len(command.metadata.Authorization.Identities)) + authorizationIdentities := make(map[typedIdentity]typedIdentityAuthorization, len(command.metadata.Authorization.Identities)) for identity, authorization := range command.metadata.Authorization.Identities { authorization.RequiredScopes = append([]string{}, authorization.RequiredScopes...) - authorization.ConditionalScopes = append([]ConditionalScope{}, authorization.ConditionalScopes...) + authorization.ConditionalScopes = append([]typedConditionalScope{}, authorization.ConditionalScopes...) authorizationIdentities[identity] = authorization } accessTokens := make([]string, 0, len(command.metadata.Authorization.Identities)) - for _, identity := range []Identity{IdentityBot, IdentityUser} { + for _, identity := range []typedIdentity{typedIdentityBot, typedIdentityUser} { if _, ok := command.metadata.Authorization.Identities[identity]; ok { accessTokens = append(accessTokens, string(identity)) } } businessFlags := legacyFlagsFromCompiled(command.fields) - partial := typedSchemaOutcome{Supported: false} - if definition := command.output.Outcomes.PartialFailure; definition != nil { - partial = typedSchemaOutcome{Supported: true, EnvelopeOK: false, ExitCode: definition.ExitCode, Stdout: "result_envelope", FailedItems: definition.FailedItems} - } return typedSchemaContract{ - Name: command.metadata.Service + " " + command.metadata.Command, + Name: string(command.metadata.Service) + " " + command.metadata.Command, Description: command.metadata.Description, InputSchema: input, OutputSchema: schemaNodeFromShape(command.dataShape), Meta: typedSchemaMeta{ - EnvelopeVersion: "1.0", AccessTokens: accessTokens, Danger: command.metadata.Risk == RiskHighRiskWrite, Risk: command.metadata.Risk, + EnvelopeVersion: "1.0", AccessTokens: accessTokens, Danger: command.metadata.Risk == typedRiskHighRiskWrite, Risk: command.metadata.Risk, Authorization: typedSchemaAuthorization{Identities: authorizationIdentities}, - CLI: defaultTypedCLI(accessTokens, businessFlags), Relations: append([]Relation{}, commandInputRelations(command)...), Formats: schemaFormats, - Outcomes: typedSchemaOutcomes{Success: typedSchemaOutcome{Supported: true, EnvelopeOK: true, ExitCode: 0, Stdout: "result_envelope"}, PartialFailure: partial}, + CLI: defaultTypedCLI(accessTokens, businessFlags), Relations: append([]typedRelation{}, commandInputRelations(command)...), Formats: schemaFormats, + Outcomes: typedSchemaOutcomes{Success: typedSchemaOutcome{Supported: true, EnvelopeOK: true, ExitCode: 0, Stdout: "result_envelope"}, PartialFailure: typedSchemaOutcome{Supported: false}}, ResultMeta: typedResultMetaSchema(command.output.Meta), - Artifacts: append([]ArtifactDefinition{}, command.output.Artifacts...), + Artifacts: []any{}, }, } } -func typedResultMetaSchema(definition ResultMetaDefinition) *typedSchemaNode { - if !definition.Count && !definition.Pagination { +func typedResultMetaSchema(definition typedResultMetaDefinition) *typedSchemaNode { + if !definition.Pagination { return nil } - additional := JSONValue(false) + additional := typedJSONValue(false) result := &typedSchemaNode{Type: "object", Properties: make(map[string]typedSchemaNode), AdditionalProperties: &additional} - if definition.Count { - zero := float64(0) - result.Properties["count"] = typedSchemaNode{Type: "integer", Minimum: &zero, Description: "number of returned business records"} - } if definition.Pagination { zero, one := float64(0), float64(1) required := []string{"complete", "pages", "items"} @@ -195,35 +186,35 @@ func typedResultMetaSchema(definition ResultMetaDefinition) *typedSchemaNode { return result } -func commandInputRelations(command *compiledCommand) []Relation { - result := make([]Relation, 0, len(command.relations)) +func commandInputRelations(command *compiledCommand) []typedRelation { + result := make([]typedRelation, 0, len(command.relations)) for _, relation := range command.relations { params := make([]string, 0, len(relation.fields)) for _, index := range relation.fields { params = append(params, command.fields[index].name) } - result = append(result, Relation{Kind: relation.kind, Params: params, Presence: relation.presence, Stage: relation.stage}) + result = append(result, typedRelation{Kind: relation.kind, Params: params, Presence: relation.presence, Stage: relation.stage}) } return result } -func schemaNodeFromShape(shape ValueShape) typedSchemaNode { +func schemaNodeFromShape(shape typedValueShape) typedSchemaNode { switch value := shape.(type) { case anyJSONShape: return typedSchemaNode{} - case StringShape: + case typedStringShape: node := typedSchemaNode{Type: "string", Format: value.Format, MinLength: value.MinLength, MaxLength: value.MaxLength} for _, item := range value.Enum { node.Enum = append(node.Enum, item) } return node - case BooleanShape: + case typedBooleanShape: node := typedSchemaNode{Type: "boolean"} for _, item := range value.Enum { node.Enum = append(node.Enum, item) } return node - case IntegerShape: + case typedIntegerShape: node := typedSchemaNode{Type: "integer"} if value.Minimum != nil { v := float64(*value.Minimum) @@ -237,22 +228,22 @@ func schemaNodeFromShape(shape ValueShape) typedSchemaNode { node.Enum = append(node.Enum, item) } return node - case NumberShape: + case typedNumberShape: node := typedSchemaNode{Type: "number", Minimum: value.Minimum, Maximum: value.Maximum} for _, item := range value.Enum { node.Enum = append(node.Enum, item) } return node - case NullShape: + case typedNullShape: return typedSchemaNode{Type: "null"} - case ConstShape: + case typedConstShape: item := value.Value return typedSchemaNode{Const: &item} - case ArrayShape: + case typedArrayShape: item := schemaNodeFromShape(value.Items) return typedSchemaNode{Type: "array", Items: &item, MinItems: value.MinItems, MaxItems: value.MaxItems} - case ObjectShape: - additional := JSONValue(value.AdditionalProperties) + case typedObjectShape: + additional := typedJSONValue(value.AdditionalProperties) if value.AdditionalPropertiesShape != nil { additional = schemaNodeFromShape(value.AdditionalPropertiesShape) } @@ -267,7 +258,7 @@ func schemaNodeFromShape(shape ValueShape) typedSchemaNode { } } return node - case OneOfShape: + case typedOneOfShape: node := typedSchemaNode{} for _, variant := range value.Variants { node.OneOf = append(node.OneOf, schemaNodeFromShape(variant)) @@ -280,7 +271,7 @@ func schemaNodeFromShape(shape ValueShape) typedSchemaNode { func typedOutputFormats(command *compiledCommand) []typedSchemaFormat { jsonSelectors := []string{"json"} - if command.output.Mode == OutputFixedJSON { + if command.output.Mode == typedOutputFixedJSON { jsonSelectors = []string{"json", "pretty", "table", "ndjson", "csv"} } else if command.hooks.renderers["pretty"] == nil { // Legacy OutFormat accepts --format pretty without a renderer and falls @@ -290,7 +281,7 @@ func typedOutputFormats(command *compiledCommand) []typedSchemaFormat { } escapeHTML := !command.output.DisableHTMLEscaping formats := []typedSchemaFormat{{Name: "json", Default: true, MediaType: "application/json", SelectedBy: jsonSelectors, EscapeHTML: &escapeHTML}} - if command.output.Mode == OutputFixedJSON { + if command.output.Mode == typedOutputFixedJSON { return formats } if command.hooks.renderers["pretty"] != nil { @@ -304,13 +295,13 @@ func typedOutputFormats(command *compiledCommand) []typedSchemaFormat { } func defaultTypedCLI(identities []string, businessFlags []Flag) typedSchemaCLI { - falseValue, jsonValue := JSONValue(false), JSONValue("json") + falseValue, jsonValue := typedJSONValue(false), typedJSONValue("json") format := typedSchemaSystemFlag{Flag: "--format", Role: "output", Type: "string", Default: &jsonValue, Enum: []string{"json", "pretty", "table", "ndjson", "csv"}} for _, flag := range businessFlags { if flag.Name != "format" { continue } - value := JSONValue(flag.Default) + value := typedJSONValue(flag.Default) format.Default = &value format.Enum = append([]string(nil), flag.Enum...) break diff --git a/shortcuts/common/typed_schema_export.go b/shortcuts/common/typed_schema_export.go index f34ecc996b..3188add949 100644 --- a/shortcuts/common/typed_schema_export.go +++ b/shortcuts/common/typed_schema_export.go @@ -3,8 +3,11 @@ package common -// ShortcutSchema returns the immutable schema contract of a Typed Shortcut. -func ShortcutSchema(shortcut Shortcut) (any, bool) { +import "github.com/larksuite/cli/internal/commandbridge" + +// ShortcutSchema returns the immutable schema contract of a hosted command. +// The internal token keeps schema inspection out of common's public surface. +func ShortcutSchema(shortcut Shortcut, _ commandbridge.Access) (any, bool) { if shortcut.typed == nil { return nil, false } diff --git a/shortcuts/common/typed_shape.go b/shortcuts/common/typed_shape.go index e09880d729..23e513397a 100644 --- a/shortcuts/common/typed_shape.go +++ b/shortcuts/common/typed_shape.go @@ -1,71 +1,137 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT +//nolint:forbidigo // Shape-lowering errors are intermediate build diagnostics wrapped by the command-set startup guard. package common -// ValueShape is the closed set of JSON shapes accepted by Typed Shortcut. -type ValueShape interface{ valueShape() } +import ( + "fmt" -type StringShape struct { + "github.com/larksuite/cli/extension/command" +) + +// typedValueShape is the normalized private schema IR. Public authoring shapes +// are lowered into it once; runtime validation and schema rendering never read +// the public AST directly. +type typedValueShape interface{ typedValueShape() } + +type typedStringShape struct { Enum []string Format string MinLength *int MaxLength *int } -type BooleanShape struct{ Enum []bool } -type IntegerShape struct { +type typedBooleanShape struct{ Enum []bool } +type typedIntegerShape struct { Enum []int64 Minimum *int64 Maximum *int64 } -type NumberShape struct { +type typedNumberShape struct { Enum []float64 Minimum *float64 Maximum *float64 } -type NullShape struct{} -type ConstShape struct{ Value JSONValue } -type ArrayShape struct { - Items ValueShape +type typedNullShape struct{} +type typedConstShape struct{ Value typedJSONValue } +type typedArrayShape struct { + Items typedValueShape MinItems *int MaxItems *int } -type ObjectShape struct { - Fields []ValueField +type typedObjectShape struct { + Fields []typedValueField AdditionalProperties bool - AdditionalPropertiesShape ValueShape + AdditionalPropertiesShape typedValueShape } -type ValueField struct { +type typedValueField struct { Name string Description string Required bool - Shape ValueShape + Shape typedValueShape } -type OneOfShape struct{ Variants []ValueShape } +type typedOneOfShape struct{ Variants []typedValueShape } -// anyJSONShape is inferred only for Data=any. It is intentionally unexported: -// arbitrary JSON is a migration escape hatch for an established standard -// envelope that already forwarded every JSON value, not a general input shape. +// anyJSONShape is inferred only for Data=any and interface-valued fields. type anyJSONShape struct{} -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() {} -func (anyJSONShape) valueShape() {} +func (typedStringShape) typedValueShape() {} +func (typedBooleanShape) typedValueShape() {} +func (typedIntegerShape) typedValueShape() {} +func (typedNumberShape) typedValueShape() {} +func (typedNullShape) typedValueShape() {} +func (typedConstShape) typedValueShape() {} +func (typedArrayShape) typedValueShape() {} +func (typedObjectShape) typedValueShape() {} +func (typedOneOfShape) typedValueShape() {} +func (anyJSONShape) typedValueShape() {} -type DataDefinition struct { - Shape ValueShape - Overrides []DataField -} +type typedDataDefinition = command.DataDefinition +type typedDataField = command.DataField -type DataField struct { - Path string - Description string - Shape ValueShape +func lowerAuthoringShape(shape command.ValueShape) (typedValueShape, error) { + switch value := shape.(type) { + case nil: + return nil, nil + case command.StringShape: + return typedStringShape{ + Enum: append([]string(nil), value.Enum...), Format: value.Format, + MinLength: cloneScalarPointer(value.MinLength), MaxLength: cloneScalarPointer(value.MaxLength), + }, nil + case command.BooleanShape: + return typedBooleanShape{Enum: append([]bool(nil), value.Enum...)}, nil + case command.IntegerShape: + return typedIntegerShape{ + Enum: append([]int64(nil), value.Enum...), + Minimum: cloneScalarPointer(value.Minimum), Maximum: cloneScalarPointer(value.Maximum), + }, nil + case command.NumberShape: + return typedNumberShape{ + Enum: append([]float64(nil), value.Enum...), + Minimum: cloneScalarPointer(value.Minimum), Maximum: cloneScalarPointer(value.Maximum), + }, nil + case command.NullShape: + return typedNullShape{}, nil + case command.ConstShape: + return typedConstShape{Value: cloneJSONValue(value.Value)}, nil + case command.ArrayShape: + items, err := lowerAuthoringShape(value.Items) + if err != nil { + return nil, err + } + return typedArrayShape{ + Items: items, MinItems: cloneScalarPointer(value.MinItems), MaxItems: cloneScalarPointer(value.MaxItems), + }, nil + case command.ObjectShape: + fields := make([]typedValueField, len(value.Fields)) + for index, field := range value.Fields { + fieldShape, err := lowerAuthoringShape(field.Shape) + if err != nil { + return nil, fmt.Errorf("field %q: %w", field.Name, err) + } + fields[index] = typedValueField{ + Name: field.Name, Description: field.Description, Required: field.Required, Shape: fieldShape, + } + } + additional, err := lowerAuthoringShape(value.AdditionalPropertiesShape) + if err != nil { + return nil, err + } + return typedObjectShape{ + Fields: fields, AdditionalProperties: value.AdditionalProperties, + AdditionalPropertiesShape: additional, + }, nil + case command.OneOfShape: + variants := make([]typedValueShape, len(value.Variants)) + for index, variant := range value.Variants { + lowered, err := lowerAuthoringShape(variant) + if err != nil { + return nil, fmt.Errorf("variant %d: %w", index, err) + } + variants[index] = lowered + } + return typedOneOfShape{Variants: variants}, nil + default: + return nil, fmt.Errorf("unsupported public shape %T", shape) + } } diff --git a/shortcuts/common/typed_test_authoring_test.go b/shortcuts/common/typed_test_authoring_test.go new file mode 100644 index 0000000000..227d1de248 --- /dev/null +++ b/shortcuts/common/typed_test_authoring_test.go @@ -0,0 +1,129 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +// The former generic authoring facade remains test-only so compiler and runner +// unit tests can build compact fixtures. Production authors have one surface: +// extension/command. + +import ( + "context" + "fmt" + "io" + "reflect" + "strings" +) + +type typedDefinition[Args any, Data any] struct { + Metadata typedCommandMetadata + Input typedInputDefinition + Output typedOutputDefinition + Hooks typedHooks[Args, Data] +} + +type typedHooks[Args any, Data any] struct { + Normalize func(context.Context, typedRuntimeContext, *Args) error + Validate func(context.Context, typedRuntimeContext, *Args) error + DryRun func(context.Context, typedRuntimeContext, *Args) *DryRunAPI + Execute func(context.Context, typedRuntimeContext, *Args) (typedResult[Data], error) + Renderers map[string]typedRenderer[Data] +} + +type typedRenderer[Data any] func(io.Writer, Data) error + +type typedResult[Data any] struct { + Data Data + Outcome typedOutcomeKind + Meta *typedResultMeta +} + +func typedSuccess[Data any](data Data) typedResult[Data] { + return typedResult[Data]{Data: data, Outcome: typedOutcomeSuccess} +} + +func (result typedResult[Data]) WithMeta(meta typedResultMeta) typedResult[Data] { + result.Meta = &meta + return result +} + +func typedPaginationResultMeta(pagination *typedResultPaginationMeta) typedResultMeta { + return typedResultMeta{Pagination: pagination} +} + +func defineTypedShortcut[Args any, Data any](definition typedDefinition[Args, Data]) Shortcut { + compiled, err := compileDefinition(definition) + if err != nil { + service := strings.TrimSpace(string(definition.Metadata.Service)) + command := strings.TrimSpace(definition.Metadata.Command) + if service == "" { + service = "" + } + if command == "" { + command = "" + } + panic(fmt.Sprintf("typed shortcut %s %s: %v", service, command, err)) + } + shortcut := shortcutFromCompiled(compiled) + if err := validateTypedFlagMountPlan(compiled, shortcut.PrintFlagSchema != nil, typedRisk(shortcut.Risk)); err != nil { + panic(fmt.Sprintf("typed shortcut %s %s: %v", compiled.metadata.Service, compiled.metadata.Command, err)) + } + return shortcut +} + +func compileDefinition[Args any, Data any](definition typedDefinition[Args, Data]) (*compiledCommand, error) { + if definition.Hooks.Execute == nil { + return nil, fmt.Errorf("Hooks.Execute is required") + } + return compileDefinitionParts( + definition.Metadata, + definition.Input, + definition.Output, + reflect.TypeFor[Args](), + reflect.TypeFor[Data](), + adaptTestHooks(definition.Hooks), + testRendererMarkers(definition.Hooks.Renderers), + false, + ) +} + +func adaptTestHooks[Args any, Data any](hooks typedHooks[Args, Data]) compiledHooks { + adapted := compiledHooks{newArgs: func() any { return new(Args) }} + if hooks.Normalize != nil { + adapted.normalize = func(ctx context.Context, runtime typedRuntimeContext, args any) error { + return hooks.Normalize(ctx, runtime, args.(*Args)) + } + } + if hooks.Validate != nil { + adapted.validate = func(ctx context.Context, runtime typedRuntimeContext, args any) error { + return hooks.Validate(ctx, runtime, args.(*Args)) + } + } + if hooks.DryRun != nil { + adapted.dryRun = func(ctx context.Context, runtime typedRuntimeContext, args any) (*DryRunAPI, error) { + return hooks.DryRun(ctx, runtime, args.(*Args)), nil + } + } + adapted.execute = func(ctx context.Context, runtime typedRuntimeContext, args any) (compiledResult, error) { + result, err := hooks.Execute(ctx, runtime, args.(*Args)) + return compiledResult{data: result.Data, outcome: result.Outcome, meta: result.Meta}, err + } + if len(hooks.Renderers) > 0 { + adapted.renderers = make(map[string]func(io.Writer, any) error, len(hooks.Renderers)) + for name, renderer := range hooks.Renderers { + captured := renderer + adapted.renderers[name] = func(writer io.Writer, data any) error { + return captured(writer, data.(Data)) + } + } + } + return adapted +} + +func testRendererMarkers[Data any](renderers map[string]typedRenderer[Data]) map[string]rendererMarker { + markers := make(map[string]rendererMarker, len(renderers)) + for name, renderer := range renderers { + markers[name] = rendererMarker{isNil: renderer == nil} + } + return markers +} diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index b787527b40..adef0e329d 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -96,9 +96,8 @@ type Shortcut struct { // tweak the command; cmd.Parent() is available at this point. PostMount func(cmd *cobra.Command) - // typed is the fully compiled contract produced by Define. It remains - // private so legacy registry and public Schema cannot observe a partially - // migrated Typed Shortcut. + // typed is the private executable contract produced by the internal command + // host from extension/command. It never forms a second authoring surface. typed *compiledCommand } diff --git a/shortcuts/register.go b/shortcuts/register.go index 2d1aabd36a..1a1feca389 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -14,6 +14,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdmeta" "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/commandbridge" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/deprecation" "github.com/larksuite/cli/internal/registry" @@ -107,13 +108,13 @@ func init() { // //go:noinline func AllShortcuts() []common.Shortcut { - return common.CloneShortcuts(allShortcuts) + return common.CloneHostedShortcuts(allShortcuts, commandbridge.Access{}) } // AllShortcutsWithExternal returns one isolated shortcut snapshot after validating external path collisions. func AllShortcutsWithExternal(commands []common.Shortcut) ([]common.Shortcut, error) { registered := AllShortcuts() - external := common.CloneShortcuts(commands) + external := common.CloneHostedShortcuts(commands, commandbridge.Access{}) paths := make(map[string]struct{}, len(registered)+len(external)) for _, shortcut := range registered { paths[shortcut.Service+" "+shortcut.Command] = struct{}{} From 9953cebd3eb3f8b2a9c51f9ea5f277d9bdfbdf11 Mon Sep 17 00:00:00 2001 From: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:11:11 +0800 Subject: [PATCH 47/47] revert(schema): keep the schema command blind to shortcuts The schema command serves the generated API catalog only, matching main. Drop the shortcut contract lookup and completion from cmd/schema and restore the constructor surface. ShortcutSchema stays on the sealed commandbridge surface, where the host compiler tests assert the contract; the CLI itself no longer consumes it. The surface scenario and wrapper e2e pin the boundary from the other side: schema resolution and completion must not see mounted shortcuts. --- cmd/build.go | 4 +- cmd/command_sets_test.go | 26 ++--- cmd/schema/schema.go | 155 +++----------------------- cmd/schema/schema_test.go | 76 ------------- extension/command/wrapper_e2e_test.go | 4 - 5 files changed, 28 insertions(+), 237 deletions(-) diff --git a/cmd/build.go b/cmd/build.go index aa325093f7..9b30053f05 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -303,9 +303,9 @@ func buildInternalWithConfig(ctx context.Context, inv cmdutil.InvocationContext, rootCmd.AddCommand(doctor.NewCmdDoctorWithRecovery(f, runtime.recovery)) rootCmd.AddCommand(whoami.NewCmdWhoamiWithRecovery(f, runtime.recovery)) rootCmd.AddCommand(api.NewCmdApiWithContext(ctx, f, nil)) - rootCmd.AddCommand(schema.NewCmdSchemaWithVisibilityAndShortcuts(f, func(path []string) bool { + rootCmd.AddCommand(schema.NewCmdSchemaWithVisibility(f, func(path []string) bool { return runtime.surface.CanReference(surface.CommandID(strings.Join(path, "/"))) - }, registeredShortcuts, nil)) + }, nil)) rootCmd.AddCommand(completion.NewCmdCompletion(f)) rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f)) rootCmd.AddCommand(cmdevent.NewCmdEvents(f)) diff --git a/cmd/command_sets_test.go b/cmd/command_sets_test.go index 82bfe6165f..29f9df7176 100644 --- a/cmd/command_sets_test.go +++ b/cmd/command_sets_test.go @@ -6,7 +6,6 @@ package cmd import ( "bytes" "context" - "encoding/json" "errors" "os" "os/exec" @@ -181,32 +180,25 @@ func TestCommandSetSubprocess(t *testing.T) { 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 external schema: %v\nstderr: %s", err, stderr.String()) + t.Fatalf("complete schema path: %v\nstderr: %s", err, stderr.String()) } - if !strings.Contains(stdout.String(), "+business-surface") { - t.Fatalf("external schema is missing from shell completion: %s", stdout.String()) + 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 external command: %v\nstderr: %s", err, stderr.String()) - } - var schema struct { - Name string `json:"name"` - InputSchema json.RawMessage `json:"inputSchema"` - OutputSchema json.RawMessage `json:"outputSchema"` - } - if err := json.Unmarshal(stdout.Bytes(), &schema); err != nil { - t.Fatalf("decode external schema: %v\n%s", err, stdout.String()) + if _, err := root.ExecuteC(); err == nil { + t.Fatalf("schema resolved an external command: %s", stdout.String()) } - if schema.Name != "im +business-surface" || len(schema.InputSchema) == 0 || len(schema.OutputSchema) == 0 || - string(schema.InputSchema) == "null" || string(schema.OutputSchema) == "null" { - t.Fatalf("external schema = %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/schema/schema.go b/cmd/schema/schema.go index 5032780faf..b4ead42b3b 100644 --- a/cmd/schema/schema.go +++ b/cmd/schema/schema.go @@ -7,21 +7,16 @@ import ( "context" "errors" "io" - "slices" - "sort" "strings" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/apicatalog" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/commandbridge" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/meta" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/internal/schema" - "github.com/larksuite/cli/shortcuts" - "github.com/larksuite/cli/shortcuts/common" "github.com/spf13/cobra" ) @@ -49,22 +44,16 @@ type SchemaOptions struct { // NewCmdSchema creates the schema command. If runF is non-nil it is called instead of the default runner (test hook). func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Command { - return NewCmdSchemaWithVisibilityAndShortcuts(f, nil, shortcuts.AllShortcuts(), runF) + return NewCmdSchemaWithVisibility(f, nil, runF) } // NewCmdSchemaWithVisibility creates the schema command projected through one -// visibility predicate, resolving shortcuts from the registered set. Retained at -// its established signature: CommandVisibility is an ordinary exported function -// type, so callers outside this module can and do construct one. -func NewCmdSchemaWithVisibility(f *cmdutil.Factory, visibility CommandVisibility, runF func(*SchemaOptions) error) *cobra.Command { - return NewCmdSchemaWithVisibilityAndShortcuts(f, visibility, shortcuts.AllShortcuts(), runF) -} - -// NewCmdSchemaWithVisibilityAndShortcuts creates schema commands from one build-local shortcut snapshot. -func NewCmdSchemaWithVisibilityAndShortcuts( +// build-local command surface. Existing callers should use NewCmdSchema; the +// root builder uses this form so schema execution and completion share the +// exact presentation plan captured by that Cobra tree. +func NewCmdSchemaWithVisibility( f *cmdutil.Factory, visibility CommandVisibility, - registered []common.Shortcut, runF func(*SchemaOptions) error, ) *cobra.Command { opts := &SchemaOptions{Factory: f} @@ -79,7 +68,7 @@ func NewCmdSchemaWithVisibilityAndShortcuts( if runF != nil { return runF(opts) } - return schemaRunWithVisibilityAndShortcuts(opts, visibility, registered) + return schemaRunWithVisibility(opts, visibility) }, } cmdutil.DisableAuthCheck(cmd) @@ -94,7 +83,7 @@ func NewCmdSchemaWithVisibilityAndShortcuts( _ = cmd.Flags().MarkHidden("json") _ = cmd.Flags().MarkHidden("as") - cmd.ValidArgsFunction = completeSchemaPath(f, visibility, registered) + cmd.ValidArgsFunction = completeSchemaPath(f, visibility) cmdutil.SetRisk(cmd, cmdutil.RiskRead) return cmd @@ -106,13 +95,11 @@ func NewCmdSchemaWithVisibilityAndShortcuts( func completeSchemaPath( f *cmdutil.Factory, visibility CommandVisibility, - registered []common.Shortcut, ) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { mode := f.ResolveStrictMode(cmd.Context()) catalog := projectSchemaCatalog(registry.SchemaCatalog(), visibility) completions, noSpace := catalog.Complete(args, toComplete, registry.FilterForStrictMode(mode)) - completions = mergeSchemaCompletions(completions, shortcutSchemaCompletionsFrom(registered, args, toComplete, visibility, mode)) directive := cobra.ShellCompDirectiveNoFileComp if noSpace { directive |= cobra.ShellCompDirectiveNoSpace @@ -121,34 +108,33 @@ func completeSchemaPath( } } -func schemaRunWithVisibilityAndShortcuts(opts *SchemaOptions, visibility CommandVisibility, registered []common.Shortcut) error { +func schemaRunWithVisibility(opts *SchemaOptions, visibility CommandVisibility) error { out := opts.Factory.IOStreams.Out mode := opts.Factory.ResolveStrictMode(opts.Ctx) - return runSchemaCatalogWithShortcuts(out, apicatalog.ParsePath(opts.Args), mode, registry.SchemaCatalog(), visibility, registered) + return runSchemaWithVisibility(out, apicatalog.ParsePath(opts.Args), mode, visibility) } -func runSchemaCatalog( +// runSchemaWithVisibility resolves the path through the schema catalog and renders the +// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and +// schema owns rendering (Envelope/Envelopes); this adapter only chooses the +// output shape — a single resolved method renders as one envelope object, +// anything broader as an array — and maps resolve failures to hints. +func runSchemaWithVisibility( out io.Writer, parts []string, mode core.StrictMode, - catalog apicatalog.Catalog, visibility CommandVisibility, ) error { - return runSchemaCatalogWithShortcuts(out, parts, mode, catalog, visibility, shortcuts.AllShortcuts()) + return runSchemaCatalog(out, parts, mode, registry.SchemaCatalog(), visibility) } -func runSchemaCatalogWithShortcuts( +func runSchemaCatalog( out io.Writer, parts []string, mode core.StrictMode, catalog apicatalog.Catalog, visibility CommandVisibility, - registered []common.Shortcut, ) error { - if contract, ok := resolveShortcutSchemaFrom(registered, parts, visibility, mode); ok { - output.PrintJson(out, contract) - return nil - } // Test the source catalog before presentation projection. A distribution // that intentionally conceals every generated method still has metadata; // bare `schema` should render an empty list rather than claim metadata is @@ -178,113 +164,6 @@ func runSchemaCatalogWithShortcuts( return nil } -func resolveShortcutSchemaFrom( - registered []common.Shortcut, - parts []string, - visibility CommandVisibility, - mode core.StrictMode, -) (any, bool) { - if len(parts) != 2 || !strings.HasPrefix(parts[1], "+") { - return nil, false - } - for _, shortcut := range registered { - if shortcut.Service != parts[0] || shortcut.Command != parts[1] { - continue - } - if !shortcutSchemaVisible(shortcut, visibility, mode) { - return nil, false - } - return common.ShortcutSchema(shortcut, commandbridge.Access{}) - } - return nil, false -} - -func shortcutSchemaCompletionsFrom( - registered []common.Shortcut, - args []string, - toComplete string, - visibility CommandVisibility, - mode core.StrictMode, -) []string { - if len(args) == 0 && strings.Contains(toComplete, ".") { - parts := strings.SplitN(toComplete, ".", 2) - return shortcutCommandCompletions(registered, parts[0], parts[1], parts[0]+".", visibility, mode) - } - if len(args) == 0 { - services := make(map[string]struct{}) - for _, shortcut := range registered { - if !strings.HasPrefix(shortcut.Service, toComplete) || !shortcutSchemaVisible(shortcut, visibility, mode) { - continue - } - if _, ok := common.ShortcutSchema(shortcut, commandbridge.Access{}); ok { - services[shortcut.Service] = struct{}{} - } - } - result := make([]string, 0, len(services)) - for service := range services { - result = append(result, service) - } - sort.Strings(result) - return result - } - if len(args) == 1 { - return shortcutCommandCompletions(registered, args[0], toComplete, "", visibility, mode) - } - return nil -} - -func shortcutCommandCompletions( - registered []common.Shortcut, - service string, - prefix string, - outputPrefix string, - visibility CommandVisibility, - mode core.StrictMode, -) []string { - var result []string - for _, shortcut := range registered { - if shortcut.Service != service || !strings.HasPrefix(shortcut.Command, prefix) || !shortcutSchemaVisible(shortcut, visibility, mode) { - continue - } - if _, ok := common.ShortcutSchema(shortcut, commandbridge.Access{}); ok { - result = append(result, outputPrefix+shortcut.Command+"\t"+shortcut.Description) - } - } - sort.Strings(result) - return result -} - -func shortcutSchemaVisible(shortcut common.Shortcut, visibility CommandVisibility, mode core.StrictMode) bool { - if shortcut.Hidden || (visibility != nil && !visibility([]string{shortcut.Service, shortcut.Command})) { - return false - } - if !mode.IsActive() { - return true - } - identities := shortcut.AuthTypes - if len(identities) == 0 { - identities = []string{string(core.AsUser)} - } - return slices.Contains(identities, string(mode.ForcedIdentity())) -} - -func mergeSchemaCompletions(groups ...[]string) []string { - seen := make(map[string]struct{}) - var result []string - for _, group := range groups { - for _, candidate := range group { - name := strings.SplitN(candidate, "\t", 2)[0] - if _, ok := seen[name]; ok { - continue - } - seen[name] = struct{}{} - result = append(result, candidate) - } - } - sort.Strings(result) - return result -} - // projectSchemaCatalog produces the metadata view corresponding to one final // command surface. It lives in cmd/schema so apicatalog remains a policy-free // navigation module. Resolve, broad listings, and Complete all consume the diff --git a/cmd/schema/schema_test.go b/cmd/schema/schema_test.go index 64cf5a5ecd..653b92c5be 100644 --- a/cmd/schema/schema_test.go +++ b/cmd/schema/schema_test.go @@ -5,7 +5,6 @@ package schema import ( "bytes" - "context" "encoding/json" "errors" "reflect" @@ -13,13 +12,10 @@ import ( "testing" "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/extension/command" "github.com/larksuite/cli/internal/apicatalog" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/commandhost" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/meta" - "github.com/larksuite/cli/shortcuts/common" ) func TestSchemaCmd_FlagParsing(t *testing.T) { @@ -40,78 +36,6 @@ func TestSchemaCmd_FlagParsing(t *testing.T) { } } -// mustCompileFixture compiles a business declaration through the production -// compiler. Schema discovery only sees typed shortcuts, and going through the -// host compiler keeps these fixtures on the same path a real distribution takes. -func mustCompileFixture[Args any, Data any](t *testing.T, definition command.Definition[Args, Data]) common.Shortcut { - t.Helper() - shortcut, err := commandhost.CompileDeclaration(command.Define(definition)) - if err != nil { - t.Fatalf("compile fixture: %v", err) - } - return shortcut -} - -func TestHiddenShortcutIsExcludedFromSchemaDiscovery(t *testing.T) { - type args struct { - Value string `flag:"value" schema:"required" doc:"fixture value"` - } - type data struct { - OK bool `json:"ok" schema:"required" doc:"success state"` - } - hidden := mustCompileFixture(t, command.Definition[args, data]{ - Metadata: command.CommandMetadata{ - Service: "hidden-fixture", Command: "+hidden-schema", Description: "Hidden schema fixture", 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{OK: true}), nil - }}, - }) - hidden.Hidden = true - registered := []common.Shortcut{hidden} - - if schema, ok := resolveShortcutSchemaFrom(registered, []string{hidden.Service, hidden.Command}, nil, core.StrictModeOff); ok || schema != nil { - t.Fatalf("hidden shortcut schema = %#v, visible = %v", schema, ok) - } - if completions := shortcutSchemaCompletionsFrom(registered, []string{hidden.Service}, "+hidden", nil, core.StrictModeOff); len(completions) != 0 { - t.Fatalf("hidden shortcut completions = %#v", completions) - } -} - -func TestShortcutSchemaDiscoveryHonorsStrictMode(t *testing.T) { - type args struct { - Value string `flag:"value" schema:"required" doc:"fixture value"` - } - type data struct { - OK bool `json:"ok" schema:"required" doc:"success state"` - } - userOnly := mustCompileFixture(t, command.Definition[args, data]{ - Metadata: command.CommandMetadata{ - Service: "strict-fixture", Command: "+user-schema", Description: "User schema fixture", 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{OK: true}), nil - }}, - }) - registered := []common.Shortcut{userOnly} - path := []string{userOnly.Service, userOnly.Command} - - if schema, ok := resolveShortcutSchemaFrom(registered, path, nil, core.StrictModeBot); ok || schema != nil { - t.Fatalf("bot strict mode schema = %#v, visible = %v", schema, ok) - } - if completions := shortcutSchemaCompletionsFrom(registered, []string{userOnly.Service}, "+user", nil, core.StrictModeBot); len(completions) != 0 { - t.Fatalf("bot strict mode completions = %#v", completions) - } - if schema, ok := resolveShortcutSchemaFrom(registered, path, nil, core.StrictModeUser); !ok || schema == nil { - t.Fatalf("user strict mode schema = %#v, visible = %v", schema, ok) - } - if completions := shortcutSchemaCompletionsFrom(registered, []string{userOnly.Service}, "+user", nil, core.StrictModeUser); len(completions) != 1 { - t.Fatalf("user strict mode completions = %#v", completions) - } -} - func TestSchemaCmd_OutputFlagsAcceptedForCompat(t *testing.T) { // Agents are habituated to --format/--json/--as from api/service commands. // schema must accept them without erroring and always emit the JSON envelope — diff --git a/extension/command/wrapper_e2e_test.go b/extension/command/wrapper_e2e_test.go index c7bbb72cc8..c844fe3db6 100644 --- a/extension/command/wrapper_e2e_test.go +++ b/extension/command/wrapper_e2e_test.go @@ -97,10 +97,6 @@ func TestExternalWrapperCommandSurface(t *testing.T) { t.Fatalf("wrapper note help = %s", noteHelp) } - schema := run("schema", "im", "+wrapper-read") - if !strings.Contains(schema, `"name": "im +wrapper-read"`) || !strings.Contains(schema, `"outputSchema"`) { - t.Fatalf("wrapper schema = %s", schema) - } completion := run("__complete", "im", "+wrap") if !strings.Contains(completion, "+wrapper-read") { t.Fatalf("wrapper completion = %s", completion)