Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,17 @@ pscale --org <org> database list --format json

Pass `--org <org>` on resource commands (`database`, `branch`, `sql`, `api`, …). Not on `org list`.

Organization members (email or the USER_ID from list):

```bash
pscale org member list --org <org> --format json
pscale org member show user@example.com --org <org> --format json
pscale org member update user@example.com --org <org> --format json --role member
pscale org member remove user@example.com --org <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
Expand Down
126 changes: 126 additions & 0 deletions internal/cmd/org/member.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
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 <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.`,
}

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)
}
73 changes: 73 additions & 0 deletions internal/cmd/org/member_list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
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 flags struct {
query string
page int
perPage int
}

cmd := &cobra.Command{
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 {
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: flags.query,
}, ps.WithPage(flags.page), ps.WithPerPage(flags.perPage))
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 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))
}
return nil
Comment thread
cursor[bot] marked this conversation as resolved.
}

return ch.Printer.PrintResource(toOrganizationMembers(members))
},
}

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
}
93 changes: 93 additions & 0 deletions internal/cmd/org/member_remove.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
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 <email|user-id>",
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("email|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:
// 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)
}
}
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
}
52 changes: 52 additions & 0 deletions internal/cmd/org/member_show.go
Original file line number Diff line number Diff line change
@@ -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 <email|user-id>",
Short: "Show an organization member",
Long: `Show an organization member by email or 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]

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
}
Loading