diff --git a/cmd/auth/list.go b/cmd/auth/list.go index d92a028cb3..abe038cba8 100644 --- a/cmd/auth/list.go +++ b/cmd/auth/list.go @@ -45,6 +45,9 @@ func authListRun(opts *ListOptions) error { f := opts.Factory multi, _ := core.LoadMultiAppConfig() + if err := cmdutil.ProjectProfileError(f.Invocation, multi); err != nil { + return err + } if multi == nil || len(multi.Apps) == 0 { if opts.JSON { output.PrintJson(f.IOStreams.Out, map[string]interface{}{ diff --git a/cmd/auth/list_test.go b/cmd/auth/list_test.go index 070e4fae15..220b911b88 100644 --- a/cmd/auth/list_test.go +++ b/cmd/auth/list_test.go @@ -5,6 +5,7 @@ package auth import ( "encoding/json" + "errors" "strings" "testing" @@ -62,6 +63,31 @@ func TestAuthListRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) { } } +func TestAuthListRun_ProjectProfileMissingFailsClosedWhenNotConfigured(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + projectPath := core.ProjectConfigPath(t.TempDir()) + f, _, _, _ := cmdutil.TestFactory(t, nil) + f.Invocation = cmdutil.InvocationContext{ + Profile: "missing", + ProfileSource: core.ProfileSourceProject, + ProfileConfigPath: projectPath, + } + err := authListRun(&ListOptions{Factory: f, JSON: true}) + if err == nil { + t.Fatal("authListRun() error = nil, want project profile not found") + } + var cfgErr *core.ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("error type = %T, want *core.ConfigError", err) + } + wantMsg := `profile "missing" is configured by project but not found` + wantHint := "project config: " + projectPath + "; run: lark-cli profile list" + if cfgErr.Code != 3 || cfgErr.Type != "config" || cfgErr.Message != wantMsg || cfgErr.Hint != wantHint { + t.Fatalf("ConfigError = %#v", cfgErr) + } +} + // TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp covers the // reason this hint exists workspace-aware in the first place: an AI agent // in OpenClaw / Hermes that probes auth list before binding gets routed to diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index 7e82127edd..8cd5e7ce76 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -45,6 +45,9 @@ func authLogoutRun(opts *LogoutOptions) error { f := opts.Factory multi, _ := core.LoadMultiAppConfig() + if err := cmdutil.ProjectProfileError(f.Invocation, multi); err != nil { + return err + } if multi == nil || len(multi.Apps) == 0 { if opts.JSON { output.PrintJson(f.IOStreams.Out, map[string]interface{}{ diff --git a/cmd/auth/logout_test.go b/cmd/auth/logout_test.go index 613e470548..8f7e1b5a98 100644 --- a/cmd/auth/logout_test.go +++ b/cmd/auth/logout_test.go @@ -5,6 +5,7 @@ package auth import ( "encoding/json" + "errors" "net/url" "strings" "testing" @@ -59,6 +60,31 @@ func TestAuthLogoutRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) { } } +func TestAuthLogoutRun_ProjectProfileMissingFailsClosedWhenNotConfigured(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + projectPath := core.ProjectConfigPath(t.TempDir()) + f, _, _, _ := cmdutil.TestFactory(t, nil) + f.Invocation = cmdutil.InvocationContext{ + Profile: "missing", + ProfileSource: core.ProfileSourceProject, + ProfileConfigPath: projectPath, + } + err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}) + if err == nil { + t.Fatal("authLogoutRun() error = nil, want project profile not found") + } + var cfgErr *core.ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("error type = %T, want *core.ConfigError", err) + } + wantMsg := `profile "missing" is configured by project but not found` + wantHint := "project config: " + projectPath + "; run: lark-cli profile list" + if cfgErr.Code != 3 || cfgErr.Type != "config" || cfgErr.Message != wantMsg || cfgErr.Hint != wantHint { + t.Fatalf("ConfigError = %#v", cfgErr) + } +} + func TestAuthLogoutRun_JSONMode_NotLoggedIn_WritesStdoutOnly(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) writeLogoutConfig(t, nil) diff --git a/cmd/bootstrap.go b/cmd/bootstrap.go index 841a884094..5a4dced0b0 100644 --- a/cmd/bootstrap.go +++ b/cmd/bootstrap.go @@ -8,6 +8,7 @@ import ( "io" "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" "github.com/spf13/pflag" ) @@ -26,5 +27,46 @@ 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 + if globals.Profile != "" { + return cmdutil.InvocationContext{ + Profile: globals.Profile, + ProfileSource: core.ProfileSourceCLI, + }, nil + } + if skipProjectProfileLookup(args, fs.Args()) { + return cmdutil.InvocationContext{ProfileSource: core.ProfileSourceGlobal}, nil + } + project, err := core.ResolveProjectProfile() + if err != nil { + return cmdutil.InvocationContext{}, err + } + if project != nil { + return cmdutil.InvocationContext{ + Profile: project.Profile, + ProfileSource: core.ProfileSourceProject, + ProfileConfigPath: project.Path, + }, nil + } + return cmdutil.InvocationContext{ProfileSource: core.ProfileSourceGlobal}, nil +} + +func skipProjectProfileLookup(rawArgs, positionals []string) bool { + for _, arg := range rawArgs { + if arg == "-h" || arg == "--help" { + return true + } + } + if len(positionals) == 0 { + return false + } + switch positionals[0] { + case "completion", "__complete", "__completeNoDesc": + return true + case "profile": + return len(positionals) < 2 || positionals[1] != "current" + case "config": + return len(positionals) >= 2 && (positionals[1] == "bind" || positionals[1] == "init" || positionals[1] == "remove") + default: + return false + } } diff --git a/cmd/bootstrap_test.go b/cmd/bootstrap_test.go index aa5fd3de79..f683d21a0b 100644 --- a/cmd/bootstrap_test.go +++ b/cmd/bootstrap_test.go @@ -3,7 +3,27 @@ package cmd -import "testing" +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +func writeBootstrapProjectConfig(t *testing.T, dir, body string) string { + t.Helper() + path := core.ProjectConfigPath(dir) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatalf("MkdirAll(project config dir): %v", err) + } + if err := os.WriteFile(path, []byte(body), 0600); err != nil { + t.Fatalf("WriteFile(project config): %v", err) + } + return path +} func TestBootstrapInvocationContext_ProfileFlag(t *testing.T) { inv, err := BootstrapInvocationContext([]string{"--profile", "target", "auth", "status"}) @@ -70,3 +90,161 @@ func TestBootstrapInvocationContext_HelpWithProfile(t *testing.T) { t.Fatalf("profile = %q, want %q", inv.Profile, "target") } } + +func TestBootstrapInvocationContext_ProjectProfile(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + repo := t.TempDir() + if err := os.Mkdir(filepath.Join(repo, ".git"), 0700); err != nil { + t.Fatalf("Mkdir(.git): %v", err) + } + writeBootstrapProjectConfig(t, repo, `{"profile":"bytedance"}`) + sub := filepath.Join(repo, "sub") + if err := os.MkdirAll(sub, 0700); err != nil { + t.Fatalf("MkdirAll(sub): %v", err) + } + cmdutil.TestChdir(t, sub) + + inv, err := BootstrapInvocationContext([]string{"auth", "status"}) + if err != nil { + t.Fatalf("BootstrapInvocationContext() error = %v", err) + } + if inv.Profile != "bytedance" { + t.Fatalf("profile = %q, want bytedance", inv.Profile) + } + if inv.ProfileSource != core.ProfileSourceProject { + t.Fatalf("ProfileSource = %q, want project", inv.ProfileSource) + } + wantRepo, err := filepath.EvalSymlinks(repo) + if err != nil { + t.Fatalf("EvalSymlinks(repo): %v", err) + } + if inv.ProfileConfigPath != core.ProjectConfigPath(wantRepo) { + t.Fatalf("ProfileConfigPath = %q", inv.ProfileConfigPath) + } +} + +func TestBootstrapInvocationContext_ProfileFlagOverridesProjectProfile(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + repo := t.TempDir() + if err := os.Mkdir(filepath.Join(repo, ".git"), 0700); err != nil { + t.Fatalf("Mkdir(.git): %v", err) + } + writeBootstrapProjectConfig(t, repo, `{"profile":"project"}`) + cmdutil.TestChdir(t, repo) + + inv, err := BootstrapInvocationContext([]string{"--profile", "cli", "auth", "status"}) + if err != nil { + t.Fatalf("BootstrapInvocationContext() error = %v", err) + } + if inv.Profile != "cli" { + t.Fatalf("profile = %q, want cli", inv.Profile) + } + if inv.ProfileSource != core.ProfileSourceCLI { + t.Fatalf("ProfileSource = %q, want cli", inv.ProfileSource) + } + if inv.ProfileConfigPath != "" { + t.Fatalf("ProfileConfigPath = %q, want empty", inv.ProfileConfigPath) + } +} + +func TestBootstrapInvocationContext_ProfileBindSkipsMalformedProjectConfig(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + repo := t.TempDir() + if err := os.Mkdir(filepath.Join(repo, ".git"), 0700); err != nil { + t.Fatalf("Mkdir(.git): %v", err) + } + writeBootstrapProjectConfig(t, repo, `{`) + cmdutil.TestChdir(t, repo) + + inv, err := BootstrapInvocationContext([]string{"profile", "bind", "bytedance"}) + if err != nil { + t.Fatalf("BootstrapInvocationContext() error = %v", err) + } + if inv.Profile != "" || inv.ProfileSource != core.ProfileSourceGlobal { + t.Fatalf("invocation = %#v, want global without profile", inv) + } +} + +func TestBootstrapInvocationContext_ProfileCurrentReadsMalformedProjectConfig(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + repo := t.TempDir() + if err := os.Mkdir(filepath.Join(repo, ".git"), 0700); err != nil { + t.Fatalf("Mkdir(.git): %v", err) + } + writeBootstrapProjectConfig(t, repo, `{`) + cmdutil.TestChdir(t, repo) + + _, err := BootstrapInvocationContext([]string{"profile", "current"}) + if err == nil { + t.Fatal("BootstrapInvocationContext() error = nil, want malformed project config error") + } + var cfgErr *core.ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("error type = %T, want *core.ConfigError", err) + } + if cfgErr.Code != 3 || cfgErr.Type != "config" { + t.Fatalf("ConfigError metadata = code:%d type:%q", cfgErr.Code, cfgErr.Type) + } + wantRepo, err := filepath.EvalSymlinks(repo) + if err != nil { + t.Fatalf("EvalSymlinks(repo): %v", err) + } + wantMsg := "invalid project config " + core.ProjectConfigPath(wantRepo) + ": unexpected end of JSON input" + if cfgErr.Message != wantMsg { + t.Fatalf("ConfigError.Message = %q, want %q", cfgErr.Message, wantMsg) + } +} + +func TestBootstrapInvocationContext_CompletionValueDoesNotSkipProjectProfile(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + repo := t.TempDir() + if err := os.Mkdir(filepath.Join(repo, ".git"), 0700); err != nil { + t.Fatalf("Mkdir(.git): %v", err) + } + writeBootstrapProjectConfig(t, repo, `{"profile":"bytedance"}`) + cmdutil.TestChdir(t, repo) + + inv, err := BootstrapInvocationContext([]string{"auth", "status", "completion"}) + if err != nil { + t.Fatalf("BootstrapInvocationContext() error = %v", err) + } + if inv.ProfileSource != core.ProfileSourceProject || inv.Profile != "bytedance" { + t.Fatalf("invocation = %#v, want project profile", inv) + } +} + +func TestBootstrapInvocationContext_CompletionCommandSkipsMalformedProjectConfig(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + repo := t.TempDir() + if err := os.Mkdir(filepath.Join(repo, ".git"), 0700); err != nil { + t.Fatalf("Mkdir(.git): %v", err) + } + writeBootstrapProjectConfig(t, repo, `{`) + cmdutil.TestChdir(t, repo) + + inv, err := BootstrapInvocationContext([]string{"completion"}) + if err != nil { + t.Fatalf("BootstrapInvocationContext() error = %v", err) + } + if inv.Profile != "" || inv.ProfileSource != core.ProfileSourceGlobal { + t.Fatalf("invocation = %#v, want global without profile", inv) + } +} + +func TestBootstrapInvocationContext_CompletionAfterUnknownValueFlagSkipsMalformedProjectConfig(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + repo := t.TempDir() + if err := os.Mkdir(filepath.Join(repo, ".git"), 0700); err != nil { + t.Fatalf("Mkdir(.git): %v", err) + } + writeBootstrapProjectConfig(t, repo, `{`) + cmdutil.TestChdir(t, repo) + + inv, err := BootstrapInvocationContext([]string{"--config-dir", t.TempDir(), "completion"}) + if err != nil { + t.Fatalf("BootstrapInvocationContext() error = %v", err) + } + if inv.Profile != "" || inv.ProfileSource != core.ProfileSourceGlobal { + t.Fatalf("invocation = %#v, want global without profile", inv) + } +} diff --git a/cmd/config/active_profile.go b/cmd/config/active_profile.go new file mode 100644 index 0000000000..9545f963aa --- /dev/null +++ b/cmd/config/active_profile.go @@ -0,0 +1,13 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +func noActiveProfileError(f *cmdutil.Factory, multi *core.MultiAppConfig) error { + return cmdutil.ActiveProfileError(f.Invocation, multi) +} diff --git a/cmd/config/default_as.go b/cmd/config/default_as.go index f1d5de4e79..93a82d612a 100644 --- a/cmd/config/default_as.go +++ b/cmd/config/default_as.go @@ -27,7 +27,7 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command { app := multi.CurrentAppConfig(f.Invocation.Profile) if app == nil { - return core.NoActiveProfileError() + return noActiveProfileError(f, multi) } if len(args) == 0 { diff --git a/cmd/config/show.go b/cmd/config/show.go index 5526f0254a..2536ebb910 100644 --- a/cmd/config/show.go +++ b/cmd/config/show.go @@ -55,7 +55,7 @@ func configShowRun(opts *ConfigShowOptions) error { } app := config.CurrentAppConfig(f.Invocation.Profile) if app == nil { - return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").WithHint("run: lark-cli profile list") + return noActiveProfileError(f, config) } users := "(no logged-in users)" if len(app.Users) > 0 { diff --git a/cmd/config/strict_mode.go b/cmd/config/strict_mode.go index 46610585ab..d7460e466a 100644 --- a/cmd/config/strict_mode.go +++ b/cmd/config/strict_mode.go @@ -45,20 +45,20 @@ explicit user confirmation — never run on your own initiative.`, if reset { app := multi.CurrentAppConfig(f.Invocation.Profile) if app == nil { - return core.NoActiveProfileError() + return noActiveProfileError(f, multi) } return resetStrictMode(f, multi, app, global, args) } if len(args) == 0 { app := multi.CurrentAppConfig(f.Invocation.Profile) if app == nil { - return core.NoActiveProfileError() + return noActiveProfileError(f, multi) } return showStrictMode(cmd.Context(), f, multi, app) } app := multi.CurrentAppConfig(f.Invocation.Profile) if !global && app == nil { - return core.NoActiveProfileError() + return noActiveProfileError(f, multi) } return setStrictMode(f, multi, app, args[0], global) }, @@ -138,7 +138,7 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App } } else { if app == nil { - return core.NoActiveProfileError() + return noActiveProfileError(f, multi) } app.StrictMode = &mode } diff --git a/cmd/profile/current.go b/cmd/profile/current.go new file mode 100644 index 0000000000..a90eaba3f8 --- /dev/null +++ b/cmd/profile/current.go @@ -0,0 +1,66 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package profile + +import ( + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" +) + +type currentProfileOutput struct { + Profile string `json:"profile"` + Source string `json:"source"` + Config string `json:"config"` + AppID string `json:"appId"` + Brand core.LarkBrand `json:"brand"` +} + +// NewCmdProfileCurrent creates the profile current subcommand. +func NewCmdProfileCurrent(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "current", + Short: "Show the effective profile", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return profileCurrentRun(f) + }, + } + cmdutil.SetRisk(cmd, "read") + return cmd +} + +func profileCurrentRun(f *cmdutil.Factory) error { + multi, err := core.LoadOrNotConfigured() + if err != nil { + return err + } + app := multi.CurrentAppConfig(f.Invocation.Profile) + if app == nil { + return cmdutil.ActiveProfileError(f.Invocation, multi) + } + + source := f.Invocation.ProfileSource + if source == "" { + source = core.ProfileSourceGlobal + } + configPath := "" + switch source { + case core.ProfileSourceProject: + configPath = f.Invocation.ProfileConfigPath + case core.ProfileSourceGlobal: + configPath = core.GetConfigPath() + } + + output.PrintJson(f.IOStreams.Out, currentProfileOutput{ + Profile: app.ProfileName(), + Source: string(source), + Config: configPath, + AppID: app.AppId, + Brand: app.Brand, + }) + return nil +} diff --git a/cmd/profile/profile.go b/cmd/profile/profile.go index 2216a4f391..88a05b9ae1 100644 --- a/cmd/profile/profile.go +++ b/cmd/profile/profile.go @@ -21,6 +21,9 @@ func NewCmdProfile(f *cmdutil.Factory) *cobra.Command { }) cmd.AddCommand(NewCmdProfileList(f)) + cmd.AddCommand(NewCmdProfileCurrent(f)) + cmd.AddCommand(NewCmdProfileBind(f)) + cmd.AddCommand(NewCmdProfileUnbind(f)) cmd.AddCommand(NewCmdProfileUse(f)) cmd.AddCommand(NewCmdProfileAdd(f)) cmd.AddCommand(NewCmdProfileRemove(f)) diff --git a/cmd/profile/profile_test.go b/cmd/profile/profile_test.go index 3cd7247202..c4ca7ec7c2 100644 --- a/cmd/profile/profile_test.go +++ b/cmd/profile/profile_test.go @@ -11,6 +11,7 @@ import ( "strings" "testing" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/i18n" @@ -34,6 +35,240 @@ func setupProfileConfigDir(t *testing.T) string { return dir } +func saveProfileTestConfig(t *testing.T) { + t.Helper() + if err := core.SaveMultiAppConfig(&core.MultiAppConfig{ + CurrentApp: "bytedance", + Apps: []core.AppConfig{ + {Name: "bytedance", AppId: "app_bytedance", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu}, + {Name: "team-prod", AppId: "app_team", AppSecret: core.PlainSecret("secret"), Brand: core.BrandLark}, + {Name: "lark-boe", AppId: "app_lark_boe", AppSecret: core.PlainSecret("secret"), Brand: core.BrandLark}, + }, + }); err != nil { + t.Fatalf("SaveMultiAppConfig() error = %v", err) + } +} + +func writeProfileProjectConfig(t *testing.T, dir, body string) string { + t.Helper() + path := core.ProjectConfigPath(dir) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatalf("MkdirAll(project config dir): %v", err) + } + if err := os.WriteFile(path, []byte(body), 0600); err != nil { + t.Fatalf("WriteFile(project config): %v", err) + } + return path +} + +func TestProfileBindRun_WritesProjectConfigAtGitRoot(t *testing.T) { + setupProfileConfigDir(t) + saveProfileTestConfig(t) + + repo := t.TempDir() + if err := os.Mkdir(filepath.Join(repo, ".git"), 0700); err != nil { + t.Fatalf("Mkdir(.git): %v", err) + } + sub := filepath.Join(repo, "sub") + if err := os.MkdirAll(sub, 0700); err != nil { + t.Fatalf("MkdirAll(sub): %v", err) + } + cmdutil.TestChdir(t, sub) + + f, _, _, _ := cmdutil.TestFactory(t, nil) + if err := profileBindRun(f, "team-prod"); err != nil { + t.Fatalf("profileBindRun() error = %v", err) + } + + data, err := os.ReadFile(core.ProjectConfigPath(repo)) + if err != nil { + t.Fatalf("ReadFile(project config): %v", err) + } + var cfg core.ProjectConfig + if err := json.Unmarshal(data, &cfg); err != nil { + t.Fatalf("Unmarshal(project config): %v", err) + } + if cfg.Profile != "team-prod" { + t.Fatalf("project profile = %q, want team-prod", cfg.Profile) + } +} + +func TestProfileUnbindRun_RemovesNearestProjectConfig(t *testing.T) { + setupProfileConfigDir(t) + repo := t.TempDir() + if err := os.Mkdir(filepath.Join(repo, ".git"), 0700); err != nil { + t.Fatalf("Mkdir(.git): %v", err) + } + path := writeProfileProjectConfig(t, repo, `{"profile":"bytedance"}`) + sub := filepath.Join(repo, "sub") + if err := os.MkdirAll(sub, 0700); err != nil { + t.Fatalf("MkdirAll(sub): %v", err) + } + cmdutil.TestChdir(t, sub) + + f, _, _, _ := cmdutil.TestFactory(t, nil) + if err := profileUnbindRun(f); err != nil { + t.Fatalf("profileUnbindRun() error = %v", err) + } + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("project config still exists, Stat err = %v", err) + } +} + +func TestProfileBindRun_InvalidProfileReturnsTypedError(t *testing.T) { + setupProfileConfigDir(t) + f, _, _, _ := cmdutil.TestFactory(t, nil) + err := profileBindRun(f, "") + if err == nil { + t.Fatal("profileBindRun() error = nil, want validation error") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("ProblemOf() ok = false, err = %T", err) + } + if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("problem = %#v", problem) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("error type = %T, want *errs.ValidationError", err) + } + if validationErr.Param != "" { + t.Fatalf("Param = %q, want ", validationErr.Param) + } + if errors.Unwrap(err) == nil { + t.Fatal("validation error cause is nil") + } +} + +func TestProfileCurrentRun_CLIProfileMissingReturnsValidationError(t *testing.T) { + setupProfileConfigDir(t) + saveProfileTestConfig(t) + + f, _, _, _ := cmdutil.TestFactory(t, nil) + f.Invocation = cmdutil.InvocationContext{ + Profile: "missing", + ProfileSource: core.ProfileSourceCLI, + } + err := profileCurrentRun(f) + if err == nil { + t.Fatal("profileCurrentRun() error = nil, want validation error") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("ProblemOf() ok = false, err = %T", err) + } + if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("problem = %#v", problem) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("error type = %T, want *errs.ValidationError", err) + } + if validationErr.Param != "--profile" { + t.Fatalf("Param = %q, want --profile", validationErr.Param) + } +} + +func TestProfileBindRun_MissingProfileReturnsTypedError(t *testing.T) { + setupProfileConfigDir(t) + saveProfileTestConfig(t) + f, _, _, _ := cmdutil.TestFactory(t, nil) + err := profileBindRun(f, "missing") + if err == nil { + t.Fatal("profileBindRun() error = nil, want validation error") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("ProblemOf() ok = false, err = %T", err) + } + if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("problem = %#v", problem) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("error type = %T, want *errs.ValidationError", err) + } + if validationErr.Param != "" { + t.Fatalf("Param = %q, want ", validationErr.Param) + } +} + +func TestProfileCurrentRun_ProjectSource(t *testing.T) { + configDir := setupProfileConfigDir(t) + saveProfileTestConfig(t) + projectPath := core.ProjectConfigPath(t.TempDir()) + + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + f.Invocation = cmdutil.InvocationContext{ + Profile: "team-prod", + ProfileSource: core.ProfileSourceProject, + ProfileConfigPath: projectPath, + } + if err := profileCurrentRun(f); err != nil { + t.Fatalf("profileCurrentRun() error = %v", err) + } + + var got currentProfileOutput + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("Unmarshal(stdout): %v\nstdout=%s", err, stdout.String()) + } + if got.Profile != "team-prod" || got.Source != "project" || got.Config != projectPath || got.AppID != "app_team" { + t.Fatalf("current profile = %#v", got) + } + if got.Config == filepath.Join(configDir, "config.json") { + t.Fatalf("project source should report project config, got global path %q", got.Config) + } +} + +func TestProfileCurrentRun_CLISourceLeavesConfigEmpty(t *testing.T) { + setupProfileConfigDir(t) + saveProfileTestConfig(t) + + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + f.Invocation = cmdutil.InvocationContext{ + Profile: "bytedance", + ProfileSource: core.ProfileSourceCLI, + } + if err := profileCurrentRun(f); err != nil { + t.Fatalf("profileCurrentRun() error = %v", err) + } + + var got currentProfileOutput + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("Unmarshal(stdout): %v\nstdout=%s", err, stdout.String()) + } + if got.Profile != "bytedance" || got.Source != "cli" || got.Config != "" || got.AppID != "app_bytedance" { + t.Fatalf("current profile = %#v", got) + } +} + +func TestProfileCurrentRun_ProjectProfileMissingReturnsConfigError(t *testing.T) { + setupProfileConfigDir(t) + saveProfileTestConfig(t) + projectPath := core.ProjectConfigPath(t.TempDir()) + + f, _, _, _ := cmdutil.TestFactory(t, nil) + f.Invocation = cmdutil.InvocationContext{ + Profile: "missing", + ProfileSource: core.ProfileSourceProject, + ProfileConfigPath: projectPath, + } + err := profileCurrentRun(f) + if err == nil { + t.Fatal("profileCurrentRun() error = nil, want project profile not found") + } + var cfgErr *core.ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("error type = %T, want *core.ConfigError", err) + } + wantMsg := `profile "missing" is configured by project but not found` + wantHint := "project config: " + projectPath + "; run: lark-cli profile list; available profiles: bytedance, team-prod, lark-boe" + if cfgErr.Code != 3 || cfgErr.Type != "config" || cfgErr.Message != wantMsg || cfgErr.Hint != wantHint { + t.Fatalf("ConfigError = %#v", cfgErr) + } +} + func TestProfileAddRun_InvalidExistingConfigReturnsError(t *testing.T) { dir := setupProfileConfigDir(t) if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte("{invalid json"), 0600); err != nil { diff --git a/cmd/profile/project_bind.go b/cmd/profile/project_bind.go new file mode 100644 index 0000000000..03d055eba6 --- /dev/null +++ b/cmd/profile/project_bind.go @@ -0,0 +1,113 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package profile + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/vfs" +) + +// NewCmdProfileBind creates the profile bind subcommand. +func NewCmdProfileBind(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "bind ", + Short: "Bind the current project to a profile", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return profileBindRun(f, args[0]) + }, + } + cmdutil.SetRisk(cmd, "write") + return cmd +} + +// NewCmdProfileUnbind creates the profile unbind subcommand. +func NewCmdProfileUnbind(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "unbind", + Short: "Remove the current project's profile binding", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return profileUnbindRun(f) + }, + } + cmdutil.SetRisk(cmd, "write") + return cmd +} + +func profileBindRun(f *cmdutil.Factory, profile string) error { + profile = strings.TrimSpace(profile) + if err := core.ValidateProfileName(profile); err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("").WithCause(err) + } + multi, err := core.LoadOrNotConfigured() + if err != nil { + return err + } + app := multi.FindApp(profile) + if app == nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "profile %q not found", profile). + WithHint("available profiles: %s", formatProfileNameList(multi.ProfileNames())). + WithParam("") + } + cwd, err := vfs.Getwd() + if err != nil { + return errs.NewInternalError(errs.SubtypeFileIO, "cannot determine working directory: %v", err).WithCause(err) + } + path, err := core.ProjectConfigWritePath(cwd) + if err != nil { + return err + } + if err := core.SaveProjectConfig(path, app.ProfileName()); err != nil { + return err + } + output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Project profile bound to %q", app.ProfileName())) + fmt.Fprintf(f.IOStreams.ErrOut, "Config: %s\n", path) + return nil +} + +func profileUnbindRun(f *cmdutil.Factory) error { + cwd, err := vfs.Getwd() + if err != nil { + return errs.NewInternalError(errs.SubtypeFileIO, "cannot determine working directory: %v", err).WithCause(err) + } + path, ok, err := core.FindProjectConfigPath(cwd) + if err != nil { + return err + } + if !ok { + return errs.NewValidationError(errs.SubtypeFailedPrecondition, "no project profile binding found"). + WithHint("run: lark-cli profile bind ") + } + fileRemoved, profileRemoved, err := core.RemoveProjectProfile(path) + if err != nil { + return err + } + if !profileRemoved { + return errs.NewValidationError(errs.SubtypeFailedPrecondition, "no project profile binding found"). + WithHint("run: lark-cli profile bind ") + } + output.PrintSuccess(f.IOStreams.ErrOut, "Project profile binding removed") + if fileRemoved { + fmt.Fprintf(f.IOStreams.ErrOut, "Removed: %s\n", path) + } else { + fmt.Fprintf(f.IOStreams.ErrOut, "Updated: %s\n", path) + } + return nil +} + +func formatProfileNameList(names []string) string { + if len(names) == 0 { + return "(none)" + } + return strings.Join(names, ", ") +} diff --git a/internal/cmdutil/active_profile.go b/internal/cmdutil/active_profile.go new file mode 100644 index 0000000000..5d05fbe057 --- /dev/null +++ b/internal/cmdutil/active_profile.go @@ -0,0 +1,41 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/core" +) + +// ProjectProfileError returns a project-specific error when the invocation +// selects a project profile that does not exist in the global profile list. +func ProjectProfileError(inv InvocationContext, multi *core.MultiAppConfig) error { + if inv.Profile == "" || inv.ProfileSource != core.ProfileSourceProject { + return nil + } + if multi != nil && multi.FindApp(inv.Profile) != nil { + return nil + } + return core.ProjectProfileNotFoundError(inv.Profile, inv.ProfileConfigPath, profileNames(multi)) +} + +// ActiveProfileError classifies a missing effective profile by selector source. +func ActiveProfileError(inv InvocationContext, multi *core.MultiAppConfig) error { + if err := ProjectProfileError(inv, multi); err != nil { + return err + } + if inv.Profile != "" && inv.ProfileSource == core.ProfileSourceCLI { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "profile %q not found", inv.Profile). + WithParam("--profile"). + WithHint("run: lark-cli profile list") + } + return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").WithHint("run: lark-cli profile list") +} + +func profileNames(multi *core.MultiAppConfig) []string { + if multi == nil { + return nil + } + return multi.ProfileNames() +} diff --git a/internal/cmdutil/factory.go b/internal/cmdutil/factory.go index 1b167b7863..f75ad412d0 100644 --- a/internal/cmdutil/factory.go +++ b/internal/cmdutil/factory.go @@ -26,7 +26,9 @@ import ( // All function fields are lazily initialized and cached after first call. // In tests, replace any field to stub out external dependencies. type InvocationContext struct { - Profile string + Profile string + ProfileSource core.ProfileSource + ProfileConfigPath string } type Factory struct { diff --git a/internal/cmdutil/factory_default.go b/internal/cmdutil/factory_default.go index 2051e0beaa..c197f5c0a2 100644 --- a/internal/cmdutil/factory_default.go +++ b/internal/cmdutil/factory_default.go @@ -61,10 +61,12 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory { // Phase 2: Credential (sole data source) // Keychain is read via closure so callers can replace f.Keychain after construction. f.Credential = buildCredentialProvider(credentialDeps{ - Keychain: func() keychain.KeychainAccess { return f.Keychain }, - Profile: inv.Profile, - HttpClient: f.HttpClient, - ErrOut: f.IOStreams.ErrOut, + Keychain: func() keychain.KeychainAccess { return f.Keychain }, + Profile: inv.Profile, + ProfileSource: inv.ProfileSource, + ProfileConfigPath: inv.ProfileConfigPath, + HttpClient: f.HttpClient, + ErrOut: f.IOStreams.ErrOut, }) // Phase 3: Config derived from Credential via an explicit conversion boundary. @@ -162,15 +164,17 @@ func buildSDKTransport() http.RoundTripper { } type credentialDeps struct { - Keychain func() keychain.KeychainAccess - Profile string - HttpClient func() (*http.Client, error) - ErrOut io.Writer + Keychain func() keychain.KeychainAccess + Profile string + ProfileSource core.ProfileSource + ProfileConfigPath string + HttpClient func() (*http.Client, error) + ErrOut io.Writer } func buildCredentialProvider(deps credentialDeps) *credential.CredentialProvider { providers := extcred.Providers() - defaultAcct := credential.NewDefaultAccountProvider(deps.Keychain, deps.Profile) + defaultAcct := credential.NewDefaultAccountProviderWithSource(deps.Keychain, deps.Profile, deps.ProfileSource, deps.ProfileConfigPath) defaultToken := credential.NewDefaultTokenProvider(defaultAcct, deps.HttpClient, deps.ErrOut) // NOTE: Do not pass deps.ErrOut as warnOut. Credential resolution // happens before the command runs, so any plain-text warning written diff --git a/internal/core/config.go b/internal/core/config.go index 846ee2bad6..62443b3f3a 100644 --- a/internal/core/config.go +++ b/internal/core/config.go @@ -30,6 +30,15 @@ const ( // IsBot returns true if the identity is bot. func (id Identity) IsBot() bool { return id == AsBot } +// ProfileSource describes where the effective profile selector came from. +type ProfileSource string + +const ( + ProfileSourceCLI ProfileSource = "cli" + ProfileSourceProject ProfileSource = "project" + ProfileSourceGlobal ProfileSource = "global" +) + // AppUser is a logged-in user record stored in config. type AppUser struct { UserOpenId string `json:"userOpenId"` diff --git a/internal/core/project_config.go b/internal/core/project_config.go new file mode 100644 index 0000000000..b16a6262d9 --- /dev/null +++ b/internal/core/project_config.go @@ -0,0 +1,228 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package core + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/internal/vfs" +) + +const ( + // ProjectConfigDirName is the project-local CLI config directory. + ProjectConfigDirName = ".lark-cli" + // ProjectConfigFileName is the project-local CLI config file. + ProjectConfigFileName = "config.json" +) + +// ProjectConfig is intentionally small: it stores project preferences, not +// credentials or login state. +type ProjectConfig struct { + Profile string `json:"profile,omitempty"` +} + +// ProjectProfileBinding is the resolved project profile and its source file. +type ProjectProfileBinding struct { + Profile string + Path string +} + +// ResolveProjectProfile finds and parses the nearest project profile binding. +func ResolveProjectProfile() (*ProjectProfileBinding, error) { + cwd, err := vfs.Getwd() + if err != nil { + return nil, &ConfigError{Code: 3, Type: "config", Message: fmt.Sprintf("cannot determine working directory: %v", err)} + } + return ResolveProjectProfileFrom(cwd) +} + +// ResolveProjectProfileFrom finds and parses the nearest project profile binding +// from startDir upward. Search stops after the nearest Git root is checked. +func ResolveProjectProfileFrom(startDir string) (*ProjectProfileBinding, error) { + path, ok, err := FindProjectConfigPath(startDir) + if err != nil || !ok { + return nil, err + } + cfg, err := LoadProjectConfig(path) + if err != nil { + return nil, err + } + return &ProjectProfileBinding{Profile: cfg.Profile, Path: path}, nil +} + +// ProjectConfigPath returns the project config path under dir. +func ProjectConfigPath(dir string) string { + return filepath.Join(dir, ProjectConfigDirName, ProjectConfigFileName) +} + +// FindProjectConfigPath returns the nearest .lark-cli/config.json from startDir upward. +func FindProjectConfigPath(startDir string) (string, bool, error) { + dir := filepath.Clean(startDir) + for { + path := ProjectConfigPath(dir) + info, err := vfs.Stat(path) + switch { + case err == nil && info.IsDir(): + return "", false, &ConfigError{Code: 3, Type: "config", Message: fmt.Sprintf("project config %s is a directory", path)} + case err == nil: + return path, true, nil + case !errors.Is(err, os.ErrNotExist): + return "", false, &ConfigError{Code: 3, Type: "config", Message: fmt.Sprintf("cannot inspect project config %s: %v", path, err)} + } + + if isGitRoot(dir) { + return "", false, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return "", false, nil + } + dir = parent + } +} + +// ProjectConfigWritePath returns where profile bind should write. It updates an +// existing binding when found; otherwise it writes at the Git root, or cwd when +// outside a Git repository. +func ProjectConfigWritePath(startDir string) (string, error) { + if path, ok, err := FindProjectConfigPath(startDir); err != nil || ok { + return path, err + } + root := findGitRoot(startDir) + if root == "" { + root = filepath.Clean(startDir) + } + return ProjectConfigPath(root), nil +} + +// LoadProjectConfig parses a project-local config file. +func LoadProjectConfig(path string) (*ProjectConfig, error) { + data, err := vfs.ReadFile(path) + if err != nil { + return nil, &ConfigError{Code: 3, Type: "config", Message: fmt.Sprintf("cannot read project config %s: %v", path, err)} + } + var cfg ProjectConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, &ConfigError{Code: 3, Type: "config", Message: fmt.Sprintf("invalid project config %s: %v", path, err)} + } + cfg.Profile = strings.TrimSpace(cfg.Profile) + if cfg.Profile == "" { + return nil, &ConfigError{Code: 3, Type: "config", Message: fmt.Sprintf("project config %s must set profile", path)} + } + if err := ValidateProfileName(cfg.Profile); err != nil { + return nil, &ConfigError{Code: 3, Type: "config", Message: fmt.Sprintf("invalid project profile in %s: %v", path, err)} + } + return &cfg, nil +} + +// SaveProjectConfig writes the minimal project profile binding. +func SaveProjectConfig(path, profile string) error { + profile = strings.TrimSpace(profile) + if err := ValidateProfileName(profile); err != nil { + return &ConfigError{Code: 3, Type: "config", Message: fmt.Sprintf("invalid project profile: %v", err)} + } + fields := map[string]json.RawMessage{} + data, err := vfs.ReadFile(path) + switch { + case err == nil: + if err := json.Unmarshal(data, &fields); err != nil { + return &ConfigError{Code: 3, Type: "config", Message: fmt.Sprintf("invalid project config %s: %v", path, err)} + } + case !errors.Is(err, os.ErrNotExist): + return &ConfigError{Code: 3, Type: "config", Message: fmt.Sprintf("cannot read project config %s: %v", path, err)} + } + profileJSON, err := json.Marshal(profile) + if err != nil { + return &ConfigError{Code: 3, Type: "config", Message: fmt.Sprintf("failed to marshal project config: %v", err)} + } + fields["profile"] = profileJSON + data, err = json.MarshalIndent(fields, "", " ") + if err != nil { + return &ConfigError{Code: 3, Type: "config", Message: fmt.Sprintf("failed to marshal project config: %v", err)} + } + if err := vfs.MkdirAll(filepath.Dir(path), 0700); err != nil { + return &ConfigError{Code: 3, Type: "config", Message: fmt.Sprintf("cannot create project config directory %s: %v", filepath.Dir(path), err)} + } + if err := validate.AtomicWrite(path, append(data, '\n'), 0600); err != nil { + return &ConfigError{Code: 3, Type: "config", Message: fmt.Sprintf("cannot write project config %s: %v", path, err)} + } + return nil +} + +// RemoveProjectProfile removes only the profile binding. If no other fields +// remain, the project config file is deleted. +func RemoveProjectProfile(path string) (fileRemoved bool, profileRemoved bool, err error) { + data, err := vfs.ReadFile(path) + if err != nil { + return false, false, &ConfigError{Code: 3, Type: "config", Message: fmt.Sprintf("cannot read project config %s: %v", path, err)} + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return false, false, &ConfigError{Code: 3, Type: "config", Message: fmt.Sprintf("invalid project config %s: %v", path, err)} + } + if _, ok := fields["profile"]; !ok { + return false, false, nil + } + delete(fields, "profile") + if len(fields) == 0 { + if err := vfs.Remove(path); err != nil { + return false, false, &ConfigError{Code: 3, Type: "config", Message: fmt.Sprintf("failed to remove project config %s: %v", path, err)} + } + if dir := filepath.Dir(path); filepath.Base(dir) == ProjectConfigDirName { + _ = vfs.Remove(dir) + } + return true, true, nil + } + next, err := json.MarshalIndent(fields, "", " ") + if err != nil { + return false, false, &ConfigError{Code: 3, Type: "config", Message: fmt.Sprintf("failed to marshal project config: %v", err)} + } + if err := validate.AtomicWrite(path, append(next, '\n'), 0600); err != nil { + return false, false, &ConfigError{Code: 3, Type: "config", Message: fmt.Sprintf("cannot write project config %s: %v", path, err)} + } + return false, true, nil +} + +// ProjectProfileNotFoundError explains that a project binding references a +// profile that is not present in the user's global profile list. +func ProjectProfileNotFoundError(profile, path string, names []string) error { + hint := "run: lark-cli profile list" + if path != "" { + hint = fmt.Sprintf("project config: %s; %s", path, hint) + } + if len(names) > 0 { + hint += fmt.Sprintf("; available profiles: %s", formatProfileNames(names)) + } + return &ConfigError{ + Code: 3, + Type: "config", + Message: fmt.Sprintf("profile %q is configured by project but not found", profile), + Hint: hint, + } +} + +func isGitRoot(dir string) bool { + _, err := vfs.Stat(filepath.Join(dir, ".git")) + return err == nil +} + +func findGitRoot(startDir string) string { + dir := filepath.Clean(startDir) + for { + if isGitRoot(dir) { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + return "" + } + dir = parent + } +} diff --git a/internal/core/project_config_test.go b/internal/core/project_config_test.go new file mode 100644 index 0000000000..005349702a --- /dev/null +++ b/internal/core/project_config_test.go @@ -0,0 +1,253 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package core + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" +) + +func writeProjectConfig(t *testing.T, dir, body string) string { + t.Helper() + path := ProjectConfigPath(dir) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatalf("MkdirAll(project config dir): %v", err) + } + if err := os.WriteFile(path, []byte(body), 0600); err != nil { + t.Fatalf("WriteFile(%s): %v", path, err) + } + return path +} + +func TestResolveProjectProfileFrom_FindsNearestConfig(t *testing.T) { + repo := t.TempDir() + if err := os.Mkdir(filepath.Join(repo, ".git"), 0700); err != nil { + t.Fatalf("Mkdir(.git): %v", err) + } + writeProjectConfig(t, repo, `{"profile":"root"}`) + sub := filepath.Join(repo, "sub") + if err := os.MkdirAll(sub, 0700); err != nil { + t.Fatalf("MkdirAll(sub): %v", err) + } + subConfig := writeProjectConfig(t, sub, `{"profile":"child"}`) + + got, err := ResolveProjectProfileFrom(filepath.Join(sub, "deep")) + if err != nil { + t.Fatalf("ResolveProjectProfileFrom() error = %v", err) + } + if got.Profile != "child" || got.Path != subConfig { + t.Fatalf("binding = %#v, want child at %s", got, subConfig) + } +} + +func TestFindProjectConfigPath_StopsAtGitRoot(t *testing.T) { + parent := t.TempDir() + writeProjectConfig(t, parent, `{"profile":"parent"}`) + repo := filepath.Join(parent, "repo") + sub := filepath.Join(repo, "sub") + if err := os.MkdirAll(filepath.Join(repo, ".git"), 0700); err != nil { + t.Fatalf("MkdirAll(.git): %v", err) + } + if err := os.MkdirAll(sub, 0700); err != nil { + t.Fatalf("MkdirAll(sub): %v", err) + } + + _, ok, err := FindProjectConfigPath(sub) + if err != nil { + t.Fatalf("FindProjectConfigPath() error = %v", err) + } + if ok { + t.Fatal("FindProjectConfigPath() found config above Git root") + } +} + +func TestProjectConfigWritePath_UsesGitRootWhenNoExistingConfig(t *testing.T) { + repo := t.TempDir() + if err := os.Mkdir(filepath.Join(repo, ".git"), 0700); err != nil { + t.Fatalf("Mkdir(.git): %v", err) + } + sub := filepath.Join(repo, "a", "b") + if err := os.MkdirAll(sub, 0700); err != nil { + t.Fatalf("MkdirAll(sub): %v", err) + } + + got, err := ProjectConfigWritePath(sub) + if err != nil { + t.Fatalf("ProjectConfigWritePath() error = %v", err) + } + want := ProjectConfigPath(repo) + if got != want { + t.Fatalf("ProjectConfigWritePath() = %q, want %q", got, want) + } +} + +func TestProjectConfigWritePath_UsesStartDirOutsideGitRepo(t *testing.T) { + dir := t.TempDir() + + got, err := ProjectConfigWritePath(dir) + if err != nil { + t.Fatalf("ProjectConfigWritePath() error = %v", err) + } + want := ProjectConfigPath(dir) + if got != want { + t.Fatalf("ProjectConfigWritePath() = %q, want %q", got, want) + } +} + +func TestSaveProjectConfig_CreatesConfigDirectory(t *testing.T) { + dir := t.TempDir() + path := ProjectConfigPath(dir) + if err := SaveProjectConfig(path, "bytedance"); err != nil { + t.Fatalf("SaveProjectConfig() error = %v", err) + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("project config missing after save: %v", err) + } +} + +func TestLoadProjectConfig_InvalidJSONFailsClosed(t *testing.T) { + path := writeProjectConfig(t, t.TempDir(), `{`) + _, err := LoadProjectConfig(path) + if err == nil { + t.Fatal("LoadProjectConfig() error = nil, want invalid config error") + } + var cfgErr *ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("error type = %T, want *ConfigError", err) + } + if cfgErr.Message == "" { + t.Fatal("ConfigError.Message is empty") + } +} + +func TestLoadProjectConfig_MissingProfileFailsClosed(t *testing.T) { + path := writeProjectConfig(t, t.TempDir(), `{"defaults":{"appId":"cli_x"}}`) + _, err := LoadProjectConfig(path) + if err == nil { + t.Fatal("LoadProjectConfig() error = nil, want missing profile error") + } +} + +func TestSaveProjectConfig_PreservesOtherFields(t *testing.T) { + path := writeProjectConfig(t, t.TempDir(), `{"defaults":{"appId":"cli_x"}}`) + if err := SaveProjectConfig(path, "bytedance"); err != nil { + t.Fatalf("SaveProjectConfig() error = %v", err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(project config): %v", err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + t.Fatalf("Unmarshal(project config): %v", err) + } + if _, ok := fields["defaults"]; !ok { + t.Fatalf("defaults field missing after save: %s", string(data)) + } + var profile string + if err := json.Unmarshal(fields["profile"], &profile); err != nil { + t.Fatalf("Unmarshal(profile): %v", err) + } + if profile != "bytedance" { + t.Fatalf("profile = %q, want bytedance", profile) + } +} + +func TestSaveProjectConfig_InvalidExistingConfigFailsClosed(t *testing.T) { + path := writeProjectConfig(t, t.TempDir(), `{`) + err := SaveProjectConfig(path, "bytedance") + if err == nil { + t.Fatal("SaveProjectConfig() error = nil, want invalid config error") + } + var cfgErr *ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("error type = %T, want *ConfigError", err) + } + wantMsg := "invalid project config " + path + ": unexpected end of JSON input" + if cfgErr.Code != 3 || cfgErr.Type != "config" || cfgErr.Message != wantMsg { + t.Fatalf("ConfigError = %#v, want message %q", cfgErr, wantMsg) + } + data, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatalf("ReadFile(project config): %v", readErr) + } + if string(data) != `{` { + t.Fatalf("project config changed after failed save: %q", string(data)) + } +} + +func TestSaveProjectConfig_InvalidProfileFailsClosed(t *testing.T) { + path := ProjectConfigPath(t.TempDir()) + err := SaveProjectConfig(path, "") + if err == nil { + t.Fatal("SaveProjectConfig() error = nil, want invalid profile error") + } + var cfgErr *ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("error type = %T, want *ConfigError", err) + } + if cfgErr.Code != 3 || cfgErr.Type != "config" || cfgErr.Message != "invalid project profile: profile name cannot be empty" { + t.Fatalf("ConfigError = %#v", cfgErr) + } + if _, statErr := os.Stat(path); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("project config exists after failed save, stat err = %v", statErr) + } +} + +func TestRemoveProjectProfile_PreservesOtherFields(t *testing.T) { + path := writeProjectConfig(t, t.TempDir(), `{"profile":"bytedance","defaults":{"appId":"cli_x"}}`) + fileRemoved, profileRemoved, err := RemoveProjectProfile(path) + if err != nil { + t.Fatalf("RemoveProjectProfile() error = %v", err) + } + if fileRemoved || !profileRemoved { + t.Fatalf("RemoveProjectProfile() = fileRemoved:%v profileRemoved:%v, want file kept and profile removed", fileRemoved, profileRemoved) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(project config): %v", err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + t.Fatalf("Unmarshal(project config): %v", err) + } + if _, ok := fields["defaults"]; !ok { + t.Fatalf("defaults field missing after profile removal: %s", string(data)) + } + if _, ok := fields["profile"]; ok { + t.Fatalf("profile field still present after removal: %s", string(data)) + } + if _, err := LoadProjectConfig(path); err == nil { + t.Fatal("LoadProjectConfig() error = nil after profile removal, want missing profile") + } +} + +func TestRemoveProjectProfile_RemovesFileWhenOnlyProfileField(t *testing.T) { + dir := t.TempDir() + path := writeProjectConfig(t, dir, `{"profile":"bytedance"}`) + fileRemoved, profileRemoved, err := RemoveProjectProfile(path) + if err != nil { + t.Fatalf("RemoveProjectProfile() error = %v", err) + } + if !fileRemoved || !profileRemoved { + t.Fatalf("RemoveProjectProfile() = fileRemoved:%v profileRemoved:%v, want both true", fileRemoved, profileRemoved) + } + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected file removed, stat err = %v", err) + } + if _, err := os.Stat(filepath.Join(dir, ProjectConfigDirName)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected empty project config dir removed, stat err = %v", err) + } +} + +func TestValidateProfileName_AllowsHyphenatedNames(t *testing.T) { + for _, name := range []string{"bytedance", "team-prod", "lark-boe"} { + if err := ValidateProfileName(name); err != nil { + t.Fatalf("ValidateProfileName(%q) error = %v", name, err) + } + } +} diff --git a/internal/credential/default_provider.go b/internal/credential/default_provider.go index d7401b62ed..06e8d99502 100644 --- a/internal/credential/default_provider.go +++ b/internal/credential/default_provider.go @@ -61,15 +61,21 @@ func classifyTATResponseCode(code int, oauthErr, errDesc, brand, appID string) e // DefaultAccountProvider resolves account from config.json via keychain. type DefaultAccountProvider struct { - keychain func() keychain.KeychainAccess - profile string + keychain func() keychain.KeychainAccess + profile string + profileSource core.ProfileSource + profileConfigPath string } func NewDefaultAccountProvider(kc func() keychain.KeychainAccess, profile string) *DefaultAccountProvider { + return NewDefaultAccountProviderWithSource(kc, profile, core.ProfileSourceGlobal, "") +} + +func NewDefaultAccountProviderWithSource(kc func() keychain.KeychainAccess, profile string, source core.ProfileSource, configPath string) *DefaultAccountProvider { if kc == nil { kc = keychain.Default } - return &DefaultAccountProvider{keychain: kc, profile: profile} + return &DefaultAccountProvider{keychain: kc, profile: profile, profileSource: source, profileConfigPath: configPath} } func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account, error) { @@ -78,6 +84,9 @@ func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account, if err != nil { return nil, core.NotConfiguredError() } + if p.profile != "" && p.profileSource == core.ProfileSourceProject && multi.FindApp(p.profile) == nil { + return nil, core.ProjectProfileNotFoundError(p.profile, p.profileConfigPath, multi.ProfileNames()) + } cfg, err := core.ResolveConfigFromMulti(multi, p.keychain(), p.profile) if err != nil { diff --git a/internal/credential/integration_test.go b/internal/credential/integration_test.go index de173d1948..da034d8246 100644 --- a/internal/credential/integration_test.go +++ b/internal/credential/integration_test.go @@ -5,6 +5,7 @@ package credential_test import ( "context" + "errors" "testing" extcred "github.com/larksuite/cli/extension/credential" @@ -81,6 +82,44 @@ func (m *mockDefaultTokenProvider) ResolveToken(ctx context.Context, req credent return &credential.TokenResult{Token: m.token, Scopes: m.scopes}, nil } +func TestDefaultAccountProvider_ProjectProfileMissingFailsClosed(t *testing.T) { + t.Setenv(envvars.CliAppID, "") + t.Setenv(envvars.CliAppSecret, "") + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + if err := core.SaveMultiAppConfig(&core.MultiAppConfig{ + CurrentApp: "bytedance", + Apps: []core.AppConfig{{ + Name: "bytedance", + AppId: "cfg_app", + AppSecret: core.PlainSecret("cfg_secret"), + Brand: core.BrandFeishu, + }}, + }); err != nil { + t.Fatalf("SaveMultiAppConfig: %v", err) + } + + defaultAcct := credential.NewDefaultAccountProviderWithSource( + func() keychain.KeychainAccess { return &noopKC{} }, + "missing", + core.ProfileSourceProject, + "/repo/.lark-cli/config.json", + ) + _, err := defaultAcct.ResolveAccount(context.Background()) + if err == nil { + t.Fatal("ResolveAccount() error = nil, want project profile not found") + } + var cfgErr *core.ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("error type = %T, want *core.ConfigError", err) + } + wantMsg := `profile "missing" is configured by project but not found` + wantHint := "project config: /repo/.lark-cli/config.json; run: lark-cli profile list; available profiles: bytedance" + if cfgErr.Code != 3 || cfgErr.Type != "config" || cfgErr.Message != wantMsg || cfgErr.Hint != wantHint { + t.Fatalf("ConfigError = %#v", cfgErr) + } +} + func TestFullChain_ConfigStrictMode(t *testing.T) { t.Setenv(envvars.CliAppID, "") t.Setenv(envvars.CliAppSecret, "")