| index b11c3171c..2b5c60370 100644 |
| --- a/config.go |
| +++ b/config.go |
| @@ -34,38 +34,52 @@ import ( |
| // 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. |
| +// 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"` |
| } |
| |
| -// 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 |
| +// 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. |
| -func loadConfigFile(path string) (string, error) { |
| +// exists to prevent. The console comes back normalized ("" when unset); |
| +// the account comes back trimmed. |
| +func loadConfig(path string) (cliConfig, error) { |
| raw, err := os.ReadFile(path) |
| if errors.Is(err, os.ErrNotExist) { |
| - return "", nil |
| + return cliConfig{}, nil |
| } |
| if err != nil { |
| - return "", err |
| + return cliConfig{}, 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) |
| + return cliConfig{}, 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) |
| + cfg.Console = "" |
| + } else { |
| + n, err := normalizeConsole(cfg.Console) |
| + if err != nil { |
| + return cliConfig{}, fmt.Errorf("%s: %w", path, err) |
| + } |
| + cfg.Console = n |
| } |
| - return n, nil |
| + cfg.Account = strings.TrimSpace(cfg.Account) |
| + return cfg, nil |
| +} |
| + |
| +// 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 |
| @@ -111,6 +125,49 @@ func configDefault() (console, source string, err error) { |
| return c, path, nil |
| } |
| |
| +// 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) { |
| + if dir, werr := os.Getwd(); werr == nil { |
| + for { |
| + path := filepath.Join(dir, ".carlos", "config") |
| + cfg, err := loadConfig(path) |
| + if err != nil { |
| + return "", "", "", err |
| + } |
| + if cfg.Account != "" { |
| + return cfg.Account, path, cfg.Console, 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 |
| + } |
| + cfg, err := loadConfig(path) |
| + if err != nil { |
| + return "", "", "", err |
| + } |
| + if cfg.Account == "" { |
| + return "", "", "", nil |
| + } |
| + return cfg.Account, path, 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) { |
| @@ -121,22 +178,54 @@ func globalConfigPath() (string, error) { |
| 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. |
| +// 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. |
| +func writeConfigKeys(path string, set 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(cliConfig{Console: n}, "", " ") |
| + all := map[string]any{} |
| + if raw, err := os.ReadFile(path); err == nil { |
| + if err := json.Unmarshal(raw, &all); err != nil { |
| + return fmt.Errorf("%s is not readable as a carlos config: %w", path, err) |
| + } |
| + } else if !errors.Is(err, os.ErrNotExist) { |
| + return err |
| + } |
| + for k, v := range set { |
| + all[k] = v |
| + } |
| + body, err := json.MarshalIndent(all, "", " ") |
| if err != nil { |
| return err |
| } |