diff --git a/cmd/app.go b/cmd/app.go index 98793bc..9f770ff 100644 --- a/cmd/app.go +++ b/cmd/app.go @@ -9,6 +9,7 @@ import ( "github.com/pterm/pterm" "github.com/samber/lo" "github.com/spf13/cobra" + "golang.org/x/sync/errgroup" ) var appCmd = &cobra.Command{ @@ -25,7 +26,14 @@ var appListCmd = &cobra.Command{ RunE: runAppList, } -// --- app history subcommand (scaffold) +var appDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete an app and all its deployments", + Long: "Deletes all deployments for an application. Use --version to scope deletion to a specific version.", + Args: cobra.ExactArgs(1), + RunE: runAppDelete, +} + var appHistoryCmd = &cobra.Command{ Use: "history ", Short: "Show deployment history for an application", @@ -36,8 +44,13 @@ var appHistoryCmd = &cobra.Command{ func init() { // register subcommands under app appCmd.AddCommand(appListCmd) + appCmd.AddCommand(appDeleteCmd) appCmd.AddCommand(appHistoryCmd) + // Flags for delete + appDeleteCmd.Flags().String("version", "", "Only delete deployments for this version (default: all versions)") + appDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + // Add optional filters for list appListCmd.Flags().String("name", "", "Filter by application name") appListCmd.Flags().String("version", "", "Filter by version label") @@ -206,6 +219,76 @@ func runAppList(cmd *cobra.Command, args []string) error { return nil } +func runAppDelete(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + appName := args[0] + version, _ := cmd.Flags().GetString("version") + skipConfirm, _ := cmd.Flags().GetBool("yes") + + params := kernel.DeploymentListParams{ + AppName: kernel.Opt(appName), + Limit: kernel.Opt(int64(100)), + Offset: kernel.Opt(int64(0)), + } + if version != "" { + params.AppVersion = kernel.Opt(version) + } + + initial, err := client.Deployments.List(cmd.Context(), params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + if initial == nil || len(initial.Items) == 0 { + pterm.Info.Printf("No deployments found for app '%s'\n", appName) + return nil + } + + if !skipConfirm { + scope := "all versions" + if version != "" { + scope = fmt.Sprintf("version '%s'", version) + } + msg := fmt.Sprintf("Delete all deployments for app '%s' (%s)? This cannot be undone.", appName, scope) + pterm.DefaultInteractiveConfirm.DefaultText = msg + ok, _ := pterm.DefaultInteractiveConfirm.Show() + if !ok { + pterm.Info.Println("Deletion cancelled") + return nil + } + } + + spinner, _ := pterm.DefaultSpinner.Start(fmt.Sprintf("Deleting deployments for app '%s'...", appName)) + deleted := 0 + + for { + page, err := client.Deployments.List(cmd.Context(), params) + if err != nil { + spinner.Fail("Failed to list deployments") + return util.CleanedUpSdkError{Err: err} + } + items := page.Items + if len(items) == 0 { + break + } + + g, gctx := errgroup.WithContext(cmd.Context()) + for _, dep := range items { + g.Go(func() error { + return client.Deployments.Delete(gctx, dep.ID) + }) + } + if err := g.Wait(); err != nil { + spinner.Fail("Failed to delete deployments") + return util.CleanedUpSdkError{Err: err} + } + deleted += len(items) + spinner.UpdateText(fmt.Sprintf("Deleted %d deployment(s) so far...", deleted)) + } + + spinner.Success(fmt.Sprintf("Deleted %d deployment(s) for app '%s'", deleted, appName)) + return nil +} + func runAppHistory(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) appName := args[0] diff --git a/cmd/deploy.go b/cmd/deploy.go index e826eb0..d82d7ea 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -23,6 +23,14 @@ import ( "github.com/spf13/cobra" ) +var deployDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a deployment", + Long: "Stops a running deployment and marks it for deletion. If already stopped or failed, deletes immediately.", + Args: cobra.ExactArgs(1), + RunE: runDeployDelete, +} + var deployLogsCmd = &cobra.Command{ Use: "logs ", Short: "Stream logs for a deployment", @@ -65,6 +73,9 @@ func init() { deployLogsCmd.Flags().BoolP("with-timestamps", "t", false, "Include timestamps in each log line") deployCmd.AddCommand(deployLogsCmd) + deployDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + deployCmd.AddCommand(deployDeleteCmd) + deployHistoryCmd.Flags().Int("limit", 20, "Max deployments to return (default 20)") deployHistoryCmd.Flags().Int("per-page", 20, "Items per page (alias of --limit)") deployHistoryCmd.Flags().Int("page", 1, "Page number (1-based)") @@ -306,6 +317,33 @@ func quoteIfNeeded(s string) string { return s } +func runDeployDelete(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + deploymentID := args[0] + skipConfirm, _ := cmd.Flags().GetBool("yes") + + if !skipConfirm { + msg := fmt.Sprintf("Are you sure you want to delete deployment '%s'? This cannot be undone.", deploymentID) + pterm.DefaultInteractiveConfirm.DefaultText = msg + ok, _ := pterm.DefaultInteractiveConfirm.Show() + if !ok { + pterm.Info.Println("Deletion cancelled") + return nil + } + } + + if err := client.Deployments.Delete(cmd.Context(), deploymentID); err != nil { + if util.IsNotFound(err) { + pterm.Warning.Printf("Deployment '%s' not found\n", deploymentID) + return nil + } + return util.CleanedUpSdkError{Err: err} + } + + pterm.Success.Printf("Deleted deployment %s\n", deploymentID) + return nil +} + func runDeployLogs(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) diff --git a/cmd/profiles.go b/cmd/profiles.go index 03b8c41..046364c 100644 --- a/cmd/profiles.go +++ b/cmd/profiles.go @@ -12,14 +12,16 @@ import ( "github.com/kernel/cli/pkg/util" "github.com/kernel/kernel-go-sdk" "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/pagination" "github.com/pterm/pterm" + "github.com/samber/lo" "github.com/spf13/cobra" ) // ProfilesService defines the subset of the Kernel SDK profile client that we use. type ProfilesService interface { Get(ctx context.Context, idOrName string, opts ...option.RequestOption) (res *kernel.Profile, err error) - List(ctx context.Context, opts ...option.RequestOption) (res *[]kernel.Profile, err error) + List(ctx context.Context, query kernel.ProfileListParams, opts ...option.RequestOption) (res *pagination.OffsetPagination[kernel.Profile], err error) Delete(ctx context.Context, idOrName string, opts ...option.RequestOption) (err error) New(ctx context.Context, body kernel.ProfileNewParams, opts ...option.RequestOption) (res *kernel.Profile, err error) Download(ctx context.Context, idOrName string, opts ...option.RequestOption) (res *http.Response, err error) @@ -31,7 +33,10 @@ type ProfilesGetInput struct { } type ProfilesListInput struct { - Output string + Output string + Page int + PerPage int + Query string } type ProfilesCreateInput struct { @@ -60,28 +65,56 @@ func (p ProfilesCmd) List(ctx context.Context, in ProfilesListInput) error { return fmt.Errorf("unsupported --output value: use 'json'") } + page := in.Page + perPage := in.PerPage + if page <= 0 { + page = 1 + } + if perPage <= 0 { + perPage = 20 + } + if in.Output != "json" { pterm.Info.Println("Fetching profiles...") } - items, err := p.profiles.List(ctx) + + params := kernel.ProfileListParams{} + if in.Query != "" { + params.Query = kernel.Opt(in.Query) + } + params.Limit = kernel.Opt(int64(perPage + 1)) + params.Offset = kernel.Opt(int64((page - 1) * perPage)) + + result, err := p.profiles.List(ctx, params) if err != nil { return util.CleanedUpSdkError{Err: err} } + var items []kernel.Profile + if result != nil { + items = result.Items + } + + hasMore := len(items) > perPage + if hasMore { + items = items[:perPage] + } + itemsThisPage := len(items) + if in.Output == "json" { - if items == nil || len(*items) == 0 { + if len(items) == 0 { fmt.Println("[]") return nil } - return util.PrintPrettyJSONSlice(*items) + return util.PrintPrettyJSONSlice(items) } - if items == nil || len(*items) == 0 { + if len(items) == 0 { pterm.Info.Println("No profiles found") return nil } rows := pterm.TableData{{"Profile ID", "Name", "Created At", "Updated At", "Last Used At"}} - for _, prof := range *items { + for _, prof := range items { name := prof.Name if name == "" { name = "-" @@ -95,6 +128,17 @@ func (p ProfilesCmd) List(ctx context.Context, in ProfilesListInput) error { }) } PrintTableNoPad(rows, true) + + pterm.Printf("\nPage: %d Per-page: %d Items this page: %d Has more: %s\n", page, perPage, itemsThisPage, lo.Ternary(hasMore, "yes", "no")) + if hasMore { + nextPage := page + 1 + nextCmd := fmt.Sprintf("kernel profile list --page %d --per-page %d", nextPage, perPage) + if in.Query != "" { + nextCmd += fmt.Sprintf(" --query \"%s\"", in.Query) + } + pterm.Printf("Next: %s\n", nextCmd) + } + return nil } @@ -299,6 +343,9 @@ func init() { profilesCmd.AddCommand(profilesDownloadCmd) profilesListCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + profilesListCmd.Flags().Int("per-page", 20, "Items per page (default 20)") + profilesListCmd.Flags().Int("page", 1, "Page number (1-based)") + profilesListCmd.Flags().String("query", "", "Search profiles by name or ID") profilesGetCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") profilesCreateCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") profilesCreateCmd.Flags().String("name", "", "Optional unique profile name") @@ -310,9 +357,18 @@ func init() { func runProfilesList(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) output, _ := cmd.Flags().GetString("output") + perPage, _ := cmd.Flags().GetInt("per-page") + page, _ := cmd.Flags().GetInt("page") + query, _ := cmd.Flags().GetString("query") + svc := client.Profiles p := ProfilesCmd{profiles: &svc} - return p.List(cmd.Context(), ProfilesListInput{Output: output}) + return p.List(cmd.Context(), ProfilesListInput{ + Output: output, + Page: page, + PerPage: perPage, + Query: query, + }) } func runProfilesGet(cmd *cobra.Command, args []string) error { diff --git a/cmd/profiles_test.go b/cmd/profiles_test.go index cf9b9fa..8995b75 100644 --- a/cmd/profiles_test.go +++ b/cmd/profiles_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "fmt" "io" "net/http" "os" @@ -13,6 +14,7 @@ import ( "github.com/kernel/kernel-go-sdk" "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/pagination" "github.com/pterm/pterm" "github.com/stretchr/testify/assert" ) @@ -44,7 +46,7 @@ func captureProfilesOutput(t *testing.T) *bytes.Buffer { // FakeProfilesService implements ProfilesService type FakeProfilesService struct { GetFunc func(ctx context.Context, idOrName string, opts ...option.RequestOption) (*kernel.Profile, error) - ListFunc func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.Profile, error) + ListFunc func(ctx context.Context, query kernel.ProfileListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.Profile], error) DeleteFunc func(ctx context.Context, idOrName string, opts ...option.RequestOption) error NewFunc func(ctx context.Context, body kernel.ProfileNewParams, opts ...option.RequestOption) (*kernel.Profile, error) DownloadFunc func(ctx context.Context, idOrName string, opts ...option.RequestOption) (*http.Response, error) @@ -56,12 +58,11 @@ func (f *FakeProfilesService) Get(ctx context.Context, idOrName string, opts ... } return &kernel.Profile{ID: idOrName, CreatedAt: time.Unix(0, 0), UpdatedAt: time.Unix(0, 0)}, nil } -func (f *FakeProfilesService) List(ctx context.Context, opts ...option.RequestOption) (*[]kernel.Profile, error) { +func (f *FakeProfilesService) List(ctx context.Context, query kernel.ProfileListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.Profile], error) { if f.ListFunc != nil { - return f.ListFunc(ctx, opts...) + return f.ListFunc(ctx, query, opts...) } - empty := []kernel.Profile{} - return &empty, nil + return &pagination.OffsetPagination[kernel.Profile]{Items: []kernel.Profile{}}, nil } func (f *FakeProfilesService) Delete(ctx context.Context, idOrName string, opts ...option.RequestOption) error { if f.DeleteFunc != nil { @@ -86,7 +87,7 @@ func TestProfilesList_Empty(t *testing.T) { buf := captureProfilesOutput(t) fake := &FakeProfilesService{} p := ProfilesCmd{profiles: fake} - _ = p.List(context.Background(), ProfilesListInput{}) + _ = p.List(context.Background(), ProfilesListInput{Page: 1, PerPage: 20}) assert.Contains(t, buf.String(), "No profiles found") } @@ -94,13 +95,69 @@ func TestProfilesList_WithRows(t *testing.T) { buf := captureProfilesOutput(t) created := time.Unix(0, 0) rows := []kernel.Profile{{ID: "p1", Name: "alpha", CreatedAt: created, UpdatedAt: created}, {ID: "p2", Name: "", CreatedAt: created, UpdatedAt: created}} - fake := &FakeProfilesService{ListFunc: func(ctx context.Context, opts ...option.RequestOption) (*[]kernel.Profile, error) { return &rows, nil }} + fake := &FakeProfilesService{ListFunc: func(ctx context.Context, query kernel.ProfileListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.Profile], error) { + return &pagination.OffsetPagination[kernel.Profile]{Items: rows}, nil + }} p := ProfilesCmd{profiles: fake} - _ = p.List(context.Background(), ProfilesListInput{}) + _ = p.List(context.Background(), ProfilesListInput{Page: 1, PerPage: 20}) out := buf.String() assert.Contains(t, out, "p1") assert.Contains(t, out, "alpha") assert.Contains(t, out, "p2") + assert.Contains(t, out, "Has more: no") +} + +func TestProfilesList_HasMore(t *testing.T) { + buf := captureProfilesOutput(t) + created := time.Unix(0, 0) + perPage := 2 + items := make([]kernel.Profile, perPage+1) + for i := range items { + items[i] = kernel.Profile{ID: fmt.Sprintf("p%d", i), CreatedAt: created, UpdatedAt: created} + } + fake := &FakeProfilesService{ListFunc: func(ctx context.Context, query kernel.ProfileListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.Profile], error) { + return &pagination.OffsetPagination[kernel.Profile]{Items: items}, nil + }} + p := ProfilesCmd{profiles: fake} + _ = p.List(context.Background(), ProfilesListInput{Page: 1, PerPage: perPage}) + out := buf.String() + assert.Contains(t, out, "Has more: yes") + assert.Contains(t, out, "Next: kernel profile list --page 2 --per-page 2") + assert.Contains(t, out, "p0") + assert.Contains(t, out, "p1") + assert.NotContains(t, out, "p2") +} + +func TestProfilesList_QueryInNextHint(t *testing.T) { + buf := captureProfilesOutput(t) + created := time.Unix(0, 0) + items := make([]kernel.Profile, 3) + for i := range items { + items[i] = kernel.Profile{ID: fmt.Sprintf("p%d", i), CreatedAt: created, UpdatedAt: created} + } + fake := &FakeProfilesService{ListFunc: func(ctx context.Context, query kernel.ProfileListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.Profile], error) { + return &pagination.OffsetPagination[kernel.Profile]{Items: items}, nil + }} + p := ProfilesCmd{profiles: fake} + _ = p.List(context.Background(), ProfilesListInput{Page: 1, PerPage: 2, Query: "my-bot"}) + out := buf.String() + assert.Contains(t, out, `--query "my-bot"`) +} + +func TestProfilesList_QueryWithSpacesQuoted(t *testing.T) { + buf := captureProfilesOutput(t) + created := time.Unix(0, 0) + items := make([]kernel.Profile, 3) + for i := range items { + items[i] = kernel.Profile{ID: fmt.Sprintf("p%d", i), CreatedAt: created, UpdatedAt: created} + } + fake := &FakeProfilesService{ListFunc: func(ctx context.Context, query kernel.ProfileListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.Profile], error) { + return &pagination.OffsetPagination[kernel.Profile]{Items: items}, nil + }} + p := ProfilesCmd{profiles: fake} + _ = p.List(context.Background(), ProfilesListInput{Page: 1, PerPage: 2, Query: "my bot"}) + out := buf.String() + assert.Contains(t, out, `--query "my bot"`) } func TestProfilesGet_Success(t *testing.T) { diff --git a/go.mod b/go.mod index 1e7285d..2eae5da 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.35.0 + github.com/kernel/kernel-go-sdk v0.37.0 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 github.com/samber/lo v1.51.0 @@ -19,6 +19,7 @@ require ( github.com/zalando/go-keyring v0.2.6 golang.org/x/crypto v0.47.0 golang.org/x/oauth2 v0.30.0 + golang.org/x/sync v0.19.0 ) require ( @@ -53,7 +54,6 @@ require ( github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/term v0.39.0 // indirect golang.org/x/text v0.33.0 // indirect diff --git a/go.sum b/go.sum index b50a470..067be8d 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.35.0 h1:zQcDPxq7N1njnNVoFmxvi3XMKoqemOVlnkVYuYPqAE0= -github.com/kernel/kernel-go-sdk v0.35.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.37.0 h1:90/AJUSSY0P09S2qO9GLP3xPr0qS8z0Fb7frDbVnJGQ= +github.com/kernel/kernel-go-sdk v0.37.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=