diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml index 83f365d..b159ab6 100644 --- a/.github/workflows/danger.yml +++ b/.github/workflows/danger.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'pull_request' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: ruby/setup-ruby@v1 with: ruby-version: '3.3' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 74a2e77..6690e63 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: pull-requests: write # to be able to comment on released pull requests steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-tags: true - name: Setup Node.js @@ -32,7 +32,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v6 with: - go-version: '1.25.4' + go-version: '1.26.2' - name: Install quill CLI run: curl -sSfL https://raw.githubusercontent.com/anchore/quill/main/install.sh | sh -s -- -b /usr/local/bin - name: Check if snapshot build @@ -50,7 +50,7 @@ jobs: QUILL_NOTARY_KEY_ID: ${{ secrets.QUILL_NOTARY_KEY_ID }} QUILL_NOTARY_ISSUER: ${{ secrets.QUILL_NOTARY_ISSUER }} - name: Upload dist artifacts to GitHub when not on main - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 if: "!contains(github.ref, 'main')" with: name: gpcore diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 26ff654..f74e404 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,12 +6,12 @@ jobs: tests: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Setup Go uses: actions/setup-go@v6 with: - go-version: '1.25.4' + go-version: '1.26.2' - name: Install dependencies run: go mod download diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 1b51922..00ae61b 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -15,19 +15,20 @@ builds: ldflags: - "-s -w -X '{{ .ModulePath }}/cmd.version={{.Tag}}' -X '{{ .ModulePath }}/cmd.commit={{.Commit}}' -X '{{ .ModulePath }}/cmd.date={{.Date}}'" - - id: gpcore-macos - goos: - - darwin - ldflags: - - "-s -w -X '{{ .ModulePath }}/cmd.version={{.Tag}}' -X '{{ .ModulePath }}/cmd.commit={{.Commit}}' -X '{{ .ModulePath }}/cmd.date={{.Date}}'" - goarch: - - amd64 - - arm64 - hooks: - post: - - cmd: quill sign-and-notarize "{{ .Path }}" --dry-run={{ .IsSnapshot }} --ad-hoc={{ .IsSnapshot }} -vv - env: - - QUILL_LOG_FILE=/tmp/quill-{{ .Target }}.log +# TODO: Disabled for now due to a missing agreement from apple developer account. +# - id: gpcore-macos +# goos: +# - darwin +# ldflags: +# - "-s -w -X '{{ .ModulePath }}/cmd.version={{.Tag}}' -X '{{ .ModulePath }}/cmd.commit={{.Commit}}' -X '{{ .ModulePath }}/cmd.date={{.Date}}'" +# goarch: +# - amd64 +# - arm64 +# hooks: +# post: +# - cmd: quill sign-and-notarize "{{ .Path }}" --dry-run={{ .IsSnapshot }} --ad-hoc={{ .IsSnapshot }} -vv +# env: +# - QUILL_LOG_FILE=/tmp/quill-{{ .Target }}.log archives: - formats: diff --git a/.releaserc b/.releaserc index e665d6f..013ec31 100644 --- a/.releaserc +++ b/.releaserc @@ -1,12 +1,12 @@ { "branches": [ - { - "name": "dev", - "prerelease": true - }, { "name": "main", "prerelease": false + }, + { + "name": "next", + "prerelease": true } ], "plugins": [ diff --git a/cmd/admin_project/update.go b/cmd/admin_project/update.go new file mode 100644 index 0000000..68bedc1 --- /dev/null +++ b/cmd/admin_project/update.go @@ -0,0 +1,75 @@ +package admin_project + +import ( + "buf.build/gen/go/gportal/gpcore/grpc/go/gpcore/api/admin/v1/adminv1grpc" + adminv1 "buf.build/gen/go/gportal/gpcore/protocolbuffers/go/gpcore/api/admin/v1" + cloudv1 "buf.build/gen/go/gportal/gpcore/protocolbuffers/go/gpcore/api/cloud/v1" + "github.com/G-PORTAL/gpcore-cli/pkg/client" + "github.com/G-PORTAL/gpcore-cli/pkg/config" + "github.com/G-PORTAL/gpcore-cli/pkg/protobuf" + "github.com/spf13/cobra" + "google.golang.org/grpc" +) + +// update is implemented manually (not generated) because +// admin.UpdateProjectRequest carries a nested BillingProfile message which the +// YAML generator cannot express as command flags. The billing profile is +// referenced by its UUID; the backend resolves the full profile from the ID. + +var updateId string +var updateName string +var updateAvatarUrl string +var updateBillingProfileId string +var updateServerPoolIds []string + +var updateCmd = &cobra.Command{ + Args: cobra.OnlyValidArgs, + DisableFlagsInUseLine: true, + Long: "Update project details (admin)", + RunE: func(cobraCmd *cobra.Command, args []string) error { + ctx := client.ExtractContext(cobraCmd) + grpcConn := ctx.Value("conn").(*grpc.ClientConn) + grpcClient := adminv1grpc.NewAdminServiceClient(grpcConn) + + req := &adminv1.UpdateProjectRequest{ + Id: updateId, + Name: updateName, + AvatarUrl: updateAvatarUrl, + ServerPoolIds: updateServerPoolIds, + } + if updateBillingProfileId != "" { + req.BillingProfile = cloudv1.BillingProfile_builder{ + Id: updateBillingProfileId, + }.Build() + } + + resp, err := grpcClient.UpdateProject(cobraCmd.Context(), req) + if err != nil { + return err + } + if config.JSONOutput { + jsonData, err := protobuf.MarshalIndent(resp) + if err != nil { + return err + } + cobraCmd.Println(string(jsonData)) + } + return nil + }, + Short: "Update project details (admin)", + Use: "update", + ValidArgs: []string{"id", "name", "avatar-url", "billing-profile-id", "server-pool-ids"}, +} + +func init() { + updateCmd.Flags().StringVar(&updateId, "id", "", "Project UUID (required)") + updateCmd.Flags().StringVar(&updateName, "name", "", "Project name (required)") + updateCmd.Flags().StringVar(&updateAvatarUrl, "avatar-url", "", "Avatar URL") + updateCmd.Flags().StringVar(&updateBillingProfileId, "billing-profile-id", "", "Billing profile UUID") + updateCmd.Flags().StringSliceVar(&updateServerPoolIds, "server-pool-ids", nil, "Server pool UUIDs") + + updateCmd.MarkFlagRequired("id") + updateCmd.MarkFlagRequired("name") + + RootAdminProjectCommand.AddCommand(updateCmd) +} diff --git a/cmd/agent/root.go b/cmd/agent/root.go index d0c9c2b..be4e79c 100644 --- a/cmd/agent/root.go +++ b/cmd/agent/root.go @@ -54,11 +54,16 @@ func New() *cobra.Command { rootCmd.PersistentFlags().BoolVarP(&config.Verbose, "verbose", "v", false, "verbose mode") rootCmd.PersistentFlags().BoolVarP(&config.JSONOutput, "json", "j", false, "output as JSON") rootCmd.PersistentFlags().BoolVarP(&config.CSVOutput, "csv", "x", false, "output as CSV") + rootCmd.MarkFlagsMutuallyExclusive("json", "csv") + + // Disable Cobra's built-in "completion" subcommand (a custom one is registered below) + rootCmd.CompletionOptions.DisableDefaultCmd = true // Special client commands cmd.SelfupdateCommand(&rootCmd) cmd.SetLogLevelCommand(&rootCmd) cmd.LiveLogCommand(&rootCmd) + cmd.NotificationsCommand(&rootCmd) //InteractiveCLICommand(&rootCmd) // Autogenerated commands diff --git a/cmd/agent/start.go b/cmd/agent/start.go index ab98a2b..d5f2dcb 100644 --- a/cmd/agent/start.go +++ b/cmd/agent/start.go @@ -10,6 +10,7 @@ import ( "syscall" "github.com/G-PORTAL/gpcore-cli/pkg/api" + "github.com/G-PORTAL/gpcore-cli/pkg/client" "github.com/G-PORTAL/gpcore-cli/pkg/config" "github.com/G-PORTAL/gpcore-cli/pkg/consts" "github.com/charmbracelet/log" @@ -71,7 +72,7 @@ var startCmd = &cobra.Command{ if err := rootCmd.ExecuteContext(ctx); err != nil { log.Errorf("Error executing command on agent: %v", err) - rootCmd.Printf("Error executing command on agent: %v\n", err) + rootCmd.Printf("Error: %s\n", client.FormatCommandError(err)) _ = s.Exit(1) // send cmd exit code to the client return } diff --git a/cmd/completion.go b/cmd/completion.go index a08e21f..dce82a8 100644 --- a/cmd/completion.go +++ b/cmd/completion.go @@ -15,13 +15,13 @@ func CompletionCommand(rootCmd *cobra.Command) { RunE: func(cmd *cobra.Command, args []string) error { switch args[0] { case "bash": - _ = rootCmd.GenBashCompletion(cmd.OutOrStdout()) + _ = rootCmd.GenBashCompletionV2(cmd.OutOrStdout(), true) case "zsh": _ = rootCmd.GenZshCompletion(cmd.OutOrStdout()) case "fish": _ = rootCmd.GenFishCompletion(cmd.OutOrStdout(), true) case "powershell": - _ = rootCmd.GenPowerShellCompletion(cmd.OutOrStdout()) + _ = rootCmd.GenPowerShellCompletionWithDesc(cmd.OutOrStdout()) } return nil diff --git a/cmd/livelog.go b/cmd/livelog.go index ade33f6..7a1afbd 100644 --- a/cmd/livelog.go +++ b/cmd/livelog.go @@ -4,7 +4,7 @@ import ( "buf.build/gen/go/gportal/gpcore/grpc/go/gpcore/api/admin/v1/adminv1grpc" adminv1 "buf.build/gen/go/gportal/gpcore/protocolbuffers/go/gpcore/api/admin/v1" cloudv1 "buf.build/gen/go/gportal/gpcore/protocolbuffers/go/gpcore/api/cloud/v1" - "github.com/charmbracelet/ssh" + "github.com/G-PORTAL/gpcore-cli/pkg/api" "github.com/jedib0t/go-pretty/v6/text" "github.com/spf13/cobra" "google.golang.org/grpc" @@ -18,37 +18,16 @@ func LiveLogCommand(rootCmd *cobra.Command) { DisableFlagsInUseLine: true, Args: cobra.OnlyValidArgs, RunE: func(cobraCmd *cobra.Command, args []string) error { - sshSession := cobraCmd.Context().Value("ssh").(*ssh.Session) - cobraCmd.SetOut(*sshSession) - conn := cobraCmd.Context().Value("conn").(*grpc.ClientConn) admin := adminv1grpc.NewAdminServiceClient(conn) - res, err := admin.SubscribeServerLogs(cobraCmd.Context(), &adminv1.SubscribeServerLogsRequest{}) + stream, err := admin.SubscribeServerLogs(cobraCmd.Context(), &adminv1.SubscribeServerLogsRequest{}) if err != nil { return err } - connectionClosed := false - go func() { - breakChan := make(chan bool) - (*sshSession).Break(breakChan) - <-breakChan - connectionClosed = true - }() - - cobraCmd.Printf("\033[33mWaiting for new notifications ...\033[0m\n") // We wait for (and print out) relevant notifications until the break // request is received. - for { - if connectionClosed { - break - } - - msg, err := res.Recv() // Blocking - if err != nil { - return err - } - + return api.StreamMessages(cobraCmd, stream, func(msg *adminv1.SubscribeServerLogsResponse) { // TODO: Filter for source/server/datacenter // TODO: Only above level ... @@ -78,9 +57,7 @@ func LiveLogCommand(rootCmd *cobra.Command) { cobraCmd.Printf("%s: [%s] [%s] [%s] -> %s\n", color.Sprint(time), datacenter, server, source, color.Sprint(m.GetMessage())) } - } - - return nil + }) }, }) } diff --git a/cmd/node/change_rescue_mode.go b/cmd/node/change_rescue_mode.go index 5edf89d..87666da 100644 --- a/cmd/node/change_rescue_mode.go +++ b/cmd/node/change_rescue_mode.go @@ -3,6 +3,7 @@ package node import ( "buf.build/gen/go/gportal/gpcore/grpc/go/gpcore/api/cloud/v1/cloudv1grpc" cloudv1 "buf.build/gen/go/gportal/gpcore/protocolbuffers/go/gpcore/api/cloud/v1" + "fmt" "github.com/G-PORTAL/gpcore-cli/pkg/client" "github.com/G-PORTAL/gpcore-cli/pkg/config" "github.com/G-PORTAL/gpcore-cli/pkg/protobuf" @@ -21,6 +22,20 @@ var changeRescueModeCmd = &cobra.Command{ Long: "Change rescue mode", RunE: func(cobraCmd *cobra.Command, args []string) error { ctx := client.ExtractContext(cobraCmd) + + // --project-id is optional and falls back to the project selected via + // "project use" (mirrors the generated commands' behavior). + session := ctx.Value("config").(*config.SessionConfig) + if session == nil { + return fmt.Errorf("no session found, please login first") + } + if changeRescueModeProjectId == "" { + if session.CurrentProject == nil { + return fmt.Errorf("no project selected: pass --project-id or select one with \"project use\"") + } + changeRescueModeProjectId = *session.CurrentProject + } + grpcConn := ctx.Value("conn").(*grpc.ClientConn) client := cloudv1grpc.NewCloudServiceClient(grpcConn) resp, err := client.ChangeNodeRescueMode(cobraCmd.Context(), &cloudv1.ChangeNodeRescueModeRequest{ @@ -51,12 +66,11 @@ var changeRescueModeCmd = &cobra.Command{ func init() { changeRescueModeCmd.Flags().StringVar(&changeRescueModeId, "id", "", "Node ID (required)") - changeRescueModeCmd.Flags().StringVar(&changeRescueModeProjectId, "project-id", "", "Project ID (required)") - changeRescueModeCmd.Flags().BoolVar(&changeRescueModeEnabled, "enabled", false, "Enable or disable rescue mode (required)") - changeRescueModeCmd.Flags().StringVar(&changeRescueModePassword, "password", "", "Password for rescue mode (required)") + changeRescueModeCmd.Flags().StringVar(&changeRescueModeProjectId, "project-id", "", "Project ID (defaults to the project selected via \"project use\")") + changeRescueModeCmd.Flags().BoolVar(&changeRescueModeEnabled, "enabled", false, "Enable or disable rescue mode") + changeRescueModeCmd.Flags().StringVar(&changeRescueModePassword, "password", "", "Password for rescue mode") changeRescueModeCmd.MarkFlagRequired("id") - changeRescueModeCmd.MarkFlagRequired("project-id") RootNodeCommand.AddCommand(changeRescueModeCmd) } diff --git a/cmd/node/root.go b/cmd/node/root.go index 1648023..ed11ca6 100644 --- a/cmd/node/root.go +++ b/cmd/node/root.go @@ -1,28 +1,20 @@ package node import ( - "fmt" - "github.com/G-PORTAL/gpcore-cli/pkg/config" "github.com/spf13/cobra" ) +// Note: node subcommands no longer require a globally selected project here. +// Each subcommand accepts an optional --project-id that falls back to the +// project selected via "project use" (and errors if neither is set). This +// avoids forcing "project use" when the project is passed explicitly. var RootNodeCommand = &cobra.Command{ Use: "node", Short: "Utility to combine multiple nodes api actions", Long: `Utility to combine multiple nodes api actions`, + GroupID: "resources", DisableFlagsInUseLine: true, Args: cobra.MatchAll(cobra.ExactArgs(0), cobra.OnlyValidArgs), - PersistentPreRunE: func(cobraCmd *cobra.Command, args []string) error { - // Context is not set in PersistentPreRunE, so we need to get the session config manually - config, err := config.GetSessionConfig() - if err != nil { - return err - } - if config.CurrentProject == nil { - return fmt.Errorf("no project selected") - } - return nil - }, RunE: func(cobraCmd *cobra.Command, args []string) error { return cobraCmd.Usage() }, diff --git a/cmd/notifications.go b/cmd/notifications.go new file mode 100644 index 0000000..f968717 --- /dev/null +++ b/cmd/notifications.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "buf.build/gen/go/gportal/gpcore/grpc/go/gpcore/api/cloud/v1/cloudv1grpc" + cloudv1 "buf.build/gen/go/gportal/gpcore/protocolbuffers/go/gpcore/api/cloud/v1" + "github.com/G-PORTAL/gpcore-cli/pkg/api" + "github.com/jedib0t/go-pretty/v6/text" + "github.com/spf13/cobra" + "google.golang.org/grpc" +) + +// NotificationsCommand registers the user-facing "notifications" command, which +// streams notifications via cloud.SubscribeNotifications. It shares the +// break/recv stream loop with "livelog" through api.StreamMessages; only the +// per-message rendering differs. +func NotificationsCommand(rootCmd *cobra.Command) { + rootCmd.AddCommand(&cobra.Command{ + Use: "notifications", + Short: "Live notification stream", + Long: "Subscribe to and print live notifications for your account", + DisableFlagsInUseLine: true, + Args: cobra.OnlyValidArgs, + RunE: func(cobraCmd *cobra.Command, args []string) error { + conn := cobraCmd.Context().Value("conn").(*grpc.ClientConn) + cloudClient := cloudv1grpc.NewCloudServiceClient(conn) + stream, err := cloudClient.SubscribeNotifications(cobraCmd.Context(), &cloudv1.SubscribeNotificationsRequest{}) + if err != nil { + return err + } + + return api.StreamMessages(cobraCmd, stream, func(msg *cloudv1.SubscribeNotificationsResponse) { + notification := msg.GetNotification() + if notification == nil { + return + } + + switch { + case notification.GetNode() != nil: + node := notification.GetNode() + label := text.Colors{text.FgCyan, text.BgBlack}.Sprint("Node") + cobraCmd.Printf("[%s] %s (%s)\n", label, node.GetFqdn(), node.GetId()) + case notification.GetProject() != nil: + project := notification.GetProject() + label := text.Colors{text.FgGreen, text.BgBlack}.Sprint("Project") + cobraCmd.Printf("[%s] %s (%s)\n", label, project.GetName(), project.GetId()) + case notification.GetUser() != nil: + user := notification.GetUser() + label := text.Colors{text.FgMagenta, text.BgBlack}.Sprint("User") + cobraCmd.Printf("[%s] %s (%s)\n", label, user.GetFullName(), user.GetId()) + case notification.GetServerLog() != nil: + m := notification.GetServerLog() + label := text.Colors{text.FgYellow, text.BgBlack}.Sprint("ServerLog") + t := m.GetUpdatedAt().AsTime().Format("15:04:05") + cobraCmd.Printf("[%s] %s -> %s\n", label, t, m.GetMessage()) + case notification.GetHeartbeat() != nil: + // Heartbeats keep the stream alive; ignore them in output. + } + }) + }, + }) +} diff --git a/cmd/project/_network_create.go b/cmd/project/_network_create.go deleted file mode 100644 index 85247e9..0000000 --- a/cmd/project/_network_create.go +++ /dev/null @@ -1,82 +0,0 @@ -package project - -import ( - "buf.build/gen/go/gportal/gpcore/grpc/go/gpcore/api/admin/v1/adminv1grpc" - adminv1 "buf.build/gen/go/gportal/gpcore/protocolbuffers/go/gpcore/api/admin/v1" - cloudv1 "buf.build/gen/go/gportal/gpcore/protocolbuffers/go/gpcore/api/cloud/v1" - "encoding/json" - "github.com/G-PORTAL/gpcore-cli/pkg/client" - "github.com/G-PORTAL/gpcore-cli/pkg/config" - "github.com/G-PORTAL/gpcore-cli/pkg/protobuf" - "github.com/spf13/cobra" - "google.golang.org/grpc" -) - -// This command is disabled at the moment because the ListSubnets endpoint is -// missing on gRPC (which is needed to get the subnets from the IDs). - -var networkCreateProjectId string -var networkCreateName string -var networkCreateType string -var networkCreateSubnets []string -var networkCreateVlanId int32 -var networkCreateDatacenter string - -var networkCreateCmd = &cobra.Command{ - Args: cobra.OnlyValidArgs, - DisableFlagsInUseLine: true, - Long: "", - RunE: func(cobraCmd *cobra.Command, args []string) error { - ctx := client.ExtractContext(cobraCmd) - grpcConn := ctx.Value("conn").(*grpc.ClientConn) - client := adminv1grpc.NewAdminServiceClient(grpcConn) - - networkCreateSubnetStructs := make([]*cloudv1.Subnet, 0) - // TODO: Get subnets from networkCreateSubnetUUIDList IDs - // TODO: ListSubnets endpoint missing on gRPC - - networkCreateDatacenterStruct := &cloudv1.Datacenter{ - Id: networkCreateDatacenter, - } - - resp, err := client.CreateProjectNetwork(cobraCmd.Context(), &adminv1.CreateProjectNetworkRequest{ - Name: networkCreateName, - ProjectId: networkCreateProjectId, - Subnets: networkCreateSubnetStructs, - Datacenter: networkCreateDatacenterStruct, - Type: protobuf.NetworkTypeToProto(networkCreateType), - VlanId: &networkCreateVlanId, - }) - if err != nil { - return err - } - respData := resp - if config.JSONOutput { - jsonData, err := protobuf.MarshalIndent(respData) - if err != nil { - return err - } - cobraCmd.Println(string(jsonData)) - } - return nil - }, - Short: "", - Use: "network-create", - ValidArgs: []string{"project-id", "name", "type", "subnet-ids", "datacenter-id", "vlan-id"}, -} - -func init() { - networkCreateCmd.Flags().StringVar(&networkCreateProjectId, "project-id", "", "Project ID (required)") - networkCreateCmd.Flags().StringVar(&networkCreateName, "name", "", "Network name (required)") - networkCreateCmd.Flags().StringVar(&networkCreateType, "type", "PRIVATE", "Network type (default:\"cloudv1.NETWORK_TYPE_PRIVATE\")") - networkCreateCmd.Flags().StringSliceVar(&networkCreateSubnets, "subnet-ids", []string{}, "Subnets (required)") - networkCreateCmd.Flags().StringVar(&networkCreateDatacenter, "datacenter-id", "", "Datacenter ID (required)") - networkCreateCmd.Flags().Int32Var(&networkCreateVlanId, "vlan-id", int32(0), "VLAN ID") - - networkCreateCmd.MarkFlagRequired("project-id") - networkCreateCmd.MarkFlagRequired("name") - networkCreateCmd.MarkFlagRequired("type") - networkCreateCmd.MarkFlagRequired("subnets") - - RootProjectCommand.AddCommand(networkCreateCmd) -} diff --git a/cmd/project/root.go b/cmd/project/root.go index 4bea6c9..eb4c308 100644 --- a/cmd/project/root.go +++ b/cmd/project/root.go @@ -10,6 +10,7 @@ var RootProjectCommand = &cobra.Command{ Use: "project", Short: "Utility to combine multiple project api actions", Long: `Utility to combine multiple project api actions`, + GroupID: "admin", DisableFlagsInUseLine: true, Args: cobra.MatchAll(cobra.ExactArgs(0), cobra.OnlyValidArgs), RunE: func(cobraCmd *cobra.Command, args []string) error { diff --git a/cmd/project/use.go b/cmd/project/use.go index f05d409..4de3851 100644 --- a/cmd/project/use.go +++ b/cmd/project/use.go @@ -3,6 +3,7 @@ package project import ( "buf.build/gen/go/gportal/gpcore/grpc/go/gpcore/api/cloud/v1/cloudv1grpc" cloudv1 "buf.build/gen/go/gportal/gpcore/protocolbuffers/go/gpcore/api/cloud/v1" + "fmt" "github.com/G-PORTAL/gpcore-cli/pkg/client" "github.com/G-PORTAL/gpcore-cli/pkg/config" "github.com/charmbracelet/log" @@ -10,18 +11,84 @@ import ( "google.golang.org/grpc" ) +var useClear bool + var useCmd = &cobra.Command{ - Use: "use", - Short: "Selects a project to use", - Long: "Selects a project to use", + Use: "use [project-name-or-id]", + Short: "Select, show or clear the active project", + Long: "Manage the active project used as the default --project-id for\n" + + "project-scoped commands (e.g. \"node get\").\n\n" + + " - With an argument: selects the given project (by name or UUID).\n" + + " - Without arguments: shows the currently active project.\n" + + " - With --clear: clears the active project selection.", + Example: " gpcore project use my-project\n" + + " gpcore project use # show the active project\n" + + " gpcore project use --clear # clear the active project", DisableFlagsInUseLine: true, - Args: cobra.MatchAll(cobra.ExactArgs(1)), + Args: cobra.MaximumNArgs(1), RunE: func(cobraCmd *cobra.Command, args []string) error { ctx := client.ExtractContext(cobraCmd) + cfg := ctx.Value("config").(*config.SessionConfig) + + // Clear the active project. + if useClear { + if len(args) > 0 { + return fmt.Errorf("--clear cannot be combined with a project argument") + } + if cfg.CurrentProject == nil { + cobraCmd.Println("No active project was set.") + return nil + } + cfg.CurrentProject = nil + if err := cfg.Write(); err != nil { + return err + } + if err := config.RefreshSessionConfig(); err != nil { + return err + } + log.Info("Cleared active project") + cobraCmd.Println("Active project cleared.") + return nil + } + grpcConn := ctx.Value("conn").(*grpc.ClientConn) grpcClient := cloudv1grpc.NewCloudServiceClient(grpcConn) - cfg := ctx.Value("config").(*config.SessionConfig) + // No argument: show the currently active project. + if len(args) == 0 { + if cfg.CurrentProject == nil { + cobraCmd.Println("No active project selected.") + cobraCmd.Println("Select one with \"project use \".") + return nil + } + + // Resolve the project to show its name and to verify it is still + // accessible in the current (possibly impersonated) user context. + projectID := *cfg.CurrentProject + resp, err := grpcClient.GetProject(cobraCmd.Context(), &cloudv1.GetProjectRequest{ + Id: projectID, + }) + if err != nil || resp.GetProject() == nil { + // The active project is not accessible in the current context + // (e.g. it was selected while impersonating another user). Clear + // the stale selection so it cannot leak across contexts. + cfg.CurrentProject = nil + if werr := cfg.Write(); werr != nil { + return werr + } + if rerr := config.RefreshSessionConfig(); rerr != nil { + return rerr + } + cobraCmd.Printf("The previously active project (%s) is not accessible "+ + "in the current context and has been cleared.\n", projectID) + cobraCmd.Println("Select one with \"project use \".") + return nil + } + cobraCmd.Printf("Active project: %s (%s)\n", resp.GetProject().GetName(), projectID) + return nil + } + + // Argument given: select the project. var newProject *cloudv1.Project resp, err := grpcClient.ListProjects(cobraCmd.Context(), &cloudv1.ListProjectsRequest{}) @@ -62,5 +129,7 @@ var useCmd = &cobra.Command{ } func init() { + useCmd.Flags().BoolVar(&useClear, "clear", false, "Clear the active project selection") + RootProjectCommand.AddCommand(useCmd) } diff --git a/cmd/server/filter.go b/cmd/server/filter.go new file mode 100644 index 0000000..aabb572 --- /dev/null +++ b/cmd/server/filter.go @@ -0,0 +1,155 @@ +package server + +import ( + "buf.build/gen/go/gportal/gpcore/grpc/go/gpcore/api/admin/v1/adminv1grpc" + adminv1 "buf.build/gen/go/gportal/gpcore/protocolbuffers/go/gpcore/api/admin/v1" + typesv1 "buf.build/gen/go/gportal/gpcore/protocolbuffers/go/gpcore/type/v1" + "fmt" + "strconv" + "strings" + + "github.com/G-PORTAL/gpcore-cli/pkg/api" + "github.com/G-PORTAL/gpcore-cli/pkg/client" + "github.com/G-PORTAL/gpcore-cli/pkg/config" + "github.com/G-PORTAL/gpcore-cli/pkg/protobuf" + "github.com/charmbracelet/ssh" + "github.com/jedib0t/go-pretty/v6/table" + "github.com/spf13/cobra" + "google.golang.org/grpc" + "google.golang.org/protobuf/proto" +) + +// filter is implemented manually (not generated) because it calls ListServers +// with an ExtendedSearch payload, which contains a repeated Filter message that +// the YAML generator cannot express as command flags. Use "server +// search-options" to discover the valid filter names and values. + +var filterSearch string +var filterFilters []string + +// parseFilter converts a "name=value" string into a typesv1.Filter. Values are +// treated as strings by default. Prefix the value with "int:" or "bool:" to +// send an integer or boolean filter value respectively. +func parseFilter(raw string) (*typesv1.Filter, error) { + parts := strings.SplitN(raw, "=", 2) + if len(parts) != 2 || parts[0] == "" { + return nil, fmt.Errorf("invalid filter %q, expected format name=value", raw) + } + name, value := parts[0], parts[1] + + builder := typesv1.Filter_builder{Name: name} + switch { + case strings.HasPrefix(value, "int:"): + n, err := strconv.ParseInt(strings.TrimPrefix(value, "int:"), 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid integer filter value for %q: %w", name, err) + } + builder.IntegerValue = &n + case strings.HasPrefix(value, "bool:"): + b, err := strconv.ParseBool(strings.TrimPrefix(value, "bool:")) + if err != nil { + return nil, fmt.Errorf("invalid boolean filter value for %q: %w", name, err) + } + builder.BooleanValue = &b + default: + builder.StringValue = &value + } + return builder.Build(), nil +} + +var filterCmd = &cobra.Command{ + Args: cobra.OnlyValidArgs, + DisableFlagsInUseLine: true, + Long: "List servers using extended search filters. Use 'server search-options' to discover valid filter names and values.", + RunE: func(cobraCmd *cobra.Command, args []string) error { + ctx := client.ExtractContext(cobraCmd) + grpcConn := ctx.Value("conn").(*grpc.ClientConn) + grpcClient := adminv1grpc.NewAdminServiceClient(grpcConn) + + var extendedSearch *typesv1.SearchRequest + if len(filterFilters) > 0 { + filters := make([]*typesv1.Filter, 0, len(filterFilters)) + for _, raw := range filterFilters { + f, err := parseFilter(raw) + if err != nil { + return err + } + filters = append(filters, f) + } + extendedSearch = typesv1.SearchRequest_builder{Filters: filters}.Build() + } + + sshSession := ctx.Value("ssh").(*ssh.Session) + tbl := table.NewWriter() + tbl.SetStyle(table.StyleRounded) + tbl.SetOutputMirror(*sshSession) + cobraCmd.SetOut(*sshSession) + defer cobraCmd.SetOut(nil) + tbl.AppendHeader(table.Row{"Id", "Name", "InPool", "PowerState", "ProvisionState", "CreatedAt"}) + + var combinedData []proto.Message + var totalPages int32 + pagination := &typesv1.PaginationRequest{Page: 1} + for { + req := &adminv1.ListServersRequest{Pagination: pagination} + if filterSearch != "" { + req.Search = &filterSearch + } + if extendedSearch != nil { + req.ExtendedSearch = extendedSearch + } + + resp, err := grpcClient.ListServers(cobraCmd.Context(), req) + if err != nil { + return err + } + + for _, entry := range resp.Servers { + tbl.AppendRow(table.Row{ + fmt.Sprintf("%v", entry.Id), + fmt.Sprintf("%v", entry.Name), + api.FormatBoolean(entry.InPool), + api.FormatServerPowerState(entry.PowerState), + api.FormatServerProvisioningState(entry.ProvisionState), + api.FormatDate(entry.CreatedAt), + }) + combinedData = append(combinedData, entry) + } + + if resp.Pagination == nil { + break + } + totalPages = resp.GetPagination().GetTotal() + pagination.Page++ + if resp.Pagination.Page >= totalPages { + break + } + } + + if config.CSVOutput { + tbl.RenderCSV() + return nil + } + if !config.JSONOutput { + tbl.Render() + } + if config.JSONOutput { + jsonData, err := protobuf.MarshalIndent(combinedData) + if err != nil { + return err + } + cobraCmd.Println(string(jsonData)) + } + return nil + }, + Short: "List servers using extended search filters", + Use: "filter", + ValidArgs: []string{"search", "filter"}, +} + +func init() { + filterCmd.Flags().StringVar(&filterSearch, "search", "", "Optional free-text search term") + filterCmd.Flags().StringArrayVar(&filterFilters, "filter", nil, "Extended search filter in the form name=value (prefix value with int: or bool: to type it). Repeatable.") + + RootServerCommand.AddCommand(filterCmd) +} diff --git a/cmd/user/impersonate.go b/cmd/user/impersonate.go index 4889a1c..5912e92 100644 --- a/cmd/user/impersonate.go +++ b/cmd/user/impersonate.go @@ -46,6 +46,11 @@ var impersonateCmd = &cobra.Command{ expiresIn := int(resp.GetToken().GetExpiresAt().GetSeconds()) sessionConfig.ImpersonateExpiresIn = &expiresIn + // The active project belongs to the previous user context and is not + // valid for the impersonated user. Clear it so project-scoped commands + // don't operate on a project that does not belong to the new context. + sessionConfig.CurrentProject = nil + err = sessionConfig.Write() if err != nil { return err diff --git a/cmd/user/logout.go b/cmd/user/logout.go index 9c65188..f004dbf 100644 --- a/cmd/user/logout.go +++ b/cmd/user/logout.go @@ -28,6 +28,19 @@ var logoutCmd = &cobra.Command{ } if !isImpersonated { + // Even when not impersonating, clear any lingering active project so + // a selection left over from a previous (expired) impersonation does + // not leak into the current user context. + if sessionConfig.CurrentProject != nil { + sessionConfig.CurrentProject = nil + if err = sessionConfig.Write(); err != nil { + return err + } + if err = config.RefreshSessionConfig(); err != nil { + return err + } + cobraCmd.Println("Cleared a lingering active project from a previous session.") + } cobraCmd.Println("No need to logout, you do not impersonate anybody.") return nil } diff --git a/go.mod b/go.mod index 79fbae2..a766836 100644 --- a/go.mod +++ b/go.mod @@ -1,88 +1,88 @@ module github.com/G-PORTAL/gpcore-cli -go 1.24.6 +go 1.26.2 // For development //replace github.com/G-PORTAL/gpcore-go => ../gpcore-go require ( - buf.build/gen/go/gportal/gpcore/grpc/go v1.5.1-20250804091548-289250b42883.2 - buf.build/gen/go/gportal/gpcore/protocolbuffers/go v1.36.9-20250804091548-289250b42883.1 + buf.build/gen/go/gportal/gpcore/grpc/go v1.6.2-20260601154410-8490479c7661.1 + buf.build/gen/go/gportal/gpcore/protocolbuffers/go v1.36.11-20260601154410-8490479c7661.1 github.com/G-PORTAL/gpcore-go v0.0.0-20250923094355-04f2fe445e8f github.com/Nerzal/gocloak/v13 v13.9.0 - github.com/charmbracelet/log v0.4.2 + github.com/charmbracelet/log v1.0.0 github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 github.com/charmbracelet/wish v1.4.7 - github.com/creativeprojects/go-selfupdate v1.5.1 + github.com/creativeprojects/go-selfupdate v1.5.2 github.com/dave/jennifer v1.7.1 github.com/gertd/go-pluralize v0.2.1 - github.com/jedib0t/go-pretty/v6 v6.6.8 - github.com/melbahja/goph v1.4.0 - github.com/spf13/cobra v1.10.1 + github.com/jedib0t/go-pretty/v6 v6.7.10 + github.com/melbahja/goph v1.5.0 + github.com/spf13/cobra v1.10.2 github.com/stoewer/go-strcase v1.3.1 - github.com/zalando/go-keyring v0.2.6 - golang.org/x/crypto v0.42.0 - google.golang.org/grpc v1.75.1 - google.golang.org/protobuf v1.36.9 + github.com/zalando/go-keyring v0.2.8 + golang.org/x/crypto v0.50.0 + google.golang.org/grpc v1.81.0 + google.golang.org/protobuf v1.36.11 gopkg.in/op/go-logging.v1 v1.0.0-20160315200505-970db520ece7 gopkg.in/yaml.v3 v3.0.1 ) require ( - al.essio.dev/pkg/shellescape v1.6.0 // indirect - buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.9-20250912141014-52f32327d4b0.1 // indirect - code.gitea.io/sdk/gitea v0.22.0 // indirect - github.com/42wim/httpsig v1.2.3 // indirect + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 // indirect + code.gitea.io/sdk/gitea v0.24.1 // indirect + github.com/42wim/httpsig v1.2.4 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/bubbletea v1.3.10 // indirect - github.com/charmbracelet/colorprofile v0.3.2 // indirect - github.com/charmbracelet/keygen v0.5.3 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/keygen v0.5.4 // indirect github.com/charmbracelet/lipgloss v1.1.0 // indirect - github.com/charmbracelet/x/ansi v0.10.1 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13 // indirect - github.com/charmbracelet/x/conpty v0.1.1 // indirect - github.com/charmbracelet/x/errors v0.0.0-20250922100529-c9afca5d6f21 // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/conpty v0.2.0 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/creack/pty v1.1.24 // indirect - github.com/danieljoos/wincred v1.2.2 // indirect + github.com/danieljoos/wincred v1.2.3 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-fed/httpsig v1.1.0 // indirect - github.com/go-logfmt/logfmt v0.6.0 // indirect - github.com/go-resty/resty/v2 v2.16.5 // indirect - github.com/godbus/dbus/v5 v5.1.0 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect - github.com/google/go-github/v30 v30.1.0 // indirect - github.com/google/go-querystring v1.1.0 // indirect + github.com/go-logfmt/logfmt v0.6.1 // indirect + github.com/go-resty/resty/v2 v2.17.2 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/go-github/v74 v74.0.0 // indirect + github.com/google/go-querystring v1.2.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect - github.com/hashicorp/go-version v1.7.0 // indirect + github.com/hashicorp/go-version v1.9.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/fs v0.1.0 // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pkg/sftp v1.13.9 // indirect + github.com/pkg/sftp v1.13.10 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/segmentio/ksuid v1.0.4 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/ulikunitz/xz v0.5.15 // indirect - github.com/xanzy/go-gitlab v0.115.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect - golang.org/x/net v0.44.0 // indirect - golang.org/x/oauth2 v0.31.0 // indirect - golang.org/x/sys v0.36.0 // indirect - golang.org/x/text v0.29.0 // indirect - golang.org/x/time v0.13.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250922171735-9219d122eba9 // indirect + gitlab.com/gitlab-org/api/client-go v1.46.0 // indirect + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/time v0.15.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect ) diff --git a/go.sum b/go.sum index 35f00c4..3c66034 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,13 @@ -al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA= -al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.9-20250912141014-52f32327d4b0.1 h1:DQLS/rRxLHuugVzjJU5AvOwD57pdFl9he/0O7e5P294= -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.9-20250912141014-52f32327d4b0.1/go.mod h1:aY3zbkNan5F+cGm9lITDP6oxJIwu0dn9KjJuJjWaHkg= -buf.build/gen/go/gportal/gpcore/grpc/go v1.5.1-20250804091548-289250b42883.2 h1:6gjQy8rvNdpfbuzEPoimtQ2BD5hebvV9TMxiuNWQGCQ= -buf.build/gen/go/gportal/gpcore/grpc/go v1.5.1-20250804091548-289250b42883.2/go.mod h1:/rxp37k3p7xxCe1X1jfSNMtYowCkcEvhiDbL3tBpyWA= -buf.build/gen/go/gportal/gpcore/protocolbuffers/go v1.36.9-20250804091548-289250b42883.1 h1:oUYlXzAMRIm/umpdF+7CxjDc5BrLJTAc+SDTXW3xSKE= -buf.build/gen/go/gportal/gpcore/protocolbuffers/go v1.36.9-20250804091548-289250b42883.1/go.mod h1:9cZ5Dad/JncDI9XPW3QDXovAAL/bj/Ro+/fPcBL0uIw= -code.gitea.io/sdk/gitea v0.22.0 h1:HCKq7bX/HQ85Nw7c/HAhWgRye+vBp5nQOE8Md1+9Ef0= -code.gitea.io/sdk/gitea v0.22.0/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= -github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs= -github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 h1:s6hzCXtND/ICdGPTMGk7C+/BFlr2Jg5GyH0NKf4XGXg= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= +buf.build/gen/go/gportal/gpcore/grpc/go v1.6.2-20260601154410-8490479c7661.1 h1:6VSqM8RcjqhIqrk7mJAiHbOfAJANuVkpk7BOzUSCCbU= +buf.build/gen/go/gportal/gpcore/grpc/go v1.6.2-20260601154410-8490479c7661.1/go.mod h1:CtydbfeRdqoQ9NgmVTdXkArcelU4AC3NpBtzToK6A5g= +buf.build/gen/go/gportal/gpcore/protocolbuffers/go v1.36.11-20260601154410-8490479c7661.1 h1:Fu0IVtmzobbb6SK5H0s5zrAzmILVXqhmIEja3Sjijwg= +buf.build/gen/go/gportal/gpcore/protocolbuffers/go v1.36.11-20260601154410-8490479c7661.1/go.mod h1:P8EapWY+m/+4oATK4HFxRi9zB1QyW1oAz+yvxT4ckt4= +code.gitea.io/sdk/gitea v0.24.1 h1:hpaqcdGcBmfMpV7JSbBJVwE99qo+WqGreJYKrDKEyW8= +code.gitea.io/sdk/gitea v0.24.1/go.mod h1:5/77BL3sHneCMEiZaMT9lfTvnnibsYxyO48mceCF3qA= +github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU= +github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps= github.com/G-PORTAL/gpcore-go v0.0.0-20250923094355-04f2fe445e8f h1:gvIRcU/B46blquBfuf/oVEzmnlW3etX0R6a9Oa1Mbs0= github.com/G-PORTAL/gpcore-go v0.0.0-20250923094355-04f2fe445e8f/go.mod h1:CoJMebbI1HNVoUR6iATl1QfoDSyfjC+BbeGH1kDZS7k= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= @@ -20,39 +18,43 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= -github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= -github.com/charmbracelet/keygen v0.5.3 h1:2MSDC62OUbDy6VmjIE2jM24LuXUvKywLCmaJDmr/Z/4= -github.com/charmbracelet/keygen v0.5.3/go.mod h1:TcpNoMAO5GSmhx3SgcEMqCrtn8BahKhB8AlwnLjRUpk= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/keygen v0.5.4 h1:XQYgf6UEaTGgQSSmiPpIQ78WfseNQp4Pz8N/c1OsrdA= +github.com/charmbracelet/keygen v0.5.4/go.mod h1:t4oBRr41bvK7FaJsAaAQhhkUuHslzFXVjOBwA55CZNM= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= -github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= +github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdRc4= +github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA= github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 h1:dCVbCRRtg9+tsfiTXTp0WupDlHruAXyp+YoxGVofHHc= github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309/go.mod h1:R9cISUs5kAH4Cq/rguNbSwcR+slE5Dfm8FEs//uoIGE= github.com/charmbracelet/wish v1.4.7 h1:O+jdLac3s6GaqkOHHSwezejNK04vl6VjO1A+hl8J8Yc= github.com/charmbracelet/wish v1.4.7/go.mod h1:OBZ8vC62JC5cvbxJLh+bIWtG7Ctmct+ewziuUWK+G14= -github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= -github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= -github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= -github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= -github.com/charmbracelet/x/errors v0.0.0-20250922100529-c9afca5d6f21 h1:hEZp21B03sMDSyChx2152zUm43m5b2A3WykdyvOaZkI= -github.com/charmbracelet/x/errors v0.0.0-20250922100529-c9afca5d6f21/go.mod h1:O2BTD/aMVQDmrvqroIO3fB6zXUuU07ZpVt21QTmZjRg= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/conpty v0.2.0 h1:eKtA2hm34qNfgJCDp/M6Dc0gLy7e07YEK4qAdNGOvVY= +github.com/charmbracelet/x/conpty v0.2.0/go.mod h1:fexgUnVrZgw8scD49f6VSi0Ggj9GWYIrpedRthAwW/8= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= -github.com/creativeprojects/go-selfupdate v1.5.1 h1:fuyEGFFfqcC8SxDGolcEPYPLXGQ9Mcrc5uRyRG2Mqnk= -github.com/creativeprojects/go-selfupdate v1.5.1/go.mod h1:2uY75rP8z/D/PBuDn6mlBnzu+ysEmwOJfcgF8np0JIM= -github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= -github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= +github.com/creativeprojects/go-selfupdate v1.5.2 h1:3KR3JLrq70oplb9yZzbmJ89qRP78D1AN/9u+l3k0LJ4= +github.com/creativeprojects/go-selfupdate v1.5.2/go.mod h1:BCOuwIl1dRRCmPNRPH0amULeZqayhKyY2mH/h4va7Dk= +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/dave/jennifer v1.7.1 h1:B4jJJDHelWcDhlRQxWeo0Npa/pYKBLrirAQoTN45txo= github.com/dave/jennifer v1.7.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -68,60 +70,57 @@ github.com/gertd/go-pluralize v0.2.1 h1:M3uASbVjMnTsPb0PNqg+E/24Vwigyo/tvyMTtAlL github.com/gertd/go-pluralize v0.2.1/go.mod h1:rbYaKDbsXxmRfr8uygAEKhOWsjyrrqrkHVpZvoOp8zk= github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= -github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= -github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= +github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM= -github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA= -github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= -github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk= +github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-github/v30 v30.1.0 h1:VLDx+UolQICEOKu2m4uAoMti1SxuEBAl7RSEG16L+Oo= -github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQFEufcolZ95JfU8= -github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= -github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= -github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= -github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= -github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/go-github/v74 v74.0.0 h1:yZcddTUn8DPbj11GxnMrNiAnXH14gNs559AsUpNpPgM= +github.com/google/go-github/v74 v74.0.0/go.mod h1:ubn/YdyftV80VPSI26nSJvaEsTOnsjrxG3o9kJhcyak= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/graph-gophers/graphql-go v1.9.0 h1:yu0ucKHLc5qGpRwLYKIWtr9bOoxovkWasuBrPQwlHls= +github.com/graph-gophers/graphql-go v1.9.0/go.mod h1:23olKZ7duEvHlF/2ELEoSZaY1aNPfShjP782SOoNTyM= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= -github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= -github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= +github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jedib0t/go-pretty/v6 v6.6.8 h1:JnnzQeRz2bACBobIaa/r+nqjvws4yEhcmaZ4n1QzsEc= -github.com/jedib0t/go-pretty/v6 v6.6.8/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= +github.com/jedib0t/go-pretty/v6 v6.7.10 h1:B/2qW2Bkv2L6n14PP8o1kx75kWzHOQ3YTluWzg9icac= +github.com/jedib0t/go-pretty/v6 v6.7.10/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= -github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= -github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/melbahja/goph v1.4.0 h1:z0PgDbBFe66lRYl3v5dGb9aFgPy0kotuQ37QOwSQFqs= -github.com/melbahja/goph v1.4.0/go.mod h1:uG+VfK2Dlhk+O32zFrRlc3kYKTlV6+BtvPWd/kK7U68= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/melbahja/goph v1.5.0 h1:RQUBpLvfg3i7fjfG8rTcSWyMjVRfdhwrrfQhjYee4dQ= +github.com/melbahja/goph v1.5.0/go.mod h1:dDwo+44cmvfDLdiVpc6fJxexf5BA5yEDUeE5YgtuDO4= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -132,19 +131,17 @@ github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+ github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/sftp v1.13.5/go.mod h1:wHDZ0IZX6JcBYRK1TH9bcVq8G7TLpVHYIGJRFnmPfxg= -github.com/pkg/sftp v1.13.9 h1:4NGkvGudBL7GteO3m6qnaQ4pC0Kvf0onSVc9gR3EWBw= -github.com/pkg/sftp v1.13.9/go.mod h1:OBN7bVXdstkFFN/gdnHPUb5TE8eb8G1Rp9wCItqjkkA= +github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= +github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c= github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE= -github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= -github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -156,7 +153,6 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= @@ -164,127 +160,62 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= -github.com/xanzy/go-gitlab v0.115.0 h1:6DmtItNcVe+At/liXSgfE/DZNZrGfalQmBRmOcJjOn8= -github.com/xanzy/go-gitlab v0.115.0/go.mod h1:5XCDtM7AM6WMKmfDdOiEpyRWUqui2iS9ILfvCZ2gJ5M= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= -github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= -go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= -go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= -go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= -go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= +github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= +gitlab.com/gitlab-org/api/client-go v1.46.0 h1:YxBWFZIFYKcGESCb9fpkwzouo+apyB9pr/XTWzNoL24= +gitlab.com/gitlab-org/api/client-go v1.46.0/go.mod h1:FtgyU6g2HS5+fMhw6nLK96GBEEBx5MzntOiJWfIaiN8= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= -golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= -golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= -golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU= -golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= -golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo= -golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= -golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= -golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= -golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI= -golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250922171735-9219d122eba9 h1:V1jCN2HBa8sySkR5vLcCSqJSTMv093Rw9EJefhQGP7M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250922171735-9219d122eba9/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= -google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= -google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= -google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= -google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw= +google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/op/go-logging.v1 v1.0.0-20160315200505-970db520ece7 h1:wO6teAQa+/6NqTzys8iAd3kWzcWWzrIhLZOdTGfDYAo= diff --git a/pkg/api/formatter.go b/pkg/api/formatter.go index a442293..d9d24e7 100644 --- a/pkg/api/formatter.go +++ b/pkg/api/formatter.go @@ -37,6 +37,7 @@ var SpecialFormatters = []string{ "ServerProvisioningState", "ServerPowerState", "BillingPeriod", + "ServerIssueSource", } func FormatPrice(price *typesv1.Price) string { @@ -229,3 +230,7 @@ func FormatServerPowerState(state cloudv1.ServerPowerState) string { return strings.TrimPrefix(state.String(), "SERVER_POWER_STATE_") // TODO: More colors here } + +func FormatServerIssueSource(source cloudv1.ServerIssueSource) string { + return strings.TrimPrefix(source.String(), "SERVER_ISSUE_SOURCE_") +} diff --git a/pkg/api/stream.go b/pkg/api/stream.go new file mode 100644 index 0000000..76aee5b --- /dev/null +++ b/pkg/api/stream.go @@ -0,0 +1,46 @@ +package api + +import ( + "github.com/charmbracelet/ssh" + "github.com/spf13/cobra" +) + +// RecvStream is the minimal interface implemented by a gRPC server-streaming +// client (e.g. grpc.ServerStreamingClient[T]). It is satisfied by the streams +// returned by SubscribeServerLogs and SubscribeNotifications. +type RecvStream[T any] interface { + Recv() (*T, error) +} + +// StreamMessages consumes a gRPC server stream until the SSH client sends a +// break (Ctrl-C) or the stream returns an error. Each received message is +// passed to the handler for rendering. This is shared by the "livelog" and +// "notifications" commands, which both follow the same break/recv loop. +func StreamMessages[T any](cobraCmd *cobra.Command, stream RecvStream[T], handler func(msg *T)) error { + sshSession := cobraCmd.Context().Value("ssh").(*ssh.Session) + cobraCmd.SetOut(*sshSession) + + connectionClosed := false + go func() { + breakChan := make(chan bool) + (*sshSession).Break(breakChan) + <-breakChan + connectionClosed = true + }() + + cobraCmd.Printf("\033[33mWaiting for new notifications ...\033[0m\n") + for { + if connectionClosed { + break + } + + msg, err := stream.Recv() // Blocking + if err != nil { + return err + } + + handler(msg) + } + + return nil +} diff --git a/pkg/client/error.go b/pkg/client/error.go new file mode 100644 index 0000000..c36df8a --- /dev/null +++ b/pkg/client/error.go @@ -0,0 +1,60 @@ +package client + +import ( + "fmt" + "strings" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// FormatCommandError turns an error returned from a command into a clean, +// user-facing message. For gRPC errors it unwraps the underlying status so +// the user sees the actual server message (e.g. "failed to load node") and a +// short, actionable hint based on the status code instead of the raw +// "rpc error: code = Unknown desc = ..." wrapper. +func FormatCommandError(err error) string { + if err == nil { + return "" + } + + st, ok := status.FromError(err) + if !ok { + // Not a gRPC status error, return as-is. + return err.Error() + } + + msg := st.Message() + if hint := hintFor(st.Code(), msg); hint != "" { + return fmt.Sprintf("%s\n%s", msg, hint) + } + return msg +} + +// hintFor returns a short, actionable hint for an error. It inspects the +// message first (to catch cases the status code alone can not distinguish, +// e.g. a missing-role error returned as Unauthenticated) and falls back to a +// per-code hint. An empty string means no extra hint should be shown. +func hintFor(code codes.Code, msg string) string { + lower := strings.ToLower(msg) + + // Missing roles/permissions can be reported by the backend as either + // PermissionDenied or Unauthenticated. Detect it by message so we do not + // wrongly tell the user their session expired. + if strings.Contains(lower, "required role") || strings.Contains(lower, "permission") { + return "Hint: your account is missing the roles required for this action. Ask an administrator to grant the listed roles." + } + + switch code { + case codes.NotFound: + return "Hint: the resource was not found. Verify the IDs you passed and that the selected project (\"project use\" or --project-id) is the one that owns the resource." + case codes.PermissionDenied: + return "Hint: you do not have permission to access this resource." + case codes.Unauthenticated: + return "Hint: authentication failed. Your session may have expired, try logging in again." + case codes.InvalidArgument: + return "Hint: one or more arguments are invalid. Check the values passed to the command." + default: + return "" + } +} diff --git a/pkg/generator/add_commands.go b/pkg/generator/add_commands.go index 4d08060..a9580f6 100644 --- a/pkg/generator/add_commands.go +++ b/pkg/generator/add_commands.go @@ -3,10 +3,26 @@ package generator import ( . "github.com/dave/jennifer/jen" "github.com/stoewer/go-strcase" + "sort" ) +// CommandGroup defines a group of commands for help output organization. +type CommandGroup struct { + ID string + Title string +} + +// CommandGroups defines the available command groups in display order. +var CommandGroups = []CommandGroup{ + {ID: "resources", Title: "Cloud Resources:"}, + {ID: "networking", Title: "Networking:"}, + {ID: "billing", Title: "Billing & Reporting:"}, + {ID: "admin", Title: "Administration:"}, +} + // GenerateAddCommands generates the AddGeneratedCommands function, which will -// add all generated commands to the root command. +// add all generated commands to the root command, including command group +// registration for organized help output. func GenerateAddCommands(commands []string, targetFilename string) error { f := NewFile("cmd") warningComment(f) @@ -20,7 +36,23 @@ func GenerateAddCommands(commands []string, targetFilename string) error { f.Func().Id("AddGeneratedCommands"). Params(Id("cmd").Op("*").Qual("github.com/spf13/cobra", "Command")). BlockFunc(func(g *Group) { - for _, command := range commands { + // Register command groups + for _, group := range CommandGroups { + g.Id("cmd").Dot("AddGroup").Call( + Op("&").Qual("github.com/spf13/cobra", "Group").Values(Dict{ + Id("ID"): Lit(group.ID), + Id("Title"): Lit(group.Title), + })) + } + g.Line() + + // Sort commands for consistent output + sorted := make([]string, len(commands)) + copy(sorted, commands) + sort.Strings(sorted) + + // Add commands + for _, command := range sorted { pkg := escapePackage(command) g.Id("cmd").Dot("AddCommand").Call( Qual("github.com/G-PORTAL/gpcore-cli/cmd/"+pkg, "Root"+strcase.UpperCamelCase(pkg)+"Command")) diff --git a/pkg/generator/definition.go b/pkg/generator/definition.go index 13506bb..17ff2bf 100644 --- a/pkg/generator/definition.go +++ b/pkg/generator/definition.go @@ -32,8 +32,37 @@ type Param struct { Description string `yaml:"description"` Required bool `yaml:"required"` Default interface{} `yaml:"default"` + Optional bool `yaml:"optional"` // Proto field is a pointer type (oneof/optional) + // Source, when set, makes the flag optional and falls back to a value from + // the session when the flag is left empty. The only supported value is + // "session.CurrentProject", which uses the project selected via + // "project use". If neither the flag nor the session value is set, the + // command errors out. This lets project-scoped commands omit --project-id + // once a project has been selected. + Source string `yaml:"source"` } +// APICall maps a CLI action to a gRPC endpoint via the "api-call" field in the +// YAML definitions (e.g. "admin.ListServers", "cloudv2.ListNodes"). +// +// Intentional API coverage gaps (do NOT add commands for these): +// - payment.* credit-card RPCs (AddCreditCard, RemoveCreditCard, +// ListCreditCards, ChangeDefaultCreditCard): credit cards are no longer +// used, so these calls are not supported. +// - payment.* plan-code RPCs (ListPlanCodes, GetDefaultPlanCode, +// ChangeDefaultPlanCode): Lago is no longer used, so plan codes are not +// supported. +// - auth.* RPCs (CreateClient, ListClients, GetClient, UpdateClient, +// DeleteClient, ResetClientSecret, Register, ResendConfirmEMail, GetUser): +// OAuth client management is not needed in the CLI. +// - admin.GetDashboard: not needed in the CLI. +// - admin.CreateProjectNetwork: dropped. It requires resolving subnet IDs via +// a ListSubnets endpoint that is not available on the gRPC API, and the +// request carries nested struct fields the generator cannot express. The +// previously disabled cmd/project/_network_create.go stub was removed. +// +// Internal / agent-plane services are also intentionally not surfaced in the +// CLI: network.v1.*, metadata.v1.*, gateway.v1.*, and cloud.v2.ReadinessCheck. type APICall struct { Client string Endpoint string @@ -68,6 +97,7 @@ type SubcommandDefinition struct { Identifier string `yaml:"identifier"` IdentifierKey string `yaml:"identifier-key"` Description string `yaml:"description"` + Group string `yaml:"group"` } type SubcommandMetadata struct { diff --git a/pkg/generator/definition/admin-project.yaml b/pkg/generator/definition/admin-project.yaml new file mode 100644 index 0000000..0dbf811 --- /dev/null +++ b/pkg/generator/definition/admin-project.yaml @@ -0,0 +1,60 @@ +description: Administrative project actions (cross-tenant, requires admin credentials) +group: admin + +# NOTE: This resource intentionally exposes the admin.* project endpoints under +# a dedicated "admin-project" command, separate from the user-facing "project" +# command (which is bound to cloud.* endpoints). The two cannot be merged +# because: +# - The gRPC connection is session-global (admin OR client-credentials, see +# pkg/api/connection.go), so we cannot switch credentials per-call. +# - Generated init() functions register subcommands unconditionally, so two +# actions with the same name under one root command would collide in cobra. +# The admin.UpdateProject action is implemented manually in update.go, because +# its request contains a nested BillingProfile message which the generator +# cannot express as command flags. + +actions: + list: + api-call: admin.ListProjects + description: List all projects (admin) + root-key: Projects + params: + - name: search + type: string + description: Optional search term + required: false + - name: user_id + type: string + description: Optional filter by user UUID + required: false + fields: + - Id + - Name + - Currency.Currency + - CanForceDeleteNodes.Boolean + - CreatedAt.Date + + get: + api-call: admin.GetProject + description: Get details for a project (admin) + params: + - name: id + type: string + description: Project UUID + required: true + + nodes: + api-call: admin.ListProjectNodes + description: List all nodes in a project (admin) + root-key: Nodes + params: + - name: id + type: string + description: Project UUID + required: true + fields: + - Id + - Fqdn + - Status.ServerProvisioningState + - Flavour.Flavour + - Datacenter.Datacenter diff --git a/pkg/generator/definition/billing-profile.yaml b/pkg/generator/definition/billing-profile.yaml index 683ef20..c182735 100644 --- a/pkg/generator/definition/billing-profile.yaml +++ b/pkg/generator/definition/billing-profile.yaml @@ -1,4 +1,5 @@ description: Manage billing profiles for your account +group: billing actions: list: @@ -101,4 +102,57 @@ actions: - name: billing_email description: Defines the billing email address type: string - default: "" \ No newline at end of file + default: "" + + update: + api-call: payment.UpdateBillingProfile + description: Update an existing billing profile + identifier: nil + params: + - name: id + type: string + description: Billing Profile UUID + required: true + - name: name + type: string + description: Full name + required: true + - name: company + description: Company name + type: string + - name: vat_id + description: Vat ID (e.g. DE123456789) + type: string + - name: country_code + description: Country Code (e.g. DE) + type: string + required: true + - name: state + description: State + type: string + - name: street + description: Street (e.g. Some Road 123) + type: string + required: true + - name: city + description: City + type: string + required: true + - name: postcode + description: Post Code + type: string + required: true + - name: billing_email + description: Defines the billing email address + type: string + default: "" + + delete: + api-call: payment.DeleteBillingProfile + description: Delete a billing profile + identifier: nil + params: + - name: id + type: string + description: Billing Profile UUID + required: true \ No newline at end of file diff --git a/pkg/generator/definition/country.yaml b/pkg/generator/definition/country.yaml index aee7966..6eed95c 100644 --- a/pkg/generator/definition/country.yaml +++ b/pkg/generator/definition/country.yaml @@ -1,4 +1,5 @@ description: Country related actions +group: admin actions: list: diff --git a/pkg/generator/definition/datacenter.yaml b/pkg/generator/definition/datacenter.yaml index 8ece75a..5a7bbf1 100644 --- a/pkg/generator/definition/datacenter.yaml +++ b/pkg/generator/definition/datacenter.yaml @@ -1,4 +1,5 @@ description: List or update datacenter colocations +group: admin actions: list: diff --git a/pkg/generator/definition/flavour.yaml b/pkg/generator/definition/flavour.yaml index 44a9593..5960432 100644 --- a/pkg/generator/definition/flavour.yaml +++ b/pkg/generator/definition/flavour.yaml @@ -1,4 +1,5 @@ description: Flavours (images/sizes) the customer can choose from +group: resources actions: list: @@ -30,6 +31,11 @@ actions: type: string description: Datacenter UUID required: true + - name: include_sold_out + type: bool + description: Include sold-out flavours in the result + required: false + optional: true fields: - Id - Name @@ -105,6 +111,9 @@ actions: update: api-call: admin.UpdateFlavour description: Update flavour details + # NOTE: UpdateFlavourRequest also has a repeated "mappings" + # ([]FlavourMapping) field. It is intentionally not exposed because the + # generator cannot express a repeated nested message as command flags. params: - name: id type: string diff --git a/pkg/generator/definition/image.yaml b/pkg/generator/definition/image.yaml index 0acb8df..c999cc3 100644 --- a/pkg/generator/definition/image.yaml +++ b/pkg/generator/definition/image.yaml @@ -1,4 +1,5 @@ description: Disk images to boot and install on the nodes +group: resources actions: list: @@ -24,6 +25,7 @@ actions: default: typev1.CLOUD_PROVIDER_TYPE_AWS description: Filter by cloud provider type required: false + optional: true fields: - Id - Name @@ -75,6 +77,10 @@ actions: type: "[]cloudv1.AuthenticationType" description: Authentication types required: true + - name: contains_spla_license + type: bool + description: Contains SPLA license + required: true create: api-call: admin.CreateImage diff --git a/pkg/generator/definition/ip.yaml b/pkg/generator/definition/ip.yaml index 9f8baef..423936a 100644 --- a/pkg/generator/definition/ip.yaml +++ b/pkg/generator/definition/ip.yaml @@ -1,10 +1,16 @@ description: IP related actions +group: networking actions: history: api-call: admin.ListIPHistories description: List all IP history entries root-key: IpHistories + params: + - name: search + type: string + description: Optional search term + required: false fields: - CreatedAt.DateTime - User diff --git a/pkg/generator/definition/log.yaml b/pkg/generator/definition/log.yaml index 6efc236..4879dbd 100644 --- a/pkg/generator/definition/log.yaml +++ b/pkg/generator/definition/log.yaml @@ -1,4 +1,5 @@ description: Server and admin logs +group: admin actions: server: @@ -23,6 +24,19 @@ actions: api-call: admin.ListAdminLogs description: List all admin logs root-key: Logs + params: + - name: search + type: string + description: Optional search term + required: false + - name: admin_user_id + type: string + description: Filter by admin user UUID + required: false + - name: user_id + type: string + description: Filter by target user UUID + required: false fields: - CreatedAt.DateTime - AdminUser.BasicUser diff --git a/pkg/generator/definition/network-arp.yaml b/pkg/generator/definition/network-arp.yaml index e17f9f0..9d35055 100644 --- a/pkg/generator/definition/network-arp.yaml +++ b/pkg/generator/definition/network-arp.yaml @@ -1,4 +1,5 @@ description: Network ARP table +group: networking actions: lookup: @@ -7,7 +8,7 @@ actions: params: - name: mac_address type: string - description: IP address + description: MAC address required: true list: @@ -20,4 +21,10 @@ actions: - IpAddress - MacAddress - UpdatedAt.Date - # TODO: Implement SearchRequest/SearchOptions to include extended_search here \ No newline at end of file + # TODO: Implement SearchRequest/SearchOptions to include extended_search here + + # search-options returns the valid extended-search filter fields for ARP + # entries (the counterpart to "server search-options"). + search-options: + api-call: admin.GetArpEntriesSearchOptions + description: List available extended-search filter options for ARP entries \ No newline at end of file diff --git a/pkg/generator/definition/network-subnet.yaml b/pkg/generator/definition/network-subnet.yaml index acac725..bf1e1af 100644 --- a/pkg/generator/definition/network-subnet.yaml +++ b/pkg/generator/definition/network-subnet.yaml @@ -1,4 +1,5 @@ description: Manage subnets +group: networking actions: delete: diff --git a/pkg/generator/definition/network-switch.yaml b/pkg/generator/definition/network-switch.yaml index a0b1cac..ce1c636 100644 --- a/pkg/generator/definition/network-switch.yaml +++ b/pkg/generator/definition/network-switch.yaml @@ -1,10 +1,16 @@ description: Network Switches +group: networking actions: list: api-call: admin.ListSwitches description: List all network switches root-key: Switches + params: + - name: search + type: string + description: Optional search term + required: false fields: - Id - Name diff --git a/pkg/generator/definition/network.yaml b/pkg/generator/definition/network.yaml index d57eca4..cb7811c 100644 --- a/pkg/generator/definition/network.yaml +++ b/pkg/generator/definition/network.yaml @@ -1,4 +1,5 @@ description: Network management +group: networking actions: list: diff --git a/pkg/generator/definition/node-agent.yaml b/pkg/generator/definition/node-agent.yaml index 5106630..940f5cc 100644 --- a/pkg/generator/definition/node-agent.yaml +++ b/pkg/generator/definition/node-agent.yaml @@ -1,4 +1,5 @@ description: Agents, which are deployed on the nodes +group: resources actions: list: diff --git a/pkg/generator/definition/node.yaml b/pkg/generator/definition/node.yaml index 3bcd20b..69a5b46 100644 --- a/pkg/generator/definition/node.yaml +++ b/pkg/generator/definition/node.yaml @@ -1,10 +1,16 @@ description: Node related actions +group: resources actions: list: api-call: cloudv2.ListNodes description: List all nodes in the project root-key: Nodes + params: + - name: search + type: string + description: Optional search term + required: false fields: - Id - Fqdn @@ -30,14 +36,19 @@ actions: - name: project_id description: Project ID type: string - required: true + source: session.CurrentProject # There is an update actions in the cloud API as well. Not sure which # update node actions we should use here. update: api-call: admin.UpdateNode description: Update node details + identifier: nil params: + - name: id + type: string + description: Node UUID + required: true - name: managed type: bool required: true @@ -50,7 +61,7 @@ actions: - name: project_id type: string description: Project ID - required: true + source: session.CurrentProject - name: flavour_id type: string description: Flavour ID @@ -96,7 +107,7 @@ actions: - name: project_id type: string description: Project UUID - required: true + source: session.CurrentProject destroy-immediately: api-call: admin.DestroyNode @@ -120,7 +131,7 @@ actions: - name: project_id type: string description: Project ID - required: true + source: session.CurrentProject - name: fqdn type: string description: Fully qualified domain name @@ -140,7 +151,7 @@ actions: - name: user_data type: string description: User data - required: true + default: "" power-action: api-call: cloud.PowerActionNode @@ -154,7 +165,7 @@ actions: - name: project_id description: Project ID type: string - required: true + source: session.CurrentProject - name: power_state type: cloudv1.ServerPowerState default: cloudv1.SERVER_POWER_STATE_REBOOT @@ -183,7 +194,8 @@ actions: required: true - name: project_id type: string - required: true + description: Project ID + source: session.CurrentProject - name: billing_period type: cloudv1.BillingPeriod default: cloudv1.BILLING_PERIOD_MONTHLY diff --git a/pkg/generator/definition/operating-systems.yaml b/pkg/generator/definition/operating-systems.yaml index 9cec182..3e11456 100644 --- a/pkg/generator/definition/operating-systems.yaml +++ b/pkg/generator/definition/operating-systems.yaml @@ -1,4 +1,5 @@ description: Operating system related operations +group: resources actions: list: @@ -36,6 +37,9 @@ actions: update: api-call: admin.UpdateOperatingSystem description: Update operating system details + # NOTE: UpdateOperatingSystemRequest also has an optional "icon" (File) + # field. It is intentionally not exposed because the generator cannot + # express a nested File message (file upload) as command flags. params: - name: id type: string diff --git a/pkg/generator/definition/project-cloudprovider.yaml b/pkg/generator/definition/project-cloudprovider.yaml index 1f2b7a3..51bd5b0 100644 --- a/pkg/generator/definition/project-cloudprovider.yaml +++ b/pkg/generator/definition/project-cloudprovider.yaml @@ -1,4 +1,5 @@ description: Project cloud providers +group: admin actions: list: @@ -136,6 +137,10 @@ actions: identifier: nil params: - name: image_id + type: string + description: Image UUID + required: true + - name: cloud_provider_image_id type: string description: Cloud Provider image ID required: true diff --git a/pkg/generator/definition/project-image.yaml b/pkg/generator/definition/project-image.yaml index b652482..3357490 100644 --- a/pkg/generator/definition/project-image.yaml +++ b/pkg/generator/definition/project-image.yaml @@ -1,4 +1,5 @@ description: Project images +group: resources actions: list: @@ -23,7 +24,7 @@ actions: - name: project_id type: string description: Project ID - required: true + source: session.CurrentProject create: api-call: cloud.CreateProjectImage @@ -54,7 +55,7 @@ actions: - name: project_id type: string description: Project ID - required: true + source: session.CurrentProject delete-version: api-call: cloud.DeleteProjectImageVersion @@ -68,6 +69,6 @@ actions: - name: project_id type: string description: Project ID - required: true + source: session.CurrentProject identifier: session.CurrentProject \ No newline at end of file diff --git a/pkg/generator/definition/project.yaml b/pkg/generator/definition/project.yaml index 0f348fc..d02c392 100644 --- a/pkg/generator/definition/project.yaml +++ b/pkg/generator/definition/project.yaml @@ -1,4 +1,5 @@ description: Project related actions +group: admin actions: list: @@ -22,7 +23,6 @@ actions: description: Project ID required: true - # TODO: Fix after dependency upgrade update: api-call: cloud.UpdateProject description: Update project details @@ -31,23 +31,22 @@ actions: type: string description: Project name required: true - - name: description - type: string - description: Project description - required: true - - name: environment - type: cloudv1.ProjectEnvironment - description: Project environment - default: cloudv1.PROJECT_ENVIRONMENT_DEVELOPMENT - required: true - name: credit_card_id type: string description: Credit card ID required: true - name: billing_address_id type: string - description: Billing address ID + description: Billing profile ID required: true + - name: description + type: string + description: Project description (deprecated) + default: "" + - name: environment + type: cloudv1.ProjectEnvironment + description: Project environment (deprecated) + default: cloudv1.PROJECT_ENVIRONMENT_DEVELOPMENT create: api-call: cloud.CreateProject @@ -58,16 +57,18 @@ actions: type: string description: Project name required: true + - name: billing_address_id + type: string + description: Billing profile ID + required: true - name: description type: string + description: Project description (deprecated) default: "" - name: environment type: cloudv1.ProjectEnvironment + description: Project environment (deprecated) default: cloudv1.PROJECT_ENVIRONMENT_DEVELOPMENT - required: true - - name: billing_address_id - type: string - required: true delete: api-call: cloud.DeleteProject @@ -87,6 +88,11 @@ actions: description: Datacenter to fetch the flavours from type: string required: true + - name: include_sold_out + type: bool + description: Include sold-out flavours in the result + required: false + optional: true fields: - Id - Name @@ -106,6 +112,15 @@ actions: logs: api-call: cloudv2.ListProjectLogs description: List all logs in the project + params: + - name: search + type: string + description: Optional search term + default: "" + - name: user_id + type: string + description: Filter by user UUID + default: "" fields: - CreatedAt.DateTime - User.BasicUser @@ -172,6 +187,12 @@ actions: images: api-call: cloud.ListProjectImages description: List all images in the project + params: + - name: only_available + type: bool + description: Only include available images + required: false + optional: true # TODO: Custom formatter to output keys in authorized_keys format ssh-keys: diff --git a/pkg/generator/definition/region.yaml b/pkg/generator/definition/region.yaml index fd4a194..30440d1 100644 --- a/pkg/generator/definition/region.yaml +++ b/pkg/generator/definition/region.yaml @@ -1,4 +1,5 @@ description: Manage regions in which datacenters are located +group: admin actions: list: diff --git a/pkg/generator/definition/reporting.yaml b/pkg/generator/definition/reporting.yaml index faef6e0..fb6df78 100644 --- a/pkg/generator/definition/reporting.yaml +++ b/pkg/generator/definition/reporting.yaml @@ -1,4 +1,5 @@ description: Reporting +group: billing actions: list: diff --git a/pkg/generator/definition/server-pool.yaml b/pkg/generator/definition/server-pool.yaml index 3a66610..2af4d6a 100644 --- a/pkg/generator/definition/server-pool.yaml +++ b/pkg/generator/definition/server-pool.yaml @@ -1,4 +1,5 @@ description: Server Pools +group: resources actions: list: diff --git a/pkg/generator/definition/server.yaml b/pkg/generator/definition/server.yaml index a086c81..5bf7293 100644 --- a/pkg/generator/definition/server.yaml +++ b/pkg/generator/definition/server.yaml @@ -1,4 +1,5 @@ description: Physical servers +group: resources actions: list: @@ -18,6 +19,14 @@ actions: description: Optional search term required: false + # search-options returns the set of valid extended-search filter fields and + # their allowed values. Use it to discover the --filter keys accepted by the + # "server filter" command (which calls the same ListServers RPC with an + # ExtendedSearch payload). + search-options: + api-call: admin.GetServerSearchOptions + description: List available extended-search filter options for servers + list-available: api-call: admin.ListAvailableServer description: List all available servers @@ -76,6 +85,19 @@ actions: type: string description: Server Pool UUID required: false + # is_broken is deprecated in the gRPC API and intentionally not exposed. + - name: autofix + type: bool + description: Enable the automated monitoring fix job for this server + default: false + - name: maintenance + type: bool + description: Set or clear maintenance mode + required: false + - name: maintenance_reason + type: string + description: Reason for maintenance (used when maintenance is true) + required: false delete: api-call: admin.DeleteServer @@ -90,6 +112,10 @@ actions: description: Force destroy the server, even if the server is unresponsive default: false required: true + - name: skip_clean + type: bool + description: Allow deletion without cleaning the device + default: false replace: api-call: admin.ReplaceServer @@ -114,6 +140,55 @@ actions: description: Get server platform management details params: - name: id + type: string + description: Server UUID + required: true + + issues: + api-call: admin.ListServerIssues + description: List server issues across all servers + root-key: Issues + params: + - name: source + type: cloudv1.ServerIssueSource + description: Filter by issue source + optional: true + required: false + - name: datacenter_id + type: string + description: Filter by datacenter UUID + required: false + - name: include_resolved + type: bool + description: Include resolved issues + required: false + optional: true + - name: check_name + type: string + description: Filter by check name (e.g. device_exists, primary_interface, pm_offline) + required: false + fields: + - Id + - ServerId + - Source.ServerIssueSource + - CheckName + - Message + - Resolved.Boolean + + server-issues: + api-call: admin.GetServerIssues + description: Get all issues for a specific server + params: + - name: server_id + type: string + description: Server UUID + required: true + + validate: + api-call: admin.ValidateServer + description: Validate a server and trigger a sync if needed + params: + - name: server_id type: string description: Server UUID required: true \ No newline at end of file diff --git a/pkg/generator/definition/spla.yaml b/pkg/generator/definition/spla.yaml index 7d07912..a11d6fc 100644 --- a/pkg/generator/definition/spla.yaml +++ b/pkg/generator/definition/spla.yaml @@ -1,6 +1,16 @@ description: SPLA Reporting +group: billing actions: get: api-call: admin.GetSplaReporting - description: Get SPLA reporting details \ No newline at end of file + description: Get SPLA reporting details + params: + - name: year + type: int32 + description: Reporting year + required: true + - name: month + type: int32 + description: Reporting month (1-12) + required: true \ No newline at end of file diff --git a/pkg/generator/definition/sshkey.yaml b/pkg/generator/definition/sshkey.yaml index 2ab757a..dcc5698 100644 --- a/pkg/generator/definition/sshkey.yaml +++ b/pkg/generator/definition/sshkey.yaml @@ -1,4 +1,5 @@ description: SSH Key management +group: admin actions: list: diff --git a/pkg/generator/definition/timezone.yaml b/pkg/generator/definition/timezone.yaml index d06a938..c0c7ad0 100644 --- a/pkg/generator/definition/timezone.yaml +++ b/pkg/generator/definition/timezone.yaml @@ -1,4 +1,5 @@ description: Timezones +group: admin actions: list: diff --git a/pkg/generator/definition/user.yaml b/pkg/generator/definition/user.yaml index 575becc..2422fd2 100644 --- a/pkg/generator/definition/user.yaml +++ b/pkg/generator/definition/user.yaml @@ -1,4 +1,5 @@ description: User management +group: admin actions: ssh-keys: @@ -79,4 +80,12 @@ actions: - name: user_id type: string description: User ID - required: true \ No newline at end of file + required: true + - name: reason + type: string + description: Reason for unlocking the user + required: true + + stats: + api-call: admin.GetUserStats + description: Get aggregated user statistics \ No newline at end of file diff --git a/pkg/generator/generator.go b/pkg/generator/generator.go index 3aea70d..d83ee63 100644 --- a/pkg/generator/generator.go +++ b/pkg/generator/generator.go @@ -62,9 +62,9 @@ func main() { log.Fatal(err) } } - // Create root command if not exist + // Generate root command if no custom root.go exists (always regenerate root_gen.go) if _, err := os.Stat("./cmd/" + subcommandName + "/root.go"); os.IsNotExist(err) { - log.Printf(" Create root command ./cmd/%s/root"+generatedFileSuffix+".go ...\n", subcommandName) + log.Printf(" Generate root command ./cmd/%s/root"+generatedFileSuffix+".go ...\n", subcommandName) targetFilename := "./cmd/" + subcommandName + "/root" + generatedFileSuffix + ".go" err = generator.GenerateRootCommand(metadata, targetFilename) if err != nil { diff --git a/pkg/generator/helpers.go b/pkg/generator/helpers.go index eae2bb3..30eacd8 100644 --- a/pkg/generator/helpers.go +++ b/pkg/generator/helpers.go @@ -216,6 +216,11 @@ func defaultValue(param Param) *Statement { if isArrayType(param.Type) { return Index().Id(strings.TrimPrefix(param.Type, "[]")).Values(Dict{}) } + // Enum-typed flags are bound to string variables, so an empty + // string is the correct zero value when no default is given. + if isEnumType(param.Type) { + return Lit("") + } } } else { // TODO: Fileupload @@ -252,6 +257,11 @@ func defaultValue(param Param) *Statement { // parameterDescription returns the description of a parameter. If the parameter // has a default value, the default value is added to the description. func parameterDescription(param Param) string { + // Session-sourced params are optional flags with a session fallback. + if param.Source == "session.CurrentProject" { + return fmt.Sprintf("%s (defaults to the project selected via \"project use\")", param.Description) + } + var flags []string if param.Required { flags = append(flags, "required") diff --git a/pkg/generator/root_command.go b/pkg/generator/root_command.go index 1ba7e81..2498b94 100644 --- a/pkg/generator/root_command.go +++ b/pkg/generator/root_command.go @@ -18,7 +18,7 @@ func GenerateRootCommand(metadata SubcommandDefinition, targetFilename string) e commandName := "Root" + strcase.UpperCamelCase(metadata.Name) + "Command" command := strings.ReplaceAll(metadata.Name, "_", "-") - f.Var().Add(Id(commandName).Op("=").Op("&").Qual("github.com/spf13/cobra", "Command").Values(Dict{ + values := Dict{ Id("Use"): Lit(command), Id("Short"): Lit(metadata.Description), Id("Long"): Lit(metadata.Description), @@ -27,7 +27,11 @@ func GenerateRootCommand(metadata SubcommandDefinition, targetFilename string) e Id("TraverseChildren"): True(), Id("Args"): Qual("github.com/spf13/cobra", "OnlyValidArgs"), Id("RunE"): Qual("github.com/G-PORTAL/gpcore-cli/cmd/help", "UnknownSubcommandAction"), - })) + } + if metadata.Group != "" { + values[Id("GroupID")] = Lit(metadata.Group) + } + f.Var().Add(Id(commandName).Op("=").Op("&").Qual("github.com/spf13/cobra", "Command").Values(values)) return f.Save(targetFilename) } diff --git a/pkg/generator/sub_command.go b/pkg/generator/sub_command.go index 90683bc..7a1fb46 100644 --- a/pkg/generator/sub_command.go +++ b/pkg/generator/sub_command.go @@ -44,6 +44,11 @@ func GenerateSubCommand(metadata SubcommandMetadata, targetFilename string) erro for _, param := range metadata.Action.Params { f.Var().Add(variableDefinition(name, param)) } + // When the identifier is the current-project session value, declare the + // optional --project-id override variable. + if resolveIdentifier(metadata) == "session.CurrentProject" { + f.Var().Id(strcase.LowerCamelCase(name) + "ProjectIdOverride").String() + } f.Line() // Enum helper functions @@ -85,22 +90,13 @@ func GenerateSubCommand(metadata SubcommandMetadata, targetFilename string) erro Id("Long"): Lit(metadata.Action.Description), Id("SilenceUsage"): True(), Id("SilenceErrors"): True(), - Id("Args"): Qual("github.com/spf13/cobra", "OnlyValidArgs"), + Id("Args"): Qual("github.com/spf13/cobra", "NoArgs"), Id("RunE"): Func().Params( Id("cobraCmd").Op("*").Qual("github.com/spf13/cobra", "Command"), Id("args").Index().String()).Error(). Block(runCommand(name, metadata)...), } - // Add flags and params - if len(metadata.Action.Params) > 0 { - var args []Code - for _, v := range metadata.Action.Params { - args = append(args, Lit(strcase.KebabCase(v.Name))) - } - values[Id("ValidArgs")] = Index().String().Values(args...) - } - // Final command f.Var().Add(Id(name+"Cmd").Op("="). Op("&").Qual("github.com/spf13/cobra", "Command"). @@ -130,21 +126,7 @@ func runCommand(name string, metadata SubcommandMetadata) []Code { // Identifiers can be set on the action or the definition. If the action // is set, the action identifier is used. If not, the definition identifier // is used. - identifier := "" - // "Global identifier for all actions present? - if metadata.Definition.Identifier != "" { - identifier = metadata.Definition.Identifier - } - // Override with action identifier - if metadata.Action.Identifier != "" { - // Reset identifier on purpose (override) global identifier - if metadata.Action.Identifier == "nil" { - identifier = "" - } else { - // Action specific identifier - identifier = metadata.Action.Identifier - } - } + identifier := resolveIdentifier(metadata) // The identifier key is the key used in the request to identify the // resource. By default, this is "Id". This can be overridden globally @@ -157,6 +139,13 @@ func runCommand(name string, metadata SubcommandMetadata) []Code { identifierKey = metadata.Action.IdentifierKey } + // When the identifier comes from the current project session value, we also + // expose an optional --project-id flag that overrides it. This lets the + // command be used either after "project use" or by passing --project-id + // explicitly (mirroring the source: session.CurrentProject params). + identifierProjectOverride := identifier == "session.CurrentProject" + identifierOverrideVar := strcase.LowerCamelCase(name) + "ProjectIdOverride" + if identifier != "" { c = append(c, Id("session").Op(":="). Id("ctx"). @@ -164,8 +153,56 @@ func runCommand(name string, metadata SubcommandMetadata) []Code { Assert(Op("*").Qual("github.com/G-PORTAL/gpcore-cli/pkg/config", "SessionConfig"))) c = append(c, If(Id("session").Op("==").Nil()).Block( Return(Qual("fmt", "Errorf").Call(Lit("no session found, please login first"))))) - c = append(c, If(Id(identifier).Op("==").Nil()).Block( - Return(Qual("fmt", "Errorf").Call(Lit("no identifier found, please set the identifier first"))))) + + if identifierProjectOverride { + // resolvedProjectId := + // if resolvedProjectId == "" { if session.CurrentProject == nil { err } else { resolvedProjectId = *session.CurrentProject } } + c = append(c, Id("resolvedProjectId").Op(":=").Id(identifierOverrideVar)) + c = append(c, If(Id("resolvedProjectId").Op("==").Lit("")).Block( + If(Id(identifier).Op("==").Nil()).Block( + Return(Qual("fmt", "Errorf").Call( + Lit("no project selected: pass --project-id or select one with \"project use\"")))), + Id("resolvedProjectId").Op("=").Op("*").Id(identifier), + )) + } else { + c = append(c, If(Id(identifier).Op("==").Nil()).Block( + Return(Qual("fmt", "Errorf").Call(Lit("no identifier found, please set the identifier first"))))) + } + c = append(c, Line()) + } + + // Session-sourced params (e.g. --project-id falling back to the project + // selected via "project use"). The flag is optional; when empty we fall + // back to the session value and error if that is unset as well. + sourceParams := make([]Param, 0) + for _, param := range metadata.Action.Params { + if param.Source != "" { + sourceParams = append(sourceParams, param) + } + } + if len(sourceParams) > 0 { + // Declare the session once (it may already be declared above when an + // identifier is used). + if identifier == "" { + c = append(c, Id("session").Op(":="). + Id("ctx"). + Dot("Value").Call(Lit("config")). + Assert(Op("*").Qual("github.com/G-PORTAL/gpcore-cli/pkg/config", "SessionConfig"))) + c = append(c, If(Id("session").Op("==").Nil()).Block( + Return(Qual("fmt", "Errorf").Call(Lit("no session found, please login first"))))) + } + for _, param := range sourceParams { + variable := strcase.LowerCamelCase(name) + title(strcase.LowerCamelCase(param.Name)) + sessionField := strings.TrimPrefix(param.Source, "session.") + flagName := strcase.KebabCase(param.Name) + // if == "" { if session. == nil { error } else { var = *session. } } + c = append(c, If(Id(variable).Op("==").Lit("")).Block( + If(Id("session").Dot(sessionField).Op("==").Nil()).Block( + Return(Qual("fmt", "Errorf").Call( + Lit("no project selected: pass --"+flagName+" or select one with \"project use\"")))), + Id(variable).Op("=").Op("*").Id("session").Dot(sessionField), + )) + } c = append(c, Line()) } @@ -186,7 +223,11 @@ func runCommand(name string, metadata SubcommandMetadata) []Code { apiCallParams := Dict{} // Do we have an identifier? if identifier != "" { - apiCallParams[Id(identifierKey)] = Op("*").Id(identifier) + if identifierProjectOverride { + apiCallParams[Id(identifierKey)] = Id("resolvedProjectId") + } else { + apiCallParams[Id(identifierKey)] = Op("*").Id(identifier) + } } // Specific parameters set? @@ -194,12 +235,19 @@ func runCommand(name string, metadata SubcommandMetadata) []Code { variable := strcase.LowerCamelCase(name) + title(strcase.LowerCamelCase(param.Name)) var val *Statement if isEnumType(param.Type) && !isArrayType(param.Type) { - // Enum helper function call - if !param.Required { + // Enum helper function call. Use a pointer when the proto field + // is explicitly marked as optional (oneof/optional pointer type). + if param.Optional { + // Variable is shadowed and converted before the API call, + // take its address for the pointer proto field. val = Op("&").Id(variable) } else { val = Qual("github.com/G-PORTAL/gpcore-cli/pkg/protobuf", stripPackage(param.Type)+"ToProto").Call(Id(variable)) } + } else if param.Source != "" { + // Session-sourced param: the variable is resolved (flag or session + // fallback) before the API call and always passed by value. + val = Id(variable) } else { // Optional pointer type if !param.Required && param.Default == nil { @@ -222,9 +270,11 @@ func runCommand(name string, metadata SubcommandMetadata) []Code { respC := make([]Code, 0) - // Special variables for pointer usage + // For enum params marked as optional, the proto field is a pointer type + // (oneof/optional). We need to convert the string to the enum value and + // take its address. for _, param := range metadata.Action.Params { - if isEnumType(param.Type) && !param.Required { + if isEnumType(param.Type) && param.Optional { variable := strcase.LowerCamelCase(name) + title(strcase.LowerCamelCase(param.Name)) respC = append(respC, Id(variable). Op(":="). @@ -560,6 +610,24 @@ func tableColValue(col string, structPrefix string) []Code { return c } +// resolveIdentifier returns the effective identifier for an action, taking the +// definition-level identifier and any action-level override (including the +// "nil" reset) into account. An empty string means no identifier is used. +func resolveIdentifier(metadata SubcommandMetadata) string { + identifier := "" + if metadata.Definition.Identifier != "" { + identifier = metadata.Definition.Identifier + } + if metadata.Action.Identifier != "" { + if metadata.Action.Identifier == "nil" { + identifier = "" + } else { + identifier = metadata.Action.Identifier + } + } + return identifier +} + // initFunc generates the init function, which will add all flags and params to // the command and add it to the root command. Required flags are marked as // such. @@ -599,10 +667,28 @@ func initFunc(name string, metadata SubcommandMetadata) []Code { c = append(c, Line()) } - // Required fields + // Optional --project-id override for commands whose identifier is the + // current-project session value. Lets the command run either after + // "project use" or with an explicit --project-id. + if resolveIdentifier(metadata) == "session.CurrentProject" { + c = append(c, + Id(name+"Cmd"). + Dot("Flags").Call(). + Dot("StringVar").Params( + Op("&").Id(strcase.LowerCamelCase(name)+"ProjectIdOverride"), + Lit("project-id"), + Lit(""), + Lit("Project UUID (defaults to the project selected via \"project use\")"), + )) + c = append(c, Line()) + } + + // Required fields. Session-sourced params (param.Source set) are never + // marked required: they fall back to the session value (e.g. the project + // selected via "project use") when the flag is omitted. containsRequiredFields := false for _, param := range metadata.Action.Params { - if param.Required { + if param.Required && param.Source == "" { c = append(c, Id(strcase.LowerCamelCase(name)+"Cmd"). Dot("MarkFlagRequired"). @@ -614,6 +700,44 @@ func initFunc(name string, metadata SubcommandMetadata) []Code { c = append(c, Line()) } + // Register shell completion functions for enum-typed flags + for _, param := range metadata.Action.Params { + paramType := param.Type + if isArrayType(paramType) { + paramType = strings.TrimPrefix(paramType, "[]") + } + if isEnumType(paramType) { + enumPrefix := strings.ToUpper(strcase.SnakeCase(stripEnum(stripPackage(paramType)))) + "_" + c = append(c, + Id(strcase.LowerCamelCase(name)+"Cmd"). + Dot("RegisterFlagCompletionFunc").Call( + Lit(strcase.KebabCase(param.Name)), + Func().Params( + Id("cmd").Op("*").Qual("github.com/spf13/cobra", "Command"), + Id("args").Index().String(), + Id("toComplete").String(), + ).Params(Index().String(), Qual("github.com/spf13/cobra", "ShellCompDirective")).Block( + Var().Id("completions").Index().String(), + For(List(Id("_"), Id("v")).Op(":=").Range().Qual(clientPackageName(paramType), stripPackage(paramType)+"_name")).Block( + If(Qual("strings", "HasSuffix").Call(Id("v"), Lit("UNSPECIFIED"))).Block(Continue()), + Id("name").Op(":=").Qual("strings", "ToLower").Call( + Qual("strings", "TrimPrefix").Call(Id("v"), Lit(enumPrefix)), + ), + If(Qual("strings", "HasPrefix").Call(Id("name"), Id("toComplete"))).Block( + Id("completions").Op("=").Append( + Id("completions"), + Id("name"), + ), + ), + ), + Qual("sort", "Strings").Call(Id("completions")), + Return(Id("completions"), Qual("github.com/spf13/cobra", "ShellCompDirectiveNoFileComp")), + ), + )) + } + } + c = append(c, Line()) + // Add the command to the root command when the user has set up the admin // configuration. addCommand := Id("Root" + strcase.UpperCamelCase(metadata.Definition.Name) + "Command"). diff --git a/tools.go b/tools.go deleted file mode 100644 index f8cdefd..0000000 --- a/tools.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build tools -// +build tools - -package tools - -import _ "github.com/gertd/go-pluralize" -import _ "github.com/stoewer/go-strcase"