carlos / platform Public

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
Paul Campbell 2834e62d69dbe25ccc25f651187426898f354f23 parent 518c8b0
3 files changed, +116 −8
  • auth.go +33 −8
  • config_test.go +79 −0
  • e2e_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)
}