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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 84 additions & 1 deletion cmd/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -25,7 +26,14 @@ var appListCmd = &cobra.Command{
RunE: runAppList,
}

// --- app history subcommand (scaffold)
var appDeleteCmd = &cobra.Command{
Use: "delete <app_name>",
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 <app_name>",
Short: "Show deployment history for an application",
Expand All @@ -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")
Expand Down Expand Up @@ -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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this initial list fetch is only used as an existence check and then thrown away — the deletion loop re-lists from offset 0 anyway. could skip this entirely and just check deleted == 0 after the loop to print "no deployments found"

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())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: no concurrency limit on the errgroup — this fires up to 100 parallel deletes per page. g.SetLimit(10) or similar would be friendlier to the API

for _, dep := range items {
g.Go(func() error {
return client.Deployments.Delete(gctx, dep.ID)
})
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Batch delete missing not-found handling causes unnecessary failures

Medium Severity

The batch delete goroutines in runAppDelete propagate not-found errors as failures, while every other delete path in the codebase — including runDeployDelete in the same diff — treats not-found as success (idempotent delete). If a deployment is removed between the List call and the Delete call (e.g., by a concurrent user or auto-cleanup), the not-found error from one goroutine causes g.Wait() to fail, cancels the gctx context (aborting other in-flight deletes), and aborts the entire operation unnecessarily.

Fix in Cursor Fix in Web

}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unbounded parallel deletes may overwhelm the API

Medium Severity

The errgroup created for each page of deployments has no concurrency limit, so up to 100 Delete calls fire simultaneously per batch. This can overwhelm the API with concurrent requests, triggering rate limiting or transient failures that cause the entire batch to abort. Adding a concurrency limit via g.SetLimit() would make this much safer.

Fix in Cursor Fix in Web

if err := g.Wait(); err != nil {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if one delete in the batch fails, g.Wait() returns early but some deletes may have already succeeded. might be worth printing the deleted count in the error path too, so the user knows partial progress happened

spinner.Fail("Failed to delete deployments")
return util.CleanedUpSdkError{Err: err}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Batch delete error discards partial progress count

Medium Severity

When g.Wait() returns an error during the batch deletion loop, the function reports "Failed to delete deployments" and returns — but the deleted count from prior successful iterations is silently discarded. For a destructive, irreversible operation, the user has no way to know whether 0 or hundreds of deployments were already removed, making recovery difficult.

Fix in Cursor Fix in Web

deleted += len(items)
spinner.UpdateText(fmt.Sprintf("Deleted %d deployment(s) so far...", deleted))
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deletion loop may infinite-loop on async deletes

High Severity

The deletion loop always re-lists deployments at Offset 0. The PR description states that Delete "stops a running deployment and marks it for deletion" rather than deleting immediately. If marked-for-deletion deployments still appear in list results, the loop will keep fetching the same items and re-attempting deletion indefinitely. The offset is never advanced, so there's no forward progress when items linger in the list after a delete call.

Additional Locations (1)

Fix in Cursor Fix in Web


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]
Expand Down
38 changes: 38 additions & 0 deletions cmd/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ import (
"github.com/spf13/cobra"
)

var deployDeleteCmd = &cobra.Command{
Use: "delete <deployment_id>",
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 <deployment_id>",
Short: "Stream logs for a deployment",
Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -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)

Expand Down
72 changes: 64 additions & 8 deletions cmd/profiles.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -31,7 +33,10 @@ type ProfilesGetInput struct {
}

type ProfilesListInput struct {
Output string
Output string
Page int
PerPage int
Query string
}

type ProfilesCreateInput struct {
Expand Down Expand Up @@ -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 = "-"
Expand All @@ -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
}

Expand Down Expand Up @@ -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")
Expand All @@ -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 {
Expand Down
Loading