carlos / platform Public

cli: whoami prints account memberships

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell aca4bea790ea3e15f9fd00e8e112404c48263032 parent 2311128
2 files changed, +73 −7
  • auth.go +15 −0
  • auth_test.go +58 −7
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)