carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// The .carlos/config file: a settable default console.
2//
3// Two levels, one shape. ~/.carlos/config is the machine's default;
4// .carlos/config at a project root — committed to the repo — pins that
5// project to a deployment (the titogo checkout says the Tito stack; a
6// checkout with no opinion falls to the global). The nearest .carlos/config
7// walking up from the working directory wins.
8//
9// A resolved default is authoritative: resolution never falls past it to
10// "the only credential on this machine". Holding only a flagship credential
11// inside a checkout pinned to another stack is an error naming that stack —
12// never a quiet ship to the flagship. The single-credential convenience
13// exists only for the no-default world.
14//
15// The file is deliberately not a secret and never holds one: nothing from
16// ~/.carlos/credentials moves here, ever. It IS repo-controlled input when
17// committed, which a malicious PR could repoint — accepted in the spec
18// (docs/superpowers/specs/2026-08-04-console-default-resolution-design.md)
19// because per-origin credentials mean no token ever crosses consoles, and
20// every verb prints the console it is talking to.
21//
22// Destinations: one checkout can deploy to several stacks. A file may hold
23// a "destinations" object of named blocks, each shaped like the top level
24// (console, account, app, kind, artifact, target), and --destination / -d
25// (or CARLOS_DESTINATION) picks one:
26//
27// {"destinations": {"exciting": {"console": "...", "app": "shop"},
28// "tito": {"console": "...", "app": "shop"}}}
29//
30// The chosen block is laid over the file's top level, so a key shared by
31// every destination (say "artifact") can live once at the top. A
32// top-level "default" names the block used when nothing is selected. See
33// loadConfig for the resolution rules.
34package main
35
36import (
37 "encoding/json"
38 "errors"
39 "fmt"
40 "os"
41 "path/filepath"
42 "strings"
43)
44
45// defaultConsole is where `carlos auth login` lands when nothing — flag,
46// env, project config, global config — names a console: the flagship. Login
47// only; no other verb ever assumes it.
48const defaultConsole = "https://platform.carlosframework.com"
49
50// cliConfig is the whole file. Unknown fields are ignored on read and — since
51// the account key landed — PRESERVED on rewrite (writeConfigKeys): the file
52// is additive, other tools may learn to read it, and a rewrite that drops
53// what it doesn't understand makes every future key lossy against the binary
54// that predates it.
55type cliConfig struct {
56 Console string `json:"console"`
57 Account string `json:"account"`
58 // The project-defaults block (2026-08-13 spec): what this checkout
59 // deploys as. App is honored by every app-scoped verb; Kind/Artifact/
60 // Target answer zero-arg `carlos deploy`. All console-paired like
61 // Account: a file's defaults mean THIS stack.
62 App string `json:"app"`
63 Kind string `json:"kind"`
64 Artifact string `json:"artifact"`
65 Target string `json:"target"`
66 // UpdateCheck: "off" disables the once-a-day newer-release hint
67 // (update.go). Any other value — including absent — leaves it on.
68 UpdateCheck string `json:"update_check"`
69 // MessagePrompt: "off" suppresses the optional mutation-description
70 // question. Any other value, including absent, leaves it enabled.
71 MessagePrompt string `json:"message_prompt"`
72
73 // Destination is the destinations block this view was resolved from
74 // ("" when the file has none). Never read from or written to the file
75 // as a key: it names what loadConfig did, for messages and the walk's
76 // did-anything-define-it check.
77 Destination string `json:"-"`
78}
79
80// configFile is the on-disk shape: the top level plus the named blocks.
81// Blocks are cliConfig too, so a "destinations" key nested inside one is
82// simply ignored rather than recursed into.
83type configFile struct {
84 cliConfig
85 Default string `json:"default"`
86 Destinations map[string]cliConfig `json:"destinations"`
87}
88
89// destinationFlag is --destination / -d, bound by consoleOption on every
90// verb that talks to a console. A package variable rather than a value
91// threaded through: the config is read deep inside session resolution, a
92// long way from any flag set, and one process runs one verb.
93var destinationFlag string
94
95// selectedDestination is the destination this run asked for: the flag,
96// else CARLOS_DESTINATION, else "" (let the files decide).
97func selectedDestination() string {
98 if d := strings.TrimSpace(destinationFlag); d != "" {
99 return d
100 }
101 return strings.TrimSpace(os.Getenv("CARLOS_DESTINATION"))
102}
103
104// fileDefaultDestination is the block a file resolves to when nothing was
105// selected: its "default", else its only block. names is never empty.
106func fileDefaultDestination(path, def string, names []string) (string, error) {
107 if def != "" {
108 for _, n := range names {
109 if n == def {
110 return def, nil
111 }
112 }
113 return "", fmt.Errorf("%s: the default destination %q is not one of its destinations (%s)",
114 path, def, strings.Join(names, ", "))
115 }
116 if len(names) == 1 {
117 return names[0], nil
118 }
119 return "", fmt.Errorf("%s defines several destinations (%s) and no default — pick one with --destination <name> (-d), set CARLOS_DESTINATION, or add \"default\": \"<name>\"",
120 path, strings.Join(names, ", "))
121}
122
123func configMessagePrompt() (string, error) {
124 cfg, _, err := walkConfig(func(c cliConfig) bool { return c.MessagePrompt != "" })
125 return cfg.MessagePrompt, err
126}
127
128// walkConfig finds the nearest .carlos/config (cwd upward, then global)
129// that pick() accepts, returning it with its path. The zero config and ""
130// mean "no file said anything". Every per-key default walk (account, app,
131// update_check) is this walk with a different pick.
132//
133// CARLOS_CONFIG, when set, skips this discovery entirely: the named file is
134// the only one consulted, whether or not pick() accepts it. This is the
135// override configDefault documents in the same terms as CARLOS_CREDENTIALS
136// (auth.go credentialsPath) — for tests, and for anyone keeping their
137// config somewhere other than $HOME's discovery path.
138func walkConfig(pick func(cliConfig) bool) (cliConfig, string, error) {
139 cfg, src, defined, err := walkConfigFiles(pick)
140 if err != nil {
141 return cliConfig{}, "", err
142 }
143 // A named destination that no file defines is a typo or a missing
144 // block — never "no opinion". Falling through would hand the verb to
145 // the single-credential convenience: a ship to whatever stack this
146 // machine happens to be logged in to. Checked only when nothing was
147 // picked, because only then was every file read: a stack key can only
148 // be picked from a file that defines the destination anyway (loadConfig
149 // strips it elsewhere), and a preference picked early says nothing
150 // about the files past it.
151 if d := selectedDestination(); d != "" && src == "" && !defined {
152 return cliConfig{}, "", fmt.Errorf("no .carlos/config defines the destination %q", d)
153 }
154 return cfg, src, nil
155}
156
157// walkConfigFiles is walkConfig's discovery, also reporting whether any
158// file it read defined the selected destination.
159func walkConfigFiles(pick func(cliConfig) bool) (cfg cliConfig, src string, defined bool, err error) {
160 visit := func(path string) (bool, error) {
161 c, err := loadConfig(path)
162 if err != nil {
163 return false, err
164 }
165 if c.Destination != "" {
166 defined = true
167 }
168 if pick(c) {
169 cfg, src = c, path
170 return true, nil
171 }
172 return false, nil
173 }
174 if p := os.Getenv("CARLOS_CONFIG"); p != "" {
175 _, err := visit(p)
176 return cfg, src, defined, err
177 }
178 if dir, werr := os.Getwd(); werr == nil {
179 for {
180 if done, err := visit(filepath.Join(dir, ".carlos", "config")); err != nil || done {
181 return cfg, src, defined, err
182 }
183 parent := filepath.Dir(dir)
184 if parent == dir {
185 break
186 }
187 dir = parent
188 }
189 }
190 path, perr := globalConfigPath()
191 if perr != nil {
192 return cfg, src, defined, nil
193 }
194 _, err = visit(path)
195 return cfg, src, defined, err
196}
197
198// configApp resolves the configured default APP: the nearest file whose
199// app key is set, with that same file's console for the pairing rule —
200// exactly configAccount's contract, for the sibling key.
201func configApp() (app, source, fileConsole string, err error) {
202 cfg, src, err := walkConfig(func(c cliConfig) bool { return c.App != "" })
203 if err != nil || src == "" {
204 return "", "", "", err
205 }
206 return cfg.App, src, cfg.Console, nil
207}
208
209// configDestination names the destination block the configured console
210// came from, when the session is using that console ("" otherwise: a
211// --console to somewhere else is not "destination X").
212func configDestination(console string) string {
213 cfg, src, err := walkConfig(func(c cliConfig) bool { return c.Console != "" })
214 if err != nil || src == "" || cfg.Console != console {
215 return ""
216 }
217 return cfg.Destination
218}
219
220// configDeploy resolves the project's deploy block: the nearest file that
221// names an artifact, whole — kind/artifact/target travel together, and
222// mixing halves of two files would ship one project's bytes with
223// another's shape.
224func configDeploy() (cfg cliConfig, source string, err error) {
225 return walkConfig(func(c cliConfig) bool { return c.Artifact != "" })
226}
227
228// loadConfig reads one .carlos/config whole. A missing file is a zero
229// config, not an error. A file that cannot be parsed, or names a console
230// normalizeConsole refuses, is an ERROR — silently ignoring a typo'd
231// config would ship to the wrong console, the exact mistake this file
232// exists to prevent. The console comes back normalized ("" when unset);
233// the account comes back trimmed.
234//
235// A file with a "destinations" block resolves to one of them, laid over
236// its top level:
237//
238// - a destination was selected and the file defines it: that block;
239// - a destination was selected and the file does NOT define it: the
240// file's preferences only (update_check, message_prompt) — its
241// console/app/deploy keys describe some other stack. Same for a file
242// with no destinations block at all. walkConfig refuses when no file
243// defines the selection;
244// - nothing selected: the file's "default" (an error if it names no
245// block), else its only block;
246// - nothing selected, no default, several blocks: an error naming them.
247// Guessing, or quietly using the top level, is the wrong deployment
248// again.
249func loadConfig(path string) (cliConfig, error) {
250 raw, err := os.ReadFile(path)
251 if errors.Is(err, os.ErrNotExist) {
252 return cliConfig{}, nil
253 }
254 if err != nil {
255 return cliConfig{}, err
256 }
257 var file configFile
258 if err := json.Unmarshal(raw, &file); err != nil {
259 return cliConfig{}, fmt.Errorf("%s is not readable as a carlos config: %w", path, err)
260 }
261 cfg := file.cliConfig
262 if name := selectedDestination(); name != "" {
263 if block, ok := file.Destinations[name]; ok {
264 cfg = overlayDestination(cfg, block)
265 cfg.Destination = name
266 } else {
267 cfg = cliConfig{UpdateCheck: cfg.UpdateCheck, MessagePrompt: cfg.MessagePrompt}
268 }
269 } else if len(file.Destinations) > 0 {
270 name, err := fileDefaultDestination(path, strings.TrimSpace(file.Default), mapKeys(file.Destinations))
271 if err != nil {
272 return cliConfig{}, err
273 }
274 cfg = overlayDestination(cfg, file.Destinations[name])
275 cfg.Destination = name
276 }
277 if strings.TrimSpace(cfg.Console) == "" {
278 cfg.Console = ""
279 } else {
280 n, err := normalizeConsole(cfg.Console)
281 if err != nil {
282 return cliConfig{}, fmt.Errorf("%s: %w", path, err)
283 }
284 cfg.Console = n
285 }
286 cfg.Account = strings.TrimSpace(cfg.Account)
287 // Same trim for every defaults key: {"app": "shop "} would otherwise
288 // travel as shop%20 and 404 confusingly.
289 cfg.App = strings.TrimSpace(cfg.App)
290 cfg.Kind = strings.TrimSpace(cfg.Kind)
291 cfg.Artifact = strings.TrimSpace(cfg.Artifact)
292 cfg.Target = strings.TrimSpace(cfg.Target)
293 return cfg, nil
294}
295
296// overlayDestination lays a destination block over the file's top level:
297// every key the block sets wins. Preferences (update_check,
298// message_prompt) are machine-wide in spirit and stay top-level only.
299func overlayDestination(top, block cliConfig) cliConfig {
300 for _, kv := range []struct {
301 dst *string
302 v string
303 }{
304 {&top.Console, block.Console},
305 {&top.Account, block.Account},
306 {&top.App, block.App},
307 {&top.Kind, block.Kind},
308 {&top.Artifact, block.Artifact},
309 {&top.Target, block.Target},
310 } {
311 if strings.TrimSpace(kv.v) != "" {
312 *kv.dst = kv.v
313 }
314 }
315 return top
316}
317
318// loadConfigFile is loadConfig's console-only view — the shape every
319// console-default caller predating the account key still reads.
320func loadConfigFile(path string) (string, error) {
321 cfg, err := loadConfig(path)
322 return cfg.Console, err
323}
324
325// configDefault resolves the configured default console: the nearest
326// .carlos/config walking up from the working directory, else
327// ~/.carlos/config. Returns the normalized console and the file it came
328// from — the source goes into error messages, because "not logged in to X"
329// is only actionable if you know WHICH file said X.
330//
331// If the walk passes through $HOME it finds ~/.carlos/config by the same
332// rule — the global default arriving one step early, same answer. The
333// explicit global check after the walk is for working directories outside
334// $HOME entirely.
335//
336// CARLOS_CONFIG, when set, names the one file consulted and skips discovery
337// entirely — walkConfig's override, since this is walkConfig picking on
338// the console key.
339func configDefault() (console, source string, err error) {
340 cfg, src, err := walkConfig(func(c cliConfig) bool { return c.Console != "" })
341 return cfg.Console, src, err
342}
343
344// configAccount resolves the configured default ACCOUNT: the nearest
345// .carlos/config walking up from the working directory whose account key is
346// set, else ~/.carlos/config. A level that names only a console does not
347// stop this walk — console and account are separate defaults that happen to
348// share a file. Returns the account, the file it came from, and that SAME
349// file's console ("" when it names none): the pairing rule lives at the
350// caller (apimode.go defaultAccount), which honors a file's account only
351// for the console the file itself names — a config pair points at one
352// stack, and --console to a different stack must not drag the other stack's
353// account along.
354func configAccount() (account, source, fileConsole string, err error) {
355 cfg, src, err := walkConfig(func(c cliConfig) bool { return c.Account != "" })
356 if err != nil || src == "" {
357 return "", "", "", err
358 }
359 return cfg.Account, src, cfg.Console, nil
360}
361
362// globalConfigPath is ~/.carlos/config — the same directory that holds
363// credentials, though this file is not a secret and never holds one.
364func globalConfigPath() (string, error) {
365 home, err := os.UserHomeDir()
366 if err != nil {
367 return "", fmt.Errorf("no home directory for ~/.carlos/config: %w", err)
368 }
369 return filepath.Join(home, ".carlos", "config"), nil
370}
371
372// saveConfigFile validates the console and writes the "console" key at
373// path, preserving everything else the file holds (writeConfigKeys).
374// dirPerm: 0700 for ~/.carlos, which MkdirAll leaves alone if it already
375// exists; 0755 for a project directory that is meant to be committed.
376func saveConfigFile(path, console string, dirPerm os.FileMode) error {
377 n, err := normalizeConsole(console)
378 if err != nil {
379 return err
380 }
381 return writeConfigKeys(path, map[string]any{"console": n}, dirPerm)
382}
383
384// saveConfigAccount writes a console+account pair — the chooser's "save as
385// this project's default" answer. The two keys land together deliberately:
386// configAccount's pairing rule reads them as one fact ("this checkout means
387// this account on this console"), so saving the account without pinning the
388// console would leave a default that silently stops applying the day the
389// directory's console default changes.
390func saveConfigAccount(path, console, account string, dirPerm os.FileMode) error {
391 n, err := normalizeConsole(console)
392 if err != nil {
393 return err
394 }
395 return writeConfigKeys(path, map[string]any{"console": n, "account": account}, dirPerm)
396}
397
398// writeConfigKeys rewrites path with the given keys set, keeping every
399// field it does not understand — a rewrite that drops unknown keys makes
400// every future config key lossy against the binary that predates it.
401// Temp-file-then-rename like credentialFile.save, so a crash cannot leave
402// a truncated config — but 0644, not 0600: this file is not a secret.
403//
404// Keys land where loadConfig would read them back: inside the selected
405// destination's block (created if missing), else inside the file's
406// default or only block, else at the top level. A file that cannot say
407// which block is refused, as it is on read.
408func writeConfigKeys(path string, set map[string]any, dirPerm os.FileMode) error {
409 all, err := readConfigMap(path)
410 if err != nil {
411 return err
412 }
413 into, err := configWriteTarget(path, all)
414 if err != nil {
415 return err
416 }
417 for k, v := range set {
418 into[k] = v
419 }
420 return writeConfigMap(path, all, dirPerm)
421}
422
423// readConfigMap reads a config as raw JSON, every key kept — the form a
424// rewrite must start from. A missing file is an empty map.
425func readConfigMap(path string) (map[string]any, error) {
426 all := map[string]any{}
427 raw, err := os.ReadFile(path)
428 if errors.Is(err, os.ErrNotExist) {
429 return all, nil
430 }
431 if err != nil {
432 return nil, err
433 }
434 if err := json.Unmarshal(raw, &all); err != nil {
435 return nil, fmt.Errorf("%s is not readable as a carlos config: %w", path, err)
436 }
437 if all == nil { // the file said null
438 all = map[string]any{}
439 }
440 return all, nil
441}
442
443// writeConfigMap writes all to path, temp-file-then-rename (see
444// writeConfigKeys).
445func writeConfigMap(path string, all map[string]any, dirPerm os.FileMode) error {
446 dir := filepath.Dir(path)
447 if err := os.MkdirAll(dir, dirPerm); err != nil {
448 return err
449 }
450 body, err := json.MarshalIndent(all, "", " ")
451 if err != nil {
452 return err
453 }
454 body = append(body, '\n')
455 tmp, err := os.CreateTemp(dir, ".config-*")
456 if err != nil {
457 return err
458 }
459 defer os.Remove(tmp.Name())
460 if err := tmp.Chmod(0o644); err != nil {
461 tmp.Close()
462 return err
463 }
464 if _, err := tmp.Write(body); err != nil {
465 tmp.Close()
466 return err
467 }
468 if err := tmp.Close(); err != nil {
469 return err
470 }
471 return os.Rename(tmp.Name(), path)
472}
473
474// configWriteTarget picks the object inside a parsed config that
475// writeConfigKeys should set keys on — see its comment for the rule.
476func configWriteTarget(path string, all map[string]any) (map[string]any, error) {
477 var dests map[string]any
478 if raw, ok := all["destinations"]; ok {
479 if dests, ok = raw.(map[string]any); !ok {
480 return nil, fmt.Errorf("%s: \"destinations\" is not an object", path)
481 }
482 }
483 name := selectedDestination()
484 if name == "" {
485 if len(dests) == 0 {
486 return all, nil
487 }
488 def, _ := all["default"].(string)
489 var err error
490 if name, err = fileDefaultDestination(path, strings.TrimSpace(def), mapKeys(dests)); err != nil {
491 return nil, err
492 }
493 }
494 if dests == nil {
495 dests = map[string]any{}
496 all["destinations"] = dests
497 }
498 block, ok := dests[name].(map[string]any)
499 if !ok {
500 if _, exists := dests[name]; exists {
501 return nil, fmt.Errorf("%s: destination %q is not an object", path, name)
502 }
503 block = map[string]any{}
504 dests[name] = block
505 }
506 return block, nil
507}
508