auth: login resolves through config defaults to the flagship
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KxXEWoRXU8WVTXjTJypuJ5
3 files changed,
+79
−15
auth.go+24 −7auth_test.go+0 −8config_test.go+55 −0
diff --git a/auth.go b/auth.go| index a8eddcd2f..1d0fd9a27 100644 |
| --- a/auth.go |
| +++ b/auth.go |
| @@ -558,20 +558,37 @@ func loginDeadline(now time.Time, expiresIn int) time.Time { |
| return now.Add(d) |
| } |
| +// loginConsole is login's resolution chain: flag → CARLOS_CONSOLE → the |
| +// configured default → the flagship. Login alone gets the built-in landing |
| +// place: there may be no credential yet to pick from, and the flagship is |
| +// the product answer. Every other verb resolves through pick(), which |
| +// assumes nothing. |
| +func loginConsole(flagValue string) (string, error) { |
| + base := flagValue |
| + if base == "" { |
| + base = os.Getenv("CARLOS_CONSOLE") |
| + } |
| + if base == "" { |
| + var err error |
| + if base, _, err = configDefault(); err != nil { |
| + return "", err |
| + } |
| + } |
| + if base == "" { |
| + base = defaultConsole |
| + } |
| + return normalizeConsole(base) |
| +} |
| + |
| func authLogin(out io.Writer, args []string) error { |
| fs := flag.NewFlagSet("auth login", flag.ContinueOnError) |
| fs.SetOutput(out) |
| - console := fs.String("console", os.Getenv("CARLOS_CONSOLE"), "the console to log in to (https://…)") |
| + console := fs.String("console", "", "the console to log in to (default: the configured default, else "+defaultConsole+")") |
| label := fs.String("label", "", "what to call this terminal on the console (default: user@host)") |
| if err := fs.Parse(args); err != nil { |
| return err |
| } |
| - if *console == "" { |
| - return errors.New("which console? pass -console https://platform.carlosframework.com " + |
| - "(or set CARLOS_CONSOLE). On a box with no browser, don't log in at all — " + |
| - "pass -owner <address> to `carlos accounts create` instead") |
| - } |
| - base, err := normalizeConsole(*console) |
| + base, err := loginConsole(*console) |
| if err != nil { |
| return err |
| } |
diff --git a/auth_test.go b/auth_test.go| index 9e2536c3b..bb8a50c74 100644 |
| --- a/auth_test.go |
| +++ b/auth_test.go |
| @@ -340,14 +340,6 @@ func TestAuthLoginDenied(t *testing.T) { |
| } |
| } |
| -func TestAuthLoginNeedsAConsole(t *testing.T) { |
| - useTempCredentials(t) |
| - var out bytes.Buffer |
| - if err := authLogin(&out, nil); err == nil || !strings.Contains(err.Error(), "-console") { |
| - t.Fatalf("login with no console = %v, want a refusal naming -console", err) |
| - } |
| -} |
| - |
| // A credentials file anyone can read is a credential anyone can steal: the |
| // CLI says so rather than pretending it is fine. |
| func TestLooseCredentialPermissionsAreReported(t *testing.T) { |
diff --git a/config_test.go b/config_test.go| index c6ea881c1..5d695d5b1 100644 |
| --- a/config_test.go |
| +++ b/config_test.go |
| @@ -1,6 +1,7 @@ |
| package main |
| import ( |
| + "bytes" |
| "os" |
| "path/filepath" |
| "strings" |
| @@ -207,3 +208,57 @@ func TestConfigDefaultIsAuthoritative(t *testing.T) { |
| t.Fatal("malformed config must be an error, not a skip to the credential") |
| } |
| } |
| + |
| +// The login chain: flag → CARLOS_CONSOLE → project config → global config → |
| +// the flagship. Login is the one verb with a built-in landing place; there |
| +// may be no credential yet, and the flagship is the product answer. |
| +func TestLoginConsoleChain(t *testing.T) { |
| + useTempCredentials(t) |
| + home := os.Getenv("HOME") |
| + cwd, err := os.Getwd() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + if got, err := loginConsole(""); err != nil || got != defaultConsole { |
| + t.Fatalf("bare loginConsole = %q %v, want the flagship", got, err) |
| + } |
| + writeConfig(t, home, `{"console": "https://global.example"}`) |
| + if got, err := loginConsole(""); err != nil || got != "https://global.example" { |
| + t.Fatalf("global config = %q %v", got, err) |
| + } |
| + writeConfig(t, cwd, `{"console": "https://project.example"}`) |
| + if got, err := loginConsole(""); err != nil || got != "https://project.example" { |
| + t.Fatalf("project config = %q %v", got, err) |
| + } |
| + t.Setenv("CARLOS_CONSOLE", "https://env.example") |
| + if got, err := loginConsole(""); err != nil || got != "https://env.example" { |
| + t.Fatalf("env = %q %v", got, err) |
| + } |
| + if got, err := loginConsole("https://flag.example"); err != nil || got != "https://flag.example" { |
| + t.Fatalf("flag = %q %v", got, err) |
| + } |
| + // A malformed config fails the login rather than silently logging in |
| + // to the flagship. |
| + t.Setenv("CARLOS_CONSOLE", "") |
| + os.WriteFile(filepath.Join(cwd, ".carlos", "config"), []byte(`{`), 0o644) |
| + if _, err := loginConsole(""); err == nil { |
| + t.Fatal("malformed config must fail the login, not skip to the flagship") |
| + } |
| +} |
| + |
| +// End to end: with a configured default and no -console at all, login goes |
| +// where the config points. |
| +func TestAuthLoginUsesConfiguredDefault(t *testing.T) { |
| + useTempCredentials(t) |
| + fc := newFakeConsole(t) |
| + writeConfig(t, os.Getenv("HOME"), `{"console": "`+fc.ts.URL+`"}`) |
| + var out bytes.Buffer |
| + if err := authLogin(&out, nil); err != nil { |
| + t.Fatalf("authLogin via config default: %v\n%s", err, out.String()) |
| + } |
| + f, _ := loadCredentials() |
| + if c := f.find(fc.ts.URL); c == nil || c.Token != "cli-token" { |
| + t.Fatalf("credential stored = %+v, want one for the configured console", c) |
| + } |
| +} |