diff --git a/cmd/api/api.go b/cmd/api/api.go index 1fe651d0..b6383707 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -57,6 +57,10 @@ func normalisePath(raw string) string { // NewCmdApi creates the api command. If runF is non-nil it is called instead of apiRun (test hook). func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command { + return NewCmdApiWithContext(context.Background(), f, runF) +} + +func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command { opts := &APIOptions{Factory: f} var asStr string @@ -79,7 +83,7 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON (supports - for stdin)") cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)") - cmd.Flags().StringVar(&asStr, "as", "auto", "identity type: user | bot | auto (default)") + cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr) cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses") cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages") cmd.Flags().IntVar(&opts.PageSize, "page-size", 0, "page size (0 = use API default)") @@ -96,9 +100,6 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command } return nil, cobra.ShellCompDirectiveNoFileComp } - _ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { - return []string{"user", "bot"}, cobra.ShellCompDirectiveNoFileComp - }) _ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp }) diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go index 9f5be702..e81cc984 100644 --- a/cmd/api/api_test.go +++ b/cmd/api/api_test.go @@ -180,6 +180,24 @@ func TestApiValidArgsFunction(t *testing.T) { } } +func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2, + }) + + cmd := NewCmdApi(f, nil) + flag := cmd.Flags().Lookup("as") + if flag == nil { + t.Fatal("expected --as flag to be registered") + } + if !flag.Hidden { + t.Fatal("expected --as flag to be hidden in strict mode") + } + if got := flag.DefValue; got != "bot" { + t.Fatalf("default value = %q, want %q", got, "bot") + } +} + func TestApiCmd_PageLimitDefault(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, diff --git a/cmd/build.go b/cmd/build.go new file mode 100644 index 00000000..c50d6ec1 --- /dev/null +++ b/cmd/build.go @@ -0,0 +1,132 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "io" + "os" + + "golang.org/x/term" + + "github.com/larksuite/cli/cmd/api" + "github.com/larksuite/cli/cmd/auth" + "github.com/larksuite/cli/cmd/completion" + cmdconfig "github.com/larksuite/cli/cmd/config" + "github.com/larksuite/cli/cmd/doctor" + "github.com/larksuite/cli/cmd/profile" + "github.com/larksuite/cli/cmd/schema" + "github.com/larksuite/cli/cmd/service" + cmdupdate "github.com/larksuite/cli/cmd/update" + "github.com/larksuite/cli/internal/build" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/keychain" + "github.com/larksuite/cli/shortcuts" + "github.com/spf13/cobra" +) + +// BuildOption configures optional aspects of the command tree construction. +type BuildOption func(*buildConfig) + +type buildConfig struct { + streams *cmdutil.IOStreams + keychain keychain.KeychainAccess + globals GlobalOptions +} + +// WithIO sets the IO streams for the CLI by wrapping raw reader/writers. +// Terminal detection is derived from the input's underlying *os.File, if any; +// non-file readers (bytes.Buffer, strings.Reader, …) produce IsTerminal=false. +func WithIO(in io.Reader, out, errOut io.Writer) BuildOption { + return func(c *buildConfig) { + isTerminal := false + if f, ok := in.(*os.File); ok { + isTerminal = term.IsTerminal(int(f.Fd())) + } + c.streams = &cmdutil.IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal} + } +} + +// WithKeychain sets the secret storage backend. If not provided, the platform keychain is used. +func WithKeychain(kc keychain.KeychainAccess) BuildOption { + return func(c *buildConfig) { + c.keychain = kc + } +} + +// HideProfile sets the visibility policy for the root-level --profile flag. +// When hide is true the flag stays registered (so existing invocations still +// parse) but is omitted from help and shell completion. Typically called as +// HideProfile(isSingleAppMode()). +func HideProfile(hide bool) BuildOption { + return func(c *buildConfig) { + c.globals.HideProfile = hide + } +} + +// Build constructs the full command tree without executing. +// Returns only the cobra.Command; Factory is internal. +// Use Execute for the standard production entry point. +func Build(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) *cobra.Command { + _, rootCmd := buildInternal(ctx, inv, opts...) + return rootCmd +} + +// buildInternal is a pure assembly function: it wires the command tree from +// inv and BuildOptions alone. Any state-dependent decision (disk, network, +// env) belongs in the caller and must be threaded in via BuildOption. +// +// Callers must supply WithIO; buildInternal intentionally does not default +// the streams so tests and alternative entry points can't silently inherit +// os.Std*. +func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) (*cmdutil.Factory, *cobra.Command) { + // cfg.globals.Profile is left zero here; it's bound to the --profile + // flag in RegisterGlobalFlags and filled by cobra's parse step. + cfg := &buildConfig{} + for _, o := range opts { + o(cfg) + } + + f := cmdutil.NewDefault(cfg.streams, inv) + if cfg.keychain != nil { + f.Keychain = cfg.keychain + } + rootCmd := &cobra.Command{ + Use: "lark-cli", + Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls", + Long: rootLong, + Version: build.Version, + } + + rootCmd.SetContext(ctx) + rootCmd.SetIn(cfg.streams.In) + rootCmd.SetOut(cfg.streams.Out) + rootCmd.SetErr(cfg.streams.ErrOut) + + installTipsHelpFunc(rootCmd) + rootCmd.SilenceErrors = true + + RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals) + rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { + cmd.SilenceUsage = true + } + + rootCmd.AddCommand(cmdconfig.NewCmdConfig(f)) + rootCmd.AddCommand(auth.NewCmdAuth(f)) + rootCmd.AddCommand(profile.NewCmdProfile(f)) + rootCmd.AddCommand(doctor.NewCmdDoctor(f)) + rootCmd.AddCommand(api.NewCmdApiWithContext(ctx, f, nil)) + rootCmd.AddCommand(schema.NewCmdSchema(f, nil)) + rootCmd.AddCommand(completion.NewCmdCompletion(f)) + rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f)) + service.RegisterServiceCommandsWithContext(ctx, rootCmd, f) + shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f) + + // Prune commands incompatible with strict mode. + if mode := f.ResolveStrictMode(ctx); mode.IsActive() { + pruneForStrictMode(rootCmd, mode) + } + + return f, rootCmd +} diff --git a/cmd/global_flags.go b/cmd/global_flags.go index d634cc4f..b77e8f18 100644 --- a/cmd/global_flags.go +++ b/cmd/global_flags.go @@ -3,15 +3,38 @@ package cmd -import "github.com/spf13/pflag" +import ( + "github.com/larksuite/cli/internal/core" + "github.com/spf13/pflag" +) // GlobalOptions are the root-level flags shared by bootstrap parsing and the -// actual Cobra command tree. +// actual Cobra command tree. Profile is the parsed --profile value; HideProfile +// is a build-time policy — when true, --profile stays parseable but is marked +// hidden from help and shell completion. type GlobalOptions struct { - Profile string + Profile string + HideProfile bool } -// RegisterGlobalFlags registers the root-level persistent flags. +// RegisterGlobalFlags registers the root-level persistent flags on fs and +// applies any visibility policy encoded in opts. Pure function: no disk, +// network, or environment reads — the caller decides HideProfile. func RegisterGlobalFlags(fs *pflag.FlagSet, opts *GlobalOptions) { fs.StringVar(&opts.Profile, "profile", "", "use a specific profile") + if opts.HideProfile { + _ = fs.MarkHidden("profile") + } +} + +// isSingleAppMode reports whether the on-disk config has at most one app. +// Missing configs are treated as single-app since --profile is meaningless +// until at least two profiles exist. Intended for the Execute entry point — +// buildInternal must not call this directly to stay state-free. +func isSingleAppMode() bool { + raw, err := core.LoadMultiAppConfig() + if err != nil || raw == nil { + return true + } + return len(raw.Apps) <= 1 } diff --git a/cmd/global_flags_test.go b/cmd/global_flags_test.go new file mode 100644 index 00000000..c24d1573 --- /dev/null +++ b/cmd/global_flags_test.go @@ -0,0 +1,110 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "os" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/spf13/pflag" +) + +func testStreams() BuildOption { return WithIO(os.Stdin, os.Stdout, os.Stderr) } + +func TestRegisterGlobalFlags_PolicyVisible(t *testing.T) { + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + opts := &GlobalOptions{} + RegisterGlobalFlags(fs, opts) + + flag := fs.Lookup("profile") + if flag == nil { + t.Fatal("profile flag should be registered") + } + if flag.Hidden { + t.Fatal("profile flag should be visible when HideProfile is false") + } +} + +func TestRegisterGlobalFlags_PolicyHidden(t *testing.T) { + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + opts := &GlobalOptions{HideProfile: true} + RegisterGlobalFlags(fs, opts) + + flag := fs.Lookup("profile") + if flag == nil { + t.Fatal("profile flag should be registered") + } + if !flag.Hidden { + t.Fatal("profile flag should be hidden when HideProfile is true") + } + if err := fs.Parse([]string{"--profile", "x"}); err != nil { + t.Fatalf("Parse() error = %v; hidden flag should still parse", err) + } + if opts.Profile != "x" { + t.Fatalf("opts.Profile = %q, want %q", opts.Profile, "x") + } +} + +func TestIsSingleAppMode_NoConfig(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + if !isSingleAppMode() { + t.Fatal("isSingleAppMode() = false, want true when no config exists") + } +} + +func TestIsSingleAppMode_SingleApp(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + saveAppsForTest(t, []core.AppConfig{ + {Name: "default", AppId: "cli_a", AppSecret: core.PlainSecret("x"), Brand: core.BrandFeishu}, + }) + if !isSingleAppMode() { + t.Fatal("isSingleAppMode() = false, want true for single-app config") + } +} + +func TestIsSingleAppMode_MultiApp(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + saveAppsForTest(t, []core.AppConfig{ + {Name: "a", AppId: "cli_a", AppSecret: core.PlainSecret("x"), Brand: core.BrandFeishu}, + {Name: "b", AppId: "cli_b", AppSecret: core.PlainSecret("y"), Brand: core.BrandFeishu}, + }) + if isSingleAppMode() { + t.Fatal("isSingleAppMode() = true, want false for multi-app config") + } +} + +func TestBuildInternal_HideProfileOption(t *testing.T) { + _, root := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams(), HideProfile(true)) + + flag := root.PersistentFlags().Lookup("profile") + if flag == nil { + t.Fatal("profile flag should be registered") + } + if !flag.Hidden { + t.Fatal("profile flag should be hidden when HideProfile(true) is applied") + } +} + +func TestBuildInternal_DefaultShowsProfileFlag(t *testing.T) { + _, root := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams()) + + flag := root.PersistentFlags().Lookup("profile") + if flag == nil { + t.Fatal("profile flag should be registered by default") + } + if flag.Hidden { + t.Fatal("profile flag should be visible by default") + } +} + +func saveAppsForTest(t *testing.T, apps []core.AppConfig) { + t.Helper() + multi := &core.MultiAppConfig{CurrentApp: apps[0].Name, Apps: apps} + if err := core.SaveMultiAppConfig(multi); err != nil { + t.Fatalf("SaveMultiAppConfig() error = %v", err) + } +} diff --git a/cmd/init.go b/cmd/init.go new file mode 100644 index 00000000..d9093eab --- /dev/null +++ b/cmd/init.go @@ -0,0 +1,18 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmd + +import "github.com/larksuite/cli/internal/vfs" + +// SetDefaultFS replaces the global filesystem implementation used by internal +// packages. The provided fs must implement the vfs.FS interface. If fs is nil, +// the default OS filesystem is restored. +// +// Call this before Build or Execute to take effect. +func SetDefaultFS(fs vfs.FS) { + if fs == nil { + fs = vfs.OsFs{} + } + vfs.DefaultFS = fs +} diff --git a/cmd/root.go b/cmd/root.go index dca93f7c..57c91d8b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -14,15 +14,6 @@ import ( "os" "strconv" - "github.com/larksuite/cli/cmd/api" - "github.com/larksuite/cli/cmd/auth" - "github.com/larksuite/cli/cmd/completion" - cmdconfig "github.com/larksuite/cli/cmd/config" - "github.com/larksuite/cli/cmd/doctor" - "github.com/larksuite/cli/cmd/profile" - "github.com/larksuite/cli/cmd/schema" - "github.com/larksuite/cli/cmd/service" - cmdupdate "github.com/larksuite/cli/cmd/update" internalauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/build" "github.com/larksuite/cli/internal/cmdutil" @@ -30,7 +21,6 @@ import ( "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/internal/update" - "github.com/larksuite/cli/shortcuts" "github.com/spf13/cobra" ) @@ -95,38 +85,11 @@ func Execute() int { fmt.Fprintln(os.Stderr, "Error:", err) return 1 } - f := cmdutil.NewDefault(inv) - - globals := &GlobalOptions{Profile: inv.Profile} - rootCmd := &cobra.Command{ - Use: "lark-cli", - Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls", - Long: rootLong, - Version: build.Version, - } - installTipsHelpFunc(rootCmd) - rootCmd.SilenceErrors = true - - RegisterGlobalFlags(rootCmd.PersistentFlags(), globals) - rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { - cmd.SilenceUsage = true - } - - rootCmd.AddCommand(cmdconfig.NewCmdConfig(f)) - rootCmd.AddCommand(auth.NewCmdAuth(f)) - rootCmd.AddCommand(profile.NewCmdProfile(f)) - rootCmd.AddCommand(doctor.NewCmdDoctor(f)) - rootCmd.AddCommand(api.NewCmdApi(f, nil)) - rootCmd.AddCommand(schema.NewCmdSchema(f, nil)) - rootCmd.AddCommand(completion.NewCmdCompletion(f)) - rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f)) - service.RegisterServiceCommands(rootCmd, f) - shortcuts.RegisterShortcuts(rootCmd, f) - - // Prune commands incompatible with strict mode. - if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() { - pruneForStrictMode(rootCmd, mode) - } + f, rootCmd := buildInternal( + context.Background(), inv, + WithIO(os.Stdin, os.Stdout, os.Stderr), + HideProfile(isSingleAppMode()), + ) // --- Update check (non-blocking) --- if !isCompletionCommand(os.Args) { @@ -277,10 +240,19 @@ func writeSecurityPolicyError(w io.Writer, spErr *internalauth.SecurityPolicyErr } // installTipsHelpFunc wraps the default help function to append a TIPS section -// when a command has tips set via cmdutil.SetTips. +// when a command has tips set via cmdutil.SetTips. It also force-shows global +// flags that are normally hidden in single-app mode (currently --profile) +// when rendering the root command's own help, so users discovering the CLI +// still see them at `lark-cli --help`. func installTipsHelpFunc(root *cobra.Command) { defaultHelp := root.HelpFunc() root.SetHelpFunc(func(cmd *cobra.Command, args []string) { + if cmd == root { + if f := root.PersistentFlags().Lookup("profile"); f != nil && f.Hidden { + f.Hidden = false + defer func() { f.Hidden = true }() + } + } defaultHelp(cmd, args) tips := cmdutil.GetTips(cmd) if len(tips) == 0 { diff --git a/cmd/root_integration_test.go b/cmd/root_integration_test.go index 55501873..f14cc87c 100644 --- a/cmd/root_integration_test.go +++ b/cmd/root_integration_test.go @@ -135,7 +135,7 @@ func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictM t.Fatalf("SaveMultiAppConfig() error = %v", err) } - f := cmdutil.NewDefault(cmdutil.InvocationContext{Profile: profile}) + f := cmdutil.NewDefault(nil, cmdutil.InvocationContext{Profile: profile}) stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} f.IOStreams = &cmdutil.IOStreams{In: nil, Out: stdout, ErrOut: stderr} diff --git a/cmd/schema/schema.go b/cmd/schema/schema.go index f45d6e81..152f37d2 100644 --- a/cmd/schema/schema.go +++ b/cmd/schema/schema.go @@ -4,12 +4,14 @@ package schema import ( + "context" "fmt" "io" "sort" "strings" "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/internal/util" @@ -19,6 +21,7 @@ import ( // SchemaOptions holds all inputs for the schema command. type SchemaOptions struct { Factory *cmdutil.Factory + Ctx context.Context // Positional args Path string @@ -41,7 +44,7 @@ func printServices(w io.Writer) { fmt.Fprintf(w, "\n%sUsage: lark-cli schema ..%s\n", output.Dim, output.Reset) } -func printResourceList(w io.Writer, spec map[string]interface{}) { +func printResourceList(w io.Writer, spec map[string]interface{}, mode core.StrictMode) { name := registry.GetStrFromMap(spec, "name") version := registry.GetStrFromMap(spec, "version") title := registry.GetStrFromMap(spec, "title") @@ -55,9 +58,13 @@ func printResourceList(w io.Writer, spec map[string]interface{}) { resources, _ := spec["resources"].(map[string]interface{}) for _, resName := range sortedKeys(resources) { - fmt.Fprintf(w, " %s%s%s\n", output.Cyan, resName, output.Reset) resMap, _ := resources[resName].(map[string]interface{}) methods, _ := resMap["methods"].(map[string]interface{}) + methods = filterMethodsByStrictMode(methods, mode) + if len(methods) == 0 { + continue + } + fmt.Fprintf(w, " %s%s%s\n", output.Cyan, resName, output.Reset) for _, methodName := range sortedKeys(methods) { m, _ := methods[methodName].(map[string]interface{}) httpMethod := registry.GetStrFromMap(m, "httpMethod") @@ -359,6 +366,7 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co if len(args) > 0 { opts.Path = args[0] } + opts.Ctx = cmd.Context() if runF != nil { return runF(opts) } @@ -367,7 +375,7 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co } cmdutil.DisableAuthCheck(cmd) - cmd.ValidArgsFunction = completeSchemaPath + cmd.ValidArgsFunction = completeSchemaPath(f) cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty") _ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp @@ -379,78 +387,86 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co // completeSchemaPath provides tab-completion for the schema path argument. // It handles dotted resource names (e.g. app.table.fields) by iterating all // resources and classifying each as a prefix-match or fully-matched. -func completeSchemaPath(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - if len(args) > 0 { - return nil, cobra.ShellCompDirectiveNoFileComp - } +func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } - parts := strings.Split(toComplete, ".") + parts := strings.Split(toComplete, ".") - // Level 1: complete service names - if len(parts) <= 1 { - var completions []string - for _, s := range registry.ListFromMetaProjects() { - if strings.HasPrefix(s, toComplete) { - completions = append(completions, s+".") + // Level 1: complete service names + if len(parts) <= 1 { + var completions []string + for _, s := range registry.ListFromMetaProjects() { + if strings.HasPrefix(s, toComplete) { + completions = append(completions, s+".") + } } + return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace } - return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace - } - serviceName := parts[0] - spec := registry.LoadFromMeta(serviceName) - if spec == nil { - return nil, cobra.ShellCompDirectiveNoFileComp - } - resources, _ := spec["resources"].(map[string]interface{}) - if resources == nil { - return nil, cobra.ShellCompDirectiveNoFileComp - } + serviceName := parts[0] + spec := registry.LoadFromMeta(serviceName) + if spec == nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + mode := f.ResolveStrictMode(cmd.Context()) + spec = filterSpecByStrictMode(spec, mode) + resources, _ := spec["resources"].(map[string]interface{}) + if resources == nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + afterService := strings.Join(parts[1:], ".") + completions := completeSchemaPathForSpec(serviceName, resources, afterService) - // afterService = everything user typed after "serviceName." - afterService := strings.Join(parts[1:], ".") + allTrailingDot := len(completions) > 0 + for _, c := range completions { + if !strings.HasSuffix(c, ".") { + allTrailingDot = false + break + } + } + directive := cobra.ShellCompDirectiveNoFileComp + if allTrailingDot { + directive |= cobra.ShellCompDirectiveNoSpace + } + return completions, directive + } +} +func completeSchemaPathForSpec(serviceName string, resources map[string]interface{}, afterService string) []string { var completions []string for resName, resVal := range resources { if strings.HasPrefix(resName, afterService) { - // afterService is a prefix of this resource name → resource candidate completions = append(completions, serviceName+"."+resName+".") - } else if strings.HasPrefix(afterService, resName+".") { - // This resource is fully matched; remainder is method prefix - methodPrefix := afterService[len(resName)+1:] - resMap, _ := resVal.(map[string]interface{}) - if resMap == nil { - continue - } - methods, _ := resMap["methods"].(map[string]interface{}) - for methodName := range methods { - if strings.HasPrefix(methodName, methodPrefix) { - completions = append(completions, serviceName+"."+resName+"."+methodName) - } + continue + } + if !strings.HasPrefix(afterService, resName+".") { + continue + } + methodPrefix := afterService[len(resName)+1:] + resMap, _ := resVal.(map[string]interface{}) + if resMap == nil { + continue + } + methods, _ := resMap["methods"].(map[string]interface{}) + for methodName := range methods { + if strings.HasPrefix(methodName, methodPrefix) { + completions = append(completions, serviceName+"."+resName+"."+methodName) } } } sort.Strings(completions) - - // If all completions end with ".", user is still navigating resources → NoSpace - allTrailingDot := len(completions) > 0 - for _, c := range completions { - if !strings.HasSuffix(c, ".") { - allTrailingDot = false - break - } - } - directive := cobra.ShellCompDirectiveNoFileComp - if allTrailingDot { - directive |= cobra.ShellCompDirectiveNoSpace - } - return completions, directive + return completions } func schemaRun(opts *SchemaOptions) error { out := opts.Factory.IOStreams.Out + mode := opts.Factory.ResolveStrictMode(opts.Ctx) if opts.Path == "" { printServices(out) @@ -469,9 +485,9 @@ func schemaRun(opts *SchemaOptions) error { if len(parts) == 1 { if opts.Format == "pretty" { - printResourceList(out, spec) + printResourceList(out, spec, mode) } else { - output.PrintJson(out, spec) + output.PrintJson(out, filterSpecByStrictMode(spec, mode)) } return nil } @@ -492,6 +508,7 @@ func schemaRun(opts *SchemaOptions) error { if opts.Format == "pretty" { fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset) methods, _ := resource["methods"].(map[string]interface{}) + methods = filterMethodsByStrictMode(methods, mode) for _, mName := range sortedKeys(methods) { m, _ := methods[mName].(map[string]interface{}) httpMethod := registry.GetStrFromMap(m, "httpMethod") @@ -500,13 +517,26 @@ func schemaRun(opts *SchemaOptions) error { } fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.%s\n", output.Dim, serviceName, resName, output.Reset) } else { - output.PrintJson(out, resource) + // For JSON output, filter methods in a copy to avoid mutating the registry. + if mode.IsActive() { + filtered := make(map[string]interface{}) + for k, v := range resource { + filtered[k] = v + } + if methods, ok := resource["methods"].(map[string]interface{}); ok { + filtered["methods"] = filterMethodsByStrictMode(methods, mode) + } + output.PrintJson(out, filtered) + } else { + output.PrintJson(out, resource) + } } return nil } methodName := remaining[0] methods, _ := resource["methods"].(map[string]interface{}) + methods = filterMethodsByStrictMode(methods, mode) method, ok := methods[methodName].(map[string]interface{}) if !ok { var mNames []string @@ -525,3 +555,67 @@ func schemaRun(opts *SchemaOptions) error { } return nil } + +// filterSpecByStrictMode returns a shallow copy of spec with each resource's methods +// filtered by strict mode. Returns the original spec when strict mode is off. +func filterSpecByStrictMode(spec map[string]interface{}, mode core.StrictMode) map[string]interface{} { + if !mode.IsActive() { + return spec + } + result := make(map[string]interface{}, len(spec)) + for k, v := range spec { + result[k] = v + } + resources, _ := spec["resources"].(map[string]interface{}) + if resources == nil { + return result + } + filteredRes := make(map[string]interface{}, len(resources)) + for resName, resVal := range resources { + resMap, ok := resVal.(map[string]interface{}) + if !ok { + continue + } + methods, _ := resMap["methods"].(map[string]interface{}) + filtered := filterMethodsByStrictMode(methods, mode) + if len(filtered) == 0 { + continue + } + resCopy := make(map[string]interface{}, len(resMap)) + for k, v := range resMap { + resCopy[k] = v + } + resCopy["methods"] = filtered + filteredRes[resName] = resCopy + } + result["resources"] = filteredRes + return result +} + +// filterMethodsByStrictMode removes methods incompatible with the active strict mode. +// Returns the original map unmodified when strict mode is off. +func filterMethodsByStrictMode(methods map[string]interface{}, mode core.StrictMode) map[string]interface{} { + if !mode.IsActive() || methods == nil { + return methods + } + token := registry.IdentityToAccessToken(string(mode.ForcedIdentity())) + filtered := make(map[string]interface{}, len(methods)) + for name, val := range methods { + m, ok := val.(map[string]interface{}) + if !ok { + continue + } + tokens, _ := m["accessTokens"].([]interface{}) + if tokens == nil { + filtered[name] = val + continue + } + for _, t := range tokens { + if ts, ok := t.(string); ok && ts == token { + filtered[name] = val + break + } + } + } + return filtered +} diff --git a/cmd/schema/schema_test.go b/cmd/schema/schema_test.go index 639822ac..da412930 100644 --- a/cmd/schema/schema_test.go +++ b/cmd/schema/schema_test.go @@ -182,3 +182,49 @@ func TestHasFileFields(t *testing.T) { }) } } + +func TestCompleteSchemaPathForSpec(t *testing.T) { + resources := map[string]interface{}{ + "records": map[string]interface{}{ + "methods": map[string]interface{}{ + "create": map[string]interface{}{}, + "list": map[string]interface{}{}, + }, + }, + "record_permissions": map[string]interface{}{ + "methods": map[string]interface{}{ + "get": map[string]interface{}{}, + }, + }, + } + + got := completeSchemaPathForSpec("base", resources, "records.cr") + if len(got) != 1 || got[0] != "base.records.create" { + t.Fatalf("completions = %v, want [base.records.create]", got) + } + + got = completeSchemaPathForSpec("base", resources, "record") + if len(got) != 2 || got[0] != "base.record_permissions." || got[1] != "base.records." { + t.Fatalf("resource completions = %v", got) + } +} + +func TestFilterSpecByStrictMode_RemovesIncompatibleMethodsFromCompletionSource(t *testing.T) { + spec := map[string]interface{}{ + "resources": map[string]interface{}{ + "records": map[string]interface{}{ + "methods": map[string]interface{}{ + "list": map[string]interface{}{"accessTokens": []interface{}{"tenant"}}, + "create": map[string]interface{}{"accessTokens": []interface{}{"user"}}, + }, + }, + }, + } + + filtered := filterSpecByStrictMode(spec, core.StrictModeBot) + resources, _ := filtered["resources"].(map[string]interface{}) + got := completeSchemaPathForSpec("base", resources, "records.") + if len(got) != 1 || got[0] != "base.records.list" { + t.Fatalf("filtered completions = %v, want [base.records.list]", got) + } +} diff --git a/cmd/service/service.go b/cmd/service/service.go index 5639075b..3be06fea 100644 --- a/cmd/service/service.go +++ b/cmd/service/service.go @@ -24,6 +24,10 @@ import ( // RegisterServiceCommands registers all service commands from from_meta specs. func RegisterServiceCommands(parent *cobra.Command, f *cmdutil.Factory) { + RegisterServiceCommandsWithContext(context.Background(), parent, f) +} + +func RegisterServiceCommandsWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) { for _, project := range registry.ListFromMetaProjects() { spec := registry.LoadFromMeta(project) if spec == nil { @@ -38,11 +42,15 @@ func RegisterServiceCommands(parent *cobra.Command, f *cmdutil.Factory) { if resources == nil { continue } - registerService(parent, spec, resources, f) + registerServiceWithContext(ctx, parent, spec, resources, f) } } func registerService(parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) { + registerServiceWithContext(context.Background(), parent, spec, resources, f) +} + +func registerServiceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) { specName := registry.GetStrFromMap(spec, "name") specDesc := registry.GetServiceDescription(specName, "en") if specDesc == "" { @@ -70,11 +78,15 @@ func registerService(parent *cobra.Command, spec map[string]interface{}, resourc if resMap == nil { continue } - registerResource(svc, spec, resName, resMap, f) + registerResourceWithContext(ctx, svc, spec, resName, resMap, f) } } func registerResource(parent *cobra.Command, spec map[string]interface{}, name string, resource map[string]interface{}, f *cmdutil.Factory) { + registerResourceWithContext(context.Background(), parent, spec, name, resource, f) +} + +func registerResourceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, name string, resource map[string]interface{}, f *cmdutil.Factory) { res := &cobra.Command{ Use: name, Short: name + " operations", @@ -87,7 +99,7 @@ func registerResource(parent *cobra.Command, spec map[string]interface{}, name s if methodMap == nil { continue } - registerMethod(res, spec, methodMap, methodName, name, f) + registerMethodWithContext(ctx, res, spec, methodMap, methodName, name, f) } } @@ -101,16 +113,16 @@ type ServiceMethodOptions struct { SchemaPath string // Flags - Params string - Data string - As core.Identity - Output string - PageAll bool - PageLimit int - PageDelay int - Format string - JqExpr string - DryRun bool + Params string + Data string + As core.Identity + Output string + PageAll bool + PageLimit int + PageDelay int + Format string + JqExpr string + DryRun bool File string // --file flag value FileFields []string // auto-detected file field names from metadata } @@ -121,11 +133,19 @@ func detectFileFields(method map[string]interface{}) []string { } func registerMethod(parent *cobra.Command, spec map[string]interface{}, method map[string]interface{}, name string, resName string, f *cmdutil.Factory) { - parent.AddCommand(NewCmdServiceMethod(f, spec, method, name, resName, nil)) + registerMethodWithContext(context.Background(), parent, spec, method, name, resName, f) +} + +func registerMethodWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, method map[string]interface{}, name string, resName string, f *cmdutil.Factory) { + parent.AddCommand(NewCmdServiceMethodWithContext(ctx, f, spec, method, name, resName, nil)) } // NewCmdServiceMethod creates a command for a dynamically registered service method. func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command { + return NewCmdServiceMethodWithContext(context.Background(), f, spec, method, name, resName, runF) +} + +func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command { desc := registry.GetStrFromMap(method, "description") httpMethod := registry.GetStrFromMap(method, "httpMethod") specName := registry.GetStrFromMap(spec, "name") @@ -159,7 +179,7 @@ func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{} case "POST", "PUT", "PATCH", "DELETE": cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)") } - cmd.Flags().StringVar(&asStr, "as", "auto", "identity type: user | bot | auto (default)") + cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr) cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses") cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages") cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)") @@ -177,10 +197,6 @@ func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{} cmd.Flags().StringVar(&opts.File, "file", "", "file to upload ([field=]path, supports - for stdin)") } } - - _ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { - return []string{"user", "bot"}, cobra.ShellCompDirectiveNoFileComp - }) _ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp }) diff --git a/cmd/service/service_test.go b/cmd/service/service_test.go index f9adc5d4..3a2e5a7b 100644 --- a/cmd/service/service_test.go +++ b/cmd/service/service_test.go @@ -121,6 +121,24 @@ func TestRegisterService_MergesExistingCommand(t *testing.T) { } } +func TestNewCmdServiceMethod_StrictModeHidesAsFlag(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2, + }) + + cmd := NewCmdServiceMethod(f, driveSpec(), driveMethod("GET", nil), "copy", "files", nil) + flag := cmd.Flags().Lookup("as") + if flag == nil { + t.Fatal("expected --as flag to be registered") + } + if !flag.Hidden { + t.Fatal("expected --as flag to be hidden in strict mode") + } + if got := flag.DefValue; got != "bot" { + t.Fatalf("default value = %q, want %q", got, "bot") + } +} + // ── NewCmdServiceMethod flags ── func TestNewCmdServiceMethod_GETHasNoDataFlag(t *testing.T) { diff --git a/internal/cmdutil/factory_default.go b/internal/cmdutil/factory_default.go index c9b4e92c..53b58417 100644 --- a/internal/cmdutil/factory_default.go +++ b/internal/cmdutil/factory_default.go @@ -8,13 +8,11 @@ import ( "fmt" "io" "net/http" - "os" "sync" "time" lark "github.com/larksuite/oapi-sdk-go/v3" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" - "golang.org/x/term" extcred "github.com/larksuite/cli/extension/credential" "github.com/larksuite/cli/extension/fileio" @@ -34,27 +32,26 @@ import ( // Phase 2: Credential (sole data source for account info) // Phase 3: Config derived from Credential // Phase 4: LarkClient derived from Credential -func NewDefault(inv InvocationContext) *Factory { +func NewDefault(streams *IOStreams, inv InvocationContext) *Factory { + if streams == nil { + streams = SystemIO() + } f := &Factory{ Keychain: keychain.Default(), Invocation: inv, - } - f.IOStreams = &IOStreams{ - In: os.Stdin, - Out: os.Stdout, - ErrOut: os.Stderr, - IsTerminal: term.IsTerminal(int(os.Stdin.Fd())), + IOStreams: streams, } // Phase 0: FileIO provider (no dependency) f.FileIOProvider = fileio.GetProvider() // Phase 1: HttpClient (no credential dependency) - f.HttpClient = cachedHttpClientFunc() + f.HttpClient = cachedHttpClientFunc(f) // Phase 2: Credential (sole data source) + // Keychain is read via closure so callers can replace f.Keychain after construction. f.Credential = buildCredentialProvider(credentialDeps{ - Keychain: f.Keychain, + Keychain: func() keychain.KeychainAccess { return f.Keychain }, Profile: inv.Profile, HttpClient: f.HttpClient, ErrOut: f.IOStreams.ErrOut, @@ -93,9 +90,9 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error { return nil } -func cachedHttpClientFunc() func() (*http.Client, error) { +func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) { return sync.OnceValues(func() (*http.Client, error) { - util.WarnIfProxied(os.Stderr) + util.WarnIfProxied(f.IOStreams.ErrOut) var transport http.RoundTripper = util.NewBaseTransport() transport = &RetryTransport{Base: transport} @@ -122,7 +119,7 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) { lark.WithLogLevel(larkcore.LogLevelError), lark.WithHeaders(BaseSecurityHeaders()), } - util.WarnIfProxied(os.Stderr) + util.WarnIfProxied(f.IOStreams.ErrOut) opts = append(opts, lark.WithHttpClient(&http.Client{ Transport: buildSDKTransport(), CheckRedirect: safeRedirectPolicy, @@ -142,7 +139,7 @@ func buildSDKTransport() http.RoundTripper { } type credentialDeps struct { - Keychain keychain.KeychainAccess + Keychain func() keychain.KeychainAccess Profile string HttpClient func() (*http.Client, error) ErrOut io.Writer diff --git a/internal/cmdutil/factory_default_test.go b/internal/cmdutil/factory_default_test.go index fe91ead7..7204e2de 100644 --- a/internal/cmdutil/factory_default_test.go +++ b/internal/cmdutil/factory_default_test.go @@ -63,7 +63,7 @@ func TestNewDefault_InvocationProfileUsedByStrictModeAndConfig(t *testing.T) { t.Fatalf("SaveMultiAppConfig() error = %v", err) } - f := NewDefault(InvocationContext{Profile: "target"}) + f := NewDefault(nil, InvocationContext{Profile: "target"}) if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeBot { t.Fatalf("ResolveStrictMode() = %q, want %q", got, core.StrictModeBot) } @@ -103,7 +103,7 @@ func TestNewDefault_InvocationProfileMissingSticksAcrossEarlyStrictMode(t *testi t.Fatalf("SaveMultiAppConfig() error = %v", err) } - f := NewDefault(InvocationContext{Profile: "missing"}) + f := NewDefault(nil, InvocationContext{Profile: "missing"}) if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeOff { t.Fatalf("ResolveStrictMode() = %q, want %q", got, core.StrictModeOff) } @@ -144,7 +144,7 @@ func TestNewDefault_ResolveAs_UsesDefaultAsFromEnvAccount(t *testing.T) { t.Setenv(envvars.CliTenantAccessToken, "") t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - f := NewDefault(InvocationContext{}) + f := NewDefault(nil, InvocationContext{}) cmd := newCmdWithAsFlag("auto", false) got := f.ResolveAs(context.Background(), cmd, "auto") @@ -164,7 +164,7 @@ func TestNewDefault_ConfigReturnsCliConfigCopyOfCredentialAccount(t *testing.T) t.Setenv(envvars.CliTenantAccessToken, "") t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - f := NewDefault(InvocationContext{}) + f := NewDefault(nil, InvocationContext{}) acct, err := f.Credential.ResolveAccount(context.Background()) if err != nil { @@ -189,7 +189,7 @@ func TestNewDefault_ConfigUsesRuntimePlaceholderForTokenOnlyEnvAccount(t *testin t.Setenv(envvars.CliTenantAccessToken, "") t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - f := NewDefault(InvocationContext{}) + f := NewDefault(nil, InvocationContext{}) acct, err := f.Credential.ResolveAccount(context.Background()) if err != nil { @@ -217,7 +217,7 @@ func TestNewDefault_FileIOProviderDoesNotResolveDuringInitialization(t *testing. fileio.Register(provider) t.Cleanup(func() { fileio.Register(prev) }) - f := NewDefault(InvocationContext{}) + f := NewDefault(nil, InvocationContext{}) if f.FileIOProvider != provider { t.Fatalf("NewDefault() provider = %T, want %T", f.FileIOProvider, provider) } diff --git a/internal/cmdutil/factory_http_test.go b/internal/cmdutil/factory_http_test.go index c27e9e69..a2b50e82 100644 --- a/internal/cmdutil/factory_http_test.go +++ b/internal/cmdutil/factory_http_test.go @@ -4,11 +4,12 @@ package cmdutil import ( + "io" "testing" ) func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) { - fn := cachedHttpClientFunc() + fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}}) c1, err := fn() if err != nil { @@ -28,7 +29,7 @@ func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) { } func TestCachedHttpClientFunc_HasTimeout(t *testing.T) { - fn := cachedHttpClientFunc() + fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}}) c, _ := fn() if c.Timeout == 0 { t.Error("expected non-zero timeout") @@ -36,7 +37,7 @@ func TestCachedHttpClientFunc_HasTimeout(t *testing.T) { } func TestCachedHttpClientFunc_HasRedirectPolicy(t *testing.T) { - fn := cachedHttpClientFunc() + fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}}) c, _ := fn() if c.CheckRedirect == nil { t.Error("expected CheckRedirect to be set (safeRedirectPolicy)") diff --git a/internal/cmdutil/identity_flag.go b/internal/cmdutil/identity_flag.go new file mode 100644 index 00000000..c99d5c62 --- /dev/null +++ b/internal/cmdutil/identity_flag.go @@ -0,0 +1,68 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +// AddAPIIdentityFlag registers the standard --as flag shape used by api/service commands. +func AddAPIIdentityFlag(ctx context.Context, cmd *cobra.Command, f *Factory, target *string) { + addIdentityFlag(ctx, cmd, f, target, identityFlagConfig{ + defaultValue: "auto", + usage: "identity type: user | bot | auto (default)", + completionValues: []string{"user", "bot"}, + }) +} + +// AddShortcutIdentityFlag registers the standard --as flag shape used by shortcuts. +func AddShortcutIdentityFlag(ctx context.Context, cmd *cobra.Command, f *Factory, authTypes []string) { + if len(authTypes) == 0 { + authTypes = []string{"user"} + } + addIdentityFlag(ctx, cmd, f, nil, identityFlagConfig{ + defaultValue: authTypes[0], + usage: "identity type: " + strings.Join(authTypes, " | "), + completionValues: authTypes, + }) +} + +type identityFlagConfig struct { + defaultValue string + usage string + completionValues []string +} + +// addIdentityFlag centralizes --as registration and strict-mode UX. +// When strict mode is active, the flag is still accepted for compatibility +// but hidden from help/completion and locked to the forced identity by default. +func addIdentityFlag(ctx context.Context, cmd *cobra.Command, f *Factory, target *string, cfg identityFlagConfig) { + if forced := f.ResolveStrictMode(ctx).ForcedIdentity(); forced != "" { + // Keep registering --as in strict mode even though it is hidden. + // This preserves parser compatibility for existing invocations that still pass + // --as, and keeps downstream GetString("as") / ResolveAs paths stable. + // The usage text below is effectively placeholder text because the flag is hidden. + registerIdentityFlag(cmd, target, string(forced), + fmt.Sprintf("identity locked to %s by strict mode (admin-managed)", forced)) + _ = cmd.Flags().MarkHidden("as") + return + } + + registerIdentityFlag(cmd, target, cfg.defaultValue, cfg.usage) + _ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return cfg.completionValues, cobra.ShellCompDirectiveNoFileComp + }) +} + +func registerIdentityFlag(cmd *cobra.Command, target *string, defaultValue, usage string) { + if target != nil { + cmd.Flags().StringVar(target, "as", defaultValue, usage) + return + } + cmd.Flags().String("as", defaultValue, usage) +} diff --git a/internal/cmdutil/identity_flag_test.go b/internal/cmdutil/identity_flag_test.go new file mode 100644 index 00000000..fa93d726 --- /dev/null +++ b/internal/cmdutil/identity_flag_test.go @@ -0,0 +1,68 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "context" + "testing" + + "github.com/larksuite/cli/internal/core" + "github.com/spf13/cobra" +) + +func TestAddAPIIdentityFlag_NonStrictMode(t *testing.T) { + f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + cmd := &cobra.Command{Use: "test"} + + AddAPIIdentityFlag(context.Background(), cmd, f, nil) + + flag := cmd.Flags().Lookup("as") + if flag == nil { + t.Fatal("expected --as flag to be registered") + } + if flag.Hidden { + t.Fatal("expected --as flag to be visible outside strict mode") + } + if got := flag.DefValue; got != "auto" { + t.Fatalf("default value = %q, want %q", got, "auto") + } +} + +func TestAddAPIIdentityFlag_StrictModeHidesFlagAndLocksDefault(t *testing.T) { + f, _, _, _ := TestFactory(t, &core.CliConfig{ + AppID: "a", AppSecret: "s", SupportedIdentities: 2, + }) + cmd := &cobra.Command{Use: "test"} + + AddAPIIdentityFlag(context.Background(), cmd, f, nil) + + flag := cmd.Flags().Lookup("as") + if flag == nil { + t.Fatal("expected --as flag to be registered") + } + if !flag.Hidden { + t.Fatal("expected --as flag to be hidden in strict mode") + } + if got := flag.DefValue; got != "bot" { + t.Fatalf("default value = %q, want %q", got, "bot") + } +} + +func TestAddShortcutIdentityFlag_UsesAuthTypes(t *testing.T) { + f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + cmd := &cobra.Command{Use: "test"} + + AddShortcutIdentityFlag(context.Background(), cmd, f, []string{"bot"}) + + flag := cmd.Flags().Lookup("as") + if flag == nil { + t.Fatal("expected --as flag to be registered") + } + if flag.Hidden { + t.Fatal("expected --as flag to be visible outside strict mode") + } + if got := flag.DefValue; got != "bot" { + t.Fatalf("default value = %q, want %q", got, "bot") + } +} diff --git a/internal/cmdutil/iostreams.go b/internal/cmdutil/iostreams.go index 76068e05..5853478a 100644 --- a/internal/cmdutil/iostreams.go +++ b/internal/cmdutil/iostreams.go @@ -3,7 +3,12 @@ package cmdutil -import "io" +import ( + "io" + "os" + + "golang.org/x/term" +) // IOStreams provides the standard input/output/error streams. // Commands should use these instead of os.Stdin/Stdout/Stderr @@ -14,3 +19,13 @@ type IOStreams struct { ErrOut io.Writer IsTerminal bool } + +// SystemIO creates an IOStreams wired to the process's standard file descriptors. +func SystemIO() *IOStreams { + return &IOStreams{ + In: os.Stdin, //nolint:forbidigo // entry point for real stdio + Out: os.Stdout, //nolint:forbidigo // entry point for real stdio + ErrOut: os.Stderr, //nolint:forbidigo // entry point for real stdio + IsTerminal: term.IsTerminal(int(os.Stdin.Fd())), //nolint:forbidigo // need Fd() for terminal check + } +} diff --git a/internal/credential/default_provider.go b/internal/credential/default_provider.go index bedad7b8..a3ebb90c 100644 --- a/internal/credential/default_provider.go +++ b/internal/credential/default_provider.go @@ -21,11 +21,14 @@ import ( // DefaultAccountProvider resolves account from config.json via keychain. type DefaultAccountProvider struct { - keychain keychain.KeychainAccess + keychain func() keychain.KeychainAccess profile string } -func NewDefaultAccountProvider(kc keychain.KeychainAccess, profile string) *DefaultAccountProvider { +func NewDefaultAccountProvider(kc func() keychain.KeychainAccess, profile string) *DefaultAccountProvider { + if kc == nil { + kc = keychain.Default + } return &DefaultAccountProvider{keychain: kc, profile: profile} } @@ -36,7 +39,7 @@ func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account, return nil, &core.ConfigError{Code: 2, Type: "config", Message: "not configured", Hint: "run `lark-cli config init --new` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete setup."} } - cfg, err := core.ResolveConfigFromMulti(multi, p.keychain, p.profile) + cfg, err := core.ResolveConfigFromMulti(multi, p.keychain(), p.profile) if err != nil { return nil, err } diff --git a/internal/credential/integration_test.go b/internal/credential/integration_test.go index 46a3485f..7daef1d6 100644 --- a/internal/credential/integration_test.go +++ b/internal/credential/integration_test.go @@ -12,6 +12,7 @@ import ( "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/envvars" + "github.com/larksuite/cli/internal/keychain" ) type noopKC struct{} @@ -99,7 +100,7 @@ func TestFullChain_ConfigStrictMode(t *testing.T) { } ep := &envprovider.Provider{} - defaultAcct := credential.NewDefaultAccountProvider(&noopKC{}, "") + defaultAcct := credential.NewDefaultAccountProvider(func() keychain.KeychainAccess { return &noopKC{} }, "") cp := credential.NewCredentialProvider( []extcred.Provider{ep}, diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 1ddb69e5..356ce125 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -571,12 +571,16 @@ func enhancePermissionError(err error, requiredScopes []string) error { // Mount registers the shortcut on a parent command. func (s Shortcut) Mount(parent *cobra.Command, f *cmdutil.Factory) { + s.MountWithContext(context.Background(), parent, f) +} + +func (s Shortcut) MountWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) { if s.Execute != nil { - s.mountDeclarative(parent, f) + s.mountDeclarative(ctx, parent, f) } } -func (s Shortcut) mountDeclarative(parent *cobra.Command, f *cmdutil.Factory) { +func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) { shortcut := s if len(shortcut.AuthTypes) == 0 { shortcut.AuthTypes = []string{"user"} @@ -592,7 +596,7 @@ func (s Shortcut) mountDeclarative(parent *cobra.Command, f *cmdutil.Factory) { }, } cmdutil.SetSupportedIdentities(cmd, shortcut.AuthTypes) - registerShortcutFlags(cmd, &shortcut) + registerShortcutFlagsWithContext(ctx, cmd, f, &shortcut) cmdutil.SetTips(cmd, shortcut.Tips) parent.AddCommand(cmd) } @@ -823,7 +827,11 @@ func rejectPositionalArgs() cobra.PositionalArgs { } } -func registerShortcutFlags(cmd *cobra.Command, s *Shortcut) { +func registerShortcutFlags(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) { + registerShortcutFlagsWithContext(context.Background(), cmd, f, s) +} + +func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) { for _, fl := range s.Flags { desc := fl.Desc if len(fl.Enum) > 0 { @@ -874,11 +882,7 @@ func registerShortcutFlags(cmd *cobra.Command, s *Shortcut) { cmd.Flags().Bool("yes", false, "confirm high-risk operation") } cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output") - cmd.Flags().String("as", s.AuthTypes[0], "identity type: user | bot") - - _ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { - return s.AuthTypes, cobra.ShellCompDirectiveNoFileComp - }) + cmdutil.AddShortcutIdentityFlag(ctx, cmd, f, s.AuthTypes) if s.HasFormat { _ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp diff --git a/shortcuts/common/runner_identity_flag_test.go b/shortcuts/common/runner_identity_flag_test.go new file mode 100644 index 00000000..a6ed1020 --- /dev/null +++ b/shortcuts/common/runner_identity_flag_test.go @@ -0,0 +1,45 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "context" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/spf13/cobra" +) + +func TestShortcutMount_StrictModeHidesAsFlag(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2, + }) + parent := &cobra.Command{Use: "root"} + shortcut := Shortcut{ + Service: "docs", + Command: "+fetch", + Description: "fetch doc", + AuthTypes: []string{"user", "bot"}, + Execute: func(context.Context, *RuntimeContext) error { + return nil + }, + } + + shortcut.Mount(parent, f) + cmd, _, err := parent.Find([]string{"+fetch"}) + if err != nil { + t.Fatalf("Find() error = %v", err) + } + flag := cmd.Flags().Lookup("as") + if flag == nil { + t.Fatal("expected --as flag to be registered") + } + if !flag.Hidden { + t.Fatal("expected --as flag to be hidden in strict mode") + } + if got := flag.DefValue; got != "bot" { + t.Fatalf("default value = %q, want %q", got, "bot") + } +} diff --git a/shortcuts/common/runner_jq_test.go b/shortcuts/common/runner_jq_test.go index 17af83dc..94900267 100644 --- a/shortcuts/common/runner_jq_test.go +++ b/shortcuts/common/runner_jq_test.go @@ -145,10 +145,10 @@ func TestRuntimeContext_FileIO_UsesExecutionContext(t *testing.T) { } } -func newTestShortcutCmd(s *Shortcut) *cobra.Command { +func newTestShortcutCmd(s *Shortcut, f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{Use: "test-shortcut"} cmd.SetContext(context.Background()) - registerShortcutFlags(cmd, s) + registerShortcutFlags(cmd, f, s) return cmd } @@ -177,7 +177,7 @@ func TestRunShortcut_JqAndFormatConflict(t *testing.T) { return nil }, } - cmd := newTestShortcutCmd(s) + cmd := newTestShortcutCmd(s, newTestFactory()) cmd.Flags().Set("jq", ".data") cmd.Flags().Set("format", "table") cmd.Flags().Set("as", "bot") @@ -200,7 +200,7 @@ func TestRunShortcut_JqInvalidExpression(t *testing.T) { return nil }, } - cmd := newTestShortcutCmd(s) + cmd := newTestShortcutCmd(s, newTestFactory()) cmd.Flags().Set("jq", "invalid[") cmd.Flags().Set("as", "bot") @@ -223,7 +223,7 @@ func TestRunShortcut_JqRuntimeError_PropagatesError(t *testing.T) { return nil }, } - cmd := newTestShortcutCmd(s) + cmd := newTestShortcutCmd(s, newTestFactory()) cmd.Flags().Set("jq", ".foo | invalid_func_xyz") cmd.Flags().Set("as", "bot") diff --git a/shortcuts/register.go b/shortcuts/register.go index 09d3813b..0a745a0c 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -4,6 +4,8 @@ package shortcuts import ( + "context" + "github.com/spf13/cobra" "github.com/larksuite/cli/internal/cmdutil" @@ -56,6 +58,10 @@ func AllShortcuts() []common.Shortcut { // RegisterShortcuts registers all +shortcut commands on the program. func RegisterShortcuts(program *cobra.Command, f *cmdutil.Factory) { + RegisterShortcutsWithContext(context.Background(), program, f) +} + +func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f *cmdutil.Factory) { // Group by service byService := make(map[string][]common.Shortcut) for _, s := range allShortcuts { @@ -84,7 +90,7 @@ func RegisterShortcuts(program *cobra.Command, f *cmdutil.Factory) { } for _, shortcut := range shortcuts { - shortcut.Mount(svc, f) + shortcut.MountWithContext(ctx, svc, f) } } }