From c56c69bef8fe160085ab68b555da1d730829df48 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:55:01 +0000 Subject: [PATCH 1/3] CLI: add builder commands and `build --builder` for the Builders API The staging SDK (kernel/hypeman-go@3d2b0a55) adds a Builders resource (POST/GET /builders, GET/DELETE /builders/{id}, POST /builders/{id}/prune) and BuildNewParams.BuilderID, so builds can reuse a persistent cache disk. That commit lives only in the private kernel/hypeman-go-staging repo and is not resolvable as github.com/kernel/hypeman-go, so go.mod stays on b0c2df62be94 (already the newest published revision). To ship the coverage now, these commands call /builders through the client's generic request methods and pass builder_id as a multipart extra field. Both are marked to move to client.Builders.* and params.BuilderID once the SDK publishes. A full enumeration of api.md against pkg/cmd/ found no other gaps. Co-authored-by: Cursor --- pkg/cmd/build.go | 11 ++ pkg/cmd/buildercmd.go | 332 ++++++++++++++++++++++++++++++++++++++++++ pkg/cmd/cmd.go | 1 + 3 files changed, 344 insertions(+) create mode 100644 pkg/cmd/buildercmd.go diff --git a/pkg/cmd/build.go b/pkg/cmd/build.go index 9599fc3..9a7f64b 100644 --- a/pkg/cmd/build.go +++ b/pkg/cmd/build.go @@ -58,6 +58,11 @@ Examples: Name: "base-image-digest", Usage: "Pinned base image digest for reproducible builds", }, + &cli.StringFlag{ + Name: "builder", + Aliases: []string{"builder-id"}, + Usage: "Builder ID whose persistent cache disk backs this build (builds on the same builder are serialized)", + }, &cli.StringFlag{ Name: "cache-scope", Usage: "Tenant-specific cache key prefix", @@ -170,6 +175,12 @@ func handleBuild(ctx context.Context, cmd *cli.Command) error { if v := cmd.String("base-image-digest"); v != "" { params.BaseImageDigest = hypeman.Opt(v) } + if v := cmd.String("builder"); v != "" { + // builder_id is not on BuildNewParams in the generated SDK yet; extra fields + // are written into the same multipart body. Move to params.BuilderID once + // the SDK exposes it. + params.SetExtraFields(map[string]any{"builder_id": v}) + } if v := cmd.String("cache-scope"); v != "" { params.CacheScope = hypeman.Opt(v) } diff --git a/pkg/cmd/buildercmd.go b/pkg/cmd/buildercmd.go new file mode 100644 index 0000000..48c58d5 --- /dev/null +++ b/pkg/cmd/buildercmd.go @@ -0,0 +1,332 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/kernel/hypeman-go" + "github.com/kernel/hypeman-go/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +// The generated SDK does not expose a BuilderService yet, so these commands reach +// the /builders endpoints through the client's generic request methods. Replace +// them with client.Builders.* once that service ships. + +var builderCmd = cli.Command{ + Name: "builder", + Aliases: []string{"builders"}, + Usage: "Manage persistent build cache builders", + Description: `Manage builders, which own a persistent cache disk that backs builds. + +A builder runs one build at a time; builds targeting the same builder are +serialized. Pass a builder to 'hypeman build' with --builder to reuse its cache +across builds. + +Examples: + # Create a builder with a 100GB cache disk + hypeman builder create --name ci --disk-size 100 + + # List builders + hypeman builder list + + # Build using a builder's cache + hypeman build --builder ci-builder-id ./myapp + + # Reset a builder's cache disk without losing its identity + hypeman builder prune ci-builder-id`, + Commands: []*cli.Command{ + &builderCreateCmd, + &builderListCmd, + &builderGetCmd, + &builderDeleteCmd, + &builderPruneCmd, + }, + HideHelpCommand: true, +} + +var builderCreateCmd = cli.Command{ + Name: "create", + Usage: "Create a builder and its cache disk", + Description: "Creates a builder and its cache disk. One build at a time runs per builder.", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "id", + Usage: "Optional caller-supplied identifier (auto-generated if not provided)", + }, + &cli.StringFlag{ + Name: "name", + Usage: "Optional non-unique display name", + }, + &cli.IntFlag{ + Name: "disk-size", + Aliases: []string{"disk-size-gb"}, + Usage: "Cache disk size in gigabytes (omit to use the server default)", + }, + &cli.StringSliceFlag{ + Name: "tag", + Usage: "Set builder tag key-value pair (KEY=VALUE, can be repeated)", + }, + }, + Action: handleBuilderCreate, + HideHelpCommand: true, +} + +var builderListCmd = cli.Command{ + Name: "list", + Usage: "List builders", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "quiet", + Aliases: []string{"q"}, + Usage: "Only display builder IDs", + }, + &cli.StringSliceFlag{ + Name: "tag", + Usage: "Filter by tag key-value pair (KEY=VALUE, can be repeated)", + }, + }, + Action: handleBuilderList, + HideHelpCommand: true, +} + +var builderGetCmd = cli.Command{ + Name: "get", + Usage: "Get builder details", + ArgsUsage: "", + Action: handleBuilderGet, + HideHelpCommand: true, +} + +var builderDeleteCmd = cli.Command{ + Name: "delete", + Aliases: []string{"rm"}, + Usage: "Delete a builder", + Description: "Permanently deletes a builder and its cache disk.", + ArgsUsage: "", + Action: handleBuilderDelete, + HideHelpCommand: true, +} + +var builderPruneCmd = cli.Command{ + Name: "prune", + Usage: "Reset a builder's cache disk", + Description: "Resets the builder's cache disk. The builder transitions to pruning, then ready. Builder identity is preserved.", + ArgsUsage: "", + Action: handleBuilderPrune, + HideHelpCommand: true, +} + +type builderCreateRequest struct { + ID string `json:"id,omitempty"` + DiskSizeGB int64 `json:"disk_size_gb,omitempty"` + Name string `json:"name,omitempty"` + Tags map[string]string `json:"tags,omitempty"` +} + +func handleBuilderCreate(ctx context.Context, cmd *cli.Command) error { + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + + body := builderCreateRequest{ + ID: cmd.String("id"), + Name: cmd.String("name"), + } + if cmd.IsSet("disk-size") { + body.DiskSizeGB = int64(cmd.Int("disk-size")) + } + tags, malformedTags := parseKeyValueSpecs(cmd.StringSlice("tag")) + for _, malformed := range malformedTags { + fmt.Fprintf(os.Stderr, "Warning: ignoring malformed tag: %s\n", malformed) + } + if len(tags) > 0 { + body.Tags = tags + } + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + var res []byte + opts = append(opts, option.WithResponseBodyInto(&res)) + if err := client.Post(ctx, "builders", body, nil, opts...); err != nil { + return err + } + + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + + obj := gjson.ParseBytes(res) + if format == "auto" || format == "" { + fmt.Println(obj.Get("id").String()) + return nil + } + + return ShowJSON(os.Stdout, "builder create", obj, format, transform) +} + +func handleBuilderList(ctx context.Context, cmd *cli.Command) error { + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + tags, malformedTags := parseKeyValueSpecs(cmd.StringSlice("tag")) + for _, malformed := range malformedTags { + fmt.Fprintf(os.Stderr, "Warning: ignoring malformed tag filter: %s\n", malformed) + } + for key, value := range tags { + opts = append(opts, option.WithQuery(fmt.Sprintf("tags[%s]", key), value)) + } + + var res []byte + opts = append(opts, option.WithResponseBodyInto(&res)) + if err := client.Get(ctx, "builders", nil, nil, opts...); err != nil { + return err + } + + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + builders := gjson.ParseBytes(res) + + if format != "auto" && format != "" { + return ShowJSON(os.Stdout, "builder list", builders, format, transform) + } + + if cmd.Bool("quiet") { + builders.ForEach(func(_, value gjson.Result) bool { + fmt.Println(value.Get("id").String()) + return true + }) + return nil + } + + return showBuilderListTable(builders) +} + +func showBuilderListTable(builders gjson.Result) error { + if !builders.IsArray() || len(builders.Array()) == 0 { + fmt.Fprintln(os.Stderr, "No builders found.") + return nil + } + + table := NewTableWriter(os.Stdout, "ID", "NAME", "STATUS", "DISK", "ACTIVE BUILD", "QUEUED", "LAST USED", "CREATED") + table.TruncOrder = []int{0, 1, 4} // ID first, then NAME, ACTIVE BUILD + + builders.ForEach(func(_, value gjson.Result) bool { + name := value.Get("name").String() + if name == "" { + name = "-" + } + + activeBuild := value.Get("active_build_id").String() + if activeBuild == "" { + activeBuild = "-" + } + + table.AddRow( + value.Get("id").String(), + name, + value.Get("status").String(), + fmt.Sprintf("%d GB", value.Get("disk_size_gb").Int()), + activeBuild, + fmt.Sprintf("%d", len(value.Get("queued_builds").Array())), + FormatTimeAgo(value.Get("last_used_at").Time()), + FormatTimeAgo(value.Get("created_at").Time()), + ) + return true + }) + + table.Render() + return nil +} + +func handleBuilderGet(ctx context.Context, cmd *cli.Command) error { + id, err := requireBuilderID(cmd, "get") + if err != nil { + return err + } + + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + var res []byte + opts = append(opts, option.WithResponseBodyInto(&res)) + if err := client.Get(ctx, fmt.Sprintf("builders/%s", id), nil, nil, opts...); err != nil { + return err + } + + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + + obj := gjson.ParseBytes(res) + return ShowJSON(os.Stdout, "builder get", obj, format, transform) +} + +func handleBuilderDelete(ctx context.Context, cmd *cli.Command) error { + id, err := requireBuilderID(cmd, "delete") + if err != nil { + return err + } + + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + + opts := []option.RequestOption{option.WithHeader("Accept", "*/*")} + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + if err := client.Delete(ctx, fmt.Sprintf("builders/%s", id), nil, nil, opts...); err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "Deleted builder %s\n", id) + return nil +} + +func handleBuilderPrune(ctx context.Context, cmd *cli.Command) error { + id, err := requireBuilderID(cmd, "prune") + if err != nil { + return err + } + + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + var res []byte + opts = append(opts, option.WithResponseBodyInto(&res)) + if err := client.Post(ctx, fmt.Sprintf("builders/%s/prune", id), nil, nil, opts...); err != nil { + return err + } + + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + + obj := gjson.ParseBytes(res) + if format == "auto" || format == "" { + fmt.Fprintf(os.Stderr, "Pruning builder %s (status: %s)\n", id, obj.Get("status").String()) + return nil + } + + return ShowJSON(os.Stdout, "builder prune", obj, format, transform) +} + +func requireBuilderID(cmd *cli.Command, action string) (string, error) { + args := cmd.Args().Slice() + if len(args) < 1 { + return "", fmt.Errorf("builder ID required\nUsage: hypeman builder %s ", action) + } + return args[0], nil +} diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 2129b93..14f266b 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -69,6 +69,7 @@ func init() { }, Commands: []*cli.Command{ &buildCmd, + &builderCmd, &execCmd, &cpCmd, &pullCmd, From 9865b3425197385fdd2a4ea5a4c6216ddf15dfca Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:58:41 +0000 Subject: [PATCH 2/3] CLI: update hypeman SDK to 3d2b0a5 and use typed Builders service The SDK now generates a BuilderService and a builder_id field on BuildNewParams, so the builder commands no longer need to reach the /builders endpoints through the client's generic request methods and `build --builder` no longer needs to smuggle builder_id through extra fields. Requesting the raw response body leaves the typed result nil, so the handlers that read fields off the response only ask for it in the formats that render the response verbatim. Co-authored-by: Cursor --- go.mod | 2 +- go.sum | 4 +- pkg/cmd/build.go | 5 +- pkg/cmd/buildercmd.go | 140 +++++++++++++++++++------------------ pkg/cmd/buildercmd_test.go | 45 ++++++++++++ 5 files changed, 120 insertions(+), 76 deletions(-) create mode 100644 pkg/cmd/buildercmd_test.go diff --git a/go.mod b/go.mod index 226e5d1..fc8dae2 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/google/go-containerregistry v0.20.7 github.com/gorilla/websocket v1.5.3 github.com/itchyny/json2yaml v0.1.4 - github.com/kernel/hypeman-go v0.22.1-0.20260804210000-b0c2df62be94 + github.com/kernel/hypeman-go v0.22.1-0.20260806004456-3d2b0a558b46 github.com/knadh/koanf/parsers/yaml v1.1.0 github.com/knadh/koanf/providers/env v1.1.0 github.com/knadh/koanf/providers/file v1.2.1 diff --git a/go.sum b/go.sum index ef31241..0abd5f9 100644 --- a/go.sum +++ b/go.sum @@ -78,8 +78,8 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnV github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/itchyny/json2yaml v0.1.4 h1:/pErVOXGG5iTyXHi/QKR4y3uzhLjGTEmmJIy97YT+k8= github.com/itchyny/json2yaml v0.1.4/go.mod h1:6iudhBZdarpjLFRNj+clWLAkGft+9uCcjAZYXUH9eGI= -github.com/kernel/hypeman-go v0.22.1-0.20260804210000-b0c2df62be94 h1:9lNZxwf6sWx9vScQ0lKAWkHKFLeGiMaoGBxBOhQcePE= -github.com/kernel/hypeman-go v0.22.1-0.20260804210000-b0c2df62be94/go.mod h1:of8qI/nef2OPLzt0EMlIRbMdJHEvuc4yWG8g/ioNg48= +github.com/kernel/hypeman-go v0.22.1-0.20260806004456-3d2b0a558b46 h1:YrR0RmsbMWoHJArvUbRMxosQtMxNUTIkP5c690RiYpU= +github.com/kernel/hypeman-go v0.22.1-0.20260806004456-3d2b0a558b46/go.mod h1:of8qI/nef2OPLzt0EMlIRbMdJHEvuc4yWG8g/ioNg48= github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= diff --git a/pkg/cmd/build.go b/pkg/cmd/build.go index 9a7f64b..d6d5722 100644 --- a/pkg/cmd/build.go +++ b/pkg/cmd/build.go @@ -176,10 +176,7 @@ func handleBuild(ctx context.Context, cmd *cli.Command) error { params.BaseImageDigest = hypeman.Opt(v) } if v := cmd.String("builder"); v != "" { - // builder_id is not on BuildNewParams in the generated SDK yet; extra fields - // are written into the same multipart body. Move to params.BuilderID once - // the SDK exposes it. - params.SetExtraFields(map[string]any{"builder_id": v}) + params.BuilderID = hypeman.Opt(v) } if v := cmd.String("cache-scope"); v != "" { params.CacheScope = hypeman.Opt(v) diff --git a/pkg/cmd/buildercmd.go b/pkg/cmd/buildercmd.go index 48c58d5..06daa12 100644 --- a/pkg/cmd/buildercmd.go +++ b/pkg/cmd/buildercmd.go @@ -11,10 +11,6 @@ import ( "github.com/urfave/cli/v3" ) -// The generated SDK does not expose a BuilderService yet, so these commands reach -// the /builders endpoints through the client's generic request methods. Replace -// them with client.Builders.* once that service ships. - var builderCmd = cli.Command{ Name: "builder", Aliases: []string{"builders"}, @@ -119,29 +115,25 @@ var builderPruneCmd = cli.Command{ HideHelpCommand: true, } -type builderCreateRequest struct { - ID string `json:"id,omitempty"` - DiskSizeGB int64 `json:"disk_size_gb,omitempty"` - Name string `json:"name,omitempty"` - Tags map[string]string `json:"tags,omitempty"` -} - func handleBuilderCreate(ctx context.Context, cmd *cli.Command) error { client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) - body := builderCreateRequest{ - ID: cmd.String("id"), - Name: cmd.String("name"), + params := hypeman.BuilderNewParams{} + if v := cmd.String("id"); v != "" { + params.ID = hypeman.Opt(v) + } + if v := cmd.String("name"); v != "" { + params.Name = hypeman.Opt(v) } if cmd.IsSet("disk-size") { - body.DiskSizeGB = int64(cmd.Int("disk-size")) + params.DiskSizeGB = hypeman.Opt(int64(cmd.Int("disk-size"))) } tags, malformedTags := parseKeyValueSpecs(cmd.StringSlice("tag")) for _, malformed := range malformedTags { fmt.Fprintf(os.Stderr, "Warning: ignoring malformed tag: %s\n", malformed) } if len(tags) > 0 { - body.Tags = tags + params.Tags = tags } var opts []option.RequestOption @@ -149,22 +141,27 @@ func handleBuilderCreate(ctx context.Context, cmd *cli.Command) error { opts = append(opts, debugMiddlewareOption) } - var res []byte - opts = append(opts, option.WithResponseBodyInto(&res)) - if err := client.Post(ctx, "builders", body, nil, opts...); err != nil { - return err - } - format := cmd.Root().String("format") transform := cmd.Root().String("transform") - obj := gjson.ParseBytes(res) - if format == "auto" || format == "" { - fmt.Println(obj.Get("id").String()) - return nil + // WithResponseBodyInto captures the raw body but leaves the typed result nil, so + // only request it for the formats that render the response verbatim. + if format != "auto" && format != "" { + var res []byte + opts = append(opts, option.WithResponseBodyInto(&res)) + if _, err := client.Builders.New(ctx, params, opts...); err != nil { + return err + } + return ShowJSON(os.Stdout, "builder create", gjson.ParseBytes(res), format, transform) + } + + builder, err := client.Builders.New(ctx, params, opts...) + if err != nil { + return err } - return ShowJSON(os.Stdout, "builder create", obj, format, transform) + fmt.Println(builder.ID) + return nil } func handleBuilderList(ctx context.Context, cmd *cli.Command) error { @@ -175,41 +172,44 @@ func handleBuilderList(ctx context.Context, cmd *cli.Command) error { opts = append(opts, debugMiddlewareOption) } + params := hypeman.BuilderListParams{} tags, malformedTags := parseKeyValueSpecs(cmd.StringSlice("tag")) for _, malformed := range malformedTags { fmt.Fprintf(os.Stderr, "Warning: ignoring malformed tag filter: %s\n", malformed) } - for key, value := range tags { - opts = append(opts, option.WithQuery(fmt.Sprintf("tags[%s]", key), value)) - } - - var res []byte - opts = append(opts, option.WithResponseBodyInto(&res)) - if err := client.Get(ctx, "builders", nil, nil, opts...); err != nil { - return err + if len(tags) > 0 { + params.Tags = tags } format := cmd.Root().String("format") transform := cmd.Root().String("transform") - builders := gjson.ParseBytes(res) if format != "auto" && format != "" { - return ShowJSON(os.Stdout, "builder list", builders, format, transform) + var res []byte + opts = append(opts, option.WithResponseBodyInto(&res)) + if _, err := client.Builders.List(ctx, params, opts...); err != nil { + return err + } + return ShowJSON(os.Stdout, "builder list", gjson.ParseBytes(res), format, transform) + } + + builders, err := client.Builders.List(ctx, params, opts...) + if err != nil { + return err } if cmd.Bool("quiet") { - builders.ForEach(func(_, value gjson.Result) bool { - fmt.Println(value.Get("id").String()) - return true - }) + for _, b := range *builders { + fmt.Println(b.ID) + } return nil } - return showBuilderListTable(builders) + return showBuilderListTable(*builders) } -func showBuilderListTable(builders gjson.Result) error { - if !builders.IsArray() || len(builders.Array()) == 0 { +func showBuilderListTable(builders []hypeman.Builder) error { + if len(builders) == 0 { fmt.Fprintln(os.Stderr, "No builders found.") return nil } @@ -217,29 +217,28 @@ func showBuilderListTable(builders gjson.Result) error { table := NewTableWriter(os.Stdout, "ID", "NAME", "STATUS", "DISK", "ACTIVE BUILD", "QUEUED", "LAST USED", "CREATED") table.TruncOrder = []int{0, 1, 4} // ID first, then NAME, ACTIVE BUILD - builders.ForEach(func(_, value gjson.Result) bool { - name := value.Get("name").String() + for _, b := range builders { + name := b.Name if name == "" { name = "-" } - activeBuild := value.Get("active_build_id").String() + activeBuild := b.ActiveBuildID if activeBuild == "" { activeBuild = "-" } table.AddRow( - value.Get("id").String(), + b.ID, name, - value.Get("status").String(), - fmt.Sprintf("%d GB", value.Get("disk_size_gb").Int()), + string(b.Status), + fmt.Sprintf("%d GB", b.DiskSizeGB), activeBuild, - fmt.Sprintf("%d", len(value.Get("queued_builds").Array())), - FormatTimeAgo(value.Get("last_used_at").Time()), - FormatTimeAgo(value.Get("created_at").Time()), + fmt.Sprintf("%d", len(b.QueuedBuilds)), + FormatTimeAgo(b.LastUsedAt), + FormatTimeAgo(b.CreatedAt), ) - return true - }) + } table.Render() return nil @@ -260,7 +259,7 @@ func handleBuilderGet(ctx context.Context, cmd *cli.Command) error { var res []byte opts = append(opts, option.WithResponseBodyInto(&res)) - if err := client.Get(ctx, fmt.Sprintf("builders/%s", id), nil, nil, opts...); err != nil { + if _, err := client.Builders.Get(ctx, id, opts...); err != nil { return err } @@ -279,12 +278,12 @@ func handleBuilderDelete(ctx context.Context, cmd *cli.Command) error { client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) - opts := []option.RequestOption{option.WithHeader("Accept", "*/*")} + var opts []option.RequestOption if cmd.Root().Bool("debug") { opts = append(opts, debugMiddlewareOption) } - if err := client.Delete(ctx, fmt.Sprintf("builders/%s", id), nil, nil, opts...); err != nil { + if err := client.Builders.Delete(ctx, id, opts...); err != nil { return err } @@ -305,22 +304,25 @@ func handleBuilderPrune(ctx context.Context, cmd *cli.Command) error { opts = append(opts, debugMiddlewareOption) } - var res []byte - opts = append(opts, option.WithResponseBodyInto(&res)) - if err := client.Post(ctx, fmt.Sprintf("builders/%s/prune", id), nil, nil, opts...); err != nil { - return err - } - format := cmd.Root().String("format") transform := cmd.Root().String("transform") - obj := gjson.ParseBytes(res) - if format == "auto" || format == "" { - fmt.Fprintf(os.Stderr, "Pruning builder %s (status: %s)\n", id, obj.Get("status").String()) - return nil + if format != "auto" && format != "" { + var res []byte + opts = append(opts, option.WithResponseBodyInto(&res)) + if _, err := client.Builders.Prune(ctx, id, opts...); err != nil { + return err + } + return ShowJSON(os.Stdout, "builder prune", gjson.ParseBytes(res), format, transform) } - return ShowJSON(os.Stdout, "builder prune", obj, format, transform) + builder, err := client.Builders.Prune(ctx, id, opts...) + if err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "Pruning builder %s (status: %s)\n", id, builder.Status) + return nil } func requireBuilderID(cmd *cli.Command, action string) (string, error) { diff --git a/pkg/cmd/buildercmd_test.go b/pkg/cmd/buildercmd_test.go new file mode 100644 index 0000000..5cb0e71 --- /dev/null +++ b/pkg/cmd/buildercmd_test.go @@ -0,0 +1,45 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBuilderCommandStructure(t *testing.T) { + assert.Equal(t, "builder", builderCmd.Name) + assert.Contains(t, builderCmd.Aliases, "builders") + + subcommandNames := make([]string, len(builderCmd.Commands)) + for i, cmd := range builderCmd.Commands { + subcommandNames[i] = cmd.Name + } + + assert.Contains(t, subcommandNames, "create") + assert.Contains(t, subcommandNames, "list") + assert.Contains(t, subcommandNames, "get") + assert.Contains(t, subcommandNames, "delete") + assert.Contains(t, subcommandNames, "prune") +} + +func TestBuilderCreateCmdFlags(t *testing.T) { + flagNames := make([]string, 0) + for _, flag := range builderCreateCmd.Flags { + flagNames = append(flagNames, flag.Names()...) + } + + assert.Contains(t, flagNames, "id") + assert.Contains(t, flagNames, "name") + assert.Contains(t, flagNames, "disk-size") + assert.Contains(t, flagNames, "tag") +} + +func TestBuildCmdHasBuilderFlag(t *testing.T) { + flagNames := make([]string, 0) + for _, flag := range buildCmd.Flags { + flagNames = append(flagNames, flag.Names()...) + } + + assert.Contains(t, flagNames, "builder") + assert.Contains(t, flagNames, "builder-id") +} From f20eba8a93e1affa6c8e286fa7fc62770c11f3c7 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:03:30 +0000 Subject: [PATCH 3/3] CLI: update hypeman SDK to v0.23.0 Bumps github.com/kernel/hypeman-go to v0.23.0 (dbfca4d). A full enumeration of api.md methods and their param structs against the CLI command tree found no coverage gaps, so no command or flag changes are needed. Co-authored-by: Cursor --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index fc8dae2..423ffd4 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/google/go-containerregistry v0.20.7 github.com/gorilla/websocket v1.5.3 github.com/itchyny/json2yaml v0.1.4 - github.com/kernel/hypeman-go v0.22.1-0.20260806004456-3d2b0a558b46 + github.com/kernel/hypeman-go v0.23.0 github.com/knadh/koanf/parsers/yaml v1.1.0 github.com/knadh/koanf/providers/env v1.1.0 github.com/knadh/koanf/providers/file v1.2.1 diff --git a/go.sum b/go.sum index 0abd5f9..6109527 100644 --- a/go.sum +++ b/go.sum @@ -78,8 +78,8 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnV github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/itchyny/json2yaml v0.1.4 h1:/pErVOXGG5iTyXHi/QKR4y3uzhLjGTEmmJIy97YT+k8= github.com/itchyny/json2yaml v0.1.4/go.mod h1:6iudhBZdarpjLFRNj+clWLAkGft+9uCcjAZYXUH9eGI= -github.com/kernel/hypeman-go v0.22.1-0.20260806004456-3d2b0a558b46 h1:YrR0RmsbMWoHJArvUbRMxosQtMxNUTIkP5c690RiYpU= -github.com/kernel/hypeman-go v0.22.1-0.20260806004456-3d2b0a558b46/go.mod h1:of8qI/nef2OPLzt0EMlIRbMdJHEvuc4yWG8g/ioNg48= +github.com/kernel/hypeman-go v0.23.0 h1:07JJgYhApTAJFofx1iGRbiIn2O4un8Pm7L27WI4OiSM= +github.com/kernel/hypeman-go v0.23.0/go.mod h1:of8qI/nef2OPLzt0EMlIRbMdJHEvuc4yWG8g/ioNg48= github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=