auth: pick() honors the configured default console, authoritatively
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KxXEWoRXU8WVTXjTJypuJ5
3 files changed,
+116
−8
auth.go+33 −8config_test.go+79 −0e2e_test.go+4 −0
diff --git a/auth.go b/auth.go| index fba2b8a39..a8eddcd2f 100644 |
| --- a/auth.go |
| +++ b/auth.go |
| @@ -236,14 +236,37 @@ func (f *credentialFile) remove(console string) bool { |
| return false |
| } |
| -// pick resolves which credential a verb should use. An explicit -console |
| -// wins; otherwise the only credential is used, and several is a refusal |
| -// rather than a guess — shipping to the wrong deployment because the CLI |
| -// picked one for you is not a mistake worth being convenient about. |
| +// pick resolves which credential a verb should use: an explicit -console, |
| +// else CARLOS_CONSOLE, else the configured default (.carlos/config walking |
| +// up from cwd, then ~/.carlos/config — config.go), else the only live |
| +// credential. Several with no default is a refusal rather than a guess — |
| +// shipping to the wrong deployment because the CLI picked one for you is |
| +// not a mistake worth being convenient about. |
| +// |
| +// Any explicitly named console — flag, env, or config — is authoritative: |
| +// it resolves against credentials for THAT console or errors. It never |
| +// falls back to "the only credential", because inside a checkout pinned to |
| +// one stack, quietly using a credential for a different stack is the wrong |
| +// deployment with extra steps. |
| func (f *credentialFile) pick(console string) (*credential, error) { |
| if console == "" { |
| console = os.Getenv("CARLOS_CONSOLE") |
| } |
| + from := "" |
| + if console == "" { |
| + var src string |
| + var err error |
| + console, src, err = configDefault() |
| + if err != nil { |
| + return nil, err |
| + } |
| + if console != "" { |
| + // Only a config-sourced console gets the clause: whoever typed |
| + // -console or exported CARLOS_CONSOLE knows where it came from, |
| + // but a default set weeks ago in a file needs naming. |
| + from = fmt.Sprintf(" (the default from %s)", src) |
| + } |
| + } |
| if console != "" { |
| want, err := normalizeConsole(console) |
| if err != nil { |
| @@ -251,11 +274,11 @@ func (f *credentialFile) pick(console string) (*credential, error) { |
| } |
| c := f.find(want) |
| if c == nil { |
| - return nil, fmt.Errorf("not logged in to %s — run: carlos auth login -console %s", want, want) |
| + return nil, fmt.Errorf("not logged in to %s%s — run: carlos auth login -console %s", want, from, want) |
| } |
| if c.Expired(time.Now()) { |
| - return nil, fmt.Errorf("the credential for %s expired %s — run: carlos auth login -console %s", |
| - want, c.ExpiresAt.Format(time.RFC3339), want) |
| + return nil, fmt.Errorf("the credential for %s%s expired %s — run: carlos auth login -console %s", |
| + want, from, c.ExpiresAt.Format(time.RFC3339), want) |
| } |
| return c, nil |
| } |
| @@ -269,13 +292,15 @@ func (f *credentialFile) pick(console string) (*credential, error) { |
| case 1: |
| return live[0], nil |
| case 0: |
| + // EXACT wording — apimode.go's noCredentialsMsg matches this string. |
| return nil, errors.New("not logged in — run: carlos auth login -console <url>") |
| default: |
| var names []string |
| for _, c := range live { |
| names = append(names, c.Console) |
| } |
| - return nil, fmt.Errorf("logged in to several consoles — say which with -console: %s", |
| + return nil, fmt.Errorf("logged in to several consoles — say which with -console, "+ |
| + "or set a default with `carlos auth default <url>`: %s", |
| strings.Join(names, ", ")) |
| } |
| } |
diff --git a/config_test.go b/config_test.go| index 22890c844..c6ea881c1 100644 |
| --- a/config_test.go |
| +++ b/config_test.go |
| @@ -5,6 +5,7 @@ import ( |
| "path/filepath" |
| "strings" |
| "testing" |
| + "time" |
| ) |
| // writeConfig plants a .carlos/config under dir, the way a checked-in |
| @@ -128,3 +129,81 @@ func TestSaveConfigFile(t *testing.T) { |
| t.Fatalf("config mode = %v, want 0644", fi.Mode().Perm()) |
| } |
| } |
| + |
| +func TestPickHonorsConfigDefault(t *testing.T) { |
| + useTempCredentials(t) |
| + home := os.Getenv("HOME") |
| + cwd, err := os.Getwd() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + now := time.Now().UTC() |
| + f, _ := loadCredentials() |
| + f.put(credential{Console: "https://flagship.example", Address: "p@keymail.dev", |
| + Token: "t-flag", ExpiresAt: now.Add(time.Hour)}) |
| + f.put(credential{Console: "https://tito.example", Address: "p@keymail.dev", |
| + Token: "t-tito", ExpiresAt: now.Add(time.Hour)}) |
| + |
| + // Two credentials, no default: today's refusal — and it now also says |
| + // how to stop having to choose. |
| + if _, err := f.pick(""); err == nil || !strings.Contains(err.Error(), "carlos auth default") { |
| + t.Fatalf("multi-credential refusal = %v, want the set-a-default hint", err) |
| + } |
| + |
| + // A global default resolves the ambiguity. |
| + writeConfig(t, home, `{"console": "https://tito.example"}`) |
| + c, err := f.pick("") |
| + if err != nil || c.Token != "t-tito" { |
| + t.Fatalf("pick with global default = %+v %v, want the tito credential", c, err) |
| + } |
| + |
| + // A project default overrides the global. |
| + writeConfig(t, cwd, `{"console": "https://flagship.example"}`) |
| + if c, err = f.pick(""); err != nil || c.Token != "t-flag" { |
| + t.Fatalf("pick with project default = %+v %v, want the flagship credential", c, err) |
| + } |
| + |
| + // CARLOS_CONSOLE still beats any config. |
| + t.Setenv("CARLOS_CONSOLE", "https://tito.example") |
| + if c, err = f.pick(""); err != nil || c.Token != "t-tito" { |
| + t.Fatalf("env over config = %+v %v", c, err) |
| + } |
| + t.Setenv("CARLOS_CONSOLE", "") |
| + |
| + // An explicit flag still beats everything. |
| + if c, err = f.pick("https://tito.example"); err != nil || c.Token != "t-tito" { |
| + t.Fatalf("flag over config = %+v %v", c, err) |
| + } |
| +} |
| + |
| +func TestConfigDefaultIsAuthoritative(t *testing.T) { |
| + useTempCredentials(t) |
| + cwd, err := os.Getwd() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // The titogo case: the checkout is pinned to a stack this machine holds |
| + // no credential for, and the only credential is for a DIFFERENT console. |
| + // Falling back to it would quietly ship the project to the wrong |
| + // deployment — the exact mistake the default exists to prevent. |
| + f, _ := loadCredentials() |
| + f.put(credential{Console: "https://flagship.example", Address: "p@keymail.dev", |
| + Token: "t-flag", ExpiresAt: time.Now().Add(time.Hour)}) |
| + src := writeConfig(t, cwd, `{"console": "https://tito.example"}`) |
| + |
| + _, err = f.pick("") |
| + if err == nil { |
| + t.Fatal("a set default with no credential must refuse, not fall back") |
| + } |
| + for _, want := range []string{"https://tito.example", src} { |
| + if !strings.Contains(err.Error(), want) { |
| + t.Fatalf("authoritative refusal %q does not name %q", err, want) |
| + } |
| + } |
| + |
| + // A malformed config is an error even though a usable credential exists. |
| + os.WriteFile(src, []byte(`{`), 0o644) |
| + if _, err := f.pick(""); err == nil { |
| + t.Fatal("malformed config must be an error, not a skip to the credential") |
| + } |
| +} |
diff --git a/e2e_test.go b/e2e_test.go| index 89934bee9..b0ab741fb 100644 |
| --- a/e2e_test.go |
| +++ b/e2e_test.go |
| @@ -700,6 +700,8 @@ func TestAccountsCLI(t *testing.T) { |
| t.Helper() |
| cmd := exec.Command(carlosBin, args...) |
| cmd.Env = env |
| + // The binary now walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. |
| + cmd.Dir = tmp |
| out, err := cmd.CombinedOutput() |
| if err != nil { |
| t.Fatalf("carlos %s: %v\n%s", strings.Join(args, " "), err, out) |
| @@ -737,6 +739,7 @@ func TestAccountsCLI(t *testing.T) { |
| // rather than print a shrug. |
| failed := exec.Command(carlosBin, "accounts", "create", "-registry", regPath, "-name", "Kass") |
| failed.Env = env |
| + failed.Dir = tmp |
| refusal, err := failed.CombinedOutput() |
| if err == nil { |
| t.Fatalf("accounts create with no -owner succeeded — that is the ownerless account bug:\n%s", refusal) |
| @@ -754,6 +757,7 @@ func TestAccountsCLI(t *testing.T) { |
| // open either. |
| failed = exec.Command(carlosBin, "accounts", "create", "-registry", regPath, "-name", "Kass", "-owner", "paul") |
| failed.Env = env |
| + failed.Dir = tmp |
| if refusal, err := failed.CombinedOutput(); err == nil { |
| t.Errorf("accounts create accepted the non-address owner %q:\n%s", "paul", refusal) |
| } |