carlos / platform Public

review fixes: one resolution per invocation, loud fallbacks, honest saves

Opus review findings applied: secrets set resolves the account ONCE
(double resolution could seal with one account's key and write to
another — undecryptable); resolution memoized on the session; the ops
fallback names itself; a failed config save warns instead of killing
the verb; the save prompt is skipped from $HOME (it would rewrite the
global default); consoleless config accounts no longer apply to every
console; explicit -registry failures surface instead of hopping to
console mode; resolvedAccount fatals on a nil session; direct-mode
empty==ops invariant pinned by test; interactiveTerminal/chooserIn
injectable so both chooser branches are tested.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell b12bf23a1f09ea5fa2ff89dba9f7dcafba00099e parent a4f5799
8 files changed, +270 −52
  • account_choose.go +23 −10
  • account_choose_test.go +19 −7
  • accounts.go +14 −1
  • accounts_resolve_test.go +12 −0
  • apimode.go +58 −16
  • apimode_test.go +112 −11
  • appconfig_cli.go +8 −2
  • docs/superpowers/specs/2026-08-12-account-default-resolution-design.md +24 −5
diff --git a/account_choose.go b/account_choose.go
index 04a95ce68..1f4594be4 100644
--- a/account_choose.go
+++ b/account_choose.go
@@ -37,18 +37,28 @@ func severalAccountsErr(accts []apiAccountEntry) error {
// interactiveTerminal reports whether a prompt has a human on both ends.
// Stdin AND stdout must be terminals: a redirected stdout means the output
-// is for a parser, and a redirected stdin has no one to answer.
-func interactiveTerminal() bool {
+// is for a parser, and a redirected stdin has no one to answer. A var, not
+// a func: a global that reads real file descriptors is untestable in both
+// directions (under `go test` stdin is /dev/null — a char device — and only
+// the piped stdout saves the check), so tests set it explicitly.
+var interactiveTerminal = func() bool {
return charDevice(os.Stdin) && charDevice(os.Stdout)
}
-// chooseAccount asks which account an omitted -account means, then whether
-// to remember the answer as the project's default (the caller writes the
-// config; this function only asks). A junk answer re-prompts once; two
-// misses, or EOF at the pick, fall back to the non-interactive refusal so
-// the caller's error path is identical either way. EOF or anything but
-// y/Y at the save prompt means no — the pick itself stands.
-func chooseAccount(in io.Reader, out io.Writer, console string, accts []apiAccountEntry) (apiAccountEntry, bool, error) {
+// chooserIn is where chooseAccount reads its answers — os.Stdin, swapped by
+// tests for the same reason interactiveTerminal is a var.
+var chooserIn io.Reader = os.Stdin
+
+// chooseAccount asks which account an omitted -account means, then — when
+// saveDest names a file — whether to remember the answer there (the caller
+// writes the config; this function only asks). An empty saveDest skips the
+// save prompt entirely: from $HOME the "project" file would be the global
+// config, and a y/N inside a ship must not carry that weight. A junk answer
+// re-prompts once; two misses, or EOF at the pick, fall back to the
+// non-interactive refusal so the caller's error path is identical either
+// way. EOF or anything but y/Y at the save prompt means no — the pick
+// itself stands.
+func chooseAccount(in io.Reader, out io.Writer, console string, accts []apiAccountEntry, saveDest string) (apiAccountEntry, bool, error) {
fmt.Fprintf(out, "you belong to several accounts on %s:\n", console)
for i, a := range accts {
fmt.Fprintf(out, " %d. %s (%s)\n", i+1, a.Name, a.Sqid)
@@ -67,7 +77,10 @@ func chooseAccount(in io.Reader, out io.Writer, console string, accts []apiAccou
if picked == nil {
return apiAccountEntry{}, false, severalAccountsErr(accts)
}
- fmt.Fprintf(out, "save %s as this project's default account? [y/N] ", picked.Name)
+ if saveDest == "" {
+ return *picked, false, nil
+ }
+ fmt.Fprintf(out, "save %s as this directory's default account (%s)? [y/N] ", picked.Name, saveDest)
save := sc.Scan() && strings.EqualFold(strings.TrimSpace(sc.Text()), "y")
return *picked, save, nil
}
diff --git a/account_choose_test.go b/account_choose_test.go
index 3296a285e..4e2d10ba6 100644
--- a/account_choose_test.go
+++ b/account_choose_test.go
@@ -22,14 +22,14 @@ func TestChooseAccount(t *testing.T) {
// A clean pick of the second entry, declining the save.
var out bytes.Buffer
- picked, save, err := chooseAccount(strings.NewReader("2\nn\n"), &out, "https://console.example", accts)
+ picked, save, err := chooseAccount(strings.NewReader("2\nn\n"), &out, "https://console.example", accts, ".carlos/config")
if err != nil || picked.Sqid != "bce" || save {
t.Fatalf("pick 2 = %+v save=%v err=%v", picked, save, err)
}
for _, want := range []string{
"you belong to several accounts on https://console.example",
"1. tom@keymail.dev (bdk)", "2. acme (bce)", "account [1-2]:",
- "save acme as this project's default account? [y/N]",
+ "save acme as this directory's default account (.carlos/config)? [y/N]",
} {
if !strings.Contains(out.String(), want) {
t.Fatalf("chooser output missing %q:\n%s", want, out.String())
@@ -38,28 +38,40 @@ func TestChooseAccount(t *testing.T) {
// Saying y to the save prompt.
out.Reset()
- picked, save, err = chooseAccount(strings.NewReader("1\ny\n"), &out, "https://console.example", accts)
+ picked, save, err = chooseAccount(strings.NewReader("1\ny\n"), &out, "https://console.example", accts, ".carlos/config")
if err != nil || picked.Sqid != "bdk" || !save {
t.Fatalf("pick 1 + save = %+v save=%v err=%v", picked, save, err)
}
+ // An empty saveDest — the caller decided saving is off the table (from
+ // $HOME the "project" file would be the global config) — asks nothing
+ // and never saves, even with a y queued up.
+ out.Reset()
+ picked, save, err = chooseAccount(strings.NewReader("1\ny\n"), &out, "https://console.example", accts, "")
+ if err != nil || picked.Sqid != "bdk" || save {
+ t.Fatalf("no-dest pick = %+v save=%v err=%v, want pick without a save", picked, save, err)
+ }
+ if strings.Contains(out.String(), "save ") {
+ t.Fatalf("no-dest chooser must not offer the save:\n%s", out.String())
+ }
+
// An empty save answer means no — N is the default.
out.Reset()
- _, save, err = chooseAccount(strings.NewReader("1\n\n"), &out, "https://console.example", accts)
+ _, save, err = chooseAccount(strings.NewReader("1\n\n"), &out, "https://console.example", accts, ".carlos/config")
if err != nil || save {
t.Fatalf("empty save answer = save=%v err=%v, want no-save", save, err)
}
// EOF at the save prompt also means no; the pick itself stands.
out.Reset()
- picked, save, err = chooseAccount(strings.NewReader("1\n"), &out, "https://console.example", accts)
+ picked, save, err = chooseAccount(strings.NewReader("1\n"), &out, "https://console.example", accts, ".carlos/config")
if err != nil || picked.Sqid != "bdk" || save {
t.Fatalf("EOF at save = %+v save=%v err=%v", picked, save, err)
}
// Junk once re-prompts; a valid second answer lands.
out.Reset()
- picked, _, err = chooseAccount(strings.NewReader("zzz\n1\nn\n"), &out, "https://console.example", accts)
+ picked, _, err = chooseAccount(strings.NewReader("zzz\n1\nn\n"), &out, "https://console.example", accts, ".carlos/config")
if err != nil || picked.Sqid != "bdk" {
t.Fatalf("junk-then-1 = %+v err=%v", picked, err)
}
@@ -70,7 +82,7 @@ func TestChooseAccount(t *testing.T) {
// Junk twice — including out-of-range numbers — is the refusal.
for _, input := range []string{"zzz\nzzz\n", "0\n3\n", ""} {
out.Reset()
- if _, _, err = chooseAccount(strings.NewReader(input), &out, "https://console.example", accts); err == nil || err.Error() != refusal {
+ if _, _, err = chooseAccount(strings.NewReader(input), &out, "https://console.example", accts, ".carlos/config"); err == nil || err.Error() != refusal {
t.Fatalf("input %q = %v, want the several-accounts refusal", input, err)
}
}
diff --git a/accounts.go b/accounts.go
index 135eb33df..d20bcac4f 100644
--- a/accounts.go
+++ b/accounts.go
@@ -217,8 +217,21 @@ func runAccountsList(args []string) {
// console API and must keep working on a box that has a registry and no
// bucket env, no credentials — while a laptop with neither registry nor
// box access (the #157 case) asks its console. An explicit -console
- // skips the registry even where one exists.
+ // skips the registry even where one exists; an explicit -registry is a
+ // commitment, not a hint — a typo'd path or an EACCES must surface as
+ // registry.Open's own error, never as a silent hop to console mode
+ // answering "not logged in" to someone who asked about a database.
+ explicitRegistry := false
+ fs.Visit(func(f *flag.Flag) {
+ if f.Name == "registry" {
+ explicitRegistry = true
+ }
+ })
if *consoleFlag == "" {
+ if explicitRegistry {
+ runAccountsListRegistry(*regPath)
+ return
+ }
if _, err := os.Stat(*regPath); err == nil {
runAccountsListRegistry(*regPath)
return
diff --git a/accounts_resolve_test.go b/accounts_resolve_test.go
index 4464126ab..54f751cdf 100644
--- a/accounts_resolve_test.go
+++ b/accounts_resolve_test.go
@@ -9,6 +9,18 @@ import (
"github.com/carlosframework/platform/internal/release"
)
+// The load-bearing invariant behind the account-default branch (#157): the
+// flag default flipped from "ops" to "" for every dual-mode verb, and
+// direct mode survives ONLY because resolveAccount reads "" as ops — with
+// no registry involved, since CI and fresh boxes have none.
+func TestResolveAccountEmptyMeansOps(t *testing.T) {
+ for _, want := range []string{"", "ops", " ops ", " "} {
+ if got := resolveAccount("/nonexistent/registry.db", want, ""); got != release.OpsAccount {
+ t.Errorf("resolveAccount(%q) = %q, want %q", want, got, release.OpsAccount)
+ }
+ }
+}
+
func TestPickAccount(t *testing.T) {
accts := []registry.Account{
{ID: 1, Sqid: "bab", Name: "ops"},
diff --git a/apimode.go b/apimode.go
index c71597cf4..2eb5a1b91 100644
--- a/apimode.go
+++ b/apimode.go
@@ -45,6 +45,11 @@ type apiSession struct {
c *consoleClient
console string // origin, for messages
address string
+ // defAcct memoizes defaultAccount's answer: resolution happens once per
+ // invocation however many times a verb asks (runSecretsSet asks twice —
+ // once for the seal key, once for the write; two chooser prompts there
+ // once sealed a value with one account's key and wrote it to another).
+ defAcct *apiAcct
}
// resolveMode decides which mode the CLI is in. (nil, nil) means direct mode
@@ -262,11 +267,29 @@ func (s *apiSession) listAccounts(ctx context.Context) (accts []apiAccountEntry,
// guessed account that stayed invisible would be the wrong-deployment
// mistake this file already refuses everywhere else.
func (s *apiSession) defaultAccount(ctx context.Context) (apiAcct, error) {
+ if s.defAcct != nil {
+ return *s.defAcct, nil
+ }
+ a, err := s.defaultAccountOnce(ctx)
+ if err == nil {
+ s.defAcct = &a
+ }
+ return a, err
+}
+
+func (s *apiSession) defaultAccountOnce(ctx context.Context) (apiAcct, error) {
name, src, fileConsole, err := configAccount()
if err != nil {
return apiAcct{}, err
}
- if name != "" && (fileConsole == "" || fileConsole == s.console) {
+ // Honored ONLY when the file names THIS console: the pair is one fact.
+ // A consoleless {"account": …} is deliberately not a wildcard — the
+ // console key is nearly inert as committed input (no credential, no
+ // request), but an account that applied to every console would let a
+ // checked-in file silently redirect a ship between stacks the caller
+ // really is a member of. saveConfigAccount always writes the pair, so
+ // only a hand-written file can even be consoleless.
+ if name != "" && fileConsole == s.console {
// The name travels verbatim, like a typed -account: the console
// resolves it against the caller's memberships, so a stale config
// value fails with the same membership 404 a typo would.
@@ -278,9 +301,12 @@ func (s *apiSession) defaultAccount(ctx context.Context) (apiAcct, error) {
return apiAcct{}, err
}
if !supported {
- // A console from before the listing endpoint cannot say whose
- // token this is — keep the pre-discovery default so nothing
- // regresses against an un-upgraded console.
+ // No memberships answer — a console from before the listing
+ // endpoint, or any 404-shaped thing in front of one. Keep the
+ // pre-discovery default so old consoles regress nothing, but SAY
+ // so: a silent ops here followed by the membership 404 is exactly
+ // the three-failures-deep riddle #157 was filed about.
+ mutedf(stdout, "account: ops (%s did not answer with your memberships)", s.console)
return apiAcct{Seg: "ops"}, nil
}
switch len(accts) {
@@ -293,21 +319,30 @@ func (s *apiSession) defaultAccount(ctx context.Context) (apiAcct, error) {
if !interactiveTerminal() {
return apiAcct{}, severalAccountsErr(accts)
}
- picked, save, err := chooseAccount(os.Stdin, stdout, s.console, accts)
+ // ./.carlos/config in the working directory — the same "run it where
+ // you want the file" rule as `auth default -project`. Except from
+ // $HOME, where that path IS ~/.carlos/config: a y there would silently
+ // repoint the machine-wide default console from inside a ship, so the
+ // save is not offered at all.
+ dest := filepath.Join(".carlos", "config")
+ if abs, err := filepath.Abs(dest); err == nil {
+ if gp, gerr := globalConfigPath(); gerr == nil && abs == gp {
+ dest = ""
+ }
+ }
+ picked, save, err := chooseAccount(chooserIn, stdout, s.console, accts, dest)
if err != nil {
return apiAcct{}, err
}
- if save {
- // ./.carlos/config in the working directory — the same "run it
- // where you want the file" rule as `auth default -project`. The
- // console is pinned alongside: configAccount reads the pair as one
- // fact, and an account without its console would silently stop
- // applying the day the directory's console default changed.
- path := filepath.Join(".carlos", "config")
- if err := saveConfigAccount(path, s.console, picked.Name, 0o755); err != nil {
- return apiAcct{}, err
+ if save && dest != "" {
+ // The save is bookkeeping, never a precondition: the human already
+ // answered, the account is known, and a read-only checkout must
+ // not turn that answer into a dead ship.
+ if err := saveConfigAccount(dest, s.console, picked.Name, 0o755); err != nil {
+ warnf(stdout, "not saved (%s): %v", dest, err)
+ } else {
+ mutedf(stdout, "saved: %s is now this directory's default account (%s)", picked.Name, dest)
}
- mutedf(stdout, "saved: %s is now this directory's default account (%s)", picked.Name, path)
}
return apiAcct{Seg: picked.Sqid, Exact: true}, nil
}
@@ -320,9 +355,16 @@ func (s *apiSession) defaultAccount(ctx context.Context) (apiAcct, error) {
// print it and stop.
func resolvedAccount(sess *apiSession, raw, rawID *string) apiAcct {
a := apiAccount(raw, rawID)
- if a.Seg != "" || sess == nil {
+ if a.Seg != "" {
return a
}
+ if sess == nil {
+ // Unreachable from any current verb (each guards its mode before
+ // resolving), and kept LOUD: returning the empty segment would
+ // build /api/cli/apps//<app> and answer a client-side bug with the
+ // membership-404 riddle.
+ log.Fatal("resolvedAccount: no session — a direct-mode path reached API account resolution (a bug in this binary)")
+ }
r, err := sess.defaultAccount(context.Background())
if err != nil {
log.Fatal(err)
diff --git a/apimode_test.go b/apimode_test.go
index fb7579a7a..5b1fd4415 100644
--- a/apimode_test.go
+++ b/apimode_test.go
@@ -2817,14 +2817,25 @@ func TestDefaultAccountSingleMembership(t *testing.T) {
}
}
+// forceTerminal pins interactiveTerminal for one test — the real check
+// reads process file descriptors, which a test controls in neither
+// direction (under `go test` stdin is /dev/null, a char device).
+func forceTerminal(t *testing.T, tty bool) {
+ t.Helper()
+ old := interactiveTerminal
+ interactiveTerminal = func() bool { return tty }
+ t.Cleanup(func() { interactiveTerminal = old })
+}
+
func TestDefaultAccountSeveralNonTTY(t *testing.T) {
useTempCredentials(t)
captureStdout(t)
+ forceTerminal(t, false)
s, _ := accountsConsole(t, http.StatusOK,
`{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev"},{"sqid":"bce","name":"acme"}]}`)
- // Under go test stdin is not a terminal, so several memberships take
- // the non-interactive refusal — which names every account.
+ // No terminal: several memberships take the non-interactive refusal —
+ // which names every account.
_, err := s.defaultAccount(context.Background())
want := "you belong to several accounts — say which with -account: tom@keymail.dev (bdk), acme (bce)"
if err == nil || err.Error() != want {
@@ -2832,6 +2843,72 @@ func TestDefaultAccountSeveralNonTTY(t *testing.T) {
}
}
+func TestDefaultAccountChooserPicksSavesAndMemoizes(t *testing.T) {
+ useTempCredentials(t)
+ out := captureStdout(t)
+ forceTerminal(t, true)
+ oldIn := chooserIn
+ chooserIn = strings.NewReader("2\ny\n")
+ t.Cleanup(func() { chooserIn = oldIn })
+ s, hits := accountsConsole(t, http.StatusOK,
+ `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev"},{"sqid":"bce","name":"acme"}]}`)
+
+ a, err := s.defaultAccount(context.Background())
+ if err != nil || a.Seg != "bce" || !a.Exact {
+ t.Fatalf("chooser pick = %+v, %v; want acme's sqid, exact", a, err)
+ }
+ // y saved the pair to ./.carlos/config (cwd is a temp dir here).
+ cfg, err := loadConfig(filepath.Join(".carlos", "config"))
+ if err != nil || cfg.Account != "acme" || cfg.Console != s.console {
+ t.Fatalf("saved config = %+v, %v; want the acme/%s pair", cfg, err, s.console)
+ }
+ if !strings.Contains(out.String(), "saved: acme") {
+ t.Fatalf("the save must be confirmed:\n%s", out.String())
+ }
+
+ // Asking again is memoized: same answer, no second fetch, no second
+ // chooser (the input reader is already drained — a re-prompt would
+ // EOF into the refusal).
+ a2, err := s.defaultAccount(context.Background())
+ if err != nil || a2 != a {
+ t.Fatalf("second resolution = %+v, %v; want the memoized first", a2, err)
+ }
+ if *hits != 1 {
+ t.Fatalf("endpoint hits = %d, want 1 — resolution happens once per invocation", *hits)
+ }
+}
+
+func TestDefaultAccountChooserSaveFailureIsNotFatal(t *testing.T) {
+ useTempCredentials(t)
+ out := captureStdout(t)
+ forceTerminal(t, true)
+ oldIn := chooserIn
+ chooserIn = strings.NewReader("1\ny\n")
+ t.Cleanup(func() { chooserIn = oldIn })
+ s, _ := accountsConsole(t, http.StatusOK,
+ `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev"},{"sqid":"bce","name":"acme"}]}`)
+
+ // A read-only working directory: the config walk still reads fine
+ // (nothing exists to read), but MkdirAll(".carlos") cannot create.
+ cwd, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(cwd, 0o555); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { os.Chmod(cwd, 0o755) })
+ // The human answered; the account is known; the save is bookkeeping.
+ // A read-only checkout must not turn the answer into a dead ship.
+ a, err := s.defaultAccount(context.Background())
+ if err != nil || a.Seg != "bdk" {
+ t.Fatalf("pick with failing save = %+v, %v; want the pick to stand", a, err)
+ }
+ if !strings.Contains(out.String(), "not saved") {
+ t.Fatalf("a failed save must be said out loud:\n%s", out.String())
+ }
+}
+
func TestDefaultAccountOldConsoleFallsBackToOps(t *testing.T) {
useTempCredentials(t)
captureStdout(t)
@@ -2843,6 +2920,21 @@ func TestDefaultAccountOldConsoleFallsBackToOps(t *testing.T) {
}
}
+func TestDefaultAccountOpsFallbackIsSaidOutLoud(t *testing.T) {
+ useTempCredentials(t)
+ out := captureStdout(t)
+ s, _ := accountsConsole(t, http.StatusNotFound, "404 page not found\n")
+
+ // The fallback must not be silent: an invisible ops followed by the
+ // membership 404 is the three-failures-deep riddle #157 is about.
+ if _, err := s.defaultAccount(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(out.String(), "account: ops") || !strings.Contains(out.String(), s.console) {
+ t.Fatalf("the ops fallback must name itself and the console:\n%s", out.String())
+ }
+}
+
func TestDefaultAccountZeroMemberships(t *testing.T) {
useTempCredentials(t)
captureStdout(t)
@@ -2886,22 +2978,31 @@ func TestDefaultAccountConfigDefault(t *testing.T) {
t.Fatalf("the config-sourced account and its file must be printed; got:\n%s", out.String())
}
- // A config account with NO console is a pure default: honored too.
+ // A config account with NO console is ignored: the pair is one fact,
+ // and an account key that applied to every console would let a
+ // checked-in file redirect ships across stacks. With zero memberships
+ // behind it, resolution reports those — proving the fall-through.
+ // (Each check below uses a fresh session: resolution memoizes per
+ // invocation, and these are separate invocations.)
writeConfig(t, ".", `{"account": "acme"}`)
- if a, err = s.defaultAccount(context.Background()); err != nil || a.Seg != "acme" {
- t.Fatalf("consoleless config account = %+v, %v", a, err)
+ s2 := newTestSession(s.console)
+ if _, err = s2.defaultAccount(context.Background()); err == nil ||
+ !strings.Contains(err.Error(), "no account") {
+ t.Fatalf("consoleless config account = %v, want fall-through to memberships", err)
+ }
+ if *hits != 1 {
+ t.Fatalf("endpoint hits = %d, want exactly the fall-through call", *hits)
}
- // Paired with a DIFFERENT console it is ignored — and with zero
- // memberships behind it, resolution reports those, proving the walk
- // fell through to the endpoint.
+ // Paired with a DIFFERENT console it is ignored the same way.
writeConfig(t, ".", `{"console": "https://other.example", "account": "acme"}`)
- if _, err = s.defaultAccount(context.Background()); err == nil ||
+ s3 := newTestSession(s.console)
+ if _, err = s3.defaultAccount(context.Background()); err == nil ||
!strings.Contains(err.Error(), "no account") {
t.Fatalf("mismatched config console = %v, want fall-through to memberships", err)
}
- if *hits != 1 {
- t.Fatalf("endpoint hits = %d, want exactly the fall-through call", *hits)
+ if *hits != 2 {
+ t.Fatalf("endpoint hits = %d, want one per fall-through", *hits)
}
}
diff --git a/appconfig_cli.go b/appconfig_cli.go
index 566d8d735..e9b382c98 100644
--- a/appconfig_cli.go
+++ b/appconfig_cli.go
@@ -608,7 +608,13 @@ func runSecretsSet(args []string) {
}
if sess != nil {
printUsing(os.Stdout, sess)
- pub := secretsPubKeyAPI(sess, resolvedAccount(sess, acctRaw, acctIDRaw), *app)
+ // Resolved ONCE, used for both the seal key and the write: two
+ // resolutions here could answer differently (the chooser asked
+ // twice), sealing the value with one account's public key and
+ // writing it into another's config — an undecryptable secret whose
+ // only symptom is a render failure in the hourly adopt log.
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ pub := secretsPubKeyAPI(sess, acct, *app)
set := make(map[string]appconfig.Var, len(kv))
for k, v := range kv {
env, err := appconfig.Seal(pub, []byte(v))
@@ -617,7 +623,7 @@ func runSecretsSet(args []string) {
}
set[k] = appconfig.Var{Kind: "secret", Envelope: env}
}
- if _, err := sess.configApply(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app,
+ if _, err := sess.configApply(context.Background(), acct, *app,
apiConfigApply{Environment: *environment, Set: set}); err != nil {
log.Fatal(err)
}
diff --git a/docs/superpowers/specs/2026-08-12-account-default-resolution-design.md b/docs/superpowers/specs/2026-08-12-account-default-resolution-design.md
index b41ccdd1b..b4e6bf2c7 100644
--- a/docs/superpowers/specs/2026-08-12-account-default-resolution-design.md
+++ b/docs/superpowers/specs/2026-08-12-account-default-resolution-design.md
@@ -62,9 +62,15 @@ session, in order:
exactly as today.
2. **Config default:** the `account` key of the nearest `.carlos/config`
(same upward walk as the console default, then global). Honored **only
- when that file's `console` matches the session's console, or the file
- names no console** — a config pair points at one stack; `-console` to a
- different stack must not drag the other stack's account along.
+ when that same file's `console` matches the session's console** — the
+ pair is one fact; `-console` to a different stack must not drag the
+ other stack's account along. A consoleless `{"account": …}` is
+ deliberately NOT a wildcard (tightened in review, 2026-08-12): the
+ committed `console` key is nearly inert as repo input — no credential,
+ no request — but an account applying to every console would let a
+ checked-in file silently redirect ships between stacks the caller
+ really is a member of. The chooser always writes the pair, so only a
+ hand-written file can even be consoleless.
3. **Memberships** via `GET /api/cli/apps`:
- exactly one → use its **sqid, exact-matched** (`?exact=sqid`), and
print a muted `account: <name> (<sqid>)` line under the "using …"
@@ -76,7 +82,11 @@ session, in order:
- zero (unreachable in practice — sign-in mints a personal account) →
error naming the console.
4. **Old console** (endpoint 404s) → send `ops`, byte-for-byte today's
- behavior. No regression against un-upgraded consoles.
+ behavior — but SAID: a muted
+ `account: ops (<console> did not answer with your memberships)` line
+ prints (review, 2026-08-12: a silent ops followed by the membership 404
+ is the exact riddle #157 was filed about, and any 404-shaped thing in
+ front of a console — proxy, mis-stamped route — lands here too).
The resolution happens once per invocation, before the first request that
needs an account path; verbs that never touch an account path never call it.
@@ -88,7 +98,7 @@ needs an account path; verbs that never touch an account path never call it.
2. acme (bce)
account [1-2]:
- save acme as this project's default account? [y/N]
+ save acme as this directory's default account (.carlos/config)? [y/N]
- Pick by number; anything else (including empty) re-prompts once, then
errors with the non-TTY message. EOF (^D) errors the same way.
@@ -96,6 +106,15 @@ needs an account path; verbs that never touch an account path never call it.
`./.carlos/config` **in the working directory** — same placement rule as
`auth default -project` ("run it where you want the file"). Any other
answer saves nothing.
+- **From `$HOME` the save is not offered at all** (review, 2026-08-12):
+ there `./.carlos/config` IS `~/.carlos/config`, and a y/N buried inside
+ a ship must not silently repoint the machine-wide default console.
+- **A failed save warns and continues** (review, 2026-08-12): the human
+ already answered and the account is known — a read-only checkout must
+ not turn bookkeeping into a dead ship.
+- Resolution is memoized on the session: however many times a verb asks
+ (`secrets set` asks for the seal key and again for the write), the
+ chooser runs at most once and both answers are the same account.
- The saved value is the account **name** (readable in a committed file);
it resolves server-side against memberships like any `-account` value.
- Single-account users never see either prompt.