carlos / platform Public

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
Paul Campbell dc670a24af39cae61d519beac779fb5a27ae2328 parent a94384d
3 files changed, +79 −15
  • auth.go +24 −7
  • auth_test.go +0 −8
  • config_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)
+ }
+}