From 95ef221cb1223829b9951fa64b21296454d47bf4 Mon Sep 17 00:00:00 2001 From: chen Date: Mon, 13 Apr 2026 16:07:18 +0800 Subject: [PATCH 1/2] feat: support LARKSUITE_CLI_PROFILE env var for multi-app selection Use LARKSUITE_CLI_PROFILE (not LARKSUITE_CLI_APP_ID which collides with the env credential provider) for profile-based app selection. The env var accepts both profile names and app IDs, consistent with --profile flag. - Add envvars.CliProfile constant - Rename standalone FindApp to FindAppByID to avoid shadowing the method - Use multi.CurrentAppConfig() in ActiveApp for name+appId resolution - Add test for CurrentApp fallback when env var is unset Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/core/config.go | 71 +++++++++++-- internal/core/config_test.go | 192 +++++++++++++++++++++++++++++++++-- internal/envvars/envvars.go | 1 + 3 files changed, 248 insertions(+), 16 deletions(-) diff --git a/internal/core/config.go b/internal/core/config.go index 8570d5f3..5a7f0b36 100644 --- a/internal/core/config.go +++ b/internal/core/config.go @@ -12,6 +12,7 @@ import ( "strings" "unicode/utf8" + "github.com/larksuite/cli/internal/envvars" "github.com/larksuite/cli/internal/keychain" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" @@ -86,6 +87,52 @@ func (m *MultiAppConfig) CurrentAppConfig(profileOverride string) *AppConfig { return nil } +// FindAppByID searches apps by appId and returns a pointer to the matching AppConfig. +// Returns a ConfigError listing available IDs if not found. +func FindAppByID(apps []AppConfig, appID string) (*AppConfig, error) { + for i := range apps { + if apps[i].AppId == appID { + return &apps[i], nil + } + } + ids := make([]string, len(apps)) + for i, a := range apps { + ids[i] = a.AppId + } + return nil, &ConfigError{ + Code: 2, + Type: "config", + Message: fmt.Sprintf("app %q not found in config; available: %s", appID, strings.Join(ids, ", ")), + Hint: "check LARKSUITE_CLI_PROFILE or run `lark-cli config init`", + } +} + +// ActiveApp returns the active app from a MultiAppConfig. +// Resolution priority: LARKSUITE_CLI_PROFILE env var > CurrentApp field > Apps[0]. +func ActiveApp(multi *MultiAppConfig) (*AppConfig, error) { + if len(multi.Apps) == 0 { + return nil, &ConfigError{Code: 2, Type: "config", Message: "no apps configured", Hint: "run `lark-cli config init`"} + } + if envProfile := os.Getenv(envvars.CliProfile); envProfile != "" { + app := multi.CurrentAppConfig(envProfile) + if app == nil { + return nil, &ConfigError{ + Code: 2, + Type: "config", + Message: fmt.Sprintf("profile %q not found", envProfile), + Hint: fmt.Sprintf("available profiles: %s", formatProfileNames(multi.ProfileNames())), + } + } + return app, nil + } + // Fall back to CurrentAppConfig's own resolution (CurrentApp field > Apps[0]). + app := multi.CurrentAppConfig("") + if app == nil { + return nil, &ConfigError{Code: 2, Type: "config", Message: "no apps configured", Hint: "run `lark-cli config init`"} + } + return app, nil +} + // FindApp looks up an app by name, then by appId. Returns nil if not found. // Name match takes priority: if profile A has Name "X" and profile B has AppId "X", // FindApp("X") returns profile A. @@ -239,14 +286,24 @@ func RequireConfigForProfile(kc keychain.KeychainAccess, profileOverride string) // ResolveConfigFromMulti resolves a single-app config from an already-loaded MultiAppConfig. // This avoids re-reading the config file when the caller has already loaded it. +// Resolution priority: profileOverride > LARKSUITE_CLI_PROFILE env var > config.CurrentApp > Apps[0]. func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, profileOverride string) (*CliConfig, error) { - app := raw.CurrentAppConfig(profileOverride) - if app == nil { - return nil, &ConfigError{ - Code: 2, - Type: "config", - Message: fmt.Sprintf("profile %q not found", profileOverride), - Hint: fmt.Sprintf("available profiles: %s", formatProfileNames(raw.ProfileNames())), + var app *AppConfig + if profileOverride != "" { + app = raw.CurrentAppConfig(profileOverride) + if app == nil { + return nil, &ConfigError{ + Code: 2, + Type: "config", + Message: fmt.Sprintf("profile %q not found", profileOverride), + Hint: fmt.Sprintf("available profiles: %s", formatProfileNames(raw.ProfileNames())), + } + } + } else { + var err error + app, err = ActiveApp(raw) + if err != nil { + return nil, err } } diff --git a/internal/core/config_test.go b/internal/core/config_test.go index 3d727c50..3d5d6f52 100644 --- a/internal/core/config_test.go +++ b/internal/core/config_test.go @@ -85,6 +85,174 @@ func TestMultiAppConfig_RoundTrip(t *testing.T) { } } +// noopKeychain satisfies keychain.KeychainAccess for tests (returns empty, no error). +type noopKeychain struct{} + +func (n *noopKeychain) Get(service, account string) (string, error) { return "", nil } +func (n *noopKeychain) Set(service, account, value string) error { return nil } +func (n *noopKeychain) Remove(service, account string) error { return nil } + +func TestFindAppByID_Found(t *testing.T) { + apps := []AppConfig{ + {AppId: "cli_aaa", Brand: BrandFeishu}, + {AppId: "cli_bbb", Brand: BrandLark}, + } + got, err := FindAppByID(apps, "cli_bbb") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.AppId != "cli_bbb" { + t.Errorf("AppId = %q, want %q", got.AppId, "cli_bbb") + } +} + +func TestFindAppByID_NotFound(t *testing.T) { + apps := []AppConfig{ + {AppId: "cli_aaa", Brand: BrandFeishu}, + } + _, err := FindAppByID(apps, "cli_zzz") + if err == nil { + t.Fatal("expected error for missing app") + } + var cfgErr *ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("expected ConfigError, got %T", err) + } +} + +func TestActiveApp_EnvVar(t *testing.T) { + t.Setenv("LARKSUITE_CLI_PROFILE", "cli_bbb") + multi := &MultiAppConfig{ + Apps: []AppConfig{ + {AppId: "cli_aaa"}, + {AppId: "cli_bbb"}, + }, + } + got, err := ActiveApp(multi) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.AppId != "cli_bbb" { + t.Errorf("AppId = %q, want %q", got.AppId, "cli_bbb") + } +} + +func TestActiveApp_FallsBackToFirst(t *testing.T) { + t.Setenv("LARKSUITE_CLI_PROFILE", "") + multi := &MultiAppConfig{ + Apps: []AppConfig{ + {AppId: "cli_aaa"}, + {AppId: "cli_bbb"}, + }, + } + got, err := ActiveApp(multi) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.AppId != "cli_aaa" { + t.Errorf("AppId = %q, want %q", got.AppId, "cli_aaa") + } +} + +func TestActiveApp_FallsBackToCurrentApp(t *testing.T) { + t.Setenv("LARKSUITE_CLI_PROFILE", "") + multi := &MultiAppConfig{ + CurrentApp: "cli_bbb", + Apps: []AppConfig{ + {AppId: "cli_aaa"}, + {AppId: "cli_bbb"}, + }, + } + got, err := ActiveApp(multi) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.AppId != "cli_bbb" { + t.Errorf("AppId = %q, want %q", got.AppId, "cli_bbb") + } +} + +func TestActiveApp_EnvVarNotFound(t *testing.T) { + t.Setenv("LARKSUITE_CLI_PROFILE", "cli_missing") + multi := &MultiAppConfig{ + Apps: []AppConfig{{AppId: "cli_aaa"}}, + } + _, err := ActiveApp(multi) + if err == nil { + t.Fatal("expected error for missing app") + } +} + +func TestRequireConfig_EnvVarSelectsApp(t *testing.T) { + tmp := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp) + t.Setenv("LARKSUITE_CLI_PROFILE", "cli_bbb") + + config := &MultiAppConfig{ + Apps: []AppConfig{ + {AppId: "cli_aaa", AppSecret: PlainSecret("sec_a"), Brand: BrandFeishu, Users: []AppUser{}}, + {AppId: "cli_bbb", AppSecret: PlainSecret("sec_b"), Brand: BrandLark, Users: []AppUser{{UserOpenId: "ou_123", UserName: "bob"}}}, + }, + } + if err := SaveMultiAppConfig(config); err != nil { + t.Fatalf("save: %v", err) + } + + got, err := RequireConfig(&noopKeychain{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.AppID != "cli_bbb" { + t.Errorf("AppID = %q, want %q", got.AppID, "cli_bbb") + } + if got.UserOpenId != "ou_123" { + t.Errorf("UserOpenId = %q, want %q", got.UserOpenId, "ou_123") + } +} + +func TestRequireConfig_DefaultsToFirstApp(t *testing.T) { + tmp := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp) + t.Setenv("LARKSUITE_CLI_PROFILE", "") + + config := &MultiAppConfig{ + Apps: []AppConfig{ + {AppId: "cli_first", AppSecret: PlainSecret("sec"), Brand: BrandFeishu, Users: []AppUser{}}, + }, + } + if err := SaveMultiAppConfig(config); err != nil { + t.Fatalf("save: %v", err) + } + + got, err := RequireConfig(&noopKeychain{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.AppID != "cli_first" { + t.Errorf("AppID = %q, want %q", got.AppID, "cli_first") + } +} + +func TestRequireConfig_EnvVarNotFound(t *testing.T) { + tmp := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp) + t.Setenv("LARKSUITE_CLI_PROFILE", "cli_missing") + + config := &MultiAppConfig{ + Apps: []AppConfig{ + {AppId: "cli_aaa", AppSecret: PlainSecret("sec"), Brand: BrandFeishu, Users: []AppUser{}}, + }, + } + if err := SaveMultiAppConfig(config); err != nil { + t.Fatalf("save: %v", err) + } + + _, err := RequireConfig(&noopKeychain{}) + if err == nil { + t.Fatal("expected error for missing app") + } +} + func TestResolveConfigFromMulti_RejectsSecretKeyMismatch(t *testing.T) { raw := &MultiAppConfig{ Apps: []AppConfig{ @@ -164,27 +332,33 @@ func TestResolveConfigFromMulti_MatchingKeychainRefPassesValidation(t *testing.T } } -func TestResolveConfigFromMulti_DoesNotUseEnvProfileFallback(t *testing.T) { - t.Setenv("LARKSUITE_CLI_PROFILE", "missing") +func TestResolveConfigFromMulti_ProfileOverrideTakesPrecedenceOverEnv(t *testing.T) { + t.Setenv("LARKSUITE_CLI_PROFILE", "env-profile") raw := &MultiAppConfig{ - CurrentApp: "active", Apps: []AppConfig{ { - Name: "active", - AppId: "cli_active", - AppSecret: PlainSecret("secret"), + Name: "env-profile", + AppId: "cli_env", + AppSecret: PlainSecret("secret_env"), + Brand: BrandFeishu, + }, + { + Name: "explicit", + AppId: "cli_explicit", + AppSecret: PlainSecret("secret_explicit"), Brand: BrandFeishu, }, }, } - cfg, err := ResolveConfigFromMulti(raw, nil, "") + // When profileOverride is set, it should be used instead of the env var. + cfg, err := ResolveConfigFromMulti(raw, nil, "explicit") if err != nil { t.Fatalf("ResolveConfigFromMulti() error = %v", err) } - if cfg.ProfileName != "active" { - t.Fatalf("ResolveConfigFromMulti() profile = %q, want %q", cfg.ProfileName, "active") + if cfg.ProfileName != "explicit" { + t.Fatalf("ResolveConfigFromMulti() profile = %q, want %q", cfg.ProfileName, "explicit") } } diff --git a/internal/envvars/envvars.go b/internal/envvars/envvars.go index 1d80ac1c..a4c16d8b 100644 --- a/internal/envvars/envvars.go +++ b/internal/envvars/envvars.go @@ -11,4 +11,5 @@ const ( CliTenantAccessToken = "LARKSUITE_CLI_TENANT_ACCESS_TOKEN" CliDefaultAs = "LARKSUITE_CLI_DEFAULT_AS" CliStrictMode = "LARKSUITE_CLI_STRICT_MODE" + CliProfile = "LARKSUITE_CLI_PROFILE" ) From 79cf353670eb7694773ed5fa236d9bdff88a6b6a Mon Sep 17 00:00:00 2001 From: chen Date: Mon, 13 Apr 2026 16:42:41 +0800 Subject: [PATCH 2/2] feat: bootstrap reads LARKSUITE_CLI_PROFILE env var as --profile fallback Commands that use f.Invocation.Profile directly (config show, auth logout, auth list, config default-as) now respect the env var. Priority: --profile flag > LARKSUITE_CLI_PROFILE env var > (none). Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/bootstrap.go | 8 +++++++- cmd/bootstrap_test.go | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/cmd/bootstrap.go b/cmd/bootstrap.go index 841a8840..29cbadb2 100644 --- a/cmd/bootstrap.go +++ b/cmd/bootstrap.go @@ -6,8 +6,10 @@ package cmd import ( "errors" "io" + "os" "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/envvars" "github.com/spf13/pflag" ) @@ -26,5 +28,9 @@ func BootstrapInvocationContext(args []string) (cmdutil.InvocationContext, error if err := fs.Parse(args); err != nil && !errors.Is(err, pflag.ErrHelp) { return cmdutil.InvocationContext{}, err } - return cmdutil.InvocationContext{Profile: globals.Profile}, nil + profile := globals.Profile + if profile == "" { + profile = os.Getenv(envvars.CliProfile) + } + return cmdutil.InvocationContext{Profile: profile}, nil } diff --git a/cmd/bootstrap_test.go b/cmd/bootstrap_test.go index aa5fd3de..238535cc 100644 --- a/cmd/bootstrap_test.go +++ b/cmd/bootstrap_test.go @@ -61,6 +61,28 @@ func TestBootstrapInvocationContext_ShortHelp(t *testing.T) { } } +func TestBootstrapInvocationContext_EnvVarFallback(t *testing.T) { + t.Setenv("LARKSUITE_CLI_PROFILE", "env-profile") + inv, err := BootstrapInvocationContext([]string{"auth", "status"}) + if err != nil { + t.Fatalf("BootstrapInvocationContext() error = %v", err) + } + if inv.Profile != "env-profile" { + t.Fatalf("profile = %q, want %q", inv.Profile, "env-profile") + } +} + +func TestBootstrapInvocationContext_FlagOverridesEnvVar(t *testing.T) { + t.Setenv("LARKSUITE_CLI_PROFILE", "env-profile") + inv, err := BootstrapInvocationContext([]string{"--profile", "flag-profile", "auth", "status"}) + if err != nil { + t.Fatalf("BootstrapInvocationContext() error = %v", err) + } + if inv.Profile != "flag-profile" { + t.Fatalf("profile = %q, want %q", inv.Profile, "flag-profile") + } +} + func TestBootstrapInvocationContext_HelpWithProfile(t *testing.T) { inv, err := BootstrapInvocationContext([]string{"--profile", "target", "--help"}) if err != nil {