From ec6dd00099dc3332456dd1cabaca6d82deb38739 Mon Sep 17 00:00:00 2001 From: Elom Gomez Date: Wed, 19 Aug 2026 12:33:42 -0500 Subject: [PATCH 1/4] Add pscale org member list, show, update, and remove Co-authored-by: Cursor --- AGENTS.md | 11 + internal/cmd/org/member.go | 123 +++++++++++ internal/cmd/org/member_list.go | 61 ++++++ internal/cmd/org/member_remove.go | 90 +++++++++ internal/cmd/org/member_show.go | 52 +++++ internal/cmd/org/member_test.go | 191 ++++++++++++++++++ internal/cmd/org/member_update.go | 82 ++++++++ internal/cmd/org/org.go | 1 + internal/mock/org.go | 32 +++ internal/planetscale/organization_members.go | 141 +++++++++++++ .../planetscale/organization_members_test.go | 90 +++++++++ internal/planetscale/organizations.go | 4 + 12 files changed, 878 insertions(+) create mode 100644 internal/cmd/org/member.go create mode 100644 internal/cmd/org/member_list.go create mode 100644 internal/cmd/org/member_remove.go create mode 100644 internal/cmd/org/member_show.go create mode 100644 internal/cmd/org/member_test.go create mode 100644 internal/cmd/org/member_update.go create mode 100644 internal/planetscale/organization_members.go create mode 100644 internal/planetscale/organization_members_test.go diff --git a/AGENTS.md b/AGENTS.md index 817c685d..093bee5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,17 @@ pscale --org database list --format json Pass `--org ` on resource commands (`database`, `branch`, `sql`, `api`, …). Not on `org list`. + Organization members (`user_id` from list is what show/update/remove take; email also works): + + ```bash + pscale org member list --org --format json + pscale org member show --org --format json + pscale org member update --org --format json --role member + pscale org member remove --org --format json --force + ``` + + Only org admins can change another member's role or remove someone else. Nobody can change their own role. `--role` is `admin`, `member`, or `analyst`. + 5. **Discover resources** before SQL: ```bash diff --git a/internal/cmd/org/member.go b/internal/cmd/org/member.go new file mode 100644 index 00000000..f751e201 --- /dev/null +++ b/internal/cmd/org/member.go @@ -0,0 +1,123 @@ +package org + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" + "github.com/spf13/cobra" +) + +func MemberCmd(ch *cmdutil.Helper) *cobra.Command { + cmd := &cobra.Command{ + Use: "member ", + Short: "List, show, update, and remove organization members", + Long: `Manage organization members and their roles. + +Only organization admins can change another member's role or remove someone +else. Nobody can change their own role. Members can still leave the organization +themselves.`, + } + + cmd.PersistentFlags().StringVar(&ch.Config.Organization, "org", ch.Config.Organization, "The organization for the current user") + cmd.MarkPersistentFlagRequired("org") + + cmd.AddCommand(MemberListCmd(ch)) + cmd.AddCommand(MemberShowCmd(ch)) + cmd.AddCommand(MemberUpdateCmd(ch)) + cmd.AddCommand(MemberRemoveCmd(ch)) + + return cmd +} + +type organizationMember struct { + UserID string `header:"user_id" json:"user_id"` + Name string `header:"name" json:"name"` + Email string `header:"email" json:"email"` + Role string `header:"role" json:"role"` + + orig *ps.OrganizationMembership +} + +func toOrganizationMembers(members []*ps.OrganizationMembership) []*organizationMember { + out := make([]*organizationMember, 0, len(members)) + for _, m := range members { + out = append(out, toOrganizationMember(m)) + } + return out +} + +func toOrganizationMember(m *ps.OrganizationMembership) *organizationMember { + name := m.User.DisplayName + if name == "" { + name = m.User.Name + } + return &organizationMember{ + UserID: m.User.ID, + Name: name, + Email: m.User.Email, + Role: m.Role, + orig: m, + } +} + +func (m *organizationMember) MarshalJSON() ([]byte, error) { + return json.MarshalIndent(m.orig, "", " ") +} + +func (m *organizationMember) MarshalCSVValue() interface{} { + return []*organizationMember{m} +} + +func memberNotFound(org, id string) error { + return fmt.Errorf("member %s does not exist in organization %s", + printer.BoldBlue(id), printer.BoldBlue(org)) +} + +func matchMember(m *ps.OrganizationMembership, id string) bool { + if m.User.ID == id || m.ID == id { + return true + } + return strings.EqualFold(m.User.Email, id) +} + +func resolveMember(ctx context.Context, ch *cmdutil.Helper, client *ps.Client, id string) (*ps.OrganizationMembership, error) { + org := ch.Config.Organization + query := "" + if strings.Contains(id, "@") { + query = id + } + + page := 1 + perPage := 100 + for { + members, err := client.Organizations.ListMembers(ctx, &ps.ListOrganizationMembersRequest{ + Organization: org, + Query: query, + }, ps.WithPage(page), ps.WithPerPage(perPage)) + if err != nil { + switch cmdutil.ErrCode(err) { + case ps.ErrNotFound: + return nil, fmt.Errorf("organization %s does not exist", printer.BoldBlue(org)) + default: + return nil, cmdutil.HandleError(err) + } + } + + for _, m := range members { + if matchMember(m, id) { + return m, nil + } + } + if len(members) < perPage { + break + } + page++ + } + + return nil, memberNotFound(org, id) +} diff --git a/internal/cmd/org/member_list.go b/internal/cmd/org/member_list.go new file mode 100644 index 00000000..54df00f1 --- /dev/null +++ b/internal/cmd/org/member_list.go @@ -0,0 +1,61 @@ +package org + +import ( + "fmt" + + "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" + "github.com/spf13/cobra" +) + +func MemberListCmd(ch *cmdutil.Helper) *cobra.Command { + var query string + + cmd := &cobra.Command{ + Use: "list", + Short: "List members of an organization", + Args: cobra.NoArgs, + Aliases: []string{"ls"}, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + org := ch.Config.Organization + + client, err := ch.Client() + if err != nil { + return err + } + + end := ch.Printer.PrintProgress(fmt.Sprintf("Fetching members of %s...", printer.BoldBlue(org))) + defer end() + + members, err := client.Organizations.ListMembers(ctx, &ps.ListOrganizationMembersRequest{ + Organization: org, + Query: query, + }) + if err != nil { + switch cmdutil.ErrCode(err) { + case ps.ErrNotFound: + return fmt.Errorf("organization %s does not exist", printer.BoldBlue(org)) + default: + return cmdutil.HandleError(err) + } + } + end() + + if len(members) == 0 && ch.Printer.Format() == printer.Human { + if query != "" { + ch.Printer.Printf("No members in %s match %s.\n", printer.BoldBlue(org), printer.BoldBlue(query)) + } else { + ch.Printer.Printf("No members in %s.\n", printer.BoldBlue(org)) + } + return nil + } + + return ch.Printer.PrintResource(toOrganizationMembers(members)) + }, + } + + cmd.Flags().StringVar(&query, "query", "", "Filter members by name or email prefix") + return cmd +} diff --git a/internal/cmd/org/member_remove.go b/internal/cmd/org/member_remove.go new file mode 100644 index 00000000..6617c3ed --- /dev/null +++ b/internal/cmd/org/member_remove.go @@ -0,0 +1,90 @@ +package org + +import ( + "fmt" + + "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" + "github.com/spf13/cobra" +) + +func MemberRemoveCmd(ch *cmdutil.Helper) *cobra.Command { + var flags struct { + force bool + deletePasswords bool + deleteServiceTokens bool + } + + cmd := &cobra.Command{ + Use: "remove ", + Short: "Remove a member from an organization", + Aliases: []string{"rm"}, + Long: `Remove a member from an organization. + +Removing someone else requires organization admin. You can remove yourself +(leave) without being an admin. The last admin cannot be removed.`, + Args: cmdutil.RequiredArgs("user-id"), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + id := args[0] + + client, err := ch.Client() + if err != nil { + return err + } + + end := ch.Printer.PrintProgress(fmt.Sprintf("Fetching member %s in %s...", printer.BoldBlue(id), printer.BoldBlue(ch.Config.Organization))) + member, err := resolveMember(ctx, ch, client, id) + end() + if err != nil { + return err + } + + if !flags.force { + if err := ch.Printer.ConfirmCommand(member.User.Email, "remove organization member", "removal of organization member"); err != nil { + return err + } + } + + end = ch.Printer.PrintProgress(fmt.Sprintf("Removing %s from %s...", printer.BoldBlue(member.User.Email), printer.BoldBlue(ch.Config.Organization))) + defer end() + + err = client.Organizations.RemoveMember(ctx, &ps.RemoveOrganizationMemberRequest{ + Organization: ch.Config.Organization, + UserID: member.User.ID, + DeletePasswords: flags.deletePasswords, + DeleteServiceTokens: flags.deleteServiceTokens, + }) + if err != nil { + switch cmdutil.ErrCode(err) { + case ps.ErrNotFound: + return memberNotFound(ch.Config.Organization, id) + case ps.ErrPermission: + return fmt.Errorf("only organization admins can remove other members") + default: + return cmdutil.HandleError(err) + } + } + end() + + if ch.Printer.Format() == printer.Human { + ch.Printer.Printf("Removed %s from %s.\n", + printer.BoldBlue(member.User.Email), + printer.BoldBlue(ch.Config.Organization)) + return nil + } + + return ch.Printer.PrintResource(map[string]string{ + "result": "member removed", + "org": ch.Config.Organization, + "user": member.User.ID, + }) + }, + } + + cmd.Flags().BoolVar(&flags.force, "force", false, "Remove the member without confirmation") + cmd.Flags().BoolVar(&flags.deletePasswords, "delete-passwords", false, "Delete passwords created by the member. Cannot be used when removing yourself.") + cmd.Flags().BoolVar(&flags.deleteServiceTokens, "delete-service-tokens", false, "Delete service tokens created by the member. Cannot be used when removing yourself.") + return cmd +} diff --git a/internal/cmd/org/member_show.go b/internal/cmd/org/member_show.go new file mode 100644 index 00000000..9f77a9ff --- /dev/null +++ b/internal/cmd/org/member_show.go @@ -0,0 +1,52 @@ +package org + +import ( + "fmt" + + "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" + "github.com/spf13/cobra" +) + +func MemberShowCmd(ch *cmdutil.Helper) *cobra.Command { + cmd := &cobra.Command{ + Use: "show ", + Short: "Show an organization member", + Long: `Show an organization member by user id, email, or membership id. + +The table user_id is the identifier used by update and remove.`, + Args: cmdutil.RequiredArgs("user-id"), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + id := args[0] + + client, err := ch.Client() + if err != nil { + return err + } + + end := ch.Printer.PrintProgress(fmt.Sprintf("Fetching member %s in %s...", printer.BoldBlue(id), printer.BoldBlue(ch.Config.Organization))) + defer end() + + member, err := client.Organizations.GetMember(ctx, &ps.GetOrganizationMemberRequest{ + Organization: ch.Config.Organization, + UserID: id, + }) + if err != nil { + if cmdutil.ErrCode(err) != ps.ErrNotFound { + return cmdutil.HandleError(err) + } + member, err = resolveMember(ctx, ch, client, id) + if err != nil { + return err + } + } + end() + + return ch.Printer.PrintResource(toOrganizationMember(member)) + }, + } + + return cmd +} diff --git a/internal/cmd/org/member_test.go b/internal/cmd/org/member_test.go new file mode 100644 index 00000000..60c74bb9 --- /dev/null +++ b/internal/cmd/org/member_test.go @@ -0,0 +1,191 @@ +package org + +import ( + "bytes" + "context" + "testing" + + qt "github.com/frankban/quicktest" + "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/config" + "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" +) + +func testMember() *ps.OrganizationMembership { + return &ps.OrganizationMembership{ + ID: "mem-1", + Role: "member", + User: ps.OrganizationMemberUser{ + ID: "user-1", + Name: "Ada", + DisplayName: "Ada Lovelace", + Email: "ada@example.com", + }, + } +} + +func TestOrg_MemberListCmd(t *testing.T) { + c := qt.New(t) + + var buf bytes.Buffer + format := printer.JSON + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + + svc := &mock.OrganizationsService{ + ListMembersFn: func(ctx context.Context, req *ps.ListOrganizationMembersRequest, opts ...ps.ListOption) ([]*ps.OrganizationMembership, error) { + c.Assert(req.Organization, qt.Equals, "planetscale") + c.Assert(req.Query, qt.Equals, "ada") + return []*ps.OrganizationMembership{testMember()}, nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: "planetscale"}, + Client: func() (*ps.Client, error) { + return &ps.Client{Organizations: svc}, nil + }, + } + + cmd := MemberListCmd(ch) + cmd.SetArgs([]string{"--query", "ada"}) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(svc.ListMembersFnInvoked, qt.IsTrue) + c.Assert(buf.String(), qt.Contains, "ada@example.com") +} + +func TestOrg_MemberShowCmd(t *testing.T) { + c := qt.New(t) + + var buf bytes.Buffer + format := printer.JSON + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + + svc := &mock.OrganizationsService{ + GetMemberFn: func(ctx context.Context, req *ps.GetOrganizationMemberRequest) (*ps.OrganizationMembership, error) { + c.Assert(req.UserID, qt.Equals, "user-1") + return testMember(), nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: "planetscale"}, + Client: func() (*ps.Client, error) { + return &ps.Client{Organizations: svc}, nil + }, + } + + cmd := MemberShowCmd(ch) + cmd.SetArgs([]string{"user-1"}) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(svc.GetMemberFnInvoked, qt.IsTrue) + c.Assert(buf.String(), qt.Contains, "user-1") +} + +func TestOrg_MemberShowCmd_ResolveEmail(t *testing.T) { + c := qt.New(t) + + var buf bytes.Buffer + format := printer.JSON + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + + svc := &mock.OrganizationsService{ + GetMemberFn: func(ctx context.Context, req *ps.GetOrganizationMemberRequest) (*ps.OrganizationMembership, error) { + return nil, &ps.Error{Code: ps.ErrNotFound} + }, + ListMembersFn: func(ctx context.Context, req *ps.ListOrganizationMembersRequest, opts ...ps.ListOption) ([]*ps.OrganizationMembership, error) { + c.Assert(req.Query, qt.Equals, "ada@example.com") + return []*ps.OrganizationMembership{testMember()}, nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: "planetscale"}, + Client: func() (*ps.Client, error) { + return &ps.Client{Organizations: svc}, nil + }, + } + + cmd := MemberShowCmd(ch) + cmd.SetArgs([]string{"ada@example.com"}) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(svc.ListMembersFnInvoked, qt.IsTrue) + c.Assert(buf.String(), qt.Contains, "ada@example.com") +} + +func TestOrg_MemberUpdateCmd(t *testing.T) { + c := qt.New(t) + + var buf bytes.Buffer + format := printer.JSON + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + + svc := &mock.OrganizationsService{ + ListMembersFn: func(ctx context.Context, req *ps.ListOrganizationMembersRequest, opts ...ps.ListOption) ([]*ps.OrganizationMembership, error) { + return []*ps.OrganizationMembership{testMember()}, nil + }, + UpdateMemberFn: func(ctx context.Context, req *ps.UpdateOrganizationMemberRequest) (*ps.OrganizationMembership, error) { + c.Assert(req.UserID, qt.Equals, "user-1") + c.Assert(req.Role, qt.Equals, "admin") + m := testMember() + m.Role = "admin" + return m, nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: "planetscale"}, + Client: func() (*ps.Client, error) { + return &ps.Client{Organizations: svc}, nil + }, + } + + cmd := MemberUpdateCmd(ch) + cmd.SetArgs([]string{"user-1", "--role", "admin"}) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(svc.UpdateMemberFnInvoked, qt.IsTrue) + c.Assert(buf.String(), qt.Contains, `"role": "admin"`) +} + +func TestOrg_MemberRemoveCmd(t *testing.T) { + c := qt.New(t) + + var buf bytes.Buffer + format := printer.JSON + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + + svc := &mock.OrganizationsService{ + ListMembersFn: func(ctx context.Context, req *ps.ListOrganizationMembersRequest, opts ...ps.ListOption) ([]*ps.OrganizationMembership, error) { + return []*ps.OrganizationMembership{testMember()}, nil + }, + RemoveMemberFn: func(ctx context.Context, req *ps.RemoveOrganizationMemberRequest) error { + c.Assert(req.UserID, qt.Equals, "user-1") + c.Assert(req.DeletePasswords, qt.IsTrue) + return nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: "planetscale"}, + Client: func() (*ps.Client, error) { + return &ps.Client{Organizations: svc}, nil + }, + } + + cmd := MemberRemoveCmd(ch) + cmd.SetArgs([]string{"user-1", "--force", "--delete-passwords"}) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(svc.RemoveMemberFnInvoked, qt.IsTrue) + c.Assert(buf.String(), qt.Contains, "member removed") +} diff --git a/internal/cmd/org/member_update.go b/internal/cmd/org/member_update.go new file mode 100644 index 00000000..bccbdb17 --- /dev/null +++ b/internal/cmd/org/member_update.go @@ -0,0 +1,82 @@ +package org + +import ( + "fmt" + + "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" + "github.com/spf13/cobra" +) + +func MemberUpdateCmd(ch *cmdutil.Helper) *cobra.Command { + var role string + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update an organization member's role", + Long: `Update another member's organization role. + +Only organization admins can do this. You cannot change your own role. +Assignable roles: admin, member, analyst.`, + Args: cmdutil.RequiredArgs("user-id"), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + id := args[0] + + if role == "" { + return fmt.Errorf("must specify --role (admin, member, or analyst)") + } + switch role { + case "admin", "member", "analyst": + default: + return fmt.Errorf("--role accepts admin, member, or analyst, got %q", role) + } + + client, err := ch.Client() + if err != nil { + return err + } + + end := ch.Printer.PrintProgress(fmt.Sprintf("Fetching member %s in %s...", printer.BoldBlue(id), printer.BoldBlue(ch.Config.Organization))) + member, err := resolveMember(ctx, ch, client, id) + end() + if err != nil { + return err + } + + end = ch.Printer.PrintProgress(fmt.Sprintf("Updating role for %s to %s...", printer.BoldBlue(member.User.Email), printer.BoldBlue(role))) + defer end() + + updated, err := client.Organizations.UpdateMember(ctx, &ps.UpdateOrganizationMemberRequest{ + Organization: ch.Config.Organization, + UserID: member.User.ID, + Role: role, + }) + if err != nil { + switch cmdutil.ErrCode(err) { + case ps.ErrNotFound: + return memberNotFound(ch.Config.Organization, id) + case ps.ErrPermission: + return fmt.Errorf("only organization admins can change member roles") + default: + return cmdutil.HandleError(err) + } + } + end() + + if ch.Printer.Format() == printer.Human { + ch.Printer.Printf("Updated %s to %s in %s.\n", + printer.BoldBlue(updated.User.Email), + printer.BoldBlue(updated.Role), + printer.BoldBlue(ch.Config.Organization)) + return nil + } + + return ch.Printer.PrintResource(toOrganizationMember(updated)) + }, + } + + cmd.Flags().StringVar(&role, "role", "", "Role to assign: admin, member, or analyst") + return cmd +} diff --git a/internal/cmd/org/org.go b/internal/cmd/org/org.go index df0fabf7..fc9ac523 100644 --- a/internal/cmd/org/org.go +++ b/internal/cmd/org/org.go @@ -18,6 +18,7 @@ func OrgCmd(ch *cmdutil.Helper) *cobra.Command { cmd.AddCommand(SwitchCmd(ch)) cmd.AddCommand(ShowCmd(ch)) cmd.AddCommand(ListCmd(ch)) + cmd.AddCommand(MemberCmd(ch)) return cmd } diff --git a/internal/mock/org.go b/internal/mock/org.go index 3e86ddfa..525d2545 100644 --- a/internal/mock/org.go +++ b/internal/mock/org.go @@ -18,6 +18,18 @@ type OrganizationsService struct { ListClusterSKUsFn func(context.Context, *ps.ListOrganizationClusterSKUsRequest, ...ps.ListOption) ([]*ps.ClusterSKU, error) ListClusterSKUsFnInvoked bool + + ListMembersFn func(context.Context, *ps.ListOrganizationMembersRequest, ...ps.ListOption) ([]*ps.OrganizationMembership, error) + ListMembersFnInvoked bool + + GetMemberFn func(context.Context, *ps.GetOrganizationMemberRequest) (*ps.OrganizationMembership, error) + GetMemberFnInvoked bool + + UpdateMemberFn func(context.Context, *ps.UpdateOrganizationMemberRequest) (*ps.OrganizationMembership, error) + UpdateMemberFnInvoked bool + + RemoveMemberFn func(context.Context, *ps.RemoveOrganizationMemberRequest) error + RemoveMemberFnInvoked bool } func (o *OrganizationsService) Get(ctx context.Context, req *ps.GetOrganizationRequest) (*ps.Organization, error) { @@ -39,3 +51,23 @@ func (o *OrganizationsService) ListClusterSKUs(ctx context.Context, req *ps.List o.ListClusterSKUsFnInvoked = true return o.ListClusterSKUsFn(ctx, req, opts...) } + +func (o *OrganizationsService) ListMembers(ctx context.Context, req *ps.ListOrganizationMembersRequest, opts ...ps.ListOption) ([]*ps.OrganizationMembership, error) { + o.ListMembersFnInvoked = true + return o.ListMembersFn(ctx, req, opts...) +} + +func (o *OrganizationsService) GetMember(ctx context.Context, req *ps.GetOrganizationMemberRequest) (*ps.OrganizationMembership, error) { + o.GetMemberFnInvoked = true + return o.GetMemberFn(ctx, req) +} + +func (o *OrganizationsService) UpdateMember(ctx context.Context, req *ps.UpdateOrganizationMemberRequest) (*ps.OrganizationMembership, error) { + o.UpdateMemberFnInvoked = true + return o.UpdateMemberFn(ctx, req) +} + +func (o *OrganizationsService) RemoveMember(ctx context.Context, req *ps.RemoveOrganizationMemberRequest) error { + o.RemoveMemberFnInvoked = true + return o.RemoveMemberFn(ctx, req) +} diff --git a/internal/planetscale/organization_members.go b/internal/planetscale/organization_members.go new file mode 100644 index 00000000..8fe2d56d --- /dev/null +++ b/internal/planetscale/organization_members.go @@ -0,0 +1,141 @@ +package planetscale + +import ( + "context" + "fmt" + "net/http" + "net/url" + "path" + "time" +) + +// OrganizationMembership is a user's membership in an organization. +// ID is the membership public id. PATCH/DELETE/GET member routes take the +// nested user's public id (User.ID), not this ID. +type OrganizationMembership struct { + ID string `json:"id"` + Role string `json:"role"` + User OrganizationMemberUser `json:"user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// OrganizationMemberUser is the user nested on an organization membership. +type OrganizationMemberUser struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` + Name string `json:"name"` + Email string `json:"email"` + AvatarURL string `json:"avatar_url"` + TwoFactorAuthConfigured bool `json:"two_factor_auth_configured"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type organizationMembersResponse struct { + Data []*OrganizationMembership `json:"data"` +} + +type ListOrganizationMembersRequest struct { + Organization string + Query string +} + +type GetOrganizationMemberRequest struct { + Organization string + UserID string +} + +type UpdateOrganizationMemberRequest struct { + Organization string `json:"-"` + UserID string `json:"-"` + Role string `json:"role"` +} + +type RemoveOrganizationMemberRequest struct { + Organization string + UserID string + DeletePasswords bool + DeleteServiceTokens bool +} + +func organizationMembersAPIPath(org string) string { + return path.Join(organizationsAPIPath, org, "members") +} + +func organizationMemberAPIPath(org, userID string) string { + return path.Join(organizationMembersAPIPath(org), userID) +} + +func (o *organizationsService) ListMembers(ctx context.Context, listReq *ListOrganizationMembersRequest, opts ...ListOption) ([]*OrganizationMembership, error) { + defaultOpts := defaultListOptions(WithPerPage(100)) + for _, opt := range opts { + if err := opt(defaultOpts); err != nil { + return nil, err + } + } + if listReq.Query != "" { + defaultOpts.URLValues.Set("q", listReq.Query) + } + + req, err := o.client.newRequest(http.MethodGet, organizationMembersAPIPath(listReq.Organization), nil, WithQueryParams(*defaultOpts.URLValues)) + if err != nil { + return nil, fmt.Errorf("error creating request for list organization members: %w", err) + } + + resp := &organizationMembersResponse{} + if err := o.client.do(ctx, req, &resp); err != nil { + return nil, err + } + + return resp.Data, nil +} + +func (o *organizationsService) GetMember(ctx context.Context, getReq *GetOrganizationMemberRequest) (*OrganizationMembership, error) { + req, err := o.client.newRequest(http.MethodGet, organizationMemberAPIPath(getReq.Organization, getReq.UserID), nil) + if err != nil { + return nil, fmt.Errorf("error creating request for get organization member: %w", err) + } + + membership := &OrganizationMembership{} + if err := o.client.do(ctx, req, &membership); err != nil { + return nil, err + } + + return membership, nil +} + +func (o *organizationsService) UpdateMember(ctx context.Context, updateReq *UpdateOrganizationMemberRequest) (*OrganizationMembership, error) { + body := struct { + Role string `json:"role"` + }{Role: updateReq.Role} + + req, err := o.client.newRequest(http.MethodPatch, organizationMemberAPIPath(updateReq.Organization, updateReq.UserID), body) + if err != nil { + return nil, fmt.Errorf("error creating request for update organization member: %w", err) + } + + membership := &OrganizationMembership{} + if err := o.client.do(ctx, req, &membership); err != nil { + return nil, err + } + + return membership, nil +} + +func (o *organizationsService) RemoveMember(ctx context.Context, removeReq *RemoveOrganizationMemberRequest) error { + v := url.Values{} + if removeReq.DeletePasswords { + v.Set("delete_passwords", "true") + } + if removeReq.DeleteServiceTokens { + v.Set("delete_service_tokens", "true") + } + + req, err := o.client.newRequest(http.MethodDelete, organizationMemberAPIPath(removeReq.Organization, removeReq.UserID), nil, WithQueryParams(v)) + if err != nil { + return fmt.Errorf("error creating request for remove organization member: %w", err) + } + + return o.client.do(ctx, req, nil) +} diff --git a/internal/planetscale/organization_members_test.go b/internal/planetscale/organization_members_test.go new file mode 100644 index 00000000..8a971189 --- /dev/null +++ b/internal/planetscale/organization_members_test.go @@ -0,0 +1,90 @@ +package planetscale + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + qt "github.com/frankban/quicktest" +) + +func TestOrganizations_ListMembers(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/members") + c.Assert(r.URL.Query().Get("q"), qt.Equals, "ada") + w.WriteHeader(200) + out := `{"data":[{"id":"mem-1","role":"admin","user":{"id":"user-1","name":"Ada","email":"ada@example.com","display_name":"Ada","avatar_url":"","two_factor_auth_configured":true,"created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z"},"created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z"}]}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + members, err := client.Organizations.ListMembers(context.Background(), &ListOrganizationMembersRequest{ + Organization: "my-org", + Query: "ada", + }) + c.Assert(err, qt.IsNil) + c.Assert(members, qt.HasLen, 1) + c.Assert(members[0].Role, qt.Equals, "admin") + c.Assert(members[0].User.ID, qt.Equals, "user-1") + c.Assert(members[0].User.Email, qt.Equals, "ada@example.com") +} + +func TestOrganizations_UpdateMember(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPatch) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/members/user-1") + var body struct { + Role string `json:"role"` + } + c.Assert(json.NewDecoder(r.Body).Decode(&body), qt.IsNil) + c.Assert(body.Role, qt.Equals, "member") + w.WriteHeader(200) + out := `{"id":"mem-1","role":"member","user":{"id":"user-1","name":"Ada","email":"ada@example.com","display_name":"Ada","avatar_url":"","two_factor_auth_configured":false,"created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z"},"created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z"}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + member, err := client.Organizations.UpdateMember(context.Background(), &UpdateOrganizationMemberRequest{ + Organization: "my-org", + UserID: "user-1", + Role: "member", + }) + c.Assert(err, qt.IsNil) + c.Assert(member.Role, qt.Equals, "member") + c.Assert(member.UpdatedAt, qt.Equals, time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC)) +} + +func TestOrganizations_RemoveMember(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodDelete) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/members/user-1") + c.Assert(r.URL.Query().Get("delete_passwords"), qt.Equals, "true") + w.WriteHeader(204) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + err = client.Organizations.RemoveMember(context.Background(), &RemoveOrganizationMemberRequest{ + Organization: "my-org", + UserID: "user-1", + DeletePasswords: true, + }) + c.Assert(err, qt.IsNil) +} diff --git a/internal/planetscale/organizations.go b/internal/planetscale/organizations.go index 30ca044c..8f7a8044 100644 --- a/internal/planetscale/organizations.go +++ b/internal/planetscale/organizations.go @@ -23,6 +23,10 @@ type OrganizationsService interface { List(context.Context) ([]*Organization, error) ListRegions(context.Context, *ListOrganizationRegionsRequest) ([]*Region, error) ListClusterSKUs(context.Context, *ListOrganizationClusterSKUsRequest, ...ListOption) ([]*ClusterSKU, error) + ListMembers(context.Context, *ListOrganizationMembersRequest, ...ListOption) ([]*OrganizationMembership, error) + GetMember(context.Context, *GetOrganizationMemberRequest) (*OrganizationMembership, error) + UpdateMember(context.Context, *UpdateOrganizationMemberRequest) (*OrganizationMembership, error) + RemoveMember(context.Context, *RemoveOrganizationMemberRequest) error } // ListRegionsRequest encapsulates the request for getting a list of regions for From aeddccb6877ee6b757678a9217e835918e258ef6 Mon Sep 17 00:00:00 2001 From: Elom Gomez Date: Wed, 19 Aug 2026 13:29:02 -0500 Subject: [PATCH 2/4] Paginate org member list and keep API permission reasons Large organizations were silently truncated to the first 100 members, and permission failures were rewritten as "only admins can do this" even when the API rejected the request for a different reason. Co-authored-by: Cursor --- internal/cmd/org/member_list.go | 26 ++++++++---- internal/cmd/org/member_remove.go | 4 +- internal/cmd/org/member_test.go | 70 +++++++++++++++++++++++++++++++ internal/cmd/org/member_update.go | 4 +- 4 files changed, 94 insertions(+), 10 deletions(-) diff --git a/internal/cmd/org/member_list.go b/internal/cmd/org/member_list.go index 54df00f1..4572423f 100644 --- a/internal/cmd/org/member_list.go +++ b/internal/cmd/org/member_list.go @@ -10,11 +10,19 @@ import ( ) func MemberListCmd(ch *cmdutil.Helper) *cobra.Command { - var query string + var flags struct { + query string + page int + perPage int + } cmd := &cobra.Command{ - Use: "list", - Short: "List members of an organization", + Use: "list", + Short: "List members of an organization", + Long: `List members of an organization. + +Results are paginated: 100 members per page by default. Use --page and +--per-page to walk organizations with more members than one page holds.`, Args: cobra.NoArgs, Aliases: []string{"ls"}, RunE: func(cmd *cobra.Command, args []string) error { @@ -31,8 +39,8 @@ func MemberListCmd(ch *cmdutil.Helper) *cobra.Command { members, err := client.Organizations.ListMembers(ctx, &ps.ListOrganizationMembersRequest{ Organization: org, - Query: query, - }) + Query: flags.query, + }, ps.WithPage(flags.page), ps.WithPerPage(flags.perPage)) if err != nil { switch cmdutil.ErrCode(err) { case ps.ErrNotFound: @@ -44,8 +52,8 @@ func MemberListCmd(ch *cmdutil.Helper) *cobra.Command { end() if len(members) == 0 && ch.Printer.Format() == printer.Human { - if query != "" { - ch.Printer.Printf("No members in %s match %s.\n", printer.BoldBlue(org), printer.BoldBlue(query)) + if flags.query != "" { + ch.Printer.Printf("No members in %s match %s.\n", printer.BoldBlue(org), printer.BoldBlue(flags.query)) } else { ch.Printer.Printf("No members in %s.\n", printer.BoldBlue(org)) } @@ -56,6 +64,8 @@ func MemberListCmd(ch *cmdutil.Helper) *cobra.Command { }, } - cmd.Flags().StringVar(&query, "query", "", "Filter members by name or email prefix") + cmd.Flags().StringVar(&flags.query, "query", "", "Filter members by name or email prefix") + cmd.Flags().IntVar(&flags.page, "page", 0, "Page number to fetch") + cmd.Flags().IntVar(&flags.perPage, "per-page", 100, "Number of results per page") return cmd } diff --git a/internal/cmd/org/member_remove.go b/internal/cmd/org/member_remove.go index 6617c3ed..381f1f34 100644 --- a/internal/cmd/org/member_remove.go +++ b/internal/cmd/org/member_remove.go @@ -61,7 +61,9 @@ Removing someone else requires organization admin. You can remove yourself case ps.ErrNotFound: return memberNotFound(ch.Config.Organization, id) case ps.ErrPermission: - return fmt.Errorf("only organization admins can remove other members") + // More than one server-side rule can reject this, so surface the + // API's reason rather than assuming the caller is not an admin. + return fmt.Errorf("cannot remove %s: %w", member.User.Email, err) default: return cmdutil.HandleError(err) } diff --git a/internal/cmd/org/member_test.go b/internal/cmd/org/member_test.go index 60c74bb9..5877735d 100644 --- a/internal/cmd/org/member_test.go +++ b/internal/cmd/org/member_test.go @@ -3,6 +3,8 @@ package org import ( "bytes" "context" + "errors" + "net/url" "testing" qt "github.com/frankban/quicktest" @@ -57,6 +59,74 @@ func TestOrg_MemberListCmd(t *testing.T) { c.Assert(buf.String(), qt.Contains, "ada@example.com") } +func TestOrg_MemberListCmd_Pagination(t *testing.T) { + c := qt.New(t) + + var buf bytes.Buffer + format := printer.JSON + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + + svc := &mock.OrganizationsService{ + ListMembersFn: func(ctx context.Context, req *ps.ListOrganizationMembersRequest, opts ...ps.ListOption) ([]*ps.OrganizationMembership, error) { + listOpts := &ps.ListOptions{URLValues: &url.Values{}} + for _, opt := range opts { + c.Assert(opt(listOpts), qt.IsNil) + } + c.Assert(listOpts.URLValues.Get("page"), qt.Equals, "3") + c.Assert(listOpts.URLValues.Get("per_page"), qt.Equals, "25") + return []*ps.OrganizationMembership{testMember()}, nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: "planetscale"}, + Client: func() (*ps.Client, error) { + return &ps.Client{Organizations: svc}, nil + }, + } + + cmd := MemberListCmd(ch) + cmd.SetArgs([]string{"--page", "3", "--per-page", "25"}) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(svc.ListMembersFnInvoked, qt.IsTrue) +} + +func TestOrg_MemberUpdateCmd_PermissionErrorKeepsServerReason(t *testing.T) { + c := qt.New(t) + + var buf bytes.Buffer + format := printer.JSON + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + + apiErr := &ps.Error{Code: ps.ErrPermission} + svc := &mock.OrganizationsService{ + ListMembersFn: func(ctx context.Context, req *ps.ListOrganizationMembersRequest, opts ...ps.ListOption) ([]*ps.OrganizationMembership, error) { + return []*ps.OrganizationMembership{testMember()}, nil + }, + UpdateMemberFn: func(ctx context.Context, req *ps.UpdateOrganizationMemberRequest) (*ps.OrganizationMembership, error) { + return nil, apiErr + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: "planetscale"}, + Client: func() (*ps.Client, error) { + return &ps.Client{Organizations: svc}, nil + }, + } + + cmd := MemberUpdateCmd(ch) + cmd.SetArgs([]string{"user-1", "--role", "admin"}) + err := cmd.Execute() + c.Assert(err, qt.IsNotNil) + c.Assert(errors.Is(err, apiErr), qt.IsTrue) + c.Assert(err.Error(), qt.Contains, "ada@example.com") +} + func TestOrg_MemberShowCmd(t *testing.T) { c := qt.New(t) diff --git a/internal/cmd/org/member_update.go b/internal/cmd/org/member_update.go index bccbdb17..72410803 100644 --- a/internal/cmd/org/member_update.go +++ b/internal/cmd/org/member_update.go @@ -58,7 +58,9 @@ Assignable roles: admin, member, analyst.`, case ps.ErrNotFound: return memberNotFound(ch.Config.Organization, id) case ps.ErrPermission: - return fmt.Errorf("only organization admins can change member roles") + // More than one server-side rule can reject this, so surface the + // API's reason rather than assuming the caller is not an admin. + return fmt.Errorf("cannot change the role for %s: %w", member.User.Email, err) default: return cmdutil.HandleError(err) } From dcf878a10d65bb4627e2638c5b125b0b45a0d543 Mon Sep 17 00:00:00 2001 From: Elom Gomez Date: Wed, 19 Aug 2026 14:17:23 -0500 Subject: [PATCH 3/4] Distinguish an empty member page from an empty organization An empty --page past the last results was printing "No members in ", which matches a truly empty org. Other list commands already split those cases. Co-authored-by: Cursor --- internal/cmd/org/member_list.go | 4 +++- internal/cmd/org/member_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/internal/cmd/org/member_list.go b/internal/cmd/org/member_list.go index 4572423f..bf71736b 100644 --- a/internal/cmd/org/member_list.go +++ b/internal/cmd/org/member_list.go @@ -52,7 +52,9 @@ Results are paginated: 100 members per page by default. Use --page and end() if len(members) == 0 && ch.Printer.Format() == printer.Human { - if flags.query != "" { + if flags.page > 0 { + ch.Printer.Println("No members found on this page.") + } else if flags.query != "" { ch.Printer.Printf("No members in %s match %s.\n", printer.BoldBlue(org), printer.BoldBlue(flags.query)) } else { ch.Printer.Printf("No members in %s.\n", printer.BoldBlue(org)) diff --git a/internal/cmd/org/member_test.go b/internal/cmd/org/member_test.go index 5877735d..fee33ae8 100644 --- a/internal/cmd/org/member_test.go +++ b/internal/cmd/org/member_test.go @@ -93,6 +93,35 @@ func TestOrg_MemberListCmd_Pagination(t *testing.T) { c.Assert(svc.ListMembersFnInvoked, qt.IsTrue) } +func TestOrg_MemberListCmd_EmptyPage(t *testing.T) { + c := qt.New(t) + + var buf bytes.Buffer + format := printer.Human + p := printer.NewPrinter(&format) + p.SetHumanOutput(&buf) + + svc := &mock.OrganizationsService{ + ListMembersFn: func(ctx context.Context, req *ps.ListOrganizationMembersRequest, opts ...ps.ListOption) ([]*ps.OrganizationMembership, error) { + return nil, nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: "planetscale"}, + Client: func() (*ps.Client, error) { + return &ps.Client{Organizations: svc}, nil + }, + } + + cmd := MemberListCmd(ch) + cmd.SetArgs([]string{"--page", "4"}) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(buf.String(), qt.Contains, "No members found on this page.") + c.Assert(buf.String(), qt.Not(qt.Contains), "No members in") +} + func TestOrg_MemberUpdateCmd_PermissionErrorKeepsServerReason(t *testing.T) { c := qt.New(t) From 11cf42b055637df87f6343cef89901f9f633d257 Mon Sep 17 00:00:00 2001 From: Elom Gomez Date: Wed, 19 Aug 2026 14:35:02 -0500 Subject: [PATCH 4/4] Accept email as the primary org member identifier show, update, and remove already resolved members by email, but usage still said , which implied you needed the public id from list. Co-authored-by: Cursor --- AGENTS.md | 8 ++++---- internal/cmd/org/member.go | 3 +++ internal/cmd/org/member_remove.go | 5 +++-- internal/cmd/org/member_show.go | 8 ++++---- internal/cmd/org/member_update.go | 5 +++-- 5 files changed, 17 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fbf80422..6ebdbe07 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,13 +76,13 @@ pscale --org database list --format json Pass `--org ` on resource commands (`database`, `branch`, `sql`, `api`, …). Not on `org list`. - Organization members (`user_id` from list is what show/update/remove take; email also works): + Organization members (email or the USER_ID from list): ```bash pscale org member list --org --format json - pscale org member show --org --format json - pscale org member update --org --format json --role member - pscale org member remove --org --format json --force + pscale org member show user@example.com --org --format json + pscale org member update user@example.com --org --format json --role member + pscale org member remove user@example.com --org --format json --force ``` Only org admins can change another member's role or remove someone else. Nobody can change their own role. `--role` is `admin`, `member`, or `analyst`. diff --git a/internal/cmd/org/member.go b/internal/cmd/org/member.go index f751e201..2cddde94 100644 --- a/internal/cmd/org/member.go +++ b/internal/cmd/org/member.go @@ -18,6 +18,9 @@ func MemberCmd(ch *cmdutil.Helper) *cobra.Command { Short: "List, show, update, and remove organization members", Long: `Manage organization members and their roles. +show, update, and remove take an email or a user id (the USER_ID column from +'org member list'). Email is usually the easiest. + Only organization admins can change another member's role or remove someone else. Nobody can change their own role. Members can still leave the organization themselves.`, diff --git a/internal/cmd/org/member_remove.go b/internal/cmd/org/member_remove.go index 381f1f34..cb3125b1 100644 --- a/internal/cmd/org/member_remove.go +++ b/internal/cmd/org/member_remove.go @@ -17,14 +17,15 @@ func MemberRemoveCmd(ch *cmdutil.Helper) *cobra.Command { } cmd := &cobra.Command{ - Use: "remove ", + Use: "remove ", Short: "Remove a member from an organization", Aliases: []string{"rm"}, Long: `Remove a member from an organization. +Identify the member by email or by the USER_ID from 'org member list'. Removing someone else requires organization admin. You can remove yourself (leave) without being an admin. The last admin cannot be removed.`, - Args: cmdutil.RequiredArgs("user-id"), + Args: cmdutil.RequiredArgs("email|user-id"), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() id := args[0] diff --git a/internal/cmd/org/member_show.go b/internal/cmd/org/member_show.go index 9f77a9ff..ad2318ed 100644 --- a/internal/cmd/org/member_show.go +++ b/internal/cmd/org/member_show.go @@ -11,12 +11,12 @@ import ( func MemberShowCmd(ch *cmdutil.Helper) *cobra.Command { cmd := &cobra.Command{ - Use: "show ", + Use: "show ", Short: "Show an organization member", - Long: `Show an organization member by user id, email, or membership id. + Long: `Show an organization member by email or user id. -The table user_id is the identifier used by update and remove.`, - Args: cmdutil.RequiredArgs("user-id"), +'org member list' prints both EMAIL and USER_ID.`, + Args: cmdutil.RequiredArgs("email|user-id"), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() id := args[0] diff --git a/internal/cmd/org/member_update.go b/internal/cmd/org/member_update.go index 72410803..8cc89b4e 100644 --- a/internal/cmd/org/member_update.go +++ b/internal/cmd/org/member_update.go @@ -13,13 +13,14 @@ func MemberUpdateCmd(ch *cmdutil.Helper) *cobra.Command { var role string cmd := &cobra.Command{ - Use: "update ", + Use: "update ", Short: "Update an organization member's role", Long: `Update another member's organization role. +Identify the member by email or by the USER_ID from 'org member list'. Only organization admins can do this. You cannot change your own role. Assignable roles: admin, member, analyst.`, - Args: cmdutil.RequiredArgs("user-id"), + Args: cmdutil.RequiredArgs("email|user-id"), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() id := args[0]