auth: .carlos/config default-console file and lookup walk
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KxXEWoRXU8WVTXjTJypuJ5
3 files changed,
+300
−2
auth_test.go+9 −2config.go+161 −0config_test.go+130 −0
diff --git a/auth_test.go b/auth_test.go| index ae735e5df..9e2536c3b 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 |
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..22890c844 |
| --- /dev/null |
| +++ b/config_test.go |
| @@ -0,0 +1,130 @@ |
| +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()) |
| + } |
| +} |