diff --git a/pkg/cmd/org/list/list.go b/pkg/cmd/org/list/list.go new file mode 100644 index 000000000..9fa1c2759 --- /dev/null +++ b/pkg/cmd/org/list/list.go @@ -0,0 +1,187 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package list + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const listQuery = ` +query($first: Int, $after: CursorKey, $orderBy: ProfileOrder, $filter: ProfileFilter) { + viewer { + profiles(first: $first, after: $after, orderBy: $orderBy, filter: $filter) { + totalCount + edges { + node { + id + state + organization { + id + name + } + membership { + role + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } +} +` + +type ( + organization struct { + ID string `json:"id"` + Name string `json:"name"` + } + + membership struct { + Role string `json:"role"` + } + + profile struct { + ID string `json:"id"` + State string `json:"state"` + Organization *organization `json:"organization"` + Membership *membership `json:"membership"` + } +) + +func NewCmdList(f *cmdutil.Factory) *cobra.Command { + var ( + flagLimit int + flagOutput *string + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List organizations you have access to", + Aliases: []string{"ls"}, + Example: ` # List all organizations + proboctl org list + + # Output as JSON + proboctl org ls --json`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateOutputFlag(flagOutput); err != nil { + return err + } + + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/connect/v1/graphql", + cfg.HTTPTimeoutDuration(), + ) + + variables := map[string]any{ + "filter": map[string]any{ + "state": "ACTIVE", + }, + } + + profiles, totalCount, err := api.Paginate( + client, + listQuery, + variables, + flagLimit, + func(data json.RawMessage) (*api.Connection[profile], error) { + var resp struct { + Viewer struct { + Profiles api.Connection[profile] `json:"profiles"` + } `json:"viewer"` + } + if err := json.Unmarshal(data, &resp); err != nil { + return nil, err + } + return &resp.Viewer.Profiles, nil + }, + ) + if err != nil { + return err + } + + if *flagOutput == cmdutil.OutputJSON { + return cmdutil.PrintJSON(f.IOStreams.Out, profiles) + } + + if len(profiles) == 0 { + _, _ = fmt.Fprintln(f.IOStreams.Out, "No organizations found.") + return nil + } + + rows := make([][]string, 0, len(profiles)) + for _, p := range profiles { + orgID := "" + orgName := "" + if p.Organization != nil { + orgID = p.Organization.ID + orgName = p.Organization.Name + } + + role := "" + if p.Membership != nil { + role = p.Membership.Role + } + + rows = append(rows, []string{ + orgID, + orgName, + role, + }) + } + + t := cmdutil.NewTable("ID", "NAME", "ROLE").Rows(rows...) + + _, _ = fmt.Fprintln(f.IOStreams.Out, t) + + if totalCount > len(profiles) { + _, _ = fmt.Fprintf( + f.IOStreams.ErrOut, + "\nShowing %d of %d organizations\n", + len(profiles), + totalCount, + ) + } + + return nil + }, + } + + cmd.Flags().IntVarP(&flagLimit, "limit", "L", 30, "Maximum number of organizations to list") + flagOutput = cmdutil.AddOutputFlag(cmd) + + return cmd +} diff --git a/pkg/cmd/org/org.go b/pkg/cmd/org/org.go new file mode 100644 index 000000000..63ae5f8cf --- /dev/null +++ b/pkg/cmd/org/org.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package org + +import ( + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cmd/cmdutil" + "go.probo.inc/probo/pkg/cmd/org/list" +) + +func NewCmdOrg(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "org ", + Short: "Manage organizations", + } + + cmd.AddCommand(list.NewCmdList(f)) + + return cmd +} diff --git a/pkg/cmd/user/list/list.go b/pkg/cmd/user/list/list.go new file mode 100644 index 000000000..11ab05977 --- /dev/null +++ b/pkg/cmd/user/list/list.go @@ -0,0 +1,208 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package list + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const listQuery = ` +query($id: ID!, $first: Int, $after: CursorKey, $orderBy: ProfileOrder, $filter: ProfileFilter) { + node(id: $id) { + ... on Organization { + profiles(first: $first, after: $after, orderBy: $orderBy, filter: $filter) { + totalCount + edges { + node { + id + fullName + emailAddress + state + kind + position + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +} +` + +type profile struct { + ID string `json:"id"` + FullName string `json:"fullName"` + EmailAddress string `json:"emailAddress"` + State string `json:"state"` + Kind *string `json:"kind"` + Position *string `json:"position"` +} + +func NewCmdList(f *cmdutil.Factory) *cobra.Command { + var ( + flagOrg string + flagLimit int + flagOrder string + flagOrderDir string + flagActive bool + flagOutput *string + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List users in an organization", + Aliases: []string{"ls"}, + Example: ` # List users in the default organization + proboctl user list + + # List only active users as JSON + proboctl user ls --active --json`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateOutputFlag(flagOutput); err != nil { + return err + } + + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + ) + + if flagOrg == "" { + flagOrg = hc.Organization + } + + if flagOrg == "" { + return fmt.Errorf("organization is required; pass --org or set a default with 'proboctl auth login'") + } + + variables := map[string]any{ + "id": flagOrg, + } + + if flagOrder != "" { + if err := cmdutil.ValidateEnum("order-by", flagOrder, []string{"FULL_NAME", "CREATED_AT", "KIND"}); err != nil { + return err + } + variables["orderBy"] = map[string]any{ + "field": flagOrder, + "direction": flagOrderDir, + } + } + + if flagActive { + variables["filter"] = map[string]any{ + "excludeContractEnded": true, + } + } + + profiles, totalCount, err := api.Paginate( + client, + listQuery, + variables, + flagLimit, + func(data json.RawMessage) (*api.Connection[profile], error) { + var resp struct { + Node struct { + Profiles api.Connection[profile] `json:"profiles"` + } `json:"node"` + } + if err := json.Unmarshal(data, &resp); err != nil { + return nil, err + } + return &resp.Node.Profiles, nil + }, + ) + if err != nil { + return err + } + + if *flagOutput == cmdutil.OutputJSON { + return cmdutil.PrintJSON(f.IOStreams.Out, profiles) + } + + if len(profiles) == 0 { + _, _ = fmt.Fprintln(f.IOStreams.Out, "No users found.") + return nil + } + + rows := make([][]string, 0, len(profiles)) + for _, p := range profiles { + kind := "" + if p.Kind != nil { + kind = *p.Kind + } + + position := "" + if p.Position != nil { + position = *p.Position + } + + rows = append(rows, []string{ + p.ID, + p.FullName, + p.EmailAddress, + p.State, + kind, + position, + }) + } + + t := cmdutil.NewTable("ID", "NAME", "EMAIL", "STATE", "KIND", "POSITION").Rows(rows...) + + _, _ = fmt.Fprintln(f.IOStreams.Out, t) + + if totalCount > len(profiles) { + _, _ = fmt.Fprintf( + f.IOStreams.ErrOut, + "\nShowing %d of %d users\n", + len(profiles), + totalCount, + ) + } + + return nil + }, + } + + cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID") + cmd.Flags().IntVarP(&flagLimit, "limit", "L", 30, "Maximum number of users to list") + cmd.Flags().StringVar(&flagOrder, "order-by", "", "Order by field (FULL_NAME, CREATED_AT, KIND)") + cmd.Flags().StringVar(&flagOrderDir, "order-direction", "DESC", "Sort direction (ASC, DESC)") + cmd.Flags().BoolVar(&flagActive, "active", false, "Exclude users whose contract has ended") + flagOutput = cmdutil.AddOutputFlag(cmd) + + return cmd +} diff --git a/pkg/cmd/user/user.go b/pkg/cmd/user/user.go new file mode 100644 index 000000000..456acc1de --- /dev/null +++ b/pkg/cmd/user/user.go @@ -0,0 +1,34 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package user + +import ( + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cmd/cmdutil" + "go.probo.inc/probo/pkg/cmd/user/list" + "go.probo.inc/probo/pkg/cmd/user/view" +) + +func NewCmdUser(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "user ", + Short: "Manage users", + } + + cmd.AddCommand(list.NewCmdList(f)) + cmd.AddCommand(view.NewCmdView(f)) + + return cmd +} diff --git a/pkg/cmd/user/view/view.go b/pkg/cmd/user/view/view.go new file mode 100644 index 000000000..26e2a94bb --- /dev/null +++ b/pkg/cmd/user/view/view.go @@ -0,0 +1,159 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package view + +import ( + "encoding/json" + "fmt" + + "github.com/charmbracelet/lipgloss" + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const viewQuery = ` +query($id: ID!) { + node(id: $id) { + ... on Profile { + id + fullName + emailAddress + state + additionalEmailAddresses + kind + position + contractStartDate + contractEndDate + createdAt + updatedAt + } + } +} +` + +type viewResponse struct { + Node *struct { + ID string `json:"id"` + FullName string `json:"fullName"` + EmailAddress string `json:"emailAddress"` + State string `json:"state"` + AdditionalEmailAddresses []string `json:"additionalEmailAddresses"` + Kind *string `json:"kind"` + Position *string `json:"position"` + ContractStartDate *string `json:"contractStartDate"` + ContractEndDate *string `json:"contractEndDate"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + } `json:"node"` +} + +func NewCmdView(f *cmdutil.Factory) *cobra.Command { + var flagOutput *string + + cmd := &cobra.Command{ + Use: "view ", + Short: "View a user", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateOutputFlag(flagOutput); err != nil { + return err + } + + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + ) + + data, err := client.Do( + viewQuery, + map[string]any{"id": args[0]}, + ) + if err != nil { + return err + } + + var resp viewResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + if resp.Node == nil { + return fmt.Errorf("user %s not found", args[0]) + } + + if *flagOutput == cmdutil.OutputJSON { + return cmdutil.PrintJSON(f.IOStreams.Out, resp.Node) + } + + p := resp.Node + out := f.IOStreams.Out + + bold := lipgloss.NewStyle().Bold(true) + label := lipgloss.NewStyle().Foreground(lipgloss.Color("242")).Width(22) + + _, _ = fmt.Fprintf(out, "%s\n\n", bold.Render(p.FullName)) + + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("ID:"), p.ID) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Email:"), p.EmailAddress) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("State:"), p.State) + + if p.Kind != nil && *p.Kind != "" { + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Kind:"), *p.Kind) + } + + if p.Position != nil && *p.Position != "" { + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Position:"), *p.Position) + } + + if len(p.AdditionalEmailAddresses) > 0 { + _, _ = fmt.Fprintln(out) + _, _ = fmt.Fprintf(out, "%s\n", bold.Render("Additional Emails")) + for _, email := range p.AdditionalEmailAddresses { + _, _ = fmt.Fprintf(out, " %s\n", email) + } + } + + _, _ = fmt.Fprintln(out) + if p.ContractStartDate != nil { + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Contract Start:"), *p.ContractStartDate) + } + if p.ContractEndDate != nil { + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Contract End:"), *p.ContractEndDate) + } + + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Created:"), cmdutil.FormatTime(p.CreatedAt)) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Updated:"), cmdutil.FormatTime(p.UpdatedAt)) + + return nil + }, + } + + flagOutput = cmdutil.AddOutputFlag(cmd) + + return cmd +}