cli: accounts list works signed-in (#157)
Mode split by registry-file existence, not resolveMode — the verb must keep working on a box with a registry and no credentials, and now works on a laptop with neither registry nor box access.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+145
−8
accounts.go+58 −3accounts_list_test.go+81 −0docs/superpowers/specs/2026-08-12-account-default-resolution-design.md+6 −5
diff --git a/accounts.go b/accounts.go| index b398cc583..135eb33df 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,57 @@ 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. |
| + if *consoleFlag == "" { |
| + 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/docs/superpowers/specs/2026-08-12-account-default-resolution-design.md b/docs/superpowers/specs/2026-08-12-account-default-resolution-design.md| index 8db27311c..b41ccdd1b 100644 |
| --- a/docs/superpowers/specs/2026-08-12-account-default-resolution-design.md |
| +++ b/docs/superpowers/specs/2026-08-12-account-default-resolution-design.md |
| @@ -120,11 +120,12 @@ file, so the fix is evident. |
| ## Discovery verbs |
| -- **`carlos accounts list`**: signed-in mode (no `CARLOS_DEPLOYMENT_BUCKET` |
| - / `CARLOS_DEPLOYMENT_DIR`, credential present) calls the endpoint and |
| - prints a NAME/SQID table. The registry form stays for boxes, keyed off |
| - the same `resolveMode` split every other dual-mode verb uses; gains |
| - `-console`. |
| +- **`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 |