diff --git a/pkg/cmd/control/control.go b/pkg/cmd/control/control.go new file mode 100644 index 000000000..564768ac1 --- /dev/null +++ b/pkg/cmd/control/control.go @@ -0,0 +1,40 @@ +// 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 control + +import ( + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cmd/cmdutil" + "go.probo.inc/probo/pkg/cmd/control/create" + "go.probo.inc/probo/pkg/cmd/control/delete" + "go.probo.inc/probo/pkg/cmd/control/list" + "go.probo.inc/probo/pkg/cmd/control/update" + "go.probo.inc/probo/pkg/cmd/control/view" +) + +func NewCmdControl(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "control ", + Short: "Manage controls", + } + + cmd.AddCommand(list.NewCmdList(f)) + cmd.AddCommand(create.NewCmdCreate(f)) + cmd.AddCommand(view.NewCmdView(f)) + cmd.AddCommand(update.NewCmdUpdate(f)) + cmd.AddCommand(delete.NewCmdDelete(f)) + + return cmd +} diff --git a/pkg/cmd/control/create/create.go b/pkg/cmd/control/create/create.go new file mode 100644 index 000000000..b70233577 --- /dev/null +++ b/pkg/cmd/control/create/create.go @@ -0,0 +1,136 @@ +// 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 create + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const createMutation = ` +mutation($input: CreateControlInput!) { + createControl(input: $input) { + controlEdge { + node { + id + sectionTitle + name + description + bestPractice + } + } + } +} +` + +type createResponse struct { + CreateControl struct { + ControlEdge struct { + Node struct { + ID string `json:"id"` + SectionTitle string `json:"sectionTitle"` + Name string `json:"name"` + Description *string `json:"description"` + BestPractice bool `json:"bestPractice"` + } `json:"node"` + } `json:"controlEdge"` + } `json:"createControl"` +} + +func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { + var ( + flagFramework string + flagSectionTitle string + flagName string + flagDescription string + flagBestPractice bool + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a new control", + Example: ` # Create a control + proboctl control create --framework FW_ID --section-title "A.5" --name "Information security policies"`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + 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(), + ) + + input := map[string]any{ + "frameworkId": flagFramework, + "sectionTitle": flagSectionTitle, + "name": flagName, + "bestPractice": flagBestPractice, + } + + if flagDescription != "" { + input["description"] = flagDescription + } + + data, err := client.Do( + createMutation, + map[string]any{"input": input}, + ) + if err != nil { + return err + } + + var resp createResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + c := resp.CreateControl.ControlEdge.Node + _, _ = fmt.Fprintf( + f.IOStreams.Out, + "Created control %s (%s)\n", + c.ID, + c.Name, + ) + + return nil + }, + } + + cmd.Flags().StringVar(&flagFramework, "framework", "", "Framework ID (required)") + cmd.Flags().StringVar(&flagSectionTitle, "section-title", "", "Section title (required)") + cmd.Flags().StringVar(&flagName, "name", "", "Control name (required)") + cmd.Flags().StringVar(&flagDescription, "description", "", "Control description") + cmd.Flags().BoolVar(&flagBestPractice, "best-practice", false, "Mark as best practice") + + _ = cmd.MarkFlagRequired("framework") + _ = cmd.MarkFlagRequired("section-title") + _ = cmd.MarkFlagRequired("name") + + return cmd +} diff --git a/pkg/cmd/control/delete/delete.go b/pkg/cmd/control/delete/delete.go new file mode 100644 index 000000000..4fd96d54d --- /dev/null +++ b/pkg/cmd/control/delete/delete.go @@ -0,0 +1,102 @@ +// 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 delete + +import ( + "fmt" + + "github.com/charmbracelet/huh" + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const deleteMutation = ` +mutation($input: DeleteControlInput!) { + deleteControl(input: $input) { + deletedControlId + } +} +` + +func NewCmdDelete(f *cmdutil.Factory) *cobra.Command { + var flagYes bool + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a control", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !flagYes { + if !f.IOStreams.IsInteractive() { + return fmt.Errorf("cannot delete control: confirmation required, use --yes to confirm") + } + + var confirmed bool + err := huh.NewConfirm(). + Title(fmt.Sprintf("Delete control %s?", args[0])). + Value(&confirmed). + Run() + if err != nil { + return err + } + if !confirmed { + return nil + } + } + + 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(), + ) + + _, err = client.Do( + deleteMutation, + map[string]any{ + "input": map[string]any{ + "controlId": args[0], + }, + }, + ) + if err != nil { + return err + } + + _, _ = fmt.Fprintf( + f.IOStreams.Out, + "Deleted control %s\n", + args[0], + ) + + return nil + }, + } + + cmd.Flags().BoolVarP(&flagYes, "yes", "y", false, "Skip confirmation prompt") + + return cmd +} diff --git a/pkg/cmd/control/list/list.go b/pkg/cmd/control/list/list.go new file mode 100644 index 000000000..81642bd0c --- /dev/null +++ b/pkg/cmd/control/list/list.go @@ -0,0 +1,200 @@ +// 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: ControlOrder, $filter: ControlFilter) { + node(id: $id) { + __typename + ... on Framework { + controls(first: $first, after: $after, orderBy: $orderBy, filter: $filter) { + totalCount + edges { + node { + id + sectionTitle + name + description + bestPractice + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +} +` + +type control struct { + ID string `json:"id"` + SectionTitle string `json:"sectionTitle"` + Name string `json:"name"` + Description *string `json:"description"` + BestPractice bool `json:"bestPractice"` +} + +func NewCmdList(f *cmdutil.Factory) *cobra.Command { + var ( + flagFramework string + flagLimit int + flagOrderBy string + flagOrderDir string + flagFilter string + flagOutput *string + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List controls in a framework", + Aliases: []string{"ls"}, + Example: ` # List controls in a framework + proboctl control list --framework + + # Filter and output as JSON + proboctl control ls --framework --filter "access" --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(), + ) + + variables := map[string]any{ + "id": flagFramework, + } + + if flagOrderBy != "" { + if err := cmdutil.ValidateEnum("order-by", flagOrderBy, []string{"CREATED_AT", "SECTION_TITLE"}); err != nil { + return err + } + variables["orderBy"] = map[string]any{ + "field": flagOrderBy, + "direction": flagOrderDir, + } + } + + if flagFilter != "" { + variables["filter"] = map[string]any{ + "query": flagFilter, + } + } + + controls, totalCount, err := api.Paginate( + client, + listQuery, + variables, + flagLimit, + func(data json.RawMessage) (*api.Connection[control], error) { + var resp struct { + Node *struct { + Typename string `json:"__typename"` + Controls api.Connection[control] `json:"controls"` + } `json:"node"` + } + if err := json.Unmarshal(data, &resp); err != nil { + return nil, err + } + if resp.Node == nil { + return nil, fmt.Errorf("framework %s not found", flagFramework) + } + if resp.Node.Typename != "Framework" { + return nil, fmt.Errorf("expected Framework node, got %s", resp.Node.Typename) + } + return &resp.Node.Controls, nil + }, + ) + if err != nil { + return err + } + + if *flagOutput == cmdutil.OutputJSON { + return cmdutil.PrintJSON(f.IOStreams.Out, controls) + } + + if len(controls) == 0 { + _, _ = fmt.Fprintln(f.IOStreams.Out, "No controls found.") + return nil + } + + rows := make([][]string, 0, len(controls)) + for _, c := range controls { + bp := "No" + if c.BestPractice { + bp = "Yes" + } + rows = append(rows, []string{ + c.ID, + c.SectionTitle, + c.Name, + bp, + }) + } + + t := cmdutil.NewTable("ID", "SECTION", "NAME", "BEST PRACTICE").Rows(rows...) + + _, _ = fmt.Fprintln(f.IOStreams.Out, t) + + if totalCount > len(controls) { + _, _ = fmt.Fprintf( + f.IOStreams.ErrOut, + "\nShowing %d of %d controls\n", + len(controls), + totalCount, + ) + } + + return nil + }, + } + + cmd.Flags().StringVar(&flagFramework, "framework", "", "Framework ID (required)") + cmd.Flags().IntVarP(&flagLimit, "limit", "L", 30, "Maximum number of controls to list") + cmd.Flags().StringVar(&flagOrderBy, "order-by", "", "Order by field (CREATED_AT, SECTION_TITLE)") + cmd.Flags().StringVar(&flagOrderDir, "order-direction", "DESC", "Sort direction (ASC, DESC)") + cmd.Flags().StringVarP(&flagFilter, "filter", "q", "", "Filter controls by search query") + flagOutput = cmdutil.AddOutputFlag(cmd) + + _ = cmd.MarkFlagRequired("framework") + + return cmd +} diff --git a/pkg/cmd/control/update/update.go b/pkg/cmd/control/update/update.go new file mode 100644 index 000000000..4e6b020fb --- /dev/null +++ b/pkg/cmd/control/update/update.go @@ -0,0 +1,138 @@ +// 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 update + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const updateMutation = ` +mutation($input: UpdateControlInput!) { + updateControl(input: $input) { + control { + id + sectionTitle + name + description + bestPractice + } + } +} +` + +type updateResponse struct { + UpdateControl struct { + Control struct { + ID string `json:"id"` + SectionTitle string `json:"sectionTitle"` + Name string `json:"name"` + Description *string `json:"description"` + BestPractice bool `json:"bestPractice"` + } `json:"control"` + } `json:"updateControl"` +} + +func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command { + var ( + flagSectionTitle string + flagName string + flagDescription string + flagBestPractice bool + ) + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update a control", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + 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(), + ) + + input := map[string]any{ + "id": args[0], + } + + if cmd.Flags().Changed("section-title") { + input["sectionTitle"] = flagSectionTitle + } + if cmd.Flags().Changed("name") { + input["name"] = flagName + } + if cmd.Flags().Changed("description") { + if flagDescription == "" { + input["description"] = nil + } else { + input["description"] = flagDescription + } + } + if cmd.Flags().Changed("best-practice") { + input["bestPractice"] = flagBestPractice + } + + if len(input) == 1 { + return fmt.Errorf("at least one field must be specified for update") + } + + data, err := client.Do( + updateMutation, + map[string]any{"input": input}, + ) + if err != nil { + return err + } + + var resp updateResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + c := resp.UpdateControl.Control + _, _ = fmt.Fprintf( + f.IOStreams.Out, + "Updated control %s (%s)\n", + c.ID, + c.Name, + ) + + return nil + }, + } + + cmd.Flags().StringVar(&flagSectionTitle, "section-title", "", "Section title") + cmd.Flags().StringVar(&flagName, "name", "", "Control name") + cmd.Flags().StringVar(&flagDescription, "description", "", "Control description") + cmd.Flags().BoolVar(&flagBestPractice, "best-practice", false, "Mark as best practice") + + return cmd +} diff --git a/pkg/cmd/control/view/view.go b/pkg/cmd/control/view/view.go new file mode 100644 index 000000000..4c0b30dd9 --- /dev/null +++ b/pkg/cmd/control/view/view.go @@ -0,0 +1,152 @@ +// 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) { + __typename + ... on Control { + id + sectionTitle + name + description + bestPractice + framework { + id + name + } + createdAt + updatedAt + } + } +} +` + +type viewResponse struct { + Node *struct { + Typename string `json:"__typename"` + ID string `json:"id"` + SectionTitle string `json:"sectionTitle"` + Name string `json:"name"` + Description *string `json:"description"` + BestPractice bool `json:"bestPractice"` + Framework struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"framework"` + 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 control", + 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("control %s not found", args[0]) + } + + if resp.Node.Typename != "Control" { + return fmt.Errorf("expected Control node, got %s", resp.Node.Typename) + } + + if *flagOutput == cmdutil.OutputJSON { + return cmdutil.PrintJSON(f.IOStreams.Out, resp.Node) + } + + c := 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(c.Name)) + + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("ID:"), c.ID) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Section:"), c.SectionTitle) + _, _ = fmt.Fprintf(out, "%s%s (%s)\n", label.Render("Framework:"), c.Framework.Name, c.Framework.ID) + + if c.Description != nil && *c.Description != "" { + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Description:"), *c.Description) + } + + bp := "No" + if c.BestPractice { + bp = "Yes" + } + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Best Practice:"), bp) + + _, _ = fmt.Fprintln(out) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Created:"), cmdutil.FormatTime(c.CreatedAt)) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Updated:"), cmdutil.FormatTime(c.UpdatedAt)) + + return nil + }, + } + + flagOutput = cmdutil.AddOutputFlag(cmd) + + return cmd +} diff --git a/pkg/cmd/framework/create/create.go b/pkg/cmd/framework/create/create.go new file mode 100644 index 000000000..e4f5ebfd7 --- /dev/null +++ b/pkg/cmd/framework/create/create.go @@ -0,0 +1,132 @@ +// 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 create + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const createMutation = ` +mutation($input: CreateFrameworkInput!) { + createFramework(input: $input) { + frameworkEdge { + node { + id + name + description + } + } + } +} +` + +type createResponse struct { + CreateFramework struct { + FrameworkEdge struct { + Node struct { + ID string `json:"id"` + Name string `json:"name"` + Description *string `json:"description"` + } `json:"node"` + } `json:"frameworkEdge"` + } `json:"createFramework"` +} + +func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { + var ( + flagOrg string + flagName string + flagDescription string + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a new framework", + Example: ` # Create a framework + proboctl framework create --name "ISO 27001:2022" --description "Information security standard"`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + 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'") + } + + input := map[string]any{ + "organizationId": flagOrg, + "name": flagName, + } + + if flagDescription != "" { + input["description"] = flagDescription + } + + data, err := client.Do( + createMutation, + map[string]any{"input": input}, + ) + if err != nil { + return err + } + + var resp createResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + fw := resp.CreateFramework.FrameworkEdge.Node + _, _ = fmt.Fprintf( + f.IOStreams.Out, + "Created framework %s (%s)\n", + fw.ID, + fw.Name, + ) + + return nil + }, + } + + cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID") + cmd.Flags().StringVar(&flagName, "name", "", "Framework name (required)") + cmd.Flags().StringVar(&flagDescription, "description", "", "Framework description") + + _ = cmd.MarkFlagRequired("name") + + return cmd +} diff --git a/pkg/cmd/framework/delete/delete.go b/pkg/cmd/framework/delete/delete.go new file mode 100644 index 000000000..d0948293e --- /dev/null +++ b/pkg/cmd/framework/delete/delete.go @@ -0,0 +1,102 @@ +// 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 delete + +import ( + "fmt" + + "github.com/charmbracelet/huh" + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const deleteMutation = ` +mutation($input: DeleteFrameworkInput!) { + deleteFramework(input: $input) { + deletedFrameworkId + } +} +` + +func NewCmdDelete(f *cmdutil.Factory) *cobra.Command { + var flagYes bool + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a framework", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !flagYes { + if !f.IOStreams.IsInteractive() { + return fmt.Errorf("cannot delete framework: confirmation required, use --yes to confirm") + } + + var confirmed bool + err := huh.NewConfirm(). + Title(fmt.Sprintf("Delete framework %s?", args[0])). + Value(&confirmed). + Run() + if err != nil { + return err + } + if !confirmed { + return nil + } + } + + 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(), + ) + + _, err = client.Do( + deleteMutation, + map[string]any{ + "input": map[string]any{ + "frameworkId": args[0], + }, + }, + ) + if err != nil { + return err + } + + _, _ = fmt.Fprintf( + f.IOStreams.Out, + "Deleted framework %s\n", + args[0], + ) + + return nil + }, + } + + cmd.Flags().BoolVarP(&flagYes, "yes", "y", false, "Skip confirmation prompt") + + return cmd +} diff --git a/pkg/cmd/framework/framework.go b/pkg/cmd/framework/framework.go new file mode 100644 index 000000000..0621454bf --- /dev/null +++ b/pkg/cmd/framework/framework.go @@ -0,0 +1,40 @@ +// 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 framework + +import ( + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cmd/cmdutil" + "go.probo.inc/probo/pkg/cmd/framework/create" + "go.probo.inc/probo/pkg/cmd/framework/delete" + "go.probo.inc/probo/pkg/cmd/framework/list" + "go.probo.inc/probo/pkg/cmd/framework/update" + "go.probo.inc/probo/pkg/cmd/framework/view" +) + +func NewCmdFramework(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "framework ", + Short: "Manage frameworks", + } + + cmd.AddCommand(list.NewCmdList(f)) + cmd.AddCommand(create.NewCmdCreate(f)) + cmd.AddCommand(view.NewCmdView(f)) + cmd.AddCommand(update.NewCmdUpdate(f)) + cmd.AddCommand(delete.NewCmdDelete(f)) + + return cmd +} diff --git a/pkg/cmd/framework/list/list.go b/pkg/cmd/framework/list/list.go new file mode 100644 index 000000000..5c9cd9ce7 --- /dev/null +++ b/pkg/cmd/framework/list/list.go @@ -0,0 +1,188 @@ +// 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: FrameworkOrder) { + node(id: $id) { + __typename + ... on Organization { + frameworks(first: $first, after: $after, orderBy: $orderBy) { + totalCount + edges { + node { + id + name + description + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +} +` + +type framework struct { + ID string `json:"id"` + Name string `json:"name"` + Description *string `json:"description"` +} + +func NewCmdList(f *cmdutil.Factory) *cobra.Command { + var ( + flagOrg string + flagLimit int + flagOrderBy string + flagOrderDir string + flagOutput *string + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List frameworks in an organization", + Aliases: []string{"ls"}, + 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 flagOrderBy != "" { + if err := cmdutil.ValidateEnum("order-by", flagOrderBy, []string{"CREATED_AT"}); err != nil { + return err + } + variables["orderBy"] = map[string]any{ + "field": flagOrderBy, + "direction": flagOrderDir, + } + } + + frameworks, totalCount, err := api.Paginate( + client, + listQuery, + variables, + flagLimit, + func(data json.RawMessage) (*api.Connection[framework], error) { + var resp struct { + Node *struct { + Typename string `json:"__typename"` + Frameworks api.Connection[framework] `json:"frameworks"` + } `json:"node"` + } + if err := json.Unmarshal(data, &resp); err != nil { + return nil, err + } + if resp.Node == nil { + return nil, fmt.Errorf("organization %s not found", flagOrg) + } + if resp.Node.Typename != "Organization" { + return nil, fmt.Errorf("expected Organization node, got %s", resp.Node.Typename) + } + return &resp.Node.Frameworks, nil + }, + ) + if err != nil { + return err + } + + if *flagOutput == cmdutil.OutputJSON { + return cmdutil.PrintJSON(f.IOStreams.Out, frameworks) + } + + if len(frameworks) == 0 { + _, _ = fmt.Fprintln(f.IOStreams.Out, "No frameworks found.") + return nil + } + + rows := make([][]string, 0, len(frameworks)) + for _, fw := range frameworks { + desc := "" + if fw.Description != nil { + desc = *fw.Description + } + rows = append(rows, []string{ + fw.ID, + fw.Name, + desc, + }) + } + + t := cmdutil.NewTable("ID", "NAME", "DESCRIPTION").Rows(rows...) + + _, _ = fmt.Fprintln(f.IOStreams.Out, t) + + if totalCount > len(frameworks) { + _, _ = fmt.Fprintf( + f.IOStreams.ErrOut, + "\nShowing %d of %d frameworks\n", + len(frameworks), + totalCount, + ) + } + + return nil + }, + } + + cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID") + cmd.Flags().IntVarP(&flagLimit, "limit", "L", 30, "Maximum number of frameworks to list") + cmd.Flags().StringVar(&flagOrderBy, "order-by", "", "Order by field (CREATED_AT)") + cmd.Flags().StringVar(&flagOrderDir, "order-direction", "DESC", "Sort direction (ASC, DESC)") + flagOutput = cmdutil.AddOutputFlag(cmd) + + return cmd +} diff --git a/pkg/cmd/framework/update/update.go b/pkg/cmd/framework/update/update.go new file mode 100644 index 000000000..538be2a1c --- /dev/null +++ b/pkg/cmd/framework/update/update.go @@ -0,0 +1,124 @@ +// 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 update + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const updateMutation = ` +mutation($input: UpdateFrameworkInput!) { + updateFramework(input: $input) { + framework { + id + name + description + } + } +} +` + +type updateResponse struct { + UpdateFramework struct { + Framework struct { + ID string `json:"id"` + Name string `json:"name"` + Description *string `json:"description"` + } `json:"framework"` + } `json:"updateFramework"` +} + +func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command { + var ( + flagName string + flagDescription string + ) + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update a framework", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + 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(), + ) + + input := map[string]any{ + "id": args[0], + } + + if cmd.Flags().Changed("name") { + input["name"] = flagName + } + if cmd.Flags().Changed("description") { + if flagDescription == "" { + input["description"] = nil + } else { + input["description"] = flagDescription + } + } + + if len(input) == 1 { + return fmt.Errorf("at least one field must be specified for update") + } + + data, err := client.Do( + updateMutation, + map[string]any{"input": input}, + ) + if err != nil { + return err + } + + var resp updateResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + fw := resp.UpdateFramework.Framework + _, _ = fmt.Fprintf( + f.IOStreams.Out, + "Updated framework %s (%s)\n", + fw.ID, + fw.Name, + ) + + return nil + }, + } + + cmd.Flags().StringVar(&flagName, "name", "", "Framework name") + cmd.Flags().StringVar(&flagDescription, "description", "", "Framework description") + + return cmd +} diff --git a/pkg/cmd/framework/view/view.go b/pkg/cmd/framework/view/view.go new file mode 100644 index 000000000..3dbe1be02 --- /dev/null +++ b/pkg/cmd/framework/view/view.go @@ -0,0 +1,132 @@ +// 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) { + __typename + ... on Framework { + id + name + description + createdAt + updatedAt + } + } +} +` + +type viewResponse struct { + Node *struct { + Typename string `json:"__typename"` + ID string `json:"id"` + Name string `json:"name"` + Description *string `json:"description"` + 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 framework", + 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("framework %s not found", args[0]) + } + + if resp.Node.Typename != "Framework" { + return fmt.Errorf("expected Framework node, got %s", resp.Node.Typename) + } + + if *flagOutput == cmdutil.OutputJSON { + return cmdutil.PrintJSON(f.IOStreams.Out, resp.Node) + } + + fw := 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(fw.Name)) + + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("ID:"), fw.ID) + + if fw.Description != nil && *fw.Description != "" { + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Description:"), *fw.Description) + } + + _, _ = fmt.Fprintln(out) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Created:"), cmdutil.FormatTime(fw.CreatedAt)) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Updated:"), cmdutil.FormatTime(fw.UpdatedAt)) + + return nil + }, + } + + flagOutput = cmdutil.AddOutputFlag(cmd) + + return cmd +}