From f7e8e356eb5b176d9affe83d5a7eef1d4f220387 Mon Sep 17 00:00:00 2001 From: tuxedomm <273098272+tuxedomm@users.noreply.github.com> Date: Thu, 9 Apr 2026 20:58:46 +0800 Subject: [PATCH 1/5] feat: add SetDefaultFS to allow replacing the global filesystem implementation Change-Id: If5c3e50e84859f9ac4ffceeb0ac3dc7b7330b274 --- cmd/build.go | 115 +++++++++++++++++++++++ cmd/init.go | 18 ++++ cmd/root.go | 43 +-------- cmd/root_integration_test.go | 2 +- internal/cmdutil/factory_default.go | 27 +++--- internal/cmdutil/factory_default_test.go | 12 +-- internal/cmdutil/factory_http_test.go | 7 +- internal/cmdutil/iostreams.go | 17 +++- internal/credential/default_provider.go | 9 +- internal/credential/integration_test.go | 3 +- 10 files changed, 181 insertions(+), 72 deletions(-) create mode 100644 cmd/build.go create mode 100644 cmd/init.go diff --git a/cmd/build.go b/cmd/build.go new file mode 100644 index 00000000..dd03d005 --- /dev/null +++ b/cmd/build.go @@ -0,0 +1,115 @@ +// 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 +} + +// WithIO sets the IO streams for the CLI. If not provided, os.Stdin/Stdout/Stderr are used. +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 + } +} + +// 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 the internal constructor that also returns Factory for error handling. +func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) (*cmdutil.Factory, *cobra.Command) { + cfg := &buildConfig{ + streams: cmdutil.SystemIO(), + } + for _, o := range opts { + o(cfg) + } + + f := cmdutil.NewDefault(cfg.streams, inv) + if cfg.keychain != nil { + f.Keychain = cfg.keychain + } + + 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, + } + + 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(), 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(ctx); mode.IsActive() { + pruneForStrictMode(rootCmd, mode) + } + + return f, rootCmd +} 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..8088346a 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,7 @@ 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) // --- Update check (non-blocking) --- if !isCompletionCommand(os.Args) { 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/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/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}, From db422ef24ae773cc3bb5f1364a4aa80d90eff5f9 Mon Sep 17 00:00:00 2001 From: tuxedomm <273098272+tuxedomm@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:23:31 +0800 Subject: [PATCH 2/5] feat(schema): filter methods by strict mode in schema output When strict mode is active, schema output now excludes methods that are incompatible with the forced identity. This applies to both pretty and JSON output formats at the resource and method levels. Change-Id: I39647d5578466c3e23dc545bfb917ae075203ad7 --- cmd/schema/schema.go | 97 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 92 insertions(+), 5 deletions(-) diff --git a/cmd/schema/schema.go b/cmd/schema/schema.go index f45d6e81..6dca541f 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) } @@ -451,6 +459,7 @@ func completeSchemaPath(_ *cobra.Command, args []string, toComplete string) ([]s func schemaRun(opts *SchemaOptions) error { out := opts.Factory.IOStreams.Out + mode := opts.Factory.ResolveStrictMode(opts.Ctx) if opts.Path == "" { printServices(out) @@ -469,9 +478,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 +501,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 +510,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 +548,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 +} From 254d7e7e0c2d730d2ba5d62e8a33097cde1749e5 Mon Sep 17 00:00:00 2001 From: tuxedomm <273098272+tuxedomm@users.noreply.github.com> Date: Sat, 11 Apr 2026 14:36:30 +0800 Subject: [PATCH 3/5] refactor: centralize strict-mode as flag registration Change-Id: Iec11151c5002c2f58a8aa067d08747db2e4d2d8c --- cmd/api/api.go | 5 +- cmd/api/api_test.go | 18 +++++ cmd/service/service.go | 26 +++---- cmd/service/service_test.go | 18 +++++ internal/cmdutil/identity_flag.go | 68 +++++++++++++++++++ internal/cmdutil/identity_flag_test.go | 67 ++++++++++++++++++ shortcuts/common/runner.go | 10 +-- shortcuts/common/runner_identity_flag_test.go | 45 ++++++++++++ shortcuts/common/runner_jq_test.go | 10 +-- 9 files changed, 236 insertions(+), 31 deletions(-) create mode 100644 internal/cmdutil/identity_flag.go create mode 100644 internal/cmdutil/identity_flag_test.go create mode 100644 shortcuts/common/runner_identity_flag_test.go diff --git a/cmd/api/api.go b/cmd/api/api.go index 1fe651d0..e4f83b4d 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -79,7 +79,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(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 +96,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/service/service.go b/cmd/service/service.go index 5639075b..f5d9ae1a 100644 --- a/cmd/service/service.go +++ b/cmd/service/service.go @@ -101,16 +101,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 } @@ -159,7 +159,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(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 +177,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/identity_flag.go b/internal/cmdutil/identity_flag.go new file mode 100644 index 00000000..99b7ed3d --- /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(cmd *cobra.Command, f *Factory, target *string) { + addIdentityFlag(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(cmd *cobra.Command, f *Factory, authTypes []string) { + if len(authTypes) == 0 { + authTypes = []string{"user"} + } + addIdentityFlag(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(cmd *cobra.Command, f *Factory, target *string, cfg identityFlagConfig) { + if forced := f.ResolveStrictMode(context.Background()).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..2f135075 --- /dev/null +++ b/internal/cmdutil/identity_flag_test.go @@ -0,0 +1,67 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "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(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(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(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/shortcuts/common/runner.go b/shortcuts/common/runner.go index 1ddb69e5..0bcc6c76 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -592,7 +592,7 @@ func (s Shortcut) mountDeclarative(parent *cobra.Command, f *cmdutil.Factory) { }, } cmdutil.SetSupportedIdentities(cmd, shortcut.AuthTypes) - registerShortcutFlags(cmd, &shortcut) + registerShortcutFlags(cmd, f, &shortcut) cmdutil.SetTips(cmd, shortcut.Tips) parent.AddCommand(cmd) } @@ -823,7 +823,7 @@ func rejectPositionalArgs() cobra.PositionalArgs { } } -func registerShortcutFlags(cmd *cobra.Command, s *Shortcut) { +func registerShortcutFlags(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) { for _, fl := range s.Flags { desc := fl.Desc if len(fl.Enum) > 0 { @@ -874,11 +874,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(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") From 5040edbfcbb56297e0bc275ee53beb4893516718 Mon Sep 17 00:00:00 2001 From: tuxedomm <273098272+tuxedomm@users.noreply.github.com> Date: Sat, 11 Apr 2026 14:49:21 +0800 Subject: [PATCH 4/5] fix: align strict-mode completion and build context Change-Id: I1d8da0dd4636384203cb748605682677ad1c4d92 --- cmd/api/api.go | 6 +- cmd/build.go | 6 +- cmd/schema/schema.go | 111 +++++++++++++------------ cmd/schema/schema_test.go | 46 ++++++++++ cmd/service/service.go | 30 +++++-- internal/cmdutil/identity_flag.go | 12 +-- internal/cmdutil/identity_flag_test.go | 7 +- shortcuts/common/runner.go | 16 +++- shortcuts/register.go | 8 +- 9 files changed, 167 insertions(+), 75 deletions(-) diff --git a/cmd/api/api.go b/cmd/api/api.go index e4f83b4d..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)") - cmdutil.AddAPIIdentityFlag(cmd, f, &asStr) + 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)") diff --git a/cmd/build.go b/cmd/build.go index dd03d005..7a10e66c 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -99,12 +99,12 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B rootCmd.AddCommand(auth.NewCmdAuth(f)) rootCmd.AddCommand(profile.NewCmdProfile(f)) rootCmd.AddCommand(doctor.NewCmdDoctor(f)) - rootCmd.AddCommand(api.NewCmdApi(f, nil)) + rootCmd.AddCommand(api.NewCmdApiWithContext(ctx, 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) + service.RegisterServiceCommandsWithContext(ctx, rootCmd, f) + shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f) // Prune commands incompatible with strict mode. if mode := f.ResolveStrictMode(ctx); mode.IsActive() { diff --git a/cmd/schema/schema.go b/cmd/schema/schema.go index 6dca541f..152f37d2 100644 --- a/cmd/schema/schema.go +++ b/cmd/schema/schema.go @@ -375,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 @@ -387,74 +387,81 @@ 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 = everything user typed after "serviceName." - afterService := strings.Join(parts[1:], ".") + afterService := strings.Join(parts[1:], ".") + completions := completeSchemaPathForSpec(serviceName, resources, afterService) + allTrailingDot := len(completions) > 0 + for _, c := range completions { + if !strings.HasSuffix(c, ".") { + allTrailingDot = false + break + } + } + directive := cobra.ShellCompDirectiveNoFileComp + if allTrailingDot { + directive |= cobra.ShellCompDirectiveNoSpace + } + return completions, directive + } +} + +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 { 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 f5d9ae1a..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) } } @@ -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)") } - cmdutil.AddAPIIdentityFlag(cmd, f, &asStr) + 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)") diff --git a/internal/cmdutil/identity_flag.go b/internal/cmdutil/identity_flag.go index 99b7ed3d..c99d5c62 100644 --- a/internal/cmdutil/identity_flag.go +++ b/internal/cmdutil/identity_flag.go @@ -12,8 +12,8 @@ import ( ) // AddAPIIdentityFlag registers the standard --as flag shape used by api/service commands. -func AddAPIIdentityFlag(cmd *cobra.Command, f *Factory, target *string) { - addIdentityFlag(cmd, f, target, identityFlagConfig{ +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"}, @@ -21,11 +21,11 @@ func AddAPIIdentityFlag(cmd *cobra.Command, f *Factory, target *string) { } // AddShortcutIdentityFlag registers the standard --as flag shape used by shortcuts. -func AddShortcutIdentityFlag(cmd *cobra.Command, f *Factory, authTypes []string) { +func AddShortcutIdentityFlag(ctx context.Context, cmd *cobra.Command, f *Factory, authTypes []string) { if len(authTypes) == 0 { authTypes = []string{"user"} } - addIdentityFlag(cmd, f, nil, identityFlagConfig{ + addIdentityFlag(ctx, cmd, f, nil, identityFlagConfig{ defaultValue: authTypes[0], usage: "identity type: " + strings.Join(authTypes, " | "), completionValues: authTypes, @@ -41,8 +41,8 @@ type identityFlagConfig struct { // 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(cmd *cobra.Command, f *Factory, target *string, cfg identityFlagConfig) { - if forced := f.ResolveStrictMode(context.Background()).ForcedIdentity(); forced != "" { +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. diff --git a/internal/cmdutil/identity_flag_test.go b/internal/cmdutil/identity_flag_test.go index 2f135075..fa93d726 100644 --- a/internal/cmdutil/identity_flag_test.go +++ b/internal/cmdutil/identity_flag_test.go @@ -4,6 +4,7 @@ package cmdutil import ( + "context" "testing" "github.com/larksuite/cli/internal/core" @@ -14,7 +15,7 @@ func TestAddAPIIdentityFlag_NonStrictMode(t *testing.T) { f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) cmd := &cobra.Command{Use: "test"} - AddAPIIdentityFlag(cmd, f, nil) + AddAPIIdentityFlag(context.Background(), cmd, f, nil) flag := cmd.Flags().Lookup("as") if flag == nil { @@ -34,7 +35,7 @@ func TestAddAPIIdentityFlag_StrictModeHidesFlagAndLocksDefault(t *testing.T) { }) cmd := &cobra.Command{Use: "test"} - AddAPIIdentityFlag(cmd, f, nil) + AddAPIIdentityFlag(context.Background(), cmd, f, nil) flag := cmd.Flags().Lookup("as") if flag == nil { @@ -52,7 +53,7 @@ func TestAddShortcutIdentityFlag_UsesAuthTypes(t *testing.T) { f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) cmd := &cobra.Command{Use: "test"} - AddShortcutIdentityFlag(cmd, f, []string{"bot"}) + AddShortcutIdentityFlag(context.Background(), cmd, f, []string{"bot"}) flag := cmd.Flags().Lookup("as") if flag == nil { diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 0bcc6c76..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, f, &shortcut) + registerShortcutFlagsWithContext(ctx, cmd, f, &shortcut) cmdutil.SetTips(cmd, shortcut.Tips) parent.AddCommand(cmd) } @@ -824,6 +828,10 @@ func rejectPositionalArgs() cobra.PositionalArgs { } 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,7 +882,7 @@ func registerShortcutFlags(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) cmd.Flags().Bool("yes", false, "confirm high-risk operation") } cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output") - cmdutil.AddShortcutIdentityFlag(cmd, f, s.AuthTypes) + 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/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) } } } From bcbc1bf42af539956366d246a3b32163fea42f3a Mon Sep 17 00:00:00 2001 From: tuxedomm <273098272+tuxedomm@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:53:50 +0800 Subject: [PATCH 5/5] refactor(cmd): hide --profile in single-app mode via build option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GlobalOptions gains HideProfile; RegisterGlobalFlags stays pure and reads the policy off the struct. No boolean-trap parameter, one call per site. - buildConfig holds GlobalOptions inline so HideProfile(bool) BuildOption mutates it directly. buildInternal stays a pure assembly function and requires callers to supply WithIO — no implicit os.Std* fallback. - Add WithIO BuildOption (wrapping raw io.Reader/Writer with automatic *os.File TTY detection); Execute injects streams explicitly and decides profile visibility via HideProfile(isSingleAppMode()). - installTipsHelpFunc force-shows hidden root flags while rendering the root command's own help, so single-app users still discover --profile via lark-cli --help without it polluting subcommand helps. Change-Id: I7755387e993992ca969e0a4a6f54441cc1993eef --- cmd/build.go | 33 +++++++++--- cmd/global_flags.go | 31 +++++++++-- cmd/global_flags_test.go | 110 +++++++++++++++++++++++++++++++++++++++ cmd/root.go | 17 +++++- 4 files changed, 177 insertions(+), 14 deletions(-) create mode 100644 cmd/global_flags_test.go diff --git a/cmd/build.go b/cmd/build.go index 7a10e66c..c50d6ec1 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -32,9 +32,12 @@ type BuildOption func(*buildConfig) type buildConfig struct { streams *cmdutil.IOStreams keychain keychain.KeychainAccess + globals GlobalOptions } -// WithIO sets the IO streams for the CLI. If not provided, os.Stdin/Stdout/Stderr are used. +// 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 @@ -52,6 +55,16 @@ func WithKeychain(kc keychain.KeychainAccess) BuildOption { } } +// 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. @@ -60,11 +73,17 @@ func Build(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOpti return rootCmd } -// buildInternal is the internal constructor that also returns Factory for error handling. +// 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 := &buildConfig{ - streams: cmdutil.SystemIO(), - } + // 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) } @@ -73,8 +92,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B if cfg.keychain != nil { f.Keychain = cfg.keychain } - - globals := &GlobalOptions{Profile: inv.Profile} rootCmd := &cobra.Command{ Use: "lark-cli", Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls", @@ -90,7 +107,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B installTipsHelpFunc(rootCmd) rootCmd.SilenceErrors = true - RegisterGlobalFlags(rootCmd.PersistentFlags(), globals) + RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals) rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { cmd.SilenceUsage = true } 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/root.go b/cmd/root.go index 8088346a..57c91d8b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -85,7 +85,11 @@ func Execute() int { fmt.Fprintln(os.Stderr, "Error:", err) return 1 } - f, rootCmd := buildInternal(context.Background(), inv) + f, rootCmd := buildInternal( + context.Background(), inv, + WithIO(os.Stdin, os.Stdout, os.Stderr), + HideProfile(isSingleAppMode()), + ) // --- Update check (non-blocking) --- if !isCompletionCommand(os.Args) { @@ -236,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 {