Merge pull request #51 from carlosframework/worktree-console-default-config
carlos: settable default consoles (~/.carlos/config + per-project .carlos/config)
10 files changed,
+1897
−78
CLAUDE.md+6 −4auth.go+132 −22auth_e2e_test.go+42 −12auth_test.go+9 −10config.go+161 −0config_test.go+327 −0docs/superpowers/plans/2026-08-04-console-default-resolution.md+939 −0docs/superpowers/specs/2026-08-04-console-default-resolution-design.md+162 −0e2e_test.go+99 −22scopedkeys_e2e_test.go+20 −8
diff --git a/CLAUDE.md b/CLAUDE.md| index c1c195707..363f011c8 100644 |
| --- a/CLAUDE.md |
| +++ b/CLAUDE.md |
| @@ -50,10 +50,12 @@ capable model — exact values, file paths and interfaces go in the brief. |
| "verified" reports in one day. |
| - Icons are vendored Lucide (in-code SVG constants, never CDN); emoji is |
| content, icons are chrome. |
| -- **A test that shells out to the carlos binary must set `CARLOS_CREDENTIALS` |
| - into its tempdir.** The fallback is the operator's real login; a logged-in |
| - laptop running `go test ./...` minted 40+ real accounts on the production |
| - console before this was isolated (`carlosTestEnv`). |
| +- **A test that shells out to the carlos binary must build its env through |
| + `carlosTestEnv`** (masks `CARLOS_CREDENTIALS`, `HOME`, `CARLOS_CONSOLE`) |
| + **and set `cmd.Dir` to a scratch dir** — the binary walks cwd ancestors for |
| + `.carlos/config`. The fallback is the operator's real login and default |
| + console; a logged-in laptop running `go test ./...` minted 40+ real accounts |
| + on the production console before this was isolated. |
| ## Deploy reality (2026-08-02) |
diff --git a/auth.go b/auth.go| index fba2b8a39..e8053f778 100644 |
| --- a/auth.go |
| +++ b/auth.go |
| @@ -48,20 +48,29 @@ import ( |
| "time" |
| ) |
| -const authUsage = `usage: carlos auth <login|whoami|logout> |
| - |
| - login -console <url> log this terminal in to a deployment's console. |
| - 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. |
| +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. |
| +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. |
| @@ -83,6 +92,8 @@ func RunAuth(args []string) { |
| err = authWhoami(os.Stdout, args[1:]) |
| case "logout": |
| err = authLogout(os.Stdout, args[1:]) |
| + case "default": |
| + err = authDefault(os.Stdout, args[1:]) |
| default: |
| fmt.Fprint(os.Stderr, authUsage) |
| os.Exit(1) |
| @@ -236,14 +247,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 +285,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 +303,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, ", ")) |
| } |
| } |
| @@ -533,20 +569,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 |
| } |
| @@ -717,6 +770,63 @@ func authLogout(out io.Writer, args []string) error { |
| return nil |
| } |
| +// 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>]") |
| + } |
| +} |
| + |
| // ---- what other verbs use --------------------------------------------------- |
| // loggedInCredential is the seam every other verb reaches through: the |
diff --git a/auth_e2e_test.go b/auth_e2e_test.go| index c1d0bb82e..b743d1e9b 100644 |
| --- a/auth_e2e_test.go |
| +++ b/auth_e2e_test.go |
| @@ -48,25 +48,41 @@ func TestAuthCLI(t *testing.T) { |
| fc := newFakeConsole(t) |
| home := t.TempDir() |
| creds := filepath.Join(home, ".carlos", "credentials") |
| - env := append(os.Environ(), "CARLOS_CREDENTIALS="+creds) |
| + env := append(os.Environ(), |
| + "CARLOS_CREDENTIALS="+creds, |
| + "HOME="+home, |
| + "CARLOS_CONSOLE=", |
| + ) |
| run := func(args ...string) (string, error) { |
| cmd := exec.Command(bin, args...) |
| cmd.Env = env |
| + // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. |
| + cmd.Dir = home |
| out, err := cmd.CombinedOutput() |
| return string(out), err |
| } |
| - // A login with no console at all refuses, and names both ways out. |
| - out, err := run("auth", "login") |
| - if err == nil { |
| - t.Fatalf("carlos auth login with no -console exited 0:\n%s", out) |
| + // A login with no console anywhere now lands on the BUILT-IN default — |
| + // the production flagship — which this test must never talk to. Prove |
| + // the default chain is consulted without crossing the network: a config |
| + // default in the scratch home points at the fake console, and login |
| + // with no -console follows it. |
| + if err := os.MkdirAll(filepath.Join(home, ".carlos"), 0o755); err != nil { |
| + t.Fatal(err) |
| } |
| - if !strings.Contains(out, "-console") || !strings.Contains(out, "-owner") { |
| - t.Fatalf("refusal does not name both options:\n%s", out) |
| + if err := os.WriteFile(filepath.Join(home, ".carlos", "config"), |
| + []byte(`{"console": "`+fc.ts.URL+`"}`+"\n"), 0o644); err != nil { |
| + t.Fatal(err) |
| } |
| - |
| fc.approved.Store(true) |
| + out, err := run("auth", "login", "-label", "e2e@box") |
| + if err != nil { |
| + t.Fatalf("carlos auth login via config default: %v\n%s", err, out) |
| + } |
| + if !strings.Contains(out, fc.ts.URL) { |
| + t.Fatalf("login did not follow the config default to the fake console:\n%s", out) |
| + } |
| out, err = run("auth", "login", "-console", fc.ts.URL, "-label", "e2e@box") |
| if err != nil { |
| t.Fatalf("carlos auth login: %v\n%s", err, out) |
| @@ -116,9 +132,13 @@ func TestAccountsCreateNeedsAnOwner(t *testing.T) { |
| "CARLOS_DEPLOYMENT_DIR="+filepath.Join(tmp, "store"), |
| "CARLOS_CREDENTIALS="+filepath.Join(tmp, "credentials"), |
| "CARLOS_DATA_DIR="+tmp, |
| + "HOME="+tmp, |
| + "CARLOS_CONSOLE=", |
| ) |
| cmd := exec.Command(bin, "accounts", "create", "-name", "orphan") |
| cmd.Env = env |
| + // pick() 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("ownerless accounts create exited 0:\n%s", out) |
| @@ -142,13 +162,14 @@ func TestAccountsCreateRefusesANonSlugName(t *testing.T) { |
| bin := buildCarlos(t) |
| tmp := t.TempDir() |
| storeDir := filepath.Join(tmp, "store") |
| - env := append(os.Environ(), |
| + env := carlosTestEnv(t, |
| "CARLOS_DEPLOYMENT_DIR="+storeDir, |
| - "CARLOS_CREDENTIALS="+filepath.Join(tmp, "credentials"), |
| "CARLOS_DATA_DIR="+tmp, |
| ) |
| cmd := exec.Command(bin, "accounts", "create", "-name", "Paul's Stuff!", "-owner", "paul@keymail.dev") |
| cmd.Env = env |
| + // pick() 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("non-slug accounts create exited 0:\n%s", out) |
| @@ -174,7 +195,11 @@ func TestAccountsCreateWithOwnerFlag(t *testing.T) { |
| "CARLOS_DEPLOYMENT_DIR="+storeDir, |
| "CARLOS_CREDENTIALS="+filepath.Join(tmp, "credentials"), |
| "CARLOS_DATA_DIR="+tmp, |
| + "HOME="+tmp, |
| + "CARLOS_CONSOLE=", |
| ) |
| + // pick() 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 accounts create -owner: %v\n%s", err, out) |
| @@ -210,11 +235,12 @@ func TestAccountsCreateTrimsTheNameBeforeMinting(t *testing.T) { |
| tmp := t.TempDir() |
| storeDir := filepath.Join(tmp, "store") |
| cmd := exec.Command(bin, "accounts", "create", "-name", " kass ", "-owner", "paul@keymail.dev") |
| - cmd.Env = append(os.Environ(), |
| + cmd.Env = carlosTestEnv(t, |
| "CARLOS_DEPLOYMENT_DIR="+storeDir, |
| - "CARLOS_CREDENTIALS="+filepath.Join(tmp, "credentials"), |
| "CARLOS_DATA_DIR="+tmp, |
| ) |
| + // pick() 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 accounts create -name ' kass ': %v\n%s", err, out) |
| @@ -259,9 +285,13 @@ func TestAccountsCreateUsesTheLoggedInIdentity(t *testing.T) { |
| cmd.Env = append(os.Environ(), |
| "CARLOS_CREDENTIALS="+creds, |
| "CARLOS_DATA_DIR="+tmp, |
| + "HOME="+tmp, |
| + "CARLOS_CONSOLE=", |
| // Deliberately no CARLOS_DEPLOYMENT_DIR: the console owns the bucket |
| // write on this path, and a laptop has no deployment store configured. |
| ) |
| + // pick() 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 accounts create (logged in): %v\n%s", err, out) |
diff --git a/auth_test.go b/auth_test.go| index ae735e5df..bb8a50c74 100644 |
| --- a/auth_test.go |
| +++ b/auth_test.go |
| @@ -13,10 +13,17 @@ import ( |
| "time" |
| ) |
| -// useTempCredentials points the credentials file at a scratch path for the |
| -// length of one test. |
| +// 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 |
| @@ -333,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.go b/config.go| new file mode 100644 |
| index 000000000..b11c3171c |
| --- /dev/null |
| +++ b/config.go |
| @@ -0,0 +1,161 @@ |
| +// 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) |
| +} |
diff --git a/config_test.go b/config_test.go| new file mode 100644 |
| index 000000000..4e835c085 |
| --- /dev/null |
| +++ b/config_test.go |
| @@ -0,0 +1,327 @@ |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// 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()) |
| + } |
| +} |
| + |
| +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") |
| + } |
| +} |
| + |
| +// 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) |
| + } |
| +} |
| + |
| +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") |
| + } |
| +} |
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. |
diff --git a/docs/superpowers/specs/2026-08-04-console-default-resolution-design.md b/docs/superpowers/specs/2026-08-04-console-default-resolution-design.md| new file mode 100644 |
| index 000000000..05b164552 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-04-console-default-resolution-design.md |
| @@ -0,0 +1,162 @@ |
| +# Console default resolution for the carlos CLI |
| + |
| +**Date:** 2026-08-04 |
| +**Status:** Approved (Paul, 2026-08-04 session) |
| + |
| +## Problem |
| + |
| +`carlos auth login` with no `-console` and no `CARLOS_CONSOLE` refuses and |
| +makes you retype `https://platform.carlosframework.com` — a URL the error |
| +message itself already contains. Meanwhile every other verb resolves its |
| +console through `pick()` (auth.go): explicit flag → env → the only live |
| +credential → a refusal when there are several. There is no way to say "in |
| +this checkout, the console is the Tito stack" or "on this machine, the |
| +default is the flagship" — someone permanently multi-homed (flagship + a |
| +self-hosted deployment) types `-console` forever. |
| + |
| +## Rules (what the user asked for, verbatim in spirit) |
| + |
| +1. **One credential → never specify.** Unchanged from today. |
| +2. **Several credentials and no default set → always specify.** Unchanged |
| + refusal; the message additionally suggests setting a default. |
| +3. **A default is settable at two levels**: global (`~/.carlos/config`) for |
| + everything, and per-project (`.carlos/config` at the project root, |
| + committed to the repo) which overrides the global. The titogo checkout |
| + defaults to the Tito stack; everywhere else defaults to the flagship. |
| + |
| +## Resolution order |
| + |
| +For **every** verb that talks to a console (`ship`, `promote`, `accounts`, |
| +`auth whoami`, `auth logout`, …): |
| + |
| + -console flag |
| + → CARLOS_CONSOLE env |
| + → nearest .carlos/config walking up from cwd |
| + → ~/.carlos/config |
| + → the only live credential |
| + → refuse: "logged in to several consoles — say which with -console, |
| + or set a default with: carlos auth default <url>" |
| + |
| +`carlos auth login` uses the same chain but, having no credential to fall |
| +back on, ends differently: |
| + |
| + … → ~/.carlos/config |
| + → built-in https://platform.carlosframework.com |
| + |
| +The HEADLESS warning in the usage text stays: a default makes it easier to |
| +accidentally log in on a box, so the "don't log in on boxes — use -owner" |
| +note earns its keep more, not less. |
| + |
| +### A set default is authoritative — no fallback past it |
| + |
| +If a config default resolves, the single-credential auto-pick is **not** |
| +consulted. Holding only a flagship credential inside the titogo checkout |
| +(whose `.carlos/config` says the Tito stack), `carlos ship` errors |
| +"not logged in to <tito console> — run: carlos auth login …" rather than |
| +quietly shipping titogo to the flagship. A default is explicit intent; the |
| +single-credential rule exists only for the no-default world. |
| + |
| +The same holds for `CARLOS_CONSOLE` and `-console` today, so the invariant |
| +is uniform: **any explicitly named console resolves against credentials for |
| +that console or errors — it never silently substitutes another.** |
| + |
| +## The config file |
| + |
| +One layout at both levels: a `.carlos` **directory** containing a `config` |
| +file. `~/.carlos` already exists (it holds `credentials`); projects get the |
| +same shape. One lookup rule, no file-vs-directory branching, and room for a |
| +future gitignored `.carlos/local` without a migration. |
| + |
| +`config` is JSON: |
| + |
| + { |
| + "console": "https://carlos-tito-ie.example" |
| + } |
| + |
| +- Unknown fields are ignored (additive evolution, per the wire-shape rule — |
| + though this file is new, so nothing existing parses it). |
| +- A missing file, or a file without `"console"`, means "no default at this |
| + level" and resolution continues down the chain. |
| +- A **malformed** file (unparseable JSON) is an error, not a skip: silently |
| + ignoring a typo'd project config would ship to the wrong console — the |
| + exact mistake this feature exists to prevent. |
| +- The stored value is normalized on read the same way `-console` is |
| + (`normalizeConsole`: https, or http only for loopback; scheme+host only). |
| + A value that fails normalization is an error, same as malformed JSON. |
| + |
| +### Project lookup walk |
| + |
| +From cwd upward to the filesystem root, the first directory containing |
| +`.carlos/config` wins. If the walk passes through `$HOME`, it finds |
| +`~/.carlos/config` by the same rule — which is simply the global default |
| +arriving one step early; behavior is identical. If the walk finds nothing, |
| +`~/.carlos/config` is consulted explicitly (projects outside `$HOME`). |
| + |
| +## Setting and inspecting: `carlos auth default` |
| + |
| + carlos auth default print the effective default and |
| + where it came from (flag/env are |
| + not shown — this reports config) |
| + carlos auth default <url> write ~/.carlos/config |
| + carlos auth default -project <url> write ./.carlos/config (cwd) |
| + |
| +- URLs are normalized and validated on **write** (same `normalizeConsole` |
| + rule), so a bad default is caught when set, not at first use. |
| +- `-project` writes to `./.carlos/config` relative to cwd — deliberately not |
| + "the repo root", because the CLI has no VCS awareness and guessing a root |
| + is worse than the documented rule "run it where you want the file". |
| +- Writes create the `.carlos` directory (0755 for a project, `~/.carlos` |
| + stays 0700) and rewrite `config` wholesale via the same |
| + temp-file-then-rename pattern as `credentials.save()`. `config` holds no |
| + secret; 0644 is fine. |
| +- Removing a default: delete the file, or edit it — it is one line of JSON. |
| + No `-clear` flag until someone actually wants one (YAGNI). |
| +- With no config anywhere, `carlos auth default` prints that the built-in |
| + login default is the flagship and that other verbs use the |
| + single-credential rule. |
| + |
| +## Security notes (accepted, recorded) |
| + |
| +- **A committed `.carlos/config` is repo-controlled input.** A malicious PR |
| + could repoint a project at a *different console the operator is logged in |
| + to*. Per-origin credentials contain the blast radius — no token is ever |
| + sent to a console other than the one it was issued by — and every verb |
| + prints the console it is talking to. No extra ceremony (no trust prompt, |
| + no allowlist): the mitigation is per-origin credentials plus visibility. |
| +- **`config` is not a secret** and lives world-readable in repos by design. |
| + Nothing from `credentials` moves into it, ever. |
| +- The device-code phishing stance (bare URI + typed code, never the |
| + prefilled link) is untouched. |
| + |
| +## What changes in code |
| + |
| +- `auth.go pick()` grows the two config steps between env and |
| + single-credential, and the authoritative-default rule. |
| +- `auth.go authLogin()` replaces its "which console?" error with the chain |
| + plus the built-in flagship fallback. |
| +- New: config load/save (one small file, likely `config.go`), the walk, and |
| + the `default` sub-verb in `RunAuth` + `authUsage`. |
| +- `credentials` file: **untouched**. No wire-shape change anywhere; the |
| + console API is untouched. |
| + |
| +Consumer check (the "everything that PARSES the shape" rule): the config |
| +file is new — its only parser is the CLI itself. No other component reads |
| +`~/.carlos` except the CLI's own credentials code, which does not change. |
| + |
| +## Testing |
| + |
| +- Resolution order: each link in the chain wins over everything below it, |
| + for `login` and for `pick()`-based verbs (table test with temp dirs and |
| + `CARLOS_CREDENTIALS`/`HOME`/cwd control). |
| +- Authoritative default: project default set + only credential is for a |
| + different console → error naming the default console, not a silent pick. |
| +- Walk: nested cwd finds an ancestor's `.carlos/config`; nearer beats |
| + farther; `$HOME` reached mid-walk equals global. |
| +- Malformed config and non-normalizable URL → error, not skip. |
| +- `auth default` round-trip: write global, write `-project`, print shows |
| + value and source path. |
| +- Login with nothing configured anywhere → flagship URL in the printed |
| + output (no network in the test; assert on the start-request target via a |
| + fake server or on the printed base). |
| +- Existing multi-credential refusal still fires when no default is set, with |
| + the new set-a-default hint. |
diff --git a/e2e_test.go b/e2e_test.go| index 4e992452a..7bb355fbc 100644 |
| --- a/e2e_test.go |
| +++ b/e2e_test.go |
| @@ -63,20 +63,29 @@ func e2eGet(t *testing.T, base, host, path string) (int, string) { |
| return resp.StatusCode, string(b) |
| } |
| -// carlosTestEnv is os.Environ() with the operator's real login masked: |
| -// CARLOS_CREDENTIALS points into the test's own tempdir, where no |
| -// credential exists. Every subprocess that runs the carlos binary MUST |
| -// build its env through this. A test that inherits the real |
| -// ~/.carlos/credentials is one `go test ./...` on a logged-in laptop away |
| -// from writing to a production console — which is exactly how 40+ test |
| -// accounts got minted on the flagship (2026-08-04). |
| +// carlosTestEnv is os.Environ() with the operator's real login and console |
| +// state masked: CARLOS_CREDENTIALS points into the test's own tempdir where |
| +// no credential exists, HOME is a scratch dir (the binary reads |
| +// ~/.carlos/config for the default console), and CARLOS_CONSOLE is cleared. |
| +// Every subprocess that runs the carlos binary MUST build its env through |
| +// this AND set cmd.Dir to a scratch dir — the binary walks cwd ancestors |
| +// for .carlos/config, so a subprocess running from the real checkout reads |
| +// the developer's real config the moment one exists. A test that inherits |
| +// the real ~/.carlos is one `go test ./...` on a logged-in laptop away from |
| +// writing to a production console — which is exactly how 40+ test accounts |
| +// got minted on the flagship (2026-08-04). |
| // |
| // Duplicated in scopedkeys_e2e_test.go: this file is package main_test (an |
| // external test package), that one is package main, and the two cannot |
| // share an unexported helper across the package boundary. |
| func carlosTestEnv(t *testing.T, extra ...string) []string { |
| t.Helper() |
| - env := append(os.Environ(), "CARLOS_CREDENTIALS="+filepath.Join(t.TempDir(), "credentials")) |
| + scratch := t.TempDir() |
| + env := append(os.Environ(), |
| + "CARLOS_CREDENTIALS="+filepath.Join(scratch, "credentials"), |
| + "HOME="+scratch, |
| + "CARLOS_CONSOLE=", |
| + ) |
| return append(env, extra...) |
| } |
| @@ -497,7 +506,11 @@ func TestStatusCLI(t *testing.T) { |
| if err := os.MkdirAll(dryRunDir, 0o755); err != nil { |
| t.Fatalf("mkdir dry-run dir: %v", err) |
| } |
| - out, err := exec.Command(carlosBin, "status", "-dry-run", dryRunDir).CombinedOutput() |
| + // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. |
| + cmd := exec.Command(carlosBin, "status", "-dry-run", dryRunDir) |
| + cmd.Env = append(os.Environ(), "HOME="+tmp, "CARLOS_CONSOLE=") |
| + cmd.Dir = tmp |
| + out, err := cmd.CombinedOutput() |
| if err != nil { |
| t.Fatalf("carlos status -dry-run %s: %v\n%s", dryRunDir, err, out) |
| } |
| @@ -505,7 +518,10 @@ func TestStatusCLI(t *testing.T) { |
| t.Errorf("carlos status -dry-run output = %q, want it to contain %q", out, "dry-run: no leaks") |
| } |
| - if err := exec.Command(carlosBin, "status").Run(); err == nil { |
| + cmd = exec.Command(carlosBin, "status") |
| + cmd.Env = append(os.Environ(), "HOME="+tmp, "CARLOS_CONSOLE=") |
| + cmd.Dir = tmp |
| + if err := cmd.Run(); err == nil { |
| t.Error("carlos status with no -dry-run flag: want a non-zero exit, got 0") |
| } |
| } |
| @@ -702,11 +718,18 @@ func TestAccountsCLI(t *testing.T) { |
| } |
| storeDir := filepath.Join(tmp, "store") |
| regPath := filepath.Join(tmp, "registry.db") |
| + // The subprocess must never see the operator's real ~/.carlos — a genuine |
| + // login on the developer's machine makes ownerless `accounts create` |
| + // succeed (the logged-in identity becomes the owner, by design) and |
| + // flips this test's answer. carlosTestEnv masks credentials, HOME and |
| + // CARLOS_CONSOLE; cmd.Dir below closes the cwd-walk path. |
| env := carlosTestEnv(t, "CARLOS_DEPLOYMENT_DIR="+storeDir) |
| run := func(args ...string) string { |
| 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) |
| @@ -744,6 +767,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) |
| @@ -761,6 +785,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) |
| } |
| @@ -911,11 +936,16 @@ func TestAccountsMigrateTakesTheLiveObjects(t *testing.T) { |
| } |
| env := carlosTestEnv(t, |
| "CARLOS_DEPLOYMENT_DIR="+storeDir, |
| - "CARLOS_RELEASE_PUBKEY="+pubB64) |
| + "CARLOS_RELEASE_PUBKEY="+pubB64, |
| + "HOME="+tmp, |
| + "CARLOS_CONSOLE=", |
| + ) |
| run := func(args ...string) (string, error) { |
| t.Helper() |
| cmd := exec.Command(carlosBin, args...) |
| cmd.Env = env |
| + // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. |
| + cmd.Dir = tmp |
| out, err := cmd.CombinedOutput() |
| return string(out), err |
| } |
| @@ -1062,11 +1092,16 @@ func TestAccountsMigrateDoesNotCopyTheProductionFlag(t *testing.T) { |
| } |
| env := append(os.Environ(), |
| "CARLOS_DEPLOYMENT_DIR="+storeDir, |
| - "CARLOS_RELEASE_PUBKEY="+pubB64) |
| + "CARLOS_RELEASE_PUBKEY="+pubB64, |
| + "HOME="+tmp, |
| + "CARLOS_CONSOLE=", |
| + ) |
| run := func(args ...string) (string, error) { |
| t.Helper() |
| cmd := exec.Command(carlosBin, args...) |
| cmd.Env = env |
| + // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. |
| + cmd.Dir = tmp |
| out, err := cmd.CombinedOutput() |
| return string(out), err |
| } |
| @@ -1083,7 +1118,7 @@ func TestAccountsMigrateDoesNotCopyTheProductionFlag(t *testing.T) { |
| t.Fatal(err) |
| } |
| - out, err := run("accounts", "create", "-registry", regPath, "-name", "Kass", "-owner", "paul@keymail.dev") |
| + out, err := run("accounts", "create", "-registry", regPath, "-name", "kass", "-owner", "paul@keymail.dev") |
| if err != nil { |
| t.Fatalf("accounts create: %v\n%s", err, out) |
| } |
| @@ -1261,8 +1296,12 @@ func TestAddUnitBackingRequiresDBForHibernate(t *testing.T) { |
| regPath := filepath.Join(tmp, "registry.db") |
| // No -db: refused, non-zero exit, no row written. |
| - out, err := exec.Command(carlosBin, "add", "-registry", regPath, "-app", "kass", |
| - "-backing", "unit", "-hibernate", "no-db.example.com").CombinedOutput() |
| + // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. |
| + cmd := exec.Command(carlosBin, "add", "-registry", regPath, "-app", "kass", |
| + "-backing", "unit", "-hibernate", "no-db.example.com") |
| + cmd.Env = append(os.Environ(), "HOME="+tmp, "CARLOS_CONSOLE=") |
| + cmd.Dir = tmp |
| + out, err := cmd.CombinedOutput() |
| if err == nil { |
| t.Fatalf("add -backing unit -hibernate with no -db: want non-zero exit, got 0\n%s", out) |
| } |
| @@ -1273,8 +1312,11 @@ func TestAddUnitBackingRequiresDBForHibernate(t *testing.T) { |
| // With -db: succeeds, and the route's DBPath is exactly what was given |
| // (ops.FleetDBs reads this verbatim — no default synthesized here). |
| dbPath := filepath.Join(tmp, "unit-state", "app.db") |
| - out, err = exec.Command(carlosBin, "add", "-registry", regPath, "-app", "kass", |
| - "-backing", "unit", "-hibernate", "-db", dbPath, "with-db.example.com").CombinedOutput() |
| + cmd = exec.Command(carlosBin, "add", "-registry", regPath, "-app", "kass", |
| + "-backing", "unit", "-hibernate", "-db", dbPath, "with-db.example.com") |
| + cmd.Env = append(os.Environ(), "HOME="+tmp, "CARLOS_CONSOLE=") |
| + cmd.Dir = tmp |
| + out, err = cmd.CombinedOutput() |
| if err != nil { |
| t.Fatalf("add -backing unit -hibernate -db %s: %v\n%s", dbPath, err, out) |
| } |
| @@ -1432,11 +1474,16 @@ func TestAccountsMigrateLegacyIntoOps(t *testing.T) { |
| } |
| env := carlosTestEnv(t, |
| "CARLOS_DEPLOYMENT_DIR="+storeDir, |
| - "CARLOS_RELEASE_PUBKEY="+pubB64) |
| + "CARLOS_RELEASE_PUBKEY="+pubB64, |
| + "HOME="+tmp, |
| + "CARLOS_CONSOLE=", |
| + ) |
| run := func(args ...string) (string, error) { |
| t.Helper() |
| cmd := exec.Command(carlosBin, args...) |
| cmd.Env = env |
| + // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. |
| + cmd.Dir = tmp |
| out, err := cmd.CombinedOutput() |
| return string(out), err |
| } |
| @@ -1548,6 +1595,8 @@ func TestEnvCLI(t *testing.T) { |
| t.Helper() |
| cmd := exec.Command(carlosBin, args...) |
| cmd.Env = env |
| + // pick() 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) |
| @@ -1599,6 +1648,8 @@ func TestSecretsCLI(t *testing.T) { |
| t.Helper() |
| cmd := exec.Command(carlosBin, args...) |
| cmd.Env = env |
| + // pick() 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) |
| @@ -1677,6 +1728,8 @@ func TestEnvironmentBundlesCLI(t *testing.T) { |
| t.Helper() |
| cmd := exec.Command(carlosBin, args...) |
| cmd.Env = env |
| + // pick() 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) |
| @@ -1687,6 +1740,8 @@ func TestEnvironmentBundlesCLI(t *testing.T) { |
| t.Helper() |
| cmd := exec.Command(carlosBin, args...) |
| cmd.Env = env |
| + // pick() 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: succeeded, want a refusal\n%s", strings.Join(args, " "), out) |
| @@ -1824,6 +1879,8 @@ func TestExecDeliveryCLI(t *testing.T) { |
| t.Helper() |
| cmd := exec.Command(carlosBin, args...) |
| cmd.Env = env |
| + // pick() 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) |
| @@ -1834,6 +1891,8 @@ func TestExecDeliveryCLI(t *testing.T) { |
| t.Helper() |
| cmd := exec.Command(carlosBin, args...) |
| cmd.Env = env |
| + // pick() 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: succeeded, want a refusal\n%s", strings.Join(args, " "), out) |
| @@ -1930,6 +1989,8 @@ func TestEnvironmentMergeVisibleFromCLI(t *testing.T) { |
| t.Helper() |
| cmd := exec.Command(carlosBin, args...) |
| cmd.Env = env |
| + // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. |
| + cmd.Dir = tmp |
| if out, err := cmd.CombinedOutput(); err != nil { |
| t.Fatalf("carlos %s: %v\n%s", strings.Join(args, " "), err, out) |
| } |
| @@ -2003,11 +2064,16 @@ func TestRouteCLI(t *testing.T) { |
| } |
| env := carlosTestEnv(t, |
| "CARLOS_DEPLOYMENT_DIR="+storeDir, |
| - "CARLOS_RELEASE_PUBKEY="+pubB64) |
| + "CARLOS_RELEASE_PUBKEY="+pubB64, |
| + "HOME="+tmp, |
| + "CARLOS_CONSOLE=", |
| + ) |
| run := func(args ...string) (string, error) { |
| t.Helper() |
| cmd := exec.Command(carlosBin, args...) |
| cmd.Env = env |
| + // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. |
| + cmd.Dir = tmp |
| out, err := cmd.CombinedOutput() |
| return string(out), err |
| } |
| @@ -2274,6 +2340,7 @@ type routeTestFixture struct { |
| bin string |
| storeDir string |
| regPath string |
| + tmpDir string |
| store release.Store |
| priv ed25519.PrivateKey |
| pubB64 string |
| @@ -2303,7 +2370,7 @@ func newRouteTestFixture(t *testing.T) *routeTestFixture { |
| } |
| return &routeTestFixture{ |
| t: t, bin: bin, storeDir: storeDir, regPath: filepath.Join(tmp, "registry.db"), |
| - store: release.NewDirStore(storeDir), priv: priv, pubB64: pubB64, |
| + tmpDir: tmp, store: release.NewDirStore(storeDir), priv: priv, pubB64: pubB64, |
| } |
| } |
| @@ -2314,6 +2381,8 @@ func (f *routeTestFixture) run(env []string, args ...string) (string, error) { |
| f.t.Helper() |
| cmd := exec.Command(f.bin, args...) |
| cmd.Env = env |
| + // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. |
| + cmd.Dir = f.tmpDir |
| out, err := cmd.CombinedOutput() |
| return string(out), err |
| } |
| @@ -2321,7 +2390,10 @@ func (f *routeTestFixture) run(env []string, args ...string) (string, error) { |
| func (f *routeTestFixture) fullEnv() []string { |
| return carlosTestEnv(f.t, |
| "CARLOS_DEPLOYMENT_DIR="+f.storeDir, |
| - "CARLOS_RELEASE_PUBKEY="+f.pubB64) |
| + "CARLOS_RELEASE_PUBKEY="+f.pubB64, |
| + "HOME="+f.tmpDir, |
| + "CARLOS_CONSOLE=", |
| + ) |
| } |
| func (f *routeTestFixture) ship(acct, app, version string) { |
| @@ -3163,11 +3235,16 @@ func TestAccountsMigrateLegacyKeepsNewerQualifiedPointer(t *testing.T) { |
| } |
| env := carlosTestEnv(t, |
| "CARLOS_DEPLOYMENT_DIR="+storeDir, |
| - "CARLOS_RELEASE_PUBKEY="+pubB64) |
| + "CARLOS_RELEASE_PUBKEY="+pubB64, |
| + "HOME="+tmp, |
| + "CARLOS_CONSOLE=", |
| + ) |
| run := func(args ...string) (string, error) { |
| t.Helper() |
| cmd := exec.Command(carlosBin, args...) |
| cmd.Env = env |
| + // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. |
| + cmd.Dir = tmp |
| out, err := cmd.CombinedOutput() |
| return string(out), err |
| } |
diff --git a/scopedkeys_e2e_test.go b/scopedkeys_e2e_test.go| index a6cb352ab..439d2deda 100644 |
| --- a/scopedkeys_e2e_test.go |
| +++ b/scopedkeys_e2e_test.go |
| @@ -10,13 +10,17 @@ import ( |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| -// carlosTestEnv is os.Environ() with the operator's real login masked: |
| -// CARLOS_CREDENTIALS points into the test's own tempdir, where no |
| -// credential exists. Every subprocess that runs the carlos binary MUST |
| -// build its env through this. A test that inherits the real |
| -// ~/.carlos/credentials is one `go test ./...` on a logged-in laptop away |
| -// from writing to a production console — which is exactly how 40+ test |
| -// accounts got minted on the flagship (2026-08-04). |
| +// carlosTestEnv is os.Environ() with the operator's real login and console |
| +// state masked: CARLOS_CREDENTIALS points into the test's own tempdir where |
| +// no credential exists, HOME is a scratch dir (the binary reads |
| +// ~/.carlos/config for the default console), and CARLOS_CONSOLE is cleared. |
| +// Every subprocess that runs the carlos binary MUST build its env through |
| +// this AND set cmd.Dir to a scratch dir — the binary walks cwd ancestors |
| +// for .carlos/config, so a subprocess running from the real checkout reads |
| +// the developer's real config the moment one exists. A test that inherits |
| +// the real ~/.carlos is one `go test ./...` on a logged-in laptop away from |
| +// writing to a production console — which is exactly how 40+ test accounts |
| +// got minted on the flagship (2026-08-04). |
| // |
| // Duplicated from e2e_test.go rather than shared: that file is package |
| // main_test (an external test package with no access to this package's |
| @@ -24,7 +28,12 @@ import ( |
| // an unexported helper across the package boundary. |
| func carlosTestEnv(t *testing.T, extra ...string) []string { |
| t.Helper() |
| - env := append(os.Environ(), "CARLOS_CREDENTIALS="+filepath.Join(t.TempDir(), "credentials")) |
| + scratch := t.TempDir() |
| + env := append(os.Environ(), |
| + "CARLOS_CREDENTIALS="+filepath.Join(scratch, "credentials"), |
| + "HOME="+scratch, |
| + "CARLOS_CONSOLE=", |
| + ) |
| return append(env, extra...) |
| } |
| @@ -36,6 +45,7 @@ func carlosTestEnv(t *testing.T, extra ...string) []string { |
| func TestScopedKeysCLI(t *testing.T) { |
| bin := buildCarlos(t) |
| bucket := t.TempDir() |
| + scratch := t.TempDir() |
| run := func(env []string, args ...string) (string, error) { |
| t.Helper() |
| @@ -45,6 +55,8 @@ func TestScopedKeysCLI(t *testing.T) { |
| "CARLOS_DEPLOYMENT_DIR=" + bucket, |
| "CARLOS_DATA_DIR=" + t.TempDir(), |
| }, env...)...) |
| + // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. |
| + cmd.Dir = scratch |
| out, err := cmd.CombinedOutput() |
| return string(out), err |
| } |