diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 5547f83..cda9cbd 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.1.1" + ".": "0.1.2" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index e76f343..b08ef77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 0.1.2 (2025-11-20) + +Full Changelog: [v0.1.1...v0.1.2](https://github.com/onkernel/hypeman-cli/compare/v0.1.1...v0.1.2) + +### ⚠ BREAKING CHANGES + +* new logic for parsing arguments + +### Features + +* new logic for parsing arguments ([de05b62](https://github.com/onkernel/hypeman-cli/commit/de05b6274cb3d3c27dcfe9784a331a9762a8dca5)) + ## 0.1.1 (2025-11-14) Full Changelog: [v0.1.0...v0.1.1](https://github.com/onkernel/hypeman-cli/compare/v0.1.0...v0.1.1) diff --git a/pkg/cmd/health.go b/pkg/cmd/health.go index b8080a6..9a0d1d8 100644 --- a/pkg/cmd/health.go +++ b/pkg/cmd/health.go @@ -6,6 +6,7 @@ import ( "context" "fmt" + "github.com/onkernel/hypeman-go" "github.com/onkernel/hypeman-go/option" "github.com/tidwall/gjson" "github.com/urfave/cli/v3" @@ -20,15 +21,15 @@ var healthCheck = cli.Command{ } func handleHealthCheck(ctx context.Context, cmd *cli.Command) error { - cc := getAPICommandContext(cmd) + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() if len(unusedArgs) > 0 { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } var res []byte - _, err := cc.client.Health.Check( + _, err := client.Health.Check( ctx, - option.WithMiddleware(cc.AsMiddleware()), + option.WithMiddleware(debugMiddleware(cmd.Bool("debug"))), option.WithResponseBodyInto(&res), ) if err != nil { diff --git a/pkg/cmd/image.go b/pkg/cmd/image.go index 658a731..d6cb361 100644 --- a/pkg/cmd/image.go +++ b/pkg/cmd/image.go @@ -6,7 +6,6 @@ import ( "context" "fmt" - "github.com/onkernel/hypeman-cli/pkg/jsonflag" "github.com/onkernel/hypeman-go" "github.com/onkernel/hypeman-go/option" "github.com/tidwall/gjson" @@ -17,13 +16,9 @@ var imagesCreate = cli.Command{ Name: "create", Usage: "Pull and convert OCI image", Flags: []cli.Flag{ - &jsonflag.JSONStringFlag{ + &cli.StringFlag{ Name: "name", Usage: "OCI image reference (e.g., docker.io/library/nginx:latest)", - Config: jsonflag.JSONConfig{ - Kind: jsonflag.Body, - Path: "name", - }, }, }, Action: handleImagesCreate, @@ -63,17 +58,22 @@ var imagesDelete = cli.Command{ } func handleImagesCreate(ctx context.Context, cmd *cli.Command) error { - cc := getAPICommandContext(cmd) + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() if len(unusedArgs) > 0 { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } params := hypeman.ImageNewParams{} + if err := unmarshalStdinWithFlags(cmd, map[string]string{ + "name": "name", + }, ¶ms); err != nil { + return err + } var res []byte - _, err := cc.client.Images.New( + _, err := client.Images.New( ctx, params, - option.WithMiddleware(cc.AsMiddleware()), + option.WithMiddleware(debugMiddleware(cmd.Bool("debug"))), option.WithResponseBodyInto(&res), ) if err != nil { @@ -87,7 +87,7 @@ func handleImagesCreate(ctx context.Context, cmd *cli.Command) error { } func handleImagesRetrieve(ctx context.Context, cmd *cli.Command) error { - cc := getAPICommandContext(cmd) + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() if !cmd.IsSet("name") && len(unusedArgs) > 0 { cmd.Set("name", unusedArgs[0]) @@ -97,10 +97,10 @@ func handleImagesRetrieve(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } var res []byte - _, err := cc.client.Images.Get( + _, err := client.Images.Get( ctx, cmd.Value("name").(string), - option.WithMiddleware(cc.AsMiddleware()), + option.WithMiddleware(debugMiddleware(cmd.Bool("debug"))), option.WithResponseBodyInto(&res), ) if err != nil { @@ -114,15 +114,15 @@ func handleImagesRetrieve(ctx context.Context, cmd *cli.Command) error { } func handleImagesList(ctx context.Context, cmd *cli.Command) error { - cc := getAPICommandContext(cmd) + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() if len(unusedArgs) > 0 { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } var res []byte - _, err := cc.client.Images.List( + _, err := client.Images.List( ctx, - option.WithMiddleware(cc.AsMiddleware()), + option.WithMiddleware(debugMiddleware(cmd.Bool("debug"))), option.WithResponseBodyInto(&res), ) if err != nil { @@ -136,7 +136,7 @@ func handleImagesList(ctx context.Context, cmd *cli.Command) error { } func handleImagesDelete(ctx context.Context, cmd *cli.Command) error { - cc := getAPICommandContext(cmd) + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() if !cmd.IsSet("name") && len(unusedArgs) > 0 { cmd.Set("name", unusedArgs[0]) @@ -145,9 +145,9 @@ func handleImagesDelete(ctx context.Context, cmd *cli.Command) error { if len(unusedArgs) > 0 { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - return cc.client.Images.Delete( + return client.Images.Delete( ctx, cmd.Value("name").(string), - option.WithMiddleware(cc.AsMiddleware()), + option.WithMiddleware(debugMiddleware(cmd.Bool("debug"))), ) } diff --git a/pkg/cmd/instance.go b/pkg/cmd/instance.go index 11438f1..93ae96b 100644 --- a/pkg/cmd/instance.go +++ b/pkg/cmd/instance.go @@ -6,7 +6,6 @@ import ( "context" "fmt" - "github.com/onkernel/hypeman-cli/pkg/jsonflag" "github.com/onkernel/hypeman-go" "github.com/onkernel/hypeman-go/option" "github.com/tidwall/gjson" @@ -17,135 +16,38 @@ var instancesCreate = cli.Command{ Name: "create", Usage: "Create and start instance", Flags: []cli.Flag{ - &jsonflag.JSONStringFlag{ + &cli.StringFlag{ Name: "id", Usage: "Unique identifier for the instance (provided by caller)", - Config: jsonflag.JSONConfig{ - Kind: jsonflag.Body, - Path: "id", - }, }, - &jsonflag.JSONStringFlag{ + &cli.StringFlag{ Name: "image", Usage: "Image identifier", - Config: jsonflag.JSONConfig{ - Kind: jsonflag.Body, - Path: "image", - }, }, - &jsonflag.JSONStringFlag{ + &cli.StringFlag{ Name: "name", Usage: "Human-readable name", - Config: jsonflag.JSONConfig{ - Kind: jsonflag.Body, - Path: "name", - }, }, - &jsonflag.JSONIntFlag{ + &cli.Int64Flag{ Name: "memory-max-mb", Usage: "Maximum memory with hotplug in MB", - Config: jsonflag.JSONConfig{ - Kind: jsonflag.Body, - Path: "memory_max_mb", - }, Value: 4096, }, - &jsonflag.JSONIntFlag{ + &cli.Int64Flag{ Name: "memory-mb", Usage: "Base memory in MB", - Config: jsonflag.JSONConfig{ - Kind: jsonflag.Body, - Path: "memory_mb", - }, Value: 1024, }, - &jsonflag.JSONIntFlag{ - Name: "port-mappings.guest_port", - Usage: "Port mappings from host to guest", - Config: jsonflag.JSONConfig{ - Kind: jsonflag.Body, - Path: "port_mappings.#.guest_port", - }, - }, - &jsonflag.JSONIntFlag{ - Name: "port-mappings.host_port", - Usage: "Port mappings from host to guest", - Config: jsonflag.JSONConfig{ - Kind: jsonflag.Body, - Path: "port_mappings.#.host_port", - }, - }, - &jsonflag.JSONStringFlag{ - Name: "port-mappings.protocol", - Usage: "Port mappings from host to guest", - Config: jsonflag.JSONConfig{ - Kind: jsonflag.Body, - Path: "port_mappings.#.protocol", - }, - Value: "tcp", - }, - &jsonflag.JSONAnyFlag{ - Name: "+port-mapping", - Usage: "Port mappings from host to guest", - Config: jsonflag.JSONConfig{ - Kind: jsonflag.Body, - Path: "port_mappings.-1", - SetValue: map[string]interface{}{}, - }, - }, - &jsonflag.JSONIntFlag{ + &cli.Int64Flag{ Name: "timeout-seconds", Usage: "Timeout for scale-to-zero semantics", - Config: jsonflag.JSONConfig{ - Kind: jsonflag.Body, - Path: "timeout_seconds", - }, Value: 3600, }, - &jsonflag.JSONIntFlag{ + &cli.Int64Flag{ Name: "vcpus", Usage: "Number of virtual CPUs", - Config: jsonflag.JSONConfig{ - Kind: jsonflag.Body, - Path: "vcpus", - }, Value: 2, }, - &jsonflag.JSONStringFlag{ - Name: "volumes.mount_path", - Usage: "Volumes to attach", - Config: jsonflag.JSONConfig{ - Kind: jsonflag.Body, - Path: "volumes.#.mount_path", - }, - }, - &jsonflag.JSONStringFlag{ - Name: "volumes.volume_id", - Usage: "Volumes to attach", - Config: jsonflag.JSONConfig{ - Kind: jsonflag.Body, - Path: "volumes.#.volume_id", - }, - }, - &jsonflag.JSONBoolFlag{ - Name: "volumes.readonly", - Usage: "Volumes to attach", - Config: jsonflag.JSONConfig{ - Kind: jsonflag.Body, - Path: "volumes.#.readonly", - SetValue: true, - }, - Value: false, - }, - &jsonflag.JSONAnyFlag{ - Name: "+volume", - Usage: "Volumes to attach", - Config: jsonflag.JSONConfig{ - Kind: jsonflag.Body, - Path: "volumes.-1", - SetValue: map[string]interface{}{}, - }, - }, }, Action: handleInstancesCreate, HideHelpCommand: true, @@ -214,23 +116,13 @@ var instancesStreamLogs = cli.Command{ &cli.StringFlag{ Name: "id", }, - &jsonflag.JSONBoolFlag{ + &cli.BoolFlag{ Name: "follow", Usage: "Follow logs (stream with SSE)", - Config: jsonflag.JSONConfig{ - Kind: jsonflag.Query, - Path: "follow", - SetValue: true, - }, - Value: false, }, - &jsonflag.JSONIntFlag{ + &cli.Int64Flag{ Name: "tail", Usage: "Number of lines to return from end", - Config: jsonflag.JSONConfig{ - Kind: jsonflag.Query, - Path: "tail", - }, Value: 100, }, }, @@ -239,17 +131,28 @@ var instancesStreamLogs = cli.Command{ } func handleInstancesCreate(ctx context.Context, cmd *cli.Command) error { - cc := getAPICommandContext(cmd) + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() if len(unusedArgs) > 0 { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } params := hypeman.InstanceNewParams{} + if err := unmarshalStdinWithFlags(cmd, map[string]string{ + "id": "id", + "image": "image", + "name": "name", + "memory-max-mb": "memory_max_mb", + "memory-mb": "memory_mb", + "timeout-seconds": "timeout_seconds", + "vcpus": "vcpus", + }, ¶ms); err != nil { + return err + } var res []byte - _, err := cc.client.Instances.New( + _, err := client.Instances.New( ctx, params, - option.WithMiddleware(cc.AsMiddleware()), + option.WithMiddleware(debugMiddleware(cmd.Bool("debug"))), option.WithResponseBodyInto(&res), ) if err != nil { @@ -263,7 +166,7 @@ func handleInstancesCreate(ctx context.Context, cmd *cli.Command) error { } func handleInstancesRetrieve(ctx context.Context, cmd *cli.Command) error { - cc := getAPICommandContext(cmd) + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() if !cmd.IsSet("id") && len(unusedArgs) > 0 { cmd.Set("id", unusedArgs[0]) @@ -273,10 +176,10 @@ func handleInstancesRetrieve(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } var res []byte - _, err := cc.client.Instances.Get( + _, err := client.Instances.Get( ctx, cmd.Value("id").(string), - option.WithMiddleware(cc.AsMiddleware()), + option.WithMiddleware(debugMiddleware(cmd.Bool("debug"))), option.WithResponseBodyInto(&res), ) if err != nil { @@ -290,15 +193,15 @@ func handleInstancesRetrieve(ctx context.Context, cmd *cli.Command) error { } func handleInstancesList(ctx context.Context, cmd *cli.Command) error { - cc := getAPICommandContext(cmd) + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() if len(unusedArgs) > 0 { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } var res []byte - _, err := cc.client.Instances.List( + _, err := client.Instances.List( ctx, - option.WithMiddleware(cc.AsMiddleware()), + option.WithMiddleware(debugMiddleware(cmd.Bool("debug"))), option.WithResponseBodyInto(&res), ) if err != nil { @@ -312,7 +215,7 @@ func handleInstancesList(ctx context.Context, cmd *cli.Command) error { } func handleInstancesDelete(ctx context.Context, cmd *cli.Command) error { - cc := getAPICommandContext(cmd) + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() if !cmd.IsSet("id") && len(unusedArgs) > 0 { cmd.Set("id", unusedArgs[0]) @@ -321,15 +224,15 @@ func handleInstancesDelete(ctx context.Context, cmd *cli.Command) error { if len(unusedArgs) > 0 { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - return cc.client.Instances.Delete( + return client.Instances.Delete( ctx, cmd.Value("id").(string), - option.WithMiddleware(cc.AsMiddleware()), + option.WithMiddleware(debugMiddleware(cmd.Bool("debug"))), ) } func handleInstancesPutInStandby(ctx context.Context, cmd *cli.Command) error { - cc := getAPICommandContext(cmd) + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() if !cmd.IsSet("id") && len(unusedArgs) > 0 { cmd.Set("id", unusedArgs[0]) @@ -339,10 +242,10 @@ func handleInstancesPutInStandby(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } var res []byte - _, err := cc.client.Instances.PutInStandby( + _, err := client.Instances.PutInStandby( ctx, cmd.Value("id").(string), - option.WithMiddleware(cc.AsMiddleware()), + option.WithMiddleware(debugMiddleware(cmd.Bool("debug"))), option.WithResponseBodyInto(&res), ) if err != nil { @@ -356,7 +259,7 @@ func handleInstancesPutInStandby(ctx context.Context, cmd *cli.Command) error { } func handleInstancesRestoreFromStandby(ctx context.Context, cmd *cli.Command) error { - cc := getAPICommandContext(cmd) + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() if !cmd.IsSet("id") && len(unusedArgs) > 0 { cmd.Set("id", unusedArgs[0]) @@ -366,10 +269,10 @@ func handleInstancesRestoreFromStandby(ctx context.Context, cmd *cli.Command) er return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } var res []byte - _, err := cc.client.Instances.RestoreFromStandby( + _, err := client.Instances.RestoreFromStandby( ctx, cmd.Value("id").(string), - option.WithMiddleware(cc.AsMiddleware()), + option.WithMiddleware(debugMiddleware(cmd.Bool("debug"))), option.WithResponseBodyInto(&res), ) if err != nil { @@ -383,7 +286,7 @@ func handleInstancesRestoreFromStandby(ctx context.Context, cmd *cli.Command) er } func handleInstancesStreamLogs(ctx context.Context, cmd *cli.Command) error { - cc := getAPICommandContext(cmd) + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() if !cmd.IsSet("id") && len(unusedArgs) > 0 { cmd.Set("id", unusedArgs[0]) @@ -393,11 +296,17 @@ func handleInstancesStreamLogs(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } params := hypeman.InstanceStreamLogsParams{} - stream := cc.client.Instances.StreamLogsStreaming( + if cmd.IsSet("follow") { + params.Follow = hypeman.Opt(cmd.Value("follow").(bool)) + } + if cmd.IsSet("tail") { + params.Tail = hypeman.Opt(cmd.Value("tail").(int64)) + } + stream := client.Instances.StreamLogsStreaming( ctx, cmd.Value("id").(string), params, - option.WithMiddleware(cc.AsMiddleware()), + option.WithMiddleware(debugMiddleware(cmd.Bool("debug"))), ) defer stream.Close() for stream.Next() { diff --git a/pkg/cmd/instancevolume.go b/pkg/cmd/instancevolume.go index 3228e12..a41a9c4 100644 --- a/pkg/cmd/instancevolume.go +++ b/pkg/cmd/instancevolume.go @@ -6,7 +6,6 @@ import ( "context" "fmt" - "github.com/onkernel/hypeman-cli/pkg/jsonflag" "github.com/onkernel/hypeman-go" "github.com/onkernel/hypeman-go/option" "github.com/tidwall/gjson" @@ -23,23 +22,13 @@ var instancesVolumesAttach = cli.Command{ &cli.StringFlag{ Name: "volume-id", }, - &jsonflag.JSONStringFlag{ + &cli.StringFlag{ Name: "mount-path", Usage: "Path where volume should be mounted", - Config: jsonflag.JSONConfig{ - Kind: jsonflag.Body, - Path: "mount_path", - }, }, - &jsonflag.JSONBoolFlag{ + &cli.BoolFlag{ Name: "readonly", Usage: "Mount as read-only", - Config: jsonflag.JSONConfig{ - Kind: jsonflag.Body, - Path: "readonly", - SetValue: true, - }, - Value: false, }, }, Action: handleInstancesVolumesAttach, @@ -62,7 +51,7 @@ var instancesVolumesDetach = cli.Command{ } func handleInstancesVolumesAttach(ctx context.Context, cmd *cli.Command) error { - cc := getAPICommandContext(cmd) + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() if !cmd.IsSet("volume-id") && len(unusedArgs) > 0 { cmd.Set("volume-id", unusedArgs[0]) @@ -71,16 +60,24 @@ func handleInstancesVolumesAttach(ctx context.Context, cmd *cli.Command) error { if len(unusedArgs) > 0 { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := hypeman.InstanceVolumeAttachParams{} + params := hypeman.InstanceVolumeAttachParams{ + ID: cmd.Value("id").(string), + } + if err := unmarshalStdinWithFlags(cmd, map[string]string{ + "mount-path": "mount_path", + "readonly": "readonly", + }, ¶ms); err != nil { + return err + } if cmd.IsSet("id") { params.ID = cmd.Value("id").(string) } var res []byte - _, err := cc.client.Instances.Volumes.Attach( + _, err := client.Instances.Volumes.Attach( ctx, cmd.Value("volume-id").(string), params, - option.WithMiddleware(cc.AsMiddleware()), + option.WithMiddleware(debugMiddleware(cmd.Bool("debug"))), option.WithResponseBodyInto(&res), ) if err != nil { @@ -94,7 +91,7 @@ func handleInstancesVolumesAttach(ctx context.Context, cmd *cli.Command) error { } func handleInstancesVolumesDetach(ctx context.Context, cmd *cli.Command) error { - cc := getAPICommandContext(cmd) + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() if !cmd.IsSet("volume-id") && len(unusedArgs) > 0 { cmd.Set("volume-id", unusedArgs[0]) @@ -103,16 +100,18 @@ func handleInstancesVolumesDetach(ctx context.Context, cmd *cli.Command) error { if len(unusedArgs) > 0 { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := hypeman.InstanceVolumeDetachParams{} + params := hypeman.InstanceVolumeDetachParams{ + ID: cmd.Value("id").(string), + } if cmd.IsSet("id") { params.ID = cmd.Value("id").(string) } var res []byte - _, err := cc.client.Instances.Volumes.Detach( + _, err := client.Instances.Volumes.Detach( ctx, cmd.Value("volume-id").(string), params, - option.WithMiddleware(cc.AsMiddleware()), + option.WithMiddleware(debugMiddleware(cmd.Bool("debug"))), option.WithResponseBodyInto(&res), ) if err != nil { diff --git a/pkg/cmd/util.go b/pkg/cmd/util.go index e7dea77..59a6481 100644 --- a/pkg/cmd/util.go +++ b/pkg/cmd/util.go @@ -4,29 +4,32 @@ package cmd import ( "bytes" + "encoding/base64" + "encoding/json" "fmt" - "golang.org/x/term" "io" "log" "net/http" "net/http/httputil" - "net/url" "os" + "reflect" "strings" - "github.com/itchyny/json2yaml" - "github.com/onkernel/hypeman-cli/pkg/jsonflag" + "golang.org/x/term" + "github.com/onkernel/hypeman-cli/pkg/jsonview" - "github.com/onkernel/hypeman-go" "github.com/onkernel/hypeman-go/option" + + "github.com/itchyny/json2yaml" "github.com/tidwall/gjson" "github.com/tidwall/pretty" + "github.com/tidwall/sjson" "github.com/urfave/cli/v3" ) func getDefaultRequestOptions(cmd *cli.Command) []option.RequestOption { opts := []option.RequestOption{ - option.WithHeader("User-Agent", fmt.Sprintf("Hypeman/CLI %s", Version)), + option.WithHeader("User-Agent", fmt.Sprintf("BruceTestAPI/CLI %s", Version)), option.WithHeader("X-Stainless-Lang", "cli"), option.WithHeader("X-Stainless-Package-Version", Version), option.WithHeader("X-Stainless-Runtime", "cli"), @@ -41,49 +44,109 @@ func getDefaultRequestOptions(cmd *cli.Command) []option.RequestOption { return opts } -type apiCommandContext struct { - client hypeman.Client - cmd *cli.Command +type fileReader struct { + Value io.Reader + Base64Encoded bool } -func (c apiCommandContext) AsMiddleware() option.Middleware { - body := getStdInput() - if body == nil { - body = []byte("{}") +func (f *fileReader) Set(filename string) error { + reader, err := os.Open(filename) + if err != nil { + return fmt.Errorf("failed to read file %q: %w", filename, err) } - var query = []byte("{}") - var header = []byte("{}") + f.Value = reader + return nil +} - // Apply JSON flag mutations - body, query, header, err := jsonflag.ApplyMutations(body, query, header) - if err != nil { - log.Fatal(err) +func (f *fileReader) String() string { + if f.Value == nil { + return "" } + buf := new(bytes.Buffer) + buf.ReadFrom(f.Value) + if f.Base64Encoded { + return base64.StdEncoding.EncodeToString(buf.Bytes()) + } + return buf.String() +} - debug := c.cmd.Bool("debug") +func (f *fileReader) Get() any { + return f.String() +} - return func(r *http.Request, mn option.MiddlewareNext) (*http.Response, error) { - q := r.URL.Query() - for key, values := range serializeQuery(query) { - for _, value := range values { - q.Set(key, value) +func unmarshalWithReaders(data []byte, v any) error { + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + + rv := reflect.ValueOf(v).Elem() + rt := rv.Type() + + for i := 0; i < rv.NumField(); i++ { + fv := rv.Field(i) + ft := rt.Field(i) + + jsonKey := ft.Tag.Get("json") + if jsonKey == "" { + jsonKey = ft.Name + } else if idx := strings.Index(jsonKey, ","); idx != -1 { + jsonKey = jsonKey[:idx] + } + + rawVal, ok := fields[jsonKey] + if !ok { + continue + } + + if ft.Type == reflect.TypeOf((*io.Reader)(nil)).Elem() { + var s string + if err := json.Unmarshal(rawVal, &s); err != nil { + return fmt.Errorf("field %s: %w", ft.Name, err) } + fv.Set(reflect.ValueOf(strings.NewReader(s))) + } else { + ptr := fv.Addr().Interface() + if err := json.Unmarshal(rawVal, ptr); err != nil { + return fmt.Errorf("field %s: %w", ft.Name, err) + } + } + } + + return nil +} + +func unmarshalStdinWithFlags(cmd *cli.Command, flags map[string]string, target any) error { + var data []byte + if isInputPiped() { + var err error + if data, err = io.ReadAll(os.Stdin); err != nil { + return err } - r.URL.RawQuery = q.Encode() + } - for key, values := range serializeHeader(header) { - for _, value := range values { - r.Header.Add(key, value) + // Merge CLI flags into the body + for flag, path := range flags { + if cmd.IsSet(flag) { + var err error + data, err = sjson.SetBytes(data, path, cmd.Value(flag)) + if err != nil { + return err } } + } - if r.Body != nil || len(body) > 2 { - r.Body = io.NopCloser(bytes.NewBuffer(body)) - r.ContentLength = int64(len(body)) - r.Header.Set("Content-Type", "application/json") + if data != nil { + if err := unmarshalWithReaders(data, target); err != nil { + return fmt.Errorf("failed to unmarshal JSON: %w", err) } + } - // Add debug logging if the --debug flag is set + return nil +} + +func debugMiddleware(debug bool) option.Middleware { + return func(r *http.Request, mn option.MiddlewareNext) (*http.Response, error) { if debug { logger := log.Default() @@ -107,84 +170,6 @@ func (c apiCommandContext) AsMiddleware() option.Middleware { } } -func getAPICommandContext(cmd *cli.Command) *apiCommandContext { - client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) - return &apiCommandContext{client, cmd} -} - -func serializeQuery(params []byte) url.Values { - serialized := url.Values{} - - var serialize func(value gjson.Result, path string) - serialize = func(res gjson.Result, path string) { - if res.IsObject() { - for key, value := range res.Map() { - newPath := path - if len(newPath) == 0 { - newPath += key - } else { - newPath = "[" + key + "]" - } - - serialize(value, newPath) - } - } else if res.IsArray() { - for _, value := range res.Array() { - serialize(value, path) - } - } else { - serialized.Add(path, res.String()) - } - } - serialize(gjson.GetBytes(params, "@this"), "") - - for key, values := range serialized { - serialized.Set(key, strings.Join(values, ",")) - } - - return serialized -} - -func serializeHeader(params []byte) http.Header { - serialized := http.Header{} - - var serialize func(value gjson.Result, path string) - serialize = func(res gjson.Result, path string) { - if res.IsObject() { - for key, value := range res.Map() { - newPath := path - if len(newPath) > 0 { - newPath += "." - } - newPath += key - - serialize(value, newPath) - } - } else if res.IsArray() { - for _, value := range res.Array() { - serialize(value, path) - } - } else { - serialized.Add(path, res.String()) - } - } - serialize(gjson.GetBytes(params, "@this"), "") - - return serialized -} - -func getStdInput() []byte { - if !isInputPiped() { - return nil - } - data, err := io.ReadAll(os.Stdin) - if err != nil { - log.Fatal(err) - return nil - } - return data -} - func isInputPiped() bool { stat, _ := os.Stdin.Stat() return (stat.Mode() & os.ModeCharDevice) == 0 diff --git a/pkg/cmd/version.go b/pkg/cmd/version.go index e6caf60..1d3a0c9 100644 --- a/pkg/cmd/version.go +++ b/pkg/cmd/version.go @@ -2,4 +2,4 @@ package cmd -const Version = "0.1.1" // x-release-please-version +const Version = "0.1.2" // x-release-please-version diff --git a/pkg/cmd/volume.go b/pkg/cmd/volume.go index 25a14c8..3ebc1f4 100644 --- a/pkg/cmd/volume.go +++ b/pkg/cmd/volume.go @@ -6,7 +6,6 @@ import ( "context" "fmt" - "github.com/onkernel/hypeman-cli/pkg/jsonflag" "github.com/onkernel/hypeman-go" "github.com/onkernel/hypeman-go/option" "github.com/tidwall/gjson" @@ -17,29 +16,17 @@ var volumesCreate = cli.Command{ Name: "create", Usage: "Create volume", Flags: []cli.Flag{ - &jsonflag.JSONStringFlag{ + &cli.StringFlag{ Name: "name", Usage: "Volume name", - Config: jsonflag.JSONConfig{ - Kind: jsonflag.Body, - Path: "name", - }, }, - &jsonflag.JSONIntFlag{ + &cli.Int64Flag{ Name: "size-gb", Usage: "Size in gigabytes", - Config: jsonflag.JSONConfig{ - Kind: jsonflag.Body, - Path: "size_gb", - }, }, - &jsonflag.JSONStringFlag{ + &cli.StringFlag{ Name: "id", Usage: "Optional custom identifier (auto-generated if not provided)", - Config: jsonflag.JSONConfig{ - Kind: jsonflag.Body, - Path: "id", - }, }, }, Action: handleVolumesCreate, @@ -79,17 +66,24 @@ var volumesDelete = cli.Command{ } func handleVolumesCreate(ctx context.Context, cmd *cli.Command) error { - cc := getAPICommandContext(cmd) + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() if len(unusedArgs) > 0 { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } params := hypeman.VolumeNewParams{} + if err := unmarshalStdinWithFlags(cmd, map[string]string{ + "name": "name", + "size-gb": "size_gb", + "id": "id", + }, ¶ms); err != nil { + return err + } var res []byte - _, err := cc.client.Volumes.New( + _, err := client.Volumes.New( ctx, params, - option.WithMiddleware(cc.AsMiddleware()), + option.WithMiddleware(debugMiddleware(cmd.Bool("debug"))), option.WithResponseBodyInto(&res), ) if err != nil { @@ -103,7 +97,7 @@ func handleVolumesCreate(ctx context.Context, cmd *cli.Command) error { } func handleVolumesRetrieve(ctx context.Context, cmd *cli.Command) error { - cc := getAPICommandContext(cmd) + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() if !cmd.IsSet("id") && len(unusedArgs) > 0 { cmd.Set("id", unusedArgs[0]) @@ -113,10 +107,10 @@ func handleVolumesRetrieve(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } var res []byte - _, err := cc.client.Volumes.Get( + _, err := client.Volumes.Get( ctx, cmd.Value("id").(string), - option.WithMiddleware(cc.AsMiddleware()), + option.WithMiddleware(debugMiddleware(cmd.Bool("debug"))), option.WithResponseBodyInto(&res), ) if err != nil { @@ -130,15 +124,15 @@ func handleVolumesRetrieve(ctx context.Context, cmd *cli.Command) error { } func handleVolumesList(ctx context.Context, cmd *cli.Command) error { - cc := getAPICommandContext(cmd) + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() if len(unusedArgs) > 0 { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } var res []byte - _, err := cc.client.Volumes.List( + _, err := client.Volumes.List( ctx, - option.WithMiddleware(cc.AsMiddleware()), + option.WithMiddleware(debugMiddleware(cmd.Bool("debug"))), option.WithResponseBodyInto(&res), ) if err != nil { @@ -152,7 +146,7 @@ func handleVolumesList(ctx context.Context, cmd *cli.Command) error { } func handleVolumesDelete(ctx context.Context, cmd *cli.Command) error { - cc := getAPICommandContext(cmd) + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() if !cmd.IsSet("id") && len(unusedArgs) > 0 { cmd.Set("id", unusedArgs[0]) @@ -161,9 +155,9 @@ func handleVolumesDelete(ctx context.Context, cmd *cli.Command) error { if len(unusedArgs) > 0 { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - return cc.client.Volumes.Delete( + return client.Volumes.Delete( ctx, cmd.Value("id").(string), - option.WithMiddleware(cc.AsMiddleware()), + option.WithMiddleware(debugMiddleware(cmd.Bool("debug"))), ) } diff --git a/pkg/jsonflag/json_flag.go b/pkg/jsonflag/json_flag.go deleted file mode 100644 index 605f883..0000000 --- a/pkg/jsonflag/json_flag.go +++ /dev/null @@ -1,248 +0,0 @@ -package jsonflag - -import ( - "fmt" - "strconv" - "time" - - "github.com/urfave/cli/v3" -) - -type JSONConfig struct { - Kind MutationKind - Path string - // For boolean flags that set a specific value when present - SetValue any -} - -type JSONValueCreator[T any] struct{} - -func (c JSONValueCreator[T]) Create(val T, dest *T, config JSONConfig) cli.Value { - *dest = val - return &jsonValue[T]{ - destination: dest, - config: config, - } -} - -func (c JSONValueCreator[T]) ToString(val T) string { - switch v := any(val).(type) { - case string: - if v == "" { - return v - } - return fmt.Sprintf("%q", v) - case bool: - return strconv.FormatBool(v) - case int: - return strconv.Itoa(v) - case float64: - return strconv.FormatFloat(v, 'g', -1, 64) - case time.Time: - return v.Format(time.RFC3339) - default: - return fmt.Sprintf("%v", v) - } -} - -type jsonValue[T any] struct { - destination *T - config JSONConfig -} - -func (v *jsonValue[T]) Set(val string) error { - var parsed T - var err error - - // If SetValue is configured, use that value instead of parsing the input - if v.config.SetValue != nil { - // For boolean flags with SetValue, register the configured value - if _, isBool := any(parsed).(bool); isBool { - globalRegistry.Mutate(v.config.Kind, v.config.Path, v.config.SetValue) - *v.destination = any(true).(T) // Set the flag itself to true - return nil - } - // For any flags with SetValue, register the configured value - globalRegistry.Mutate(v.config.Kind, v.config.Path, v.config.SetValue) - *v.destination = any(v.config.SetValue).(T) - return nil - } - - switch any(parsed).(type) { - case string: - parsed = any(val).(T) - case bool: - boolVal, parseErr := strconv.ParseBool(val) - if parseErr != nil { - return fmt.Errorf("invalid boolean value %q: %w", val, parseErr) - } - parsed = any(boolVal).(T) - case int: - intVal, parseErr := strconv.Atoi(val) - if parseErr != nil { - return fmt.Errorf("invalid integer value %q: %w", val, parseErr) - } - parsed = any(intVal).(T) - case float64: - floatVal, parseErr := strconv.ParseFloat(val, 64) - if parseErr != nil { - return fmt.Errorf("invalid float value %q: %w", val, parseErr) - } - parsed = any(floatVal).(T) - case time.Time: - // Try common datetime formats - formats := []string{ - time.RFC3339, - "2006-01-02T15:04:05Z07:00", - "2006-01-02T15:04:05", - "2006-01-02 15:04:05", - "2006-01-02", - "15:04:05", - "15:04", - } - var timeVal time.Time - var parseErr error - for _, format := range formats { - timeVal, parseErr = time.Parse(format, val) - if parseErr == nil { - break - } - } - if parseErr != nil { - return fmt.Errorf("invalid datetime value %q: %w", val, parseErr) - } - parsed = any(timeVal).(T) - case any: - // For `any`, store the string value directly - parsed = any(val).(T) - default: - return fmt.Errorf("unsupported type for JSON flag") - } - - *v.destination = parsed - globalRegistry.Mutate(v.config.Kind, v.config.Path, parsed) - return err -} - -func (v *jsonValue[T]) Get() any { - if v.destination != nil { - return *v.destination - } - var zero T - return zero -} - -func (v *jsonValue[T]) String() string { - if v.destination != nil { - switch val := any(*v.destination).(type) { - case string: - return val - case bool: - return strconv.FormatBool(val) - case int: - return strconv.Itoa(val) - case float64: - return strconv.FormatFloat(val, 'g', -1, 64) - case time.Time: - return val.Format(time.RFC3339) - default: - return fmt.Sprintf("%v", val) - } - } - var zero T - switch any(zero).(type) { - case string: - return "" - case bool: - return "false" - case int: - return "0" - case float64: - return "0" - case time.Time: - return "" - default: - return fmt.Sprintf("%v", zero) - } -} - -func (v *jsonValue[T]) IsBoolFlag() bool { - return v.config.SetValue != nil -} - -// JSONDateValueCreator is a specialized creator for date-only values -type JSONDateValueCreator struct{} - -func (c JSONDateValueCreator) Create(val time.Time, dest *time.Time, config JSONConfig) cli.Value { - *dest = val - return &jsonDateValue{ - destination: dest, - config: config, - } -} - -func (c JSONDateValueCreator) ToString(val time.Time) string { - return val.Format("2006-01-02") -} - -type jsonDateValue struct { - destination *time.Time - config JSONConfig -} - -func (v *jsonDateValue) Set(val string) error { - // Try date-only formats first, then fall back to datetime formats - formats := []string{ - "2006-01-02", - "01/02/2006", - "Jan 2, 2006", - "January 2, 2006", - "2-Jan-2006", - time.RFC3339, - "2006-01-02T15:04:05Z07:00", - "2006-01-02T15:04:05", - "2006-01-02 15:04:05", - } - - var timeVal time.Time - var parseErr error - for _, format := range formats { - timeVal, parseErr = time.Parse(format, val) - if parseErr == nil { - break - } - } - if parseErr != nil { - return fmt.Errorf("invalid date value %q: %w", val, parseErr) - } - - *v.destination = timeVal - globalRegistry.Mutate(v.config.Kind, v.config.Path, timeVal.Format("2006-01-02")) - return nil -} - -func (v *jsonDateValue) Get() any { - if v.destination != nil { - return *v.destination - } - return time.Time{} -} - -func (v *jsonDateValue) String() string { - if v.destination != nil { - return v.destination.Format("2006-01-02") - } - return "" -} - -func (v *jsonDateValue) IsBoolFlag() bool { - return false -} - -type JSONStringFlag = cli.FlagBase[string, JSONConfig, JSONValueCreator[string]] -type JSONBoolFlag = cli.FlagBase[bool, JSONConfig, JSONValueCreator[bool]] -type JSONIntFlag = cli.FlagBase[int, JSONConfig, JSONValueCreator[int]] -type JSONFloatFlag = cli.FlagBase[float64, JSONConfig, JSONValueCreator[float64]] -type JSONDatetimeFlag = cli.FlagBase[time.Time, JSONConfig, JSONValueCreator[time.Time]] -type JSONDateFlag = cli.FlagBase[time.Time, JSONConfig, JSONDateValueCreator] -type JSONAnyFlag = cli.FlagBase[any, JSONConfig, JSONValueCreator[any]] diff --git a/pkg/jsonflag/mutation.go b/pkg/jsonflag/mutation.go deleted file mode 100644 index 46c115b..0000000 --- a/pkg/jsonflag/mutation.go +++ /dev/null @@ -1,104 +0,0 @@ -package jsonflag - -import ( - "fmt" - "strconv" - "strings" - - "github.com/tidwall/gjson" - "github.com/tidwall/sjson" -) - -type MutationKind string - -const ( - Body MutationKind = "body" - Query MutationKind = "query" - Header MutationKind = "header" -) - -type Mutation struct { - Kind MutationKind - Path string - Value any -} - -type registry struct { - mutations []Mutation -} - -var globalRegistry = ®istry{} - -func (r *registry) Mutate(kind MutationKind, path string, value any) { - r.mutations = append(r.mutations, Mutation{ - Kind: kind, - Path: path, - Value: value, - }) -} - -func (r *registry) Apply(body, query, header []byte) ([]byte, []byte, []byte, error) { - var err error - - for _, mutation := range r.mutations { - switch mutation.Kind { - case Body: - body, err = jsonSet(body, mutation.Path, mutation.Value) - case Query: - query, err = jsonSet(query, mutation.Path, mutation.Value) - case Header: - header, err = jsonSet(header, mutation.Path, mutation.Value) - } - if err != nil { - return nil, nil, nil, fmt.Errorf("failed to apply mutation %s.%s: %w", mutation.Kind, mutation.Path, err) - } - } - - return body, query, header, nil -} - -func (r *registry) Clear() { - r.mutations = nil -} - -func (r *registry) List() []Mutation { - result := make([]Mutation, len(r.mutations)) - copy(result, r.mutations) - return result -} - -// Mutate adds a mutation that will be applied to the specified kind of data -func Mutate(kind MutationKind, path string, value any) { - globalRegistry.Mutate(kind, path, value) -} - -// ApplyMutations applies all registered mutations to the provided JSON data -func ApplyMutations(body, query, header []byte) ([]byte, []byte, []byte, error) { - return globalRegistry.Apply(body, query, header) -} - -// ClearMutations removes all registered mutations from the global registry -func ClearMutations() { - globalRegistry.Clear() -} - -// ListMutations returns a copy of all currently registered mutations -func ListMutations() []Mutation { - return globalRegistry.List() -} - -func jsonSet(json []byte, path string, value any) ([]byte, error) { - keys := strings.Split(path, ".") - path = "" - for _, key := range keys { - if key == "#" { - key = strconv.Itoa(len(gjson.GetBytes(json, path).Array()) - 1) - } - - if len(path) > 0 { - path += "." - } - path += key - } - return sjson.SetBytes(json, path, value) -} diff --git a/pkg/jsonflag/mutation_test.go b/pkg/jsonflag/mutation_test.go deleted file mode 100644 index e87e518..0000000 --- a/pkg/jsonflag/mutation_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package jsonflag - -import ( - "testing" -) - -func TestApply(t *testing.T) { - ClearMutations() - - Mutate(Body, "name", "test") - Mutate(Query, "page", 1) - Mutate(Header, "authorization", "Bearer token") - - body, query, header, err := ApplyMutations( - []byte(`{}`), - []byte(`{}`), - []byte(`{}`), - ) - - if err != nil { - t.Fatalf("Failed to apply mutations: %v", err) - } - - expectedBody := `{"name":"test"}` - expectedQuery := `{"page":1}` - expectedHeader := `{"authorization":"Bearer token"}` - - if string(body) != expectedBody { - t.Errorf("Body mismatch. Expected: %s, Got: %s", expectedBody, string(body)) - } - if string(query) != expectedQuery { - t.Errorf("Query mismatch. Expected: %s, Got: %s", expectedQuery, string(query)) - } - if string(header) != expectedHeader { - t.Errorf("Header mismatch. Expected: %s, Got: %s", expectedHeader, string(header)) - } -}