From 3e3cc53deb48353950863839b40b03160672d6af Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Fri, 31 Jul 2026 11:52:25 -0400 Subject: [PATCH 01/10] Add failing test for aliased profiles sharing an account --- internal/commands/auth_test.go | 66 ++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/internal/commands/auth_test.go b/internal/commands/auth_test.go index ad1aa9c1..f216d2b0 100644 --- a/internal/commands/auth_test.go +++ b/internal/commands/auth_test.go @@ -627,6 +627,72 @@ func TestAuthSwitch(t *testing.T) { }) } +func TestProfileAliasesUseSharedAccountWithDistinctTokens(t *testing.T) { + credDir := t.TempDir() + profileDir := t.TempDir() + + os.Setenv("FIZZY_ALIAS_NO_KR", "1") + defer os.Unsetenv("FIZZY_ALIAS_NO_KR") + store := credstore.NewStore(credstore.StoreOptions{ + ServiceName: "fizzy-alias-test", + DisableEnvVar: "FIZZY_ALIAS_NO_KR", + FallbackDir: credDir, + }) + profileStore := profile.NewStore(filepath.Join(profileDir, "config.json")) + for _, name := range []string{"walter", "walter2"} { + if err := profileStore.Create(&profile.Profile{ + Name: name, + BaseURL: "https://app.fizzy.do", + Extra: map[string]json.RawMessage{ + "account": json.RawMessage(`"1"`), + }, + }); err != nil { + t.Fatalf("create profile %s: %v", name, err) + } + } + + for profileName, token := range map[string]string{ + "walter": "walter-token", + "walter2": "agent-token", + } { + data, _ := json.Marshal(token) + if err := store.Save("profile:"+profileName, data); err != nil { + t.Fatalf("save token for %s: %v", profileName, err) + } + } + + mock := NewMockClient() + SetTestModeWithSDK(mock) + SetTestCreds(store) + SetTestProfiles(profileStore) + SetTestConfig("", "legacy-account", "https://app.fizzy.do") + defer resetTest() + + for _, tt := range []struct { + profile string + token string + }{ + {profile: "walter", token: "walter-token"}, + {profile: "walter2", token: "agent-token"}, + } { + t.Run(tt.profile, func(t *testing.T) { + cfgProfile = tt.profile + cfg.Token = "" + if err := resolveProfile(); err != nil { + t.Fatalf("resolve profile: %v", err) + } + resolveToken() + + if cfg.Account != "1" { + t.Errorf("account: want shared account '1', got %q", cfg.Account) + } + if cfg.Token != tt.token { + t.Errorf("token: want %q, got %q", tt.token, cfg.Token) + } + }) + } +} + func TestProfileFlagTokenSelection(t *testing.T) { t.Run("resolveToken loads token for profile specified via flag", func(t *testing.T) { credDir := t.TempDir() From cc04289bae6267df0b3f3d988d61468ab9b6a32e Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Fri, 31 Jul 2026 12:23:33 -0400 Subject: [PATCH 02/10] Support profile aliases for shared accounts --- README.md | 27 ++- SURFACE.txt | 1 + internal/commands/auth.go | 60 +++++-- internal/commands/auth_test.go | 260 +++++++++++++++++++++++++-- internal/commands/columns.go | 1 + internal/commands/config_cmd.go | 23 +++ internal/commands/config_cmd_test.go | 9 +- internal/commands/doctor.go | 40 +++-- internal/commands/doctor_test.go | 12 +- internal/commands/help.go | 2 +- internal/commands/quickstart.go | 4 +- internal/commands/root.go | 78 ++++++-- internal/config/config.go | 2 +- skills/fizzy/SKILL.md | 4 +- 14 files changed, 454 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index c7170301..775d822b 100644 --- a/README.md +++ b/README.md @@ -192,12 +192,37 @@ Breadcrumbs suggest next commands, making it easy for humans and agents to navig Configuration priority (highest to lowest): 1. CLI flags (`--token`, `--profile`, `--api-url`, `--board`) 2. Environment variables (`FIZZY_TOKEN`, `FIZZY_PROFILE`, `FIZZY_API_URL`, `FIZZY_BOARD`) -3. Named profile settings (base URL, board from `config.json`) +3. Named profile settings (account, base URL, board from `config.json`) 4. Local project config (`.fizzy.yaml`) 5. Global config (`~/.config/fizzy/config.yaml` or `~/.fizzy/config.yaml`) `FIZZY_ACCOUNT` is accepted as a deprecated alias for `FIZZY_PROFILE`. +Profiles created by `fizzy setup` use the account slug as their name and continue to work without changes. A profile can also use a distinct name by storing its account in the profile's `extra` settings. This supports separate credentials for multiple users of the same account: + +```bash +fizzy auth login "$WALTER_TOKEN" --profile walter --account 1 +fizzy auth login "$AGENT_TOKEN" --profile walter-agent --account 1 +``` + +The commands store credentials under each profile name while routing both profiles to account `1`: + +```json +{ + "profiles": { + "walter": { + "base_url": "https://app.fizzy.do", + "extra": { "account": "1" } + }, + "walter-agent": { + "base_url": "https://app.fizzy.do", + "extra": { "account": "1" } + } + }, + "default_profile": "walter" +} +``` + `FIZZY_NO_UPDATE_NOTIFIER=1` runs commands without update notifications. Inspect the effective config and precedence: diff --git a/SURFACE.txt b/SURFACE.txt index ef29adc9..3314847a 100644 --- a/SURFACE.txt +++ b/SURFACE.txt @@ -513,6 +513,7 @@ FLAG fizzy auth list --quiet type=bool FLAG fizzy auth list --styled type=bool FLAG fizzy auth list --token type=string FLAG fizzy auth list --verbose type=bool +FLAG fizzy auth login --account type=string FLAG fizzy auth login --agent type=bool FLAG fizzy auth login --api-url type=string FLAG fizzy auth login --count type=bool diff --git a/internal/commands/auth.go b/internal/commands/auth.go index f9e416f8..409728e5 100644 --- a/internal/commands/auth.go +++ b/internal/commands/auth.go @@ -10,6 +10,8 @@ import ( "github.com/spf13/cobra" ) +var authLoginAccount string + var authCmd = &cobra.Command{ Use: "auth", Short: "Manage authentication", @@ -23,24 +25,30 @@ var authLoginCmd = &cobra.Command{ Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { token := args[0] - profileName := cfg.Account + profileName := currentProfileName() + account := firstNonEmpty(authLoginAccount, cfg.Account) if profileName == "" { return errors.NewInvalidArgsError("No profile configured. Set --profile flag, FIZZY_PROFILE, or run 'fizzy setup'") } + if account == "" { + return errors.NewInvalidArgsError("No account configured. Set --account to the Fizzy account slug or ID") + } + activeProfile = profileName + cfg.Account = account if creds != nil { if err := credsSaveProfileToken(profileName, token); err != nil { return &output.Error{Code: output.CodeAPI, Message: err.Error()} } - // Ensure profile exists, set as default, clear YAML token - ensureProfile(profileName, cfg.APIURL, "") + // Ensure profile exists, set as default, clear YAML token. + ensureProfileForAccount(profileName, account, cfg.APIURL, "") if profiles != nil { _ = profiles.SetDefault(profileName) } globalCfg := config.LoadGlobal() - globalCfg.Account = profileName + globalCfg.Account = account if globalCfg.Token != "" { globalCfg.Token = "" } @@ -51,7 +59,7 @@ var authLoginCmd = &cobra.Command{ // Fallback: save to config file (test mode or credstore unavailable) globalCfg := config.LoadGlobal() globalCfg.Token = token - globalCfg.Account = profileName + globalCfg.Account = account if err := globalCfg.Save(); err != nil { return &output.Error{Code: output.CodeAPI, Message: err.Error()} } @@ -67,6 +75,7 @@ var authLoginCmd = &cobra.Command{ result := map[string]any{ "authenticated": true, "profile": profileName, + "account": account, "message": "Token saved", } if creds != nil { @@ -93,7 +102,7 @@ var authLogoutCmd = &cobra.Command{ return authLogoutAll() } - profileName := cfg.Account + profileName := currentProfileName() if profileName == "" { return errors.NewInvalidArgsError("No profile configured. Use --profile to specify which profile to log out, or --all to log out of all profiles") } @@ -104,14 +113,17 @@ var authLogoutCmd = &cobra.Command{ _ = credsDeleteProfileToken(profileName) } - // Remove profile from store + // Remove the profile and remember whether it selected the active account. + wasDefault := false if profiles != nil { + _, defaultName, _ := profiles.List() + wasDefault = defaultName == profileName _ = profiles.Delete(profileName) } - // Clear active account if logging out of it + // Clear the legacy account selector when its active profile is removed. globalCfg := config.LoadGlobal() - if globalCfg.Account == profileName { + if wasDefault || globalCfg.Account == profileName { globalCfg.Account = "" globalCfg.Token = "" } @@ -136,8 +148,9 @@ func authLogoutAll() error { if profiles != nil { allProfiles, _, _ := profiles.List() - for name := range allProfiles { + for name, p := range allProfiles { names[name] = true + names[profileAccount(name, p)] = true } } @@ -194,8 +207,15 @@ var authStatusCmd = &cobra.Command{ if effectiveCfg.Token != "" { status["token_configured"] = true + profileName := activeProfile + if profileName == "" { + profileName = effectiveCfg.Account + } + if profileName != "" { + status["profile"] = profileName + } if effectiveCfg.Account != "" { - status["profile"] = effectiveCfg.Account + status["account"] = effectiveCfg.Account } if effectiveCfg.APIURL != "" && effectiveCfg.APIURL != config.DefaultAPIURL { status["api_url"] = effectiveCfg.APIURL @@ -255,6 +275,7 @@ var authListCmd = &cobra.Command{ for name, p := range allProfiles { entry := map[string]any{ "profile": name, + "account": profileAccount(name, p), "base_url": p.BaseURL, "active": name == defaultName, } @@ -315,27 +336,29 @@ var authSwitchCmd = &cobra.Command{ return errors.NewError(fmt.Sprintf("No credentials found for profile %q. Run 'fizzy auth login --profile %s' or 'fizzy signup'", profileName, profileName)) } - // Ensure profile exists in store + // Ensure the profile exists without replacing its deployment URL. if profiles != nil { - ensureProfile(profileName, cfg.APIURL, "") + ensureProfile(profileName, "", "") if err := profiles.SetDefault(profileName); err != nil { return &output.Error{Code: output.CodeAPI, Message: err.Error()} } } - // Read the target profile's board from Extra + // Read the target profile's account and board. + profileAccountID := profileName var profileBoard string if profiles != nil { if p, err := profiles.Get(profileName); err == nil { + profileAccountID = profileAccount(profileName, p) if boardRaw, ok := p.Extra["board"]; ok { _ = json.Unmarshal(boardRaw, &profileBoard) } } } - // Update YAML config for backward compat + // Update YAML config for backward compatibility. globalCfg := config.LoadGlobal() - globalCfg.Account = profileName + globalCfg.Account = profileAccountID globalCfg.Board = profileBoard if err := globalCfg.Save(); err != nil { return &output.Error{Code: output.CodeAPI, Message: err.Error()} @@ -343,7 +366,8 @@ var authSwitchCmd = &cobra.Command{ // Update in-memory config if cfg != nil { - cfg.Account = profileName + activeProfile = profileName + cfg.Account = profileAccountID cfg.Board = profileBoard if creds != nil { if t, err := credsLoadProfileToken(profileName); err == nil { @@ -368,6 +392,7 @@ var authSwitchCmd = &cobra.Command{ printMutation(map[string]any{ "profile": profileName, + "account": profileAccountID, "message": fmt.Sprintf("Switched to profile %s", profileName), }, "", breadcrumbs) return nil @@ -382,5 +407,6 @@ func init() { authCmd.AddCommand(authListCmd) authCmd.AddCommand(authSwitchCmd) + authLoginCmd.Flags().StringVar(&authLoginAccount, "account", "", "Fizzy account slug or ID for this profile") authLogoutCmd.Flags().Bool("all", false, "Log out of all profiles") } diff --git a/internal/commands/auth_test.go b/internal/commands/auth_test.go index f216d2b0..b28e8fd9 100644 --- a/internal/commands/auth_test.go +++ b/internal/commands/auth_test.go @@ -1,7 +1,10 @@ package commands import ( + "context" "encoding/json" + "net/http" + "net/http/httptest" "os" "path/filepath" "strings" @@ -113,6 +116,51 @@ func TestAuthLogin(t *testing.T) { } }) + t.Run("saves an alias separately from its account", func(t *testing.T) { + credDir := t.TempDir() + configDir := t.TempDir() + profileDir := t.TempDir() + + config.SetTestConfigDir(configDir) + defer config.ResetTestConfigDir() + os.Setenv("FIZZY_ALIAS_LOGIN_NO_KR", "1") + defer os.Unsetenv("FIZZY_ALIAS_LOGIN_NO_KR") + store := credstore.NewStore(credstore.StoreOptions{ + ServiceName: "fizzy-alias-login-test", + DisableEnvVar: "FIZZY_ALIAS_LOGIN_NO_KR", + FallbackDir: credDir, + }) + profileStore := profile.NewStore(filepath.Join(profileDir, "config.json")) + + mock := NewMockClient() + SetTestModeWithSDK(mock) + SetTestCreds(store) + SetTestProfiles(profileStore) + SetTestConfig("", "walter", "https://app.fizzy.do") + activeProfile = "walter" + authLoginAccount = "1" + defer resetTest() + + if err := authLoginCmd.RunE(authLoginCmd, []string{"walter-token"}); err != nil { + t.Fatalf("login: %v", err) + } + + p, err := profileStore.Get("walter") + if err != nil { + t.Fatalf("get walter profile: %v", err) + } + if account := profileAccount("walter", p); account != "1" { + t.Errorf("profile account: want 1, got %q", account) + } + if _, err := store.Load("profile:walter"); err != nil { + t.Fatalf("load aliased credential: %v", err) + } + globalCfg := config.LoadGlobal() + if globalCfg.Account != "1" { + t.Errorf("global account: want 1, got %q", globalCfg.Account) + } + }) + t.Run("requires profile to be configured", func(t *testing.T) { mock := NewMockClient() SetTestModeWithSDK(mock) @@ -160,6 +208,65 @@ func TestAuthLogin(t *testing.T) { }) } +func TestAuthLoginCreatesAliasFromExplicitSelectors(t *testing.T) { + for _, tt := range []struct { + name string + profileArgs []string + envProfile string + }{ + {name: "flag", profileArgs: []string{"--profile", "agent"}}, + {name: "environment", envProfile: "agent"}, + } { + t.Run(tt.name, func(t *testing.T) { + configDir := t.TempDir() + config.SetTestConfigDir(configDir) + config.SetTestWorkingDir(t.TempDir()) + defer config.ResetTestConfigDir() + defer config.ResetTestWorkingDir() + + os.Setenv("FIZZY_ALIAS_SELECTOR_NO_KR", "1") + defer os.Unsetenv("FIZZY_ALIAS_SELECTOR_NO_KR") + if tt.envProfile != "" { + os.Setenv("FIZZY_PROFILE", tt.envProfile) + defer os.Unsetenv("FIZZY_PROFILE") + } + store := credstore.NewStore(credstore.StoreOptions{ + ServiceName: "fizzy-alias-selector-test-" + tt.name, + DisableEnvVar: "FIZZY_ALIAS_SELECTOR_NO_KR", + FallbackDir: t.TempDir(), + }) + profileStore := profile.NewStore(filepath.Join(configDir, "config.json")) + if err := profileStore.Create(&profile.Profile{Name: "existing", BaseURL: "https://app.fizzy.do"}); err != nil { + t.Fatalf("create existing profile: %v", err) + } + + mock := NewMockClient() + SetTestModeWithSDK(mock) + SetTestCreds(store) + SetTestProfiles(profileStore) + SetTestConfig("", "existing", "https://app.fizzy.do") + defer resetTest() + + args := []string{"auth", "login", "agent-token", "--account", "1"} + args = append(args, tt.profileArgs...) + if _, err := runCobraWithArgs(args...); err != nil { + t.Fatalf("login with %s selector: %v", tt.name, err) + } + + p, err := profileStore.Get("agent") + if err != nil { + t.Fatalf("get agent profile: %v", err) + } + if account := profileAccount("agent", p); account != "1" { + t.Errorf("account: want 1, got %q", account) + } + if _, err := store.Load("profile:agent"); err != nil { + t.Fatalf("load agent credential: %v", err) + } + }) + } +} + func TestAuthLogout(t *testing.T) { t.Run("removes profile-scoped token from credstore", func(t *testing.T) { tempDir := t.TempDir() @@ -326,6 +433,78 @@ func TestAuthLogout(t *testing.T) { }) } +func TestAuthLogoutAliasClearsActiveLegacyAccount(t *testing.T) { + configDir := t.TempDir() + config.SetTestConfigDir(configDir) + config.SetTestWorkingDir(t.TempDir()) + defer config.ResetTestConfigDir() + defer config.ResetTestWorkingDir() + + globalCfg := &config.Config{Account: "1", APIURL: "https://app.fizzy.do"} + if err := globalCfg.Save(); err != nil { + t.Fatalf("save global config: %v", err) + } + + os.Setenv("FIZZY_ALIAS_LOGOUT_NO_KR", "1") + defer os.Unsetenv("FIZZY_ALIAS_LOGOUT_NO_KR") + store := credstore.NewStore(credstore.StoreOptions{ + ServiceName: "fizzy-alias-logout-test", + DisableEnvVar: "FIZZY_ALIAS_LOGOUT_NO_KR", + FallbackDir: t.TempDir(), + }) + aliasToken, _ := json.Marshal("alias-token") + legacyToken, _ := json.Marshal("legacy-token") + if err := store.Save("profile:walter", aliasToken); err != nil { + t.Fatalf("save alias token: %v", err) + } + if err := store.Save("token:1", legacyToken); err != nil { + t.Fatalf("save legacy token: %v", err) + } + + profileStore := profile.NewStore(filepath.Join(configDir, "config.json")) + if err := profileStore.Create(&profile.Profile{ + Name: "walter", + BaseURL: "https://app.fizzy.do", + Extra: map[string]json.RawMessage{"account": json.RawMessage(`"1"`)}, + }); err != nil { + t.Fatalf("create alias profile: %v", err) + } + + mock := NewMockClient() + SetTestModeWithSDK(mock) + SetTestCreds(store) + SetTestProfiles(profileStore) + SetTestConfig("", "1", "https://app.fizzy.do") + defer resetTest() + + if err := resolveProfile(); err != nil { + t.Fatalf("resolve alias: %v", err) + } + resolveToken() + if cfg.Token != "alias-token" { + t.Fatalf("token before logout: want alias-token, got %q", cfg.Token) + } + if err := authLogoutCmd.RunE(authLogoutCmd, nil); err != nil { + t.Fatalf("logout alias: %v", err) + } + + // Simulate the next process invocation. The preserved account-scoped legacy + // token must not become active after the selected alias is removed. + cfg = config.Load() + activeProfile = "" + cfgProfile = "" + if err := resolveProfile(); err != nil { + t.Fatalf("resolve after logout: %v", err) + } + resolveToken() + if cfg.Account != "" { + t.Errorf("account after logout: want empty, got %q", cfg.Account) + } + if cfg.Token != "" { + t.Errorf("token after logout: want empty, got %q", cfg.Token) + } +} + func TestAuthStatus(t *testing.T) { t.Run("shows authenticated status when token exists", func(t *testing.T) { tempDir := t.TempDir() @@ -570,8 +749,8 @@ func TestAuthSwitch(t *testing.T) { tokenData, _ := json.Marshal("other-token") store.Save("profile:other", tokenData) - cfg := &config.Config{Account: "acme"} - cfgData, _ := yaml.Marshal(cfg) + initialCfg := &config.Config{Account: "acme"} + cfgData, _ := yaml.Marshal(initialCfg) os.WriteFile(filepath.Join(tempDir, "config.yaml"), cfgData, 0600) mock := NewMockClient() @@ -595,6 +774,16 @@ func TestAuthSwitch(t *testing.T) { if savedConfig.Board != "" { t.Errorf("expected board cleared on switch, got '%s'", savedConfig.Board) } + if cfg.APIURL != "https://staging.fizzy.do" { + t.Errorf("expected target API URL to be applied, got %q", cfg.APIURL) + } + targetProfile, err := profileStore.Get("other") + if err != nil { + t.Fatalf("get target profile: %v", err) + } + if targetProfile.BaseURL != "https://staging.fizzy.do" { + t.Errorf("expected target API URL to remain unchanged, got %q", targetProfile.BaseURL) + } // Verify profile store default was updated _, defaultName, _ := profileStore.List() @@ -628,6 +817,18 @@ func TestAuthSwitch(t *testing.T) { } func TestProfileAliasesUseSharedAccountWithDistinctTokens(t *testing.T) { + type observedRequest struct { + path string + authorization string + } + requests := make(chan observedRequest, 2) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests <- observedRequest{path: r.URL.Path, authorization: r.Header.Get("Authorization")} + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + })) + defer server.Close() + credDir := t.TempDir() profileDir := t.TempDir() @@ -642,7 +843,7 @@ func TestProfileAliasesUseSharedAccountWithDistinctTokens(t *testing.T) { for _, name := range []string{"walter", "walter2"} { if err := profileStore.Create(&profile.Profile{ Name: name, - BaseURL: "https://app.fizzy.do", + BaseURL: server.URL, Extra: map[string]json.RawMessage{ "account": json.RawMessage(`"1"`), }, @@ -662,10 +863,10 @@ func TestProfileAliasesUseSharedAccountWithDistinctTokens(t *testing.T) { } mock := NewMockClient() - SetTestModeWithSDK(mock) + SetTestMode(mock) SetTestCreds(store) SetTestProfiles(profileStore) - SetTestConfig("", "legacy-account", "https://app.fizzy.do") + SetTestConfig("", "legacy-account", server.URL) defer resetTest() for _, tt := range []struct { @@ -689,10 +890,31 @@ func TestProfileAliasesUseSharedAccountWithDistinctTokens(t *testing.T) { if cfg.Token != tt.token { t.Errorf("token: want %q, got %q", tt.token, cfg.Token) } + + if err := initSDK(boardListCmd, cfg.APIURL, cfg.Token, cfg.Account); err != nil { + t.Fatalf("initialize SDK: %v", err) + } + if _, _, err := getSDK().Boards().List(context.Background(), "/boards.json"); err != nil { + t.Fatalf("list boards: %v", err) + } + request := <-requests + if request.path != "/1/boards.json" { + t.Errorf("request path: want /1/boards.json, got %q", request.path) + } + if request.authorization != "Bearer "+tt.token { + t.Errorf("authorization: want bearer token for %s, got %q", tt.profile, request.authorization) + } }) } } +func TestProfileAccountDefaultsToProfileName(t *testing.T) { + p := &profile.Profile{Name: "6102600", BaseURL: "https://app.fizzy.do"} + if account := profileAccount(p.Name, p); account != "6102600" { + t.Errorf("account: want legacy profile name, got %q", account) + } +} + func TestProfileFlagTokenSelection(t *testing.T) { t.Run("resolveToken loads token for profile specified via flag", func(t *testing.T) { credDir := t.TempDir() @@ -1320,12 +1542,18 @@ func TestAuthLogoutAllCleansLegacyKeys(t *testing.T) { }) profileStore := profile.NewStore(filepath.Join(profileDir, "config.json")) profileStore.Create(&profile.Profile{Name: "acme", BaseURL: "https://app.fizzy.do"}) + profileStore.Create(&profile.Profile{Name: "walter", BaseURL: "https://app.fizzy.do", Extra: map[string]json.RawMessage{"account": json.RawMessage(`"1"`)}}) + profileStore.Create(&profile.Profile{Name: "jane", BaseURL: "https://app.fizzy.do", Extra: map[string]json.RawMessage{"account": json.RawMessage(`"2"`)}}) - // Save tokens in ALL key formats + // Save tokens in every key format, including legacy keys for aliased accounts. tokenData, _ := json.Marshal("my-token") - store.Save("token", tokenData) // bare legacy - store.Save("token:acme", tokenData) // account-scoped legacy - store.Save("profile:acme", tokenData) // profile-scoped + store.Save("token", tokenData) // bare legacy + store.Save("token:acme", tokenData) // account-scoped legacy + store.Save("token:1", tokenData) // aliased account legacy + store.Save("token:2", tokenData) // non-active aliased account legacy + store.Save("profile:acme", tokenData) // profile-scoped + store.Save("profile:walter", tokenData) // aliased profile + store.Save("profile:jane", tokenData) // non-active aliased profile cfg := &config.Config{Account: "acme"} cfgData, _ := yaml.Marshal(cfg) @@ -1339,6 +1567,7 @@ func TestAuthLogoutAllCleansLegacyKeys(t *testing.T) { defer resetTest() authLogoutCmd.Flags().Set("all", "true") + defer authLogoutCmd.Flags().Set("all", "false") err := authLogoutCmd.RunE(authLogoutCmd, []string{}) assertExitCode(t, err, 0) @@ -1352,10 +1581,17 @@ func TestAuthLogoutAllCleansLegacyKeys(t *testing.T) { if _, err := store.Load("profile:acme"); err == nil { t.Error("expected 'profile:acme' key removed") } + for _, key := range []string{"token:1", "token:2", "profile:walter", "profile:jane"} { + if _, err := store.Load(key); err == nil { + t.Errorf("expected %q key removed", key) + } + } - // Profile should be gone from store - if _, err := profileStore.Get("acme"); err == nil { - t.Error("expected profile removed from store") + // Every profile should be gone from the store. + for _, name := range []string{"acme", "walter", "jane"} { + if _, err := profileStore.Get(name); err == nil { + t.Errorf("expected profile %q removed", name) + } } }) } diff --git a/internal/commands/columns.go b/internal/commands/columns.go index 9c9db9c5..a5b849e3 100644 --- a/internal/commands/columns.go +++ b/internal/commands/columns.go @@ -41,6 +41,7 @@ var ( authProfileColumns = render.Columns{ {Header: "Profile", Field: "profile"}, + {Header: "Account", Field: "account"}, {Header: "Active", Field: "active"}, {Header: "Board", Field: "board"}, {Header: "Base URL", Field: "base_url"}, diff --git a/internal/commands/config_cmd.go b/internal/commands/config_cmd.go index 4a5461f5..8fa10c15 100644 --- a/internal/commands/config_cmd.go +++ b/internal/commands/config_cmd.go @@ -108,6 +108,9 @@ func configShowData(verbose bool) map[string]any { "source": displayProfileSource(eff, defaultProfile), "default": eff.Default, }, + "account": map[string]any{ + "value": emptyToNil(eff.Account), + }, "api_url": map[string]any{ "value": emptyToNil(eff.APIURL), "source": displayConfigSource(eff.APIURLSource), @@ -125,6 +128,7 @@ func configShowData(verbose bool) map[string]any { if !verbose { data = map[string]any{ "profile": emptyToNil(eff.ProfileName), + "account": emptyToNil(eff.Account), "api_url": emptyToNil(eff.APIURL), "board": emptyToNil(eff.Board), "token": map[string]any{"configured": eff.Token != "", "source": eff.TokenSource}, @@ -158,6 +162,20 @@ func configExplainData() map[string]any { }, } + accountSource := displayProfileSource(eff, defaultProfile) + if resolvedProfile != "" { + accountSource = profileSourceLabel(resolvedProfile, eff.ProfileName) + } + accountField := configExplainField{ + Value: emptyToNil(eff.Account), + Source: accountSource, + Candidates: []configExplainCandidate{ + {Source: profileSourceLabel(resolvedProfile, eff.ProfileName), Value: unsetString(profileAccount(resolvedProfile, profileCfg)), Selected: resolvedProfile != ""}, + {Source: "local config", Value: unsetString(fieldValue(localCfg, func(c *cfgpkg.Config) string { return c.Account })), Selected: resolvedProfile == "" && localCfg != nil && localCfg.Account != ""}, + {Source: "global config", Value: unsetString(fieldValue(globalCfg, func(c *cfgpkg.Config) string { return c.Account })), Selected: resolvedProfile == "" && (localCfg == nil || localCfg.Account == "") && globalCfg != nil && globalCfg.Account != ""}, + }, + } + apiURLField := configExplainField{ Value: emptyToNil(eff.APIURL), Source: displayFieldSource(eff.APIURLSource, profileSourceLabel(resolvedProfile, eff.ProfileName)), @@ -197,6 +215,7 @@ func configExplainData() map[string]any { return map[string]any{ "profile": profileField, + "account": accountField, "api_url": apiURLField, "board": boardField, "token": tokenField, @@ -213,6 +232,7 @@ func renderConfigShowHuman(data map[string]any, markdown bool) string { } profile := describeConfigShowValue(data["profile"]) + account := describeConfigShowValue(data["account"]) apiURL := describeConfigShowValue(data["api_url"]) board := describeConfigShowValue(data["board"]) token := describeConfigShowToken(data["token"]) @@ -220,6 +240,7 @@ func renderConfigShowHuman(data map[string]any, markdown bool) string { if markdown { fmt.Fprintf(&sb, "- **Profile:** `%s`\n", profile) + fmt.Fprintf(&sb, "- **Account:** `%s`\n", account) fmt.Fprintf(&sb, "- **API URL:** `%s`\n", apiURL) fmt.Fprintf(&sb, "- **Board:** `%s`\n", board) fmt.Fprintf(&sb, "- **Token:** %s\n", token) @@ -232,6 +253,7 @@ func renderConfigShowHuman(data map[string]any, markdown bool) string { sb.WriteString("- `fizzy auth list` — inspect saved profiles\n") } else { fmt.Fprintf(&sb, "Profile %s\n", profile) + fmt.Fprintf(&sb, "Account %s\n", account) fmt.Fprintf(&sb, "API URL %s\n", apiURL) fmt.Fprintf(&sb, "Board %s\n", board) fmt.Fprintf(&sb, "Token %s\n", token) @@ -260,6 +282,7 @@ func renderConfigExplainHuman(data map[string]any, markdown bool) string { label string }{ {"profile", "Profile"}, + {"account", "Account"}, {"api_url", "API URL"}, {"board", "Board"}, {"token", "Token"}, diff --git a/internal/commands/config_cmd_test.go b/internal/commands/config_cmd_test.go index b574fcd8..ee5e274a 100644 --- a/internal/commands/config_cmd_test.go +++ b/internal/commands/config_cmd_test.go @@ -103,7 +103,8 @@ func TestConfigExplainShowsPrecedence(t *testing.T) { Name: "acme", BaseURL: "https://profile.example.com", Extra: map[string]json.RawMessage{ - "board": json.RawMessage(`"profile-board"`), + "account": json.RawMessage(`"1"`), + "board": json.RawMessage(`"profile-board"`), }, }); err != nil { t.Fatalf("create profile: %v", err) @@ -118,7 +119,7 @@ func TestConfigExplainShowsPrecedence(t *testing.T) { t.Setenv("FIZZY_PROFILE", "acme") t.Setenv("FIZZY_API_URL", "https://env.example.com") cfg = config.Load() - cfg.Account = "acme" + cfg.Account = "1" cfg.APIURL = "https://env.example.com" cfg.Board = "profile-board" defer resetTest() @@ -135,6 +136,10 @@ func TestConfigExplainShowsPrecedence(t *testing.T) { if profileField["source"] != "env FIZZY_PROFILE" { t.Fatalf("expected env profile source, got %#v", profileField) } + accountField := data["account"].(map[string]any) + if accountField["value"] != "1" || accountField["source"] != "profile acme" { + t.Fatalf("expected account 1 from profile acme, got %#v", accountField) + } apiURLField := data["api_url"].(map[string]any) if apiURLField["source"] != "env FIZZY_API_URL" { t.Fatalf("expected env api url source, got %#v", apiURLField) diff --git a/internal/commands/doctor.go b/internal/commands/doctor.go index 0f473c22..47c3c197 100644 --- a/internal/commands/doctor.go +++ b/internal/commands/doctor.go @@ -79,6 +79,7 @@ func (r *DoctorResult) Summary() string { type doctorEffectiveConfig struct { ProfileName string + Account string Default bool ProfileSource string APIURL string @@ -455,7 +456,8 @@ func checkDoctorProfileStore(verbose bool) DoctorCheck { func resolveDoctorEffectiveConfig() doctorEffectiveConfig { eff := doctorEffectiveConfig{ - ProfileName: cfg.Account, + ProfileName: activeProfile, + Account: cfg.Account, APIURL: cfg.APIURL, Board: cfg.Board, Token: cfg.Token, @@ -464,6 +466,9 @@ func resolveDoctorEffectiveConfig() doctorEffectiveConfig { globalCfg, _ := loadDoctorConfigFile(globalConfigPathForDoctor()) localCfg, _ := loadDoctorConfigFile(config.LocalConfigPath()) resolvedProfile, profileCfg := resolveDoctorProfileContext() + if eff.ProfileName == "" { + eff.ProfileName = firstNonEmpty(resolvedProfile, cfg.Account) + } switch { case cfgProfile != "": @@ -510,7 +515,7 @@ func resolveDoctorEffectiveConfig() doctorEffectiveConfig { eff.BoardSource = "unset" } - eff.TokenSourceRaw, eff.TokenSource, eff.Token = doctorTokenSourceWithValue(cfg.Account, localCfg, globalCfg) + eff.TokenSourceRaw, eff.TokenSource, eff.Token = doctorTokenSourceWithValue(eff.ProfileName, localCfg, globalCfg) return eff } @@ -525,6 +530,9 @@ func checkDoctorEffectiveConfig(eff doctorEffectiveConfig, verbose bool) DoctorC } else if verbose { parts = append(parts, "profile=") } + if eff.Account != "" { + parts = append(parts, fmt.Sprintf("account=%s", eff.Account)) + } if eff.APIURL != "" { if verbose { parts = append(parts, fmt.Sprintf("api_url=%s [%s]", eff.APIURL, eff.APIURLSource)) @@ -715,7 +723,8 @@ func checkDoctorAuthentication(ctx context.Context, eff doctorEffectiveConfig, v } func checkDoctorAccountAccess(ctx context.Context, eff doctorEffectiveConfig, verbose bool) DoctorCheck { - if eff.ProfileName == "" { + account := firstNonEmpty(eff.Account, eff.ProfileName) + if account == "" { return DoctorCheck{ Name: "Account Access", Status: "warn", @@ -733,14 +742,14 @@ func checkDoctorAccountAccess(ctx context.Context, eff doctorEffectiveConfig, ve conv := convertSDKError(err) var outErr *output.Error if stderrors.As(conv, &outErr) { - return DoctorCheck{Name: "Account Access", Status: "fail", Message: fmt.Sprintf("Cannot access account %s", eff.ProfileName), Hint: outErr.Message} + return DoctorCheck{Name: "Account Access", Status: "fail", Message: fmt.Sprintf("Cannot access account %s", account), Hint: outErr.Message} } - return DoctorCheck{Name: "Account Access", Status: "fail", Message: fmt.Sprintf("Cannot access account %s", eff.ProfileName), Hint: err.Error()} + return DoctorCheck{Name: "Account Access", Status: "fail", Message: fmt.Sprintf("Cannot access account %s", account), Hint: err.Error()} } count := dataCount(normalizeAny(items)) - msg := fmt.Sprintf("Account %s accessible", eff.ProfileName) + msg := fmt.Sprintf("Account %s accessible", account) if verbose { - msg = fmt.Sprintf("Account %s accessible (%d boards, %dms)", eff.ProfileName, count, time.Since(start).Milliseconds()) + msg = fmt.Sprintf("Account %s accessible (%d boards, %dms)", account, count, time.Since(start).Milliseconds()) } return DoctorCheck{Name: "Account Access", Status: "pass", Message: msg} } @@ -1163,7 +1172,7 @@ func doctorProfileBoard(p *profile.Profile) string { return board } -func doctorTokenSourceWithValue(account string, localCfg, globalCfg *config.Config) (string, string, string) { +func doctorTokenSourceWithValue(profileName string, localCfg, globalCfg *config.Config) (string, string, string) { if cfgToken != "" { return "flag", "CLI flag", cfgToken } @@ -1171,14 +1180,14 @@ func doctorTokenSourceWithValue(account string, localCfg, globalCfg *config.Conf return "env", "environment variable", envToken } if creds != nil { - if account != "" { - if token, err := credsLoadProfileToken(account); err == nil && token != "" { + if profileName != "" { + if token, err := credsLoadProfileToken(profileName); err == nil && token != "" { if creds.UsingKeyring() { return "keyring", "system keyring", token } return "fallback-file", "fallback credential file", token } - if token, err := credsLoadLegacyToken(account); err == nil && token != "" { + if token, err := credsLoadLegacyToken(profileName); err == nil && token != "" { if creds.UsingKeyring() { return "legacy-keyring", "legacy system keyring entry", token } @@ -1277,6 +1286,7 @@ func doctorTargetsFromProfileStore() []doctorEffectiveConfig { } targets = append(targets, doctorEffectiveConfig{ ProfileName: name, + Account: profileAccount(name, p), Default: name == defaultName, ProfileSource: "profile store", APIURL: apiURL, @@ -1291,15 +1301,15 @@ func doctorTargetsFromProfileStore() []doctorEffectiveConfig { return targets } -func doctorStoredTokenSourceForProfile(account string, localCfg, globalCfg *config.Config) (string, string, string) { +func doctorStoredTokenSourceForProfile(profileName string, localCfg, globalCfg *config.Config) (string, string, string) { if creds != nil { - if token, err := credsLoadProfileToken(account); err == nil && token != "" { + if token, err := credsLoadProfileToken(profileName); err == nil && token != "" { if creds.UsingKeyring() { return "keyring", "system keyring", token } return "fallback-file", "fallback credential file", token } - if token, err := credsLoadLegacyToken(account); err == nil && token != "" { + if token, err := credsLoadLegacyToken(profileName); err == nil && token != "" { if creds.UsingKeyring() { return "legacy-keyring", "legacy system keyring entry", token } @@ -1325,7 +1335,7 @@ func newDoctorClients(eff doctorEffectiveConfig) (client *fizzy.Client, accountC }() sdkCfg := &fizzy.Config{BaseURL: eff.APIURL} client = fizzy.NewClient(sdkCfg, &fizzy.StaticTokenProvider{Token: eff.Token}, fizzy.WithUserAgent("fizzy-cli/"+currentVersion())) - accountClient = client.ForAccount(eff.ProfileName) + accountClient = client.ForAccount(firstNonEmpty(eff.Account, eff.ProfileName)) return client, accountClient, nil } diff --git a/internal/commands/doctor_test.go b/internal/commands/doctor_test.go index 86cf7084..1b7f9bf1 100644 --- a/internal/commands/doctor_test.go +++ b/internal/commands/doctor_test.go @@ -335,16 +335,16 @@ func TestDoctorAllProfilesIncludesPerProfileResults(t *testing.T) { mock.OnGet("/boards/board-1.json", &client.APIResponse{StatusCode: 200, Data: map[string]any{"id": "board-1", "name": "Roadmap"}}) result := SetTestModeWithSDK(mock) - if err := profileStore.Create(&profile.Profile{Name: "acme", BaseURL: testHTTPServer.URL, Extra: map[string]json.RawMessage{"board": json.RawMessage(`"board-1"`)}}); err != nil { - t.Fatalf("create acme profile: %v", err) + if err := profileStore.Create(&profile.Profile{Name: "walter", BaseURL: testHTTPServer.URL, Extra: map[string]json.RawMessage{"account": json.RawMessage(`"acme"`), "board": json.RawMessage(`"board-1"`)}}); err != nil { + t.Fatalf("create walter profile: %v", err) } if err := profileStore.Create(&profile.Profile{Name: "staging", BaseURL: testHTTPServer.URL}); err != nil { t.Fatalf("create staging profile: %v", err) } - if err := profileStore.SetDefault("acme"); err != nil { + if err := profileStore.SetDefault("walter"); err != nil { t.Fatalf("set default profile: %v", err) } - if err := credsSaveProfileTokenForTest(store, "acme", "test-token"); err != nil { + if err := credsSaveProfileTokenForTest(store, "walter", "test-token"); err != nil { t.Fatalf("save profile token: %v", err) } SetTestCreds(store) @@ -386,8 +386,8 @@ func TestDoctorAllProfilesIncludesPerProfileResults(t *testing.T) { found[name] = statuses } - if found["acme"]["Authentication"] != "pass" { - t.Fatalf("expected acme authentication to pass, got %#v", found["acme"]) + if found["walter"]["Authentication"] != "pass" { + t.Fatalf("expected walter authentication to pass, got %#v", found["walter"]) } if found["staging"]["Credentials"] != "fail" { t.Fatalf("expected staging credentials to fail, got %#v", found["staging"]) diff --git a/internal/commands/help.go b/internal/commands/help.go index 78eae8fd..263ed44a 100644 --- a/internal/commands/help.go +++ b/internal/commands/help.go @@ -386,7 +386,7 @@ var rootCommandGroups = map[string][]string{ } var commandExamples = map[string]string{ - "fizzy auth": "$ fizzy auth status\n$ fizzy auth login TOKEN --profile acme", + "fizzy auth": "$ fizzy auth status\n$ fizzy auth login TOKEN --profile acme\n$ fizzy auth login TOKEN --profile agent --account acme", "fizzy auth status": "$ fizzy auth status", "fizzy auth list": "$ fizzy auth list\n$ fizzy auth switch acme", "fizzy activity": "$ fizzy activity list\n$ fizzy activity list --board ", diff --git a/internal/commands/quickstart.go b/internal/commands/quickstart.go index d57863b8..ef568079 100644 --- a/internal/commands/quickstart.go +++ b/internal/commands/quickstart.go @@ -35,8 +35,8 @@ func runRootDefault(cmd *cobra.Command, args []string) error { } auth := quickStartAuthInfo{Status: "unauthenticated"} - if cfgProfile != "" { - auth.Profile = cfgProfile + if profileName := firstNonEmpty(activeProfile, cfgProfile); profileName != "" { + auth.Profile = profileName } if cfg != nil { if cfg.Account != "" { diff --git a/internal/commands/root.go b/internal/commands/root.go index 9df48c9a..99a83a01 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -59,8 +59,10 @@ var ( // Credential store creds *credstore.Store - // Profile store - profiles *profile.Store + // Profile store and the selected profile name. The selected profile is + // kept separate from cfg.Account so aliases can target the same account. + profiles *profile.Store + activeProfile string // Output writer out *output.Writer @@ -148,7 +150,14 @@ var rootCmd = &cobra.Command{ } if err := resolveProfile(); err != nil { - return &output.Error{Code: output.CodeUsage, Message: err.Error()} + // Login can create a new alias when its account is supplied explicitly. + newProfile := firstNonEmpty(cfgProfile, os.Getenv("FIZZY_PROFILE"), os.Getenv("FIZZY_ACCOUNT")) + if cmd == authLoginCmd && newProfile != "" && authLoginAccount != "" { + activeProfile = newProfile + cfg.Account = authLoginAccount + } else { + return &output.Error{Code: output.CodeUsage, Message: err.Error()} + } } resolveToken() @@ -1160,9 +1169,12 @@ func credsLoadLegacyToken(account string) (string, error) { // or env var) that doesn't exist — that must be a hard failure, not a // silent fallback to whatever was in the YAML config. func resolveProfile() error { + activeProfile = "" if profiles == nil { - // No profile store (test mode or init failure) — fall back to env var - if p := os.Getenv("FIZZY_PROFILE"); p != "" { + // No profile store (test mode or init failure) — use the selected name + // as both profile and account for legacy compatibility. + if p := firstNonEmpty(cfgProfile, profileEnvVar()); p != "" { + activeProfile = p cfg.Account = p } return nil @@ -1170,8 +1182,10 @@ func resolveProfile() error { allProfiles, defaultName, err := profiles.List() if err != nil || len(allProfiles) == 0 { - // No profiles configured — fall back to env var for account - if v := profileEnvVar(); v != "" { + // No profiles configured — use the selected name as both profile and + // account until a profile with explicit account metadata is created. + if v := firstNonEmpty(cfgProfile, profileEnvVar()); v != "" { + activeProfile = v cfg.Account = v } return nil @@ -1204,7 +1218,8 @@ func resolveProfile() error { // Apply profile settings to cfg — but only for fields that haven't // already been set by a higher-precedence source (env var). - cfg.Account = resolved + activeProfile = resolved + cfg.Account = profileAccount(resolved, p) if p.BaseURL != "" && os.Getenv("FIZZY_API_URL") == "" { cfg.APIURL = p.BaseURL } @@ -1230,12 +1245,38 @@ func profileEnvVar() string { return "" } +// profileAccount returns the account routed by a profile. Profiles created +// before account aliases were supported use their name as the account. +func profileAccount(name string, p *profile.Profile) string { + if p != nil { + if raw, ok := p.Extra["account"]; ok { + var account string + if json.Unmarshal(raw, &account) == nil && strings.TrimSpace(account) != "" { + return account + } + } + } + return name +} + +// currentProfileName returns the selected credential profile. Legacy config +// without a named profile uses the configured account as its credential key. +func currentProfileName() string { + if activeProfile != "" { + return activeProfile + } + if cfg != nil { + return cfg.Account + } + return "" +} + // resolveToken applies token precedence: YAML → credstore (with migration) → env → flag. func resolveToken() { // 1. YAML file (global + local, already in cfg.Token from config.Load()) // 2. credstore (overrides YAML — credstore is the "new" storage) if creds != nil { - profileName := cfg.Account // profile name = account slug + profileName := currentProfileName() if profileName != "" { // Try profile-scoped token first @@ -1283,9 +1324,9 @@ func migrateLegacyToken(profileName string) { cfg.Token = globalCfg.Token if err := credsSaveProfileToken(profileName, globalCfg.Token); err == nil { globalCfg.Token = "" - globalCfg.Account = profileName + globalCfg.Account = cfg.Account _ = globalCfg.Save() - ensureProfile(profileName, cfg.APIURL, "") + ensureProfileForAccount(profileName, cfg.Account, cfg.APIURL, "") } } } @@ -1296,6 +1337,12 @@ func migrateLegacyToken(profileName string) { // "keep whatever is there"), and Extra entries are preserved unless // explicitly replaced. func ensureProfile(name, baseURL, board string) { + ensureProfileForAccount(name, "", baseURL, board) +} + +// ensureProfileForAccount creates or updates a profile and associates it with +// an account. An empty account preserves existing account metadata. +func ensureProfileForAccount(name, account, baseURL, board string) { if profiles == nil { return } @@ -1320,6 +1367,13 @@ func ensureProfile(name, baseURL, board string) { if board != "" { extra["board"] = func() json.RawMessage { b, _ := json.Marshal(board); return b }() } + if account != "" { + if account == name { + delete(extra, "account") + } else { + extra["account"] = func() json.RawMessage { b, _ := json.Marshal(account); return b }() + } + } p := &profile.Profile{ Name: name, @@ -1376,6 +1430,7 @@ func ResetTestMode() { cfg = nil creds = nil profiles = nil + activeProfile = "" cfgJSON = false cfgQuiet = false cfgIDsOnly = false @@ -1386,6 +1441,7 @@ func ResetTestMode() { cfgLimit = 0 cfgJQ = "" cfgProfile = "" + authLoginAccount = "" if updateCancel != nil { updateCancel() } diff --git a/internal/config/config.go b/internal/config/config.go index 6409521a..b8a030ac 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -113,7 +113,7 @@ func findLocalConfig() string { // // 1. CLI flags (--token, --profile, --api-url, --board) // 2. Environment variables (FIZZY_TOKEN, FIZZY_PROFILE, FIZZY_API_URL, FIZZY_BOARD) -// 3. Named profile settings (BaseURL, board from config.json) +// 3. Named profile settings (account, BaseURL, board from config.json) // 4. Local project config (.fizzy.yaml) // 5. Global config (~/.config/fizzy/config.yaml) // 6. Defaults diff --git a/skills/fizzy/SKILL.md b/skills/fizzy/SKILL.md index a47f4df3..ff4908be 100644 --- a/skills/fizzy/SKILL.md +++ b/skills/fizzy/SKILL.md @@ -190,7 +190,7 @@ board: 03foq1hqmyy91tuyz3ghugg6c **Priority (highest to lowest):** 1. CLI flags (`--token`, `--profile`, `--api-url`, `--board`) 2. Environment variables (`FIZZY_TOKEN`, `FIZZY_PROFILE`, `FIZZY_API_URL`, `FIZZY_BOARD`) -3. Named profile settings (base URL, board from `config.json`) +3. Named profile settings (account, base URL, board from `config.json`) 4. Local project config (`.fizzy.yaml`) 5. Global config (`~/.config/fizzy/config.yaml` or `~/.fizzy/config.yaml`) @@ -206,6 +206,7 @@ fizzy config explain fizzy setup # Interactive wizard fizzy doctor # Full install/config/auth/API/agent health check fizzy auth login TOKEN # Save token for current profile +fizzy auth login TOKEN --profile agent --account 1 # Separate identity on account 1 fizzy auth status # Check auth status fizzy auth list # List all authenticated profiles fizzy auth switch PROFILE # Switch active profile @@ -1137,6 +1138,7 @@ fizzy auth status # Check auth fizzy auth list # Check which profiles are configured fizzy auth switch PROFILE # Switch to correct profile fizzy auth login TOKEN # Re-authenticate +fizzy auth login TOKEN --profile agent --account 1 # Save an aliased profile fizzy setup # Full interactive setup ``` From e1799969df1dcad6783a54c166fb73b26b0feb2f Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Fri, 31 Jul 2026 12:51:58 -0400 Subject: [PATCH 03/10] Address profile alias review feedback --- internal/commands/auth_test.go | 28 ++++++++++++++++++---------- internal/commands/doctor_test.go | 29 +++++++++++++++++++++++++++++ internal/commands/root.go | 7 ++++--- 3 files changed, 51 insertions(+), 13 deletions(-) diff --git a/internal/commands/auth_test.go b/internal/commands/auth_test.go index b28e8fd9..c83394a4 100644 --- a/internal/commands/auth_test.go +++ b/internal/commands/auth_test.go @@ -208,14 +208,17 @@ func TestAuthLogin(t *testing.T) { }) } -func TestAuthLoginCreatesAliasFromExplicitSelectors(t *testing.T) { +func TestAuthLoginCreatesProfilesFromExplicitSelectors(t *testing.T) { for _, tt := range []struct { name string + profileName string profileArgs []string envProfile string + account string }{ - {name: "flag", profileArgs: []string{"--profile", "agent"}}, - {name: "environment", envProfile: "agent"}, + {name: "flag alias", profileName: "agent", profileArgs: []string{"--profile", "agent"}, account: "1"}, + {name: "environment alias", profileName: "agent", envProfile: "agent", account: "1"}, + {name: "profile name defaults to account", profileName: "new-account", profileArgs: []string{"--profile", "new-account"}}, } { t.Run(tt.name, func(t *testing.T) { configDir := t.TempDir() @@ -247,21 +250,26 @@ func TestAuthLoginCreatesAliasFromExplicitSelectors(t *testing.T) { SetTestConfig("", "existing", "https://app.fizzy.do") defer resetTest() - args := []string{"auth", "login", "agent-token", "--account", "1"} + args := make([]string, 0, 5+len(tt.profileArgs)) + args = append(args, "auth", "login", "agent-token") + if tt.account != "" { + args = append(args, "--account", tt.account) + } args = append(args, tt.profileArgs...) if _, err := runCobraWithArgs(args...); err != nil { t.Fatalf("login with %s selector: %v", tt.name, err) } - p, err := profileStore.Get("agent") + p, err := profileStore.Get(tt.profileName) if err != nil { - t.Fatalf("get agent profile: %v", err) + t.Fatalf("get %s profile: %v", tt.profileName, err) } - if account := profileAccount("agent", p); account != "1" { - t.Errorf("account: want 1, got %q", account) + expectedAccount := firstNonEmpty(tt.account, tt.profileName) + if account := profileAccount(tt.profileName, p); account != expectedAccount { + t.Errorf("account: want %q, got %q", expectedAccount, account) } - if _, err := store.Load("profile:agent"); err != nil { - t.Fatalf("load agent credential: %v", err) + if _, err := store.Load("profile:" + tt.profileName); err != nil { + t.Fatalf("load %s credential: %v", tt.profileName, err) } }) } diff --git a/internal/commands/doctor_test.go b/internal/commands/doctor_test.go index 1b7f9bf1..dccd17be 100644 --- a/internal/commands/doctor_test.go +++ b/internal/commands/doctor_test.go @@ -1,7 +1,10 @@ package commands import ( + "context" "encoding/json" + "net/http" + "net/http/httptest" "os" "path/filepath" "strings" @@ -394,6 +397,32 @@ func TestDoctorAllProfilesIncludesPerProfileResults(t *testing.T) { } } +func TestNewDoctorClientsRoutesAliasThroughAccount(t *testing.T) { + paths := make(chan string, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths <- r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + })) + defer server.Close() + + _, accountClient, err := newDoctorClients(doctorEffectiveConfig{ + ProfileName: "walter", + Account: "acme", + APIURL: server.URL, + Token: "test-token", + }) + if err != nil { + t.Fatalf("create doctor clients: %v", err) + } + if _, _, err := accountClient.Boards().List(context.Background(), "/boards.json"); err != nil { + t.Fatalf("list boards: %v", err) + } + if path := <-paths; path != "/acme/boards.json" { + t.Fatalf("request path: want /acme/boards.json, got %q", path) + } +} + func TestDoctorTargetsFromProfileStoreUsesYAMLBoardFallback(t *testing.T) { configDir := t.TempDir() workDir := t.TempDir() diff --git a/internal/commands/root.go b/internal/commands/root.go index 99a83a01..ce225e6e 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -150,11 +150,12 @@ var rootCmd = &cobra.Command{ } if err := resolveProfile(); err != nil { - // Login can create a new alias when its account is supplied explicitly. + // Login can create a new profile. An explicit account creates an alias; + // otherwise the profile name remains the routed account. newProfile := firstNonEmpty(cfgProfile, os.Getenv("FIZZY_PROFILE"), os.Getenv("FIZZY_ACCOUNT")) - if cmd == authLoginCmd && newProfile != "" && authLoginAccount != "" { + if cmd == authLoginCmd && newProfile != "" { activeProfile = newProfile - cfg.Account = authLoginAccount + cfg.Account = firstNonEmpty(authLoginAccount, newProfile) } else { return &output.Error{Code: output.CodeUsage, Message: err.Error()} } From fcac767b4db76b2791eecea961689b0ced3dd322 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Fri, 31 Jul 2026 13:10:29 -0400 Subject: [PATCH 04/10] Prevent orphaned credentials during profile login --- internal/commands/auth.go | 22 +++++++--- internal/commands/auth_test.go | 76 ++++++++++++++++++++++++++++++++-- internal/commands/root.go | 46 ++++++++++++++++---- internal/commands/setup.go | 4 +- internal/commands/signup.go | 8 +++- 5 files changed, 135 insertions(+), 21 deletions(-) diff --git a/internal/commands/auth.go b/internal/commands/auth.go index 409728e5..604aa817 100644 --- a/internal/commands/auth.go +++ b/internal/commands/auth.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/basecamp/cli/output" + "github.com/basecamp/cli/profile" "github.com/basecamp/fizzy-cli/internal/config" "github.com/basecamp/fizzy-cli/internal/errors" "github.com/spf13/cobra" @@ -34,18 +35,25 @@ var authLoginCmd = &cobra.Command{ if account == "" { return errors.NewInvalidArgsError("No account configured. Set --account to the Fizzy account slug or ID") } + if err := profile.ValidateName(profileName); err != nil { + return errors.NewInvalidArgsError(err.Error()) + } activeProfile = profileName cfg.Account = account if creds != nil { - if err := credsSaveProfileToken(profileName, token); err != nil { + // Persist the profile before its credential so a profile-store failure + // cannot leave an orphaned credential behind. + if err := ensureProfileForAccount(profileName, account, cfg.APIURL, ""); err != nil { return &output.Error{Code: output.CodeAPI, Message: err.Error()} } - - // Ensure profile exists, set as default, clear YAML token. - ensureProfileForAccount(profileName, account, cfg.APIURL, "") if profiles != nil { - _ = profiles.SetDefault(profileName) + if err := profiles.SetDefault(profileName); err != nil { + return &output.Error{Code: output.CodeAPI, Message: err.Error()} + } + } + if err := credsSaveProfileToken(profileName, token); err != nil { + return &output.Error{Code: output.CodeAPI, Message: err.Error()} } globalCfg := config.LoadGlobal() globalCfg.Account = account @@ -338,7 +346,9 @@ var authSwitchCmd = &cobra.Command{ // Ensure the profile exists without replacing its deployment URL. if profiles != nil { - ensureProfile(profileName, "", "") + if err := ensureProfile(profileName, "", ""); err != nil { + return &output.Error{Code: output.CodeAPI, Message: err.Error()} + } if err := profiles.SetDefault(profileName); err != nil { return &output.Error{Code: output.CodeAPI, Message: err.Error()} } diff --git a/internal/commands/auth_test.go b/internal/commands/auth_test.go index c83394a4..24744b7b 100644 --- a/internal/commands/auth_test.go +++ b/internal/commands/auth_test.go @@ -161,6 +161,70 @@ func TestAuthLogin(t *testing.T) { } }) + t.Run("rejects invalid aliases before saving credentials", func(t *testing.T) { + os.Setenv("FIZZY_INVALID_ALIAS_NO_KR", "1") + defer os.Unsetenv("FIZZY_INVALID_ALIAS_NO_KR") + store := credstore.NewStore(credstore.StoreOptions{ + ServiceName: "fizzy-invalid-alias-test", + DisableEnvVar: "FIZZY_INVALID_ALIAS_NO_KR", + FallbackDir: t.TempDir(), + }) + profileStore := profile.NewStore(filepath.Join(t.TempDir(), "config.json")) + + mock := NewMockClient() + SetTestModeWithSDK(mock) + SetTestCreds(store) + SetTestProfiles(profileStore) + SetTestConfig("", "walter.agent", "https://app.fizzy.do") + activeProfile = "walter.agent" + authLoginAccount = "1" + defer resetTest() + + err := authLoginCmd.RunE(authLoginCmd, []string{"agent-token"}) + if err == nil { + t.Fatal("expected invalid profile error") + } + if _, err := store.Load("profile:walter.agent"); err == nil { + t.Fatal("credential was saved for an invalid profile") + } + }) + + t.Run("does not save credentials when profile creation fails", func(t *testing.T) { + configDir := t.TempDir() + config.SetTestConfigDir(configDir) + defer config.ResetTestConfigDir() + + os.Setenv("FIZZY_PROFILE_FAILURE_NO_KR", "1") + defer os.Unsetenv("FIZZY_PROFILE_FAILURE_NO_KR") + store := credstore.NewStore(credstore.StoreOptions{ + ServiceName: "fizzy-profile-failure-test", + DisableEnvVar: "FIZZY_PROFILE_FAILURE_NO_KR", + FallbackDir: t.TempDir(), + }) + blockedParent := filepath.Join(t.TempDir(), "not-a-directory") + if err := os.WriteFile(blockedParent, []byte("blocked"), 0o600); err != nil { + t.Fatalf("create blocking file: %v", err) + } + profileStore := profile.NewStore(filepath.Join(blockedParent, "config.json")) + + mock := NewMockClient() + SetTestModeWithSDK(mock) + SetTestCreds(store) + SetTestProfiles(profileStore) + SetTestConfig("", "agent", "https://app.fizzy.do") + activeProfile = "agent" + authLoginAccount = "1" + defer resetTest() + + err := authLoginCmd.RunE(authLoginCmd, []string{"agent-token"}) + if err == nil { + t.Fatal("expected profile creation error") + } + if _, err := store.Load("profile:agent"); err == nil { + t.Fatal("credential was saved without a profile") + } + }) + t.Run("requires profile to be configured", func(t *testing.T) { mock := NewMockClient() SetTestModeWithSDK(mock) @@ -1452,7 +1516,9 @@ func TestEnsureProfileUpdatesExisting(t *testing.T) { defer resetTest() // Call ensureProfile with new settings - ensureProfile("acme", "https://new.example.com", "new-board") + if err := ensureProfile("acme", "https://new.example.com", "new-board"); err != nil { + t.Fatalf("ensure profile: %v", err) + } p, err := profileStore.Get("acme") if err != nil { @@ -1487,7 +1553,9 @@ func TestEnsureProfileUpdatesExisting(t *testing.T) { defer resetTest() // Re-signup with default URL should overwrite the self-hosted URL - ensureProfile("acme", config.DefaultAPIURL, "") + if err := ensureProfile("acme", config.DefaultAPIURL, ""); err != nil { + t.Fatalf("ensure profile: %v", err) + } p, err := profileStore.Get("acme") if err != nil { @@ -1520,7 +1588,9 @@ func TestEnsureProfileUpdatesExisting(t *testing.T) { defer resetTest() // Empty baseURL should preserve the existing one - ensureProfile("acme", "", "") + if err := ensureProfile("acme", "", ""); err != nil { + t.Fatalf("ensure profile: %v", err) + } p, err := profileStore.Get("acme") if err != nil { diff --git a/internal/commands/root.go b/internal/commands/root.go index ce225e6e..99a5bfde 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -1313,7 +1313,7 @@ func migrateLegacyToken(profileName string) { // Always use the token, even if migration to profile-scoped key fails cfg.Token = t if err := credsSaveProfileToken(profileName, t); err == nil { - ensureProfile(profileName, cfg.APIURL, "") + _ = ensureProfile(profileName, cfg.APIURL, "") } return } @@ -1327,7 +1327,7 @@ func migrateLegacyToken(profileName string) { globalCfg.Token = "" globalCfg.Account = cfg.Account _ = globalCfg.Save() - ensureProfileForAccount(profileName, cfg.Account, cfg.APIURL, "") + _ = ensureProfileForAccount(profileName, cfg.Account, cfg.APIURL, "") } } } @@ -1337,18 +1337,28 @@ func migrateLegacyToken(profileName string) { // preserved only when the caller passes an empty string (meaning // "keep whatever is there"), and Extra entries are preserved unless // explicitly replaced. -func ensureProfile(name, baseURL, board string) { - ensureProfileForAccount(name, "", baseURL, board) +func ensureProfile(name, baseURL, board string) error { + return ensureProfileForAccount(name, "", baseURL, board) } // ensureProfileForAccount creates or updates a profile and associates it with // an account. An empty account preserves existing account metadata. -func ensureProfileForAccount(name, account, baseURL, board string) { +func ensureProfileForAccount(name, account, baseURL, board string) error { if profiles == nil { - return + if account != "" && account != name { + return fmt.Errorf("profile store is unavailable for alias %q", name) + } + return nil + } + if err := profile.ValidateName(name); err != nil { + return err } - existing, _ := profiles.Get(name) + allProfiles, defaultName, err := profiles.List() + if err != nil { + return err + } + existing := allProfiles[name] newBaseURL := baseURL if newBaseURL == "" { @@ -1384,10 +1394,28 @@ func ensureProfileForAccount(name, account, baseURL, board string) { p.Extra = extra } + if existing == nil { + return profiles.Create(p) + } + + if err := profiles.Delete(name); err != nil { + return err + } if err := profiles.Create(p); err != nil { - _ = profiles.Delete(name) - _ = profiles.Create(p) + // Restore the previous profile when replacing it fails. + restoreErr := profiles.Create(existing) + if restoreErr == nil && defaultName == name { + restoreErr = profiles.SetDefault(name) + } + if restoreErr != nil { + return fmt.Errorf("update profile %q: %w (restore failed: %w)", name, err, restoreErr) + } + return err } + if defaultName == name { + return profiles.SetDefault(name) + } + return nil } // SetTestSDK configures the commands package for SDK-based testing. diff --git a/internal/commands/setup.go b/internal/commands/setup.go index ff54caeb..9f6923db 100644 --- a/internal/commands/setup.go +++ b/internal/commands/setup.go @@ -274,7 +274,9 @@ func runSetup(cmd *cobra.Command, args []string) error { } // Create/update profile - ensureProfile(selectedAccountSlug, apiURL, selectedBoardID) + if err := ensureProfile(selectedAccountSlug, apiURL, selectedBoardID); err != nil { + return &output.Error{Code: output.CodeAPI, Message: err.Error()} + } // If user chose "None (skip)", clear any previously saved board if selectedBoardID == "" && profiles != nil { if p, err := profiles.Get(selectedAccountSlug); err == nil { diff --git a/internal/commands/signup.go b/internal/commands/signup.go index 05db5ba5..f74d0315 100644 --- a/internal/commands/signup.go +++ b/internal/commands/signup.go @@ -823,9 +823,13 @@ func saveSignupConfig(token, account, apiURL string) error { } // Create/update profile - ensureProfile(account, apiURL, "") + if err := ensureProfile(account, apiURL, ""); err != nil { + return err + } if profiles != nil { - _ = profiles.SetDefault(account) + if err := profiles.SetDefault(account); err != nil { + return err + } } globalCfg.Account = account From 7cb068b121b1815b9674fe1ef5bf9d530e48fc80 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Fri, 31 Jul 2026 13:28:49 -0400 Subject: [PATCH 05/10] Clear stale account aliases during onboarding --- internal/commands/config_cmd.go | 7 +++-- internal/commands/config_cmd_test.go | 38 ++++++++++++++++++++++++++++ internal/commands/setup.go | 2 +- internal/commands/setup_test.go | 25 ++++++++++++++++++ internal/commands/signup.go | 2 +- internal/commands/signup_test.go | 28 ++++++++++++++++++++ 6 files changed, 98 insertions(+), 4 deletions(-) diff --git a/internal/commands/config_cmd.go b/internal/commands/config_cmd.go index 8fa10c15..ef3bf202 100644 --- a/internal/commands/config_cmd.go +++ b/internal/commands/config_cmd.go @@ -171,8 +171,11 @@ func configExplainData() map[string]any { Source: accountSource, Candidates: []configExplainCandidate{ {Source: profileSourceLabel(resolvedProfile, eff.ProfileName), Value: unsetString(profileAccount(resolvedProfile, profileCfg)), Selected: resolvedProfile != ""}, - {Source: "local config", Value: unsetString(fieldValue(localCfg, func(c *cfgpkg.Config) string { return c.Account })), Selected: resolvedProfile == "" && localCfg != nil && localCfg.Account != ""}, - {Source: "global config", Value: unsetString(fieldValue(globalCfg, func(c *cfgpkg.Config) string { return c.Account })), Selected: resolvedProfile == "" && (localCfg == nil || localCfg.Account == "") && globalCfg != nil && globalCfg.Account != ""}, + {Source: "flag --profile", Value: unsetString(cfgProfile), Selected: resolvedProfile == "" && eff.ProfileSource == "flag --profile"}, + {Source: "env FIZZY_PROFILE", Value: unsetString(strings.TrimSpace(getEnv("FIZZY_PROFILE"))), Selected: resolvedProfile == "" && eff.ProfileSource == "env FIZZY_PROFILE"}, + {Source: "env FIZZY_ACCOUNT", Value: unsetString(strings.TrimSpace(getEnv("FIZZY_ACCOUNT"))), Selected: resolvedProfile == "" && eff.ProfileSource == "env FIZZY_ACCOUNT"}, + {Source: "local config", Value: unsetString(fieldValue(localCfg, func(c *cfgpkg.Config) string { return c.Account })), Selected: resolvedProfile == "" && eff.ProfileSource == "local config"}, + {Source: "global config", Value: unsetString(fieldValue(globalCfg, func(c *cfgpkg.Config) string { return c.Account })), Selected: resolvedProfile == "" && eff.ProfileSource == "global config"}, }, } diff --git a/internal/commands/config_cmd_test.go b/internal/commands/config_cmd_test.go index ee5e274a..4700b612 100644 --- a/internal/commands/config_cmd_test.go +++ b/internal/commands/config_cmd_test.go @@ -162,6 +162,44 @@ func TestConfigExplainShowsPrecedence(t *testing.T) { } } +func TestConfigExplainAccountCandidatesFollowProfileFallback(t *testing.T) { + configDir := t.TempDir() + workDir := t.TempDir() + config.SetTestConfigDir(configDir) + config.SetTestWorkingDir(workDir) + defer config.ResetTestConfigDir() + defer config.ResetTestWorkingDir() + + if err := os.WriteFile(filepath.Join(workDir, config.LocalConfigFile), []byte("account: stale-local\n"), 0o600); err != nil { + t.Fatalf("write local config: %v", err) + } + profileStore := profile.NewStore(filepath.Join(t.TempDir(), "config.json")) + mock := NewMockClient() + SetTestModeWithSDK(mock) + SetTestProfiles(profileStore) + t.Setenv("FIZZY_PROFILE", "new-account") + cfg = config.Load() + defer resetTest() + + if err := resolveProfile(); err != nil { + t.Fatalf("resolve profile fallback: %v", err) + } + field := configExplainData()["account"].(configExplainField) + if field.Value != "new-account" || field.Source != "env FIZZY_PROFILE" { + t.Fatalf("effective account: want new-account from env, got %#v", field) + } + + selected := "" + for _, candidate := range field.Candidates { + if candidate.Selected { + selected = candidate.Source + } + } + if selected != "env FIZZY_PROFILE" { + t.Fatalf("selected account candidate: want env FIZZY_PROFILE, got %q (%#v)", selected, field.Candidates) + } +} + func TestConfigShowVerboseIncludesProfiles(t *testing.T) { configDir := t.TempDir() profileDir := t.TempDir() diff --git a/internal/commands/setup.go b/internal/commands/setup.go index 9f6923db..e4c239f2 100644 --- a/internal/commands/setup.go +++ b/internal/commands/setup.go @@ -274,7 +274,7 @@ func runSetup(cmd *cobra.Command, args []string) error { } // Create/update profile - if err := ensureProfile(selectedAccountSlug, apiURL, selectedBoardID); err != nil { + if err := ensureProfileForAccount(selectedAccountSlug, selectedAccountSlug, apiURL, selectedBoardID); err != nil { return &output.Error{Code: output.CodeAPI, Message: err.Error()} } // If user chose "None (skip)", clear any previously saved board diff --git a/internal/commands/setup_test.go b/internal/commands/setup_test.go index bf8b1269..2aad3e6d 100644 --- a/internal/commands/setup_test.go +++ b/internal/commands/setup_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "github.com/basecamp/cli/profile" "github.com/basecamp/fizzy-cli/internal/config" "gopkg.in/yaml.v3" ) @@ -39,6 +40,30 @@ func toJSONSlice(t *testing.T, items []any) []json.RawMessage { return result } +func TestSetupAccountProfileClearsStaleAliasRouting(t *testing.T) { + profileStore := profile.NewStore(filepath.Join(t.TempDir(), "config.json")) + if err := profileStore.Create(&profile.Profile{ + Name: "acct", + BaseURL: config.DefaultAPIURL, + Extra: map[string]json.RawMessage{"account": json.RawMessage(`"old-account"`)}, + }); err != nil { + t.Fatalf("create stale alias: %v", err) + } + SetTestProfiles(profileStore) + defer resetTest() + + if err := ensureProfileForAccount("acct", "acct", config.DefaultAPIURL, ""); err != nil { + t.Fatalf("save setup profile: %v", err) + } + p, err := profileStore.Get("acct") + if err != nil { + t.Fatalf("get setup profile: %v", err) + } + if account := profileAccount("acct", p); account != "acct" { + t.Fatalf("setup account: want acct, got %q", account) + } +} + func TestParseAccounts(t *testing.T) { t.Run("parses accounts from identity response", func(t *testing.T) { data := toJSON(t, map[string]any{ diff --git a/internal/commands/signup.go b/internal/commands/signup.go index f74d0315..01b55e89 100644 --- a/internal/commands/signup.go +++ b/internal/commands/signup.go @@ -823,7 +823,7 @@ func saveSignupConfig(token, account, apiURL string) error { } // Create/update profile - if err := ensureProfile(account, apiURL, ""); err != nil { + if err := ensureProfileForAccount(account, account, apiURL, ""); err != nil { return err } if profiles != nil { diff --git a/internal/commands/signup_test.go b/internal/commands/signup_test.go index 0ac6b89f..49fb624b 100644 --- a/internal/commands/signup_test.go +++ b/internal/commands/signup_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/basecamp/cli/output" + "github.com/basecamp/cli/profile" "github.com/basecamp/fizzy-cli/internal/config" "gopkg.in/yaml.v3" ) @@ -566,6 +567,33 @@ func TestSaveSignupConfigClearsStaleAPIURL(t *testing.T) { } }) + t.Run("signup clears stale alias routing for its account", func(t *testing.T) { + config.SetTestConfigDir(t.TempDir()) + defer config.ResetTestConfigDir() + + profileStore := profile.NewStore(filepath.Join(t.TempDir(), "config.json")) + if err := profileStore.Create(&profile.Profile{ + Name: "acct", + BaseURL: config.DefaultAPIURL, + Extra: map[string]json.RawMessage{"account": json.RawMessage(`"old-account"`)}, + }); err != nil { + t.Fatalf("create stale alias: %v", err) + } + SetTestProfiles(profileStore) + defer resetTest() + + if err := saveSignupConfig("token", "acct", config.DefaultAPIURL); err != nil { + t.Fatalf("save signup config: %v", err) + } + p, err := profileStore.Get("acct") + if err != nil { + t.Fatalf("get signup profile: %v", err) + } + if account := profileAccount("acct", p); account != "acct" { + t.Fatalf("signup account: want acct, got %q", account) + } + }) + t.Run("self-hosted signup preserves custom URL", func(t *testing.T) { config.SetTestConfigDir(t.TempDir()) defer config.ResetTestConfigDir() From f3ff8dbc54bb4306d6a4b3d826bbbdaaf8b1ef5e Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Fri, 31 Jul 2026 13:44:13 -0400 Subject: [PATCH 06/10] Avoid login migration side effects and persist switch URL --- internal/commands/auth.go | 17 +++++++------ internal/commands/auth_test.go | 45 ++++++++++++++++++++++++++++++++++ internal/commands/root.go | 6 ++++- 3 files changed, 60 insertions(+), 8 deletions(-) diff --git a/internal/commands/auth.go b/internal/commands/auth.go index 604aa817..7d94f177 100644 --- a/internal/commands/auth.go +++ b/internal/commands/auth.go @@ -354,12 +354,19 @@ var authSwitchCmd = &cobra.Command{ } } - // Read the target profile's account and board. + // Read the target profile's account, board, and deployment URL. profileAccountID := profileName + profileAPIURL := config.DefaultAPIURL + if cfg != nil && cfg.APIURL != "" { + profileAPIURL = cfg.APIURL + } var profileBoard string if profiles != nil { if p, err := profiles.Get(profileName); err == nil { profileAccountID = profileAccount(profileName, p) + if p.BaseURL != "" { + profileAPIURL = p.BaseURL + } if boardRaw, ok := p.Extra["board"]; ok { _ = json.Unmarshal(boardRaw, &profileBoard) } @@ -369,6 +376,7 @@ var authSwitchCmd = &cobra.Command{ // Update YAML config for backward compatibility. globalCfg := config.LoadGlobal() globalCfg.Account = profileAccountID + globalCfg.APIURL = profileAPIURL globalCfg.Board = profileBoard if err := globalCfg.Save(); err != nil { return &output.Error{Code: output.CodeAPI, Message: err.Error()} @@ -387,12 +395,7 @@ var authSwitchCmd = &cobra.Command{ } } - // Apply profile's BaseURL - if profiles != nil { - if p, err := profiles.Get(profileName); err == nil && p.BaseURL != "" { - cfg.APIURL = p.BaseURL - } - } + cfg.APIURL = profileAPIURL } breadcrumbs := []Breadcrumb{ diff --git a/internal/commands/auth_test.go b/internal/commands/auth_test.go index 24744b7b..be458694 100644 --- a/internal/commands/auth_test.go +++ b/internal/commands/auth_test.go @@ -189,6 +189,48 @@ func TestAuthLogin(t *testing.T) { } }) + t.Run("invalid selector does not migrate a legacy token", func(t *testing.T) { + configDir := t.TempDir() + config.SetTestConfigDir(configDir) + config.SetTestWorkingDir(t.TempDir()) + defer config.ResetTestConfigDir() + defer config.ResetTestWorkingDir() + + os.Setenv("FIZZY_INVALID_MIGRATION_NO_KR", "1") + defer os.Unsetenv("FIZZY_INVALID_MIGRATION_NO_KR") + store := credstore.NewStore(credstore.StoreOptions{ + ServiceName: "fizzy-invalid-migration-test", + DisableEnvVar: "FIZZY_INVALID_MIGRATION_NO_KR", + FallbackDir: t.TempDir(), + }) + legacyToken, _ := json.Marshal("legacy-token") + if err := store.Save("token", legacyToken); err != nil { + t.Fatalf("save legacy token: %v", err) + } + profileStore := profile.NewStore(filepath.Join(configDir, "config.json")) + if err := profileStore.Create(&profile.Profile{Name: "existing", BaseURL: config.DefaultAPIURL}); err != nil { + t.Fatalf("create existing profile: %v", err) + } + + mock := NewMockClient() + SetTestModeWithSDK(mock) + SetTestCreds(store) + SetTestProfiles(profileStore) + SetTestConfig("", "existing", config.DefaultAPIURL) + defer resetTest() + + _, err := runCobraWithArgs("auth", "login", "replacement-token", "--profile", "walter.agent", "--account", "1") + if err == nil { + t.Fatal("expected invalid profile error") + } + if _, err := store.Load("profile:walter.agent"); err == nil { + t.Fatal("legacy token was migrated to an invalid profile") + } + if _, err := store.Load("token"); err != nil { + t.Fatalf("legacy token was removed: %v", err) + } + }) + t.Run("does not save credentials when profile creation fails", func(t *testing.T) { configDir := t.TempDir() config.SetTestConfigDir(configDir) @@ -846,6 +888,9 @@ func TestAuthSwitch(t *testing.T) { if savedConfig.Board != "" { t.Errorf("expected board cleared on switch, got '%s'", savedConfig.Board) } + if savedConfig.APIURL != "https://staging.fizzy.do" { + t.Errorf("expected persisted target API URL, got %q", savedConfig.APIURL) + } if cfg.APIURL != "https://staging.fizzy.do" { t.Errorf("expected target API URL to be applied, got %q", cfg.APIURL) } diff --git a/internal/commands/root.go b/internal/commands/root.go index 99a5bfde..b209e49e 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -160,7 +160,11 @@ var rootCmd = &cobra.Command{ return &output.Error{Code: output.CodeUsage, Message: err.Error()} } } - resolveToken() + // Login replaces the selected credential with its TOKEN argument, so it + // does not resolve or migrate an existing credential first. + if cmd != authLoginCmd { + resolveToken() + } // --api-url flag overrides everything (including profile BaseURL) if cfgAPIURL != "" { From 36844370645a8782f8a7aa5c724aeff60bd04651 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Fri, 31 Jul 2026 14:22:29 -0400 Subject: [PATCH 07/10] Restore profile state when login credentials fail --- internal/commands/auth.go | 73 +++++++++++- internal/commands/auth_test.go | 209 +++++++++++++++++++++++++++++++++ 2 files changed, 279 insertions(+), 3 deletions(-) diff --git a/internal/commands/auth.go b/internal/commands/auth.go index 7d94f177..2720bec0 100644 --- a/internal/commands/auth.go +++ b/internal/commands/auth.go @@ -3,6 +3,7 @@ package commands import ( "encoding/json" "fmt" + "os" "github.com/basecamp/cli/output" "github.com/basecamp/cli/profile" @@ -42,18 +43,41 @@ var authLoginCmd = &cobra.Command{ cfg.Account = account if creds != nil { + var previousProfile *profile.Profile + var previousDefault string + if profiles != nil { + allProfiles, defaultName, err := profiles.List() + if err != nil { + return &output.Error{Code: output.CodeAPI, Message: err.Error()} + } + previousProfile = allProfiles[profileName] + previousDefault = defaultName + } + // Persist the profile before its credential so a profile-store failure // cannot leave an orphaned credential behind. if err := ensureProfileForAccount(profileName, account, cfg.APIURL, ""); err != nil { return &output.Error{Code: output.CodeAPI, Message: err.Error()} } + restoreProfile := func(operationErr error) error { + if profiles == nil { + return &output.Error{Code: output.CodeAPI, Message: operationErr.Error()} + } + if restoreErr := restoreAuthLoginProfile(profileName, previousProfile, previousDefault); restoreErr != nil { + return &output.Error{ + Code: output.CodeAPI, + Message: fmt.Sprintf("%v (profile restore failed: %v)", operationErr, restoreErr), + } + } + return &output.Error{Code: output.CodeAPI, Message: operationErr.Error()} + } if profiles != nil { if err := profiles.SetDefault(profileName); err != nil { - return &output.Error{Code: output.CodeAPI, Message: err.Error()} + return restoreProfile(err) } } if err := credsSaveProfileToken(profileName, token); err != nil { - return &output.Error{Code: output.CodeAPI, Message: err.Error()} + return restoreProfile(err) } globalCfg := config.LoadGlobal() globalCfg.Account = account @@ -99,6 +123,47 @@ var authLoginCmd = &cobra.Command{ }, } +func restoreAuthLoginProfile(profileName string, previousProfile *profile.Profile, previousDefault string) error { + allProfiles, _, err := profiles.List() + if err != nil { + return err + } + if _, exists := allProfiles[profileName]; exists { + if err := profiles.Delete(profileName); err != nil { + return err + } + } + if previousProfile != nil { + if previousDefault == "" && len(allProfiles) == 1 { + // A temporary profile prevents Create from selecting the restored + // profile when the store previously had no default. + temporaryName := "fizzy-restore" + for suffix := 2; ; suffix++ { + if _, exists := allProfiles[temporaryName]; !exists { + break + } + temporaryName = fmt.Sprintf("fizzy-restore-%d", suffix) + } + if err := profiles.Create(&profile.Profile{Name: temporaryName, BaseURL: config.DefaultAPIURL}); err != nil { + return err + } + if err := profiles.Create(previousProfile); err != nil { + return err + } + return profiles.Delete(temporaryName) + } + if err := profiles.Create(previousProfile); err != nil { + return err + } + } + if previousDefault != "" { + if err := profiles.SetDefault(previousDefault); err != nil { + return err + } + } + return nil +} + var authLogoutCmd = &cobra.Command{ Use: "logout", Short: "Remove saved credentials", @@ -395,7 +460,9 @@ var authSwitchCmd = &cobra.Command{ } } - cfg.APIURL = profileAPIURL + if cfgAPIURL == "" && os.Getenv("FIZZY_API_URL") == "" { + cfg.APIURL = profileAPIURL + } } breadcrumbs := []Breadcrumb{ diff --git a/internal/commands/auth_test.go b/internal/commands/auth_test.go index be458694..eee46922 100644 --- a/internal/commands/auth_test.go +++ b/internal/commands/auth_test.go @@ -231,6 +231,154 @@ func TestAuthLogin(t *testing.T) { } }) + t.Run("restores a new profile when credential saving fails", func(t *testing.T) { + configDir := t.TempDir() + config.SetTestConfigDir(configDir) + defer config.ResetTestConfigDir() + + blockedCredDir := filepath.Join(t.TempDir(), "blocked") + if err := os.WriteFile(blockedCredDir, []byte("not a directory"), 0600); err != nil { + t.Fatalf("create blocked credential path: %v", err) + } + t.Setenv("FIZZY_LOGIN_ROLLBACK_NEW_NO_KR", "1") + store := credstore.NewStore(credstore.StoreOptions{ + ServiceName: "fizzy-login-rollback-new-test", + DisableEnvVar: "FIZZY_LOGIN_ROLLBACK_NEW_NO_KR", + FallbackDir: blockedCredDir, + }) + profileStore := profile.NewStore(filepath.Join(configDir, "config.json")) + if err := profileStore.Create(&profile.Profile{Name: "existing", BaseURL: config.DefaultAPIURL}); err != nil { + t.Fatalf("create existing profile: %v", err) + } + + SetTestCreds(store) + SetTestProfiles(profileStore) + SetTestConfig("", "1", config.DefaultAPIURL) + activeProfile = "agent" + authLoginAccount = "1" + defer resetTest() + + err := authLoginCmd.RunE(authLoginCmd, []string{"replacement-token"}) + if err == nil { + t.Fatal("expected credential save error") + } + allProfiles, defaultName, listErr := profileStore.List() + if listErr != nil { + t.Fatalf("list profiles: %v", listErr) + } + if _, ok := allProfiles["agent"]; ok { + t.Fatal("failed login left the new profile behind") + } + if defaultName != "existing" { + t.Fatalf("default profile: want existing, got %q", defaultName) + } + }) + + for _, initialDefault := range []string{"other", "agent", ""} { + name := "restores existing profile when credential saving fails" + switch initialDefault { + case "agent": + name += " while already default" + case "": + name += " with no prior default" + } + t.Run(name, func(t *testing.T) { + configDir := t.TempDir() + config.SetTestConfigDir(configDir) + defer config.ResetTestConfigDir() + + t.Setenv("FIZZY_LOGIN_ROLLBACK_EXISTING_NO_KR", "1") + credDir := filepath.Join(t.TempDir(), "credentials") + store := credstore.NewStore(credstore.StoreOptions{ + ServiceName: "fizzy-login-rollback-existing-test", + DisableEnvVar: "FIZZY_LOGIN_ROLLBACK_EXISTING_NO_KR", + FallbackDir: credDir, + }) + oldToken, _ := json.Marshal("old-token") + if err := store.Save("profile:agent", oldToken); err != nil { + t.Fatalf("save old credential: %v", err) + } + backupCredDir := credDir + "-backup" + if err := os.Rename(credDir, backupCredDir); err != nil { + t.Fatalf("move credential directory: %v", err) + } + if err := os.WriteFile(credDir, []byte("not a directory"), 0600); err != nil { + t.Fatalf("block credential directory: %v", err) + } + + profileStore := profile.NewStore(filepath.Join(configDir, "config.json")) + if err := profileStore.Create(&profile.Profile{Name: "other", BaseURL: config.DefaultAPIURL}); err != nil { + t.Fatalf("create other profile: %v", err) + } + if err := profileStore.Create(&profile.Profile{ + Name: "agent", + BaseURL: "https://old.example.com", + Extra: map[string]json.RawMessage{ + "account": json.RawMessage(`"old-account"`), + "board": json.RawMessage(`"old-board"`), + }, + }); err != nil { + t.Fatalf("create agent profile: %v", err) + } + switch initialDefault { + case "agent": + if err := profileStore.SetDefault("agent"); err != nil { + t.Fatalf("set initial default: %v", err) + } + case "": + if err := profileStore.Delete("other"); err != nil { + t.Fatalf("clear initial default: %v", err) + } + } + + SetTestCreds(store) + SetTestProfiles(profileStore) + SetTestConfig("", "new-account", "https://new.example.com") + activeProfile = "agent" + authLoginAccount = "new-account" + + err := authLoginCmd.RunE(authLoginCmd, []string{"replacement-token"}) + if removeErr := os.Remove(credDir); removeErr != nil { + t.Fatalf("unblock credential directory: %v", removeErr) + } + if renameErr := os.Rename(backupCredDir, credDir); renameErr != nil { + t.Fatalf("restore credential directory: %v", renameErr) + } + defer resetTest() + if err == nil { + t.Fatal("expected credential save error") + } + + restored, getErr := profileStore.Get("agent") + if getErr != nil { + t.Fatalf("get restored profile: %v", getErr) + } + if restored.BaseURL != "https://old.example.com" { + t.Errorf("BaseURL: want old value, got %q", restored.BaseURL) + } + if got := profileAccount("agent", restored); got != "old-account" { + t.Errorf("account: want old-account, got %q", got) + } + if got := string(restored.Extra["board"]); got != `"old-board"` { + t.Errorf("board metadata: want old value, got %s", got) + } + _, defaultName, listErr := profileStore.List() + if listErr != nil { + t.Fatalf("list profiles: %v", listErr) + } + if defaultName != initialDefault { + t.Errorf("default profile: want %q, got %q", initialDefault, defaultName) + } + data, loadErr := store.Load("profile:agent") + if loadErr != nil { + t.Fatalf("load restored credential: %v", loadErr) + } + if string(data) != string(oldToken) { + t.Errorf("credential changed: want %s, got %s", oldToken, data) + } + }) + } + t.Run("does not save credentials when profile creation fails", func(t *testing.T) { configDir := t.TempDir() config.SetTestConfigDir(configDir) @@ -909,6 +1057,67 @@ func TestAuthSwitch(t *testing.T) { } }) + for _, tt := range []struct { + name string + envURL string + flagURL string + effective string + }{ + {name: "preserves environment API URL override", envURL: "https://env.example.com", effective: "https://env.example.com"}, + {name: "preserves flag API URL override", envURL: "https://env.example.com", flagURL: "https://flag.example.com", effective: "https://flag.example.com"}, + } { + t.Run(tt.name, func(t *testing.T) { + configDir := t.TempDir() + config.SetTestConfigDir(configDir) + defer config.ResetTestConfigDir() + t.Setenv("FIZZY_API_URL", tt.envURL) + t.Setenv("FIZZY_SWITCH_OVERRIDE_NO_KR", "1") + + store := credstore.NewStore(credstore.StoreOptions{ + ServiceName: "fizzy-switch-override-test", + DisableEnvVar: "FIZZY_SWITCH_OVERRIDE_NO_KR", + FallbackDir: t.TempDir(), + }) + profileStore := profile.NewStore(filepath.Join(t.TempDir(), "config.json")) + if err := profileStore.Create(&profile.Profile{Name: "acme", BaseURL: "https://app.fizzy.do"}); err != nil { + t.Fatalf("create acme profile: %v", err) + } + if err := profileStore.Create(&profile.Profile{Name: "other", BaseURL: "https://profile.example.com"}); err != nil { + t.Fatalf("create other profile: %v", err) + } + tokenData, _ := json.Marshal("other-token") + if err := store.Save("profile:other", tokenData); err != nil { + t.Fatalf("save other credential: %v", err) + } + + SetTestModeWithSDK(NewMockClient()) + SetTestCreds(store) + SetTestProfiles(profileStore) + SetTestConfig("acme-token", "acme", tt.effective) + cfgAPIURL = tt.flagURL + defer func() { cfgAPIURL = "" }() + defer resetTest() + + if err := authSwitchCmd.RunE(authSwitchCmd, []string{"other"}); err != nil { + t.Fatalf("switch profile: %v", err) + } + if cfg.APIURL != tt.effective { + t.Errorf("effective API URL: want %q, got %q", tt.effective, cfg.APIURL) + } + data, err := os.ReadFile(filepath.Join(configDir, "config.yaml")) + if err != nil { + t.Fatalf("read global config: %v", err) + } + var savedConfig config.Config + if err := yaml.Unmarshal(data, &savedConfig); err != nil { + t.Fatalf("parse global config: %v", err) + } + if savedConfig.APIURL != "https://profile.example.com" { + t.Errorf("persisted API URL: want profile URL, got %q", savedConfig.APIURL) + } + }) + } + t.Run("fails for unknown profile", func(t *testing.T) { credDir := t.TempDir() From 28b2cd426cb2796a04392dd746901bebfdbb001b Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Fri, 31 Jul 2026 15:23:23 -0400 Subject: [PATCH 08/10] Harden profile credential isolation and recovery --- go.mod | 2 +- internal/commands/auth.go | 266 ++++++++-------- internal/commands/auth_test.go | 490 +++++++++++++++++++++++++++++ internal/commands/config_cmd.go | 3 +- internal/commands/doctor.go | 107 ++++--- internal/commands/doctor_test.go | 115 ++++++- internal/commands/profile_state.go | 284 +++++++++++++++++ internal/commands/root.go | 109 +++++-- internal/commands/setup.go | 64 ++-- internal/commands/signup.go | 53 ++-- internal/commands/signup_test.go | 54 ++++ 11 files changed, 1281 insertions(+), 266 deletions(-) create mode 100644 internal/commands/profile_state.go diff --git a/go.mod b/go.mod index 3b5a09f6..b9dca74d 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/yuin/goldmark v1.8.4 + github.com/zalando/go-keyring v0.2.8 gopkg.in/yaml.v3 v3.0.1 ) @@ -44,7 +45,6 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - github.com/zalando/go-keyring v0.2.8 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect diff --git a/internal/commands/auth.go b/internal/commands/auth.go index 2720bec0..6bf89bb3 100644 --- a/internal/commands/auth.go +++ b/internal/commands/auth.go @@ -2,6 +2,7 @@ package commands import ( "encoding/json" + stderrors "errors" "fmt" "os" @@ -39,52 +40,26 @@ var authLoginCmd = &cobra.Command{ if err := profile.ValidateName(profileName); err != nil { return errors.NewInvalidArgsError(err.Error()) } + if err := validateAccountIdentifier(account); err != nil { + return errors.NewInvalidArgsError(err.Error()) + } activeProfile = profileName cfg.Account = account if creds != nil { - var previousProfile *profile.Profile - var previousDefault string - if profiles != nil { - allProfiles, defaultName, err := profiles.List() - if err != nil { - return &output.Error{Code: output.CodeAPI, Message: err.Error()} - } - previousProfile = allProfiles[profileName] - previousDefault = defaultName - } - - // Persist the profile before its credential so a profile-store failure - // cannot leave an orphaned credential behind. - if err := ensureProfileForAccount(profileName, account, cfg.APIURL, ""); err != nil { - return &output.Error{Code: output.CodeAPI, Message: err.Error()} - } - restoreProfile := func(operationErr error) error { - if profiles == nil { - return &output.Error{Code: output.CodeAPI, Message: operationErr.Error()} - } - if restoreErr := restoreAuthLoginProfile(profileName, previousProfile, previousDefault); restoreErr != nil { - return &output.Error{ - Code: output.CodeAPI, - Message: fmt.Sprintf("%v (profile restore failed: %v)", operationErr, restoreErr), + _, err := saveProfileCredentialState(profileCredentialSaveOptions{ + ProfileName: profileName, + Account: account, + BaseURL: cfg.APIURL, + Token: token, + UpdateGlobal: func(globalCfg *config.Config, credentialStored bool) { + globalCfg.Account = account + if credentialStored { + globalCfg.Token = "" } - } - return &output.Error{Code: output.CodeAPI, Message: operationErr.Error()} - } - if profiles != nil { - if err := profiles.SetDefault(profileName); err != nil { - return restoreProfile(err) - } - } - if err := credsSaveProfileToken(profileName, token); err != nil { - return restoreProfile(err) - } - globalCfg := config.LoadGlobal() - globalCfg.Account = account - if globalCfg.Token != "" { - globalCfg.Token = "" - } - if err := globalCfg.Save(); err != nil { + }, + }) + if err != nil { return &output.Error{Code: output.CodeAPI, Message: err.Error()} } } else { @@ -123,47 +98,6 @@ var authLoginCmd = &cobra.Command{ }, } -func restoreAuthLoginProfile(profileName string, previousProfile *profile.Profile, previousDefault string) error { - allProfiles, _, err := profiles.List() - if err != nil { - return err - } - if _, exists := allProfiles[profileName]; exists { - if err := profiles.Delete(profileName); err != nil { - return err - } - } - if previousProfile != nil { - if previousDefault == "" && len(allProfiles) == 1 { - // A temporary profile prevents Create from selecting the restored - // profile when the store previously had no default. - temporaryName := "fizzy-restore" - for suffix := 2; ; suffix++ { - if _, exists := allProfiles[temporaryName]; !exists { - break - } - temporaryName = fmt.Sprintf("fizzy-restore-%d", suffix) - } - if err := profiles.Create(&profile.Profile{Name: temporaryName, BaseURL: config.DefaultAPIURL}); err != nil { - return err - } - if err := profiles.Create(previousProfile); err != nil { - return err - } - return profiles.Delete(temporaryName) - } - if err := profiles.Create(previousProfile); err != nil { - return err - } - } - if previousDefault != "" { - if err := profiles.SetDefault(previousDefault); err != nil { - return err - } - } - return nil -} - var authLogoutCmd = &cobra.Command{ Use: "logout", Short: "Remove saved credentials", @@ -180,27 +114,44 @@ var authLogoutCmd = &cobra.Command{ return errors.NewInvalidArgsError("No profile configured. Use --profile to specify which profile to log out, or --all to log out of all profiles") } - // Delete profile-scoped token from credstore. - // Preserve legacy keys for downgrade compatibility. - if creds != nil { - _ = credsDeleteProfileToken(profileName) - } - - // Remove the profile and remember whether it selected the active account. + // Read profile state before cleanup so failures cannot silently change + // which identity remains active. wasDefault := false + profileExists := false if profiles != nil { - _, defaultName, _ := profiles.List() + allProfiles, defaultName, err := profiles.List() + if err != nil { + return &output.Error{Code: output.CodeAPI, Message: err.Error()} + } + _, profileExists = allProfiles[profileName] wasDefault = defaultName == profileName - _ = profiles.Delete(profileName) } - // Clear the legacy account selector when its active profile is removed. + var cleanupErrors []error + // Preserve legacy keys for downgrade compatibility. Explicit aliases do + // not consume those keys in the current CLI. + if creds != nil { + if err := credsDeleteProfileToken(profileName); err != nil && !isCredentialNotFound(err) { + cleanupErrors = append(cleanupErrors, fmt.Errorf("delete credential: %w", err)) + } + } + if profiles != nil && profileExists { + if err := profiles.Delete(profileName); err != nil { + cleanupErrors = append(cleanupErrors, fmt.Errorf("delete profile: %w", err)) + } + } + globalCfg := config.LoadGlobal() if wasDefault || globalCfg.Account == profileName { globalCfg.Account = "" globalCfg.Token = "" } - _ = globalCfg.Save() + if err := globalCfg.Save(); err != nil { + cleanupErrors = append(cleanupErrors, fmt.Errorf("save global config: %w", err)) + } + if err := stderrors.Join(cleanupErrors...); err != nil { + return &output.Error{Code: output.CodeAPI, Message: fmt.Sprintf("logout incomplete: %v", err)} + } breadcrumbs := []Breadcrumb{ breadcrumb("login", "fizzy auth login ", "Log in again"), @@ -216,40 +167,54 @@ var authLogoutCmd = &cobra.Command{ } func authLogoutAll() error { - // Collect all known profile/account names to clean up every key format - names := map[string]bool{} + profileNames := map[string]bool{} + credentialNames := map[string]bool{} + var cleanupErrors []error if profiles != nil { - allProfiles, _, _ := profiles.List() + allProfiles, _, err := profiles.List() + if err != nil { + return &output.Error{Code: output.CodeAPI, Message: err.Error()} + } for name, p := range allProfiles { - names[name] = true - names[profileAccount(name, p)] = true + profileNames[name] = true + credentialNames[name] = true + binding, err := resolveProfileAccountBinding(name, p) + if err == nil { + credentialNames[binding.Account] = true + } } } - // Also include the YAML config's Account in case it's not in the profile store globalCfg := config.LoadGlobal() if globalCfg.Account != "" { - names[globalCfg.Account] = true + credentialNames[globalCfg.Account] = true } - for name := range names { - if creds != nil { - _ = credsDeleteProfileToken(name) // "profile:" - _ = creds.Delete("token:" + name) // legacy "token:" + if creds != nil { + for name := range credentialNames { + for _, key := range []string{profile.CredentialKey(name, ""), "token:" + name} { + if err := creds.Delete(key); err != nil && !isCredentialNotFound(err) { + cleanupErrors = append(cleanupErrors, fmt.Errorf("delete credential %q: %w", key, err)) + } + } } - if profiles != nil { - _ = profiles.Delete(name) + if err := creds.Delete("token"); err != nil && !isCredentialNotFound(err) { + cleanupErrors = append(cleanupErrors, fmt.Errorf("delete legacy credential: %w", err)) } } - if creds != nil { - // Legacy bare key - _ = creds.Delete("token") + if profiles != nil { + for name := range profileNames { + if err := profiles.Delete(name); err != nil { + cleanupErrors = append(cleanupErrors, fmt.Errorf("delete profile %q: %w", name, err)) + } + } } - - // Clear config if err := config.Delete(); err != nil { - return &output.Error{Code: output.CodeAPI, Message: err.Error()} + cleanupErrors = append(cleanupErrors, fmt.Errorf("delete global config: %w", err)) + } + if err := stderrors.Join(cleanupErrors...); err != nil { + return &output.Error{Code: output.CodeAPI, Message: fmt.Sprintf("logout incomplete: %v", err)} } breadcrumbs := []Breadcrumb{ @@ -335,7 +300,10 @@ var authListCmd = &cobra.Command{ } allProfiles, defaultName, err := profiles.List() - if err != nil || len(allProfiles) == 0 { + if err != nil { + return &output.Error{Code: output.CodeAPI, Message: err.Error()} + } + if len(allProfiles) == 0 { breadcrumbs := []Breadcrumb{ breadcrumb("login", "fizzy auth login ", "Log in"), breadcrumb("signup", "fizzy signup", "Sign up"), @@ -346,9 +314,13 @@ var authListCmd = &cobra.Command{ entries := make([]any, 0, len(allProfiles)) for name, p := range allProfiles { + binding, err := resolveProfileAccountBinding(name, p) + if err != nil { + return &output.Error{Code: output.CodeUsage, Message: err.Error()} + } entry := map[string]any{ "profile": name, - "account": profileAccount(name, p), + "account": binding.Account, "base_url": p.BaseURL, "active": name == defaultName, } @@ -388,18 +360,30 @@ var authSwitchCmd = &cobra.Command{ Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { profileName := args[0] + if err := profile.ValidateName(profileName); err != nil { + return errors.NewInvalidArgsError(err.Error()) + } + + profileSnapshot, err := snapshotProfileState(profileName) + if err != nil { + return &output.Error{Code: output.CodeAPI, Message: err.Error()} + } + targetExplicitAccount := false + if profileSnapshot.previous != nil { + binding, err := resolveProfileAccountBinding(profileName, profileSnapshot.previous) + if err != nil { + return &output.Error{Code: output.CodeUsage, Message: err.Error()} + } + targetExplicitAccount = binding.Explicit + } - // Check if we have a token for this profile hasToken := false if creds != nil { - if _, err := credsLoadProfileToken(profileName); err == nil { + if token, err := credsLoadProfileToken(profileName); err == nil && token != "" { hasToken = true } - } - if !hasToken { - // Also check legacy keys - if creds != nil { - if _, err := credsLoadLegacyToken(profileName); err == nil { + if !hasToken && !targetExplicitAccount { + if token, err := credsLoadLegacyToken(profileName); err == nil && token != "" { hasToken = true } } @@ -409,13 +393,15 @@ var authSwitchCmd = &cobra.Command{ return errors.NewError(fmt.Sprintf("No credentials found for profile %q. Run 'fizzy auth login --profile %s' or 'fizzy signup'", profileName, profileName)) } + globalSnapshot := snapshotGlobalConfig() // Ensure the profile exists without replacing its deployment URL. if profiles != nil { if err := ensureProfile(profileName, "", ""); err != nil { return &output.Error{Code: output.CodeAPI, Message: err.Error()} } if err := profiles.SetDefault(profileName); err != nil { - return &output.Error{Code: output.CodeAPI, Message: err.Error()} + restoreErr := restoreProfileState(profileSnapshot) + return &output.Error{Code: output.CodeAPI, Message: stderrors.Join(err, restoreErr).Error()} } } @@ -427,14 +413,23 @@ var authSwitchCmd = &cobra.Command{ } var profileBoard string if profiles != nil { - if p, err := profiles.Get(profileName); err == nil { - profileAccountID = profileAccount(profileName, p) - if p.BaseURL != "" { - profileAPIURL = p.BaseURL - } - if boardRaw, ok := p.Extra["board"]; ok { - _ = json.Unmarshal(boardRaw, &profileBoard) - } + p, err := profiles.Get(profileName) + if err != nil { + restoreErr := restoreProfileState(profileSnapshot) + return &output.Error{Code: output.CodeAPI, Message: stderrors.Join(err, restoreErr).Error()} + } + binding, bindingErr := resolveProfileAccountBinding(profileName, p) + if bindingErr != nil { + restoreErr := restoreProfileState(profileSnapshot) + return &output.Error{Code: output.CodeUsage, Message: stderrors.Join(bindingErr, restoreErr).Error()} + } + profileAccountID = binding.Account + targetExplicitAccount = binding.Explicit + if p.BaseURL != "" { + profileAPIURL = p.BaseURL + } + if boardRaw, ok := p.Extra["board"]; ok { + _ = json.Unmarshal(boardRaw, &profileBoard) } } @@ -444,19 +439,24 @@ var authSwitchCmd = &cobra.Command{ globalCfg.APIURL = profileAPIURL globalCfg.Board = profileBoard if err := globalCfg.Save(); err != nil { - return &output.Error{Code: output.CodeAPI, Message: err.Error()} + restoreErr := restoreProfileState(profileSnapshot) + globalRestoreErr := restoreGlobalConfig(globalSnapshot) + return &output.Error{Code: output.CodeAPI, Message: stderrors.Join(err, restoreErr, globalRestoreErr).Error()} } // Update in-memory config if cfg != nil { activeProfile = profileName + activeProfileExplicitAccount = targetExplicitAccount cfg.Account = profileAccountID cfg.Board = profileBoard if creds != nil { if t, err := credsLoadProfileToken(profileName); err == nil { cfg.Token = t - } else if t, err := credsLoadLegacyToken(profileName); err == nil { - cfg.Token = t + } else if !targetExplicitAccount { + if t, err := credsLoadLegacyToken(profileName); err == nil { + cfg.Token = t + } } } diff --git a/internal/commands/auth_test.go b/internal/commands/auth_test.go index eee46922..433bc83c 100644 --- a/internal/commands/auth_test.go +++ b/internal/commands/auth_test.go @@ -379,6 +379,78 @@ func TestAuthLogin(t *testing.T) { }) } + t.Run("rejects unsafe account identifiers before saving", func(t *testing.T) { + for _, account := range []string{"../other", "other?admin=1", "other%2Fadmin", " other"} { + t.Run(account, func(t *testing.T) { + t.Setenv("FIZZY_UNSAFE_ACCOUNT_NO_KR", "1") + store := credstore.NewStore(credstore.StoreOptions{ + ServiceName: "fizzy-unsafe-account-test", + DisableEnvVar: "FIZZY_UNSAFE_ACCOUNT_NO_KR", + FallbackDir: t.TempDir(), + }) + profileStore := profile.NewStore(filepath.Join(t.TempDir(), "config.json")) + SetTestCreds(store) + SetTestProfiles(profileStore) + SetTestConfig("", account, config.DefaultAPIURL) + activeProfile = "agent" + authLoginAccount = account + defer resetTest() + + if err := authLoginCmd.RunE(authLoginCmd, []string{"token"}); err == nil { + t.Fatal("expected invalid account error") + } + if _, err := store.Load("profile:agent"); err == nil { + t.Fatal("credential was saved for unsafe account") + } + if _, err := profileStore.Get("agent"); err == nil { + t.Fatal("profile was saved for unsafe account") + } + }) + } + }) + + t.Run("global config failure restores profile and credential", func(t *testing.T) { + configDir := t.TempDir() + config.SetTestConfigDir(configDir) + defer config.ResetTestConfigDir() + if err := os.Mkdir(filepath.Join(configDir, "config.yaml"), 0700); err != nil { + t.Fatalf("block global config: %v", err) + } + t.Setenv("FIZZY_LOGIN_YAML_FAILURE_NO_KR", "1") + store := credstore.NewStore(credstore.StoreOptions{ + ServiceName: "fizzy-login-yaml-failure-test", + DisableEnvVar: "FIZZY_LOGIN_YAML_FAILURE_NO_KR", + FallbackDir: t.TempDir(), + }) + profileStore := profile.NewStore(filepath.Join(t.TempDir(), "config.json")) + if err := profileStore.Create(&profile.Profile{Name: "existing", BaseURL: config.DefaultAPIURL}); err != nil { + t.Fatalf("create existing profile: %v", err) + } + SetTestCreds(store) + SetTestProfiles(profileStore) + SetTestConfig("", "1", config.DefaultAPIURL) + activeProfile = "agent" + authLoginAccount = "1" + defer resetTest() + + if err := authLoginCmd.RunE(authLoginCmd, []string{"token"}); err == nil { + t.Fatal("expected global config error") + } + if _, err := store.Load("profile:agent"); err == nil { + t.Fatal("credential remained after failed login") + } + allProfiles, defaultName, err := profileStore.List() + if err != nil { + t.Fatalf("list profiles: %v", err) + } + if _, exists := allProfiles["agent"]; exists { + t.Fatal("profile remained after failed login") + } + if defaultName != "existing" { + t.Fatalf("default profile: want existing, got %q", defaultName) + } + }) + t.Run("does not save credentials when profile creation fails", func(t *testing.T) { configDir := t.TempDir() config.SetTestConfigDir(configDir) @@ -695,6 +767,178 @@ func TestAuthLogout(t *testing.T) { }) } +func TestAuthLogoutReportsCredentialDeletionFailure(t *testing.T) { + configDir := t.TempDir() + config.SetTestConfigDir(configDir) + defer config.ResetTestConfigDir() + t.Setenv("FIZZY_LOGOUT_FAILURE_NO_KR", "1") + + credDir := filepath.Join(t.TempDir(), "credentials") + store := credstore.NewStore(credstore.StoreOptions{ + ServiceName: "fizzy-logout-failure-test", + DisableEnvVar: "FIZZY_LOGOUT_FAILURE_NO_KR", + FallbackDir: credDir, + }) + tokenData, _ := json.Marshal("agent-token") + if err := store.Save("profile:agent", tokenData); err != nil { + t.Fatalf("save credential: %v", err) + } + backupDir := credDir + "-backup" + if err := os.Rename(credDir, backupDir); err != nil { + t.Fatalf("move credential directory: %v", err) + } + if err := os.WriteFile(credDir, []byte("not a directory"), 0600); err != nil { + t.Fatalf("block credential directory: %v", err) + } + profileStore := profile.NewStore(filepath.Join(configDir, "config.json")) + if err := profileStore.Create(&profile.Profile{Name: "agent", BaseURL: config.DefaultAPIURL}); err != nil { + t.Fatalf("create profile: %v", err) + } + + SetTestModeWithSDK(NewMockClient()) + SetTestCreds(store) + SetTestProfiles(profileStore) + SetTestConfig("agent-token", "agent", config.DefaultAPIURL) + activeProfile = "agent" + err := authLogoutCmd.RunE(authLogoutCmd, nil) + if removeErr := os.Remove(credDir); removeErr != nil { + t.Fatalf("unblock credential directory: %v", removeErr) + } + if renameErr := os.Rename(backupDir, credDir); renameErr != nil { + t.Fatalf("restore credential directory: %v", renameErr) + } + defer resetTest() + if err == nil { + t.Fatal("expected incomplete logout error") + } + if _, loadErr := store.Load("profile:agent"); loadErr != nil { + t.Fatalf("credential should remain after failed deletion: %v", loadErr) + } +} + +func TestAuthLogoutUnknownExplicitProfilePreservesActiveProfile(t *testing.T) { + configDir := t.TempDir() + config.SetTestConfigDir(configDir) + defer config.ResetTestConfigDir() + t.Setenv("FIZZY_LOGOUT_UNKNOWN_NO_KR", "1") + + store := credstore.NewStore(credstore.StoreOptions{ + ServiceName: "fizzy-logout-unknown-test", + DisableEnvVar: "FIZZY_LOGOUT_UNKNOWN_NO_KR", + FallbackDir: t.TempDir(), + }) + tokenData, _ := json.Marshal("active-token") + if err := store.Save("profile:active", tokenData); err != nil { + t.Fatalf("save active credential: %v", err) + } + profileStore := profile.NewStore(filepath.Join(configDir, "config.json")) + if err := profileStore.Create(&profile.Profile{Name: "active", BaseURL: config.DefaultAPIURL}); err != nil { + t.Fatalf("create active profile: %v", err) + } + if err := (&config.Config{Account: "active", APIURL: config.DefaultAPIURL}).Save(); err != nil { + t.Fatalf("save global config: %v", err) + } + SetTestModeWithSDK(NewMockClient()) + SetTestCreds(store) + SetTestProfiles(profileStore) + SetTestConfig("active-token", "active", config.DefaultAPIURL) + defer resetTest() + + if _, err := runCobraWithArgs("auth", "logout", "--profile", "typo"); err != nil { + t.Fatalf("logout unknown profile: %v", err) + } + if _, err := store.Load("profile:active"); err != nil { + t.Fatalf("active credential was deleted: %v", err) + } + if _, err := profileStore.Get("active"); err != nil { + t.Fatalf("active profile was deleted: %v", err) + } +} + +func TestAuthLogoutAllReportsCredentialDeletionFailure(t *testing.T) { + configDir := t.TempDir() + config.SetTestConfigDir(configDir) + defer config.ResetTestConfigDir() + t.Setenv("FIZZY_LOGOUT_ALL_FAILURE_NO_KR", "1") + + credDir := filepath.Join(t.TempDir(), "credentials") + store := credstore.NewStore(credstore.StoreOptions{ + ServiceName: "fizzy-logout-all-failure-test", + DisableEnvVar: "FIZZY_LOGOUT_ALL_FAILURE_NO_KR", + FallbackDir: credDir, + }) + tokenData, _ := json.Marshal("agent-token") + if err := store.Save("profile:agent", tokenData); err != nil { + t.Fatalf("save credential: %v", err) + } + backupDir := credDir + "-backup" + if err := os.Rename(credDir, backupDir); err != nil { + t.Fatalf("move credential directory: %v", err) + } + if err := os.WriteFile(credDir, []byte("not a directory"), 0600); err != nil { + t.Fatalf("block credential directory: %v", err) + } + profileStore := profile.NewStore(filepath.Join(configDir, "config.json")) + if err := profileStore.Create(&profile.Profile{Name: "agent", BaseURL: config.DefaultAPIURL}); err != nil { + t.Fatalf("create profile: %v", err) + } + SetTestCreds(store) + SetTestProfiles(profileStore) + err := authLogoutAll() + if removeErr := os.Remove(credDir); removeErr != nil { + t.Fatalf("unblock credential directory: %v", removeErr) + } + if renameErr := os.Rename(backupDir, credDir); renameErr != nil { + t.Fatalf("restore credential directory: %v", renameErr) + } + defer resetTest() + if err == nil { + t.Fatal("expected incomplete logout error") + } + if _, loadErr := store.Load("profile:agent"); loadErr != nil { + t.Fatalf("credential should remain after failed deletion: %v", loadErr) + } +} + +func TestAuthLogoutAllCleansInvalidProfileMetadata(t *testing.T) { + configDir := t.TempDir() + config.SetTestConfigDir(configDir) + defer config.ResetTestConfigDir() + t.Setenv("FIZZY_LOGOUT_INVALID_NO_KR", "1") + + store := credstore.NewStore(credstore.StoreOptions{ + ServiceName: "fizzy-logout-invalid-test", + DisableEnvVar: "FIZZY_LOGOUT_INVALID_NO_KR", + FallbackDir: t.TempDir(), + }) + tokenData, _ := json.Marshal("agent-token") + if err := store.Save("profile:agent", tokenData); err != nil { + t.Fatalf("save credential: %v", err) + } + profileStore := profile.NewStore(filepath.Join(configDir, "config.json")) + if err := profileStore.Create(&profile.Profile{ + Name: "agent", + BaseURL: config.DefaultAPIURL, + Extra: map[string]json.RawMessage{"account": json.RawMessage(`"../invalid"`)}, + }); err != nil { + t.Fatalf("create invalid profile: %v", err) + } + SetTestModeWithSDK(NewMockClient()) + SetTestCreds(store) + SetTestProfiles(profileStore) + defer resetTest() + + if err := authLogoutAll(); err != nil { + t.Fatalf("logout all: %v", err) + } + if _, err := store.Load("profile:agent"); err == nil { + t.Fatal("invalid profile credential still exists") + } + if _, err := profileStore.Get("agent"); err == nil { + t.Fatal("invalid profile still exists") + } +} + func TestAuthLogoutAliasClearsActiveLegacyAccount(t *testing.T) { configDir := t.TempDir() config.SetTestConfigDir(configDir) @@ -1118,6 +1362,156 @@ func TestAuthSwitch(t *testing.T) { }) } + t.Run("empty profile credential is rejected", func(t *testing.T) { + t.Setenv("FIZZY_SWITCH_EMPTY_TOKEN_NO_KR", "1") + store := credstore.NewStore(credstore.StoreOptions{ + ServiceName: "fizzy-switch-empty-token-test", + DisableEnvVar: "FIZZY_SWITCH_EMPTY_TOKEN_NO_KR", + FallbackDir: t.TempDir(), + }) + emptyToken, _ := json.Marshal("") + if err := store.Save("profile:agent", emptyToken); err != nil { + t.Fatalf("save empty credential: %v", err) + } + profileStore := profile.NewStore(filepath.Join(t.TempDir(), "config.json")) + if err := profileStore.Create(&profile.Profile{Name: "agent", BaseURL: config.DefaultAPIURL}); err != nil { + t.Fatalf("create profile: %v", err) + } + SetTestModeWithSDK(NewMockClient()) + SetTestCreds(store) + SetTestProfiles(profileStore) + SetTestConfig("", "agent", config.DefaultAPIURL) + defer resetTest() + + if err := authSwitchCmd.RunE(authSwitchCmd, []string{"agent"}); err == nil { + t.Fatal("expected missing credential error") + } + }) + + t.Run("alias requires a profile-scoped credential", func(t *testing.T) { + t.Setenv("FIZZY_SWITCH_ALIAS_LEGACY_NO_KR", "1") + store := credstore.NewStore(credstore.StoreOptions{ + ServiceName: "fizzy-switch-alias-legacy-test", + DisableEnvVar: "FIZZY_SWITCH_ALIAS_LEGACY_NO_KR", + FallbackDir: t.TempDir(), + }) + legacyToken, _ := json.Marshal("legacy-token") + if err := store.Save("token", legacyToken); err != nil { + t.Fatalf("save legacy credential: %v", err) + } + profileStore := profile.NewStore(filepath.Join(t.TempDir(), "config.json")) + if err := profileStore.Create(&profile.Profile{Name: "existing", BaseURL: config.DefaultAPIURL}); err != nil { + t.Fatalf("create existing profile: %v", err) + } + if err := profileStore.Create(&profile.Profile{ + Name: "agent", + BaseURL: config.DefaultAPIURL, + Extra: map[string]json.RawMessage{"account": json.RawMessage(`"1"`)}, + }); err != nil { + t.Fatalf("create alias: %v", err) + } + SetTestModeWithSDK(NewMockClient()) + SetTestCreds(store) + SetTestProfiles(profileStore) + SetTestConfig("", "existing", config.DefaultAPIURL) + defer resetTest() + + if err := authSwitchCmd.RunE(authSwitchCmd, []string{"agent"}); err == nil { + t.Fatal("expected missing alias credential error") + } + _, defaultName, err := profileStore.List() + if err != nil { + t.Fatalf("list profiles: %v", err) + } + if defaultName != "existing" { + t.Fatalf("default profile changed to %q", defaultName) + } + }) + + t.Run("recovers from an invalid default profile", func(t *testing.T) { + configDir := t.TempDir() + config.SetTestConfigDir(configDir) + defer config.ResetTestConfigDir() + t.Setenv("FIZZY_SWITCH_INVALID_DEFAULT_NO_KR", "1") + store := credstore.NewStore(credstore.StoreOptions{ + ServiceName: "fizzy-switch-invalid-default-test", + DisableEnvVar: "FIZZY_SWITCH_INVALID_DEFAULT_NO_KR", + FallbackDir: t.TempDir(), + }) + profileStore := profile.NewStore(filepath.Join(configDir, "config.json")) + if err := profileStore.Create(&profile.Profile{ + Name: "broken", + BaseURL: config.DefaultAPIURL, + Extra: map[string]json.RawMessage{"account": json.RawMessage(`"../invalid"`)}, + }); err != nil { + t.Fatalf("create broken profile: %v", err) + } + if err := profileStore.Create(&profile.Profile{Name: "good", BaseURL: config.DefaultAPIURL}); err != nil { + t.Fatalf("create good profile: %v", err) + } + goodToken, _ := json.Marshal("good-token") + if err := store.Save("profile:good", goodToken); err != nil { + t.Fatalf("save good credential: %v", err) + } + SetTestModeWithSDK(NewMockClient()) + SetTestCreds(store) + SetTestProfiles(profileStore) + SetTestConfig("", "broken", config.DefaultAPIURL) + defer resetTest() + + if _, err := runCobraWithArgs("auth", "switch", "good"); err != nil { + t.Fatalf("switch from invalid default: %v", err) + } + _, defaultName, err := profileStore.List() + if err != nil { + t.Fatalf("list profiles: %v", err) + } + if defaultName != "good" { + t.Fatalf("default profile: want good, got %q", defaultName) + } + }) + + t.Run("global config failure restores previous default", func(t *testing.T) { + configDir := t.TempDir() + config.SetTestConfigDir(configDir) + defer config.ResetTestConfigDir() + if err := os.Mkdir(filepath.Join(configDir, "config.yaml"), 0700); err != nil { + t.Fatalf("block global config: %v", err) + } + t.Setenv("FIZZY_SWITCH_YAML_FAILURE_NO_KR", "1") + store := credstore.NewStore(credstore.StoreOptions{ + ServiceName: "fizzy-switch-yaml-failure-test", + DisableEnvVar: "FIZZY_SWITCH_YAML_FAILURE_NO_KR", + FallbackDir: t.TempDir(), + }) + profileStore := profile.NewStore(filepath.Join(t.TempDir(), "config.json")) + for _, name := range []string{"acme", "other"} { + if err := profileStore.Create(&profile.Profile{Name: name, BaseURL: config.DefaultAPIURL}); err != nil { + t.Fatalf("create profile %s: %v", name, err) + } + data, _ := json.Marshal(name + "-token") + if err := store.Save("profile:"+name, data); err != nil { + t.Fatalf("save credential %s: %v", name, err) + } + } + SetTestModeWithSDK(NewMockClient()) + SetTestCreds(store) + SetTestProfiles(profileStore) + SetTestConfig("acme-token", "acme", config.DefaultAPIURL) + defer resetTest() + + if err := authSwitchCmd.RunE(authSwitchCmd, []string{"other"}); err == nil { + t.Fatal("expected global config error") + } + _, defaultName, err := profileStore.List() + if err != nil { + t.Fatalf("list profiles: %v", err) + } + if defaultName != "acme" { + t.Fatalf("default profile: want acme, got %q", defaultName) + } + }) + t.Run("fails for unknown profile", func(t *testing.T) { credDir := t.TempDir() @@ -1234,6 +1628,102 @@ func TestProfileAliasesUseSharedAccountWithDistinctTokens(t *testing.T) { } } +func TestAliasedProfileDoesNotBorrowLegacyCredentials(t *testing.T) { + configDir := t.TempDir() + config.SetTestConfigDir(configDir) + defer config.ResetTestConfigDir() + + t.Setenv("FIZZY_ALIAS_LEGACY_NO_KR", "1") + store := credstore.NewStore(credstore.StoreOptions{ + ServiceName: "fizzy-alias-legacy-test", + DisableEnvVar: "FIZZY_ALIAS_LEGACY_NO_KR", + FallbackDir: t.TempDir(), + }) + legacyToken, _ := json.Marshal("legacy-token") + if err := store.Save("token", legacyToken); err != nil { + t.Fatalf("save legacy credential: %v", err) + } + if err := (&config.Config{Token: "yaml-token", Account: "1"}).Save(); err != nil { + t.Fatalf("save legacy config: %v", err) + } + profileStore := profile.NewStore(filepath.Join(configDir, "config.json")) + if err := profileStore.Create(&profile.Profile{ + Name: "agent", + BaseURL: config.DefaultAPIURL, + Extra: map[string]json.RawMessage{"account": json.RawMessage(`"1"`)}, + }); err != nil { + t.Fatalf("create alias: %v", err) + } + + SetTestCreds(store) + SetTestProfiles(profileStore) + SetTestConfig("yaml-token", "1", config.DefaultAPIURL) + defer resetTest() + + if err := resolveProfile(); err != nil { + t.Fatalf("resolve profile: %v", err) + } + resolveToken() + if cfg.Token != "" { + t.Fatalf("alias borrowed legacy credential %q", cfg.Token) + } + if _, err := store.Load("profile:agent"); err == nil { + t.Fatal("legacy credential was migrated into alias") + } +} + +func TestProfileStoreReadFailureDoesNotMigrateLegacyCredential(t *testing.T) { + t.Setenv("FIZZY_PROFILE_READ_FAILURE_NO_KR", "1") + store := credstore.NewStore(credstore.StoreOptions{ + ServiceName: "fizzy-profile-read-failure-test", + DisableEnvVar: "FIZZY_PROFILE_READ_FAILURE_NO_KR", + FallbackDir: t.TempDir(), + }) + legacyToken, _ := json.Marshal("legacy-token") + if err := store.Save("token", legacyToken); err != nil { + t.Fatalf("save legacy credential: %v", err) + } + profilePath := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(profilePath, []byte(`{invalid`), 0600); err != nil { + t.Fatalf("write malformed profile store: %v", err) + } + SetTestCreds(store) + SetTestProfiles(profile.NewStore(profilePath)) + SetTestConfig("", "agent", config.DefaultAPIURL) + cfgProfile = "agent" + defer resetTest() + + if err := resolveProfile(); err == nil { + t.Fatal("expected profile-store read error") + } + migrateLegacyToken("agent") + if _, err := store.Load("profile:agent"); err == nil { + t.Fatal("legacy credential migrated after profile-store read failure") + } +} + +func TestInvalidProfileAccountMetadataIsRejected(t *testing.T) { + for _, raw := range []string{`{}`, `null`, `""`, `" "`, `"../other"`, `"other?admin=1"`, `"other%2Fadmin"`} { + t.Run(raw, func(t *testing.T) { + profileStore := profile.NewStore(filepath.Join(t.TempDir(), "config.json")) + if err := profileStore.Create(&profile.Profile{ + Name: "agent", + BaseURL: config.DefaultAPIURL, + Extra: map[string]json.RawMessage{"account": json.RawMessage(raw)}, + }); err != nil { + t.Fatalf("create profile: %v", err) + } + SetTestProfiles(profileStore) + SetTestConfig("token", "legacy", config.DefaultAPIURL) + defer resetTest() + + if err := resolveProfile(); err == nil { + t.Fatal("expected invalid account metadata error") + } + }) + } +} + func TestProfileAccountDefaultsToProfileName(t *testing.T) { p := &profile.Profile{Name: "6102600", BaseURL: "https://app.fizzy.do"} if account := profileAccount(p.Name, p); account != "6102600" { diff --git a/internal/commands/config_cmd.go b/internal/commands/config_cmd.go index ef3bf202..4965b795 100644 --- a/internal/commands/config_cmd.go +++ b/internal/commands/config_cmd.go @@ -147,7 +147,8 @@ func configExplainData() map[string]any { localCfg, _ := loadDoctorConfigFile(cfgpkg.LocalConfigPath()) resolvedProfile, profileCfg := resolveDoctorProfileContext() _, defaultProfile := profileStoreInfo() - _, _, profileToken := doctorStoredTokenSourceForProfile(resolvedProfileOrEffective(resolvedProfile, eff.ProfileName), localCfg, globalCfg) + binding, _ := resolveProfileAccountBinding(resolvedProfile, profileCfg) + _, _, profileToken := doctorStoredTokenSourceForProfile(resolvedProfileOrEffective(resolvedProfile, eff.ProfileName), binding.Explicit, localCfg, globalCfg) profileField := configExplainField{ Value: emptyToNil(eff.ProfileName), diff --git a/internal/commands/doctor.go b/internal/commands/doctor.go index 47c3c197..c0bd1c9c 100644 --- a/internal/commands/doctor.go +++ b/internal/commands/doctor.go @@ -78,17 +78,19 @@ func (r *DoctorResult) Summary() string { } type doctorEffectiveConfig struct { - ProfileName string - Account string - Default bool - ProfileSource string - APIURL string - APIURLSource string - Board string - BoardSource string - Token string - TokenSource string - TokenSourceRaw string + ProfileName string + Account string + Default bool + ProfileSource string + APIURL string + APIURLSource string + Board string + BoardSource string + Token string + TokenSource string + TokenSourceRaw string + ConfigError string + RequireProfileCredential bool } var doctorVersionChecker = fetchLatestDoctorVersion @@ -216,6 +218,16 @@ func runDoctorGlobalChecks(verbose bool) []DoctorCheck { } func runDoctorTargetChecks(ctx context.Context, eff doctorEffectiveConfig, verbose bool) []DoctorCheck { + if eff.ConfigError != "" { + return []DoctorCheck{ + {Name: "Effective Config", Status: "fail", Message: eff.ConfigError}, + {Name: "Credentials", Status: "skip", Message: "Skipped (invalid profile configuration)"}, + {Name: "API Reachability", Status: "skip", Message: "Skipped (invalid profile configuration)"}, + {Name: "Authentication", Status: "skip", Message: "Skipped (invalid profile configuration)"}, + {Name: "Account Access", Status: "skip", Message: "Skipped (invalid profile configuration)"}, + {Name: "Default Board", Status: "skip", Message: "Skipped (invalid profile configuration)"}, + } + } checks := []DoctorCheck{checkDoctorEffectiveConfig(eff, verbose)} credCheck := checkDoctorCredentials(eff, verbose) checks = append(checks, @@ -225,10 +237,20 @@ func runDoctorTargetChecks(ctx context.Context, eff doctorEffectiveConfig, verbo checkDoctorAPIURL(eff, verbose), ) + if credCheck.Status == "fail" && eff.RequireProfileCredential { + authMsg := "Skipped (missing credentials)" + return append(checks, + DoctorCheck{Name: "API Reachability", Status: "skip", Message: authMsg}, + DoctorCheck{Name: "Authentication", Status: "skip", Message: authMsg}, + DoctorCheck{Name: "Account Access", Status: "skip", Message: authMsg}, + DoctorCheck{Name: "Default Board", Status: "skip", Message: authMsg}, + ) + } + reachabilityCheck := checkDoctorAPIReachability(ctx, eff, verbose) checks = append(checks, reachabilityCheck) - canAuth := credCheck.Status != "fail" && reachabilityCheck.Status == "pass" + canAuth := reachabilityCheck.Status == "pass" if canAuth { authCheck := checkDoctorAuthentication(ctx, eff, verbose) checks = append(checks, authCheck) @@ -515,7 +537,8 @@ func resolveDoctorEffectiveConfig() doctorEffectiveConfig { eff.BoardSource = "unset" } - eff.TokenSourceRaw, eff.TokenSource, eff.Token = doctorTokenSourceWithValue(eff.ProfileName, localCfg, globalCfg) + eff.RequireProfileCredential = activeProfileExplicitAccount + eff.TokenSourceRaw, eff.TokenSource, eff.Token = doctorTokenSourceWithValue(eff.ProfileName, activeProfileExplicitAccount, localCfg, globalCfg) return eff } @@ -1172,7 +1195,7 @@ func doctorProfileBoard(p *profile.Profile) string { return board } -func doctorTokenSourceWithValue(profileName string, localCfg, globalCfg *config.Config) (string, string, string) { +func doctorTokenSourceWithValue(profileName string, explicitAccount bool, localCfg, globalCfg *config.Config) (string, string, string) { if cfgToken != "" { return "flag", "CLI flag", cfgToken } @@ -1187,11 +1210,13 @@ func doctorTokenSourceWithValue(profileName string, localCfg, globalCfg *config. } return "fallback-file", "fallback credential file", token } - if token, err := credsLoadLegacyToken(profileName); err == nil && token != "" { - if creds.UsingKeyring() { - return "legacy-keyring", "legacy system keyring entry", token + if !explicitAccount { + if token, err := credsLoadLegacyToken(profileName); err == nil && token != "" { + if creds.UsingKeyring() { + return "legacy-keyring", "legacy system keyring entry", token + } + return "legacy-fallback", "legacy fallback credential file", token } - return "legacy-fallback", "legacy fallback credential file", token } } else if token, err := credsLoadLegacyToken(""); err == nil && token != "" { if creds.UsingKeyring() { @@ -1200,6 +1225,9 @@ func doctorTokenSourceWithValue(profileName string, localCfg, globalCfg *config. return "legacy-fallback", "legacy fallback credential file", token } } + if explicitAccount { + return "none", "not configured", "" + } if localCfg != nil && localCfg.Token != "" { return "local-config", "local config file", localCfg.Token } @@ -1258,8 +1286,10 @@ func doctorTargetsFromProfileStore() []doctorEffectiveConfig { targets := make([]doctorEffectiveConfig, 0, len(names)) for _, name := range names { p := allProfiles[name] + binding, bindingErr := resolveProfileAccountBinding(name, p) board := doctorProfileBoard(p) - tokenRaw, tokenSource, token := doctorStoredTokenSourceForProfile(name, localCfg, globalCfg) + requireProfileCredential := binding.Explicit || bindingErr != nil + tokenRaw, tokenSource, token := doctorStoredTokenSourceForProfile(name, requireProfileCredential, localCfg, globalCfg) apiURL := config.DefaultAPIURL apiURLSource := "default" switch { @@ -1285,23 +1315,25 @@ func doctorTargetsFromProfileStore() []doctorEffectiveConfig { boardSource = "global config" } targets = append(targets, doctorEffectiveConfig{ - ProfileName: name, - Account: profileAccount(name, p), - Default: name == defaultName, - ProfileSource: "profile store", - APIURL: apiURL, - APIURLSource: apiURLSource, - Board: board, - BoardSource: boardSource, - Token: token, - TokenSourceRaw: tokenRaw, - TokenSource: tokenSource, + ProfileName: name, + Account: binding.Account, + Default: name == defaultName, + ProfileSource: "profile store", + APIURL: apiURL, + APIURLSource: apiURLSource, + Board: board, + BoardSource: boardSource, + Token: token, + TokenSourceRaw: tokenRaw, + TokenSource: tokenSource, + ConfigError: errorString(bindingErr), + RequireProfileCredential: requireProfileCredential, }) } return targets } -func doctorStoredTokenSourceForProfile(profileName string, localCfg, globalCfg *config.Config) (string, string, string) { +func doctorStoredTokenSourceForProfile(profileName string, explicitAccount bool, localCfg, globalCfg *config.Config) (string, string, string) { if creds != nil { if token, err := credsLoadProfileToken(profileName); err == nil && token != "" { if creds.UsingKeyring() { @@ -1309,13 +1341,18 @@ func doctorStoredTokenSourceForProfile(profileName string, localCfg, globalCfg * } return "fallback-file", "fallback credential file", token } - if token, err := credsLoadLegacyToken(profileName); err == nil && token != "" { - if creds.UsingKeyring() { - return "legacy-keyring", "legacy system keyring entry", token + if !explicitAccount { + if token, err := credsLoadLegacyToken(profileName); err == nil && token != "" { + if creds.UsingKeyring() { + return "legacy-keyring", "legacy system keyring entry", token + } + return "legacy-fallback", "legacy fallback credential file", token } - return "legacy-fallback", "legacy fallback credential file", token } } + if explicitAccount { + return "none", "not configured", "" + } if localCfg != nil && localCfg.Token != "" { return "local-config", "local config file", localCfg.Token } diff --git a/internal/commands/doctor_test.go b/internal/commands/doctor_test.go index dccd17be..9315d170 100644 --- a/internal/commands/doctor_test.go +++ b/internal/commands/doctor_test.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "strings" + "sync/atomic" "testing" "github.com/basecamp/cli/credstore" @@ -397,6 +398,116 @@ func TestDoctorAllProfilesIncludesPerProfileResults(t *testing.T) { } } +func TestDoctorTokenlessAccountProfileStillChecksReachability(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + eff := doctorEffectiveConfig{ + ProfileName: "account", + Account: "account", + APIURL: server.URL, + APIURLSource: "profile store", + TokenSource: "not configured", + TokenSourceRaw: "none", + } + _ = runDoctorTargetChecks(context.Background(), eff, false) + if got := requests.Load(); got == 0 { + t.Fatal("doctor skipped reachability for account-named profile") + } +} + +func TestDoctorTokenlessAliasDoesNotBorrowOrSendLegacyToken(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + t.Setenv("FIZZY_DOCTOR_ALIAS_LEGACY_NO_KR", "1") + store := credstore.NewStore(credstore.StoreOptions{ + ServiceName: "fizzy-doctor-alias-legacy-test", + DisableEnvVar: "FIZZY_DOCTOR_ALIAS_LEGACY_NO_KR", + FallbackDir: t.TempDir(), + }) + legacyToken, _ := json.Marshal("legacy-token") + if err := store.Save("token", legacyToken); err != nil { + t.Fatalf("save legacy credential: %v", err) + } + profileStore := profile.NewStore(filepath.Join(t.TempDir(), "config.json")) + if err := profileStore.Create(&profile.Profile{ + Name: "agent", + BaseURL: server.URL, + Extra: map[string]json.RawMessage{"account": json.RawMessage(`"1"`)}, + }); err != nil { + t.Fatalf("create alias: %v", err) + } + SetTestCreds(store) + SetTestProfiles(profileStore) + defer resetTest() + + targets := doctorTargetsFromProfileStore() + if len(targets) != 1 { + t.Fatalf("targets: want 1, got %d", len(targets)) + } + if targets[0].Token != "" { + t.Fatalf("alias borrowed legacy credential %q", targets[0].Token) + } + _ = runDoctorTargetChecks(context.Background(), targets[0], false) + if got := requests.Load(); got != 0 { + t.Fatalf("doctor sent %d request(s) for tokenless alias", got) + } +} + +func TestDoctorInvalidAliasMetadataDoesNotSendRequests(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + t.Setenv("FIZZY_DOCTOR_INVALID_ALIAS_NO_KR", "1") + store := credstore.NewStore(credstore.StoreOptions{ + ServiceName: "fizzy-doctor-invalid-alias-test", + DisableEnvVar: "FIZZY_DOCTOR_INVALID_ALIAS_NO_KR", + FallbackDir: t.TempDir(), + }) + legacyToken, _ := json.Marshal("legacy-token") + if err := store.Save("token", legacyToken); err != nil { + t.Fatalf("save legacy credential: %v", err) + } + profileStore := profile.NewStore(filepath.Join(t.TempDir(), "config.json")) + if err := profileStore.Create(&profile.Profile{ + Name: "agent", + BaseURL: server.URL, + Extra: map[string]json.RawMessage{"account": json.RawMessage(`"../other"`)}, + }); err != nil { + t.Fatalf("create invalid alias: %v", err) + } + SetTestCreds(store) + SetTestProfiles(profileStore) + defer resetTest() + + targets := doctorTargetsFromProfileStore() + if len(targets) != 1 || targets[0].ConfigError == "" { + t.Fatalf("expected invalid target, got %#v", targets) + } + checks := runDoctorTargetChecks(context.Background(), targets[0], false) + if checks[0].Status != "fail" { + t.Fatalf("effective config check: want fail, got %#v", checks[0]) + } + if got := requests.Load(); got != 0 { + t.Fatalf("doctor sent %d request(s) for invalid alias", got) + } +} + func TestNewDoctorClientsRoutesAliasThroughAccount(t *testing.T) { paths := make(chan string, 1) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -538,7 +649,7 @@ func TestDoctorStoredTokenSourceIgnoresAccountMismatch(t *testing.T) { creds = nil defer func() { creds = savedCreds }() - raw, source, token := doctorStoredTokenSourceForProfile("acme", localCfg, globalCfg) + raw, source, token := doctorStoredTokenSourceForProfile("acme", false, localCfg, globalCfg) if token != "yaml-token" { t.Fatalf("expected yaml-token, got %q", token) } @@ -558,7 +669,7 @@ func TestDoctorStoredTokenSourceLocalBeforeGlobal(t *testing.T) { creds = nil defer func() { creds = savedCreds }() - raw, _, token := doctorStoredTokenSourceForProfile("acme", localCfg, globalCfg) + raw, _, token := doctorStoredTokenSourceForProfile("acme", false, localCfg, globalCfg) if token != "local-token" { t.Fatalf("expected local-token, got %q", token) } diff --git a/internal/commands/profile_state.go b/internal/commands/profile_state.go new file mode 100644 index 00000000..867094b0 --- /dev/null +++ b/internal/commands/profile_state.go @@ -0,0 +1,284 @@ +package commands + +import ( + "encoding/json" + stderrors "errors" + "fmt" + "regexp" + "strings" + + "github.com/basecamp/cli/profile" + "github.com/basecamp/fizzy-cli/internal/config" + "github.com/zalando/go-keyring" +) + +var accountIdentifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._~-]*$`) + +type profileAccountBinding struct { + Account string + Explicit bool +} + +func resolveProfileAccountBinding(name string, p *profile.Profile) (profileAccountBinding, error) { + if p == nil || p.Extra == nil { + if err := validateAccountIdentifier(name); err != nil { + return profileAccountBinding{}, fmt.Errorf("profile %q: %w", name, err) + } + return profileAccountBinding{Account: name}, nil + } + + raw, present := p.Extra["account"] + if !present { + if err := validateAccountIdentifier(name); err != nil { + return profileAccountBinding{}, fmt.Errorf("profile %q: %w", name, err) + } + return profileAccountBinding{Account: name}, nil + } + + var account string + if err := json.Unmarshal(raw, &account); err != nil || strings.TrimSpace(account) == "" { + return profileAccountBinding{}, fmt.Errorf("profile %q has invalid account metadata: account must be a non-empty string", name) + } + account = strings.TrimSpace(account) + if err := validateAccountIdentifier(account); err != nil { + return profileAccountBinding{}, fmt.Errorf("profile %q has invalid account metadata: %w", name, err) + } + return profileAccountBinding{Account: account, Explicit: true}, nil +} + +func errorString(err error) string { + if err == nil { + return "" + } + return err.Error() +} + +func validateAccountIdentifier(account string) error { + if !accountIdentifierPattern.MatchString(account) { + return fmt.Errorf("invalid account %q: use a slug or ID containing only letters, numbers, periods, underscores, tildes, or hyphens", account) + } + return nil +} + +func profileHasExplicitAccount(profileName string) bool { + if profiles == nil || profileName == "" { + return false + } + allProfiles, _, err := profiles.List() + if err != nil { + // A profile-store read failure makes legacy migration ambiguous. + return true + } + p := allProfiles[profileName] + if p == nil || p.Extra == nil { + return false + } + _, present := p.Extra["account"] + return present +} + +type profileStateSnapshot struct { + name string + previous *profile.Profile + previousDefault string +} + +func snapshotProfileState(name string) (profileStateSnapshot, error) { + snapshot := profileStateSnapshot{name: name} + if profiles == nil { + return snapshot, nil + } + allProfiles, defaultName, err := profiles.List() + if err != nil { + return profileStateSnapshot{}, err + } + snapshot.previous = allProfiles[name] + snapshot.previousDefault = defaultName + return snapshot, nil +} + +func restoreProfileState(snapshot profileStateSnapshot) error { + if profiles == nil { + return nil + } + allProfiles, _, err := profiles.List() + if err != nil { + return err + } + if _, exists := allProfiles[snapshot.name]; exists { + if err := profiles.Delete(snapshot.name); err != nil { + return err + } + } + if snapshot.previous != nil { + if snapshot.previousDefault == "" && len(allProfiles) == 1 { + temporaryName := "fizzy-restore" + for suffix := 2; ; suffix++ { + if _, exists := allProfiles[temporaryName]; !exists { + break + } + temporaryName = fmt.Sprintf("fizzy-restore-%d", suffix) + } + if err := profiles.Create(&profile.Profile{Name: temporaryName, BaseURL: config.DefaultAPIURL}); err != nil { + return err + } + if err := profiles.Create(snapshot.previous); err != nil { + return err + } + return profiles.Delete(temporaryName) + } + if err := profiles.Create(snapshot.previous); err != nil { + return err + } + } + if snapshot.previousDefault != "" { + return profiles.SetDefault(snapshot.previousDefault) + } + return nil +} + +type credentialStateSnapshot struct { + profileName string + data []byte + exists bool +} + +func snapshotProfileCredential(profileName string) (credentialStateSnapshot, error) { + snapshot := credentialStateSnapshot{profileName: profileName} + if creds == nil { + return snapshot, nil + } + data, err := creds.Load(profile.CredentialKey(profileName, "")) + if err != nil { + if isCredentialNotFound(err) { + return snapshot, nil + } + return credentialStateSnapshot{}, err + } + snapshot.data = append([]byte(nil), data...) + snapshot.exists = true + return snapshot, nil +} + +func restoreProfileCredential(snapshot credentialStateSnapshot) error { + if creds == nil { + return nil + } + key := profile.CredentialKey(snapshot.profileName, "") + if snapshot.exists { + return creds.Save(key, snapshot.data) + } + if err := creds.Delete(key); err != nil && !isCredentialNotFound(err) { + return err + } + return nil +} + +func isCredentialNotFound(err error) bool { + if err == nil { + return false + } + return stderrors.Is(err, keyring.ErrNotFound) || strings.Contains(err.Error(), "credentials not found for ") +} + +type globalConfigSnapshot struct { + config *config.Config + exists bool +} + +func snapshotGlobalConfig() globalConfigSnapshot { + loaded := config.LoadGlobal() + configCopy := *loaded + return globalConfigSnapshot{config: &configCopy, exists: config.Exists()} +} + +func restoreGlobalConfig(snapshot globalConfigSnapshot) error { + if !snapshot.exists { + return config.Delete() + } + return snapshot.config.Save() +} + +type profileCredentialSaveOptions struct { + ProfileName string + Account string + BaseURL string + Board *string + Token string + AllowYAMLTokenFallback bool + UpdateGlobal func(*config.Config, bool) +} + +func saveProfileCredentialState(opts profileCredentialSaveOptions) (error, error) { + var warning error + if err := profile.ValidateName(opts.ProfileName); err != nil { + return nil, err + } + if err := validateAccountIdentifier(opts.Account); err != nil { + return nil, err + } + + profileSnapshot, err := snapshotProfileState(opts.ProfileName) + if err != nil { + return nil, err + } + credentialSnapshot, err := snapshotProfileCredential(opts.ProfileName) + if err != nil { + return nil, err + } + globalSnapshot := snapshotGlobalConfig() + + rollback := func(operationErr error, restoreCredential, restoreGlobal bool) error { + rollbackErrors := []error{operationErr} + if restoreCredential { + if restoreErr := restoreProfileCredential(credentialSnapshot); restoreErr != nil { + rollbackErrors = append(rollbackErrors, fmt.Errorf("restore credential: %w", restoreErr)) + } + } + if restoreErr := restoreProfileState(profileSnapshot); restoreErr != nil { + rollbackErrors = append(rollbackErrors, fmt.Errorf("restore profile: %w", restoreErr)) + } + if restoreGlobal { + if restoreErr := restoreGlobalConfig(globalSnapshot); restoreErr != nil { + rollbackErrors = append(rollbackErrors, fmt.Errorf("restore global config: %w", restoreErr)) + } + } + return stderrors.Join(rollbackErrors...) + } + + if err := ensureProfileForAccountWithBoard(opts.ProfileName, opts.Account, opts.BaseURL, opts.Board); err != nil { + return nil, err + } + if profiles != nil { + if err := profiles.SetDefault(opts.ProfileName); err != nil { + return nil, rollback(err, false, false) + } + } + + credentialStored := false + if creds != nil { + if err := credsSaveProfileToken(opts.ProfileName, opts.Token); err != nil { + if opts.AllowYAMLTokenFallback && !credentialSnapshot.exists { + // With no prior profile credential, the YAML fallback remains + // unambiguous and can safely carry the new token. + warning = err + } else { + if restoreErr := restoreProfileCredential(credentialSnapshot); restoreErr != nil { + return nil, rollback(stderrors.Join(err, fmt.Errorf("restore credential: %w", restoreErr)), false, false) + } + return nil, rollback(err, false, false) + } + } else { + credentialStored = true + } + } + + globalCfg := config.LoadGlobal() + if opts.UpdateGlobal != nil { + opts.UpdateGlobal(globalCfg, credentialStored) + } + if err := globalCfg.Save(); err != nil { + return warning, rollback(err, credentialStored, true) + } + return warning, nil +} diff --git a/internal/commands/root.go b/internal/commands/root.go index b209e49e..28526350 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -61,8 +61,9 @@ var ( // Profile store and the selected profile name. The selected profile is // kept separate from cfg.Account so aliases can target the same account. - profiles *profile.Store - activeProfile string + profiles *profile.Store + activeProfile string + activeProfileExplicitAccount bool // Output writer out *output.Writer @@ -149,6 +150,10 @@ var rootCmd = &cobra.Command{ } } + doctorAllProfiles := false + if cmd.Name() == "doctor" { + doctorAllProfiles, _ = cmd.Flags().GetBool("all-profiles") + } if err := resolveProfile(); err != nil { // Login can create a new profile. An explicit account creates an alias; // otherwise the profile name remains the routed account. @@ -156,13 +161,25 @@ var rootCmd = &cobra.Command{ if cmd == authLoginCmd && newProfile != "" { activeProfile = newProfile cfg.Account = firstNonEmpty(authLoginAccount, newProfile) - } else { + } else if cmd == authLogoutCmd { + requestedProfile := firstNonEmpty(cfgProfile, os.Getenv("FIZZY_PROFILE"), os.Getenv("FIZZY_ACCOUNT")) + if requestedProfile != "" { + activeProfile = requestedProfile + } else if activeProfile == "" { + return &output.Error{Code: output.CodeUsage, Message: err.Error()} + } + } else if cmd != authSwitchCmd && !doctorAllProfiles { + return &output.Error{Code: output.CodeUsage, Message: err.Error()} + } + } + if cfg.Account != "" && cmd != authLogoutCmd && cmd != authSwitchCmd && !doctorAllProfiles { + if err := validateAccountIdentifier(cfg.Account); err != nil { return &output.Error{Code: output.CodeUsage, Message: err.Error()} } } - // Login replaces the selected credential with its TOKEN argument, so it - // does not resolve or migrate an existing credential first. - if cmd != authLoginCmd { + // Commands that replace or remove credentials do not resolve or migrate + // an existing token first. Profile sweeps resolve each target separately. + if cmd != authLoginCmd && cmd != authLogoutCmd && cmd != authSwitchCmd && !doctorAllProfiles { resolveToken() } @@ -1175,6 +1192,7 @@ func credsLoadLegacyToken(account string) (string, error) { // silent fallback to whatever was in the YAML config. func resolveProfile() error { activeProfile = "" + activeProfileExplicitAccount = false if profiles == nil { // No profile store (test mode or init failure) — use the selected name // as both profile and account for legacy compatibility. @@ -1186,7 +1204,10 @@ func resolveProfile() error { } allProfiles, defaultName, err := profiles.List() - if err != nil || len(allProfiles) == 0 { + if err != nil { + return err + } + if len(allProfiles) == 0 { // No profiles configured — use the selected name as both profile and // account until a profile with explicit account metadata is created. if v := firstNonEmpty(cfgProfile, profileEnvVar()); v != "" { @@ -1224,7 +1245,15 @@ func resolveProfile() error { // Apply profile settings to cfg — but only for fields that haven't // already been set by a higher-precedence source (env var). activeProfile = resolved - cfg.Account = profileAccount(resolved, p) + if p.Extra != nil { + _, activeProfileExplicitAccount = p.Extra["account"] + } + binding, err := resolveProfileAccountBinding(resolved, p) + if err != nil { + return err + } + activeProfileExplicitAccount = binding.Explicit + cfg.Account = binding.Account if p.BaseURL != "" && os.Getenv("FIZZY_API_URL") == "" { cfg.APIURL = p.BaseURL } @@ -1250,18 +1279,13 @@ func profileEnvVar() string { return "" } -// profileAccount returns the account routed by a profile. Profiles created -// before account aliases were supported use their name as the account. +// profileAccount returns the validated account routed by a profile. func profileAccount(name string, p *profile.Profile) string { - if p != nil { - if raw, ok := p.Extra["account"]; ok { - var account string - if json.Unmarshal(raw, &account) == nil && strings.TrimSpace(account) != "" { - return account - } - } + binding, err := resolveProfileAccountBinding(name, p) + if err != nil { + return "" } - return name + return binding.Account } // currentProfileName returns the selected credential profile. Legacy config @@ -1284,11 +1308,17 @@ func resolveToken() { profileName := currentProfileName() if profileName != "" { - // Try profile-scoped token first - if t, err := credsLoadProfileToken(profileName); err == nil && t != "" { + // Explicit account bindings are aliases and require their own + // profile-scoped credential. + if activeProfileExplicitAccount { + cfg.Token = "" + if t, err := credsLoadProfileToken(profileName); err == nil && t != "" { + cfg.Token = t + } + } else if t, err := credsLoadProfileToken(profileName); err == nil && t != "" { cfg.Token = t } else { - // Legacy migration: old keys → profile-scoped key + // Legacy migration remains available for account-named profiles. migrateLegacyToken(profileName) } } else { @@ -1311,6 +1341,9 @@ func resolveToken() { // migrateLegacyToken moves a token from legacy storage to profile-scoped storage. // Handles the old single-key credstore entry, account-scoped keys, and YAML config tokens. func migrateLegacyToken(profileName string) { + if profileHasExplicitAccount(profileName) { + return + } // Check legacy credstore keys — copy to profile-scoped key but keep the // legacy keys so older CLI versions still work after a downgrade. if t, err := credsLoadLegacyToken(profileName); err == nil && t != "" { @@ -1348,6 +1381,14 @@ func ensureProfile(name, baseURL, board string) error { // ensureProfileForAccount creates or updates a profile and associates it with // an account. An empty account preserves existing account metadata. func ensureProfileForAccount(name, account, baseURL, board string) error { + var boardUpdate *string + if board != "" { + boardUpdate = &board + } + return ensureProfileForAccountWithBoard(name, account, baseURL, boardUpdate) +} + +func ensureProfileForAccountWithBoard(name, account, baseURL string, board *string) error { if profiles == nil { if account != "" && account != name { return fmt.Errorf("profile store is unavailable for alias %q", name) @@ -1379,8 +1420,12 @@ func ensureProfileForAccount(name, account, baseURL, board string) error { extra[k] = v } } - if board != "" { - extra["board"] = func() json.RawMessage { b, _ := json.Marshal(board); return b }() + if board != nil { + if *board == "" { + delete(extra, "board") + } else { + extra["board"] = func() json.RawMessage { b, _ := json.Marshal(*board); return b }() + } } if account != "" { if account == name { @@ -1417,7 +1462,22 @@ func ensureProfileForAccount(name, account, baseURL, board string) error { return err } if defaultName == name { - return profiles.SetDefault(name) + if err := profiles.SetDefault(name); err != nil { + restoreErr := profiles.Delete(name) + if restoreErr == nil { + restoreErr = profiles.Create(existing) + } + if restoreErr == nil { + restoreErr = profiles.SetDefault(name) + } + if restoreErr != nil { + return stderrors.Join( + fmt.Errorf("update profile %q: %w", name, err), + fmt.Errorf("restore profile %q: %w", name, restoreErr), + ) + } + return err + } } return nil } @@ -1464,6 +1524,7 @@ func ResetTestMode() { creds = nil profiles = nil activeProfile = "" + activeProfileExplicitAccount = false cfgJSON = false cfgQuiet = false cfgIDsOnly = false diff --git a/internal/commands/setup.go b/internal/commands/setup.go index e4c239f2..d19c3f07 100644 --- a/internal/commands/setup.go +++ b/internal/commands/setup.go @@ -263,48 +263,32 @@ func runSetup(cmd *cobra.Command, args []string) error { } if saveGlobal { - // Save token to credstore when available - credstoreSaved := false - if creds != nil { - if err := credsSaveProfileToken(selectedAccountSlug, token); err != nil { - fmt.Printf("Warning: could not save token to credential store: %v\n", err) - } else { - credstoreSaved = true - } - } - - // Create/update profile - if err := ensureProfileForAccount(selectedAccountSlug, selectedAccountSlug, apiURL, selectedBoardID); err != nil { + board := selectedBoardID + warning, err := saveProfileCredentialState(profileCredentialSaveOptions{ + ProfileName: selectedAccountSlug, + Account: selectedAccountSlug, + BaseURL: apiURL, + Board: &board, + Token: token, + AllowYAMLTokenFallback: true, + UpdateGlobal: func(existingConfig *config.Config, credentialStored bool) { + if credentialStored { + existingConfig.Token = "" + } else { + existingConfig.Token = newConfig.Token + } + existingConfig.Account = newConfig.Account + existingConfig.Board = newConfig.Board + if newConfig.APIURL != "" { + existingConfig.APIURL = newConfig.APIURL + } + }, + }) + if err != nil { return &output.Error{Code: output.CodeAPI, Message: err.Error()} } - // If user chose "None (skip)", clear any previously saved board - if selectedBoardID == "" && profiles != nil { - if p, err := profiles.Get(selectedAccountSlug); err == nil { - delete(p.Extra, "board") - _ = profiles.Delete(selectedAccountSlug) - _ = profiles.Create(p) - } - } - if profiles != nil { - _ = profiles.SetDefault(selectedAccountSlug) - } - - // Load existing global config to preserve any other settings - existingConfig := config.LoadGlobal() - // Only clear YAML token when credstore save actually succeeded - if credstoreSaved { - existingConfig.Token = "" - } else { - existingConfig.Token = newConfig.Token - } - existingConfig.Account = newConfig.Account - existingConfig.Board = newConfig.Board - if newConfig.APIURL != "" { - existingConfig.APIURL = newConfig.APIURL - } - - if err := existingConfig.Save(); err != nil { - return err + if warning != nil { + fmt.Printf("Warning: could not save token to credential store: %v\n", warning) } fmt.Println() fmt.Println("✓ Configuration saved to ~/.config/fizzy/config.yaml") diff --git a/internal/commands/signup.go b/internal/commands/signup.go index 01b55e89..f1f799e0 100644 --- a/internal/commands/signup.go +++ b/internal/commands/signup.go @@ -809,35 +809,28 @@ func readSessionToken() (string, error) { // saveSignupConfig saves the token (to credstore if available, else YAML) and // account/API URL to the global config file, matching the auth login behavior. func saveSignupConfig(token, account, apiURL string) error { - globalCfg := config.LoadGlobal() - - if creds != nil { - if err := credsSaveProfileToken(account, token); err != nil { - fmt.Fprintf(os.Stderr, "Warning: could not save token to credential store: %v\n", err) - globalCfg.Token = token - } else { - globalCfg.Token = "" - } - } else { - globalCfg.Token = token - } - - // Create/update profile - if err := ensureProfileForAccount(account, account, apiURL, ""); err != nil { - return err - } - if profiles != nil { - if err := profiles.SetDefault(account); err != nil { - return err - } - } - - globalCfg.Account = account - if apiURL != config.DefaultAPIURL { - globalCfg.APIURL = apiURL - } else { - globalCfg.APIURL = "" + warning, err := saveProfileCredentialState(profileCredentialSaveOptions{ + ProfileName: account, + Account: account, + BaseURL: apiURL, + Token: token, + AllowYAMLTokenFallback: true, + UpdateGlobal: func(globalCfg *config.Config, credentialStored bool) { + if credentialStored { + globalCfg.Token = "" + } else { + globalCfg.Token = token + } + globalCfg.Account = account + if apiURL != config.DefaultAPIURL { + globalCfg.APIURL = apiURL + } else { + globalCfg.APIURL = "" + } + }, + }) + if warning != nil { + fmt.Fprintf(os.Stderr, "Warning: could not save token to credential store: %v\n", warning) } - - return globalCfg.Save() + return err } diff --git a/internal/commands/signup_test.go b/internal/commands/signup_test.go index 49fb624b..07ea515a 100644 --- a/internal/commands/signup_test.go +++ b/internal/commands/signup_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "github.com/basecamp/cli/credstore" "github.com/basecamp/cli/output" "github.com/basecamp/cli/profile" "github.com/basecamp/fizzy-cli/internal/config" @@ -594,6 +595,59 @@ func TestSaveSignupConfigClearsStaleAPIURL(t *testing.T) { } }) + t.Run("profile failure preserves the previous credential", func(t *testing.T) { + config.SetTestConfigDir(t.TempDir()) + defer config.ResetTestConfigDir() + t.Setenv("FIZZY_SIGNUP_TRANSACTION_NO_KR", "1") + + store := credstore.NewStore(credstore.StoreOptions{ + ServiceName: "fizzy-signup-transaction-test", + DisableEnvVar: "FIZZY_SIGNUP_TRANSACTION_NO_KR", + FallbackDir: t.TempDir(), + }) + oldToken, _ := json.Marshal("old-token") + if err := store.Save("profile:acct", oldToken); err != nil { + t.Fatalf("save old credential: %v", err) + } + profileDir := filepath.Join(t.TempDir(), "profiles") + profileStore := profile.NewStore(filepath.Join(profileDir, "config.json")) + if err := profileStore.Create(&profile.Profile{ + Name: "acct", + BaseURL: config.DefaultAPIURL, + Extra: map[string]json.RawMessage{"account": json.RawMessage(`"stale-account"`)}, + }); err != nil { + t.Fatalf("create stale profile: %v", err) + } + backupDir := profileDir + "-backup" + if err := os.Rename(profileDir, backupDir); err != nil { + t.Fatalf("move profile directory: %v", err) + } + if err := os.WriteFile(profileDir, []byte("not a directory"), 0600); err != nil { + t.Fatalf("block profile directory: %v", err) + } + + SetTestCreds(store) + SetTestProfiles(profileStore) + err := saveSignupConfig("new-token", "acct", config.DefaultAPIURL) + if removeErr := os.Remove(profileDir); removeErr != nil { + t.Fatalf("unblock profile directory: %v", removeErr) + } + if renameErr := os.Rename(backupDir, profileDir); renameErr != nil { + t.Fatalf("restore profile directory: %v", renameErr) + } + defer resetTest() + if err == nil { + t.Fatal("expected profile persistence error") + } + data, loadErr := store.Load("profile:acct") + if loadErr != nil { + t.Fatalf("load old credential: %v", loadErr) + } + if string(data) != string(oldToken) { + t.Fatalf("credential changed after failed signup: want %s, got %s", oldToken, data) + } + }) + t.Run("self-hosted signup preserves custom URL", func(t *testing.T) { config.SetTestConfigDir(t.TempDir()) defer config.ResetTestConfigDir() From e27a597a53be4c1cee579dc08eeafd9919ed22d5 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Fri, 31 Jul 2026 15:26:24 -0400 Subject: [PATCH 09/10] Preserve self-hosted URL when reconstructing profiles --- internal/commands/auth.go | 9 +++++++-- internal/commands/auth_test.go | 36 +++++++++++++++++++++++++++++++++ internal/commands/config_cmd.go | 3 ++- 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/internal/commands/auth.go b/internal/commands/auth.go index 6bf89bb3..1dbf18af 100644 --- a/internal/commands/auth.go +++ b/internal/commands/auth.go @@ -394,9 +394,14 @@ var authSwitchCmd = &cobra.Command{ } globalSnapshot := snapshotGlobalConfig() - // Ensure the profile exists without replacing its deployment URL. + // Preserve a saved deployment URL and seed reconstructed profiles from + // the current effective URL. + profileBaseURL := "" + if profileSnapshot.previous == nil && cfg != nil { + profileBaseURL = cfg.APIURL + } if profiles != nil { - if err := ensureProfile(profileName, "", ""); err != nil { + if err := ensureProfile(profileName, profileBaseURL, ""); err != nil { return &output.Error{Code: output.CodeAPI, Message: err.Error()} } if err := profiles.SetDefault(profileName); err != nil { diff --git a/internal/commands/auth_test.go b/internal/commands/auth_test.go index 433bc83c..a2354acc 100644 --- a/internal/commands/auth_test.go +++ b/internal/commands/auth_test.go @@ -1512,6 +1512,42 @@ func TestAuthSwitch(t *testing.T) { } }) + t.Run("reconstructed profile inherits the effective API URL", func(t *testing.T) { + configDir := t.TempDir() + config.SetTestConfigDir(configDir) + defer config.ResetTestConfigDir() + t.Setenv("FIZZY_SWITCH_RECONSTRUCT_NO_KR", "1") + store := credstore.NewStore(credstore.StoreOptions{ + ServiceName: "fizzy-switch-reconstruct-test", + DisableEnvVar: "FIZZY_SWITCH_RECONSTRUCT_NO_KR", + FallbackDir: t.TempDir(), + }) + tokenData, _ := json.Marshal("other-token") + if err := store.Save("profile:other", tokenData); err != nil { + t.Fatalf("save target credential: %v", err) + } + profileStore := profile.NewStore(filepath.Join(t.TempDir(), "config.json")) + if err := profileStore.Create(&profile.Profile{Name: "current", BaseURL: "https://self-hosted.example.com"}); err != nil { + t.Fatalf("create current profile: %v", err) + } + SetTestModeWithSDK(NewMockClient()) + SetTestCreds(store) + SetTestProfiles(profileStore) + SetTestConfig("current-token", "current", "https://self-hosted.example.com") + defer resetTest() + + if err := authSwitchCmd.RunE(authSwitchCmd, []string{"other"}); err != nil { + t.Fatalf("switch reconstructed profile: %v", err) + } + reconstructed, err := profileStore.Get("other") + if err != nil { + t.Fatalf("get reconstructed profile: %v", err) + } + if reconstructed.BaseURL != "https://self-hosted.example.com" { + t.Fatalf("BaseURL: want self-hosted URL, got %q", reconstructed.BaseURL) + } + }) + t.Run("fails for unknown profile", func(t *testing.T) { credDir := t.TempDir() diff --git a/internal/commands/config_cmd.go b/internal/commands/config_cmd.go index 4965b795..d926a126 100644 --- a/internal/commands/config_cmd.go +++ b/internal/commands/config_cmd.go @@ -109,7 +109,8 @@ func configShowData(verbose bool) map[string]any { "default": eff.Default, }, "account": map[string]any{ - "value": emptyToNil(eff.Account), + "value": emptyToNil(eff.Account), + "source": displayProfileSource(eff, defaultProfile), }, "api_url": map[string]any{ "value": emptyToNil(eff.APIURL), From aff4141ef7db7dd211306586bb4cf247eb089d0c Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Fri, 31 Jul 2026 15:31:39 -0400 Subject: [PATCH 10/10] Report alias account source accurately --- internal/commands/config_cmd.go | 7 ++++++- internal/commands/config_cmd_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/internal/commands/config_cmd.go b/internal/commands/config_cmd.go index d926a126..a2798d00 100644 --- a/internal/commands/config_cmd.go +++ b/internal/commands/config_cmd.go @@ -102,6 +102,11 @@ func init() { func configShowData(verbose bool) map[string]any { eff := resolveDoctorEffectiveConfig() _, defaultProfile := profileStoreInfo() + resolvedProfile, _ := resolveDoctorProfileContext() + accountSource := displayProfileSource(eff, defaultProfile) + if resolvedProfile != "" { + accountSource = profileSourceLabel(resolvedProfile, eff.ProfileName) + } data := map[string]any{ "profile": map[string]any{ "value": emptyToNil(eff.ProfileName), @@ -110,7 +115,7 @@ func configShowData(verbose bool) map[string]any { }, "account": map[string]any{ "value": emptyToNil(eff.Account), - "source": displayProfileSource(eff, defaultProfile), + "source": accountSource, }, "api_url": map[string]any{ "value": emptyToNil(eff.APIURL), diff --git a/internal/commands/config_cmd_test.go b/internal/commands/config_cmd_test.go index 4700b612..93db128c 100644 --- a/internal/commands/config_cmd_test.go +++ b/internal/commands/config_cmd_test.go @@ -82,6 +82,33 @@ func TestConfigShow(t *testing.T) { } } +func TestConfigShowVerboseAttributesAliasAccountToProfile(t *testing.T) { + profileStore := profile.NewStore(filepath.Join(t.TempDir(), "config.json")) + if err := profileStore.Create(&profile.Profile{ + Name: "walter", + BaseURL: config.DefaultAPIURL, + Extra: map[string]json.RawMessage{"account": json.RawMessage(`"1"`)}, + }); err != nil { + t.Fatalf("create alias: %v", err) + } + SetTestProfiles(profileStore) + SetTestConfig("token", "legacy", config.DefaultAPIURL) + cfgProfile = "walter" + defer resetTest() + if err := resolveProfile(); err != nil { + t.Fatalf("resolve profile: %v", err) + } + + data := configShowData(true) + account, ok := data["account"].(map[string]any) + if !ok { + t.Fatalf("account: expected object, got %#v", data["account"]) + } + if account["value"] != "1" || account["source"] != "profile walter" { + t.Fatalf("account: want value 1 from profile walter, got %#v", account) + } +} + func TestConfigExplainShowsPrecedence(t *testing.T) { configDir := t.TempDir() workDir := t.TempDir()