plan: console default resolution — 5 TDD tasks
config.go (file+walk), pick() chain, login fallback, auth default verb, whole-tree gate. Pins apimode.go's exact-string noCredentialsMsg match as a wire shape and hardens useTempCredentials against the developer's real ~/.carlos/config.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KxXEWoRXU8WVTXjTJypuJ5
1 file changed,
+939
−0
docs/superpowers/plans/2026-08-04-console-default-resolution.md+939 −0
diff --git a/docs/superpowers/plans/2026-08-04-console-default-resolution.md b/docs/superpowers/plans/2026-08-04-console-default-resolution.md| new file mode 100644 |
| index 000000000..edca8e3c6 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-04-console-default-resolution.md |
| @@ -0,0 +1,939 @@ |
| +# Console Default Resolution Implementation Plan |
| + |
| +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| + |
| +**Goal:** `carlos` resolves its target console through settable defaults — global `~/.carlos/config`, per-project `.carlos/config` — with `auth login` alone falling through to the built-in flagship, per `docs/superpowers/specs/2026-08-04-console-default-resolution-design.md`. |
| + |
| +**Architecture:** One new file (`config.go`) owns the config file format, the upward directory walk, and the global path; `auth.go`'s `pick()` and `authLogin()` gain the two config steps in their resolution chains; a new `auth default` sub-verb sets and shows the default. Everything else (`ship`, `promote`, `accounts`, apimode) already resolves through `pick()`/`loggedInCredential` and picks the behavior up for free. |
| + |
| +**Tech Stack:** Go 1.26, stdlib only, tests in `package main` alongside the code (repo convention). |
| + |
| +## Global Constraints |
| + |
| +- Build/test env (sandbox): `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; if go refuses with "read-only file system", rerun with the sandbox disabled. |
| +- Before each commit: `go build ./...`, `go vet ./...`, `go test ./... -count=1` all clean; `gofmt -l` clean for touched files. |
| +- **`noCredentialsMsg` is a wire shape.** `apimode.go` matches `pick()`'s no-credentials refusal by exact string: `"not logged in — run: carlos auth login -console <url>"`. That wording must stay byte-identical in `pick()`. |
| +- The multi-credential refusal and the HEADLESS warning in `authUsage` are kept (the latter verbatim). |
| +- Built-in login fallback is exactly `https://platform.carlosframework.com`. |
| +- Config file is JSON `{"console": "https://…"}`; missing file or missing/empty `console` field = "no default at this level" (continue resolution); malformed JSON or a URL `normalizeConsole` rejects = error naming the file (never a silent skip). |
| +- A resolved config default is authoritative: never fall back past it to the single-credential pick. |
| +- Follow the repo's comment style: comments explain constraints and reasoning, not what the next line does. |
| + |
| +--- |
| + |
| +### Task 1: config.go — file format, walk, global path |
| + |
| +**Files:** |
| +- Create: `config.go` |
| +- Create: `config_test.go` |
| +- Modify: `auth_test.go:16-23` (`useTempCredentials` — isolate HOME/cwd/env too) |
| + |
| +**Interfaces:** |
| +- Consumes: `normalizeConsole(string) (string, error)` from `auth.go`. |
| +- Produces (later tasks rely on these exact signatures): |
| + - `const defaultConsole = "https://platform.carlosframework.com"` |
| + - `func loadConfigFile(path string) (string, error)` — normalized console or `""`; error only for malformed/invalid. |
| + - `func configDefault() (console, source string, err error)` — the effective configured default and the file it came from; `("", "", nil)` when none. |
| + - `func globalConfigPath() (string, error)` — `~/.carlos/config`. |
| + - `func saveConfigFile(path, console string, dirPerm os.FileMode) error` — validate, mkdir, write via temp+rename, 0644. |
| + |
| +- [ ] **Step 1: Harden the test helper.** Config resolution reads `$HOME`, cwd, and `CARLOS_CONSOLE`, so every test must point all of them away from the developer's real machine — a stray `~/.carlos/config` on the box running the tests must never change a test's answer. Replace `useTempCredentials` in `auth_test.go`: |
| + |
| +```go |
| +// useTempCredentials points everything the CLI's console resolution reads — |
| +// the credentials file, $HOME, the working directory, CARLOS_CONSOLE — at |
| +// scratch space for the length of one test. Config resolution (config.go) |
| +// consults all four; a developer's real ~/.carlos/config must never change |
| +// a test's answer. |
| +func useTempCredentials(t *testing.T) string { |
| + t.Helper() |
| + home := t.TempDir() |
| + t.Setenv("HOME", home) |
| + t.Setenv("CARLOS_CONSOLE", "") |
| + t.Chdir(t.TempDir()) |
| + path := filepath.Join(t.TempDir(), "carlos", "credentials") |
| + t.Setenv("CARLOS_CREDENTIALS", path) |
| + return path |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Write the failing tests** in a new `config_test.go`: |
| + |
| +```go |
| +package main |
| + |
| +import ( |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// writeConfig plants a .carlos/config under dir, the way a checked-in |
| +// project file or `carlos auth default` would. |
| +func writeConfig(t *testing.T, dir, body string) string { |
| + t.Helper() |
| + path := filepath.Join(dir, ".carlos", "config") |
| + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + return path |
| +} |
| + |
| +func TestLoadConfigFile(t *testing.T) { |
| + useTempCredentials(t) |
| + dir := t.TempDir() |
| + |
| + // Missing file: no default at this level, not an error. |
| + if c, err := loadConfigFile(filepath.Join(dir, ".carlos", "config")); err != nil || c != "" { |
| + t.Fatalf("missing config = %q, %v; want empty, nil", c, err) |
| + } |
| + |
| + // A well-formed file: the console comes back normalized. |
| + p := writeConfig(t, dir, `{"console": "https://tito.example/"}`+"\n") |
| + if c, err := loadConfigFile(p); err != nil || c != "https://tito.example" { |
| + t.Fatalf("loadConfigFile = %q, %v; want the normalized console", c, err) |
| + } |
| + |
| + // Unknown fields are ignored (additive evolution), and a file WITHOUT |
| + // a console field is "no default at this level", not an error. |
| + os.WriteFile(p, []byte(`{"future": true}`), 0o644) |
| + if c, err := loadConfigFile(p); err != nil || c != "" { |
| + t.Fatalf("config without console = %q, %v; want empty, nil", c, err) |
| + } |
| + |
| + // Malformed JSON is an error naming the file — silently skipping a |
| + // typo'd config would ship to the wrong console. |
| + os.WriteFile(p, []byte(`{`), 0o644) |
| + if _, err := loadConfigFile(p); err == nil || !strings.Contains(err.Error(), p) { |
| + t.Fatalf("malformed config = %v, want an error naming %s", err, p) |
| + } |
| + |
| + // A console normalizeConsole rejects is an error too, same reasoning. |
| + os.WriteFile(p, []byte(`{"console": "ftp://nope"}`), 0o644) |
| + if _, err := loadConfigFile(p); err == nil || !strings.Contains(err.Error(), p) { |
| + t.Fatalf("bad-URL config = %v, want an error naming %s", err, p) |
| + } |
| +} |
| + |
| +func TestConfigDefaultWalk(t *testing.T) { |
| + useTempCredentials(t) |
| + home := os.Getenv("HOME") |
| + |
| + // Nothing anywhere: no default, no error. |
| + if c, src, err := configDefault(); err != nil || c != "" || src != "" { |
| + t.Fatalf("bare configDefault = %q %q %v, want empty", c, src, err) |
| + } |
| + |
| + // Only the global: the global wins, and the source names the file. |
| + globalPath := writeConfig(t, home, `{"console": "https://global.example"}`) |
| + c, src, err := configDefault() |
| + if err != nil || c != "https://global.example" || src != globalPath { |
| + t.Fatalf("global-only = %q %q %v", c, src, err) |
| + } |
| + |
| + // A project config anywhere up the cwd walk beats the global, and the |
| + // NEAREST project config beats a farther one. |
| + root := t.TempDir() |
| + writeConfig(t, root, `{"console": "https://outer.example"}`) |
| + nested := filepath.Join(root, "cmd", "deep") |
| + if err := os.MkdirAll(nested, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Chdir(nested) |
| + if c, _, err = configDefault(); err != nil || c != "https://outer.example" { |
| + t.Fatalf("walk from nested = %q %v, want the ancestor's config", c, err) |
| + } |
| + nearest := writeConfig(t, nested, `{"console": "https://inner.example"}`) |
| + if c, src, err = configDefault(); err != nil || c != "https://inner.example" || src != nearest { |
| + t.Fatalf("nearest = %q %q %v", c, src, err) |
| + } |
| + |
| + // A level whose config has no console does not stop the walk. |
| + os.WriteFile(nearest, []byte(`{"future": true}`), 0o644) |
| + if c, _, err = configDefault(); err != nil || c != "https://outer.example" { |
| + t.Fatalf("empty-console level = %q %v, want the walk to continue", c, err) |
| + } |
| + |
| + // A malformed config mid-walk is an error, not a skip. |
| + os.WriteFile(nearest, []byte(`not json`), 0o644) |
| + if _, _, err = configDefault(); err == nil { |
| + t.Fatal("malformed project config must be an error, not a skip") |
| + } |
| +} |
| + |
| +func TestSaveConfigFile(t *testing.T) { |
| + useTempCredentials(t) |
| + dir := t.TempDir() |
| + path := filepath.Join(dir, ".carlos", "config") |
| + |
| + // The URL is validated and normalized on WRITE, so a bad default is |
| + // caught when set, not at first use. |
| + if err := saveConfigFile(path, "ftp://nope", 0o755); err == nil { |
| + t.Fatal("saving an invalid console must fail") |
| + } |
| + if err := saveConfigFile(path, "https://tito.example/", 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if c, err := loadConfigFile(path); err != nil || c != "https://tito.example" { |
| + t.Fatalf("round-trip = %q, %v", c, err) |
| + } |
| + // Not a secret: world-readable is fine and expected in a repo. |
| + fi, err := os.Stat(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if fi.Mode().Perm() != 0o644 { |
| + t.Fatalf("config mode = %v, want 0644", fi.Mode().Perm()) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 3: Run the tests to verify they fail.** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -run 'TestLoadConfigFile|TestConfigDefaultWalk|TestSaveConfigFile' -count=1` |
| +Expected: compile FAILURE — `loadConfigFile`, `configDefault`, `saveConfigFile` undefined. |
| + |
| +- [ ] **Step 4: Implement `config.go`:** |
| + |
| +```go |
| +// The .carlos/config file: a settable default console. |
| +// |
| +// Two levels, one shape. ~/.carlos/config is the machine's default; |
| +// .carlos/config at a project root — committed to the repo — pins that |
| +// project to a deployment (the titogo checkout says the Tito stack; a |
| +// checkout with no opinion falls to the global). The nearest .carlos/config |
| +// walking up from the working directory wins. |
| +// |
| +// A resolved default is authoritative: resolution never falls past it to |
| +// "the only credential on this machine". Holding only a flagship credential |
| +// inside a checkout pinned to another stack is an error naming that stack — |
| +// never a quiet ship to the flagship. The single-credential convenience |
| +// exists only for the no-default world. |
| +// |
| +// The file is deliberately not a secret and never holds one: nothing from |
| +// ~/.carlos/credentials moves here, ever. It IS repo-controlled input when |
| +// committed, which a malicious PR could repoint — accepted in the spec |
| +// (docs/superpowers/specs/2026-08-04-console-default-resolution-design.md) |
| +// because per-origin credentials mean no token ever crosses consoles, and |
| +// every verb prints the console it is talking to. |
| +package main |
| + |
| +import ( |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| +) |
| + |
| +// defaultConsole is where `carlos auth login` lands when nothing — flag, |
| +// env, project config, global config — names a console: the flagship. Login |
| +// only; no other verb ever assumes it. |
| +const defaultConsole = "https://platform.carlosframework.com" |
| + |
| +// cliConfig is the whole file. Unknown fields are ignored on read and |
| +// dropped on rewrite: the file is additive, and the CLI is its only parser. |
| +type cliConfig struct { |
| + Console string `json:"console"` |
| +} |
| + |
| +// loadConfigFile reads one .carlos/config. A missing file, or one without a |
| +// console, is ("", nil): "no default at this level", and resolution |
| +// continues. A file that cannot be parsed, or names a console |
| +// normalizeConsole refuses, is an ERROR — silently ignoring a typo'd |
| +// config would ship to the wrong console, the exact mistake this file |
| +// exists to prevent. |
| +func loadConfigFile(path string) (string, error) { |
| + raw, err := os.ReadFile(path) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return "", nil |
| + } |
| + if err != nil { |
| + return "", err |
| + } |
| + var cfg cliConfig |
| + if err := json.Unmarshal(raw, &cfg); err != nil { |
| + return "", fmt.Errorf("%s is not readable as a carlos config: %w", path, err) |
| + } |
| + if strings.TrimSpace(cfg.Console) == "" { |
| + return "", nil |
| + } |
| + n, err := normalizeConsole(cfg.Console) |
| + if err != nil { |
| + return "", fmt.Errorf("%s: %w", path, err) |
| + } |
| + return n, nil |
| +} |
| + |
| +// configDefault resolves the configured default console: the nearest |
| +// .carlos/config walking up from the working directory, else |
| +// ~/.carlos/config. Returns the normalized console and the file it came |
| +// from — the source goes into error messages, because "not logged in to X" |
| +// is only actionable if you know WHICH file said X. |
| +// |
| +// If the walk passes through $HOME it finds ~/.carlos/config by the same |
| +// rule — the global default arriving one step early, same answer. The |
| +// explicit global check after the walk is for working directories outside |
| +// $HOME entirely. |
| +func configDefault() (console, source string, err error) { |
| + if dir, werr := os.Getwd(); werr == nil { |
| + for { |
| + path := filepath.Join(dir, ".carlos", "config") |
| + c, err := loadConfigFile(path) |
| + if err != nil { |
| + return "", "", err |
| + } |
| + if c != "" { |
| + return c, path, nil |
| + } |
| + parent := filepath.Dir(dir) |
| + if parent == dir { |
| + break |
| + } |
| + dir = parent |
| + } |
| + } |
| + path, perr := globalConfigPath() |
| + if perr != nil { |
| + // No home directory means no global config — a state, not an error. |
| + return "", "", nil |
| + } |
| + c, err := loadConfigFile(path) |
| + if err != nil { |
| + return "", "", err |
| + } |
| + if c == "" { |
| + return "", "", nil |
| + } |
| + return c, path, nil |
| +} |
| + |
| +// globalConfigPath is ~/.carlos/config — the same directory that holds |
| +// credentials, though this file is not a secret and never holds one. |
| +func globalConfigPath() (string, error) { |
| + home, err := os.UserHomeDir() |
| + if err != nil { |
| + return "", fmt.Errorf("no home directory for ~/.carlos/config: %w", err) |
| + } |
| + return filepath.Join(home, ".carlos", "config"), nil |
| +} |
| + |
| +// saveConfigFile validates the console and writes {"console": …} at path, |
| +// creating the .carlos directory (dirPerm: 0700 for ~/.carlos, which |
| +// MkdirAll leaves alone if it already exists; 0755 for a project directory |
| +// that is meant to be committed). Temp-file-then-rename like |
| +// credentialFile.save, so a crash cannot leave a truncated config — but |
| +// 0644, not 0600: this file is not a secret. |
| +func saveConfigFile(path, console string, dirPerm os.FileMode) error { |
| + n, err := normalizeConsole(console) |
| + if err != nil { |
| + return err |
| + } |
| + dir := filepath.Dir(path) |
| + if err := os.MkdirAll(dir, dirPerm); err != nil { |
| + return err |
| + } |
| + body, err := json.MarshalIndent(cliConfig{Console: n}, "", " ") |
| + if err != nil { |
| + return err |
| + } |
| + body = append(body, '\n') |
| + tmp, err := os.CreateTemp(dir, ".config-*") |
| + if err != nil { |
| + return err |
| + } |
| + defer os.Remove(tmp.Name()) |
| + if err := tmp.Chmod(0o644); err != nil { |
| + tmp.Close() |
| + return err |
| + } |
| + if _, err := tmp.Write(body); err != nil { |
| + tmp.Close() |
| + return err |
| + } |
| + if err := tmp.Close(); err != nil { |
| + return err |
| + } |
| + return os.Rename(tmp.Name(), path) |
| +} |
| +``` |
| + |
| +- [ ] **Step 5: Run the tests to verify they pass.** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -run 'TestLoadConfigFile|TestConfigDefaultWalk|TestSaveConfigFile' -count=1 -v` |
| +Expected: PASS (3 tests). |
| + |
| +- [ ] **Step 6: Full check and commit.** |
| + |
| +Run: `go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l config.go config_test.go auth_test.go` (empty gofmt output) |
| + |
| +```bash |
| +git add config.go config_test.go auth_test.go |
| +git commit -m "auth: .carlos/config default-console file and lookup walk" |
| +``` |
| + |
| +--- |
| + |
| +### Task 2: pick() honors the configured default, authoritatively |
| + |
| +**Files:** |
| +- Modify: `auth.go:239-281` (`pick`) |
| +- Modify: `config_test.go` (add tests) |
| + |
| +**Interfaces:** |
| +- Consumes: `configDefault()` from Task 1. |
| +- Produces: `pick`'s signature is unchanged (`func (f *credentialFile) pick(console string) (*credential, error)`); every existing caller (`loggedInCredential`, and through it apimode/accounts/promoteActor) picks the new chain up with no change. |
| + |
| +**Hard constraint:** the no-credentials message must remain byte-identical — `"not logged in — run: carlos auth login -console <url>"` — because `apimode.go`'s `noCredentialsMsg` matches it exactly. It is only reachable when no default is configured, so it needs no new wording. |
| + |
| +- [ ] **Step 1: Write the failing tests** (append to `config_test.go`): |
| + |
| +```go |
| +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") |
| + } |
| +} |
| +``` |
| + |
| +Also add `"time"` to `config_test.go`'s imports. |
| + |
| +- [ ] **Step 2: Run the tests to verify they fail.** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -run 'TestPickHonorsConfigDefault|TestConfigDefaultIsAuthoritative' -count=1` |
| +Expected: FAIL — pick ignores config (wrong credential / missing hint / no refusal). |
| + |
| +- [ ] **Step 3: Replace `pick` in `auth.go`** (the whole function, currently auth.go:239-281): |
| + |
| +```go |
| +// 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 { |
| + return nil, err |
| + } |
| + c := f.find(want) |
| + if c == nil { |
| + 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%s expired %s — run: carlos auth login -console %s", |
| + want, from, c.ExpiresAt.Format(time.RFC3339), want) |
| + } |
| + return c, nil |
| + } |
| + var live []*credential |
| + for i := range f.Credentials { |
| + if !f.Credentials[i].Expired(time.Now()) { |
| + live = append(live, &f.Credentials[i]) |
| + } |
| + } |
| + switch len(live) { |
| + 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, "+ |
| + "or set a default with `carlos auth default <url>`: %s", |
| + strings.Join(names, ", ")) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run the tests to verify they pass — including the pre-existing ones.** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -count=1` |
| +Expected: PASS. `TestCredentialSelection` (asserts the multi refusal contains `-console`) and the apimode tests (exact `noCredentialsMsg`) must still pass untouched — if either fails, the wording drifted; fix the wording, not the test. |
| + |
| +- [ ] **Step 5: Full check and commit.** |
| + |
| +Run: `go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l auth.go config_test.go` |
| + |
| +```bash |
| +git add auth.go config_test.go |
| +git commit -m "auth: pick() honors the configured default console, authoritatively" |
| +``` |
| + |
| +--- |
| + |
| +### Task 3: auth login resolves through the chain to the flagship |
| + |
| +**Files:** |
| +- Modify: `auth.go:536-548` (`authLogin` — flag definition and the "which console?" refusal) |
| +- Modify: `auth_test.go:336-342` (`TestAuthLoginNeedsAConsole` — replaced) |
| +- Modify: `config_test.go` (add login tests) |
| + |
| +**Interfaces:** |
| +- Consumes: `configDefault()`, `defaultConsole` from Task 1. |
| +- Produces: `func loginConsole(flagValue string) (string, error)` — the login-only resolution chain, factored out so the flagship fallback is testable without any network. `authLogin` calls it; nothing else does. |
| + |
| +- [ ] **Step 1: Write the failing tests.** Delete `TestAuthLoginNeedsAConsole` from `auth_test.go` (its premise — refusing when no `-console` — is the behavior being removed). Append to `config_test.go`: |
| + |
| +```go |
| +// 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) |
| + } |
| +} |
| +``` |
| + |
| +Add `"bytes"` to `config_test.go`'s imports. (`newFakeConsole` serves plain `http://127.0.0.1:…`, which `normalizeConsole` allows as loopback — that is why the end-to-end test works unchanged.) |
| + |
| +- [ ] **Step 2: Run the tests to verify they fail.** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -run 'TestLoginConsoleChain|TestAuthLoginUsesConfiguredDefault' -count=1` |
| +Expected: compile FAILURE — `loginConsole` undefined. |
| + |
| +- [ ] **Step 3: Implement.** In `auth.go`, add `loginConsole` above `authLogin`, and rewrite `authLogin`'s opening. The flag currently reads `fs.String("console", os.Getenv("CARLOS_CONSOLE"), …)` — the env moves into the chain so each source is checked in order and only once. |
| + |
| +```go |
| +// 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) |
| +} |
| +``` |
| + |
| +Then in `authLogin`, replace this (current auth.go:539 and 544-551): |
| + |
| +```go |
| + console := fs.String("console", os.Getenv("CARLOS_CONSOLE"), "the console to log in to (https://…)") |
| +``` |
| +```go |
| + 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) |
| + if err != nil { |
| + return err |
| + } |
| +``` |
| + |
| +with: |
| + |
| +```go |
| + console := fs.String("console", "", "the console to log in to (default: the configured default, else "+defaultConsole+")") |
| +``` |
| +```go |
| + base, err := loginConsole(*console) |
| + if err != nil { |
| + return err |
| + } |
| +``` |
| + |
| +(`normalizeConsole` runs inside `loginConsole`; do not call it twice.) |
| + |
| +- [ ] **Step 4: Run the tests to verify they pass.** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -count=1` |
| +Expected: PASS, whole package — the existing login/denied/permission tests must be untouched by the rewrite. |
| + |
| +- [ ] **Step 5: Full check and commit.** |
| + |
| +Run: `go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l auth.go auth_test.go config_test.go` |
| + |
| +```bash |
| +git add auth.go auth_test.go config_test.go |
| +git commit -m "auth: login resolves through config defaults to the flagship" |
| +``` |
| + |
| +--- |
| + |
| +### Task 4: the `carlos auth default` sub-verb |
| + |
| +**Files:** |
| +- Modify: `auth.go:51-94` (`authUsage`, `RunAuth`) |
| +- Modify: `auth.go` (add `authDefault` after `authLogout`) |
| +- Modify: `config_test.go` (add verb test) |
| + |
| +**Interfaces:** |
| +- Consumes: `configDefault()`, `globalConfigPath()`, `saveConfigFile()`, `defaultConsole` (Task 1); `normalizeConsole` (existing). |
| +- Produces: `func authDefault(out io.Writer, args []string) error`, dispatched from `RunAuth` as `case "default"`. |
| + |
| +- [ ] **Step 1: Write the failing test** (append to `config_test.go`): |
| + |
| +```go |
| +func TestAuthDefaultVerb(t *testing.T) { |
| + useTempCredentials(t) |
| + cwd, err := os.Getwd() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var out bytes.Buffer |
| + |
| + // Nothing configured: the report explains the built-in behavior rather |
| + // than printing an empty string. |
| + if err := authDefault(&out, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, want := range []string{"no default", defaultConsole, "carlos auth default"} { |
| + if !strings.Contains(out.String(), want) { |
| + t.Fatalf("bare report missing %q:\n%s", want, out.String()) |
| + } |
| + } |
| + |
| + // Set the global; the value is normalized on write and read back with |
| + // its source. |
| + out.Reset() |
| + if err := authDefault(&out, []string{"https://global.example/"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + out.Reset() |
| + if err := authDefault(&out, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !strings.Contains(out.String(), "https://global.example") || |
| + !strings.Contains(out.String(), filepath.Join(".carlos", "config")) { |
| + t.Fatalf("report after set:\n%s", out.String()) |
| + } |
| + |
| + // -project writes ./.carlos/config, which then overrides the global. |
| + out.Reset() |
| + if err := authDefault(&out, []string{"-project", "https://project.example"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := os.Stat(filepath.Join(cwd, ".carlos", "config")); err != nil { |
| + t.Fatalf("-project did not write ./.carlos/config: %v", err) |
| + } |
| + out.Reset() |
| + if err := authDefault(&out, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !strings.Contains(out.String(), "https://project.example") { |
| + t.Fatalf("project default not effective:\n%s", out.String()) |
| + } |
| + |
| + // A bad URL is refused at set time, and -project with nothing to set |
| + // is a usage error, not a silent no-op. |
| + if err := authDefault(&out, []string{"ftp://nope"}); err == nil { |
| + t.Fatal("setting an invalid console must fail") |
| + } |
| + if err := authDefault(&out, []string{"-project"}); err == nil { |
| + t.Fatal("-project with no URL must be a usage error") |
| + } |
| + if err := authDefault(&out, []string{"a", "b"}); err == nil { |
| + t.Fatal("two positional args must be a usage error") |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run the test to verify it fails.** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -run TestAuthDefaultVerb -count=1` |
| +Expected: compile FAILURE — `authDefault` undefined. |
| + |
| +- [ ] **Step 3: Implement.** Add after `authLogout` in `auth.go`: |
| + |
| +```go |
| +// authDefault shows or sets the default console. Bare: report the effective |
| +// configured default and which file said so (flag and env are not consulted |
| +// — this verb reports CONFIG, and someone with CARLOS_CONSOLE exported can |
| +// see their own shell). With a URL: write ~/.carlos/config, or |
| +// ./.carlos/config under -project — relative to the working directory, |
| +// deliberately not "the repo root": the CLI has no VCS awareness, and |
| +// "run it where you want the file" beats guessing. |
| +func authDefault(out io.Writer, args []string) error { |
| + fs := flag.NewFlagSet("auth default", flag.ContinueOnError) |
| + fs.SetOutput(out) |
| + project := fs.Bool("project", false, "write ./.carlos/config (this directory) instead of ~/.carlos/config") |
| + if err := fs.Parse(args); err != nil { |
| + return err |
| + } |
| + switch fs.NArg() { |
| + case 0: |
| + if *project { |
| + return errors.New("-project sets a default: carlos auth default -project <url>") |
| + } |
| + console, src, err := configDefault() |
| + if err != nil { |
| + return err |
| + } |
| + if console == "" { |
| + fmt.Fprintf(out, "no default console configured.\n") |
| + fmt.Fprintf(out, "`carlos auth login` defaults to %s; other commands use the\n", defaultConsole) |
| + fmt.Fprintf(out, "only stored credential, or ask when there are several.\n") |
| + fmt.Fprintf(out, "Set one with: carlos auth default <url> (-project pins this directory)\n") |
| + return nil |
| + } |
| + fmt.Fprintf(out, "%s (from %s)\n", console, src) |
| + return nil |
| + case 1: |
| + path, err := globalConfigPath() |
| + perm := os.FileMode(0o700) // ~/.carlos also holds credentials |
| + if *project { |
| + path, err = filepath.Join(".carlos", "config"), nil |
| + perm = 0o755 // meant to be committed; nothing secret inside |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + if err := saveConfigFile(path, fs.Arg(0), perm); err != nil { |
| + return err |
| + } |
| + // Echo the NORMALIZED value: what was saved, not what was typed. |
| + console, err := normalizeConsole(fs.Arg(0)) |
| + if err != nil { |
| + return err |
| + } |
| + fmt.Fprintf(out, "default console: %s (written to %s)\n", console, path) |
| + return nil |
| + default: |
| + return errors.New("usage: carlos auth default [-project] [<url>]") |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Wire the dispatch and the usage text.** In `RunAuth` (auth.go:79-89) add a case alongside the others: |
| + |
| +```go |
| + case "default": |
| + err = authDefault(os.Stdout, args[1:]) |
| +``` |
| + |
| +Replace `authUsage` (auth.go:51-70) with — note the HEADLESS paragraph is verbatim from today: |
| + |
| +```go |
| +const authUsage = `usage: carlos auth <login|whoami|logout|default> |
| + |
| + login [-console <url>] log this terminal in to a deployment's console |
| + (default: the configured default console, else |
| + https://platform.carlosframework.com). Prints a |
| + code; you approve it in a browser that is already |
| + signed in to that console. No browser on this |
| + machine is needed — the code travels. |
| + [-label <name>] what to call this terminal on the console's |
| + command-line access page (default: user@host) |
| + whoami [-console <url>] ask the console who this terminal is |
| + logout [-console <url>] revoke this terminal's token and forget it |
| + default [-project] [<url>] |
| + show the default console and where it is set, or |
| + set it: writes ~/.carlos/config — or, with |
| + -project, ./.carlos/config, which overrides the |
| + global and is meant to be committed so a checkout |
| + pins its own deployment |
| + |
| +The token lands in ~/.carlos/credentials (mode 0600), one entry per console |
| +origin — a laptop can hold credentials for the flagship and a self-hosted |
| +deployment at once. -console may be omitted when there is exactly one, or |
| +when a default is set. |
| + |
| +HEADLESS AND BOX-SIDE USE: do not log in. A box has no browser and no human, |
| +and a long-lived token on it is a standing credential nobody is watching. |
| +Pass -owner <address> to "carlos accounts create" instead — that is the |
| +documented path, and it needs no identity of its own. |
| +` |
| +``` |
| + |
| +- [ ] **Step 5: Run the tests to verify they pass.** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -count=1` |
| +Expected: PASS, whole package. |
| + |
| +- [ ] **Step 6: Full check and commit.** |
| + |
| +Run: `go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l auth.go config_test.go` |
| + |
| +```bash |
| +git add auth.go config_test.go |
| +git commit -m "auth: carlos auth default shows and sets the default console" |
| +``` |
| + |
| +--- |
| + |
| +### Task 5: whole-tree verification |
| + |
| +**Files:** none created — this is the gate the repo requires before the branch is done. |
| + |
| +- [ ] **Step 1: Run everything, all 14 packages:** |
| + |
| +```bash |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 |
| +gofmt -l config.go config_test.go auth.go auth_test.go |
| +``` |
| + |
| +Expected: all clean; gofmt prints nothing. Pay attention to `apimode_test.go` and `internal/release/laddercheck_test.go` — both pin exact strings/channels and must pass unmodified. |
| + |
| +- [ ] **Step 2: Confirm no test file was weakened.** `git diff main -- auth_test.go` must show only: the `useTempCredentials` hardening (Task 1) and the deletion of `TestAuthLoginNeedsAConsole` (Task 3, behavior removed by design). Any other loosened assertion is a defect. |
| + |
| +- [ ] **Step 3: Commit anything outstanding** (there should be nothing) and report the branch ready for review. |