diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 2aca35a..d04f223 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.5.0" + ".": "0.5.1" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 2410f40..96e35f6 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 18 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fhypeman-ce51f144a3d2de556750203edbaa5bfeefe874660737c35a4fc37dfb30057dd5.yml -openapi_spec_hash: 27663b6503056317abcb578ac7b67c06 -config_hash: b4e65d240d7bca1ba6162ee2098c8ac2 +configured_endpoints: 22 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fhypeman-da3f4038bb544acae375f44527f515dc58308f67822905258b155192041e65ed.yml +openapi_spec_hash: 4c7f6f453c20eda7fd8689e8917c65f9 +config_hash: a7d0557c72de54fd6baded5b189777c3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b7f5db..bdac6d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## 0.5.1 (2025-12-05) + +Full Changelog: [v0.5.0...v0.5.1](https://github.com/onkernel/hypeman-cli/compare/v0.5.0...v0.5.1) + +### Features + +* **api:** manual updates ([a3f2ec1](https://github.com/onkernel/hypeman-cli/commit/a3f2ec15101a6afd6feb1da1addcb3a2589acb53)) +* fix edge cases for sending request data and add YAML support ([3e740a9](https://github.com/onkernel/hypeman-cli/commit/3e740a94698f4704e79cc5c3b6434cbb1bfcb935)) +* Ingress ([bfb79c5](https://github.com/onkernel/hypeman-cli/commit/bfb79c5a160a3b92cac3793ea49da49ddcc7c8c6)) +* Initialize volume with data ([ef9997c](https://github.com/onkernel/hypeman-cli/commit/ef9997cc2c6d0fc14531bdf9d1238f3447e3a454)) +* **push:** add hypeman push command for local image upload ([e120ec6](https://github.com/onkernel/hypeman-cli/commit/e120ec6d96531ab49909a3d55895f5fcc4d43dc2)) +* respect HYPEMAN_BASE_URL environment variable ([17122d7](https://github.com/onkernel/hypeman-cli/commit/17122d7b2d6041c57d4e2d341b52f18697aef5d4)) + + +### Bug Fixes + +* fix for default flag values ([812e009](https://github.com/onkernel/hypeman-cli/commit/812e0091f73ab5e8992adab5ca1c2cef76b60c63)) +* **run:** wait for image to be ready before creating instance ([048ee73](https://github.com/onkernel/hypeman-cli/commit/048ee7311c39d6c3c7efad9c662fa2a1993ced97)) +* use correct user agent value ([580e468](https://github.com/onkernel/hypeman-cli/commit/580e468e95a11c8c57016954464039af3b0586f1)) + + +### Chores + +* add scripts ([c3e4955](https://github.com/onkernel/hypeman-cli/commit/c3e4955f932edc7567d929f22f3e93f22ae69e1a)) +* update dependencies ([4ed31f6](https://github.com/onkernel/hypeman-cli/commit/4ed31f6294c1b94ef764bb7959dc99e89af62cfb)) + ## 0.5.0 (2025-11-26) Full Changelog: [v0.4.0...v0.5.0](https://github.com/onkernel/hypeman-cli/compare/v0.4.0...v0.5.0) diff --git a/go.mod b/go.mod index 1ec708f..3efa46f 100644 --- a/go.mod +++ b/go.mod @@ -7,11 +7,12 @@ require ( github.com/charmbracelet/bubbletea v1.3.6 github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/x/term v0.2.1 + github.com/goccy/go-yaml v1.18.0 github.com/google/go-containerregistry v0.20.7 github.com/gorilla/websocket v1.5.3 github.com/itchyny/json2yaml v0.1.4 github.com/muesli/reflow v0.3.0 - github.com/onkernel/hypeman-go v0.4.0 + github.com/onkernel/hypeman-go v0.5.0 github.com/tidwall/gjson v1.18.0 github.com/tidwall/pretty v1.2.1 github.com/tidwall/sjson v1.2.5 @@ -21,10 +22,8 @@ require ( ) require ( - github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/ansi v0.9.3 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect @@ -43,6 +42,7 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect github.com/klauspost/compress v1.18.1 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -50,7 +50,6 @@ require ( github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect - github.com/moby/sys/sequential v0.6.0 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect @@ -66,15 +65,12 @@ require ( go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/otel v1.38.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect go.opentelemetry.io/otel/metric v1.38.0 // indirect - go.opentelemetry.io/otel/sdk v1.38.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect - go.opentelemetry.io/proto/otlp v1.9.0 // indirect - golang.org/x/net v0.47.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.31.0 // indirect - google.golang.org/protobuf v1.36.10 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect + google.golang.org/grpc v1.75.1 // indirect ) diff --git a/go.sum b/go.sum index 9dc083c..a562a01 100644 --- a/go.sum +++ b/go.sum @@ -60,6 +60,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-containerregistry v0.20.7 h1:24VGNpS0IwrOZ2ms2P1QE3Xa5X9p4phx0aUgzYzHW6I= @@ -103,8 +105,8 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/onkernel/hypeman-go v0.4.0 h1:3zDpB/WOPhkdJ/Lug3DmBDUcGR5zHwpOTzLEbsO4AnE= -github.com/onkernel/hypeman-go v0.4.0/go.mod h1:pxRRFfVcLvafZpDD1O6IjwHnem3hKEuZTCClrnGiIKA= +github.com/onkernel/hypeman-go v0.5.0 h1:ILe+n18aN5MXx0ARxDJ/ZYqcX2MdfJqWrE4sn14gJ5I= +github.com/onkernel/hypeman-go v0.5.0/go.mod h1:BPT1yh0gbby1E+As/xLM3GVjw7752+2C5SaEiJV9rRc= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -169,7 +171,6 @@ golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/apiform/encoder.go b/internal/apiform/encoder.go new file mode 100644 index 0000000..16ca44b --- /dev/null +++ b/internal/apiform/encoder.go @@ -0,0 +1,227 @@ +package apiform + +import ( + "fmt" + "io" + "mime/multipart" + "net/textproto" + "path" + "reflect" + "sort" + "strconv" + "strings" +) + +// Marshal encodes a value as multipart form data using default settings +func Marshal(value any, writer *multipart.Writer) error { + e := &encoder{ + format: FormatRepeat, + } + return e.marshal(value, writer) +} + +// MarshalWithSettings encodes a value with custom array format +func MarshalWithSettings(value any, writer *multipart.Writer, arrayFormat FormFormat) error { + e := &encoder{ + format: arrayFormat, + } + return e.marshal(value, writer) +} + +type encoder struct { + format FormFormat +} + +func (e *encoder) marshal(value any, writer *multipart.Writer) error { + val := reflect.ValueOf(value) + if !val.IsValid() { + return nil + } + return e.encodeValue("", val, writer) +} + +func (e *encoder) encodeValue(key string, val reflect.Value, writer *multipart.Writer) error { + if !val.IsValid() { + return writer.WriteField(key, "") + } + + t := val.Type() + + if t.Implements(reflect.TypeOf((*io.Reader)(nil)).Elem()) { + return e.encodeReader(key, val, writer) + } + + switch t.Kind() { + case reflect.Pointer: + if val.IsNil() || !val.IsValid() { + return writer.WriteField(key, "") + } + return e.encodeValue(key, val.Elem(), writer) + + case reflect.Slice, reflect.Array: + return e.encodeArray(key, val, writer) + + case reflect.Map: + return e.encodeMap(key, val, writer) + + case reflect.Interface: + if val.IsNil() { + return writer.WriteField(key, "") + } + return e.encodeValue(key, val.Elem(), writer) + + case reflect.String: + return writer.WriteField(key, val.String()) + + case reflect.Bool: + if val.Bool() { + return writer.WriteField(key, "true") + } + return writer.WriteField(key, "false") + + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return writer.WriteField(key, strconv.FormatInt(val.Int(), 10)) + + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return writer.WriteField(key, strconv.FormatUint(val.Uint(), 10)) + + case reflect.Float32: + return writer.WriteField(key, strconv.FormatFloat(val.Float(), 'f', -1, 32)) + + case reflect.Float64: + return writer.WriteField(key, strconv.FormatFloat(val.Float(), 'f', -1, 64)) + + default: + return fmt.Errorf("unknown type: %s", t.String()) + } +} + +func (e *encoder) encodeArray(key string, val reflect.Value, writer *multipart.Writer) error { + if e.format == FormatComma { + var values []string + for i := 0; i < val.Len(); i++ { + item := val.Index(i) + var strValue string + switch item.Kind() { + case reflect.String: + strValue = item.String() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + strValue = strconv.FormatInt(item.Int(), 10) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + strValue = strconv.FormatUint(item.Uint(), 10) + case reflect.Float32, reflect.Float64: + strValue = strconv.FormatFloat(item.Float(), 'f', -1, 64) + case reflect.Bool: + strValue = strconv.FormatBool(item.Bool()) + default: + return fmt.Errorf("comma format not supported for complex array elements") + } + values = append(values, strValue) + } + return writer.WriteField(key, strings.Join(values, ",")) + } + + for i := 0; i < val.Len(); i++ { + var formattedKey string + switch e.format { + case FormatRepeat: + formattedKey = key + case FormatBrackets: + formattedKey = key + "[]" + case FormatIndicesDots: + if key == "" { + formattedKey = strconv.Itoa(i) + } else { + formattedKey = key + "." + strconv.Itoa(i) + } + case FormatIndicesBrackets: + if key == "" { + formattedKey = strconv.Itoa(i) + } else { + formattedKey = key + "[" + strconv.Itoa(i) + "]" + } + default: + return fmt.Errorf("apiform: unsupported array format") + } + + if err := e.encodeValue(formattedKey, val.Index(i), writer); err != nil { + return err + } + } + return nil +} + +var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"") + +func escapeQuotes(s string) string { + return quoteEscaper.Replace(s) +} + +func (e *encoder) encodeReader(key string, val reflect.Value, writer *multipart.Writer) error { + reader, ok := val.Convert(reflect.TypeOf((*io.Reader)(nil)).Elem()).Interface().(io.Reader) + if !ok { + return nil + } + + // Set defaults + filename := "anonymous_file" + contentType := "application/octet-stream" + + // Get filename if available + if named, ok := reader.(interface{ Filename() string }); ok { + filename = named.Filename() + } else if named, ok := reader.(interface{ Name() string }); ok { + filename = path.Base(named.Name()) + } + + // Get content type if available + if typed, ok := reader.(interface{ ContentType() string }); ok { + contentType = typed.ContentType() + } + + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, + escapeQuotes(key), escapeQuotes(filename))) + h.Set("Content-Type", contentType) + + filewriter, err := writer.CreatePart(h) + if err != nil { + return err + } + _, err = io.Copy(filewriter, reader) + return err +} + +func (e *encoder) encodeMap(key string, val reflect.Value, writer *multipart.Writer) error { + type mapPair struct { + key string + value reflect.Value + } + + if key != "" { + key = key + "." + } + + // Collect and sort map entries for deterministic output + pairs := []mapPair{} + iter := val.MapRange() + for iter.Next() { + if iter.Key().Type().Kind() != reflect.String { + return fmt.Errorf("cannot encode a map with a non string key") + } + pairs = append(pairs, mapPair{key: iter.Key().String(), value: iter.Value()}) + } + + sort.Slice(pairs, func(i, j int) bool { + return pairs[i].key < pairs[j].key + }) + + // Process sorted pairs + for _, p := range pairs { + if err := e.encodeValue(key+p.key, p.value, writer); err != nil { + return err + } + } + + return nil +} diff --git a/internal/apiform/form.go b/internal/apiform/form.go new file mode 100644 index 0000000..024de27 --- /dev/null +++ b/internal/apiform/form.go @@ -0,0 +1,20 @@ +package apiform + +type Marshaler interface { + MarshalMultipart() ([]byte, string, error) +} + +type FormFormat int + +const ( + // FormatRepeat represents arrays as repeated keys with the same value + FormatRepeat FormFormat = iota + // Comma-separated values 1,2,3 + FormatComma + // FormatBrackets uses the key[] notation for arrays + FormatBrackets + // FormatIndicesDots uses key.0, key.1, etc. notation + FormatIndicesDots + // FormatIndicesBrackets uses key[0], key[1], etc. notation + FormatIndicesBrackets +) diff --git a/internal/apiform/form_test.go b/internal/apiform/form_test.go new file mode 100644 index 0000000..2cf5bdd --- /dev/null +++ b/internal/apiform/form_test.go @@ -0,0 +1,109 @@ +package apiform + +import ( + "bytes" + "mime/multipart" + "testing" +) + +// Define test cases +var tests = map[string]struct { + value any + format FormFormat + expected string +}{ + "nil": { + value: nil, + expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n\r\n--xxx--\r\n", + }, + "string": { + value: "hello", + expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\nhello\r\n--xxx--\r\n", + }, + "int": { + value: 42, + expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n42\r\n--xxx--\r\n", + }, + "float": { + value: 3.14, + expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n3.14\r\n--xxx--\r\n", + }, + "bool": { + value: true, + expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\ntrue\r\n--xxx--\r\n", + }, + "empty slice": { + value: []string{}, + expected: "\r\n--xxx--\r\n", + }, + "nil slice": { + value: []string(nil), + expected: "\r\n--xxx--\r\n", + }, + "slice with dot indices": { + value: []string{"a", "b", "c"}, + format: FormatIndicesDots, + expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo.0\"\r\n\r\na\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.1\"\r\n\r\nb\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.2\"\r\n\r\nc\r\n--xxx--\r\n", + }, + "slice with bracket indices": { + value: []int{10, 20, 30}, + format: FormatIndicesBrackets, + expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo[0]\"\r\n\r\n10\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo[1]\"\r\n\r\n20\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo[2]\"\r\n\r\n30\r\n--xxx--\r\n", + }, + "slice with repeat": { + value: []int{10, 20, 30}, + format: FormatRepeat, + expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n10\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n20\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n30\r\n--xxx--\r\n", + }, + "slice with commas": { + value: []int{10, 20, 30}, + format: FormatComma, + expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n10,20,30\r\n--xxx--\r\n", + }, + "empty map": { + value: map[string]any{}, + expected: "\r\n--xxx--\r\n", + }, + "nil map": { + value: map[string]any(nil), + expected: "\r\n--xxx--\r\n", + }, + "map": { + value: map[string]any{"key1": "value1", "key2": "value2"}, + expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo.key1\"\r\n\r\nvalue1\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.key2\"\r\n\r\nvalue2\r\n--xxx--\r\n", + }, + "nested_map": { + value: map[string]any{"outer": map[string]int{"inner1": 10, "inner2": 20}}, + format: FormatIndicesDots, + expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo.outer.inner1\"\r\n\r\n10\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.outer.inner2\"\r\n\r\n20\r\n--xxx--\r\n", + }, + "mixed_map": { + value: map[string]any{"name": "John", "ages": []int{25, 30, 35}}, + format: FormatIndicesDots, + expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo.ages.0\"\r\n\r\n25\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.ages.1\"\r\n\r\n30\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.ages.2\"\r\n\r\n35\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.name\"\r\n\r\nJohn\r\n--xxx--\r\n", + }, +} + +func TestEncode(t *testing.T) { + for name, test := range tests { + t.Run(name, func(t *testing.T) { + buf := bytes.NewBuffer(nil) + writer := multipart.NewWriter(buf) + writer.SetBoundary("xxx") + + form := map[string]any{"foo": test.value} + err := MarshalWithSettings(form, writer, test.format) + if err != nil { + t.Errorf("serialization of %v failed with error %v", test.value, err) + } + err = writer.Close() + if err != nil { + t.Errorf("serialization of %v failed with error %v", test.value, err) + } + result := buf.String() + if result != test.expected { + t.Errorf("expected %+#v to serialize to:\n\t%q\nbut got:\n\t%q", test.value, test.expected, result) + } + }) + } +} diff --git a/internal/apiquery/encoder.go b/internal/apiquery/encoder.go new file mode 100644 index 0000000..0d09dee --- /dev/null +++ b/internal/apiquery/encoder.go @@ -0,0 +1,166 @@ +package apiquery + +import ( + "fmt" + "reflect" + "strconv" + "strings" +) + +type encoder struct { + settings QuerySettings +} + +type Pair struct { + key string + value string +} + +func (e *encoder) Encode(key string, value reflect.Value) ([]Pair, error) { + t := value.Type() + switch t.Kind() { + case reflect.Pointer: + if value.IsNil() || !value.IsValid() { + return []Pair{{key, ""}}, nil + } + return e.Encode(key, value.Elem()) + + case reflect.Array, reflect.Slice: + return e.encodeArray(key, value) + + case reflect.Map: + return e.encodeMap(key, value) + + case reflect.Interface: + if !value.Elem().IsValid() { + return []Pair{{key, ""}}, nil + } + return e.Encode(key, value.Elem()) + + default: + return e.encodePrimitive(key, value) + } +} + +func (e *encoder) encodeMap(key string, value reflect.Value) ([]Pair, error) { + var pairs []Pair + iter := value.MapRange() + for iter.Next() { + subkey := iter.Key().String() + keyPath := subkey + if len(key) > 0 { + if e.settings.NestedFormat == NestedQueryFormatDots { + keyPath = fmt.Sprintf("%s.%s", key, subkey) + } else { + keyPath = fmt.Sprintf("%s[%s]", key, subkey) + } + } + + subpairs, err := e.Encode(keyPath, iter.Value()) + if err != nil { + return nil, err + } + pairs = append(pairs, subpairs...) + } + return pairs, nil +} + +func (e *encoder) encodeArray(key string, value reflect.Value) ([]Pair, error) { + switch e.settings.ArrayFormat { + case ArrayQueryFormatComma: + elements := []string{} + for i := 0; i < value.Len(); i++ { + innerPairs, err := e.Encode("", value.Index(i)) + if err != nil { + return nil, err + } + for _, pair := range innerPairs { + elements = append(elements, pair.value) + } + } + return []Pair{{key, strings.Join(elements, ",")}}, nil + + case ArrayQueryFormatRepeat: + var pairs []Pair + for i := 0; i < value.Len(); i++ { + subpairs, err := e.Encode(key, value.Index(i)) + if err != nil { + return nil, err + } + pairs = append(pairs, subpairs...) + } + return pairs, nil + + case ArrayQueryFormatIndices: + var pairs []Pair + for i := 0; i < value.Len(); i++ { + subpairs, err := e.Encode(fmt.Sprintf("%s[%d]", key, i), value.Index(i)) + if err != nil { + return nil, err + } + pairs = append(pairs, subpairs...) + } + return pairs, nil + + case ArrayQueryFormatBrackets: + var pairs []Pair + for i := 0; i < value.Len(); i++ { + subpairs, err := e.Encode(key+"[]", value.Index(i)) + if err != nil { + return nil, err + } + pairs = append(pairs, subpairs...) + } + return pairs, nil + + default: + panic(fmt.Sprintf("Unknown ArrayFormat value: %d", e.settings.ArrayFormat)) + } +} + +func (e *encoder) encodePrimitive(key string, value reflect.Value) ([]Pair, error) { + switch value.Kind() { + case reflect.Pointer: + if !value.IsValid() || value.IsNil() { + return nil, nil + } + return e.encodePrimitive(key, value.Elem()) + + case reflect.String: + return []Pair{{key, value.String()}}, nil + + case reflect.Bool: + if value.Bool() { + return []Pair{{key, "true"}}, nil + } + return []Pair{{key, "false"}}, nil + + case reflect.Int, reflect.Int16, reflect.Int32, reflect.Int64: + return []Pair{{key, strconv.FormatInt(value.Int(), 10)}}, nil + + case reflect.Uint, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return []Pair{{key, strconv.FormatUint(value.Uint(), 10)}}, nil + + case reflect.Float32, reflect.Float64: + return []Pair{{key, strconv.FormatFloat(value.Float(), 'f', -1, 64)}}, nil + + default: + return nil, nil + } +} + +func (e *encoder) encodeField(key string, value reflect.Value) ([]Pair, error) { + present := value.FieldByName("Present") + if !present.Bool() { + return nil, nil + } + null := value.FieldByName("Null") + if null.Bool() { + return nil, fmt.Errorf("apiquery: field cannot be null") + } + raw := value.FieldByName("Raw") + if !raw.IsNil() { + return e.Encode(key, raw) + } + return e.Encode(key, value.FieldByName("Value")) +} diff --git a/internal/apiquery/query.go b/internal/apiquery/query.go new file mode 100644 index 0000000..fd07a2f --- /dev/null +++ b/internal/apiquery/query.go @@ -0,0 +1,53 @@ +package apiquery + +import ( + "net/url" + "reflect" +) + +func MarshalWithSettings(value any, settings QuerySettings) (url.Values, error) { + val := reflect.ValueOf(value) + if !val.IsValid() { + return nil, nil + } + + e := encoder{settings} + pairs, err := e.Encode("", val) + if err != nil { + return nil, err + } + + kv := url.Values{} + for _, pair := range pairs { + kv.Add(pair.key, pair.value) + } + return kv, nil +} +func Marshal(value any) (url.Values, error) { + return MarshalWithSettings(value, QuerySettings{}) +} + +type Queryer interface { + URLQuery() (url.Values, error) +} + +type NestedQueryFormat int + +const ( + NestedQueryFormatBrackets NestedQueryFormat = iota + NestedQueryFormatDots +) + +type ArrayQueryFormat int + +const ( + ArrayQueryFormatComma ArrayQueryFormat = iota + ArrayQueryFormatRepeat + ArrayQueryFormatIndices + ArrayQueryFormatBrackets +) + +type QuerySettings struct { + NestedFormat NestedQueryFormat + ArrayFormat ArrayQueryFormat +} diff --git a/internal/apiquery/query_test.go b/internal/apiquery/query_test.go new file mode 100644 index 0000000..8bee784 --- /dev/null +++ b/internal/apiquery/query_test.go @@ -0,0 +1,128 @@ +package apiquery + +import ( + "net/url" + "testing" +) + +func TestEncode(t *testing.T) { + tests := map[string]struct { + val any + settings QuerySettings + enc string + }{ + "null": { + val: nil, + enc: "query=", + }, + "string": { + val: "hello world", + enc: "query=hello world", + }, + "int": { + val: 42, + enc: "query=42", + }, + "float": { + val: 3.14, + enc: "query=3.14", + }, + "bool": { + val: true, + enc: "query=true", + }, + "empty_slice": { + val: []any{}, + settings: QuerySettings{ArrayFormat: ArrayQueryFormatComma}, + enc: "query=", + }, + "nil_slice": { + val: []any(nil), + settings: QuerySettings{ArrayFormat: ArrayQueryFormatComma}, + enc: "query=", + }, + "slice_of_ints": { + val: []any{10, 20, 30}, + settings: QuerySettings{ArrayFormat: ArrayQueryFormatComma}, + enc: "query=10,20,30", + }, + "slice_of_ints_repeat": { + val: []any{10, 20, 30}, + settings: QuerySettings{ArrayFormat: ArrayQueryFormatRepeat}, + enc: "query=10&query=20&query=30", + }, + "slice_of_ints_indices": { + val: []any{10, 20, 30}, + settings: QuerySettings{ArrayFormat: ArrayQueryFormatIndices}, + enc: "query[0]=10&query[1]=20&query[2]=30", + }, + "slice_of_ints_brackets": { + val: []any{10, 20, 30}, + settings: QuerySettings{ArrayFormat: ArrayQueryFormatBrackets}, + enc: "query[]=10&query[]=20&query[]=30", + }, + "slice_of_strings": { + val: []any{"a", "b", "c"}, + settings: QuerySettings{}, + enc: "query=a,b,c", + }, + "empty_map": { + val: map[string]any{}, + settings: QuerySettings{NestedFormat: NestedQueryFormatBrackets}, + enc: "", + }, + "nil_map": { + val: map[string]any(nil), + settings: QuerySettings{NestedFormat: NestedQueryFormatBrackets}, + enc: "", + }, + "map_string_to_int_brackets": { + val: map[string]any{"one": 1, "two": 2}, + settings: QuerySettings{NestedFormat: NestedQueryFormatBrackets}, + enc: "query[one]=1&query[two]=2", + }, + "map_string_to_int_dots": { + val: map[string]any{"one": 1, "two": 2}, + settings: QuerySettings{NestedFormat: NestedQueryFormatDots}, + enc: "query.one=1&query.two=2", + }, + "map_string_to_slice": { + val: map[string][]any{"nums": {10, 20, 30}}, + settings: QuerySettings{}, + enc: "query[nums]=10,20,30", + }, + "map_string_to_slice_repeat_dots": { + val: map[string][]any{"nums": {10, 20, 30}}, + settings: QuerySettings{ArrayFormat: ArrayQueryFormatRepeat, NestedFormat: NestedQueryFormatDots}, + enc: "query.nums=10&query.nums=20&query.nums=30", + }, + "map_with_empties": { + val: map[string]any{ + "empty-array": []any{}, + "nil-array": []any(nil), + "null": nil, + }, + settings: QuerySettings{ArrayFormat: ArrayQueryFormatComma, NestedFormat: NestedQueryFormatDots}, + enc: "query.empty-array=&query.nil-array=&query.null=", + }, + "nested_map": { + val: map[string]map[string]any{"outer": {"inner": 42}}, + settings: QuerySettings{}, + enc: "query[outer][inner]=42", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + query := map[string]any{"query": test.val} + values, err := MarshalWithSettings(query, test.settings) + if err != nil { + t.Fatalf("failed to marshal url %s", err) + } + str, _ := url.QueryUnescape(values.Encode()) + if str != test.enc { + t.Fatalf("expected %+#v to serialize to:\n\t%q\nbut got:\n\t%q", test.val, test.enc, str) + } + }) + } +} diff --git a/internal/requestflag/requestflag.go b/internal/requestflag/requestflag.go new file mode 100644 index 0000000..4edbf4f --- /dev/null +++ b/internal/requestflag/requestflag.go @@ -0,0 +1,245 @@ +package requestflag + +import ( + "time" + + "github.com/goccy/go-yaml" + "github.com/urfave/cli/v3" +) + +type ( + YAMLFlag = cli.FlagBase[requestValue[any], RequestConfig, requestValueCreator[any]] + YAMLSlice = cli.SliceBase[requestValue[any], RequestConfig, requestValueCreator[any]] + YAMLSliceFlag = cli.FlagBase[[]requestValue[any], RequestConfig, YAMLSlice] + + StringFlag = cli.FlagBase[requestValue[string], RequestConfig, requestValueCreator[string]] + StringSlice = cli.SliceBase[requestValue[string], RequestConfig, requestValueCreator[string]] + StringSliceFlag = cli.FlagBase[[]requestValue[string], RequestConfig, StringSlice] + + IntFlag = cli.FlagBase[requestValue[int64], RequestConfig, requestValueCreator[int64]] + IntSlice = cli.SliceBase[requestValue[int64], RequestConfig, requestValueCreator[int64]] + IntSliceFlag = cli.FlagBase[[]requestValue[int64], RequestConfig, IntSlice] + + DateFlag = cli.FlagBase[requestValue[string], RequestConfig, dateCreator] + DateSlice = cli.SliceBase[requestValue[string], RequestConfig, dateCreator] + DateSliceFlag = cli.FlagBase[[]requestValue[string], RequestConfig, DateSlice] + + TimeFlag = cli.FlagBase[requestValue[string], RequestConfig, timeCreator] + TimeSlice = cli.SliceBase[requestValue[string], RequestConfig, timeCreator] + TimeSliceFlag = cli.FlagBase[[]requestValue[string], RequestConfig, TimeSlice] + + DateTimeFlag = cli.FlagBase[requestValue[string], RequestConfig, dateTimeCreator] + DateTimeSlice = cli.SliceBase[requestValue[string], RequestConfig, dateTimeCreator] + DateTimeSliceFlag = cli.FlagBase[[]requestValue[string], RequestConfig, DateTimeSlice] + + FloatFlag = cli.FlagBase[requestValue[float64], RequestConfig, requestValueCreator[float64]] + FloatSlice = cli.SliceBase[requestValue[float64], RequestConfig, requestValueCreator[float64]] + FloatSliceFlag = cli.FlagBase[[]requestValue[float64], RequestConfig, FloatSlice] + + BoolFlag = cli.FlagBase[requestValue[bool], RequestConfig, requestValueCreator[bool]] + BoolSlice = cli.SliceBase[requestValue[bool], RequestConfig, requestValueCreator[bool]] + BoolSliceFlag = cli.FlagBase[[]requestValue[bool], RequestConfig, BoolSlice] +) + +type RequestConfig struct { + BodyPath string + HeaderPath string + QueryPath string + CookiePath string +} + +type RequestValue interface { + RequestConfig() RequestConfig + RequestValue() any +} + +type requestValue[T any | string | int64 | float64 | bool] struct { + value T + config RequestConfig +} + +func Value[T any | string | int64 | float64 | bool](val T) requestValue[T] { + return requestValue[T]{val, RequestConfig{}} +} + +func (s requestValue[T]) RequestConfig() RequestConfig { + return s.config +} + +func (s requestValue[T]) RequestValue() any { + return s.value +} + +func CommandRequestValue[T any | string | int64 | float64 | bool](cmd *cli.Command, name string) T { + r := cmd.Value(name).(requestValue[T]) + return r.value +} + +func CommandRequestValues[T any | string | int64 | float64 | bool](cmd *cli.Command, name string) []T { + rs := cmd.Value(name).([]requestValue[T]) + values := make([]T, len(rs)) + for i, r := range rs { + values[i] = r.value + } + return values +} + +func CollectRequestValues(rs []RequestValue) []any { + values := make([]any, len(rs)) + for i, r := range rs { + values[i] = r.RequestValue() + } + return values +} + +type requestValueCreator[T any | string | int64 | float64 | bool] struct { + destination *requestValue[T] +} + +func (s requestValueCreator[T]) Create(defaultValue requestValue[T], p *requestValue[T], c RequestConfig) cli.Value { + p.value = defaultValue.value + p.config = c + return &requestValueCreator[T]{ + destination: p, + } +} + +func (s requestValueCreator[T]) ToString(val requestValue[T]) string { + data, err := yaml.Marshal(val) + if err != nil { + return "" + } + return string(data) +} + +func (s *requestValueCreator[T]) Set(str string) error { + var isStringType bool + var zeroVal T + _, isStringType = any(zeroVal).(string) + if isStringType { + s.destination.value = any(str).(T) + } else { + var val T + if err := yaml.Unmarshal([]byte(str), &val); err != nil { + return err + } + s.destination.value = val + } + return nil +} + +func (s *requestValueCreator[T]) Get() any { + return *s.destination +} + +func (s *requestValueCreator[T]) String() string { + if s.destination != nil { + return s.ToString(*s.destination) + } + return "" +} + +func (s requestValueCreator[T]) IsBoolFlag() bool { + var zero T + _, ok := any(zero).(bool) + return ok +} + +func parseTimeWithLayouts(str string, layouts []string) (time.Time, error) { + var t time.Time + var err error + for _, layout := range layouts { + t, err = time.Parse(layout, str) + if err == nil { + break + } + } + return t, err +} + +// Value creator for date, time, and datetime types +type timeFormatCreator struct { + requestValueCreator[string] + inputFormats []string + outputFormat string +} + +func (s timeFormatCreator) Create(defaultValue requestValue[string], p *requestValue[string], c RequestConfig) cli.Value { + *p = defaultValue + p.config = c + return &timeFormatCreator{ + requestValueCreator: requestValueCreator[string]{ + destination: p, + }, + inputFormats: s.inputFormats, + outputFormat: s.outputFormat, + } +} + +func (s *timeFormatCreator) Set(str string) error { + t, err := parseTimeWithLayouts(str, s.inputFormats) + s.destination.value = t.Format(s.outputFormat) + return err +} + +type dateCreator struct { + timeFormatCreator +} + +func (d dateCreator) Create(defaultValue requestValue[string], p *requestValue[string], c RequestConfig) cli.Value { + return timeFormatCreator{ + requestValueCreator: requestValueCreator[string]{ + destination: p, + }, + inputFormats: []string{ + "2006-01-02", + "01/02/2006", + "Jan 2, 2006", + "January 2, 2006", + "2-Jan-2006", + }, + outputFormat: "2006-01-02", + }.Create(defaultValue, p, c) +} + +type timeCreator struct { + timeFormatCreator +} + +func (t timeCreator) Create(defaultValue requestValue[string], p *requestValue[string], c RequestConfig) cli.Value { + return timeFormatCreator{ + requestValueCreator: requestValueCreator[string]{ + destination: p, + }, + inputFormats: []string{ + "15:04:05", + "3:04:05PM", + "3:04 PM", + "15:04", + time.Kitchen, + }, + outputFormat: "15:04:05", + }.Create(defaultValue, p, c) +} + +type dateTimeCreator struct { + timeFormatCreator +} + +func (dt dateTimeCreator) Create(defaultValue requestValue[string], p *requestValue[string], c RequestConfig) cli.Value { + return timeFormatCreator{ + requestValueCreator: requestValueCreator[string]{ + destination: p, + }, + inputFormats: []string{ + time.RFC3339, + "2006-01-02T15:04:05Z07:00", + "2006-01-02T15:04:05", + "2006-01-02 15:04:05", + time.RFC1123, + time.RFC822, + time.ANSIC, + }, + outputFormat: time.RFC3339, + }.Create(defaultValue, p, c) +} diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 7d68896..1934a88 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -86,8 +86,8 @@ func init() { Category: "API RESOURCE", Commands: []*cli.Command{ &imagesCreate, - &imagesRetrieve, &imagesList, + &imagesGet, }, }, { @@ -95,12 +95,12 @@ func init() { Category: "API RESOURCE", Commands: []*cli.Command{ &instancesCreate, - &instancesRetrieve, &instancesList, - &instancesPutInStandby, - &instancesRestoreFromStandby, + &instancesGet, + &instancesRestore, &instancesLogs, &instancesDelete, + &instancesStandby, }, }, { @@ -116,8 +116,17 @@ func init() { Category: "API RESOURCE", Commands: []*cli.Command{ &volumesCreate, - &volumesRetrieve, &volumesList, + &volumesGet, + }, + }, + { + Name: "ingresses", + Category: "API RESOURCE", + Commands: []*cli.Command{ + &ingressesCreate, + &ingressesList, + &ingressesGet, }, }, { diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go new file mode 100644 index 0000000..67de708 --- /dev/null +++ b/pkg/cmd/cmdutil.go @@ -0,0 +1,126 @@ +package cmd + +import ( + "fmt" + "io" + "log" + "net/http" + "net/http/httputil" + "os" + "strings" + + "github.com/onkernel/hypeman-cli/pkg/jsonview" + "github.com/onkernel/hypeman-go/option" + + "github.com/itchyny/json2yaml" + "github.com/tidwall/gjson" + "github.com/tidwall/pretty" + "github.com/urfave/cli/v3" + "golang.org/x/term" +) + +func getDefaultRequestOptions(cmd *cli.Command) []option.RequestOption { + opts := []option.RequestOption{ + option.WithHeader("User-Agent", fmt.Sprintf("Hypeman/CLI %s", Version)), + option.WithHeader("X-Stainless-Lang", "cli"), + option.WithHeader("X-Stainless-Package-Version", Version), + option.WithHeader("X-Stainless-Runtime", "cli"), + option.WithHeader("X-Stainless-CLI-Command", cmd.FullName()), + } + + // Override base URL if the --base-url flag is provided + if baseURL := cmd.String("base-url"); baseURL != "" { + opts = append(opts, option.WithBaseURL(baseURL)) + } + + return opts +} + +var debugMiddlewareOption = option.WithMiddleware( + func(r *http.Request, mn option.MiddlewareNext) (*http.Response, error) { + logger := log.Default() + + if reqBytes, err := httputil.DumpRequest(r, true); err == nil { + logger.Printf("Request Content:\n%s\n", reqBytes) + } + + resp, err := mn(r) + if err != nil { + return resp, err + } + + if respBytes, err := httputil.DumpResponse(resp, true); err == nil { + logger.Printf("Response Content:\n%s\n", respBytes) + } + + return resp, err + }, +) + +func isInputPiped() bool { + stat, _ := os.Stdin.Stat() + return (stat.Mode() & os.ModeCharDevice) == 0 +} + +func isTerminal(w io.Writer) bool { + switch v := w.(type) { + case *os.File: + return term.IsTerminal(int(v.Fd())) + default: + return false + } +} + +func shouldUseColors(w io.Writer) bool { + force, ok := os.LookupEnv("FORCE_COLOR") + + if ok { + if force == "1" { + return true + } + if force == "0" { + return false + } + } + + return isTerminal(w) +} + +func ShowJSON(title string, res gjson.Result, format string, transform string) error { + if format != "raw" && transform != "" { + transformed := res.Get(transform) + if transformed.Exists() { + res = transformed + } + } + switch strings.ToLower(format) { + case "auto": + return ShowJSON(title, res, "json", "") + case "explore": + return jsonview.ExploreJSON(title, res) + case "pretty": + jsonview.DisplayJSON(title, res) + return nil + case "json": + prettyJSON := pretty.Pretty([]byte(res.Raw)) + if shouldUseColors(os.Stdout) { + fmt.Print(string(pretty.Color(prettyJSON, pretty.TerminalStyle))) + } else { + fmt.Print(string(prettyJSON)) + } + return nil + case "raw": + fmt.Println(res.Raw) + return nil + case "yaml": + input := strings.NewReader(res.Raw) + var yaml strings.Builder + if err := json2yaml.Convert(&yaml, input); err != nil { + return err + } + fmt.Print(yaml.String()) + return nil + default: + return fmt.Errorf("Invalid format: %s, valid formats are: %s", format, strings.Join(OutputFormats, ", ")) + } +} diff --git a/pkg/cmd/flagoptions.go b/pkg/cmd/flagoptions.go new file mode 100644 index 0000000..981b8e8 --- /dev/null +++ b/pkg/cmd/flagoptions.go @@ -0,0 +1,134 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "io" + "mime/multipart" + "os" + + "github.com/onkernel/hypeman-cli/internal/apiform" + "github.com/onkernel/hypeman-cli/internal/apiquery" + "github.com/onkernel/hypeman-cli/internal/requestflag" + "github.com/onkernel/hypeman-go/option" + + "github.com/urfave/cli/v3" +) + +type BodyContentType int + +const ( + MultipartFormEncoded BodyContentType = iota + ApplicationJSON +) + +func flagOptions( + cmd *cli.Command, + nestedFormat apiquery.NestedQueryFormat, + arrayFormat apiquery.ArrayQueryFormat, + bodyType BodyContentType, +) ([]option.RequestOption, error) { + var options []option.RequestOption + if cmd.Bool("debug") { + options = append(options, debugMiddlewareOption) + } + + queries := make(map[string]any) + headers := make(map[string]any) + body := make(map[string]any) + if isInputPiped() { + data, err := io.ReadAll(os.Stdin) + if err != nil { + return nil, err + } + if err := json.Unmarshal(data, &body); err != nil { + return nil, err + } + } + + for _, flag := range cmd.Flags { + if !flag.IsSet() { + continue + } + value := flag.Get() + if toSend, ok := value.(requestflag.RequestValue); ok { + config := toSend.RequestConfig() + if config.BodyPath != "" { + body[config.BodyPath] = toSend.RequestValue() + } else if config.QueryPath != "" { + queries[config.QueryPath] = toSend.RequestValue() + } else if config.HeaderPath != "" { + headers[config.HeaderPath] = toSend.RequestValue() + } + } else if toSend, ok := value.([]requestflag.RequestValue); ok { + config := toSend[0].RequestConfig() + if config.BodyPath != "" { + body[config.BodyPath] = requestflag.CollectRequestValues(toSend) + } else if config.QueryPath != "" { + queries[config.QueryPath] = requestflag.CollectRequestValues(toSend) + } else if config.HeaderPath != "" { + headers[config.HeaderPath] = requestflag.CollectRequestValues(toSend) + } + } + } + + querySettings := apiquery.QuerySettings{ + NestedFormat: nestedFormat, + ArrayFormat: arrayFormat, + } + + // Add query parameters: + if values, err := apiquery.MarshalWithSettings(queries, querySettings); err != nil { + return nil, err + } else { + for k, vs := range values { + if len(vs) == 0 { + options = append(options, option.WithQueryDel(k)) + } else { + options = append(options, option.WithQuery(k, vs[0])) + for _, v := range vs[1:] { + options = append(options, option.WithQueryAdd(k, v)) + } + } + } + } + + // Add header parameters + if values, err := apiquery.MarshalWithSettings(headers, querySettings); err != nil { + return nil, err + } else { + for k, vs := range values { + if len(vs) == 0 { + options = append(options, option.WithHeaderDel(k)) + } else { + options = append(options, option.WithHeader(k, vs[0])) + for _, v := range vs[1:] { + options = append(options, option.WithHeaderAdd(k, v)) + } + } + } + } + + switch bodyType { + case MultipartFormEncoded: + buf := new(bytes.Buffer) + writer := multipart.NewWriter(buf) + if err := apiform.MarshalWithSettings(body, writer, apiform.FormatComma); err != nil { + return nil, err + } + if err := writer.Close(); err != nil { + return nil, err + } + options = append(options, option.WithRequestBody(writer.FormDataContentType(), buf)) + case ApplicationJSON: + bodyBytes, err := json.Marshal(body) + if err != nil { + return nil, err + } + options = append(options, option.WithRequestBody("application/json", bodyBytes)) + default: + panic("Invalid body content type!") + } + + return options, nil +} diff --git a/pkg/cmd/health.go b/pkg/cmd/health.go index 9a0d1d8..4d6b861 100644 --- a/pkg/cmd/health.go +++ b/pkg/cmd/health.go @@ -6,6 +6,7 @@ import ( "context" "fmt" + "github.com/onkernel/hypeman-cli/internal/apiquery" "github.com/onkernel/hypeman-go" "github.com/onkernel/hypeman-go/option" "github.com/tidwall/gjson" @@ -26,15 +27,21 @@ func handleHealthCheck(ctx context.Context, cmd *cli.Command) error { if len(unusedArgs) > 0 { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - var res []byte - _, err := client.Health.Check( - ctx, - option.WithMiddleware(debugMiddleware(cmd.Bool("debug"))), - option.WithResponseBodyInto(&res), + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, ) if err != nil { return err } + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Health.Check(ctx, options...) + if err != nil { + return err + } json := gjson.Parse(string(res)) format := cmd.Root().String("format") diff --git a/pkg/cmd/image.go b/pkg/cmd/image.go index d6cb361..0dab5ef 100644 --- a/pkg/cmd/image.go +++ b/pkg/cmd/image.go @@ -6,6 +6,8 @@ import ( "context" "fmt" + "github.com/onkernel/hypeman-cli/internal/apiquery" + "github.com/onkernel/hypeman-cli/internal/requestflag" "github.com/onkernel/hypeman-go" "github.com/onkernel/hypeman-go/option" "github.com/tidwall/gjson" @@ -16,27 +18,18 @@ var imagesCreate = cli.Command{ Name: "create", Usage: "Pull and convert OCI image", Flags: []cli.Flag{ - &cli.StringFlag{ + &requestflag.StringFlag{ Name: "name", Usage: "OCI image reference (e.g., docker.io/library/nginx:latest)", + Config: requestflag.RequestConfig{ + BodyPath: "name", + }, }, }, Action: handleImagesCreate, HideHelpCommand: true, } -var imagesRetrieve = cli.Command{ - Name: "retrieve", - Usage: "Get image details", - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "name", - }, - }, - Action: handleImagesRetrieve, - HideHelpCommand: true, -} - var imagesList = cli.Command{ Name: "list", Usage: "List images", @@ -49,7 +42,7 @@ var imagesDelete = cli.Command{ Name: "delete", Usage: "Delete image", Flags: []cli.Flag{ - &cli.StringFlag{ + &requestflag.StringFlag{ Name: "name", }, }, @@ -57,6 +50,18 @@ var imagesDelete = cli.Command{ HideHelpCommand: true, } +var imagesGet = cli.Command{ + Name: "get", + Usage: "Get image details", + Flags: []cli.Flag{ + &requestflag.StringFlag{ + Name: "name", + }, + }, + Action: handleImagesGet, + HideHelpCommand: true, +} + func handleImagesCreate(ctx context.Context, cmd *cli.Command) error { client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() @@ -64,17 +69,22 @@ func handleImagesCreate(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } params := hypeman.ImageNewParams{} - if err := unmarshalStdinWithFlags(cmd, map[string]string{ - "name": "name", - }, ¶ms); err != nil { + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, + ) + if err != nil { return err } var res []byte - _, err := client.Images.New( + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Images.New( ctx, params, - option.WithMiddleware(debugMiddleware(cmd.Bool("debug"))), - option.WithResponseBodyInto(&res), + options..., ) if err != nil { return err @@ -86,56 +96,61 @@ func handleImagesCreate(ctx context.Context, cmd *cli.Command) error { return ShowJSON("images create", json, format, transform) } -func handleImagesRetrieve(ctx context.Context, cmd *cli.Command) error { +func handleImagesList(ctx context.Context, cmd *cli.Command) error { client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("name") && len(unusedArgs) > 0 { - cmd.Set("name", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } if len(unusedArgs) > 0 { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - var res []byte - _, err := client.Images.Get( - ctx, - cmd.Value("name").(string), - option.WithMiddleware(debugMiddleware(cmd.Bool("debug"))), - option.WithResponseBodyInto(&res), + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, ) if err != nil { return err } + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Images.List(ctx, options...) + if err != nil { + return err + } json := gjson.Parse(string(res)) format := cmd.Root().String("format") transform := cmd.Root().String("transform") - return ShowJSON("images retrieve", json, format, transform) + return ShowJSON("images list", json, format, transform) } -func handleImagesList(ctx context.Context, cmd *cli.Command) error { +func handleImagesDelete(ctx context.Context, cmd *cli.Command) error { client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("name") && len(unusedArgs) > 0 { + cmd.Set("name", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } if len(unusedArgs) > 0 { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - var res []byte - _, err := client.Images.List( - ctx, - option.WithMiddleware(debugMiddleware(cmd.Bool("debug"))), - option.WithResponseBodyInto(&res), + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, ) if err != nil { return err } - - json := gjson.Parse(string(res)) - format := cmd.Root().String("format") - transform := cmd.Root().String("transform") - return ShowJSON("images list", json, format, transform) + return client.Images.Delete( + ctx, + requestflag.CommandRequestValue[string](cmd, "name"), + options..., + ) } -func handleImagesDelete(ctx context.Context, cmd *cli.Command) error { +func handleImagesGet(ctx context.Context, cmd *cli.Command) error { client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() if !cmd.IsSet("name") && len(unusedArgs) > 0 { @@ -145,9 +160,28 @@ func handleImagesDelete(ctx context.Context, cmd *cli.Command) error { if len(unusedArgs) > 0 { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - return client.Images.Delete( + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, + ) + if err != nil { + return err + } + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Images.Get( ctx, - cmd.Value("name").(string), - option.WithMiddleware(debugMiddleware(cmd.Bool("debug"))), + requestflag.CommandRequestValue[string](cmd, "name"), + options..., ) + if err != nil { + return err + } + + json := gjson.Parse(string(res)) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON("images get", json, format, transform) } diff --git a/pkg/cmd/ingress.go b/pkg/cmd/ingress.go new file mode 100644 index 0000000..3a646f9 --- /dev/null +++ b/pkg/cmd/ingress.go @@ -0,0 +1,194 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "context" + "fmt" + + "github.com/onkernel/hypeman-cli/internal/apiquery" + "github.com/onkernel/hypeman-cli/internal/requestflag" + "github.com/onkernel/hypeman-go" + "github.com/onkernel/hypeman-go/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var ingressesCreate = cli.Command{ + Name: "create", + Usage: "Create ingress", + Flags: []cli.Flag{ + &requestflag.StringFlag{ + Name: "name", + Usage: "Human-readable name (lowercase letters, digits, and dashes only; cannot start or end with a dash)", + Config: requestflag.RequestConfig{ + BodyPath: "name", + }, + }, + &requestflag.YAMLSliceFlag{ + Name: "rule", + Usage: "Routing rules for this ingress", + Config: requestflag.RequestConfig{ + BodyPath: "rules", + }, + }, + }, + Action: handleIngressesCreate, + HideHelpCommand: true, +} + +var ingressesList = cli.Command{ + Name: "list", + Usage: "List ingresses", + Flags: []cli.Flag{}, + Action: handleIngressesList, + HideHelpCommand: true, +} + +var ingressesDelete = cli.Command{ + Name: "delete", + Usage: "Delete ingress", + Flags: []cli.Flag{ + &requestflag.StringFlag{ + Name: "id", + }, + }, + Action: handleIngressesDelete, + HideHelpCommand: true, +} + +var ingressesGet = cli.Command{ + Name: "get", + Usage: "Get ingress details", + Flags: []cli.Flag{ + &requestflag.StringFlag{ + Name: "id", + }, + }, + Action: handleIngressesGet, + HideHelpCommand: true, +} + +func handleIngressesCreate(ctx context.Context, cmd *cli.Command) error { + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + params := hypeman.IngressNewParams{} + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, + ) + if err != nil { + return err + } + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Ingresses.New( + ctx, + params, + options..., + ) + if err != nil { + return err + } + + json := gjson.Parse(string(res)) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON("ingresses create", json, format, transform) +} + +func handleIngressesList(ctx context.Context, cmd *cli.Command) error { + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, + ) + if err != nil { + return err + } + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Ingresses.List(ctx, options...) + if err != nil { + return err + } + + json := gjson.Parse(string(res)) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON("ingresses list", json, format, transform) +} + +func handleIngressesDelete(ctx context.Context, cmd *cli.Command) error { + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("id") && len(unusedArgs) > 0 { + cmd.Set("id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, + ) + if err != nil { + return err + } + return client.Ingresses.Delete( + ctx, + requestflag.CommandRequestValue[string](cmd, "id"), + options..., + ) +} + +func handleIngressesGet(ctx context.Context, cmd *cli.Command) error { + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("id") && len(unusedArgs) > 0 { + cmd.Set("id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, + ) + if err != nil { + return err + } + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Ingresses.Get( + ctx, + requestflag.CommandRequestValue[string](cmd, "id"), + options..., + ) + if err != nil { + return err + } + + json := gjson.Parse(string(res)) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON("ingresses get", json, format, transform) +} diff --git a/pkg/cmd/instance.go b/pkg/cmd/instance.go index 54294af..5cc41b5 100644 --- a/pkg/cmd/instance.go +++ b/pkg/cmd/instance.go @@ -6,6 +6,8 @@ import ( "context" "fmt" + "github.com/onkernel/hypeman-cli/internal/apiquery" + "github.com/onkernel/hypeman-cli/internal/requestflag" "github.com/onkernel/hypeman-go" "github.com/onkernel/hypeman-go/option" "github.com/tidwall/gjson" @@ -16,48 +18,75 @@ var instancesCreate = cli.Command{ Name: "create", Usage: "Create and start instance", Flags: []cli.Flag{ - &cli.StringFlag{ + &requestflag.StringFlag{ Name: "image", Usage: "OCI image reference", + Config: requestflag.RequestConfig{ + BodyPath: "image", + }, }, - &cli.StringFlag{ + &requestflag.StringFlag{ Name: "name", Usage: "Human-readable name (lowercase letters, digits, and dashes only; cannot start or end with a dash)", + Config: requestflag.RequestConfig{ + BodyPath: "name", + }, }, - &cli.StringFlag{ + &requestflag.YAMLFlag{ + Name: "env", + Usage: "Environment variables", + Config: requestflag.RequestConfig{ + BodyPath: "env", + }, + }, + &requestflag.StringFlag{ Name: "hotplug-size", Usage: `Additional memory for hotplug (human-readable format like "3GB", "1G")`, - Value: "3GB", + Value: requestflag.Value[string]("3GB"), + Config: requestflag.RequestConfig{ + BodyPath: "hotplug_size", + }, + }, + &requestflag.YAMLFlag{ + Name: "network", + Usage: "Network configuration for the instance", + Config: requestflag.RequestConfig{ + BodyPath: "network", + }, }, - &cli.StringFlag{ + &requestflag.StringFlag{ Name: "overlay-size", Usage: `Writable overlay disk size (human-readable format like "10GB", "50G")`, - Value: "10GB", + Value: requestflag.Value[string]("10GB"), + Config: requestflag.RequestConfig{ + BodyPath: "overlay_size", + }, }, - &cli.StringFlag{ + &requestflag.StringFlag{ Name: "size", Usage: `Base memory size (human-readable format like "1GB", "512MB", "2G")`, - Value: "1GB", + Value: requestflag.Value[string]("1GB"), + Config: requestflag.RequestConfig{ + BodyPath: "size", + }, }, - &cli.Int64Flag{ + &requestflag.IntFlag{ Name: "vcpus", Usage: "Number of virtual CPUs", - Value: 2, + Value: requestflag.Value[int64](2), + Config: requestflag.RequestConfig{ + BodyPath: "vcpus", + }, }, - }, - Action: handleInstancesCreate, - HideHelpCommand: true, -} - -var instancesRetrieve = cli.Command{ - Name: "retrieve", - Usage: "Get instance details", - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "id", + &requestflag.YAMLSliceFlag{ + Name: "volume", + Usage: "Volumes to attach to the instance at creation time", + Config: requestflag.RequestConfig{ + BodyPath: "volumes", + }, }, }, - Action: handleInstancesRetrieve, + Action: handleInstancesCreate, HideHelpCommand: true, } @@ -73,7 +102,7 @@ var instancesDelete = cli.Command{ Name: "delete", Usage: "Stop and delete instance", Flags: []cli.Flag{ - &cli.StringFlag{ + &requestflag.StringFlag{ Name: "id", }, }, @@ -81,48 +110,66 @@ var instancesDelete = cli.Command{ HideHelpCommand: true, } +var instancesGet = cli.Command{ + Name: "get", + Usage: "Get instance details", + Flags: []cli.Flag{ + &requestflag.StringFlag{ + Name: "id", + }, + }, + Action: handleInstancesGet, + HideHelpCommand: true, +} + var instancesLogs = cli.Command{ Name: "logs", Usage: "Streams instance console logs as Server-Sent Events. Returns the last N lines\n(controlled by `tail` parameter), then optionally continues streaming new lines\nif `follow=true`.", Flags: []cli.Flag{ - &cli.StringFlag{ + &requestflag.StringFlag{ Name: "id", }, - &cli.BoolFlag{ + &requestflag.BoolFlag{ Name: "follow", Usage: "Continue streaming new lines after initial output", + Config: requestflag.RequestConfig{ + QueryPath: "follow", + }, }, - &cli.Int64Flag{ + &requestflag.IntFlag{ Name: "tail", Usage: "Number of lines to return from end", - Value: 100, + Value: requestflag.Value[int64](100), + Config: requestflag.RequestConfig{ + QueryPath: "tail", + }, }, }, Action: handleInstancesLogs, HideHelpCommand: true, } -var instancesPutInStandby = cli.Command{ - Name: "put-in-standby", - Usage: "Put instance in standby (pause, snapshot, delete VMM)", +var instancesRestore = cli.Command{ + Name: "restore", + Usage: "Restore instance from standby", Flags: []cli.Flag{ - &cli.StringFlag{ + &requestflag.StringFlag{ Name: "id", }, }, - Action: handleInstancesPutInStandby, + Action: handleInstancesRestore, HideHelpCommand: true, } -var instancesRestoreFromStandby = cli.Command{ - Name: "restore-from-standby", - Usage: "Restore instance from standby", +var instancesStandby = cli.Command{ + Name: "standby", + Usage: "Put instance in standby (pause, snapshot, delete VMM)", Flags: []cli.Flag{ - &cli.StringFlag{ + &requestflag.StringFlag{ Name: "id", }, }, - Action: handleInstancesRestoreFromStandby, + Action: handleInstancesStandby, HideHelpCommand: true, } @@ -133,22 +180,22 @@ func handleInstancesCreate(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } params := hypeman.InstanceNewParams{} - if err := unmarshalStdinWithFlags(cmd, map[string]string{ - "image": "image", - "name": "name", - "hotplug-size": "hotplug_size", - "overlay-size": "overlay_size", - "size": "size", - "vcpus": "vcpus", - }, ¶ms); err != nil { + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, + ) + if err != nil { return err } var res []byte - _, err := client.Instances.New( + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Instances.New( ctx, params, - option.WithMiddleware(debugMiddleware(cmd.Bool("debug"))), - option.WithResponseBodyInto(&res), + options..., ) if err != nil { return err @@ -160,56 +207,61 @@ func handleInstancesCreate(ctx context.Context, cmd *cli.Command) error { return ShowJSON("instances create", json, format, transform) } -func handleInstancesRetrieve(ctx context.Context, cmd *cli.Command) error { +func handleInstancesList(ctx context.Context, cmd *cli.Command) error { client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("id") && len(unusedArgs) > 0 { - cmd.Set("id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } if len(unusedArgs) > 0 { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - var res []byte - _, err := client.Instances.Get( - ctx, - cmd.Value("id").(string), - option.WithMiddleware(debugMiddleware(cmd.Bool("debug"))), - option.WithResponseBodyInto(&res), + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, ) if err != nil { return err } + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Instances.List(ctx, options...) + if err != nil { + return err + } json := gjson.Parse(string(res)) format := cmd.Root().String("format") transform := cmd.Root().String("transform") - return ShowJSON("instances retrieve", json, format, transform) + return ShowJSON("instances list", json, format, transform) } -func handleInstancesList(ctx context.Context, cmd *cli.Command) error { +func handleInstancesDelete(ctx context.Context, cmd *cli.Command) error { client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("id") && len(unusedArgs) > 0 { + cmd.Set("id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } if len(unusedArgs) > 0 { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - var res []byte - _, err := client.Instances.List( - ctx, - option.WithMiddleware(debugMiddleware(cmd.Bool("debug"))), - option.WithResponseBodyInto(&res), + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, ) if err != nil { return err } - - json := gjson.Parse(string(res)) - format := cmd.Root().String("format") - transform := cmd.Root().String("transform") - return ShowJSON("instances list", json, format, transform) + return client.Instances.Delete( + ctx, + requestflag.CommandRequestValue[string](cmd, "id"), + options..., + ) } -func handleInstancesDelete(ctx context.Context, cmd *cli.Command) error { +func handleInstancesGet(ctx context.Context, cmd *cli.Command) error { client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() if !cmd.IsSet("id") && len(unusedArgs) > 0 { @@ -219,11 +271,30 @@ func handleInstancesDelete(ctx context.Context, cmd *cli.Command) error { if len(unusedArgs) > 0 { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - return client.Instances.Delete( + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, + ) + if err != nil { + return err + } + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Instances.Get( ctx, - cmd.Value("id").(string), - option.WithMiddleware(debugMiddleware(cmd.Bool("debug"))), + requestflag.CommandRequestValue[string](cmd, "id"), + options..., ) + if err != nil { + return err + } + + json := gjson.Parse(string(res)) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON("instances get", json, format, transform) } func handleInstancesLogs(ctx context.Context, cmd *cli.Command) error { @@ -237,17 +308,21 @@ func handleInstancesLogs(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } params := hypeman.InstanceLogsParams{} - if cmd.IsSet("follow") { - params.Follow = hypeman.Opt(cmd.Value("follow").(bool)) - } - if cmd.IsSet("tail") { - params.Tail = hypeman.Opt(cmd.Value("tail").(int64)) + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, + ) + if err != nil { + return err } stream := client.Instances.LogsStreaming( ctx, - cmd.Value("id").(string), + requestflag.CommandRequestValue[string](cmd, "id"), params, - option.WithMiddleware(debugMiddleware(cmd.Bool("debug"))), + options..., ) defer stream.Close() for stream.Next() { @@ -256,7 +331,7 @@ func handleInstancesLogs(ctx context.Context, cmd *cli.Command) error { return stream.Err() } -func handleInstancesPutInStandby(ctx context.Context, cmd *cli.Command) error { +func handleInstancesRestore(ctx context.Context, cmd *cli.Command) error { client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() if !cmd.IsSet("id") && len(unusedArgs) > 0 { @@ -266,12 +341,21 @@ func handleInstancesPutInStandby(ctx context.Context, cmd *cli.Command) error { if len(unusedArgs) > 0 { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, + ) + if err != nil { + return err + } var res []byte - _, err := client.Instances.PutInStandby( + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Instances.Restore( ctx, - cmd.Value("id").(string), - option.WithMiddleware(debugMiddleware(cmd.Bool("debug"))), - option.WithResponseBodyInto(&res), + requestflag.CommandRequestValue[string](cmd, "id"), + options..., ) if err != nil { return err @@ -280,10 +364,10 @@ func handleInstancesPutInStandby(ctx context.Context, cmd *cli.Command) error { json := gjson.Parse(string(res)) format := cmd.Root().String("format") transform := cmd.Root().String("transform") - return ShowJSON("instances put-in-standby", json, format, transform) + return ShowJSON("instances restore", json, format, transform) } -func handleInstancesRestoreFromStandby(ctx context.Context, cmd *cli.Command) error { +func handleInstancesStandby(ctx context.Context, cmd *cli.Command) error { client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() if !cmd.IsSet("id") && len(unusedArgs) > 0 { @@ -293,12 +377,21 @@ func handleInstancesRestoreFromStandby(ctx context.Context, cmd *cli.Command) er if len(unusedArgs) > 0 { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, + ) + if err != nil { + return err + } var res []byte - _, err := client.Instances.RestoreFromStandby( + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Instances.Standby( ctx, - cmd.Value("id").(string), - option.WithMiddleware(debugMiddleware(cmd.Bool("debug"))), - option.WithResponseBodyInto(&res), + requestflag.CommandRequestValue[string](cmd, "id"), + options..., ) if err != nil { return err @@ -307,5 +400,5 @@ func handleInstancesRestoreFromStandby(ctx context.Context, cmd *cli.Command) er json := gjson.Parse(string(res)) format := cmd.Root().String("format") transform := cmd.Root().String("transform") - return ShowJSON("instances restore-from-standby", json, format, transform) + return ShowJSON("instances standby", json, format, transform) } diff --git a/pkg/cmd/instancevolume.go b/pkg/cmd/instancevolume.go index a41a9c4..1c3e079 100644 --- a/pkg/cmd/instancevolume.go +++ b/pkg/cmd/instancevolume.go @@ -6,6 +6,8 @@ import ( "context" "fmt" + "github.com/onkernel/hypeman-cli/internal/apiquery" + "github.com/onkernel/hypeman-cli/internal/requestflag" "github.com/onkernel/hypeman-go" "github.com/onkernel/hypeman-go/option" "github.com/tidwall/gjson" @@ -16,19 +18,25 @@ var instancesVolumesAttach = cli.Command{ Name: "attach", Usage: "Attach volume to instance", Flags: []cli.Flag{ - &cli.StringFlag{ + &requestflag.StringFlag{ Name: "id", }, - &cli.StringFlag{ + &requestflag.StringFlag{ Name: "volume-id", }, - &cli.StringFlag{ + &requestflag.StringFlag{ Name: "mount-path", Usage: "Path where volume should be mounted", + Config: requestflag.RequestConfig{ + BodyPath: "mount_path", + }, }, - &cli.BoolFlag{ + &requestflag.BoolFlag{ Name: "readonly", Usage: "Mount as read-only", + Config: requestflag.RequestConfig{ + BodyPath: "readonly", + }, }, }, Action: handleInstancesVolumesAttach, @@ -39,10 +47,10 @@ var instancesVolumesDetach = cli.Command{ Name: "detach", Usage: "Detach volume from instance", Flags: []cli.Flag{ - &cli.StringFlag{ + &requestflag.StringFlag{ Name: "id", }, - &cli.StringFlag{ + &requestflag.StringFlag{ Name: "volume-id", }, }, @@ -61,24 +69,25 @@ func handleInstancesVolumesAttach(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } params := hypeman.InstanceVolumeAttachParams{ - ID: cmd.Value("id").(string), + ID: requestflag.CommandRequestValue[string](cmd, "id"), } - if err := unmarshalStdinWithFlags(cmd, map[string]string{ - "mount-path": "mount_path", - "readonly": "readonly", - }, ¶ms); err != nil { + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, + ) + if err != nil { return err } - if cmd.IsSet("id") { - params.ID = cmd.Value("id").(string) - } var res []byte - _, err := client.Instances.Volumes.Attach( + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Instances.Volumes.Attach( ctx, - cmd.Value("volume-id").(string), + requestflag.CommandRequestValue[string](cmd, "volume-id"), params, - option.WithMiddleware(debugMiddleware(cmd.Bool("debug"))), - option.WithResponseBodyInto(&res), + options..., ) if err != nil { return err @@ -101,18 +110,25 @@ func handleInstancesVolumesDetach(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } params := hypeman.InstanceVolumeDetachParams{ - ID: cmd.Value("id").(string), + ID: requestflag.CommandRequestValue[string](cmd, "id"), } - if cmd.IsSet("id") { - params.ID = cmd.Value("id").(string) + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, + ) + if err != nil { + return err } var res []byte - _, err := client.Instances.Volumes.Detach( + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Instances.Volumes.Detach( ctx, - cmd.Value("volume-id").(string), + requestflag.CommandRequestValue[string](cmd, "volume-id"), params, - option.WithMiddleware(debugMiddleware(cmd.Bool("debug"))), - option.WithResponseBodyInto(&res), + options..., ) if err != nil { return err diff --git a/pkg/cmd/logs.go b/pkg/cmd/logs.go index 1713913..9d4d200 100644 --- a/pkg/cmd/logs.go +++ b/pkg/cmd/logs.go @@ -51,11 +51,16 @@ func handleLogs(ctx context.Context, cmd *cli.Command) error { params.Tail = hypeman.Opt(int64(cmd.Int("tail"))) } + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + stream := client.Instances.LogsStreaming( ctx, instanceID, params, - option.WithMiddleware(debugMiddleware(cmd.Root().Bool("debug"))), + opts..., ) defer stream.Close() diff --git a/pkg/cmd/ps.go b/pkg/cmd/ps.go index ed3596c..71fabff 100644 --- a/pkg/cmd/ps.go +++ b/pkg/cmd/ps.go @@ -32,9 +32,14 @@ var psCmd = cli.Command{ func handlePs(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) + } + instances, err := client.Instances.List( ctx, - option.WithMiddleware(debugMiddleware(cmd.Root().Bool("debug"))), + opts..., ) if err != nil { return err diff --git a/pkg/cmd/pull.go b/pkg/cmd/pull.go index 2c0d0c9..9993e4e 100644 --- a/pkg/cmd/pull.go +++ b/pkg/cmd/pull.go @@ -34,10 +34,15 @@ func handlePull(ctx context.Context, cmd *cli.Command) error { Name: image, } + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + result, err := client.Images.New( ctx, params, - option.WithMiddleware(debugMiddleware(cmd.Root().Bool("debug"))), + opts..., ) if err != nil { return err diff --git a/pkg/cmd/rm.go b/pkg/cmd/rm.go index 2d2139a..a0b079b 100644 --- a/pkg/cmd/rm.go +++ b/pkg/cmd/rm.go @@ -73,12 +73,18 @@ func handleRm(ctx context.Context, cmd *cli.Command) error { } } + // Build debug options once + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + // Check instance state if not forcing if !force { inst, err := client.Instances.Get( ctx, instanceID, - option.WithMiddleware(debugMiddleware(cmd.Root().Bool("debug"))), + opts..., ) if err != nil { fmt.Printf("Error: failed to get instance %s: %v\n", instanceID, err) @@ -101,7 +107,7 @@ func handleRm(ctx context.Context, cmd *cli.Command) error { err = client.Instances.Delete( ctx, instanceID, - option.WithMiddleware(debugMiddleware(cmd.Root().Bool("debug"))), + opts..., ) if err != nil { fmt.Printf("Error: failed to remove instance %s: %v\n", instanceID, err) diff --git a/pkg/cmd/run.go b/pkg/cmd/run.go index 03c6723..de75eea 100644 --- a/pkg/cmd/run.go +++ b/pkg/cmd/run.go @@ -120,10 +120,15 @@ func handleRun(ctx context.Context, cmd *cli.Command) error { fmt.Fprintf(os.Stderr, "Creating instance %s...\n", name) + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + result, err := client.Instances.New( ctx, params, - option.WithMiddleware(debugMiddleware(cmd.Root().Bool("debug"))), + opts..., ) if err != nil { return err diff --git a/pkg/cmd/util.go b/pkg/cmd/util.go deleted file mode 100644 index 9e4701d..0000000 --- a/pkg/cmd/util.go +++ /dev/null @@ -1,243 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "bytes" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "net/http/httputil" - "os" - "reflect" - "strings" - - "golang.org/x/term" - - "github.com/onkernel/hypeman-cli/pkg/jsonview" - "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("BruceTestAPI/CLI %s", Version)), - option.WithHeader("X-Stainless-Lang", "cli"), - option.WithHeader("X-Stainless-Package-Version", Version), - option.WithHeader("X-Stainless-Runtime", "cli"), - option.WithHeader("X-Stainless-CLI-Command", cmd.FullName()), - } - - baseURL := cmd.String("base-url") - if baseURL == "" { - baseURL = os.Getenv("HYPEMAN_BASE_URL") - } - if baseURL == "" { - baseURL = "http://localhost:8080" - } - opts = append(opts, option.WithBaseURL(baseURL)) - - return opts -} - -type fileReader struct { - Value io.Reader - Base64Encoded bool -} - -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) - } - f.Value = reader - return nil -} - -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() -} - -func (f *fileReader) Get() any { - return f.String() -} - -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 - } - } - - // 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 data != nil { - if err := unmarshalWithReaders(data, target); err != nil { - return fmt.Errorf("failed to unmarshal JSON: %w", err) - } - } - - return nil -} - -func debugMiddleware(debug bool) option.Middleware { - return func(r *http.Request, mn option.MiddlewareNext) (*http.Response, error) { - if debug { - logger := log.Default() - - if reqBytes, err := httputil.DumpRequest(r, true); err == nil { - logger.Printf("Request Content:\n%s\n", reqBytes) - } - - resp, err := mn(r) - if err != nil { - return resp, err - } - - if respBytes, err := httputil.DumpResponse(resp, true); err == nil { - logger.Printf("Response Content:\n%s\n", respBytes) - } - - return resp, err - } - - return mn(r) - } -} - -func isInputPiped() bool { - stat, _ := os.Stdin.Stat() - return (stat.Mode() & os.ModeCharDevice) == 0 -} - -func isTerminal(w io.Writer) bool { - switch v := w.(type) { - case *os.File: - return term.IsTerminal(int(v.Fd())) - default: - return false - } -} - -func shouldUseColors(w io.Writer) bool { - force, ok := os.LookupEnv("FORCE_COLOR") - - if ok { - if force == "1" { - return true - } - if force == "0" { - return false - } - } - - return isTerminal(w) -} - -func ShowJSON(title string, res gjson.Result, format string, transform string) error { - if format != "raw" && transform != "" { - transformed := res.Get(transform) - if transformed.Exists() { - res = transformed - } - } - switch strings.ToLower(format) { - case "auto": - return ShowJSON(title, res, "json", "") - case "explore": - return jsonview.ExploreJSON(title, res) - case "pretty": - jsonview.DisplayJSON(title, res) - return nil - case "json": - prettyJSON := pretty.Pretty([]byte(res.Raw)) - if shouldUseColors(os.Stdout) { - fmt.Print(string(pretty.Color(prettyJSON, pretty.TerminalStyle))) - } else { - fmt.Print(string(prettyJSON)) - } - return nil - case "raw": - fmt.Println(res.Raw) - return nil - case "yaml": - input := strings.NewReader(res.Raw) - var yaml strings.Builder - if err := json2yaml.Convert(&yaml, input); err != nil { - return err - } - fmt.Print(yaml.String()) - return nil - default: - return fmt.Errorf("Invalid format: %s, valid formats are: %s", format, strings.Join(OutputFormats, ", ")) - } -} diff --git a/pkg/cmd/version.go b/pkg/cmd/version.go index 86845a8..a76967f 100644 --- a/pkg/cmd/version.go +++ b/pkg/cmd/version.go @@ -2,4 +2,4 @@ package cmd -const Version = "0.5.0" // x-release-please-version +const Version = "0.5.1" // x-release-please-version diff --git a/pkg/cmd/volume.go b/pkg/cmd/volume.go index 3ebc1f4..2da5077 100644 --- a/pkg/cmd/volume.go +++ b/pkg/cmd/volume.go @@ -6,6 +6,8 @@ import ( "context" "fmt" + "github.com/onkernel/hypeman-cli/internal/apiquery" + "github.com/onkernel/hypeman-cli/internal/requestflag" "github.com/onkernel/hypeman-go" "github.com/onkernel/hypeman-go/option" "github.com/tidwall/gjson" @@ -14,37 +16,34 @@ import ( var volumesCreate = cli.Command{ Name: "create", - Usage: "Create volume", + Usage: "Creates a new volume. Supports two modes:", Flags: []cli.Flag{ - &cli.StringFlag{ + &requestflag.StringFlag{ Name: "name", Usage: "Volume name", + Config: requestflag.RequestConfig{ + BodyPath: "name", + }, }, - &cli.Int64Flag{ + &requestflag.IntFlag{ Name: "size-gb", Usage: "Size in gigabytes", + Config: requestflag.RequestConfig{ + BodyPath: "size_gb", + }, }, - &cli.StringFlag{ + &requestflag.StringFlag{ Name: "id", Usage: "Optional custom identifier (auto-generated if not provided)", + Config: requestflag.RequestConfig{ + BodyPath: "id", + }, }, }, Action: handleVolumesCreate, HideHelpCommand: true, } -var volumesRetrieve = cli.Command{ - Name: "retrieve", - Usage: "Get volume details", - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "id", - }, - }, - Action: handleVolumesRetrieve, - HideHelpCommand: true, -} - var volumesList = cli.Command{ Name: "list", Usage: "List volumes", @@ -57,7 +56,7 @@ var volumesDelete = cli.Command{ Name: "delete", Usage: "Delete volume", Flags: []cli.Flag{ - &cli.StringFlag{ + &requestflag.StringFlag{ Name: "id", }, }, @@ -65,6 +64,18 @@ var volumesDelete = cli.Command{ HideHelpCommand: true, } +var volumesGet = cli.Command{ + Name: "get", + Usage: "Get volume details", + Flags: []cli.Flag{ + &requestflag.StringFlag{ + Name: "id", + }, + }, + Action: handleVolumesGet, + HideHelpCommand: true, +} + func handleVolumesCreate(ctx context.Context, cmd *cli.Command) error { client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() @@ -72,19 +83,22 @@ func handleVolumesCreate(ctx context.Context, cmd *cli.Command) error { 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 { + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, + ) + if err != nil { return err } var res []byte - _, err := client.Volumes.New( + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Volumes.New( ctx, params, - option.WithMiddleware(debugMiddleware(cmd.Bool("debug"))), - option.WithResponseBodyInto(&res), + options..., ) if err != nil { return err @@ -96,56 +110,61 @@ func handleVolumesCreate(ctx context.Context, cmd *cli.Command) error { return ShowJSON("volumes create", json, format, transform) } -func handleVolumesRetrieve(ctx context.Context, cmd *cli.Command) error { +func handleVolumesList(ctx context.Context, cmd *cli.Command) error { client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("id") && len(unusedArgs) > 0 { - cmd.Set("id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } if len(unusedArgs) > 0 { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - var res []byte - _, err := client.Volumes.Get( - ctx, - cmd.Value("id").(string), - option.WithMiddleware(debugMiddleware(cmd.Bool("debug"))), - option.WithResponseBodyInto(&res), + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, ) if err != nil { return err } + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Volumes.List(ctx, options...) + if err != nil { + return err + } json := gjson.Parse(string(res)) format := cmd.Root().String("format") transform := cmd.Root().String("transform") - return ShowJSON("volumes retrieve", json, format, transform) + return ShowJSON("volumes list", json, format, transform) } -func handleVolumesList(ctx context.Context, cmd *cli.Command) error { +func handleVolumesDelete(ctx context.Context, cmd *cli.Command) error { client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("id") && len(unusedArgs) > 0 { + cmd.Set("id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } if len(unusedArgs) > 0 { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - var res []byte - _, err := client.Volumes.List( - ctx, - option.WithMiddleware(debugMiddleware(cmd.Bool("debug"))), - option.WithResponseBodyInto(&res), + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, ) if err != nil { return err } - - json := gjson.Parse(string(res)) - format := cmd.Root().String("format") - transform := cmd.Root().String("transform") - return ShowJSON("volumes list", json, format, transform) + return client.Volumes.Delete( + ctx, + requestflag.CommandRequestValue[string](cmd, "id"), + options..., + ) } -func handleVolumesDelete(ctx context.Context, cmd *cli.Command) error { +func handleVolumesGet(ctx context.Context, cmd *cli.Command) error { client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() if !cmd.IsSet("id") && len(unusedArgs) > 0 { @@ -155,9 +174,28 @@ func handleVolumesDelete(ctx context.Context, cmd *cli.Command) error { if len(unusedArgs) > 0 { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - return client.Volumes.Delete( + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, + ) + if err != nil { + return err + } + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Volumes.Get( ctx, - cmd.Value("id").(string), - option.WithMiddleware(debugMiddleware(cmd.Bool("debug"))), + requestflag.CommandRequestValue[string](cmd, "id"), + options..., ) + if err != nil { + return err + } + + json := gjson.Parse(string(res)) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON("volumes get", json, format, transform) } diff --git a/scripts/build b/scripts/build new file mode 100755 index 0000000..0ca9e5b --- /dev/null +++ b/scripts/build @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +echo "==> Building hypeman" +go build ./cmd/hypeman diff --git a/scripts/run b/scripts/run new file mode 100755 index 0000000..7818f0e --- /dev/null +++ b/scripts/run @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +go run ./cmd/hypeman "$@" diff --git a/scripts/test b/scripts/test new file mode 100755 index 0000000..c26b122 --- /dev/null +++ b/scripts/test @@ -0,0 +1,56 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +NC='\033[0m' # No Color + +function prism_is_running() { + curl --silent "http://localhost:4010" >/dev/null 2>&1 +} + +kill_server_on_port() { + pids=$(lsof -t -i tcp:"$1" || echo "") + if [ "$pids" != "" ]; then + kill "$pids" + echo "Stopped $pids." + fi +} + +function is_overriding_api_base_url() { + [ -n "$TEST_API_BASE_URL" ] +} + +if ! is_overriding_api_base_url && ! prism_is_running ; then + # When we exit this script, make sure to kill the background mock server process + trap 'kill_server_on_port 4010' EXIT + + # Start the dev server + ./scripts/mock --daemon +fi + +if is_overriding_api_base_url ; then + echo -e "${GREEN}✔ Running tests against ${TEST_API_BASE_URL}${NC}" + echo +elif ! prism_is_running ; then + echo -e "${RED}ERROR:${NC} The test suite will not run without a mock Prism server" + echo -e "running against your OpenAPI spec." + echo + echo -e "To run the server, pass in the path or url of your OpenAPI" + echo -e "spec to the prism command:" + echo + echo -e " \$ ${YELLOW}npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock path/to/your.openapi.yml${NC}" + echo + + exit 1 +else + echo -e "${GREEN}✔ Mock prism server is running with your OpenAPI spec${NC}" + echo +fi + +echo "==> Running tests" +go test ./... "$@"