From 7dd0ffba4c671678883eedb41b29dd1816b970da Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 18 Dec 2025 08:31:04 +0000 Subject: [PATCH 1/8] chore(cli): run pre-codegen tests on Windows --- internal/binaryparam/binary_param_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/binaryparam/binary_param_test.go b/internal/binaryparam/binary_param_test.go index 243550e..bdac3e9 100644 --- a/internal/binaryparam/binary_param_test.go +++ b/internal/binaryparam/binary_param_test.go @@ -34,6 +34,7 @@ func TestFileOrStdin(t *testing.T) { stubStdin, err := os.Open(tempFile) require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, stubStdin.Close()) }) readCloser, stdinInUse, err := FileOrStdin(stubStdin, "-") require.NoError(t, err) @@ -51,6 +52,7 @@ func TestFileOrStdin(t *testing.T) { stubStdin, err := os.Open(tempFile) require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, stubStdin.Close()) }) readCloser, stdinInUse, err := FileOrStdin(stubStdin, "/dev/fd/0") require.NoError(t, err) @@ -68,6 +70,7 @@ func TestFileOrStdin(t *testing.T) { stubStdin, err := os.Open(tempFile) require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, stubStdin.Close()) }) readCloser, stdinInUse, err := FileOrStdin(stubStdin, "/dev/stdin") require.NoError(t, err) From f9e9d7dbcca54b2df0cde1c84e4bc65f525ef786 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 18 Dec 2025 21:26:11 +0000 Subject: [PATCH 2/8] chore(internal): codegen related update --- go.mod | 2 +- pkg/cmd/util.go | 251 +++++++++++++++ pkg/jsonview/explorer.go | 590 ++++++++++++++++++++++++++++++++++ pkg/jsonview/staticdisplay.go | 135 ++++++++ 4 files changed, 977 insertions(+), 1 deletion(-) create mode 100644 pkg/cmd/util.go create mode 100644 pkg/jsonview/explorer.go create mode 100644 pkg/jsonview/staticdisplay.go diff --git a/go.mod b/go.mod index c9888d9..5703540 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/stretchr/testify v1.10.0 github.com/tidwall/gjson v1.18.0 github.com/tidwall/pretty v1.2.1 + github.com/tidwall/sjson v1.2.5 github.com/urfave/cli-docs/v3 v3.0.0-alpha6 github.com/urfave/cli/v3 v3.3.2 golang.org/x/sys v0.38.0 @@ -55,7 +56,6 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/tidwall/match v1.1.1 // indirect - github.com/tidwall/sjson v1.2.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.7.8 // indirect github.com/yuin/goldmark-emoji v1.0.5 // indirect diff --git a/pkg/cmd/util.go b/pkg/cmd/util.go new file mode 100644 index 0000000..866a450 --- /dev/null +++ b/pkg/cmd/util.go @@ -0,0 +1,251 @@ +// 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/stainless-api/stainless-api-cli/pkg/jsonview" + "github.com/stainless-api/stainless-api-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("Stainless/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)) + } + + // Set environment if the --environment flag is provided + if environment := cmd.String("environment"); environment != "" { + switch environment { + case "production": + opts = append(opts, option.WithEnvironmentProduction()) + case "staging": + opts = append(opts, option.WithEnvironmentStaging()) + default: + log.Fatalf("Unknown environment: %s. Valid environments are: production, staging", environment) + } + } + + 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/jsonview/explorer.go b/pkg/jsonview/explorer.go new file mode 100644 index 0000000..8d725eb --- /dev/null +++ b/pkg/jsonview/explorer.go @@ -0,0 +1,590 @@ +package jsonview + +import ( + "errors" + "fmt" + "math" + "strings" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/reflow/truncate" + "github.com/muesli/reflow/wordwrap" + "github.com/tidwall/gjson" +) + +const ( + // UI layout constants + borderPadding = 2 + heightOffset = 5 + tableMinHeight = 2 + titlePaddingLeft = 2 + titlePaddingTop = 0 + footerPaddingLeft = 1 + + // Column width constants + defaultColumnWidth = 10 + keyColumnWidth = 3 + valueColumnWidth = 5 + + // String formatting constants + maxStringLength = 100 + maxPreviewLength = 24 + + arrayColor = lipgloss.Color("1") + stringColor = lipgloss.Color("5") + objectColor = lipgloss.Color("4") +) + +type keyMap struct { + Up key.Binding + Down key.Binding + Enter key.Binding + Back key.Binding + PrintValue key.Binding + Raw key.Binding + Quit key.Binding +} + +func (k keyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Quit, k.Up, k.Down, k.Back, k.Enter, k.PrintValue, k.Raw} +} + +func (k keyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{k.ShortHelp()} +} + +var keys = keyMap{ + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "down"), + ), + Back: key.NewBinding( + key.WithKeys("left", "h", "backspace"), + key.WithHelp("←/h", "go back"), + ), + Enter: key.NewBinding( + key.WithKeys("right", "l"), + key.WithHelp("→/l", "expand"), + ), + PrintValue: key.NewBinding( + key.WithKeys("p"), + key.WithHelp("p", "print and exit"), + ), + Raw: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("r", "toggle raw JSON"), + ), + Quit: key.NewBinding( + key.WithKeys("q", "esc", "ctrl+c", "enter"), + key.WithHelp("q/enter", "quit"), + ), +} + +var ( + titleStyle = lipgloss.NewStyle().Bold(true).PaddingLeft(titlePaddingLeft).PaddingTop(titlePaddingTop) + arrayStyle = lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).BorderForeground(arrayColor) + stringStyle = lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).BorderForeground(stringColor) + objectStyle = lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).BorderForeground(objectColor) + stringLiteralStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("2")) +) + +type JSONView interface { + GetPath() string + GetData() gjson.Result + Update(tea.Msg) tea.Cmd + View() string + Resize(width, height int) +} + +type TableView struct { + path string + data gjson.Result + table table.Model + rowData []gjson.Result +} + +func (tv *TableView) GetPath() string { return tv.path } +func (tv *TableView) GetData() gjson.Result { return tv.data } +func (tv *TableView) View() string { return tv.table.View() } + +func (tv *TableView) Update(msg tea.Msg) tea.Cmd { + var cmd tea.Cmd + tv.table, cmd = tv.table.Update(msg) + return cmd +} + +func (tv *TableView) Resize(width, height int) { + tv.updateColumnWidths(width) + tv.table.SetHeight(min(height-heightOffset, tableMinHeight+len(tv.table.Rows()))) +} + +func (tv *TableView) updateColumnWidths(width int) { + columns := tv.table.Columns() + widths := make([]int, len(columns)) + + // Calculate required widths from headers and content + for i, col := range columns { + widths[i] = lipgloss.Width(col.Title) + } + + for _, row := range tv.table.Rows() { + for i, cell := range row { + if i < len(widths) { + widths[i] = max(widths[i], lipgloss.Width(cell)) + } + } + } + + totalWidth := sum(widths) + available := width - borderPadding*len(columns) + + if totalWidth <= available { + for i, w := range widths { + columns[i].Width = w + } + return + } + + fairShare := float64(available) / float64(len(columns)) + shrinkable := 0.0 + + for _, w := range widths { + if float64(w) > fairShare { + shrinkable += float64(w) - fairShare + } + } + + if shrinkable > 0 { + excess := float64(totalWidth - available) + for i, w := range widths { + if float64(w) > fairShare { + reduction := (float64(w) - fairShare) * (excess / shrinkable) + widths[i] = int(math.Round(float64(w) - reduction)) + } + } + } + + for i, w := range widths { + columns[i].Width = w + } + + tv.table.SetColumns(columns) +} + +type TextView struct { + path string + data gjson.Result + viewport viewport.Model + ready bool +} + +func (tv *TextView) GetPath() string { return tv.path } +func (tv *TextView) GetData() gjson.Result { return tv.data } +func (tv *TextView) View() string { return tv.viewport.View() } +func (tv *TextView) Update(msg tea.Msg) tea.Cmd { + var cmd tea.Cmd + tv.viewport, cmd = tv.viewport.Update(msg) + return cmd +} + +func (tv *TextView) Resize(width, height int) { + h := height - heightOffset + if !tv.ready { + tv.viewport = viewport.New(width, h) + tv.viewport.SetContent(wordwrap.String(tv.data.String(), width)) + tv.ready = true + return + } + tv.viewport.Width = width + tv.viewport.Height = h +} + +type JSONViewer struct { + stack []JSONView + root string + width int + height int + rawMode bool + message string + help help.Model +} + +func ExploreJSON(title string, json gjson.Result) error { + view, err := newView("", json, false) + if err != nil { + return err + } + + viewer := &JSONViewer{stack: []JSONView{view}, root: title, rawMode: false, help: help.New()} + _, err = tea.NewProgram(viewer).Run() + if viewer.message != "" { + _, msgErr := fmt.Println("\n" + viewer.message) + err = errors.Join(err, msgErr) + } + return err +} + +func (v *JSONViewer) current() JSONView { return v.stack[len(v.stack)-1] } +func (v *JSONViewer) Init() tea.Cmd { return nil } + +func (v *JSONViewer) resize(width, height int) { + v.width, v.height = width, height + v.help.Width = width + for i := range v.stack { + v.stack[i].Resize(width, height) + } +} + +func (v *JSONViewer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + v.resize(msg.Width-borderPadding, msg.Height) + return v, nil + case tea.KeyMsg: + switch { + case key.Matches(msg, keys.Quit): + return v, tea.Quit + case key.Matches(msg, keys.Enter): + return v.navigateForward() + case key.Matches(msg, keys.Back): + return v.navigateBack() + case key.Matches(msg, keys.Raw): + return v.toggleRaw() + case key.Matches(msg, keys.PrintValue): + v.message = v.getSelectedContent() + return v, tea.Quit + } + } + + return v, v.current().Update(msg) +} + +func (v *JSONViewer) getSelectedContent() string { + tableView, ok := v.current().(*TableView) + if !ok { + return v.current().GetData().Raw + } + + selected := tableView.rowData[tableView.table.Cursor()] + if selected.Type == gjson.String { + return selected.String() + } + return selected.Raw +} + +func (v *JSONViewer) navigateForward() (tea.Model, tea.Cmd) { + tableView, ok := v.current().(*TableView) + if !ok { + return v, nil + } + + cursor := tableView.table.Cursor() + selected := tableView.rowData[cursor] + if !v.canNavigateInto(selected) { + return v, nil + } + + path := v.buildNavigationPath(tableView, cursor) + forwardView, err := newView(path, selected, v.rawMode) + if err != nil { + return v, nil + } + + v.stack = append(v.stack, forwardView) + v.resize(v.width, v.height) + return v, nil +} + +func (v *JSONViewer) buildNavigationPath(tableView *TableView, cursor int) string { + if tableView.data.IsArray() { + return fmt.Sprintf("%s[%d]", tableView.path, cursor) + } + key := tableView.data.Get("@keys").Array()[cursor].Str + return fmt.Sprintf("%s[%s]", tableView.path, quoteString(key)) +} + +func quoteString(s string) string { + // Replace backslashes and quotes with escaped versions + s = strings.ReplaceAll(s, "\\", "\\\\") + s = strings.ReplaceAll(s, "\"", "\\\"") + return stringLiteralStyle.Render("\"" + s + "\"") +} + +func (v *JSONViewer) canNavigateInto(data gjson.Result) bool { + switch { + case data.IsArray(): + return len(data.Array()) > 0 + case data.IsObject(): + return len(data.Map()) > 0 + case data.Type == gjson.String: + str := data.String() + return strings.Contains(str, "\n") || lipgloss.Width(str) >= maxStringLength + } + return false +} + +func (v *JSONViewer) navigateBack() (tea.Model, tea.Cmd) { + if len(v.stack) > 1 { + v.stack = v.stack[:len(v.stack)-1] + } + return v, nil +} + +func (v *JSONViewer) toggleRaw() (tea.Model, tea.Cmd) { + v.rawMode = !v.rawMode + + for i, view := range v.stack { + rawView, err := newView(view.GetPath(), view.GetData(), v.rawMode) + if err != nil { + return v, tea.Printf("Error: %s", err) + } + v.stack[i] = rawView + } + + v.resize(v.width, v.height) + return v, nil +} + +func (v *JSONViewer) View() string { + view := v.current() + title := v.buildTitle(view) + content := titleStyle.Render(title) + style := v.getStyleForData(view.GetData()) + content += "\n" + style.Render(view.View()) + content += "\n" + v.help.View(keys) + return content +} + +func (v *JSONViewer) buildTitle(view JSONView) string { + title := v.root + if len(view.GetPath()) > 0 { + title += " → " + view.GetPath() + } + if v.rawMode { + title += " (JSON)" + } + return title +} + +func (v *JSONViewer) getStyleForData(data gjson.Result) lipgloss.Style { + switch { + case data.Type == gjson.String: + return stringStyle + case data.IsArray(): + return arrayStyle + default: + return objectStyle + } +} + +func newView(path string, data gjson.Result, raw bool) (JSONView, error) { + if data.Type == gjson.String { + return newTextView(path, data) + } + return newTableView(path, data, raw) +} + +func newTextView(path string, data gjson.Result) (*TextView, error) { + if !data.Exists() || data.Type != gjson.String { + return nil, fmt.Errorf("invalid text JSON") + } + return &TextView{path: path, data: data}, nil +} + +func newTableView(path string, data gjson.Result, raw bool) (*TableView, error) { + if !data.Exists() || data.Type != gjson.JSON { + return nil, fmt.Errorf("invalid table JSON") + } + + switch { + case data.IsArray(): + array := data.Array() + if isArrayOfObjects(array) { + return newArrayOfObjectsTableView(path, data, array, raw), nil + } else { + return newArrayTableView(path, data, array, raw), nil + } + case data.IsObject(): + return newObjectTableView(path, data, raw), nil + default: + return nil, fmt.Errorf("unsupported JSON type") + } +} + +func newArrayTableView(path string, data gjson.Result, array []gjson.Result, raw bool) *TableView { + columns := []table.Column{{Title: "Items", Width: defaultColumnWidth}} + rows := make([]table.Row, 0, len(array)) + rowData := make([]gjson.Result, 0, len(array)) + + for _, item := range array { + rows = append(rows, table.Row{formatValue(item, raw)}) + rowData = append(rowData, item) + } + + t := createTable(columns, rows, arrayColor) + return &TableView{path: path, data: data, table: t, rowData: rowData} +} + +func newArrayOfObjectsTableView(path string, data gjson.Result, array []gjson.Result, raw bool) *TableView { + // Collect unique keys + keySet := make(map[string]struct{}) + var columns []table.Column + + for _, item := range array { + for _, key := range item.Get("@keys").Array() { + if _, exists := keySet[key.Str]; !exists { + keySet[key.Str] = struct{}{} + title := key.Str + columns = append(columns, table.Column{Title: title, Width: defaultColumnWidth}) + } + } + } + + rows := make([]table.Row, 0, len(array)) + rowData := make([]gjson.Result, 0, len(array)) + + for _, item := range array { + row := make(table.Row, len(columns)) + for i, col := range columns { + row[i] = formatValue(item.Get(col.Title), raw) + } + rows = append(rows, row) + rowData = append(rowData, item) + } + + t := createTable(columns, rows, arrayColor) + return &TableView{path: path, data: data, table: t, rowData: rowData} +} + +func newObjectTableView(path string, data gjson.Result, raw bool) *TableView { + columns := []table.Column{{Title: "Object"}, {}} + + keys := data.Get("@keys").Array() + rows := make([]table.Row, 0, len(keys)) + rowData := make([]gjson.Result, 0, len(keys)) + + for _, key := range keys { + value := data.Get(key.Str) + title := key.Str + rows = append(rows, table.Row{title, formatValue(value, raw)}) + rowData = append(rowData, value) + } + + // Adjust column widths based on content + for _, row := range rows { + for i, cell := range row { + if i < len(columns) { + columns[i].Width = max(columns[i].Width, lipgloss.Width(cell)) + } + } + } + + t := createTable(columns, rows, objectColor) + return &TableView{path: path, data: data, table: t, rowData: rowData} +} + +func createTable(columns []table.Column, rows []table.Row, bgColor lipgloss.Color) table.Model { + t := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + ) + + // Set common table styles + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true). + Bold(true) + s.Selected = s.Selected. + Foreground(lipgloss.Color("229")). + Background(bgColor). + Bold(false) + t.SetStyles(s) + + return t +} + +func formatValue(value gjson.Result, raw bool) string { + if raw { + return value.Get("@ugly").Raw + } + + switch { + case value.IsObject(): + return formatObject(value) + case value.IsArray(): + return formatArray(value) + case value.Type == gjson.String: + return value.Str + default: + return value.Raw + } +} + +func formatObject(value gjson.Result) string { + keys := value.Get("@keys").Array() + keyStrs := make([]string, len(keys)) + + for i, key := range keys { + val := value.Get(key.Str) + keyStrs[i] = formatObjectKey(key.Str, val) + } + + return "{" + strings.Join(keyStrs, ", ") + "}" +} + +func formatObjectKey(key string, val gjson.Result) string { + switch { + case val.IsObject(): + return key + ":{…}" + case val.IsArray(): + return key + ":[…]" + case val.Type == gjson.String: + str := val.Str + if lipgloss.Width(str) <= maxPreviewLength { + return fmt.Sprintf(`%s:"%s"`, key, str) + } + return fmt.Sprintf(`%s:"%s…"`, key, truncate.String(str, uint(maxPreviewLength))) + default: + return key + ":" + val.Raw + } +} + +func formatArray(value gjson.Result) string { + switch count := len(value.Array()); count { + case 0: + return "[]" + case 1: + return "[...1 item...]" + default: + return fmt.Sprintf("[...%d items...]", count) + } +} + +func isArrayOfObjects(array []gjson.Result) bool { + for _, item := range array { + if !item.IsObject() { + return false + } + } + return len(array) > 0 +} + +func sum(ints []int) int { + total := 0 + for _, n := range ints { + total += n + } + return total +} diff --git a/pkg/jsonview/staticdisplay.go b/pkg/jsonview/staticdisplay.go new file mode 100644 index 0000000..768ea34 --- /dev/null +++ b/pkg/jsonview/staticdisplay.go @@ -0,0 +1,135 @@ +package jsonview + +import ( + "fmt" + "os" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/term" + "github.com/muesli/reflow/truncate" + "github.com/tidwall/gjson" +) + +const ( + tabWidth = 2 +) + +var ( + keyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("75")).Bold(false) + stringValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("113")) + numberValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("215")) + boolValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("207")) + nullValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")).Italic(true) + bulletStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("242")) + containerStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("63")). + Padding(0, 1) +) + +func formatJSON(json gjson.Result, width int) string { + if !json.Exists() { + return nullValueStyle.Render("Invalid JSON") + } + return formatResult(json, 0, width) +} + +func formatResult(result gjson.Result, indent, width int) string { + switch result.Type { + case gjson.String: + str := result.Str + if str == "" { + return nullValueStyle.Render("(empty)") + } + if lipgloss.Width(str) > width { + str = truncate.String(str, uint(width-1)) + "…" + } + return stringValueStyle.Render(str) + case gjson.Number: + return numberValueStyle.Render(result.Raw) + case gjson.True: + return boolValueStyle.Render("yes") + case gjson.False: + return boolValueStyle.Render("no") + case gjson.Null: + return nullValueStyle.Render("null") + case gjson.JSON: + if result.IsArray() { + return formatJSONArray(result, indent, width) + } + return formatJSONObject(result, indent, width) + default: + return stringValueStyle.Render(result.String()) + } +} + +func isSingleLine(result gjson.Result, indent int) bool { + return !(result.IsObject() || result.IsArray()) +} + +func formatJSONArray(result gjson.Result, indent, width int) string { + items := result.Array() + if len(items) == 0 { + return nullValueStyle.Render(" (none)") + } + + numberWidth := lipgloss.Width(fmt.Sprintf("%d. ", len(items))) + + var formattedItems []string + for i, item := range items { + number := fmt.Sprintf("%d.", i+1) + numbering := getIndent(indent) + bulletStyle.Render(number) + + // If the item will be a one-liner, put it inline after the numbering, + // otherwise it starts with a newline and goes below the numbering. + itemWidth := width + if isSingleLine(item, indent+1) { + // Add right-padding: + numbering += strings.Repeat(" ", numberWidth-lipgloss.Width(number)) + itemWidth = width - lipgloss.Width(numbering) + } + value := formatResult(item, indent+1, itemWidth) + formattedItems = append(formattedItems, numbering+value) + } + return "\n" + strings.Join(formattedItems, "\n") +} + +func formatJSONObject(result gjson.Result, indent, width int) string { + keys := result.Get("@keys").Array() + if len(keys) == 0 { + return nullValueStyle.Render("(empty)") + } + + var items []string + for _, key := range keys { + value := result.Get(key.String()) + keyStr := getIndent(indent) + keyStyle.Render(key.String()+":") + // If item will be a one-liner, put it inline after the key, otherwise + // it starts with a newline and goes below the key. + itemWidth := width + if isSingleLine(value, indent+1) { + keyStr += " " + itemWidth = width - lipgloss.Width(keyStr) + } + formattedValue := formatResult(value, indent+1, itemWidth) + items = append(items, keyStr+formattedValue) + } + + return "\n" + strings.Join(items, "\n") +} + +func getIndent(indent int) string { + return strings.Repeat(" ", indent*tabWidth) +} + +func RenderJSON(title string, json gjson.Result) string { + width, _, err := term.GetSize(os.Stdout.Fd()) + if err != nil { + width = 80 + } + width -= containerStyle.GetBorderLeftSize() + containerStyle.GetBorderRightSize() + + containerStyle.GetPaddingLeft() + containerStyle.GetPaddingRight() + content := strings.TrimLeft(formatJSON(json, width), "\n") + return titleStyle.Render(title) + "\n" + containerStyle.Render(content) +} From c439ed63774cae542fa6eac8d01095a272061be9 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 18 Dec 2025 21:26:28 +0000 Subject: [PATCH 3/8] chore(internal): codegen related update --- go.mod | 2 +- pkg/cmd/util.go | 251 --------------- pkg/jsonview/explorer.go | 590 ---------------------------------- pkg/jsonview/staticdisplay.go | 135 -------- 4 files changed, 1 insertion(+), 977 deletions(-) delete mode 100644 pkg/cmd/util.go delete mode 100644 pkg/jsonview/explorer.go delete mode 100644 pkg/jsonview/staticdisplay.go diff --git a/go.mod b/go.mod index 5703540..c9888d9 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,6 @@ require ( github.com/stretchr/testify v1.10.0 github.com/tidwall/gjson v1.18.0 github.com/tidwall/pretty v1.2.1 - github.com/tidwall/sjson v1.2.5 github.com/urfave/cli-docs/v3 v3.0.0-alpha6 github.com/urfave/cli/v3 v3.3.2 golang.org/x/sys v0.38.0 @@ -56,6 +55,7 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.7.8 // indirect github.com/yuin/goldmark-emoji v1.0.5 // indirect diff --git a/pkg/cmd/util.go b/pkg/cmd/util.go deleted file mode 100644 index 866a450..0000000 --- a/pkg/cmd/util.go +++ /dev/null @@ -1,251 +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/stainless-api/stainless-api-cli/pkg/jsonview" - "github.com/stainless-api/stainless-api-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("Stainless/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)) - } - - // Set environment if the --environment flag is provided - if environment := cmd.String("environment"); environment != "" { - switch environment { - case "production": - opts = append(opts, option.WithEnvironmentProduction()) - case "staging": - opts = append(opts, option.WithEnvironmentStaging()) - default: - log.Fatalf("Unknown environment: %s. Valid environments are: production, staging", environment) - } - } - - 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/jsonview/explorer.go b/pkg/jsonview/explorer.go deleted file mode 100644 index 8d725eb..0000000 --- a/pkg/jsonview/explorer.go +++ /dev/null @@ -1,590 +0,0 @@ -package jsonview - -import ( - "errors" - "fmt" - "math" - "strings" - - "github.com/charmbracelet/bubbles/help" - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/table" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/muesli/reflow/truncate" - "github.com/muesli/reflow/wordwrap" - "github.com/tidwall/gjson" -) - -const ( - // UI layout constants - borderPadding = 2 - heightOffset = 5 - tableMinHeight = 2 - titlePaddingLeft = 2 - titlePaddingTop = 0 - footerPaddingLeft = 1 - - // Column width constants - defaultColumnWidth = 10 - keyColumnWidth = 3 - valueColumnWidth = 5 - - // String formatting constants - maxStringLength = 100 - maxPreviewLength = 24 - - arrayColor = lipgloss.Color("1") - stringColor = lipgloss.Color("5") - objectColor = lipgloss.Color("4") -) - -type keyMap struct { - Up key.Binding - Down key.Binding - Enter key.Binding - Back key.Binding - PrintValue key.Binding - Raw key.Binding - Quit key.Binding -} - -func (k keyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Quit, k.Up, k.Down, k.Back, k.Enter, k.PrintValue, k.Raw} -} - -func (k keyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{k.ShortHelp()} -} - -var keys = keyMap{ - Up: key.NewBinding( - key.WithKeys("up", "k"), - key.WithHelp("↑/k", "up"), - ), - Down: key.NewBinding( - key.WithKeys("down", "j"), - key.WithHelp("↓/j", "down"), - ), - Back: key.NewBinding( - key.WithKeys("left", "h", "backspace"), - key.WithHelp("←/h", "go back"), - ), - Enter: key.NewBinding( - key.WithKeys("right", "l"), - key.WithHelp("→/l", "expand"), - ), - PrintValue: key.NewBinding( - key.WithKeys("p"), - key.WithHelp("p", "print and exit"), - ), - Raw: key.NewBinding( - key.WithKeys("r"), - key.WithHelp("r", "toggle raw JSON"), - ), - Quit: key.NewBinding( - key.WithKeys("q", "esc", "ctrl+c", "enter"), - key.WithHelp("q/enter", "quit"), - ), -} - -var ( - titleStyle = lipgloss.NewStyle().Bold(true).PaddingLeft(titlePaddingLeft).PaddingTop(titlePaddingTop) - arrayStyle = lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).BorderForeground(arrayColor) - stringStyle = lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).BorderForeground(stringColor) - objectStyle = lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).BorderForeground(objectColor) - stringLiteralStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("2")) -) - -type JSONView interface { - GetPath() string - GetData() gjson.Result - Update(tea.Msg) tea.Cmd - View() string - Resize(width, height int) -} - -type TableView struct { - path string - data gjson.Result - table table.Model - rowData []gjson.Result -} - -func (tv *TableView) GetPath() string { return tv.path } -func (tv *TableView) GetData() gjson.Result { return tv.data } -func (tv *TableView) View() string { return tv.table.View() } - -func (tv *TableView) Update(msg tea.Msg) tea.Cmd { - var cmd tea.Cmd - tv.table, cmd = tv.table.Update(msg) - return cmd -} - -func (tv *TableView) Resize(width, height int) { - tv.updateColumnWidths(width) - tv.table.SetHeight(min(height-heightOffset, tableMinHeight+len(tv.table.Rows()))) -} - -func (tv *TableView) updateColumnWidths(width int) { - columns := tv.table.Columns() - widths := make([]int, len(columns)) - - // Calculate required widths from headers and content - for i, col := range columns { - widths[i] = lipgloss.Width(col.Title) - } - - for _, row := range tv.table.Rows() { - for i, cell := range row { - if i < len(widths) { - widths[i] = max(widths[i], lipgloss.Width(cell)) - } - } - } - - totalWidth := sum(widths) - available := width - borderPadding*len(columns) - - if totalWidth <= available { - for i, w := range widths { - columns[i].Width = w - } - return - } - - fairShare := float64(available) / float64(len(columns)) - shrinkable := 0.0 - - for _, w := range widths { - if float64(w) > fairShare { - shrinkable += float64(w) - fairShare - } - } - - if shrinkable > 0 { - excess := float64(totalWidth - available) - for i, w := range widths { - if float64(w) > fairShare { - reduction := (float64(w) - fairShare) * (excess / shrinkable) - widths[i] = int(math.Round(float64(w) - reduction)) - } - } - } - - for i, w := range widths { - columns[i].Width = w - } - - tv.table.SetColumns(columns) -} - -type TextView struct { - path string - data gjson.Result - viewport viewport.Model - ready bool -} - -func (tv *TextView) GetPath() string { return tv.path } -func (tv *TextView) GetData() gjson.Result { return tv.data } -func (tv *TextView) View() string { return tv.viewport.View() } -func (tv *TextView) Update(msg tea.Msg) tea.Cmd { - var cmd tea.Cmd - tv.viewport, cmd = tv.viewport.Update(msg) - return cmd -} - -func (tv *TextView) Resize(width, height int) { - h := height - heightOffset - if !tv.ready { - tv.viewport = viewport.New(width, h) - tv.viewport.SetContent(wordwrap.String(tv.data.String(), width)) - tv.ready = true - return - } - tv.viewport.Width = width - tv.viewport.Height = h -} - -type JSONViewer struct { - stack []JSONView - root string - width int - height int - rawMode bool - message string - help help.Model -} - -func ExploreJSON(title string, json gjson.Result) error { - view, err := newView("", json, false) - if err != nil { - return err - } - - viewer := &JSONViewer{stack: []JSONView{view}, root: title, rawMode: false, help: help.New()} - _, err = tea.NewProgram(viewer).Run() - if viewer.message != "" { - _, msgErr := fmt.Println("\n" + viewer.message) - err = errors.Join(err, msgErr) - } - return err -} - -func (v *JSONViewer) current() JSONView { return v.stack[len(v.stack)-1] } -func (v *JSONViewer) Init() tea.Cmd { return nil } - -func (v *JSONViewer) resize(width, height int) { - v.width, v.height = width, height - v.help.Width = width - for i := range v.stack { - v.stack[i].Resize(width, height) - } -} - -func (v *JSONViewer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - v.resize(msg.Width-borderPadding, msg.Height) - return v, nil - case tea.KeyMsg: - switch { - case key.Matches(msg, keys.Quit): - return v, tea.Quit - case key.Matches(msg, keys.Enter): - return v.navigateForward() - case key.Matches(msg, keys.Back): - return v.navigateBack() - case key.Matches(msg, keys.Raw): - return v.toggleRaw() - case key.Matches(msg, keys.PrintValue): - v.message = v.getSelectedContent() - return v, tea.Quit - } - } - - return v, v.current().Update(msg) -} - -func (v *JSONViewer) getSelectedContent() string { - tableView, ok := v.current().(*TableView) - if !ok { - return v.current().GetData().Raw - } - - selected := tableView.rowData[tableView.table.Cursor()] - if selected.Type == gjson.String { - return selected.String() - } - return selected.Raw -} - -func (v *JSONViewer) navigateForward() (tea.Model, tea.Cmd) { - tableView, ok := v.current().(*TableView) - if !ok { - return v, nil - } - - cursor := tableView.table.Cursor() - selected := tableView.rowData[cursor] - if !v.canNavigateInto(selected) { - return v, nil - } - - path := v.buildNavigationPath(tableView, cursor) - forwardView, err := newView(path, selected, v.rawMode) - if err != nil { - return v, nil - } - - v.stack = append(v.stack, forwardView) - v.resize(v.width, v.height) - return v, nil -} - -func (v *JSONViewer) buildNavigationPath(tableView *TableView, cursor int) string { - if tableView.data.IsArray() { - return fmt.Sprintf("%s[%d]", tableView.path, cursor) - } - key := tableView.data.Get("@keys").Array()[cursor].Str - return fmt.Sprintf("%s[%s]", tableView.path, quoteString(key)) -} - -func quoteString(s string) string { - // Replace backslashes and quotes with escaped versions - s = strings.ReplaceAll(s, "\\", "\\\\") - s = strings.ReplaceAll(s, "\"", "\\\"") - return stringLiteralStyle.Render("\"" + s + "\"") -} - -func (v *JSONViewer) canNavigateInto(data gjson.Result) bool { - switch { - case data.IsArray(): - return len(data.Array()) > 0 - case data.IsObject(): - return len(data.Map()) > 0 - case data.Type == gjson.String: - str := data.String() - return strings.Contains(str, "\n") || lipgloss.Width(str) >= maxStringLength - } - return false -} - -func (v *JSONViewer) navigateBack() (tea.Model, tea.Cmd) { - if len(v.stack) > 1 { - v.stack = v.stack[:len(v.stack)-1] - } - return v, nil -} - -func (v *JSONViewer) toggleRaw() (tea.Model, tea.Cmd) { - v.rawMode = !v.rawMode - - for i, view := range v.stack { - rawView, err := newView(view.GetPath(), view.GetData(), v.rawMode) - if err != nil { - return v, tea.Printf("Error: %s", err) - } - v.stack[i] = rawView - } - - v.resize(v.width, v.height) - return v, nil -} - -func (v *JSONViewer) View() string { - view := v.current() - title := v.buildTitle(view) - content := titleStyle.Render(title) - style := v.getStyleForData(view.GetData()) - content += "\n" + style.Render(view.View()) - content += "\n" + v.help.View(keys) - return content -} - -func (v *JSONViewer) buildTitle(view JSONView) string { - title := v.root - if len(view.GetPath()) > 0 { - title += " → " + view.GetPath() - } - if v.rawMode { - title += " (JSON)" - } - return title -} - -func (v *JSONViewer) getStyleForData(data gjson.Result) lipgloss.Style { - switch { - case data.Type == gjson.String: - return stringStyle - case data.IsArray(): - return arrayStyle - default: - return objectStyle - } -} - -func newView(path string, data gjson.Result, raw bool) (JSONView, error) { - if data.Type == gjson.String { - return newTextView(path, data) - } - return newTableView(path, data, raw) -} - -func newTextView(path string, data gjson.Result) (*TextView, error) { - if !data.Exists() || data.Type != gjson.String { - return nil, fmt.Errorf("invalid text JSON") - } - return &TextView{path: path, data: data}, nil -} - -func newTableView(path string, data gjson.Result, raw bool) (*TableView, error) { - if !data.Exists() || data.Type != gjson.JSON { - return nil, fmt.Errorf("invalid table JSON") - } - - switch { - case data.IsArray(): - array := data.Array() - if isArrayOfObjects(array) { - return newArrayOfObjectsTableView(path, data, array, raw), nil - } else { - return newArrayTableView(path, data, array, raw), nil - } - case data.IsObject(): - return newObjectTableView(path, data, raw), nil - default: - return nil, fmt.Errorf("unsupported JSON type") - } -} - -func newArrayTableView(path string, data gjson.Result, array []gjson.Result, raw bool) *TableView { - columns := []table.Column{{Title: "Items", Width: defaultColumnWidth}} - rows := make([]table.Row, 0, len(array)) - rowData := make([]gjson.Result, 0, len(array)) - - for _, item := range array { - rows = append(rows, table.Row{formatValue(item, raw)}) - rowData = append(rowData, item) - } - - t := createTable(columns, rows, arrayColor) - return &TableView{path: path, data: data, table: t, rowData: rowData} -} - -func newArrayOfObjectsTableView(path string, data gjson.Result, array []gjson.Result, raw bool) *TableView { - // Collect unique keys - keySet := make(map[string]struct{}) - var columns []table.Column - - for _, item := range array { - for _, key := range item.Get("@keys").Array() { - if _, exists := keySet[key.Str]; !exists { - keySet[key.Str] = struct{}{} - title := key.Str - columns = append(columns, table.Column{Title: title, Width: defaultColumnWidth}) - } - } - } - - rows := make([]table.Row, 0, len(array)) - rowData := make([]gjson.Result, 0, len(array)) - - for _, item := range array { - row := make(table.Row, len(columns)) - for i, col := range columns { - row[i] = formatValue(item.Get(col.Title), raw) - } - rows = append(rows, row) - rowData = append(rowData, item) - } - - t := createTable(columns, rows, arrayColor) - return &TableView{path: path, data: data, table: t, rowData: rowData} -} - -func newObjectTableView(path string, data gjson.Result, raw bool) *TableView { - columns := []table.Column{{Title: "Object"}, {}} - - keys := data.Get("@keys").Array() - rows := make([]table.Row, 0, len(keys)) - rowData := make([]gjson.Result, 0, len(keys)) - - for _, key := range keys { - value := data.Get(key.Str) - title := key.Str - rows = append(rows, table.Row{title, formatValue(value, raw)}) - rowData = append(rowData, value) - } - - // Adjust column widths based on content - for _, row := range rows { - for i, cell := range row { - if i < len(columns) { - columns[i].Width = max(columns[i].Width, lipgloss.Width(cell)) - } - } - } - - t := createTable(columns, rows, objectColor) - return &TableView{path: path, data: data, table: t, rowData: rowData} -} - -func createTable(columns []table.Column, rows []table.Row, bgColor lipgloss.Color) table.Model { - t := table.New( - table.WithColumns(columns), - table.WithRows(rows), - table.WithFocused(true), - ) - - // Set common table styles - s := table.DefaultStyles() - s.Header = s.Header. - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("240")). - BorderBottom(true). - Bold(true) - s.Selected = s.Selected. - Foreground(lipgloss.Color("229")). - Background(bgColor). - Bold(false) - t.SetStyles(s) - - return t -} - -func formatValue(value gjson.Result, raw bool) string { - if raw { - return value.Get("@ugly").Raw - } - - switch { - case value.IsObject(): - return formatObject(value) - case value.IsArray(): - return formatArray(value) - case value.Type == gjson.String: - return value.Str - default: - return value.Raw - } -} - -func formatObject(value gjson.Result) string { - keys := value.Get("@keys").Array() - keyStrs := make([]string, len(keys)) - - for i, key := range keys { - val := value.Get(key.Str) - keyStrs[i] = formatObjectKey(key.Str, val) - } - - return "{" + strings.Join(keyStrs, ", ") + "}" -} - -func formatObjectKey(key string, val gjson.Result) string { - switch { - case val.IsObject(): - return key + ":{…}" - case val.IsArray(): - return key + ":[…]" - case val.Type == gjson.String: - str := val.Str - if lipgloss.Width(str) <= maxPreviewLength { - return fmt.Sprintf(`%s:"%s"`, key, str) - } - return fmt.Sprintf(`%s:"%s…"`, key, truncate.String(str, uint(maxPreviewLength))) - default: - return key + ":" + val.Raw - } -} - -func formatArray(value gjson.Result) string { - switch count := len(value.Array()); count { - case 0: - return "[]" - case 1: - return "[...1 item...]" - default: - return fmt.Sprintf("[...%d items...]", count) - } -} - -func isArrayOfObjects(array []gjson.Result) bool { - for _, item := range array { - if !item.IsObject() { - return false - } - } - return len(array) > 0 -} - -func sum(ints []int) int { - total := 0 - for _, n := range ints { - total += n - } - return total -} diff --git a/pkg/jsonview/staticdisplay.go b/pkg/jsonview/staticdisplay.go deleted file mode 100644 index 768ea34..0000000 --- a/pkg/jsonview/staticdisplay.go +++ /dev/null @@ -1,135 +0,0 @@ -package jsonview - -import ( - "fmt" - "os" - "strings" - - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/x/term" - "github.com/muesli/reflow/truncate" - "github.com/tidwall/gjson" -) - -const ( - tabWidth = 2 -) - -var ( - keyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("75")).Bold(false) - stringValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("113")) - numberValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("215")) - boolValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("207")) - nullValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")).Italic(true) - bulletStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("242")) - containerStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("63")). - Padding(0, 1) -) - -func formatJSON(json gjson.Result, width int) string { - if !json.Exists() { - return nullValueStyle.Render("Invalid JSON") - } - return formatResult(json, 0, width) -} - -func formatResult(result gjson.Result, indent, width int) string { - switch result.Type { - case gjson.String: - str := result.Str - if str == "" { - return nullValueStyle.Render("(empty)") - } - if lipgloss.Width(str) > width { - str = truncate.String(str, uint(width-1)) + "…" - } - return stringValueStyle.Render(str) - case gjson.Number: - return numberValueStyle.Render(result.Raw) - case gjson.True: - return boolValueStyle.Render("yes") - case gjson.False: - return boolValueStyle.Render("no") - case gjson.Null: - return nullValueStyle.Render("null") - case gjson.JSON: - if result.IsArray() { - return formatJSONArray(result, indent, width) - } - return formatJSONObject(result, indent, width) - default: - return stringValueStyle.Render(result.String()) - } -} - -func isSingleLine(result gjson.Result, indent int) bool { - return !(result.IsObject() || result.IsArray()) -} - -func formatJSONArray(result gjson.Result, indent, width int) string { - items := result.Array() - if len(items) == 0 { - return nullValueStyle.Render(" (none)") - } - - numberWidth := lipgloss.Width(fmt.Sprintf("%d. ", len(items))) - - var formattedItems []string - for i, item := range items { - number := fmt.Sprintf("%d.", i+1) - numbering := getIndent(indent) + bulletStyle.Render(number) - - // If the item will be a one-liner, put it inline after the numbering, - // otherwise it starts with a newline and goes below the numbering. - itemWidth := width - if isSingleLine(item, indent+1) { - // Add right-padding: - numbering += strings.Repeat(" ", numberWidth-lipgloss.Width(number)) - itemWidth = width - lipgloss.Width(numbering) - } - value := formatResult(item, indent+1, itemWidth) - formattedItems = append(formattedItems, numbering+value) - } - return "\n" + strings.Join(formattedItems, "\n") -} - -func formatJSONObject(result gjson.Result, indent, width int) string { - keys := result.Get("@keys").Array() - if len(keys) == 0 { - return nullValueStyle.Render("(empty)") - } - - var items []string - for _, key := range keys { - value := result.Get(key.String()) - keyStr := getIndent(indent) + keyStyle.Render(key.String()+":") - // If item will be a one-liner, put it inline after the key, otherwise - // it starts with a newline and goes below the key. - itemWidth := width - if isSingleLine(value, indent+1) { - keyStr += " " - itemWidth = width - lipgloss.Width(keyStr) - } - formattedValue := formatResult(value, indent+1, itemWidth) - items = append(items, keyStr+formattedValue) - } - - return "\n" + strings.Join(items, "\n") -} - -func getIndent(indent int) string { - return strings.Repeat(" ", indent*tabWidth) -} - -func RenderJSON(title string, json gjson.Result) string { - width, _, err := term.GetSize(os.Stdout.Fd()) - if err != nil { - width = 80 - } - width -= containerStyle.GetBorderLeftSize() + containerStyle.GetBorderRightSize() + - containerStyle.GetPaddingLeft() + containerStyle.GetPaddingRight() - content := strings.TrimLeft(formatJSON(json, width), "\n") - return titleStyle.Render(title) + "\n" + containerStyle.Render(content) -} From f62abc2e06e2a0f85e57a22a956e3d9272fd65b2 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 18 Dec 2025 21:35:51 +0000 Subject: [PATCH 4/8] feat: added mock server tests --- internal/mocktest/mocktest.go | 96 +++++++++++++++++++++++++++++ internal/requestflag/requestflag.go | 3 +- pkg/cmd/build_test.go | 58 +++++++++++++++++ pkg/cmd/builddiagnostic_test.go | 22 +++++++ pkg/cmd/buildtargetoutput_test.go | 21 +++++++ pkg/cmd/flagoptions.go | 12 ++++ pkg/cmd/org_test.go | 26 ++++++++ pkg/cmd/project_test.go | 52 ++++++++++++++++ pkg/cmd/projectbranch_test.go | 74 ++++++++++++++++++++++ pkg/cmd/projectconfig_test.go | 31 ++++++++++ scripts/test | 4 +- 11 files changed, 396 insertions(+), 3 deletions(-) create mode 100644 internal/mocktest/mocktest.go create mode 100644 pkg/cmd/build_test.go create mode 100644 pkg/cmd/builddiagnostic_test.go create mode 100644 pkg/cmd/buildtargetoutput_test.go create mode 100644 pkg/cmd/org_test.go create mode 100644 pkg/cmd/project_test.go create mode 100644 pkg/cmd/projectbranch_test.go create mode 100644 pkg/cmd/projectconfig_test.go diff --git a/internal/mocktest/mocktest.go b/internal/mocktest/mocktest.go new file mode 100644 index 0000000..575ae89 --- /dev/null +++ b/internal/mocktest/mocktest.go @@ -0,0 +1,96 @@ +package mocktest + +import ( + "context" + "fmt" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var mockServerURL *url.URL + +func init() { + mockServerURL, _ = url.Parse("http://localhost:4010") + if testURL := os.Getenv("TEST_API_BASE_URL"); testURL != "" { + if parsed, err := url.Parse(testURL); err == nil { + mockServerURL = parsed + } + } +} + +// OnlyMockServerDialer only allows network connections to the mock server +type OnlyMockServerDialer struct{} + +func (d *OnlyMockServerDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + if address == mockServerURL.Host { + return (&net.Dialer{}).DialContext(ctx, network, address) + } + + return nil, fmt.Errorf("BLOCKED: connection to %s not allowed (only allowed: %s)", address, mockServerURL.Host) +} + +func blockNetworkExceptMockServer() (http.RoundTripper, http.RoundTripper) { + restricted := &http.Transport{ + DialContext: (&OnlyMockServerDialer{}).DialContext, + } + + origClient, origDefault := http.DefaultClient.Transport, http.DefaultTransport + http.DefaultClient.Transport, http.DefaultTransport = restricted, restricted + return origClient, origDefault +} + +func restoreNetwork(origClient, origDefault http.RoundTripper) { + http.DefaultClient.Transport, http.DefaultTransport = origClient, origDefault +} + +// TestRunMockTestWithFlags runs a test against a mock server with the provided +// CLI flags and ensures it succeeds +func TestRunMockTestWithFlags(t *testing.T, flags ...string) { + origClient, origDefault := blockNetworkExceptMockServer() + defer restoreNetwork(origClient, origDefault) + + // Check if mock server is running + conn, err := net.DialTimeout("tcp", mockServerURL.Host, 2*time.Second) + if err != nil { + require.Fail(t, "Mock server is not running on "+mockServerURL.Host+". Please start the mock server before running tests.") + } else { + conn.Close() + } + + // Get the path to the main command + _, filename, _, ok := runtime.Caller(0) + require.True(t, ok, "Could not get current file path") + dirPath := filepath.Dir(filename) + project := filepath.Join(dirPath, "..", "..", "cmd", "...") + + args := []string{"run", project, "--base-url", mockServerURL.String()} + args = append(args, flags...) + + t.Logf("Testing command: stl %s", strings.Join(args[4:], " ")) + + cmd := exec.Command("go", args...) + output, err := cmd.CombinedOutput() + if err != nil { + assert.Fail(t, "Test failed", "Error: %v\nOutput: %s", err, output) + } + + t.Logf("Test passed successfully with output:\n%s\n", output) +} + +func TestFile(t *testing.T, contents string) string { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "file.txt") + require.NoError(t, os.WriteFile(filename, []byte(contents), 0644)) + return filename +} diff --git a/internal/requestflag/requestflag.go b/internal/requestflag/requestflag.go index 4b1177c..a663cdc 100644 --- a/internal/requestflag/requestflag.go +++ b/internal/requestflag/requestflag.go @@ -564,7 +564,7 @@ func (d *DateValue) Parse(s string) error { func (d *DateTimeValue) Parse(s string) error { formats := []string{ time.RFC3339, - "2006-01-02T15:04:05Z07:00", + time.RFC3339Nano, "2006-01-02T15:04:05", "2006-01-02 15:04:05", time.RFC1123, @@ -584,6 +584,7 @@ func (d *DateTimeValue) Parse(s string) error { func (t *TimeValue) Parse(s string) error { formats := []string{ "15:04:05", + "15:04:05.999999999Z07:00", "3:04:05PM", "3:04 PM", "15:04", diff --git a/pkg/cmd/build_test.go b/pkg/cmd/build_test.go new file mode 100644 index 0000000..cef695c --- /dev/null +++ b/pkg/cmd/build_test.go @@ -0,0 +1,58 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/stainless-api/stainless-api-cli/internal/mocktest" +) + +func TestBuildsCreate(t *testing.T) { + t.Skip("Prism tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "builds", "create", + "--project", "project", + "--revision", "string", + "--allow-empty", + "--branch", "branch", + "--commit-message", "commit_message", + "--target-commit-messages", "{cli: cli, csharp: csharp, go: go, java: java, kotlin: kotlin, node: node, openapi: openapi, php: php, python: python, ruby: ruby, terraform: terraform, typescript: typescript}", + "--target", "node", + ) +} + +func TestBuildsRetrieve(t *testing.T) { + t.Skip("Prism tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "builds", "retrieve", + "--build-id", "buildId", + ) +} + +func TestBuildsList(t *testing.T) { + t.Skip("Prism tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "builds", "list", + "--project", "project", + "--branch", "branch", + "--cursor", "cursor", + "--limit", "1", + "--revision", "string", + ) +} + +func TestBuildsCompare(t *testing.T) { + t.Skip("Prism tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "builds", "compare", + "--base", "{branch: branch, revision: string, commit_message: commit_message}", + "--head", "{branch: branch, revision: string, commit_message: commit_message}", + "--project", "project", + "--target", "node", + ) +} diff --git a/pkg/cmd/builddiagnostic_test.go b/pkg/cmd/builddiagnostic_test.go new file mode 100644 index 0000000..85ab3a9 --- /dev/null +++ b/pkg/cmd/builddiagnostic_test.go @@ -0,0 +1,22 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/stainless-api/stainless-api-cli/internal/mocktest" +) + +func TestBuildsDiagnosticsList(t *testing.T) { + t.Skip("Prism tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "builds:diagnostics", "list", + "--build-id", "buildId", + "--cursor", "cursor", + "--limit", "1", + "--severity", "fatal", + "--targets", "targets", + ) +} diff --git a/pkg/cmd/buildtargetoutput_test.go b/pkg/cmd/buildtargetoutput_test.go new file mode 100644 index 0000000..089d742 --- /dev/null +++ b/pkg/cmd/buildtargetoutput_test.go @@ -0,0 +1,21 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/stainless-api/stainless-api-cli/internal/mocktest" +) + +func TestBuildsTargetOutputsRetrieve(t *testing.T) { + t.Skip("Prism tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "builds:target-outputs", "retrieve", + "--build-id", "build_id", + "--target", "node", + "--type", "source", + "--output", "url", + ) +} diff --git a/pkg/cmd/flagoptions.go b/pkg/cmd/flagoptions.go index a930241..ad8cdff 100644 --- a/pkg/cmd/flagoptions.go +++ b/pkg/cmd/flagoptions.go @@ -24,6 +24,7 @@ const ( EmptyBody BodyContentType = iota MultipartFormEncoded ApplicationJSON + ApplicationOctetStream ) func flagOptions( @@ -125,12 +126,23 @@ func flagOptions( return nil, err } options = append(options, option.WithRequestBody(writer.FormDataContentType(), buf)) + case ApplicationJSON: bodyBytes, err := json.Marshal(bodyData) if err != nil { return nil, err } options = append(options, option.WithRequestBody("application/json", bodyBytes)) + + case ApplicationOctetStream: + if bodyBytes, ok := bodyData.([]byte); ok { + options = append(options, option.WithRequestBody("application/octet-stream", bodyBytes)) + } else if bodyStr, ok := bodyData.(string); ok { + options = append(options, option.WithRequestBody("application/octet-stream", []byte(bodyStr))) + } else { + return nil, fmt.Errorf("Unsupported body for application/octet-stream: %v", bodyData) + } + default: panic("Invalid body content type!") } diff --git a/pkg/cmd/org_test.go b/pkg/cmd/org_test.go new file mode 100644 index 0000000..3b2a552 --- /dev/null +++ b/pkg/cmd/org_test.go @@ -0,0 +1,26 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/stainless-api/stainless-api-cli/internal/mocktest" +) + +func TestOrgsRetrieve(t *testing.T) { + t.Skip("Prism tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "orgs", "retrieve", + "--org", "org", + ) +} + +func TestOrgsList(t *testing.T) { + t.Skip("Prism tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "orgs", "list", + ) +} diff --git a/pkg/cmd/project_test.go b/pkg/cmd/project_test.go new file mode 100644 index 0000000..6da4205 --- /dev/null +++ b/pkg/cmd/project_test.go @@ -0,0 +1,52 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/stainless-api/stainless-api-cli/internal/mocktest" +) + +func TestProjectsCreate(t *testing.T) { + t.Skip("Prism tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "projects", "create", + "--display-name", "display_name", + "--org", "org", + "--revision", "{foo: {content: content}}", + "--slug", "slug", + "--target", "node", + ) +} + +func TestProjectsRetrieve(t *testing.T) { + t.Skip("Prism tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "projects", "retrieve", + "--project", "project", + ) +} + +func TestProjectsUpdate(t *testing.T) { + t.Skip("Prism tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "projects", "update", + "--project", "project", + "--display-name", "display_name", + ) +} + +func TestProjectsList(t *testing.T) { + t.Skip("Prism tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "projects", "list", + "--cursor", "cursor", + "--limit", "1", + "--org", "org", + ) +} diff --git a/pkg/cmd/projectbranch_test.go b/pkg/cmd/projectbranch_test.go new file mode 100644 index 0000000..78f6777 --- /dev/null +++ b/pkg/cmd/projectbranch_test.go @@ -0,0 +1,74 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/stainless-api/stainless-api-cli/internal/mocktest" +) + +func TestProjectsBranchesCreate(t *testing.T) { + t.Skip("Prism tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "projects:branches", "create", + "--project", "project", + "--branch", "branch", + "--branch-from", "branch_from", + "--force", + ) +} + +func TestProjectsBranchesRetrieve(t *testing.T) { + t.Skip("Prism tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "projects:branches", "retrieve", + "--project", "project", + "--branch", "branch", + ) +} + +func TestProjectsBranchesList(t *testing.T) { + t.Skip("Prism tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "projects:branches", "list", + "--project", "project", + "--cursor", "cursor", + "--limit", "1", + ) +} + +func TestProjectsBranchesDelete(t *testing.T) { + t.Skip("Prism tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "projects:branches", "delete", + "--project", "project", + "--branch", "branch", + ) +} + +func TestProjectsBranchesRebase(t *testing.T) { + t.Skip("Prism tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "projects:branches", "rebase", + "--project", "project", + "--branch", "branch", + "--base", "base", + ) +} + +func TestProjectsBranchesReset(t *testing.T) { + t.Skip("Prism tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "projects:branches", "reset", + "--project", "project", + "--branch", "branch", + "--target-config-sha", "target_config_sha", + ) +} diff --git a/pkg/cmd/projectconfig_test.go b/pkg/cmd/projectconfig_test.go new file mode 100644 index 0000000..d4a8002 --- /dev/null +++ b/pkg/cmd/projectconfig_test.go @@ -0,0 +1,31 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package cmd + +import ( + "testing" + + "github.com/stainless-api/stainless-api-cli/internal/mocktest" +) + +func TestProjectsConfigsRetrieve(t *testing.T) { + t.Skip("Prism tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "projects:configs", "retrieve", + "--project", "project", + "--branch", "branch", + "--include", "include", + ) +} + +func TestProjectsConfigsGuess(t *testing.T) { + t.Skip("Prism tests are disabled") + mocktest.TestRunMockTestWithFlags( + t, + "projects:configs", "guess", + "--project", "project", + "--spec", "spec", + "--branch", "branch", + ) +} diff --git a/scripts/test b/scripts/test index a5cee9f..7383fc5 100755 --- a/scripts/test +++ b/scripts/test @@ -1,6 +1,6 @@ #!/usr/bin/env bash -set -e +set -euo pipefail cd "$(dirname "$0")/.." @@ -22,7 +22,7 @@ kill_server_on_port() { } function is_overriding_api_base_url() { - [ -n "$TEST_API_BASE_URL" ] + [ -n "${TEST_API_BASE_URL:-}" ] } if ! is_overriding_api_base_url && ! prism_is_running ; then From b8293dcb107347ac57b692d540951b3c09350914 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 19 Dec 2025 07:13:41 +0000 Subject: [PATCH 5/8] fix: fix generated flag types and value wrapping --- pkg/cmd/build.go | 6 +++--- pkg/cmd/cmdutil.go | 2 +- pkg/cmd/project.go | 6 +++--- pkg/cmd/projectbranch.go | 12 ++++++------ pkg/cmd/projectconfig.go | 4 ++-- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/pkg/cmd/build.go b/pkg/cmd/build.go index 61056a2..c34b246 100644 --- a/pkg/cmd/build.go +++ b/pkg/cmd/build.go @@ -113,7 +113,7 @@ var buildsCreate = cli.Command{ Usage: "Optional commit message to use when creating a new commit.", BodyPath: "commit_message", }, - &requestflag.Flag[any]{ + &requestflag.Flag[map[string]string]{ Name: "target-commit-messages", Usage: "Optional commit messages to use for each SDK when making a new commit.\nSDKs not represented in this object will fallback to the optional\n`commit_message` parameter, or will fallback further to the default\ncommit message.", BodyPath: "target_commit_messages", @@ -184,12 +184,12 @@ var buildsCompare = cli.Command{ Name: "compare", Usage: "Create two builds whose outputs can be directly compared with each other.", Flags: []cli.Flag{ - &requestflag.Flag[any]{ + &requestflag.Flag[map[string]any]{ Name: "base", Usage: "Parameters for the base build", BodyPath: "base", }, - &requestflag.Flag[any]{ + &requestflag.Flag[map[string]any]{ Name: "head", Usage: "Parameters for the head build", BodyPath: "head", diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go index 77a1402..19b91f2 100644 --- a/pkg/cmd/cmdutil.go +++ b/pkg/cmd/cmdutil.go @@ -48,7 +48,7 @@ func getDefaultRequestOptions(cmd *cli.Command) []option.RequestOption { case "staging": opts = append(opts, option.WithEnvironmentStaging()) default: - log.Fatalf("Unknown environment: %s. Valid environments are: production, staging", environment) + log.Fatalf("Unknown environment: %s. Valid environments are %s", environment, "production, staging") } } diff --git a/pkg/cmd/project.go b/pkg/cmd/project.go index 04582b4..9b602bf 100644 --- a/pkg/cmd/project.go +++ b/pkg/cmd/project.go @@ -29,7 +29,7 @@ var projectsCreate = cli.Command{ Usage: "Organization name", BodyPath: "org", }, - &requestflag.Flag[any]{ + &requestflag.Flag[map[string]map[string]string]{ Name: "revision", Usage: "File contents to commit", BodyPath: "revision", @@ -149,7 +149,7 @@ func handleProjectsRetrieve(ctx context.Context, cmd *cli.Command) error { } params := stainless.ProjectGetParams{ - Project: stainless.Opt(cmd.Value("project").(string)), + Project: stainless.String(cmd.Value("project").(string)), } options, err := flagOptions( @@ -185,7 +185,7 @@ func handleProjectsUpdate(ctx context.Context, cmd *cli.Command) error { } params := stainless.ProjectUpdateParams{ - Project: stainless.Opt(cmd.Value("project").(string)), + Project: stainless.String(cmd.Value("project").(string)), } options, err := flagOptions( diff --git a/pkg/cmd/projectbranch.go b/pkg/cmd/projectbranch.go index 4c7c25c..6b65612 100644 --- a/pkg/cmd/projectbranch.go +++ b/pkg/cmd/projectbranch.go @@ -153,7 +153,7 @@ func handleProjectsBranchesCreate(ctx context.Context, cmd *cli.Command) error { } params := stainless.ProjectBranchNewParams{ - Project: stainless.Opt(cmd.Value("project").(string)), + Project: stainless.String(cmd.Value("project").(string)), } options, err := flagOptions( @@ -192,7 +192,7 @@ func handleProjectsBranchesRetrieve(ctx context.Context, cmd *cli.Command) error } params := stainless.ProjectBranchGetParams{ - Project: stainless.Opt(cmd.Value("project").(string)), + Project: stainless.String(cmd.Value("project").(string)), } options, err := flagOptions( @@ -233,7 +233,7 @@ func handleProjectsBranchesList(ctx context.Context, cmd *cli.Command) error { } params := stainless.ProjectBranchListParams{ - Project: stainless.Opt(cmd.Value("project").(string)), + Project: stainless.String(cmd.Value("project").(string)), } options, err := flagOptions( @@ -285,7 +285,7 @@ func handleProjectsBranchesDelete(ctx context.Context, cmd *cli.Command) error { } params := stainless.ProjectBranchDeleteParams{ - Project: stainless.Opt(cmd.Value("project").(string)), + Project: stainless.String(cmd.Value("project").(string)), } options, err := flagOptions( @@ -329,7 +329,7 @@ func handleProjectsBranchesRebase(ctx context.Context, cmd *cli.Command) error { } params := stainless.ProjectBranchRebaseParams{ - Project: stainless.Opt(cmd.Value("project").(string)), + Project: stainless.String(cmd.Value("project").(string)), } options, err := flagOptions( @@ -373,7 +373,7 @@ func handleProjectsBranchesReset(ctx context.Context, cmd *cli.Command) error { } params := stainless.ProjectBranchResetParams{ - Project: stainless.Opt(cmd.Value("project").(string)), + Project: stainless.String(cmd.Value("project").(string)), } options, err := flagOptions( diff --git a/pkg/cmd/projectconfig.go b/pkg/cmd/projectconfig.go index 2cea8ff..7476af3 100644 --- a/pkg/cmd/projectconfig.go +++ b/pkg/cmd/projectconfig.go @@ -73,7 +73,7 @@ func handleProjectsConfigsRetrieve(ctx context.Context, cmd *cli.Command) error } params := stainless.ProjectConfigGetParams{ - Project: stainless.Opt(cmd.Value("project").(string)), + Project: stainless.String(cmd.Value("project").(string)), } options, err := flagOptions( @@ -109,7 +109,7 @@ func handleProjectsConfigsGuess(ctx context.Context, cmd *cli.Command) error { } params := stainless.ProjectConfigGuessParams{ - Project: stainless.Opt(cmd.Value("project").(string)), + Project: stainless.String(cmd.Value("project").(string)), } options, err := flagOptions( From 374cd3dd0afa2b5e6ad4bd69c1645aabe457999c Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 19 Dec 2025 23:22:23 +0000 Subject: [PATCH 6/8] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 3ad25f7..f0b2dba 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 20 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/stainless%2Fstainless-v0-6783cf45e0ea6644994eae08c41f755e29948bee313a6c2aaf5b710253eb4eaa.yml -openapi_spec_hash: b37f9ad1ca6bce774c6448b28d267bec +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/stainless%2Fstainless-v0-14899ed94f51eb27170c1618fcdb134c6df08db90996eeec2f620eb8d84c604a.yml +openapi_spec_hash: a4cf6948697a56d5b07ad48ef133093b config_hash: f1b8a43873719fc8f2789008f3aa2260 From 967d82c469ec3f7aca4946c2f3bd4a32f848f7df Mon Sep 17 00:00:00 2001 From: Young-Jin Park Date: Mon, 22 Dec 2025 15:05:38 -0500 Subject: [PATCH 7/8] fix: base64 encoding regression --- pkg/cmd/build.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/build.go b/pkg/cmd/build.go index c34b246..4e68f26 100644 --- a/pkg/cmd/build.go +++ b/pkg/cmd/build.go @@ -246,16 +246,16 @@ func handleBuildsCreate(ctx context.Context, cmd *cli.Command) error { if name, oas, err := convertFileFlag(cmd, "openapi-spec"); err != nil { return err } else if oas != nil { - modifyYAML(cmd, "revision", gjson.Escape("openapi"+path.Ext(name)), map[string][]byte{ - "content": oas, + modifyYAML(cmd, "revision", gjson.Escape("openapi"+path.Ext(name)), map[string]string{ + "content": string(oas), }) } if name, config, err := convertFileFlag(cmd, "stainless-config"); err != nil { return err } else if config != nil { - modifyYAML(cmd, "revision", gjson.Escape("stainless"+path.Ext(name)), map[string][]byte{ - "content": config, + modifyYAML(cmd, "revision", gjson.Escape("stainless"+path.Ext(name)), map[string]string{ + "content": string(config), }) } From 4dfb9ee32cda0b5ef4f1973c58d7d3bcc4975879 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Mon, 22 Dec 2025 21:06:13 +0100 Subject: [PATCH 8/8] release: 0.1.0-alpha.65 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 21 +++++++++++++++++++++ pkg/cmd/version.go | 2 +- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 2eb8209..7c77f9b 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.1.0-alpha.64" + ".": "0.1.0-alpha.65" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f7e101..75bebb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## 0.1.0-alpha.65 (2025-12-22) + +Full Changelog: [v0.1.0-alpha.64...v0.1.0-alpha.65](https://github.com/stainless-api/stainless-api-cli/compare/v0.1.0-alpha.64...v0.1.0-alpha.65) + +### Features + +* added mock server tests ([f62abc2](https://github.com/stainless-api/stainless-api-cli/commit/f62abc2e06e2a0f85e57a22a956e3d9272fd65b2)) + + +### Bug Fixes + +* base64 encoding regression ([967d82c](https://github.com/stainless-api/stainless-api-cli/commit/967d82c469ec3f7aca4946c2f3bd4a32f848f7df)) +* fix generated flag types and value wrapping ([b8293dc](https://github.com/stainless-api/stainless-api-cli/commit/b8293dcb107347ac57b692d540951b3c09350914)) + + +### Chores + +* **cli:** run pre-codegen tests on Windows ([7dd0ffb](https://github.com/stainless-api/stainless-api-cli/commit/7dd0ffba4c671678883eedb41b29dd1816b970da)) +* **internal:** codegen related update ([c439ed6](https://github.com/stainless-api/stainless-api-cli/commit/c439ed63774cae542fa6eac8d01095a272061be9)) +* **internal:** codegen related update ([f9e9d7d](https://github.com/stainless-api/stainless-api-cli/commit/f9e9d7dbcca54b2df0cde1c84e4bc65f525ef786)) + ## 0.1.0-alpha.64 (2025-12-17) Full Changelog: [v0.1.0-alpha.63...v0.1.0-alpha.64](https://github.com/stainless-api/stainless-api-cli/compare/v0.1.0-alpha.63...v0.1.0-alpha.64) diff --git a/pkg/cmd/version.go b/pkg/cmd/version.go index ab588c8..e974bd7 100644 --- a/pkg/cmd/version.go +++ b/pkg/cmd/version.go @@ -2,4 +2,4 @@ package cmd -const Version = "0.1.0-alpha.64" // x-release-please-version +const Version = "0.1.0-alpha.65" // x-release-please-version