carlos / platform Public

Merge pull request #159 from carlosframework/worktree-account-default

CLI: default -account to your own account; membership discovery (#157)
Paul Campbell 9d1084ecf16c5b16a7945112ac277d15ca2d1dd9 parent a73539fparent b12bf23
23 files changed, +1401 −100
  • account_choose.go +86 −0
  • account_choose_test.go +89 −0
  • accounts.go +71 −3
  • accounts_list_test.go +81 −0
  • accounts_resolve_test.go +12 −0
  • apimode.go +146 −0
  • apimode_test.go +245 −0
  • appconfig_cli.go +16 −10
  • apps.go +8 −7
  • auth.go +15 −0
  • auth_test.go +58 −7
  • cli.go +29 −19
  • config.go +112 −23
  • config_test.go +96 −0
  • deploy.go +2 −2
  • docs/superpowers/plans/2026-08-12-account-default-resolution.md +122 −0
  • docs/superpowers/specs/2026-08-12-account-default-resolution-design.md +182 −0
  • fleets.go +14 −13
  • instances_cli.go +7 −7
  • logs.go +2 −2
  • restart.go +1 −1
  • retention.go +1 −1
  • store.go +6 −5
diff --git a/account_choose.go b/account_choose.go
new file mode 100644
index 000000000..1f4594be4
--- /dev/null
+++ b/account_choose.go
@@ -0,0 +1,86 @@
+// The interactive account chooser: what an omitted -account means when the
+// caller belongs to several accounts and a human is at the terminal. The
+// non-interactive shape of the same question is severalAccountsErr — a
+// script cannot answer a prompt, and refusing loudly beats hanging; the
+// refusal doubles as membership discovery, since it names every account.
+package main
+
+import (
+ "bufio"
+ "fmt"
+ "io"
+ "os"
+ "strconv"
+ "strings"
+)
+
+// apiAccountEntry is one row of GET /api/cli/apps's "accounts" list
+// (internal/console handleAPIApps) — only the fields this client reads.
+// The endpoint's order is the store's creation order, so entry 1 is the
+// personal account minted at sign-up.
+type apiAccountEntry struct {
+ Name string `json:"name"`
+ Sqid string `json:"sqid"`
+ Role string `json:"role"`
+}
+
+// severalAccountsErr refuses an ambiguous omitted -account, naming every
+// membership so the refusal itself answers "what could I have said".
+func severalAccountsErr(accts []apiAccountEntry) error {
+ names := make([]string, 0, len(accts))
+ for _, a := range accts {
+ names = append(names, fmt.Sprintf("%s (%s)", a.Name, a.Sqid))
+ }
+ return fmt.Errorf("you belong to several accounts — say which with -account: %s",
+ strings.Join(names, ", "))
+}
+
+// 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. 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)
+}
+
+// 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)
+ }
+ sc := bufio.NewScanner(in)
+ var picked *apiAccountEntry
+ for attempt := 0; attempt < 2 && picked == nil; attempt++ {
+ fmt.Fprintf(out, "account [1-%d]: ", len(accts))
+ if !sc.Scan() {
+ return apiAccountEntry{}, false, severalAccountsErr(accts)
+ }
+ if n, err := strconv.Atoi(strings.TrimSpace(sc.Text())); err == nil && n >= 1 && n <= len(accts) {
+ picked = &accts[n-1]
+ }
+ }
+ if picked == nil {
+ return apiAccountEntry{}, false, severalAccountsErr(accts)
+ }
+ 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
new file mode 100644
index 000000000..4e2d10ba6
--- /dev/null
+++ b/account_choose_test.go
@@ -0,0 +1,89 @@
+package main
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+)
+
+func TestSeveralAccountsErr(t *testing.T) {
+ err := severalAccountsErr([]apiAccountEntry{
+ {Name: "tom@keymail.dev", Sqid: "bdk"}, {Name: "acme", Sqid: "bce"}})
+ want := "you belong to several accounts — say which with -account: tom@keymail.dev (bdk), acme (bce)"
+ if err.Error() != want {
+ t.Fatalf("severalAccountsErr = %q\nwant %q", err, want)
+ }
+}
+
+func TestChooseAccount(t *testing.T) {
+ accts := []apiAccountEntry{
+ {Name: "tom@keymail.dev", Sqid: "bdk"}, {Name: "acme", Sqid: "bce"}}
+ refusal := severalAccountsErr(accts).Error()
+
+ // 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, ".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 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())
+ }
+ }
+
+ // Saying y to the save prompt.
+ out.Reset()
+ 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, ".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, ".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, ".carlos/config")
+ if err != nil || picked.Sqid != "bdk" {
+ t.Fatalf("junk-then-1 = %+v err=%v", picked, err)
+ }
+ if strings.Count(out.String(), "account [1-2]:") != 2 {
+ t.Fatalf("want exactly one re-prompt:\n%s", out.String())
+ }
+
+ // 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, ".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 b398cc583..d20bcac4f 100644
--- a/accounts.go
+++ b/accounts.go
@@ -16,6 +16,7 @@ import (
"errors"
"flag"
"fmt"
+ "io"
"log"
"net/http"
"os"
@@ -39,7 +40,13 @@ const accountsUsage = `usage: carlos accounts <create|list|migrate>
over any logged-in one.
[-console <url>] which logged-in console to mint through
(only needed with several)
- list sqid, name, and app count
+ list your accounts. Signed-in (the usual case):
+ name, sqid and your role, from the console.
+ On a box with a registry: sqid, name and
+ app count from the registry itself.
+ [-console <url>] list through a console even where a
+ registry exists (or: which logged-in
+ console, with several)
migrate -app <a> -to <sqid> copy an app's objects to the account-
qualified prefix and re-stamp its routes
[-from <sqid>] source account (default: the legacy,
@@ -203,9 +210,70 @@ func createAccountViaConsole(cred *credential, regPath, name string) {
func runAccountsList(args []string) {
fs := flag.NewFlagSet("accounts list", flag.ExitOnError)
- regPath := fs.String("registry", defaultRegistry(), "registry database")
+ regPath := fs.String("registry", defaultRegistry(), "registry database (box mode)")
+ consoleFlag := fs.String("console", "", "list through a console instead (default: the box registry when one exists, else your logged-in console)")
fs.Parse(args)
- r, err := registry.Open(*regPath)
+ // Mode split by what exists, not by resolveMode: this verb predates the
+ // 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; 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
+ }
+ }
+ if err := accountsListAPI(os.Stdout, *consoleFlag); err != nil {
+ log.Fatal(err)
+ }
+}
+
+// accountsListAPI is the signed-in form: the caller's own memberships from
+// GET /api/cli/apps, as a NAME/SQID/ROLE table. The one 404 this path can
+// see is a console too old to have the endpoint (no account/app segment
+// here for the membership gate to refuse), so it gets a named refusal
+// instead of notFoundErr's check-your-flags riddle.
+func accountsListAPI(out io.Writer, consoleFlag string) error {
+ cred, err := loggedInCredential(consoleFlag)
+ if err != nil {
+ return err
+ }
+ var acc struct {
+ Accounts []apiAccountEntry `json:"accounts"`
+ }
+ status, err := newConsoleClient(cred.Console, cred.Token).
+ do(context.Background(), http.MethodGet, "/api/cli/apps", nil, &acc)
+ if status == http.StatusNotFound {
+ return fmt.Errorf("%s predates account listing — upgrade the console, or run this on a box with -registry", cred.Console)
+ }
+ if err != nil {
+ return err
+ }
+ w := newTable(out)
+ fmt.Fprintln(w, "NAME\tSQID\tROLE")
+ for _, a := range acc.Accounts {
+ fmt.Fprintf(w, "%s\t%s\t%s\n", a.Name, a.Sqid, a.Role)
+ }
+ w.Flush()
+ return nil
+}
+
+func runAccountsListRegistry(regPath string) {
+ r, err := registry.Open(regPath)
if err != nil {
log.Fatal(err)
}
diff --git a/accounts_list_test.go b/accounts_list_test.go
new file mode 100644
index 000000000..3bb1a8610
--- /dev/null
+++ b/accounts_list_test.go
@@ -0,0 +1,81 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+)
+
+// signedInConsole plants a credential for an httptest console that serves
+// GET /api/cli/apps, and returns that console's URL.
+func signedInConsole(t *testing.T, handler http.HandlerFunc) string {
+ t.Helper()
+ ts := httptest.NewServer(handler)
+ t.Cleanup(ts.Close)
+ f, err := loadCredentials()
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.put(credential{Console: ts.URL, Address: "tom@keymail.dev", Token: "tok",
+ CreatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour)})
+ if err := f.save(); err != nil {
+ t.Fatal(err)
+ }
+ return ts.URL
+}
+
+// accounts list, signed-in: no registry on this machine, a credential and a
+// console that answers — the no-infra path #157 was filed for.
+func TestAccountsListSignedIn(t *testing.T) {
+ useTempCredentials(t)
+ signedInConsole(t, func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/cli/apps" || r.Header.Get("Authorization") != "Bearer tok" {
+ http.NotFound(w, r)
+ return
+ }
+ json.NewEncoder(w).Encode(map[string]any{"accounts": []map[string]any{
+ {"sqid": "bdk", "name": "tom@keymail.dev", "role": "owner", "apps": []string{}},
+ {"sqid": "bce", "name": "acme", "role": "member", "apps": []string{"shop"}},
+ }})
+ })
+
+ var out bytes.Buffer
+ if err := accountsListAPI(&out, ""); err != nil {
+ t.Fatalf("accountsListAPI: %v", err)
+ }
+ for _, want := range []string{"NAME", "SQID", "ROLE",
+ "tom@keymail.dev", "bdk", "owner", "acme", "bce", "member"} {
+ if !strings.Contains(out.String(), want) {
+ t.Fatalf("table missing %q:\n%s", want, out.String())
+ }
+ }
+}
+
+// A console from before the listing endpoint gets a plain named refusal,
+// not the membership-404 riddle.
+func TestAccountsListOldConsole(t *testing.T) {
+ useTempCredentials(t)
+ url := signedInConsole(t, func(w http.ResponseWriter, r *http.Request) {
+ http.NotFound(w, r)
+ })
+
+ var out bytes.Buffer
+ err := accountsListAPI(&out, "")
+ if err == nil || !strings.Contains(err.Error(), url) || !strings.Contains(err.Error(), "predates") {
+ t.Fatalf("old console = %v, want a refusal naming the console", err)
+ }
+}
+
+// Not logged in and no registry: the error names the fix, exactly pick()'s.
+func TestAccountsListNeitherMode(t *testing.T) {
+ useTempCredentials(t)
+ var out bytes.Buffer
+ if err := accountsListAPI(&out, ""); err == nil ||
+ !strings.Contains(err.Error(), "carlos auth login") {
+ t.Fatalf("no credential = %v, want the login hint", err)
+ }
+}
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 a26cd81bb..2eb5a1b91 100644
--- a/apimode.go
+++ b/apimode.go
@@ -22,6 +22,7 @@ import (
"errors"
"fmt"
"io"
+ "log"
"mime/multipart"
"net/http"
"net/url"
@@ -44,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
@@ -226,6 +232,146 @@ func apiPath(a apiAcct, app, tail string) string {
return p
}
+// listAccounts fetches the caller's own memberships: GET /api/cli/apps'
+// "accounts" array (handleAPIApps — live since main 5856dc5, 2026-08-03).
+// supported=false means the console predates the endpoint: the bare mux
+// 404, which cannot be the membership gate's 404 because this path carries
+// no account or app segment for a gate to refuse. Every other non-200 is
+// a real answer (401 expired token, 500) and surfaces via apiErrorFromBody
+// — never swallowed into a fallback.
+func (s *apiSession) listAccounts(ctx context.Context) (accts []apiAccountEntry, supported bool, err error) {
+ status, body, err := s.getJSON(ctx, "/api/cli/apps")
+ if err != nil {
+ return nil, false, err
+ }
+ if status == http.StatusNotFound {
+ return nil, false, nil
+ }
+ if status != http.StatusOK {
+ return nil, false, apiErrorFromBody(status, body)
+ }
+ var out struct {
+ Accounts []apiAccountEntry `json:"accounts"`
+ }
+ if err := json.Unmarshal(body, &out); err != nil {
+ return nil, false, fmt.Errorf("unreadable accounts response from %s: %w", s.console, err)
+ }
+ return out.Accounts, true, nil
+}
+
+// defaultAccount answers an omitted -account in API mode (spec:
+// docs/superpowers/specs/2026-08-12-account-default-resolution-design.md):
+// the config default when its file's console pairs with this session, else
+// the caller's one membership, else the chooser (terminal) or the naming
+// refusal (script). Whatever is decided for the caller is printed — a
+// 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
+ }
+ // 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.
+ mutedf(stdout, "account: %s (the default from %s)", name, src)
+ return apiAcct{Seg: name}, nil
+ }
+ accts, supported, err := s.listAccounts(ctx)
+ if err != nil {
+ return apiAcct{}, err
+ }
+ if !supported {
+ // 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) {
+ case 0:
+ return apiAcct{}, fmt.Errorf("your address belongs to no account on %s — create one with: carlos accounts create -name <name>", s.console)
+ case 1:
+ mutedf(stdout, "account: %s (%s)", accts[0].Name, accts[0].Sqid)
+ return apiAcct{Seg: accts[0].Sqid, Exact: true}, nil
+ }
+ if !interactiveTerminal() {
+ return apiAcct{}, severalAccountsErr(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 && 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)
+ }
+ }
+ return apiAcct{Seg: picked.Sqid, Exact: true}, nil
+}
+
+// resolvedAccount is apiAccount plus the omitted-flag default: the seam
+// every API-mode verb reaches through. Explicit flags travel verbatim and
+// never touch the network; only an empty pair asks defaultAccount. Fatal
+// on refusal because every caller is a verb body whose next line would be
+// the request itself — there is nothing for it to do with the error but
+// print it and stop.
+func resolvedAccount(sess *apiSession, raw, rawID *string) apiAcct {
+ a := apiAccount(raw, rawID)
+ 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)
+ }
+ return r
+}
+
// notFoundErr is what every call here returns for a 404 whose body carries
// no JSON "error" field. apiAccountApp (internal/console/apicli.go) answers
// an unknown account, an unknown app, and someone else's account with the
diff --git a/apimode_test.go b/apimode_test.go
index 3c3f98f69..5b1fd4415 100644
--- a/apimode_test.go
+++ b/apimode_test.go
@@ -2778,3 +2778,248 @@ func TestAPISessionCreateAppSendsPlace(t *testing.T) {
t.Fatalf("empty place must not travel as a non-empty value; got %q", gotPlace)
}
}
+
+// accountsConsole is a console stub for the omitted--account resolution
+// tests: it serves GET /api/cli/apps with the given status and body, and
+// counts how often the endpoint is consulted.
+func accountsConsole(t *testing.T, status int, body string) (*apiSession, *int) {
+ t.Helper()
+ hits := 0
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/cli/apps" || r.Method != http.MethodGet {
+ t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
+ http.NotFound(w, r)
+ return
+ }
+ hits++
+ w.WriteHeader(status)
+ io.WriteString(w, body)
+ }))
+ t.Cleanup(ts.Close)
+ return newTestSession(ts.URL), &hits
+}
+
+func TestDefaultAccountSingleMembership(t *testing.T) {
+ useTempCredentials(t)
+ out := captureStdout(t)
+ s, hits := accountsConsole(t, http.StatusOK,
+ `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev","role":"owner","apps":[]}]}`)
+
+ a, err := s.defaultAccount(context.Background())
+ if err != nil || a.Seg != "bdk" || !a.Exact {
+ t.Fatalf("one membership = %+v, %v; want its sqid, exact", a, err)
+ }
+ if *hits != 1 {
+ t.Fatalf("endpoint hits = %d, want 1", *hits)
+ }
+ if !strings.Contains(out.String(), "account: tom@keymail.dev (bdk)") {
+ t.Fatalf("the resolved account must be printed; got:\n%s", out.String())
+ }
+}
+
+// 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"}]}`)
+
+ // 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 {
+ t.Fatalf("several = %v\nwant %q", err, want)
+ }
+}
+
+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)
+ s, _ := accountsConsole(t, http.StatusNotFound, "404 page not found\n")
+
+ a, err := s.defaultAccount(context.Background())
+ if err != nil || a.Seg != "ops" || a.Exact {
+ t.Fatalf("old console = %+v, %v; want the pre-discovery ops default", a, err)
+ }
+}
+
+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)
+ s, _ := accountsConsole(t, http.StatusOK, `{"accounts":[]}`)
+
+ if _, err := s.defaultAccount(context.Background()); err == nil ||
+ !strings.Contains(err.Error(), s.console) {
+ t.Fatalf("zero memberships = %v, want an error naming the console", err)
+ }
+}
+
+func TestDefaultAccountAuthErrorSurfaces(t *testing.T) {
+ useTempCredentials(t)
+ captureStdout(t)
+ s, _ := accountsConsole(t, http.StatusUnauthorized, `{"error":"token expired"}`)
+
+ // A 401 is an answer about the TOKEN, not about memberships — it must
+ // surface, never turn into an ops fallback that then 404s confusingly.
+ if _, err := s.defaultAccount(context.Background()); err == nil ||
+ !strings.Contains(err.Error(), "token expired") {
+ t.Fatalf("401 = %v, want the console's own diagnosis", err)
+ }
+}
+
+func TestDefaultAccountConfigDefault(t *testing.T) {
+ useTempCredentials(t)
+ out := captureStdout(t)
+ s, hits := accountsConsole(t, http.StatusOK, `{"accounts":[]}`)
+
+ // A config account paired with THIS console is honored without asking
+ // the console anything.
+ cfgPath := writeConfig(t, ".", `{"console": "`+s.console+`", "account": "acme"}`)
+ a, err := s.defaultAccount(context.Background())
+ if err != nil || a.Seg != "acme" || a.Exact {
+ t.Fatalf("config account = %+v, %v; want the name, resolved server-side", a, err)
+ }
+ if *hits != 0 {
+ t.Fatalf("endpoint hits = %d, want 0 — config answered it", *hits)
+ }
+ if !strings.Contains(out.String(), "acme") || !strings.Contains(out.String(), cfgPath) {
+ t.Fatalf("the config-sourced account and its file must be printed; got:\n%s", out.String())
+ }
+
+ // 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"}`)
+ 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 the same way.
+ writeConfig(t, ".", `{"console": "https://other.example", "account": "acme"}`)
+ 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 != 2 {
+ t.Fatalf("endpoint hits = %d, want one per fall-through", *hits)
+ }
+}
+
+func TestResolvedAccountExplicitFlagsBypass(t *testing.T) {
+ useTempCredentials(t)
+ captureStdout(t)
+ s, hits := accountsConsole(t, http.StatusOK, `{"accounts":[]}`)
+
+ raw, rawID := "acme", ""
+ if a := resolvedAccount(s, &raw, &rawID); a.Seg != "acme" || a.Exact {
+ t.Fatalf("-account acme = %+v", a)
+ }
+ raw, rawID = "", "bdk"
+ if a := resolvedAccount(s, &raw, &rawID); a.Seg != "bdk" || !a.Exact {
+ t.Fatalf("-account-id bdk = %+v", a)
+ }
+ if *hits != 0 {
+ t.Fatalf("explicit flags must never consult the endpoint; hits = %d", *hits)
+ }
+}
diff --git a/appconfig_cli.go b/appconfig_cli.go
index 59b10801a..e9b382c98 100644
--- a/appconfig_cli.go
+++ b/appconfig_cli.go
@@ -215,7 +215,7 @@ func runEnvSet(args []string) {
for k, v := range kv {
set[k] = appconfig.Var{Kind: "env", Value: v}
}
- if _, err := sess.configApply(context.Background(), apiAccount(acctRaw, acctIDRaw), *app,
+ if _, err := sess.configApply(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app,
apiConfigApply{Environment: *environment, Set: set}); err != nil {
log.Fatal(err)
}
@@ -247,7 +247,7 @@ func runEnvUnset(args []string) {
}
if sess != nil {
printUsing(os.Stdout, sess)
- removed, err := sess.configApply(context.Background(), apiAccount(acctRaw, acctIDRaw), *app,
+ removed, err := sess.configApply(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app,
apiConfigApply{Environment: *environment, Unset: fs.Args()})
if err != nil {
log.Fatal(err)
@@ -281,7 +281,7 @@ func runEnvList(args []string) {
}
if sess != nil {
printUsing(os.Stdout, sess)
- cfg, err := sess.configGet(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, *environment)
+ cfg, err := sess.configGet(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, *environment)
if err != nil {
log.Fatal(err)
}
@@ -397,7 +397,7 @@ func runEnvExecDelivery(args []string) {
if value != "" {
wire = value
}
- if _, err := sess.configApply(context.Background(), apiAccount(acctRaw, acctIDRaw), *app,
+ if _, err := sess.configApply(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app,
apiConfigApply{ExecDelivery: &wire}); err != nil {
log.Fatal(err)
}
@@ -427,7 +427,7 @@ func runEnvEnvironments(args []string) {
}
if sess != nil {
printUsing(os.Stdout, sess)
- cfg, err := sess.configGet(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, "")
+ cfg, err := sess.configGet(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, "")
if err != nil {
log.Fatal(err)
}
@@ -477,7 +477,7 @@ func runEnvSync(args []string) {
}
if sess != nil {
printUsing(os.Stdout, sess)
- if err := sess.configSync(context.Background(), apiAccount(acctRaw, acctIDRaw), *app); err != nil {
+ if err := sess.configSync(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app); err != nil {
log.Fatal(err)
}
} else if err := appconfig.TouchGeneration(context.Background(), openStore(), acctOf(), *app); err != nil {
@@ -608,7 +608,13 @@ func runSecretsSet(args []string) {
}
if sess != nil {
printUsing(os.Stdout, sess)
- pub := secretsPubKeyAPI(sess, apiAccount(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(), apiAccount(acctRaw, acctIDRaw), *app,
+ if _, err := sess.configApply(context.Background(), acct, *app,
apiConfigApply{Environment: *environment, Set: set}); err != nil {
log.Fatal(err)
}
@@ -658,7 +664,7 @@ func runSecretsUnset(args []string) {
}
if sess != nil {
printUsing(os.Stdout, sess)
- removed, err := sess.configApply(context.Background(), apiAccount(acctRaw, acctIDRaw), *app,
+ removed, err := sess.configApply(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app,
apiConfigApply{Environment: *environment, Unset: fs.Args()})
if err != nil {
log.Fatal(err)
@@ -704,7 +710,7 @@ func runSecretsList(args []string) {
}
if sess != nil {
printUsing(os.Stdout, sess)
- cfg, err := sess.configGet(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, *environment)
+ cfg, err := sess.configGet(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, *environment)
if err != nil {
log.Fatal(err)
}
diff --git a/apps.go b/apps.go
index 6686af179..baee56d07 100644
--- a/apps.go
+++ b/apps.go
@@ -75,7 +75,8 @@ const appsUsage = `usage: carlos apps <create|place|delete|restore> [flags] <app
— but NO routes come back, not even the platform apex:
run "carlos add <host>" afterwards to serve it again.
- -account <sqid|name> account the app belongs to (default: ops) — resolved
+ -account <sqid|name> account the app belongs to (default: your only
+ account, or the configured/chosen one) — resolved
by the console against your own memberships; signed-in
mode only.
-account-id <sqid> account sqid, matched as a sqid ONLY — the only
@@ -199,8 +200,8 @@ func runAppsDelete(args []string) {
}
if mode == "signed-in" {
- acct := apiAccount(acctRaw, acctIDRaw)
printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
res, err := sess.trashApp(context.Background(), acct, app)
if err != nil {
log.Fatal(err)
@@ -319,8 +320,8 @@ func runAppsRestore(args []string) {
}
if mode == "signed-in" {
- acct := apiAccount(acctRaw, acctIDRaw)
printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
res, err := sess.restoreApp(context.Background(), acct, app)
if err != nil {
log.Fatal(err)
@@ -382,7 +383,7 @@ func runAppsCreate(args []string) {
// Not accountFlags: that helper also registers -registry for direct-mode
// resolution, and this verb never resolves locally — the console resolves
// the account against the caller's own memberships.
- acctRaw := fs.String("account", "ops", "account (sqid or name) to claim the app in")
+ acctRaw := fs.String("account", "", "account (sqid or name) to claim the app in (default: your only account, or the configured/chosen one)")
acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account; this is the flag an ambiguity error tells you to use")
consoleFlag := fs.String("console", "", "console to create it through (default: the only logged-in one)")
fs.Parse(args)
@@ -415,8 +416,8 @@ func runAppsCreate(args []string) {
"box-side way to hand a name to an address. To claim through a console instead,\n" +
"unset the store env and run: carlos auth login -console <url>")
}
- acct := apiAccount(acctRaw, acctIDRaw)
printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
res, err := sess.createApp(context.Background(), acct, strings.TrimSpace(*app), place2)
if err != nil {
log.Fatalf("apps create: %v", err)
@@ -475,7 +476,7 @@ func runAppsPlace(args []string) {
// Not accountFlags: that helper also registers -registry for direct-mode
// resolution, and this verb never resolves locally — placement is
// console-tracked state, API mode only, same as apps create above.
- acctRaw := fs.String("account", "ops", "account (sqid or name) the app belongs to")
+ acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account; this is the flag an ambiguity error tells you to use")
consoleFlag := fs.String("console", "", "console to place through (default: the only logged-in one)")
fs.Parse(args)
@@ -519,7 +520,7 @@ func runAppsPlace(args []string) {
"(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). Placement is console-tracked\n" +
"state: log in instead — carlos auth login -console <url>")
}
- acct := apiAccount(acctRaw, acctIDRaw)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
if err := sess.place(context.Background(), acct, strings.TrimSpace(*app), target2); err != nil {
log.Fatalf("apps place: %v", err)
diff --git a/auth.go b/auth.go
index ade8dfe54..de3a1e22e 100644
--- a/auth.go
+++ b/auth.go
@@ -732,6 +732,21 @@ func authWhoami(out io.Writer, args []string) error {
if who.ExpiresAt != "" {
mutedf(out, " expires: %s", who.ExpiresAt)
}
+ // Memberships are a bonus line, not part of whoami's contract: identity
+ // already printed, and a console from before GET /api/cli/apps (or a
+ // blip fetching it) must not make whoami look broken — so ANY error
+ // here degrades to silence rather than a message.
+ var acc struct {
+ Accounts []apiAccountEntry `json:"accounts"`
+ }
+ if _, err := newConsoleClient(c.Console, c.Token).
+ do(context.Background(), http.MethodGet, "/api/cli/apps", nil, &acc); err == nil && len(acc.Accounts) > 0 {
+ rows := make([]string, 0, len(acc.Accounts))
+ for _, a := range acc.Accounts {
+ rows = append(rows, fmt.Sprintf("%s (%s)", a.Name, a.Sqid))
+ }
+ mutedf(out, " accounts: %s", strings.Join(rows, ", "))
+ }
return nil
}
diff --git a/auth_test.go b/auth_test.go
index bb8a50c74..6ace3ddac 100644
--- a/auth_test.go
+++ b/auth_test.go
@@ -182,13 +182,14 @@ func TestPollBoundsAreEnforced(t *testing.T) {
// fakeConsole answers the CLI endpoints the way the real console does, so the
// login loop can be driven end to end without a browser.
type fakeConsole struct {
- ts *httptest.Server
- polls atomic.Int32
- approved atomic.Bool
- denied atomic.Bool
- revoked atomic.Bool
- lastAuth atomic.Value // string: the bearer the last request carried
- label atomic.Value // string
+ ts *httptest.Server
+ polls atomic.Int32
+ approved atomic.Bool
+ denied atomic.Bool
+ revoked atomic.Bool
+ noAccounts atomic.Bool // serve 404 on GET /api/cli/apps: a pre-5856dc5 console
+ lastAuth atomic.Value // string: the bearer the last request carried
+ label atomic.Value // string
}
func newFakeConsole(t *testing.T) *fakeConsole {
@@ -237,6 +238,21 @@ func newFakeConsole(t *testing.T) *fakeConsole {
"expires_at": time.Now().Add(90 * 24 * time.Hour).UTC().Format(time.RFC3339),
})
})
+ mux.HandleFunc("GET /api/cli/apps", func(w http.ResponseWriter, r *http.Request) {
+ if fc.noAccounts.Load() {
+ http.NotFound(w, r)
+ return
+ }
+ if r.Header.Get("Authorization") != "Bearer cli-token" {
+ w.WriteHeader(http.StatusUnauthorized)
+ json.NewEncoder(w).Encode(map[string]string{"error": "not signed in"})
+ return
+ }
+ json.NewEncoder(w).Encode(map[string]any{"accounts": []map[string]any{
+ {"sqid": "bab", "name": "paul@keymail.dev", "role": "owner", "apps": []string{}},
+ {"sqid": "bce", "name": "acme", "role": "member", "apps": []string{"shop"}},
+ }})
+ })
mux.HandleFunc("POST /auth/cli/revoke", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer cli-token" {
w.WriteHeader(http.StatusUnauthorized)
@@ -325,6 +341,41 @@ func TestAuthLoginStoresACredential(t *testing.T) {
}
}
+// whoami closes the loop #157 opened: who am I, AND what can I act on.
+// Memberships come from GET /api/cli/apps; a console too old to answer
+// (bare 404) must not make whoami look broken — identity prints, the
+// bonus line is simply absent.
+func TestAuthWhoamiPrintsMemberships(t *testing.T) {
+ useTempCredentials(t)
+ fc := newFakeConsole(t)
+ var out bytes.Buffer
+ if err := authLogin(&out, []string{"-console", fc.ts.URL}); err != nil {
+ t.Fatalf("authLogin: %v", err)
+ }
+
+ out.Reset()
+ if err := authWhoami(&out, []string{"-console", fc.ts.URL}); err != nil {
+ t.Fatalf("authWhoami: %v", err)
+ }
+ if !strings.Contains(out.String(), "accounts: paul@keymail.dev (bab), acme (bce)") {
+ t.Fatalf("whoami must list memberships:\n%s", out.String())
+ }
+
+ // Same command against a console that predates the endpoint: identity
+ // only, silently — no error, no accounts line.
+ fc.noAccounts.Store(true)
+ out.Reset()
+ if err := authWhoami(&out, []string{"-console", fc.ts.URL}); err != nil {
+ t.Fatalf("authWhoami against an old console: %v", err)
+ }
+ if !strings.Contains(out.String(), "paul@keymail.dev at "+fc.ts.URL) {
+ t.Fatalf("identity line missing:\n%s", out.String())
+ }
+ if strings.Contains(out.String(), "accounts:") {
+ t.Fatalf("old console must degrade silently:\n%s", out.String())
+ }
+}
+
func TestAuthLoginDenied(t *testing.T) {
useTempCredentials(t)
fc := newFakeConsole(t)
diff --git a/cli.go b/cli.go
index 64b460d6a..d8e809046 100644
--- a/cli.go
+++ b/cli.go
@@ -1552,7 +1552,11 @@ func routeVersionCheck(ctx context.Context, store release.Store, pub ed25519.Pub
// string-coupled to this function's internal flag name the way
// fs.Lookup("account") would be.
func accountFlags(fs *flag.FlagSet) (raw, rawID *string, resolve func() string) {
- acct := fs.String("account", "ops", "account (sqid or name) the app belongs to")
+ // Empty default, two meanings by mode: API mode resolves it to the
+ // caller's own account (resolvedAccount, apimode.go); direct mode's
+ // resolveAccount reads "" as ops exactly as it always has, so box and
+ // script behavior is unchanged.
+ acct := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one; ops in direct mode)")
acctID := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account; this is the flag an ambiguity error tells you to use")
regPath := fs.String("registry", defaultRegistry(), "registry database (resolves -account)")
return acct, acctID, func() string { return resolveAccount(*regPath, *acct, *acctID) }
@@ -1688,13 +1692,19 @@ func runShip(args []string) {
log.Fatal(err)
}
if sess != nil {
- // apiAccount is resolved HERE, not inside runShipAPI: it fatals on a
- // name in -account-id, and log.Fatal skips every deferred function —
- // which is the one thing runShipAPI exists to avoid (its static
- // branch defers the cleanup of a scratch tarball up to 256MiB).
- // Evaluated as an argument, the refusal lands before runShipAPI's
- // body has registered a defer at all.
- if err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, *app, *ver, *kind, *target, *label, noteText, capsSet, true); err != nil {
+ // The banner prints BEFORE the account resolves: resolution may put
+ // a muted "account: …" line — or the whole interactive chooser — on
+ // the terminal, and a prompt with no console named yet is a question
+ // with no context. The account is still resolved HERE, not inside
+ // runShipAPI: resolvedAccount fatals (on a name in -account-id, or
+ // an ambiguous omitted -account), and log.Fatal skips every deferred
+ // function — which is the one thing runShipAPI exists to avoid (its
+ // static branch defers the cleanup of a scratch tarball up to
+ // 256MiB). Resolved before the call, the refusal lands before
+ // runShipAPI's body has registered a defer at all.
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ if err := runShipAPI(sess, acct, fs, *app, *ver, *kind, *target, *label, noteText, capsSet, false); err != nil {
log.Fatal(err)
}
return
@@ -1958,8 +1968,8 @@ func runPromote(args []string) {
// flag silently do nothing.
log.Fatal("hotfix is an operator act — use the direct path (CARLOS_DEPLOYMENT_BUCKET) or the console")
}
- acct := apiAccount(acctRaw, acctIDRaw)
printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
res, err := sess.promote(context.Background(), acct, *app, version, channel)
if err != nil {
log.Fatal(err)
@@ -2034,8 +2044,8 @@ func runChannels(args []string) {
log.Fatal(err)
}
if sess != nil {
- acct := apiAccount(acctRaw, acctIDRaw)
printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
chans, err := sess.channels(context.Background(), acct, *app)
if err != nil {
log.Fatal(err)
@@ -2131,8 +2141,8 @@ func runVersionTarget(args []string) {
log.Fatal(err)
}
if sess != nil {
- acct := apiAccount(acctRaw, acctIDRaw)
printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
if fs.NArg() == 0 {
vt, err := sess.versionTarget(context.Background(), acct, *app)
if err != nil {
@@ -2276,8 +2286,8 @@ func runReleases(args []string) {
}
var rows []releaseRow
if sess != nil {
- acct := apiAccount(acctRaw, acctIDRaw)
printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
rels, err := sess.releases(context.Background(), acct, *app)
if err != nil {
log.Fatal(err)
@@ -2323,8 +2333,8 @@ func runRollback(args []string) {
log.Fatal(err)
}
if sess != nil {
- acct := apiAccount(acctRaw, acctIDRaw)
printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
if err := sess.rollback(context.Background(), acct, *app, fs.Arg(0)); err != nil {
log.Fatal(err)
}
@@ -2444,7 +2454,7 @@ func ledgerAPISession(consoleFlag string) *apiSession {
// sqid only, so the console's own symmetric resolution can never name-match
// it.
func ledgerAccountFlags(fs *flag.FlagSet) (raw, rawID *string) {
- return fs.String("account", "ops", "account (sqid or name) the app belongs to — resolved by the console"),
+ return fs.String("account", "", "account (sqid or name) the app belongs to — resolved by the console (default: your only account, or the configured/chosen one)"),
fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
}
@@ -2481,7 +2491,7 @@ func runLedgerAppend(args []string) {
sess := ledgerAPISession(*consoleFlag)
printUsing(stdout, sess)
- a, err := sess.ledgerAppend(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, name, json.RawMessage(raw))
+ a, err := sess.ledgerAppend(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, name, json.RawMessage(raw))
if err != nil {
log.Fatal(err)
}
@@ -2507,7 +2517,7 @@ func runLedgerHead(args []string) {
}
sess := ledgerAPISession(*consoleFlag)
- h, err := sess.ledgerHead(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, name)
+ h, err := sess.ledgerHead(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, name)
if err != nil {
log.Fatal(err)
}
@@ -2530,7 +2540,7 @@ func runLedgerList(args []string) {
sess := ledgerAPISession(*consoleFlag)
printUsing(stdout, sess)
- body, err := sess.ledgerList(context.Background(), apiAccount(acctRaw, acctIDRaw), *app)
+ body, err := sess.ledgerList(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app)
if err != nil {
log.Fatal(err)
}
@@ -2587,7 +2597,7 @@ func runLedgerPublish(args []string) {
sess := ledgerAPISession(*consoleFlag)
printUsing(stdout, sess)
m := ledger.Manifest{Public: *public, Ledgers: names, Title: *title}
- got, err := sess.ledgerPublish(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, m)
+ got, err := sess.ledgerPublish(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, m)
if err != nil {
log.Fatal(err)
}
@@ -2633,7 +2643,7 @@ func runLedgerBlob(args []string) {
if len(data) > ledger.MaxBlob {
log.Fatalf("ledger blob: %s is %d bytes, exceeds the %d byte cap", path, len(data), ledger.MaxBlob)
}
- sha, err := sess.ledgerBlob(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, name, data)
+ sha, err := sess.ledgerBlob(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, name, data)
if err != nil {
log.Fatal(err)
}
diff --git a/config.go b/config.go
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
}
diff --git a/config_test.go b/config_test.go
index 4e835c085..54bf00f56 100644
--- a/config_test.go
+++ b/config_test.go
@@ -2,6 +2,7 @@ package main
import (
"bytes"
+ "encoding/json"
"os"
"path/filepath"
"strings"
@@ -325,3 +326,98 @@ func TestAuthDefaultVerb(t *testing.T) {
t.Fatal("two positional args must be a usage error")
}
}
+
+func TestConfigAccountWalk(t *testing.T) {
+ useTempCredentials(t)
+ home := os.Getenv("HOME")
+
+ // Nothing anywhere: no account default, no error.
+ if a, src, cons, err := configAccount(); err != nil || a != "" || src != "" || cons != "" {
+ t.Fatalf("bare configAccount = %q %q %q %v, want empty", a, src, cons, err)
+ }
+
+ // Only the global, and the file names no console: a pure account
+ // default, fileConsole empty.
+ globalPath := writeConfig(t, home, `{"account": "acme"}`)
+ a, src, cons, err := configAccount()
+ if err != nil || a != "acme" || src != globalPath || cons != "" {
+ t.Fatalf("global-only = %q %q %q %v", a, src, cons, err)
+ }
+
+ // A nearer file with only a console does not stop the ACCOUNT walk.
+ 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 a, _, _, err = configAccount(); err != nil || a != "acme" {
+ t.Fatalf("console-only level = %q %v, want the walk to continue to acme", a, err)
+ }
+
+ // The nearest account-bearing file wins and reports ITS console —
+ // normalized, because the pairing rule compares it to a session console.
+ nearest := writeConfig(t, nested, `{"console": "https://inner.example/", "account": "tools"}`)
+ a, src, cons, err = configAccount()
+ if err != nil || a != "tools" || src != nearest || cons != "https://inner.example" {
+ t.Fatalf("nearest = %q %q %q %v", a, src, cons, err)
+ }
+
+ // Malformed config mid-walk is an error, not a skip.
+ os.WriteFile(nearest, []byte(`not json`), 0o644)
+ if _, _, _, err = configAccount(); err == nil {
+ t.Fatal("malformed project config must be an error, not a skip")
+ }
+}
+
+func TestConfigRewritesPreserveUnknownFields(t *testing.T) {
+ useTempCredentials(t)
+ dir := t.TempDir()
+ path := filepath.Join(dir, ".carlos", "config")
+
+ // A fresh saveConfigAccount writes both keys.
+ if err := saveConfigAccount(path, "https://tito.example/", "acme", 0o755); err != nil {
+ t.Fatal(err)
+ }
+ cfg, err := loadConfig(path)
+ if err != nil || cfg.Console != "https://tito.example" || cfg.Account != "acme" {
+ t.Fatalf("fresh save = %+v, %v", cfg, err)
+ }
+
+ // A file holding a key from the future keeps it across BOTH rewrites —
+ // the old drop-on-rewrite behavior made every new key lossy.
+ if err := os.WriteFile(path, []byte(`{"console":"https://tito.example","future_key":42}`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := saveConfigAccount(path, "https://tito.example", "tools", 0o755); err != nil {
+ t.Fatal(err)
+ }
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var all map[string]any
+ if err := json.Unmarshal(raw, &all); err != nil {
+ t.Fatal(err)
+ }
+ if all["future_key"] != float64(42) || all["account"] != "tools" {
+ t.Fatalf("saveConfigAccount dropped fields: %v", all)
+ }
+ if err := saveConfigFile(path, "https://other.example/", 0o755); err != nil {
+ t.Fatal(err)
+ }
+ raw, _ = os.ReadFile(path)
+ all = map[string]any{}
+ if err := json.Unmarshal(raw, &all); err != nil {
+ t.Fatal(err)
+ }
+ if all["future_key"] != float64(42) || all["account"] != "tools" || all["console"] != "https://other.example" {
+ t.Fatalf("saveConfigFile dropped fields: %v", all)
+ }
+
+ // The console is still validated on write, account or not.
+ if err := saveConfigAccount(path, "ftp://nope", "x", 0o755); err == nil {
+ t.Fatal("saving an invalid console must fail")
+ }
+}
diff --git a/deploy.go b/deploy.go
index 9b1693322..14327c5ac 100644
--- a/deploy.go
+++ b/deploy.go
@@ -59,8 +59,8 @@ func runDeploy(args []string) {
}
sess := requireDeployAPISession(*consoleFlag) // API-mode only: member verb
- acct := apiAccount(acctRaw, acctIDRaw)
- printUsing(stdout, sess) // printed HERE, once: runShipAPI's own banner is suppressed below
+ printUsing(stdout, sess) // printed HERE, once: runShipAPI's own banner is suppressed below
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
ctx := context.Background()
recs, _, _, err := sess.instanceList(ctx, acct, *app, "", "")
if err != nil {
diff --git a/docs/superpowers/plans/2026-08-12-account-default-resolution.md b/docs/superpowers/plans/2026-08-12-account-default-resolution.md
new file mode 100644
index 000000000..bab46d065
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-12-account-default-resolution.md
@@ -0,0 +1,122 @@
+# Account Default Resolution Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** When `-account` is omitted in API mode, resolve it from config or the caller's memberships (one → use it; several → interactive chooser with a save-to-project prompt), and expose memberships via `GET /api/cli/apps` (already live), `carlos accounts list`, and `carlos auth whoami`. Closes #157.
+
+**Architecture:** One new token-gated console endpoint returning `Store.AccountsFor(addr)`. CLI-side, a single resolution seam on `apiSession` (`defaultAccount`) called through a `resolvedAccount(sess, raw, rawID)` wrapper that replaces `apiAccount(...)` at every API-mode callsite; flag defaults flip `"ops"` → `""` (direct mode already reads `""` as ops, so boxes are untouched).
+
+**Tech Stack:** Go stdlib only; existing test fixtures (`internal/console` fx, CLI httptest patterns, `carlosTestEnv`).
+
+## Global Constraints
+
+- `go build ./...`, `go vet ./...`, `go test ./... -count=1` clean; `gofmt -l` clean for touched files; `GOOS=darwin` compile passes (CLI runs on Macs — no Linux-only assumptions).
+- Sandbox env for go commands: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`.
+- Wire changes additive only; membership 404 opacity byte-identical; direct/bucket mode behavior unchanged.
+- Registry-mode verbs (`carlos route`, `carlos add`, `accounts list` on a box) keep the `"ops"` default and local resolution.
+- Commits per task; spec: `docs/superpowers/specs/2026-08-12-account-default-resolution-design.md`.
+
+---
+
+### Task 1: ~~Console endpoint~~ SUPERSEDED — endpoint already exists
+
+`GET /api/cli/apps` (handleAPIApps, internal/console/authcli.go:219, live
+since main 5856dc5 2026-08-03, deployed both consoles) already returns
+`{"accounts":[{"sqid","name","role","apps"}]}` from `Store.AccountsFor`,
+leak-tested by TestAPIAppsListsMemberships. No console change, no console
+deploy. Task 4's `listAccounts` decodes this shape; the 404 fallback covers
+only pre-2026-08-03 consoles.
+
+### Task 2: Config `account` key + lossless rewrites
+
+**Files:**
+- Modify: `config.go` (`cliConfig` :39, `loadConfigFile` :50, `saveConfigFile` :124)
+- Test: `config_test.go`
+
+**Interfaces:**
+- Produces: `cliConfig.Account`; `configAccount() (name, source, fileConsole string, err error)` — nearest `.carlos/config` walking up (then global) **whose `account` is set**; `fileConsole` is that same file's normalized console ("" if unset). `saveConfigFile(path, console, perm)` and new `saveConfigAccount(path, console, account string, dirPerm os.FileMode)` both preserve unknown JSON fields on rewrite.
+
+- [ ] **Step 1: failing tests** — `account` key round-trips; console-only files return `"" ` from `configAccount` at that level and the walk continues; nearest account-bearing file wins and reports ITS console; rewrite of a file containing `{"console":…,"future_key":42}` keeps `future_key`; `saveConfigAccount` writes both keys with the file's dir at the given perm.
+- [ ] **Step 2: run** `go test . -run Config -count=1` → FAIL.
+- [ ] **Step 3: implement** — `Account string \`json:"account"\`` on `cliConfig`; refactor the read path to return the whole `cliConfig` (keep `loadConfigFile`'s console-normalization errors verbatim); `configAccount` mirrors `configDefault`'s walk but keys off `Account != ""`; rewrites read the existing file into `map[string]any` (missing file → empty map), set `"console"`/`"account"`, `json.MarshalIndent`, write with the existing perms logic.
+- [ ] **Step 4: pass**; **Step 5: commit** `cli: .carlos/config account key; rewrites preserve unknown fields`.
+
+### Task 3: Interactive chooser
+
+**Files:**
+- Create: `account_choose.go`, `account_choose_test.go`
+
+**Interfaces:**
+- Consumes: `charDevice` (color.go :93 — read its exact signature first and adapt).
+- Produces: `type apiAccountEntry struct{ Name, Sqid string }` (JSON tags `name`/`sqid` — Task 4 decodes into it); `chooseAccount(in io.Reader, out io.Writer, console string, accts []apiAccountEntry) (picked apiAccountEntry, save bool, err error)`; `interactiveTerminal() bool` (stdin AND stdout are char devices); `severalAccountsErr(accts []apiAccountEntry) error` producing exactly: `you belong to several accounts — say which with -account: tom@keymail.dev (bdk), acme (bce)`.
+
+- [ ] **Step 1: failing tests** — pick "2\n" returns accts[1]; junk then "1\n" re-prompts once; junk twice → `severalAccountsErr` text; EOF → same; save prompt: "y\n" → save=true, "\n"/"n\n" → false; output contains the numbered list and `account [1-2]:`.
+- [ ] **Step 2: run** → FAIL. **Step 3: implement** with `bufio.NewScanner(in)`, prompt loop (2 attempts), then `save <name> as this project's default account? [y/N]` reading one more line, `strings.EqualFold(answer, "y")`.
+- [ ] **Step 4: pass**; **Step 5: commit** `cli: interactive account chooser`.
+
+### Task 4: Resolution seam on apiSession
+
+**Files:**
+- Modify: `apimode.go` (near `apiAccount` :202)
+- Test: `apimode_test.go`
+
+**Interfaces:**
+- Consumes: `s.getJSON`, `apiErrorFromBody`, Task 2 `configAccount`, Task 3 chooser/entry/refusal, `saveConfigAccount`, `mutedf`, package `stdout`.
+- Produces:
+
+```go
+func (s *apiSession) listAccounts(ctx context.Context) (accts []apiAccountEntry, supported bool, err error)
+// 404 → (nil, false, nil): console predates the endpoint. Non-200/404 → apiErrorFromBody.
+
+func (s *apiSession) defaultAccount(ctx context.Context) (apiAcct, error)
+// order: configAccount (honored iff fileConsole=="" || fileConsole==s.console;
+// prints muted `account: <name> (the default from <source>)`) → listAccounts:
+// !supported → apiAcct{Seg:"ops"}; 0 → error naming s.console; 1 → muted
+// `account: <name> (<sqid>)`, apiAcct{Seg: sqid, Exact: true}; several →
+// interactiveTerminal() ? chooser (on save=true saveConfigAccount(
+// filepath.Join(".carlos","config"), s.console, picked.Name, 0o755) + muted
+// confirmation) : severalAccountsErr. Chosen entry returns Seg=sqid, Exact.
+
+func resolvedAccount(sess *apiSession, raw, rawID *string) apiAcct
+// apiAccount(raw, rawID); if Seg=="" && sess!=nil → sess.defaultAccount
+// (context.Background()), log.Fatal on error. sess==nil callers never reach
+// it (verbs split modes before touching it).
+```
+
+- [ ] **Step 1: failing tests** — httptest console serving `/api/cli/apps`: one account → Seg=sqid+Exact; several (non-TTY test env) → `severalAccountsErr` text; 404 → `{Seg:"ops"}`; explicit `-account`/`-account-id` never call the endpoint (assert via handler hit-counter); config account + matching console → its name, endpoint untouched; config console mismatch → falls through to memberships; expired-token 401 surfaces as an error, not a fallback.
+- [ ] **Step 2: run** → FAIL. **Step 3: implement.** **Step 4: pass.** **Step 5: commit** `cli: resolve omitted -account from config or memberships`.
+
+### Task 5: Flag-default flip + callsite sweep
+
+**Files:**
+- Modify: `cli.go` (:1555 accountFlags, :2447 ledgerAccountFlags, ship :1697, promote/rollback/channels/versions/tags sites :1961–:2326, ledger sites :2484–:2636), `apps.go` (:202, :322, :385, :418, :478, :522), `appconfig_cli.go` (14 sites), `deploy.go` :62, `restart.go` :41, `store.go` (4), `retention.go` :54, `instances_cli.go` (:244, :267, :307 + sites), `fleets.go` (6 defaults + 6 sites), `logs.go` (:70, :95)
+- **Leave alone:** `cli.go:72` and `cli.go:403` (registry-mode `route`/list verbs — genuine ops default).
+
+**Interfaces:** consumes Task 4's `resolvedAccount` only.
+
+- [ ] **Step 1:** flip every listed `fs.String("account", "ops", …)` to `fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")` — matching each file's existing help-string wording style; same for `accountFlags`/`ledgerAccountFlags`.
+- [ ] **Step 2:** replace every listed `apiAccount(acctRaw, acctIDRaw)` with `resolvedAccount(sess, acctRaw, acctIDRaw)`. Dual-mode verbs (ship, promote, etc. via `accountFlags`) only hit this on their `sess != nil` branch; their direct branch keeps `acctOf()` → `resolveAccount` (`""` → ops, unchanged).
+- [ ] **Step 3:** `go build ./... && go vet ./... && go test ./... -count=1` full-suite — existing tests that shipped with `-account` omitted against httptest consoles will exercise the new fallback; fix any that asserted the literal `ops` segment by adding the endpoint 404 (fallback keeps them green) or an explicit `-account ops`.
+- [ ] **Step 4: commit** `cli: -account defaults to your own account in API mode`.
+
+### Task 6: whoami memberships
+
+**Files:** modify `auth.go` (`authWhoami` :702), test in `auth_e2e_test.go` or the httptest pattern used by whoami today.
+
+- [ ] **Step 1: failing test** — console serving both endpoints: output contains `accounts: tom@keymail.dev (bdk), acme (bce)`; console without the endpoint → whoami output unchanged, exit 0.
+- [ ] **Step 2:** after the identity print, `newConsoleClient(c.Console, c.Token).do(ctx, GET, "/api/cli/apps", nil, &struct{ Accounts []apiAccountEntry }{…})`; on ANY error print nothing; else one muted line `accounts: n1 (s1), n2 (s2)`.
+- [ ] **Step 3: pass; commit** `cli: whoami prints account memberships`.
+
+### Task 7: accounts list signed-in mode
+
+**Files:** modify `accounts.go` (`runAccountsList` :204), test in `accounts` CLI test file (follow existing patterns; shell-outs use `carlosTestEnv` + scratch `cmd.Dir`).
+
+- [ ] **Step 1: failing test** — with no registry file at `-registry` and a credential for an httptest console: NAME/SQID table of the caller's accounts; with a registry file present: today's registry table (existing behavior test stays green); old console (404) → error naming the console as predating account listing.
+- [ ] **Step 2:** add `-console` flag; if `*consoleFlag == ""` and `os.Stat(*regPath)` succeeds → existing registry path verbatim; else `loggedInCredential(*consoleFlag)` (fatal on err), GET `/api/cli/apps`, on 404 fatal `"%s does not support account listing — it predates this CLI"`, else `newTable` NAME/SQID.
+- [ ] **Step 3: pass; commit** `cli: accounts list works signed-in (#157)`.
+
+### Task 8: Gate + PR
+
+- [ ] Full gate: build, vet, `go test ./... -count=1`, `gofmt -l` on touched files, `GOOS=darwin go build ./...`.
+- [ ] Opus review of the console endpoint + resolution seam (security-touching per delegation policy); fix findings.
+- [ ] Push branch, PR titled `CLI: default -account to your own account; membership discovery (#157)`, body maps spec sections to tasks, `Closes #157`.
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
new file mode 100644
index 000000000..b4e6bf2c7
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-12-account-default-resolution-design.md
@@ -0,0 +1,182 @@
+# Account default resolution and membership discovery
+
+**Date:** 2026-08-12 · **Issue:** #157 · **Status:** approved (Paul, in-session)
+
+## Problem
+
+Every API-mode verb defaults `-account` to `ops` — an account that does not
+exist on real consoles — so a new user's first ship answers the deliberately
+opaque membership 404. Worse, nothing exposes a signed-in caller's own
+memberships: `carlos accounts list` opens the registry DB on local disk, and
+`carlos auth whoami` reports identity only. A user who omits `-account` is
+guessing at a value the console knew all along (Tom, 2026-08-11: first ship
+on console.carloku.com, three failures deep before learning his account is
+named by his sign-in address).
+
+Per the no-infra rule, the CLI must answer this itself: discover the
+caller's accounts, and when the answer is unambiguous, just use it.
+
+## Decisions (Paul, 2026-08-12)
+
+- **One membership → it is the default.** No flag needed.
+- **Several memberships → interactive chooser** on a terminal; **refuse and
+ list** when stdin/stdout is not a TTY (a script cannot answer a prompt;
+ refusing loudly beats hanging, and the refusal doubles as discovery).
+- **Chooser offers to save** the pick as the project's default account
+ (`./.carlos/config`), one y/N prompt.
+- `carlos auth whoami` prints memberships; `carlos accounts list` gains a
+ signed-in mode.
+- Direct/bucket mode is untouched: `ops` remains its genuine default.
+
+## Wire (no change needed)
+
+The endpoint already exists — discovered during implementation
+(2026-08-12): `GET /api/cli/apps` (handleAPIApps, live since main 5856dc5,
+2026-08-03, deployed on both production consoles) answers with the
+caller's own live memberships:
+
+ → 200 {"accounts": [{"sqid": "bdk", "name": "tom@keymail.dev",
+ "role": "owner", "apps": […]}, …]}
+
+- `Store.AccountsFor`-backed: deleted accounts excluded, creation order
+ (the console switcher's order) — the chooser's first entry is the
+ personal account minted at sign-up. Leak-tested
+ (TestAPIAppsListsMemberships).
+- The CLI decodes only `name`/`sqid` (plus `role` for `accounts list`).
+ **No new endpoint and no console deploy required**; the 404 fallback
+ below covers only consoles older than 2026-08-03.
+
+## CLI resolution (API mode)
+
+The `-account` flag default changes from `"ops"` to `""` on every verb that
+takes it (the `accountFlags` helper and every hand-rolled API-verb pair —
+`apps create`/`delete`/`restore`/`place` and any sibling found in the
+implementation sweep).
+Direct mode is unaffected: `resolveAccount` already answers `""` with
+`release.OpsAccount`.
+
+In API mode an empty account resolves through one shared seam on the
+session, in order:
+
+1. **Explicit flags** (`-account-id`, then `-account`) — travel verbatim,
+ 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 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 …"
+ banner so what was guessed is always visible;
+ - several + TTY (stdin AND stdout are char devices) → numbered chooser,
+ then the save prompt;
+ - several + non-TTY → error:
+ `you belong to several accounts — say which with -account: tom@keymail.dev (bdk), acme (bce)`;
+ - 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 — 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.
+
+## Chooser UX
+
+ you belong to several accounts on https://console.carloku.com:
+ 1. tom@keymail.dev (bdk)
+ 2. acme (bce)
+ account [1-2]:
+
+ 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.
+- `y` writes `{"console": <session console>, "account": <name>}` to
+ `./.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.
+
+## Config schema
+
+`cliConfig` gains `Account string \`json:"account"\``. Two behavior changes
+to the file handling:
+
+- **Rewrites preserve unknown fields** from now on: load into a raw map,
+ set the keys being written, marshal back. Today's "unknown fields are
+ dropped on rewrite" makes every future key lossy against old binaries;
+ this stops the bleeding for keys added after this one.
+- **Accepted wrinkle:** binaries older than this change still drop the
+ `account` key when they rewrite the file (`auth default <url>`). It is a
+ default, not data — recoverable by re-choosing.
+
+A config `account` that no longer matches any membership fails server-side
+with the membership 404 — the error is not rewritten, but the muted
+`account: …` line above it names what the config supplied and from which
+file, so the fix is evident.
+
+## Discovery verbs
+
+- **`carlos accounts list`**: signed-in mode calls the endpoint and prints
+ a NAME/SQID/ROLE table (role rides free on the existing wire shape). The
+ registry form stays for boxes; the split keys off **registry-file
+ existence, not `resolveMode`** — the verb must keep working on a box
+ with a registry and neither bucket env nor credentials. `-console` skips
+ the registry even where one exists.
+- **`carlos auth whoami`**: after identity, fetches the endpoint and prints
+ `accounts: tom@keymail.dev (bdk), acme (bce)`. Degrades **silently** on
+ 404/error — whoami's job is identity; the membership line is a bonus, and
+ an old console must not make whoami look broken.
+
+## Security
+
+- The endpoint returns only the caller's own memberships, resolved from the
+ token's address — no account enumeration surface.
+- The chooser writes only `./.carlos/config` (0755 dir, non-secret file,
+ committed on purpose) and only after an explicit `y`.
+- The membership 404's byte-identical opacity for wrong/foreign accounts is
+ unchanged.
+
+## Testing
+
+- Console: `authcli_test` — own memberships only, deleted excluded, sorted,
+ 401 bare/wrong token.
+- CLI resolution: table test against httptest consoles — one/several/zero
+ memberships, endpoint-404 fallback to `ops`, config-account honored,
+ config console-mismatch ignored, explicit flags bypass everything.
+- Chooser: unit test with injected reader/writer — pick, re-prompt, EOF,
+ save-yes writes the file, save-no doesn't. TTY gating itself is covered
+ by the non-TTY refusal path in the resolution tests.
+- Config: `account` key round-trips; rewrite preserves unknown fields;
+ console-only files still parse ("" account = no default at this level).
+- Existing `carlosTestEnv` rules apply to anything that shells the binary.
+
+## Out of scope
+
+- #81 (apps create echoes sqid where a human wants the name), #83 (promote
+ version picker), #79 (ship-before-claim recovery).
+- A `carlos accounts default` verb — the chooser's save prompt plus editing
+ the file covers it.
+- Any change to box-side/direct-mode account handling.
diff --git a/fleets.go b/fleets.go
index 8dffa5b06..efaa88cda 100644
--- a/fleets.go
+++ b/fleets.go
@@ -76,7 +76,8 @@ const fleetsUsage = `usage: carlos fleets <create|add-box|rotate-token|tag-box|d
list every fleet the account owns, with each box's label,
status and tags.
- -account <sqid|name> account the fleet belongs to (default: ops) —
+ -account <sqid|name> account the fleet belongs to (default: your only
+ account, or the configured/chosen one) —
resolved by the console against your own
memberships.
-account-id <sqid> account sqid, matched as a sqid ONLY — supersedes
@@ -155,7 +156,7 @@ func validBoxLabel(label string) error {
func runFleetsCreate(args []string) {
fs := flag.NewFlagSet("fleets create", flag.ExitOnError)
bucket := fs.String("bucket", "", "the customer's own data bucket (must already exist)")
- acctRaw := fs.String("account", "ops", "account (sqid or name) to own the fleet")
+ acctRaw := fs.String("account", "", "account (sqid or name) to own the fleet (default: your only account, or the configured/chosen one)")
acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
consoleFlag := fs.String("console", "", "console to create it through (default: the only logged-in one)")
fs.Parse(args)
@@ -170,8 +171,8 @@ func runFleetsCreate(args []string) {
bucketV := strings.TrimSpace(*bucket)
sess := fleetsSignedInSession(*consoleFlag)
- acct := apiAccount(acctRaw, acctIDRaw)
fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
f, err := sess.createFleet(context.Background(), acct, name, bucketV)
if err != nil {
log.Fatalf("fleets create: %v", err)
@@ -182,7 +183,7 @@ func runFleetsCreate(args []string) {
func runFleetsAddBox(args []string) {
fs := flag.NewFlagSet("fleets add-box", flag.ExitOnError)
- acctRaw := fs.String("account", "ops", "account (sqid or name) the fleet belongs to")
+ acctRaw := fs.String("account", "", "account (sqid or name) the fleet belongs to (default: your only account, or the configured/chosen one)")
acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
consoleFlag := fs.String("console", "", "console to add the box through (default: the only logged-in one)")
fs.Parse(args)
@@ -199,8 +200,8 @@ func runFleetsAddBox(args []string) {
}
sess := fleetsSignedInSession(*consoleFlag)
- acct := apiAccount(acctRaw, acctIDRaw)
fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
token, err := sess.addFleetBox(context.Background(), acct, fleet, label)
if err != nil {
log.Fatalf("fleets add-box: %v", err)
@@ -210,7 +211,7 @@ func runFleetsAddBox(args []string) {
func runFleetsRotateToken(args []string) {
fs := flag.NewFlagSet("fleets rotate-token", flag.ExitOnError)
- acctRaw := fs.String("account", "ops", "account (sqid or name) the fleet belongs to")
+ acctRaw := fs.String("account", "", "account (sqid or name) the fleet belongs to (default: your only account, or the configured/chosen one)")
acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
consoleFlag := fs.String("console", "", "console to rotate through (default: the only logged-in one)")
fs.Parse(args)
@@ -227,8 +228,8 @@ func runFleetsRotateToken(args []string) {
}
sess := fleetsSignedInSession(*consoleFlag)
- acct := apiAccount(acctRaw, acctIDRaw)
fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
token, err := sess.rotateFleetBoxToken(context.Background(), acct, fleet, label)
if err != nil {
log.Fatalf("fleets rotate-token: %v", err)
@@ -273,7 +274,7 @@ func printFleetToken(fleet, label, token string, rotated bool) {
// "named, empty", not the value in *tags.
func runFleetsTagBox(args []string) {
fs := flag.NewFlagSet("fleets tag-box", flag.ExitOnError)
- acctRaw := fs.String("account", "ops", "account (sqid or name) the fleet belongs to")
+ acctRaw := fs.String("account", "", "account (sqid or name) the fleet belongs to (default: your only account, or the configured/chosen one)")
acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
consoleFlag := fs.String("console", "", "console to tag through (default: the only logged-in one)")
tags := fs.String("tags", "", `comma-separated tags, full replace ("" clears every tag) — required`)
@@ -305,8 +306,8 @@ func runFleetsTagBox(args []string) {
}
sess := fleetsSignedInSession(*consoleFlag)
- acct := apiAccount(acctRaw, acctIDRaw)
fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
if err := sess.tagFleetBox(context.Background(), acct, fleet, label, tagsV); err != nil {
log.Fatalf("fleets tag-box: %v", err)
}
@@ -319,7 +320,7 @@ func runFleetsTagBox(args []string) {
func runFleetsDetachBox(args []string) {
fs := flag.NewFlagSet("fleets detach-box", flag.ExitOnError)
- acctRaw := fs.String("account", "ops", "account (sqid or name) the fleet belongs to")
+ acctRaw := fs.String("account", "", "account (sqid or name) the fleet belongs to (default: your only account, or the configured/chosen one)")
acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
consoleFlag := fs.String("console", "", "console to detach through (default: the only logged-in one)")
fs.Parse(args)
@@ -336,8 +337,8 @@ func runFleetsDetachBox(args []string) {
}
sess := fleetsSignedInSession(*consoleFlag)
- acct := apiAccount(acctRaw, acctIDRaw)
fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
if err := sess.detachFleetBox(context.Background(), acct, fleet, label); err != nil {
log.Fatalf("fleets detach-box: %v", err)
}
@@ -346,14 +347,14 @@ func runFleetsDetachBox(args []string) {
func runFleetsList(args []string) {
fs := flag.NewFlagSet("fleets ls", flag.ExitOnError)
- acctRaw := fs.String("account", "ops", "account (sqid or name) to list fleets for")
+ acctRaw := fs.String("account", "", "account (sqid or name) to list fleets for (default: your only account, or the configured/chosen one)")
acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
consoleFlag := fs.String("console", "", "console to list through (default: the only logged-in one)")
fs.Parse(args)
sess := fleetsSignedInSession(*consoleFlag)
- acct := apiAccount(acctRaw, acctIDRaw)
fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
fleets, err := sess.listFleets(context.Background(), acct)
if err != nil {
log.Fatalf("fleets ls: %v", err)
diff --git a/instances_cli.go b/instances_cli.go
index 04b37bca0..cf5c6977a 100644
--- a/instances_cli.go
+++ b/instances_cli.go
@@ -118,8 +118,8 @@ func runInstancesEnable(args []string) {
}
seedReq = &argv
}
- acct := apiAccount(acctRaw, acctIDRaw)
printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
m, err := sess.instancesEnable(context.Background(), acct, *app, channelReq, seedReq)
if err != nil {
log.Fatalf("instances enable: %v", err)
@@ -241,7 +241,7 @@ func runInstancesCreate(args []string) {
region := fs.String("region", "", "region code for the record (subject to the manifest's allow-list)")
channel := fs.String("channel", "", "release channel the instance follows (default: the manifest's, then stable)")
consoleFlag := fs.String("console", "", "console to create through (default: the only logged-in one)")
- acctRaw := fs.String("account", "ops", "account (sqid or name) the app belongs to")
+ acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
fs.Parse(args)
if *app == "" || *host == "" {
@@ -249,7 +249,7 @@ func runInstancesCreate(args []string) {
}
sess := requireAPISession("create", *consoleFlag)
printUsing(stdout, sess)
- rec, err := sess.instanceCreate(context.Background(), apiAccount(acctRaw, acctIDRaw), *app,
+ rec, err := sess.instanceCreate(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app,
strings.TrimSpace(*host), strings.TrimSpace(*region), strings.TrimSpace(*channel))
if err != nil {
log.Fatalf("instances create: %v", err)
@@ -264,14 +264,14 @@ func runInstancesList(args []string) {
match := fs.String("match", "", "only hosts containing this substring")
health := fs.String("health", "", "only this state: running, serving, setting-up, asleep, no-readings, not-responding, failed")
consoleFlag := fs.String("console", "", "console to list through (default: the only logged-in one)")
- acctRaw := fs.String("account", "ops", "account (sqid or name) the app belongs to")
+ acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
fs.Parse(args)
if *app == "" {
log.Fatal(instancesRecordsUsage)
}
sess := requireAPISession("list", *consoleFlag)
- recs, truncated, total, err := sess.instanceList(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, *match, *health)
+ recs, truncated, total, err := sess.instanceList(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, *match, *health)
if err != nil {
log.Fatalf("instances list: %v", err)
}
@@ -304,7 +304,7 @@ func runInstancesDelete(args []string) {
app := fs.String("app", "", "app the instance belongs to")
host := fs.String("host", "", "instance host whose record to retire")
consoleFlag := fs.String("console", "", "console to delete through (default: the only logged-in one)")
- acctRaw := fs.String("account", "ops", "account (sqid or name) the app belongs to")
+ acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
fs.Parse(args)
if *app == "" || *host == "" {
@@ -312,7 +312,7 @@ func runInstancesDelete(args []string) {
}
sess := requireAPISession("delete", *consoleFlag)
printUsing(stdout, sess)
- if err := sess.instanceDelete(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, strings.TrimSpace(*host)); err != nil {
+ if err := sess.instanceDelete(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, strings.TrimSpace(*host)); err != nil {
log.Fatalf("instances delete: %v", err)
}
fmt.Fprintf(stdout, "%s %s — its route retires on the next reconcile pass; parked data stays\n",
diff --git a/logs.go b/logs.go
index 558f7fdb0..dd267d1db 100644
--- a/logs.go
+++ b/logs.go
@@ -67,7 +67,7 @@ func runLogs(args []string) {
n := fs.Int("n", 0, "at most this many lines (default 1000, max 5000)")
follow := fs.Bool("f", false, "follow: poll for new lines until interrupted")
consoleFlag := fs.String("console", "", "console to read through (default: the only logged-in one)")
- acctRaw := fs.String("account", "ops", "account (sqid or name) the app belongs to")
+ acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
fs.Usage = func() { fmt.Fprint(os.Stderr, logsUsage) }
fs.Parse(args)
@@ -92,7 +92,7 @@ func runLogs(args []string) {
}
sess := requireAPISession("logs", *consoleFlag)
- acct := apiAccount(acctRaw, acctIDRaw)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
ctx := context.Background()
res, err := sess.logsGet(ctx, acct, *app, q)
if err != nil {
diff --git a/restart.go b/restart.go
index 25c9e5a31..c9d628b9c 100644
--- a/restart.go
+++ b/restart.go
@@ -38,8 +38,8 @@ func runRestart(args []string) {
}
sess := requireRestartAPISession(*consoleFlag)
- acct := apiAccount(acctRaw, acctIDRaw)
printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
res, err := sess.restart(context.Background(), acct, name)
if err != nil {
log.Fatalf("restart: %v", err)
diff --git a/retention.go b/retention.go
index f031cded9..f4de48820 100644
--- a/retention.go
+++ b/retention.go
@@ -51,8 +51,8 @@ func runReleasesRetention(args []string) {
}
sess := requireRetentionAPISession(*consoleFlag)
- acct := apiAccount(acctRaw, acctIDRaw)
printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
ctx := context.Background()
switch {
case *off:
diff --git a/store.go b/store.go
index 3192348bb..05075cefe 100644
--- a/store.go
+++ b/store.go
@@ -60,7 +60,8 @@ const storeUsage = `usage: carlos store <create|status|grant|rotate> [flags]
until -finish deletes it. A member verb: spends
nothing new, so there is no operator gate here.
- -account <sqid|name> account the app belongs to (default: ops) —
+ -account <sqid|name> account the app belongs to (default: your only
+ account, or the configured/chosen one) —
resolved by the console against your own
memberships; signed-in mode only.
-account-id <sqid> account sqid, matched as a sqid ONLY.
@@ -142,8 +143,8 @@ func runStoreCreate(args []string) {
}
sess := requireStoreAPISession(*consoleFlag)
- acct := apiAccount(acctRaw, acctIDRaw)
printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
res, err := sess.storeCreate(context.Background(), acct, name, strings.TrimSpace(*envPrefix))
if err != nil {
log.Fatalf("store create: %v", err)
@@ -166,8 +167,8 @@ func runStoreStatus(args []string) {
}
sess := requireStoreAPISession(*consoleFlag)
- acct := apiAccount(acctRaw, acctIDRaw)
printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
res, err := sess.storeGet(context.Background(), acct, name)
if err != nil {
log.Fatalf("store status: %v", err)
@@ -191,8 +192,8 @@ func runStoreGrant(args []string) {
}
sess := requireStoreAPISession(*consoleFlag)
- acct := apiAccount(acctRaw, acctIDRaw)
printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
res, err := sess.storeGrant(context.Background(), acct, name, strings.TrimSpace(*bucket))
if err != nil {
log.Fatalf("store grant: %v", err)
@@ -216,8 +217,8 @@ func runStoreRotate(args []string) {
}
sess := requireStoreAPISession(*consoleFlag)
- acct := apiAccount(acctRaw, acctIDRaw)
printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
res, err := sess.storeRotate(context.Background(), acct, name, *finish)
if err != nil {
log.Fatalf("store rotate: %v", err)