// 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. // // Destinations: one checkout can deploy to several stacks. A file may hold // a "destinations" object of named blocks, each shaped like the top level // (console, account, app, kind, artifact, target), and --destination / -d // (or CARLOS_DESTINATION) picks one: // // {"destinations": {"exciting": {"console": "...", "app": "shop"}, // "tito": {"console": "...", "app": "shop"}}} // // The chosen block is laid over the file's top level, so a key shared by // every destination (say "artifact") can live once at the top. A // top-level "default" names the block used when nothing is selected. See // loadConfig for the resolution rules. 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 — since // the account key landed — PRESERVED on rewrite (writeConfigKeys): the file // is additive, other tools may learn to read it, and a rewrite that drops // what it doesn't understand makes every future key lossy against the binary // that predates it. type cliConfig struct { Console string `json:"console"` Account string `json:"account"` // The project-defaults block (2026-08-13 spec): what this checkout // deploys as. App is honored by every app-scoped verb; Kind/Artifact/ // Target answer zero-arg `carlos deploy`. All console-paired like // Account: a file's defaults mean THIS stack. App string `json:"app"` Kind string `json:"kind"` Artifact string `json:"artifact"` Target string `json:"target"` // UpdateCheck: "off" disables the once-a-day newer-release hint // (update.go). Any other value — including absent — leaves it on. UpdateCheck string `json:"update_check"` // MessagePrompt: "off" suppresses the optional mutation-description // question. Any other value, including absent, leaves it enabled. MessagePrompt string `json:"message_prompt"` // Destination is the destinations block this view was resolved from // ("" when the file has none). Never read from or written to the file // as a key: it names what loadConfig did, for messages and the walk's // did-anything-define-it check. Destination string `json:"-"` } // configFile is the on-disk shape: the top level plus the named blocks. // Blocks are cliConfig too, so a "destinations" key nested inside one is // simply ignored rather than recursed into. type configFile struct { cliConfig Default string `json:"default"` Destinations map[string]cliConfig `json:"destinations"` } // destinationFlag is --destination / -d, bound by consoleOption on every // verb that talks to a console. A package variable rather than a value // threaded through: the config is read deep inside session resolution, a // long way from any flag set, and one process runs one verb. var destinationFlag string // selectedDestination is the destination this run asked for: the flag, // else CARLOS_DESTINATION, else "" (let the files decide). func selectedDestination() string { if d := strings.TrimSpace(destinationFlag); d != "" { return d } return strings.TrimSpace(os.Getenv("CARLOS_DESTINATION")) } // fileDefaultDestination is the block a file resolves to when nothing was // selected: its "default", else its only block. names is never empty. func fileDefaultDestination(path, def string, names []string) (string, error) { if def != "" { for _, n := range names { if n == def { return def, nil } } return "", fmt.Errorf("%s: the default destination %q is not one of its destinations (%s)", path, def, strings.Join(names, ", ")) } if len(names) == 1 { return names[0], nil } return "", fmt.Errorf("%s defines several destinations (%s) and no default — pick one with --destination (-d), set CARLOS_DESTINATION, or add \"default\": \"\"", path, strings.Join(names, ", ")) } func configMessagePrompt() (string, error) { cfg, _, err := walkConfig(func(c cliConfig) bool { return c.MessagePrompt != "" }) return cfg.MessagePrompt, err } // walkConfig finds the nearest .carlos/config (cwd upward, then global) // that pick() accepts, returning it with its path. The zero config and "" // mean "no file said anything". Every per-key default walk (account, app, // update_check) is this walk with a different pick. // // CARLOS_CONFIG, when set, skips this discovery entirely: the named file is // the only one consulted, whether or not pick() accepts it. This is the // override configDefault documents in the same terms as CARLOS_CREDENTIALS // (auth.go credentialsPath) — for tests, and for anyone keeping their // config somewhere other than $HOME's discovery path. func walkConfig(pick func(cliConfig) bool) (cliConfig, string, error) { cfg, src, defined, err := walkConfigFiles(pick) if err != nil { return cliConfig{}, "", err } // A named destination that no file defines is a typo or a missing // block — never "no opinion". Falling through would hand the verb to // the single-credential convenience: a ship to whatever stack this // machine happens to be logged in to. Checked only when nothing was // picked, because only then was every file read: a stack key can only // be picked from a file that defines the destination anyway (loadConfig // strips it elsewhere), and a preference picked early says nothing // about the files past it. if d := selectedDestination(); d != "" && src == "" && !defined { return cliConfig{}, "", fmt.Errorf("no .carlos/config defines the destination %q", d) } return cfg, src, nil } // walkConfigFiles is walkConfig's discovery, also reporting whether any // file it read defined the selected destination. func walkConfigFiles(pick func(cliConfig) bool) (cfg cliConfig, src string, defined bool, err error) { visit := func(path string) (bool, error) { c, err := loadConfig(path) if err != nil { return false, err } if c.Destination != "" { defined = true } if pick(c) { cfg, src = c, path return true, nil } return false, nil } if p := os.Getenv("CARLOS_CONFIG"); p != "" { _, err := visit(p) return cfg, src, defined, err } if dir, werr := os.Getwd(); werr == nil { for { if done, err := visit(filepath.Join(dir, ".carlos", "config")); err != nil || done { return cfg, src, defined, err } parent := filepath.Dir(dir) if parent == dir { break } dir = parent } } path, perr := globalConfigPath() if perr != nil { return cfg, src, defined, nil } _, err = visit(path) return cfg, src, defined, err } // configApp resolves the configured default APP: the nearest file whose // app key is set, with that same file's console for the pairing rule — // exactly configAccount's contract, for the sibling key. func configApp() (app, source, fileConsole string, err error) { cfg, src, err := walkConfig(func(c cliConfig) bool { return c.App != "" }) if err != nil || src == "" { return "", "", "", err } return cfg.App, src, cfg.Console, nil } // configDestination names the destination block the configured console // came from, when the session is using that console ("" otherwise: a // --console to somewhere else is not "destination X"). func configDestination(console string) string { cfg, src, err := walkConfig(func(c cliConfig) bool { return c.Console != "" }) if err != nil || src == "" || cfg.Console != console { return "" } return cfg.Destination } // configDeploy resolves the project's deploy block: the nearest file that // names an artifact, whole — kind/artifact/target travel together, and // mixing halves of two files would ship one project's bytes with // another's shape. func configDeploy() (cfg cliConfig, source string, err error) { return walkConfig(func(c cliConfig) bool { return c.Artifact != "" }) } // loadConfig reads one .carlos/config whole. A missing file is a zero // config, not an error. 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. The console comes back normalized ("" when unset); // the account comes back trimmed. // // A file with a "destinations" block resolves to one of them, laid over // its top level: // // - a destination was selected and the file defines it: that block; // - a destination was selected and the file does NOT define it: the // file's preferences only (update_check, message_prompt) — its // console/app/deploy keys describe some other stack. Same for a file // with no destinations block at all. walkConfig refuses when no file // defines the selection; // - nothing selected: the file's "default" (an error if it names no // block), else its only block; // - nothing selected, no default, several blocks: an error naming them. // Guessing, or quietly using the top level, is the wrong deployment // again. func loadConfig(path string) (cliConfig, error) { raw, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { return cliConfig{}, nil } if err != nil { return cliConfig{}, err } var file configFile if err := json.Unmarshal(raw, &file); err != nil { return cliConfig{}, fmt.Errorf("%s is not readable as a carlos config: %w", path, err) } cfg := file.cliConfig if name := selectedDestination(); name != "" { if block, ok := file.Destinations[name]; ok { cfg = overlayDestination(cfg, block) cfg.Destination = name } else { cfg = cliConfig{UpdateCheck: cfg.UpdateCheck, MessagePrompt: cfg.MessagePrompt} } } else if len(file.Destinations) > 0 { name, err := fileDefaultDestination(path, strings.TrimSpace(file.Default), mapKeys(file.Destinations)) if err != nil { return cliConfig{}, err } cfg = overlayDestination(cfg, file.Destinations[name]) cfg.Destination = name } if strings.TrimSpace(cfg.Console) == "" { cfg.Console = "" } else { n, err := normalizeConsole(cfg.Console) if err != nil { return cliConfig{}, fmt.Errorf("%s: %w", path, err) } cfg.Console = n } cfg.Account = strings.TrimSpace(cfg.Account) // Same trim for every defaults key: {"app": "shop "} would otherwise // travel as shop%20 and 404 confusingly. cfg.App = strings.TrimSpace(cfg.App) cfg.Kind = strings.TrimSpace(cfg.Kind) cfg.Artifact = strings.TrimSpace(cfg.Artifact) cfg.Target = strings.TrimSpace(cfg.Target) return cfg, nil } // overlayDestination lays a destination block over the file's top level: // every key the block sets wins. Preferences (update_check, // message_prompt) are machine-wide in spirit and stay top-level only. func overlayDestination(top, block cliConfig) cliConfig { for _, kv := range []struct { dst *string v string }{ {&top.Console, block.Console}, {&top.Account, block.Account}, {&top.App, block.App}, {&top.Kind, block.Kind}, {&top.Artifact, block.Artifact}, {&top.Target, block.Target}, } { if strings.TrimSpace(kv.v) != "" { *kv.dst = kv.v } } return top } // loadConfigFile is loadConfig's console-only view — the shape every // console-default caller predating the account key still reads. func loadConfigFile(path string) (string, error) { cfg, err := loadConfig(path) return cfg.Console, err } // 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. // // CARLOS_CONFIG, when set, names the one file consulted and skips discovery // entirely — walkConfig's override, since this is walkConfig picking on // the console key. func configDefault() (console, source string, err error) { cfg, src, err := walkConfig(func(c cliConfig) bool { return c.Console != "" }) return cfg.Console, src, err } // configAccount resolves the configured default ACCOUNT: the nearest // .carlos/config walking up from the working directory whose account key is // set, else ~/.carlos/config. A level that names only a console does not // stop this walk — console and account are separate defaults that happen to // share a file. Returns the account, the file it came from, and that SAME // file's console ("" when it names none): the pairing rule lives at the // caller (apimode.go defaultAccount), which honors a file's account only // for the console the file itself names — a config pair points at one // stack, and --console to a different stack must not drag the other stack's // account along. func configAccount() (account, source, fileConsole string, err error) { cfg, src, err := walkConfig(func(c cliConfig) bool { return c.Account != "" }) if err != nil || src == "" { return "", "", "", err } return cfg.Account, src, cfg.Console, 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 the "console" key at // path, preserving everything else the file holds (writeConfigKeys). // dirPerm: 0700 for ~/.carlos, which MkdirAll leaves alone if it already // exists; 0755 for a project directory that is meant to be committed. func saveConfigFile(path, console string, dirPerm os.FileMode) error { n, err := normalizeConsole(console) if err != nil { return err } return writeConfigKeys(path, map[string]any{"console": n}, dirPerm) } // saveConfigAccount writes a console+account pair — the chooser's "save as // this project's default" answer. The two keys land together deliberately: // configAccount's pairing rule reads them as one fact ("this checkout means // this account on this console"), so saving the account without pinning the // console would leave a default that silently stops applying the day the // directory's console default changes. func saveConfigAccount(path, console, account string, dirPerm os.FileMode) error { n, err := normalizeConsole(console) if err != nil { return err } return writeConfigKeys(path, map[string]any{"console": n, "account": account}, dirPerm) } // writeConfigKeys rewrites path with the given keys set, keeping every // field it does not understand — a rewrite that drops unknown keys makes // every future config key lossy against the binary that predates it. // 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. // // Keys land where loadConfig would read them back: inside the selected // destination's block (created if missing), else inside the file's // default or only block, else at the top level. A file that cannot say // which block is refused, as it is on read. func writeConfigKeys(path string, set map[string]any, dirPerm os.FileMode) error { all, err := readConfigMap(path) if err != nil { return err } into, err := configWriteTarget(path, all) if err != nil { return err } for k, v := range set { into[k] = v } return writeConfigMap(path, all, dirPerm) } // readConfigMap reads a config as raw JSON, every key kept — the form a // rewrite must start from. A missing file is an empty map. func readConfigMap(path string) (map[string]any, error) { all := map[string]any{} raw, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { return all, nil } if err != nil { return nil, err } if err := json.Unmarshal(raw, &all); err != nil { return nil, fmt.Errorf("%s is not readable as a carlos config: %w", path, err) } if all == nil { // the file said null all = map[string]any{} } return all, nil } // writeConfigMap writes all to path, temp-file-then-rename (see // writeConfigKeys). func writeConfigMap(path string, all map[string]any, dirPerm os.FileMode) error { dir := filepath.Dir(path) if err := os.MkdirAll(dir, dirPerm); err != nil { return err } body, err := json.MarshalIndent(all, "", " ") 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) } // configWriteTarget picks the object inside a parsed config that // writeConfigKeys should set keys on — see its comment for the rule. func configWriteTarget(path string, all map[string]any) (map[string]any, error) { var dests map[string]any if raw, ok := all["destinations"]; ok { if dests, ok = raw.(map[string]any); !ok { return nil, fmt.Errorf("%s: \"destinations\" is not an object", path) } } name := selectedDestination() if name == "" { if len(dests) == 0 { return all, nil } def, _ := all["default"].(string) var err error if name, err = fileDefaultDestination(path, strings.TrimSpace(def), mapKeys(dests)); err != nil { return nil, err } } if dests == nil { dests = map[string]any{} all["destinations"] = dests } block, ok := dests[name].(map[string]any) if !ok { if _, exists := dests[name]; exists { return nil, fmt.Errorf("%s: destination %q is not an object", path, name) } block = map[string]any{} dests[name] = block } return block, nil }