From 600dadedbb67605a0619ff305b6859ac2d2b22f2 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 07:50:08 +0000 Subject: [PATCH 01/11] feat(api): api update --- .stats.yml | 4 ++-- go.mod | 2 +- go.sum | 4 ++-- pkg/cmd/integration.go | 2 ++ 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.stats.yml b/.stats.yml index 58aca93..68ba24f 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 115 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/xquik%2Fx-twitter-scraper-3b2c6c771ad1da0bbfeb0af115972929ed2c7fcd5e47a79556d66cd21431b224.yml -openapi_spec_hash: de2890233b68387bf5f9b6d19e7d87dc +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/xquik%2Fx-twitter-scraper-93bb7d4f1475c8043af464ec88244a034456c549136c8477f284f0a33192e1c9.yml +openapi_spec_hash: 74dca63c872249274ad99b111dea0833 config_hash: 8894c96caeb6df84c9394518810221bd diff --git a/go.mod b/go.mod index f8b39be..0a461fb 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/Xquik-dev/x-twitter-scraper-cli go 1.25 require ( - github.com/Xquik-dev/x-twitter-scraper-go v0.1.0 + github.com/Xquik-dev/x-twitter-scraper-go v0.2.0 github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.6 github.com/charmbracelet/lipgloss v1.1.0 diff --git a/go.sum b/go.sum index aad4bc2..571d9f6 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/Xquik-dev/x-twitter-scraper-go v0.1.0 h1:7pI1R5oOjgMDKl86ItIM8meUOxtwcmgGVjHctRNXXXo= -github.com/Xquik-dev/x-twitter-scraper-go v0.1.0/go.mod h1:OHW3aIR8E3+ANa/mjFTZs1sG7ePzrBEmW0a8JUN+NvI= +github.com/Xquik-dev/x-twitter-scraper-go v0.2.0 h1:WEn0e9rZEQ+m82tPTuksxhluADV1Rjpj0zJ2LTrQRvs= +github.com/Xquik-dev/x-twitter-scraper-go v0.2.0/go.mod h1:OHW3aIR8E3+ANa/mjFTZs1sG7ePzrBEmW0a8JUN+NvI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= diff --git a/pkg/cmd/integration.go b/pkg/cmd/integration.go index 33ee2ac..3b999a7 100644 --- a/pkg/cmd/integration.go +++ b/pkg/cmd/integration.go @@ -83,6 +83,7 @@ var integrationsUpdate = cli.Command{ }, &requestflag.Flag[map[string]any]{ Name: "filters", + Usage: "Event filter rules (JSON)", BodyPath: "filters", }, &requestflag.Flag[bool]{ @@ -91,6 +92,7 @@ var integrationsUpdate = cli.Command{ }, &requestflag.Flag[map[string]any]{ Name: "message-template", + Usage: "Custom message template (JSON)", BodyPath: "messageTemplate", }, &requestflag.Flag[string]{ From fb087bb19f7783f5c6b0d34a6c800da8d6ff6337 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 03:10:27 +0000 Subject: [PATCH 02/11] fix: handle empty data set using `--format explore` --- internal/jsonview/explorer.go | 4 ++++ internal/jsonview/explorer_test.go | 37 ++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 internal/jsonview/explorer_test.go diff --git a/internal/jsonview/explorer.go b/internal/jsonview/explorer.go index 055541e..ea900bc 100644 --- a/internal/jsonview/explorer.go +++ b/internal/jsonview/explorer.go @@ -406,6 +406,10 @@ func (v *JSONViewer) navigateForward() (tea.Model, tea.Cmd) { return v, nil } + if len(tableView.rowData) < 1 { + return v, nil + } + cursor := tableView.table.Cursor() selected := tableView.rowData[cursor] if !v.canNavigateInto(selected) { diff --git a/internal/jsonview/explorer_test.go b/internal/jsonview/explorer_test.go new file mode 100644 index 0000000..3f0e751 --- /dev/null +++ b/internal/jsonview/explorer_test.go @@ -0,0 +1,37 @@ +package jsonview + +import ( + "testing" + + "github.com/charmbracelet/bubbles/help" + "github.com/tidwall/gjson" +) + +func TestNavigateForward_EmptyRowData(t *testing.T) { + // An empty JSON array produces a TableView with no rows. + emptyArray := gjson.Parse("[]") + view, err := newTableView("", emptyArray, false) + if err != nil { + t.Fatalf("newTableView: %v", err) + } + + viewer := &JSONViewer{ + stack: []JSONView{view}, + root: "test", + help: help.New(), + } + + // Should return without panicking despite the empty data set. + model, cmd := viewer.navigateForward() + if model != viewer { + t.Error("expected same viewer model returned") + } + if cmd != nil { + t.Error("expected nil cmd") + } + + // Stack should remain unchanged (no new view pushed). + if len(viewer.stack) != 1 { + t.Errorf("expected stack length 1, got %d", len(viewer.stack)) + } +} From 9b49da128f9110d987a9174a6fa84419ae337e9b Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 03:10:47 +0000 Subject: [PATCH 03/11] fix: use `RawJSON` when iterating items with `--format explore` in the CLI --- internal/jsonview/explorer.go | 34 ++++++++++++++++++++-- internal/jsonview/explorer_test.go | 45 ++++++++++++++++++++++-------- pkg/cmd/cmdutil.go | 6 ++-- 3 files changed, 68 insertions(+), 17 deletions(-) diff --git a/internal/jsonview/explorer.go b/internal/jsonview/explorer.go index ea900bc..836bb2c 100644 --- a/internal/jsonview/explorer.go +++ b/internal/jsonview/explorer.go @@ -1,6 +1,7 @@ package jsonview import ( + "bytes" "encoding/json" "errors" "fmt" @@ -309,6 +310,10 @@ func ExploreJSON(title string, json gjson.Result) error { return err } +type hasRawJSON interface { + RawJSON() string +} + // ExploreJSONStream explores JSON data loaded incrementally via an iterator func ExploreJSONStream[T any](title string, it Iterator[T]) error { anyIt := genericToAnyIterator(it) @@ -327,12 +332,12 @@ func ExploreJSONStream[T any](title string, it Iterator[T]) error { return err } - // Convert items to JSON array - jsonBytes, err := json.Marshal(items) + arrayJSONBytes, err := marshalItemsToJSONArray(items) if err != nil { return err } - arrayJSON := gjson.ParseBytes(jsonBytes) + + arrayJSON := gjson.ParseBytes(arrayJSONBytes) view, err := newTableView("", arrayJSON, false) if err != nil { return err @@ -352,6 +357,29 @@ func ExploreJSONStream[T any](title string, it Iterator[T]) error { return err } +func marshalItemsToJSONArray(items []any) ([]byte, error) { + var buf bytes.Buffer + buf.WriteByte('[') + + for i, item := range items { + if i > 0 { + buf.WriteByte(',') + } + if hasRaw, ok := item.(hasRawJSON); ok { + buf.WriteString(hasRaw.RawJSON()) + } else { + jsonData, err := json.Marshal(item) + if err != nil { + return nil, err + } + buf.Write(jsonData) + } + } + + buf.WriteByte(']') + return buf.Bytes(), nil +} + func (v *JSONViewer) current() JSONView { return v.stack[len(v.stack)-1] } func (v *JSONViewer) Init() tea.Cmd { return nil } diff --git a/internal/jsonview/explorer_test.go b/internal/jsonview/explorer_test.go index 3f0e751..c559254 100644 --- a/internal/jsonview/explorer_test.go +++ b/internal/jsonview/explorer_test.go @@ -5,15 +5,15 @@ import ( "github.com/charmbracelet/bubbles/help" "github.com/tidwall/gjson" + + "github.com/stretchr/testify/require" ) func TestNavigateForward_EmptyRowData(t *testing.T) { // An empty JSON array produces a TableView with no rows. emptyArray := gjson.Parse("[]") view, err := newTableView("", emptyArray, false) - if err != nil { - t.Fatalf("newTableView: %v", err) - } + require.NoError(t, err) viewer := &JSONViewer{ stack: []JSONView{view}, @@ -23,15 +23,38 @@ func TestNavigateForward_EmptyRowData(t *testing.T) { // Should return without panicking despite the empty data set. model, cmd := viewer.navigateForward() - if model != viewer { - t.Error("expected same viewer model returned") - } - if cmd != nil { - t.Error("expected nil cmd") - } + require.Equal(t, model, viewer, "expected same viewer model returned") + require.Nil(t, cmd) // Stack should remain unchanged (no new view pushed). - if len(viewer.stack) != 1 { - t.Errorf("expected stack length 1, got %d", len(viewer.stack)) + require.Equal(t, 1, len(viewer.stack), "expected stack length 1, got %d", len(viewer.stack)) +} + +// rawJSONItem implements HasRawJSON, returning pre-built JSON. +type rawJSONItem struct { + raw string +} + +func (r rawJSONItem) RawJSON() string { return r.raw } + +func TestMarshalItemsToJSONArray_WithHasRawJSON(t *testing.T) { + items := []any{ + rawJSONItem{raw: `{"id":1,"name":"alice"}`}, + rawJSONItem{raw: `{"id":2,"name":"bob"}`}, } + + got, err := marshalItemsToJSONArray(items) + require.NoError(t, err) + require.JSONEq(t, `[{"id":1,"name":"alice"},{"id":2,"name":"bob"}]`, string(got)) +} + +func TestMarshalItemsToJSONArray_WithoutHasRawJSON(t *testing.T) { + items := []any{ + map[string]any{"id": 1, "name": "alice"}, + map[string]any{"id": 2, "name": "bob"}, + } + + got, err := marshalItemsToJSONArray(items) + require.NoError(t, err) + require.JSONEq(t, `[{"id":1,"name":"alice"},{"id":2,"name":"bob"}]`, string(got)) } diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go index f5e33f6..9ae3805 100644 --- a/pkg/cmd/cmdutil.go +++ b/pkg/cmd/cmdutil.go @@ -375,7 +375,7 @@ func countTerminalLines(data []byte, terminalWidth int) int { return bytes.Count([]byte(wrap.String(string(data), terminalWidth)), []byte("\n")) } -type HasRawJSON interface { +type hasRawJSON interface { RawJSON() string } @@ -401,7 +401,7 @@ func ShowJSONIterator[T any](stdout *os.File, title string, iter jsonview.Iterat for itemsToDisplay != 0 && iter.Next() { item := iter.Current() var obj gjson.Result - if hasRaw, ok := any(item).(HasRawJSON); ok { + if hasRaw, ok := any(item).(hasRawJSON); ok { obj = gjson.Parse(hasRaw.RawJSON()) } else { jsonData, err := json.Marshal(item) @@ -448,7 +448,7 @@ func ShowJSONIterator[T any](stdout *os.File, title string, iter jsonview.Iterat } item := iter.Current() var obj gjson.Result - if hasRaw, ok := any(item).(HasRawJSON); ok { + if hasRaw, ok := any(item).(hasRawJSON); ok { obj = gjson.Parse(hasRaw.RawJSON()) } else { jsonData, err := json.Marshal(item) From 2939f308c9443e7e0fe53e1ac47f43413b6d78a5 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:15:30 +0000 Subject: [PATCH 04/11] feat(api): api update --- .stats.yml | 6 +- pkg/cmd/cmd.go | 34 ------ pkg/cmd/radar_test.go | 2 +- pkg/cmd/style.go | 212 ---------------------------------- pkg/cmd/style_test.go | 86 -------------- pkg/cmd/xtweet.go | 111 ------------------ pkg/cmd/xtweet_test.go | 39 ------- pkg/cmd/xtweetlike.go | 140 ---------------------- pkg/cmd/xtweetlike_test.go | 61 ---------- pkg/cmd/xtweetretweet.go | 140 ---------------------- pkg/cmd/xtweetretweet_test.go | 61 ---------- pkg/cmd/xuser.go | 49 -------- pkg/cmd/xuser_test.go | 13 --- pkg/cmd/xuserfollow.go | 140 ---------------------- pkg/cmd/xuserfollow_test.go | 61 ---------- 15 files changed, 4 insertions(+), 1151 deletions(-) delete mode 100644 pkg/cmd/xtweetlike.go delete mode 100644 pkg/cmd/xtweetlike_test.go delete mode 100644 pkg/cmd/xtweetretweet.go delete mode 100644 pkg/cmd/xtweetretweet_test.go delete mode 100644 pkg/cmd/xuserfollow.go delete mode 100644 pkg/cmd/xuserfollow_test.go diff --git a/.stats.yml b/.stats.yml index 68ba24f..5fb34d8 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 115 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/xquik%2Fx-twitter-scraper-93bb7d4f1475c8043af464ec88244a034456c549136c8477f284f0a33192e1c9.yml -openapi_spec_hash: 74dca63c872249274ad99b111dea0833 +configured_endpoints: 102 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/xquik%2Fx-twitter-scraper-f4a2baf44e99ee3fa87e08d50099bc70680c9ef2e612290ab1f749396266455d.yml +openapi_spec_hash: 1b7655f5b5cc5ffb69e41461cd4d9158 config_hash: 8894c96caeb6df84c9394518810221bd diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 62da7ac..557ee52 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -133,13 +133,9 @@ func init() { Category: "API RESOURCE", Suggest: true, Commands: []*cli.Command{ - &stylesRetrieve, - &stylesUpdate, &stylesList, - &stylesDelete, &stylesAnalyze, &stylesCompare, - &stylesGetPerformance, }, }, { @@ -238,9 +234,7 @@ func init() { Suggest: true, Commands: []*cli.Command{ &xTweetsCreate, - &xTweetsRetrieve, &xTweetsList, - &xTweetsDelete, &xTweetsGetFavoriters, &xTweetsGetQuotes, &xTweetsGetReplies, @@ -249,30 +243,11 @@ func init() { &xTweetsSearch, }, }, - { - Name: "x:tweets:like", - Category: "API RESOURCE", - Suggest: true, - Commands: []*cli.Command{ - &xTweetsLikeCreate, - &xTweetsLikeDelete, - }, - }, - { - Name: "x:tweets:retweet", - Category: "API RESOURCE", - Suggest: true, - Commands: []*cli.Command{ - &xTweetsRetweetCreate, - &xTweetsRetweetDelete, - }, - }, { Name: "x:users", Category: "API RESOURCE", Suggest: true, Commands: []*cli.Command{ - &xUsersRetrieve, &xUsersRetrieveBatch, &xUsersRetrieveFollowers, &xUsersRetrieveFollowersYouKnow, @@ -285,15 +260,6 @@ func init() { &xUsersRetrieveVerifiedFollowers, }, }, - { - Name: "x:users:follow", - Category: "API RESOURCE", - Suggest: true, - Commands: []*cli.Command{ - &xUsersFollowCreate, - &xUsersFollowDeleteAll, - }, - }, { Name: "x:followers", Category: "API RESOURCE", diff --git a/pkg/cmd/radar_test.go b/pkg/cmd/radar_test.go index 60e4177..1174b6a 100644 --- a/pkg/cmd/radar_test.go +++ b/pkg/cmd/radar_test.go @@ -20,7 +20,7 @@ func TestRadarRetrieveTrendingTopics(t *testing.T) { "--count", "0", "--hours", "0", "--region", "region", - "--source", "source", + "--source", "github", ) }) } diff --git a/pkg/cmd/style.go b/pkg/cmd/style.go index bd31149..bfd83a4 100644 --- a/pkg/cmd/style.go +++ b/pkg/cmd/style.go @@ -15,53 +15,6 @@ import ( "github.com/urfave/cli/v3" ) -var stylesRetrieve = cli.Command{ - Name: "retrieve", - Usage: "Get cached style profile", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "username", - Required: true, - }, - }, - Action: handleStylesRetrieve, - HideHelpCommand: true, -} - -var stylesUpdate = requestflag.WithInnerFlags(cli.Command{ - Name: "update", - Usage: "Save style profile with custom tweets", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "username", - Required: true, - }, - &requestflag.Flag[string]{ - Name: "label", - Usage: "Display label for the style", - Required: true, - BodyPath: "label", - }, - &requestflag.Flag[[]map[string]any]{ - Name: "tweet", - Usage: "Array of tweet objects", - Required: true, - BodyPath: "tweets", - }, - }, - Action: handleStylesUpdate, - HideHelpCommand: true, -}, map[string][]requestflag.HasOuterFlag{ - "tweet": { - &requestflag.InnerFlag[string]{ - Name: "tweet.text", - InnerField: "text", - }, - }, -}) - var stylesList = cli.Command{ Name: "list", Usage: "List cached style profiles", @@ -71,20 +24,6 @@ var stylesList = cli.Command{ HideHelpCommand: true, } -var stylesDelete = cli.Command{ - Name: "delete", - Usage: "Delete a style profile", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "username", - Required: true, - }, - }, - Action: handleStylesDelete, - HideHelpCommand: true, -} - var stylesAnalyze = cli.Command{ Name: "analyze", Usage: "Analyze writing style from recent tweets", @@ -123,97 +62,6 @@ var stylesCompare = cli.Command{ HideHelpCommand: true, } -var stylesGetPerformance = cli.Command{ - Name: "get-performance", - Usage: "Get engagement metrics for style tweets", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "username", - Required: true, - }, - }, - Action: handleStylesGetPerformance, - HideHelpCommand: true, -} - -func handleStylesRetrieve(ctx context.Context, cmd *cli.Command) error { - client := xtwitterscraper.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("username") && len(unusedArgs) > 0 { - cmd.Set("username", 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, - EmptyBody, - false, - ) - if err != nil { - return err - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Styles.Get(ctx, cmd.Value("username").(string), options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "styles retrieve", obj, format, transform) -} - -func handleStylesUpdate(ctx context.Context, cmd *cli.Command) error { - client := xtwitterscraper.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("username") && len(unusedArgs) > 0 { - cmd.Set("username", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - params := xtwitterscraper.StyleUpdateParams{} - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Styles.Update( - ctx, - cmd.Value("username").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "styles update", obj, format, transform) -} - func handleStylesList(ctx context.Context, cmd *cli.Command) error { client := xtwitterscraper.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() @@ -246,31 +94,6 @@ func handleStylesList(ctx context.Context, cmd *cli.Command) error { return ShowJSON(os.Stdout, "styles list", obj, format, transform) } -func handleStylesDelete(ctx context.Context, cmd *cli.Command) error { - client := xtwitterscraper.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("username") && len(unusedArgs) > 0 { - cmd.Set("username", 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, - EmptyBody, - false, - ) - if err != nil { - return err - } - - return client.Styles.Delete(ctx, cmd.Value("username").(string), options...) -} - func handleStylesAnalyze(ctx context.Context, cmd *cli.Command) error { client := xtwitterscraper.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() @@ -338,38 +161,3 @@ func handleStylesCompare(ctx context.Context, cmd *cli.Command) error { transform := cmd.Root().String("transform") return ShowJSON(os.Stdout, "styles compare", obj, format, transform) } - -func handleStylesGetPerformance(ctx context.Context, cmd *cli.Command) error { - client := xtwitterscraper.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("username") && len(unusedArgs) > 0 { - cmd.Set("username", 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, - EmptyBody, - false, - ) - if err != nil { - return err - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Styles.GetPerformance(ctx, cmd.Value("username").(string), options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "styles get-performance", obj, format, transform) -} diff --git a/pkg/cmd/style_test.go b/pkg/cmd/style_test.go index 8dbc5ee..f5c3837 100644 --- a/pkg/cmd/style_test.go +++ b/pkg/cmd/style_test.go @@ -6,68 +6,8 @@ import ( "testing" "github.com/Xquik-dev/x-twitter-scraper-cli/internal/mocktest" - "github.com/Xquik-dev/x-twitter-scraper-cli/internal/requestflag" ) -func TestStylesRetrieve(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "--bearer-token", "string", - "styles", "retrieve", - "--username", "username", - ) - }) -} - -func TestStylesUpdate(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "--bearer-token", "string", - "styles", "update", - "--username", "username", - "--label", "label", - "--tweet", "{text: text}", - ) - }) - - t.Run("inner flags", func(t *testing.T) { - // Check that inner flags have been set up correctly - requestflag.CheckInnerFlags(stylesUpdate) - - // Alternative argument passing style using inner flags - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "--bearer-token", "string", - "styles", "update", - "--username", "username", - "--label", "label", - "--tweet.text", "text", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("" + - "label: label\n" + - "tweets:\n" + - " - text: text\n") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "--bearer-token", "string", - "styles", "update", - "--username", "username", - ) - }) -} - func TestStylesList(t *testing.T) { t.Skip("Mock server tests are disabled") t.Run("regular flags", func(t *testing.T) { @@ -80,19 +20,6 @@ func TestStylesList(t *testing.T) { }) } -func TestStylesDelete(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "--bearer-token", "string", - "styles", "delete", - "--username", "username", - ) - }) -} - func TestStylesAnalyze(t *testing.T) { t.Skip("Mock server tests are disabled") t.Run("regular flags", func(t *testing.T) { @@ -130,16 +57,3 @@ func TestStylesCompare(t *testing.T) { ) }) } - -func TestStylesGetPerformance(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "--bearer-token", "string", - "styles", "get-performance", - "--username", "username", - ) - }) -} diff --git a/pkg/cmd/xtweet.go b/pkg/cmd/xtweet.go index 808c90b..6c99c6b 100644 --- a/pkg/cmd/xtweet.go +++ b/pkg/cmd/xtweet.go @@ -56,20 +56,6 @@ var xTweetsCreate = cli.Command{ HideHelpCommand: true, } -var xTweetsRetrieve = cli.Command{ - Name: "retrieve", - Usage: "Look up tweet", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "tweet-id", - Required: true, - }, - }, - Action: handleXTweetsRetrieve, - HideHelpCommand: true, -} - var xTweetsList = cli.Command{ Name: "list", Usage: "Get multiple tweets by IDs", @@ -86,26 +72,6 @@ var xTweetsList = cli.Command{ HideHelpCommand: true, } -var xTweetsDelete = cli.Command{ - Name: "delete", - Usage: "Delete tweet", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "tweet-id", - Required: true, - }, - &requestflag.Flag[string]{ - Name: "account", - Usage: "X account (@username or account ID)", - Required: true, - BodyPath: "account", - }, - }, - Action: handleXTweetsDelete, - HideHelpCommand: true, -} - var xTweetsGetFavoriters = cli.Command{ Name: "get-favoriters", Usage: "Get users who liked a tweet", @@ -303,41 +269,6 @@ func handleXTweetsCreate(ctx context.Context, cmd *cli.Command) error { return ShowJSON(os.Stdout, "x:tweets create", obj, format, transform) } -func handleXTweetsRetrieve(ctx context.Context, cmd *cli.Command) error { - client := xtwitterscraper.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("tweet-id") && len(unusedArgs) > 0 { - cmd.Set("tweet-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, - EmptyBody, - false, - ) - if err != nil { - return err - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.X.Tweets.Get(ctx, cmd.Value("tweet-id").(string), options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "x:tweets retrieve", obj, format, transform) -} - func handleXTweetsList(ctx context.Context, cmd *cli.Command) error { client := xtwitterscraper.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() @@ -362,48 +293,6 @@ func handleXTweetsList(ctx context.Context, cmd *cli.Command) error { return client.X.Tweets.List(ctx, params, options...) } -func handleXTweetsDelete(ctx context.Context, cmd *cli.Command) error { - client := xtwitterscraper.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("tweet-id") && len(unusedArgs) > 0 { - cmd.Set("tweet-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - params := xtwitterscraper.XTweetDeleteParams{} - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.X.Tweets.Delete( - ctx, - cmd.Value("tweet-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "x:tweets delete", obj, format, transform) -} - func handleXTweetsGetFavoriters(ctx context.Context, cmd *cli.Command) error { client := xtwitterscraper.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() diff --git a/pkg/cmd/xtweet_test.go b/pkg/cmd/xtweet_test.go index d4f47bb..5e1391b 100644 --- a/pkg/cmd/xtweet_test.go +++ b/pkg/cmd/xtweet_test.go @@ -46,19 +46,6 @@ func TestXTweetsCreate(t *testing.T) { }) } -func TestXTweetsRetrieve(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "--bearer-token", "string", - "x:tweets", "retrieve", - "--tweet-id", "tweetId", - ) - }) -} - func TestXTweetsList(t *testing.T) { t.Skip("Mock server tests are disabled") t.Run("regular flags", func(t *testing.T) { @@ -72,32 +59,6 @@ func TestXTweetsList(t *testing.T) { }) } -func TestXTweetsDelete(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "--bearer-token", "string", - "x:tweets", "delete", - "--tweet-id", "tweetId", - "--account", "account", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("account: account") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "--bearer-token", "string", - "x:tweets", "delete", - "--tweet-id", "tweetId", - ) - }) -} - func TestXTweetsGetFavoriters(t *testing.T) { t.Skip("Mock server tests are disabled") t.Run("regular flags", func(t *testing.T) { diff --git a/pkg/cmd/xtweetlike.go b/pkg/cmd/xtweetlike.go deleted file mode 100644 index 4031ad8..0000000 --- a/pkg/cmd/xtweetlike.go +++ /dev/null @@ -1,140 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "context" - "fmt" - "os" - - "github.com/Xquik-dev/x-twitter-scraper-cli/internal/apiquery" - "github.com/Xquik-dev/x-twitter-scraper-cli/internal/requestflag" - "github.com/Xquik-dev/x-twitter-scraper-go" - "github.com/Xquik-dev/x-twitter-scraper-go/option" - "github.com/tidwall/gjson" - "github.com/urfave/cli/v3" -) - -var xTweetsLikeCreate = cli.Command{ - Name: "create", - Usage: "Like tweet", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "tweet-id", - Required: true, - }, - &requestflag.Flag[string]{ - Name: "account", - Usage: "X account (@username or account ID)", - Required: true, - BodyPath: "account", - }, - }, - Action: handleXTweetsLikeCreate, - HideHelpCommand: true, -} - -var xTweetsLikeDelete = cli.Command{ - Name: "delete", - Usage: "Unlike tweet", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "tweet-id", - Required: true, - }, - &requestflag.Flag[string]{ - Name: "account", - Usage: "X account (@username or account ID)", - Required: true, - BodyPath: "account", - }, - }, - Action: handleXTweetsLikeDelete, - HideHelpCommand: true, -} - -func handleXTweetsLikeCreate(ctx context.Context, cmd *cli.Command) error { - client := xtwitterscraper.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("tweet-id") && len(unusedArgs) > 0 { - cmd.Set("tweet-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - params := xtwitterscraper.XTweetLikeNewParams{} - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.X.Tweets.Like.New( - ctx, - cmd.Value("tweet-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "x:tweets:like create", obj, format, transform) -} - -func handleXTweetsLikeDelete(ctx context.Context, cmd *cli.Command) error { - client := xtwitterscraper.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("tweet-id") && len(unusedArgs) > 0 { - cmd.Set("tweet-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - params := xtwitterscraper.XTweetLikeDeleteParams{} - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.X.Tweets.Like.Delete( - ctx, - cmd.Value("tweet-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "x:tweets:like delete", obj, format, transform) -} diff --git a/pkg/cmd/xtweetlike_test.go b/pkg/cmd/xtweetlike_test.go deleted file mode 100644 index 520c8b0..0000000 --- a/pkg/cmd/xtweetlike_test.go +++ /dev/null @@ -1,61 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "testing" - - "github.com/Xquik-dev/x-twitter-scraper-cli/internal/mocktest" -) - -func TestXTweetsLikeCreate(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "--bearer-token", "string", - "x:tweets:like", "create", - "--tweet-id", "tweetId", - "--account", "account", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("account: account") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "--bearer-token", "string", - "x:tweets:like", "create", - "--tweet-id", "tweetId", - ) - }) -} - -func TestXTweetsLikeDelete(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "--bearer-token", "string", - "x:tweets:like", "delete", - "--tweet-id", "tweetId", - "--account", "account", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("account: account") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "--bearer-token", "string", - "x:tweets:like", "delete", - "--tweet-id", "tweetId", - ) - }) -} diff --git a/pkg/cmd/xtweetretweet.go b/pkg/cmd/xtweetretweet.go deleted file mode 100644 index 8780b2c..0000000 --- a/pkg/cmd/xtweetretweet.go +++ /dev/null @@ -1,140 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "context" - "fmt" - "os" - - "github.com/Xquik-dev/x-twitter-scraper-cli/internal/apiquery" - "github.com/Xquik-dev/x-twitter-scraper-cli/internal/requestflag" - "github.com/Xquik-dev/x-twitter-scraper-go" - "github.com/Xquik-dev/x-twitter-scraper-go/option" - "github.com/tidwall/gjson" - "github.com/urfave/cli/v3" -) - -var xTweetsRetweetCreate = cli.Command{ - Name: "create", - Usage: "Retweet", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "tweet-id", - Required: true, - }, - &requestflag.Flag[string]{ - Name: "account", - Usage: "X account (@username or account ID)", - Required: true, - BodyPath: "account", - }, - }, - Action: handleXTweetsRetweetCreate, - HideHelpCommand: true, -} - -var xTweetsRetweetDelete = cli.Command{ - Name: "delete", - Usage: "Unretweet", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "tweet-id", - Required: true, - }, - &requestflag.Flag[string]{ - Name: "account", - Usage: "X account (@username or account ID)", - Required: true, - BodyPath: "account", - }, - }, - Action: handleXTweetsRetweetDelete, - HideHelpCommand: true, -} - -func handleXTweetsRetweetCreate(ctx context.Context, cmd *cli.Command) error { - client := xtwitterscraper.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("tweet-id") && len(unusedArgs) > 0 { - cmd.Set("tweet-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - params := xtwitterscraper.XTweetRetweetNewParams{} - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.X.Tweets.Retweet.New( - ctx, - cmd.Value("tweet-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "x:tweets:retweet create", obj, format, transform) -} - -func handleXTweetsRetweetDelete(ctx context.Context, cmd *cli.Command) error { - client := xtwitterscraper.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("tweet-id") && len(unusedArgs) > 0 { - cmd.Set("tweet-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - params := xtwitterscraper.XTweetRetweetDeleteParams{} - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.X.Tweets.Retweet.Delete( - ctx, - cmd.Value("tweet-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "x:tweets:retweet delete", obj, format, transform) -} diff --git a/pkg/cmd/xtweetretweet_test.go b/pkg/cmd/xtweetretweet_test.go deleted file mode 100644 index f8027f1..0000000 --- a/pkg/cmd/xtweetretweet_test.go +++ /dev/null @@ -1,61 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "testing" - - "github.com/Xquik-dev/x-twitter-scraper-cli/internal/mocktest" -) - -func TestXTweetsRetweetCreate(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "--bearer-token", "string", - "x:tweets:retweet", "create", - "--tweet-id", "tweetId", - "--account", "account", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("account: account") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "--bearer-token", "string", - "x:tweets:retweet", "create", - "--tweet-id", "tweetId", - ) - }) -} - -func TestXTweetsRetweetDelete(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "--bearer-token", "string", - "x:tweets:retweet", "delete", - "--tweet-id", "tweetId", - "--account", "account", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("account: account") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "--bearer-token", "string", - "x:tweets:retweet", "delete", - "--tweet-id", "tweetId", - ) - }) -} diff --git a/pkg/cmd/xuser.go b/pkg/cmd/xuser.go index 4539d97..e38654c 100644 --- a/pkg/cmd/xuser.go +++ b/pkg/cmd/xuser.go @@ -15,20 +15,6 @@ import ( "github.com/urfave/cli/v3" ) -var xUsersRetrieve = cli.Command{ - Name: "retrieve", - Usage: "Look up X user", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "username", - Required: true, - }, - }, - Action: handleXUsersRetrieve, - HideHelpCommand: true, -} - var xUsersRetrieveBatch = cli.Command{ Name: "retrieve-batch", Usage: "Get multiple users by IDs", @@ -250,41 +236,6 @@ var xUsersRetrieveVerifiedFollowers = cli.Command{ HideHelpCommand: true, } -func handleXUsersRetrieve(ctx context.Context, cmd *cli.Command) error { - client := xtwitterscraper.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("username") && len(unusedArgs) > 0 { - cmd.Set("username", 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, - EmptyBody, - false, - ) - if err != nil { - return err - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.X.Users.Get(ctx, cmd.Value("username").(string), options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "x:users retrieve", obj, format, transform) -} - func handleXUsersRetrieveBatch(ctx context.Context, cmd *cli.Command) error { client := xtwitterscraper.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() diff --git a/pkg/cmd/xuser_test.go b/pkg/cmd/xuser_test.go index 0e26e13..62d816a 100644 --- a/pkg/cmd/xuser_test.go +++ b/pkg/cmd/xuser_test.go @@ -8,19 +8,6 @@ import ( "github.com/Xquik-dev/x-twitter-scraper-cli/internal/mocktest" ) -func TestXUsersRetrieve(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "--bearer-token", "string", - "x:users", "retrieve", - "--username", "username", - ) - }) -} - func TestXUsersRetrieveBatch(t *testing.T) { t.Skip("Mock server tests are disabled") t.Run("regular flags", func(t *testing.T) { diff --git a/pkg/cmd/xuserfollow.go b/pkg/cmd/xuserfollow.go deleted file mode 100644 index f7b4f94..0000000 --- a/pkg/cmd/xuserfollow.go +++ /dev/null @@ -1,140 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "context" - "fmt" - "os" - - "github.com/Xquik-dev/x-twitter-scraper-cli/internal/apiquery" - "github.com/Xquik-dev/x-twitter-scraper-cli/internal/requestflag" - "github.com/Xquik-dev/x-twitter-scraper-go" - "github.com/Xquik-dev/x-twitter-scraper-go/option" - "github.com/tidwall/gjson" - "github.com/urfave/cli/v3" -) - -var xUsersFollowCreate = cli.Command{ - Name: "create", - Usage: "Follow user", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "user-id", - Required: true, - }, - &requestflag.Flag[string]{ - Name: "account", - Usage: "X account (@username or account ID)", - Required: true, - BodyPath: "account", - }, - }, - Action: handleXUsersFollowCreate, - HideHelpCommand: true, -} - -var xUsersFollowDeleteAll = cli.Command{ - Name: "delete-all", - Usage: "Unfollow user", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "user-id", - Required: true, - }, - &requestflag.Flag[string]{ - Name: "account", - Usage: "X account (@username or account ID)", - Required: true, - BodyPath: "account", - }, - }, - Action: handleXUsersFollowDeleteAll, - HideHelpCommand: true, -} - -func handleXUsersFollowCreate(ctx context.Context, cmd *cli.Command) error { - client := xtwitterscraper.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("user-id") && len(unusedArgs) > 0 { - cmd.Set("user-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - params := xtwitterscraper.XUserFollowNewParams{} - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.X.Users.Follow.New( - ctx, - cmd.Value("user-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "x:users:follow create", obj, format, transform) -} - -func handleXUsersFollowDeleteAll(ctx context.Context, cmd *cli.Command) error { - client := xtwitterscraper.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("user-id") && len(unusedArgs) > 0 { - cmd.Set("user-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - params := xtwitterscraper.XUserFollowDeleteAllParams{} - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.X.Users.Follow.DeleteAll( - ctx, - cmd.Value("user-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "x:users:follow delete-all", obj, format, transform) -} diff --git a/pkg/cmd/xuserfollow_test.go b/pkg/cmd/xuserfollow_test.go deleted file mode 100644 index 5355e76..0000000 --- a/pkg/cmd/xuserfollow_test.go +++ /dev/null @@ -1,61 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "testing" - - "github.com/Xquik-dev/x-twitter-scraper-cli/internal/mocktest" -) - -func TestXUsersFollowCreate(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "--bearer-token", "string", - "x:users:follow", "create", - "--user-id", "userId", - "--account", "account", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("account: account") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "--bearer-token", "string", - "x:users:follow", "create", - "--user-id", "userId", - ) - }) -} - -func TestXUsersFollowDeleteAll(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "--bearer-token", "string", - "x:users:follow", "delete-all", - "--user-id", "userId", - "--account", "account", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("account: account") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "--bearer-token", "string", - "x:users:follow", "delete-all", - "--user-id", "userId", - ) - }) -} From da4814940e36b880079d24765ed2a8e2b0e9cd74 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 18:15:38 +0000 Subject: [PATCH 05/11] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 5fb34d8..6527155 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 102 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/xquik%2Fx-twitter-scraper-f4a2baf44e99ee3fa87e08d50099bc70680c9ef2e612290ab1f749396266455d.yml -openapi_spec_hash: 1b7655f5b5cc5ffb69e41461cd4d9158 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/xquik%2Fx-twitter-scraper-46a9af86900d469595bc007c73410022306d0863a896758d9c3c1250fbe937a3.yml +openapi_spec_hash: d858851b15eb367466f343da3cb2d556 config_hash: 8894c96caeb6df84c9394518810221bd From ce4ff99c2fd457bd041238b36255282dec76aa1a Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 03:14:41 +0000 Subject: [PATCH 06/11] feat: binary-only parameters become CLI flags that take filenames only --- internal/requestflag/requestflag.go | 10 +++ pkg/cmd/flagoptions.go | 96 +++++++++++++++++++++++++++++ pkg/cmd/xmedia.go | 9 +-- pkg/cmd/xmedia_test.go | 10 ++- pkg/cmd/xprofile.go | 18 +++--- pkg/cmd/xprofile_test.go | 19 ++++-- 6 files changed, 141 insertions(+), 21 deletions(-) diff --git a/internal/requestflag/requestflag.go b/internal/requestflag/requestflag.go index 32c13f5..bdef64f 100644 --- a/internal/requestflag/requestflag.go +++ b/internal/requestflag/requestflag.go @@ -43,6 +43,11 @@ type Flag[ // parameters. Const bool + // FileInput, when true, indicates that the flag value is always treated as a file path. The file is read + // automatically without requiring the "@" prefix. This is used for parameters with `type: string, format: + // binary` in the OpenAPI spec. + FileInput bool + // unexported fields for internal use count int // number of times the flag has been set hasBeenSet bool // whether the flag has been set from env or file @@ -59,6 +64,7 @@ type InRequest interface { GetHeaderPath() string GetBodyPath() string IsBodyRoot() bool + IsFileInput() bool } func (f Flag[T]) GetQueryPath() string { @@ -77,6 +83,10 @@ func (f Flag[T]) IsBodyRoot() bool { return f.BodyRoot } +func (f Flag[T]) IsFileInput() bool { + return f.FileInput +} + // The values that will be sent in different parts of a request. type RequestContents struct { Queries map[string]any diff --git a/pkg/cmd/flagoptions.go b/pkg/cmd/flagoptions.go index 2808ffb..8c830e0 100644 --- a/pkg/cmd/flagoptions.go +++ b/pkg/cmd/flagoptions.go @@ -98,6 +98,21 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value, return result, nil case reflect.String: + // FilePathValue is always treated as a file path without needing the "@" prefix. + // These only appear on binary upload parameters (multipart/octet-stream), which + // always use EmbedIOReader. + if v.Type() == reflect.TypeOf(FilePathValue("")) { + s := v.String() + if s == "" { + return v, nil + } + content, err := os.ReadFile(s) + if err != nil { + return v, err + } + return reflect.ValueOf(string(content)), nil + } + s := v.String() if literal, ok := strings.CutPrefix(s, "\\@"); ok { // Allow for escaped @ signs if you don't want them to be treated as files @@ -258,6 +273,12 @@ func flagOptions( } } + // For flags marked as FileInput (type: string, format: binary), the value is always + // a file path. Wrap with FilePathValue so embedFiles reads the file automatically + // without requiring the user to type the "@" prefix. This handles both values set + // via explicit CLI flags and values that arrived via piped YAML/JSON data. + wrapFileInputValues(cmd, &requestContents) + // Embed files passed as "@file.jpg" in the request body, headers, and query: embedStyle := EmbedText if bodyType == ApplicationOctetStream || bodyType == MultipartFormEncoded { @@ -371,3 +392,78 @@ func flagOptions( return options, nil } + +// FilePathValue is a string wrapper that marks a value as a file path whose contents should be read +// and embedded in the request. Unlike a regular string, embedFilesValue always treats a FilePathValue +// as a file path without needing the "@" prefix. +type FilePathValue string + +// wrapFileInputValues replaces string values for FileInput flags (type: string, format: binary) with +// FilePathValue sentinel values. embedFilesValue recognizes FilePathValue and reads the file contents +// directly, so the user doesn't need to type the "@" prefix. This handles both values set via explicit +// CLI flags and values that arrived via piped YAML/JSON data. +func wrapFileInputValues(cmd *cli.Command, contents *requestflag.RequestContents) { + bodyMap, _ := contents.Body.(map[string]any) + + for _, flag := range cmd.Flags { + inReq, ok := flag.(requestflag.InRequest) + if !ok || !inReq.IsFileInput() || inReq.IsBodyRoot() { + continue + } + + // Wrap values set via explicit CLI flags. + if flag.IsSet() { + if wrapped, changed := wrapFileInputValue(flag.Get()); changed { + if bodyPath := inReq.GetBodyPath(); bodyPath != "" { + if bodyMap != nil { + bodyMap[bodyPath] = wrapped + } + } else if queryPath := inReq.GetQueryPath(); queryPath != "" { + contents.Queries[queryPath] = wrapped + } else if headerPath := inReq.GetHeaderPath(); headerPath != "" { + contents.Headers[headerPath] = wrapped + } + } + } + + // Wrap values that arrived via piped YAML/JSON data in the body map. + if bodyPath := inReq.GetBodyPath(); bodyPath != "" && bodyMap != nil { + if value, exists := bodyMap[bodyPath]; exists { + if wrapped, changed := wrapFileInputValue(value); changed { + bodyMap[bodyPath] = wrapped + } + } + } + } +} + +func wrapFileInputValue(value any) (any, bool) { + switch v := value.(type) { + case string: + if v == "" { + return value, false + } + return FilePathValue(v), true + + case []string: + result := make([]any, len(v)) + for i, s := range v { + result[i] = FilePathValue(s) + } + return result, true + + case []any: + result := make([]any, len(v)) + for i, elem := range v { + if s, ok := elem.(string); ok { + result[i] = FilePathValue(s) + } else { + result[i] = elem + } + } + return result, true + + default: + return value, false + } +} diff --git a/pkg/cmd/xmedia.go b/pkg/cmd/xmedia.go index b87eeca..9554b60 100644 --- a/pkg/cmd/xmedia.go +++ b/pkg/cmd/xmedia.go @@ -47,10 +47,11 @@ var xMediaUpload = cli.Command{ BodyPath: "account", }, &requestflag.Flag[string]{ - Name: "file", - Usage: "Media file to upload", - Required: true, - BodyPath: "file", + Name: "file", + Usage: "Media file to upload", + Required: true, + BodyPath: "file", + FileInput: true, }, &requestflag.Flag[bool]{ Name: "is-long-video", diff --git a/pkg/cmd/xmedia_test.go b/pkg/cmd/xmedia_test.go index 0328ed4..38072da 100644 --- a/pkg/cmd/xmedia_test.go +++ b/pkg/cmd/xmedia_test.go @@ -3,6 +3,7 @@ package cmd import ( + "strings" "testing" "github.com/Xquik-dev/x-twitter-scraper-cli/internal/mocktest" @@ -45,17 +46,20 @@ func TestXMediaUpload(t *testing.T) { "--bearer-token", "string", "x:media", "upload", "--account", "account", - "--file", "Example data", + "--file", mocktest.TestFile(t, "Example data"), "--is-long-video=true", ) }) t.Run("piping data", func(t *testing.T) { + testFile := mocktest.TestFile(t, "Example data") // Test piping YAML data over stdin - pipeData := []byte("" + + pipeDataStr := "" + "account: account\n" + "file: Example data\n" + - "is_long_video: true\n") + "is_long_video: true\n" + pipeDataStr = strings.ReplaceAll(pipeDataStr, "Example data", testFile) + pipeData := []byte(pipeDataStr) mocktest.TestRunMockTestWithPipeAndFlags( t, pipeData, "--api-key", "string", diff --git a/pkg/cmd/xprofile.go b/pkg/cmd/xprofile.go index 2007646..d12dc02 100644 --- a/pkg/cmd/xprofile.go +++ b/pkg/cmd/xprofile.go @@ -62,10 +62,11 @@ var xProfileUpdateAvatar = cli.Command{ BodyPath: "account", }, &requestflag.Flag[string]{ - Name: "file", - Usage: "Avatar image (max 716KB)", - Required: true, - BodyPath: "file", + Name: "file", + Usage: "Avatar image (max 716KB)", + Required: true, + BodyPath: "file", + FileInput: true, }, }, Action: handleXProfileUpdateAvatar, @@ -84,10 +85,11 @@ var xProfileUpdateBanner = cli.Command{ BodyPath: "account", }, &requestflag.Flag[string]{ - Name: "file", - Usage: "Banner image (max 2MB)", - Required: true, - BodyPath: "file", + Name: "file", + Usage: "Banner image (max 2MB)", + Required: true, + BodyPath: "file", + FileInput: true, }, }, Action: handleXProfileUpdateBanner, diff --git a/pkg/cmd/xprofile_test.go b/pkg/cmd/xprofile_test.go index d0c54cb..62ed2be 100644 --- a/pkg/cmd/xprofile_test.go +++ b/pkg/cmd/xprofile_test.go @@ -3,6 +3,7 @@ package cmd import ( + "strings" "testing" "github.com/Xquik-dev/x-twitter-scraper-cli/internal/mocktest" @@ -50,15 +51,18 @@ func TestXProfileUpdateAvatar(t *testing.T) { "--bearer-token", "string", "x:profile", "update-avatar", "--account", "account", - "--file", "Example data", + "--file", mocktest.TestFile(t, "Example data"), ) }) t.Run("piping data", func(t *testing.T) { + testFile := mocktest.TestFile(t, "Example data") // Test piping YAML data over stdin - pipeData := []byte("" + + pipeDataStr := "" + "account: account\n" + - "file: Example data\n") + "file: Example data\n" + pipeDataStr = strings.ReplaceAll(pipeDataStr, "Example data", testFile) + pipeData := []byte(pipeDataStr) mocktest.TestRunMockTestWithPipeAndFlags( t, pipeData, "--api-key", "string", @@ -77,15 +81,18 @@ func TestXProfileUpdateBanner(t *testing.T) { "--bearer-token", "string", "x:profile", "update-banner", "--account", "account", - "--file", "Example data", + "--file", mocktest.TestFile(t, "Example data"), ) }) t.Run("piping data", func(t *testing.T) { + testFile := mocktest.TestFile(t, "Example data") // Test piping YAML data over stdin - pipeData := []byte("" + + pipeDataStr := "" + "account: account\n" + - "file: Example data\n") + "file: Example data\n" + pipeDataStr = strings.ReplaceAll(pipeDataStr, "Example data", testFile) + pipeData := []byte(pipeDataStr) mocktest.TestRunMockTestWithPipeAndFlags( t, pipeData, "--api-key", "string", From 07a40054367cdbd2ebba05fc271f0f8924adc3b3 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 03:16:00 +0000 Subject: [PATCH 07/11] feat: better error message if scheme forgotten in CLI `*_BASE_URL`/`--base-url` --- cmd/x-twitter-scraper/main.go | 7 +++++++ pkg/cmd/cmd.go | 3 +++ pkg/cmd/cmdutil.go | 9 ++++++++ pkg/cmd/cmdutil_test.go | 39 +++++++++++++++++++++++++++++++++++ 4 files changed, 58 insertions(+) diff --git a/cmd/x-twitter-scraper/main.go b/cmd/x-twitter-scraper/main.go index e41ef15..783786f 100644 --- a/cmd/x-twitter-scraper/main.go +++ b/cmd/x-twitter-scraper/main.go @@ -23,6 +23,13 @@ func main() { prepareForAutocomplete(app) } + if baseURL, ok := os.LookupEnv("X_TWITTER_SCRAPER_BASE_URL"); ok { + if err := cmd.ValidateBaseURL(baseURL, "X_TWITTER_SCRAPER_BASE_URL"); err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err.Error()) + os.Exit(1) + } + } + if err := app.Run(context.Background(), os.Args); err != nil { exitCode := 1 diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 557ee52..9f19955 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -39,6 +39,9 @@ func init() { Name: "base-url", DefaultText: "url", Usage: "Override the base URL for API requests", + Validator: func(baseURL string) error { + return ValidateBaseURL(baseURL, "--base-url") + }, }, &cli.StringFlag{ Name: "format", diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go index 9ae3805..799b80f 100644 --- a/pkg/cmd/cmdutil.go +++ b/pkg/cmd/cmdutil.go @@ -29,6 +29,15 @@ import ( var OutputFormats = []string{"auto", "explore", "json", "jsonl", "pretty", "raw", "yaml"} +// ValidateBaseURL checks that a base URL is correctly prefixed with a protocol scheme and produces a better +// error message than the person would see otherwise if it doesn't. +func ValidateBaseURL(value, source string) error { + if value != "" && !strings.HasPrefix(value, "http://") && !strings.HasPrefix(value, "https://") { + return fmt.Errorf("%s %q is missing a scheme (expected http:// or https://)", source, value) + } + return nil +} + func getDefaultRequestOptions(cmd *cli.Command) []option.RequestOption { opts := []option.RequestOption{ option.WithHeader("User-Agent", fmt.Sprintf("XTwitterScraper/CLI %s", Version)), diff --git a/pkg/cmd/cmdutil_test.go b/pkg/cmd/cmdutil_test.go index 0a46fd1..8487408 100644 --- a/pkg/cmd/cmdutil_test.go +++ b/pkg/cmd/cmdutil_test.go @@ -125,3 +125,42 @@ func TestCreateDownloadFile(t *testing.T) { assert.Equal(t, "passwd", filepath.Base(file.Name())) }) } + +func TestValidateBaseURL(t *testing.T) { + t.Parallel() + + t.Run("ValidHTTPS", func(t *testing.T) { + t.Parallel() + + require.NoError(t, ValidateBaseURL("https://api.example.com", "--base-url")) + }) + + t.Run("ValidHTTP", func(t *testing.T) { + t.Parallel() + + require.NoError(t, ValidateBaseURL("http://localhost:8080", "--base-url")) + }) + + t.Run("Empty", func(t *testing.T) { + t.Parallel() + + require.NoError(t, ValidateBaseURL("", "MY_BASE_URL")) + }) + + t.Run("MissingScheme", func(t *testing.T) { + t.Parallel() + + err := ValidateBaseURL("localhost:8080", "MY_BASE_URL") + require.Error(t, err) + assert.Contains(t, err.Error(), "MY_BASE_URL") + assert.Contains(t, err.Error(), "missing a scheme") + }) + + t.Run("HostOnly", func(t *testing.T) { + t.Parallel() + + err := ValidateBaseURL("api.example.com", "--base-url") + require.Error(t, err) + assert.Contains(t, err.Error(), "--base-url") + }) +} From 712746c9735661862689301f04ff1a90ebac1d8a Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 03:16:38 +0000 Subject: [PATCH 08/11] feat: allow `-` as value representing stdin to binary-only file parameters in CLIs --- pkg/cmd/flagoptions.go | 103 +++++++++++++++++++++++++++++++++--- pkg/cmd/flagoptions_test.go | 91 ++++++++++++++++++++++++++++++- 2 files changed, 184 insertions(+), 10 deletions(-) diff --git a/pkg/cmd/flagoptions.go b/pkg/cmd/flagoptions.go index 8c830e0..8db0a4d 100644 --- a/pkg/cmd/flagoptions.go +++ b/pkg/cmd/flagoptions.go @@ -40,12 +40,48 @@ const ( EmbedIOReader ) -func embedFiles(obj any, embedStyle FileEmbedStyle) (any, error) { +// onceStdinReader wraps an io.Reader that can only be consumed once, used to ensure stdin is read by at most +// one parameter (or only for a body root parameter or only for YAML parameter input). If reason is set, stdin +// is unavailable and read() returns an error explaining why. +type onceStdinReader struct { + stdinReader io.Reader + failureReason string +} + +func (o *onceStdinReader) read() (io.Reader, error) { + if o.failureReason != "" { + return nil, fmt.Errorf("cannot read from stdin: %s", o.failureReason) + } + if o.stdinReader == nil { + return nil, fmt.Errorf("stdin has already been read by another parameter; it can only be read once") + } + r := o.stdinReader + o.stdinReader = nil + return r, nil +} + +func (o *onceStdinReader) readAll() ([]byte, error) { + r, err := o.read() + if err != nil { + return nil, err + } + return io.ReadAll(r) +} + +func isStdinPath(s string) bool { + switch s { + case "-", "/dev/fd/0", "/dev/stdin": + return true + } + return false +} + +func embedFiles(obj any, embedStyle FileEmbedStyle, stdin *onceStdinReader) (any, error) { if obj == nil { return obj, nil } v := reflect.ValueOf(obj) - result, err := embedFilesValue(v, embedStyle) + result, err := embedFilesValue(v, embedStyle, stdin) if err != nil { return nil, err } @@ -53,7 +89,7 @@ func embedFiles(obj any, embedStyle FileEmbedStyle) (any, error) { } // Replace "@file.txt" with the file's contents inside a value -func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value, error) { +func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle, stdin *onceStdinReader) (reflect.Value, error) { // Unwrap interface values to get the concrete type if v.Kind() == reflect.Interface { if v.IsNil() { @@ -74,7 +110,7 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value, for iter.Next() { key := iter.Key() val := iter.Value() - newVal, err := embedFilesValue(val, embedStyle) + newVal, err := embedFilesValue(val, embedStyle, stdin) if err != nil { return reflect.Value{}, err } @@ -89,7 +125,7 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value, // Use `[]any` to allow for types to change when embedding files result := reflect.MakeSlice(reflect.TypeOf([]any{}), v.Len(), v.Len()) for i := 0; i < v.Len(); i++ { - newVal, err := embedFilesValue(v.Index(i), embedStyle) + newVal, err := embedFilesValue(v.Index(i), embedStyle, stdin) if err != nil { return reflect.Value{}, err } @@ -106,6 +142,13 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value, if s == "" { return v, nil } + if isStdinPath(s) { + content, err := stdin.readAll() + if err != nil { + return v, err + } + return reflect.ValueOf(string(content)), nil + } content, err := os.ReadFile(s) if err != nil { return v, err @@ -123,6 +166,13 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value, if filename, ok := strings.CutPrefix(s, "@data://"); ok { // The "@data://" prefix is for files you explicitly want to upload // as base64-encoded (even if the file itself is plain text) + if isStdinPath(filename) { + content, err := stdin.readAll() + if err != nil { + return v, err + } + return reflect.ValueOf(base64.StdEncoding.EncodeToString(content)), nil + } content, err := os.ReadFile(filename) if err != nil { return v, err @@ -132,12 +182,29 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value, // The "@file://" prefix is for files that you explicitly want to // upload as a string literal with backslash escapes (not base64 // encoded) + if isStdinPath(filename) { + content, err := stdin.readAll() + if err != nil { + return v, err + } + return reflect.ValueOf(string(content)), nil + } content, err := os.ReadFile(filename) if err != nil { return v, err } return reflect.ValueOf(string(content)), nil } else if filename, ok := strings.CutPrefix(s, "@"); ok { + if isStdinPath(filename) { + content, err := stdin.readAll() + if err != nil { + return v, err + } + if isUTF8TextFile(content) { + return reflect.ValueOf(string(content)), nil + } + return reflect.ValueOf(base64.StdEncoding.EncodeToString(content)), nil + } content, err := os.ReadFile(filename) if err != nil { // If the string is "@username", it's probably supposed to be a @@ -175,6 +242,14 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value, expectsFile = strings.Contains(filename, ".") || strings.Contains(filename, "/") } + if isStdinPath(filename) { + r, err := stdin.read() + if err != nil { + return v, err + } + return reflect.ValueOf(io.NopCloser(r)), nil + } + file, err := os.Open(filename) if err != nil { if !expectsFile { @@ -234,6 +309,7 @@ func flagOptions( requestContents := requestflag.ExtractRequestContents(cmd) + stdinConsumedByPipe := false if (bodyType == MultipartFormEncoded || bodyType == ApplicationJSON) && !ignoreStdin && isInputPiped() { pipeData, err := io.ReadAll(os.Stdin) if err != nil { @@ -241,6 +317,7 @@ func flagOptions( } if len(pipeData) > 0 { + stdinConsumedByPipe = true var bodyData any if err := yaml.Unmarshal(pipeData, &bodyData); err != nil { return nil, fmt.Errorf("Failed to parse piped data as YAML/JSON:\n%w", err) @@ -279,24 +356,34 @@ func flagOptions( // via explicit CLI flags and values that arrived via piped YAML/JSON data. wrapFileInputValues(cmd, &requestContents) + // Determine stdin availability for FileInput params that use "-". + var stdinReader onceStdinReader + if ignoreStdin { + stdinReader = onceStdinReader{failureReason: "stdin is already being used for the request body"} + } else if stdinConsumedByPipe { + stdinReader = onceStdinReader{failureReason: "stdin was already consumed by piped YAML/JSON input"} + } else { + stdinReader = onceStdinReader{stdinReader: os.Stdin} + } + // Embed files passed as "@file.jpg" in the request body, headers, and query: embedStyle := EmbedText if bodyType == ApplicationOctetStream || bodyType == MultipartFormEncoded { embedStyle = EmbedIOReader } - if embedded, err := embedFiles(requestContents.Body, embedStyle); err != nil { + if embedded, err := embedFiles(requestContents.Body, embedStyle, &stdinReader); err != nil { return nil, err } else { requestContents.Body = embedded } - if headersWithFiles, err := embedFiles(requestContents.Headers, EmbedText); err != nil { + if headersWithFiles, err := embedFiles(requestContents.Headers, EmbedText, &stdinReader); err != nil { return nil, err } else { requestContents.Headers = headersWithFiles.(map[string]any) } - if queriesWithFiles, err := embedFiles(requestContents.Queries, EmbedText); err != nil { + if queriesWithFiles, err := embedFiles(requestContents.Queries, EmbedText, &stdinReader); err != nil { return nil, err } else { requestContents.Queries = queriesWithFiles.(map[string]any) diff --git a/pkg/cmd/flagoptions_test.go b/pkg/cmd/flagoptions_test.go index e5dad4b..9a7fe3b 100644 --- a/pkg/cmd/flagoptions_test.go +++ b/pkg/cmd/flagoptions_test.go @@ -2,8 +2,10 @@ package cmd import ( "encoding/base64" + "io" "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -11,6 +13,7 @@ import ( ) func TestIsUTF8TextFile(t *testing.T) { + tests := []struct { content []byte expected bool @@ -32,6 +35,7 @@ func TestIsUTF8TextFile(t *testing.T) { } func TestEmbedFiles(t *testing.T) { + // Create temporary directory for test files tmpDir := t.TempDir() @@ -216,7 +220,8 @@ func TestEmbedFiles(t *testing.T) { for _, tt := range tests { t.Run(tt.name+" text", func(t *testing.T) { - got, err := embedFiles(tt.input, EmbedText) + + got, err := embedFiles(tt.input, EmbedText, nil) if tt.wantErr { assert.Error(t, err) } else { @@ -226,7 +231,8 @@ func TestEmbedFiles(t *testing.T) { }) t.Run(tt.name+" io.Reader", func(t *testing.T) { - _, err := embedFiles(tt.input, EmbedIOReader) + + _, err := embedFiles(tt.input, EmbedIOReader, nil) if tt.wantErr { assert.Error(t, err) } else { @@ -236,9 +242,90 @@ func TestEmbedFiles(t *testing.T) { } } +func TestEmbedFilesStdin(t *testing.T) { + + t.Run("FilePathValueDash", func(t *testing.T) { + + stdin := &onceStdinReader{stdinReader: strings.NewReader("stdin content")} + + withEmbedded, err := embedFiles(map[string]any{"file": FilePathValue("-")}, EmbedText, stdin) + require.NoError(t, err) + require.Equal(t, map[string]any{"file": "stdin content"}, withEmbedded) + }) + + t.Run("FilePathValueDevStdin", func(t *testing.T) { + + stdin := &onceStdinReader{stdinReader: strings.NewReader("stdin content")} + + withEmbedded, err := embedFiles(map[string]any{"file": FilePathValue("/dev/stdin")}, EmbedText, stdin) + require.NoError(t, err) + require.Equal(t, map[string]any{"file": "stdin content"}, withEmbedded) + }) + + t.Run("MultipleFilePathValueDashesError", func(t *testing.T) { + + stdin := &onceStdinReader{stdinReader: strings.NewReader("stdin content")} + + _, err := embedFiles(map[string]any{ + "file1": FilePathValue("-"), + "file2": FilePathValue("-"), + }, EmbedText, stdin) + require.Error(t, err) + require.Contains(t, err.Error(), "already been read") + }) + + t.Run("FilePathValueDashUnavailableStdin", func(t *testing.T) { + + stdin := &onceStdinReader{failureReason: "stdin is already being used for the request body"} + + _, err := embedFiles(map[string]any{"file": FilePathValue("-")}, EmbedText, stdin) + require.Error(t, err) + require.Contains(t, err.Error(), "cannot read from stdin") + require.Contains(t, err.Error(), "request body") + }) + + t.Run("AtDashEmbedText", func(t *testing.T) { + + stdin := &onceStdinReader{stdinReader: strings.NewReader("piped content")} + + withEmbedded, err := embedFiles(map[string]any{"data": "@-"}, EmbedText, stdin) + require.NoError(t, err) + require.Equal(t, map[string]any{"data": "piped content"}, withEmbedded) + }) + + t.Run("AtDashEmbedIOReader", func(t *testing.T) { + + stdin := &onceStdinReader{stdinReader: strings.NewReader("piped content")} + + withEmbedded, err := embedFiles(map[string]any{"data": "@-"}, EmbedIOReader, stdin) + require.NoError(t, err) + + withEmbeddedMap := withEmbedded.(map[string]any) + r := withEmbeddedMap["data"].(io.ReadCloser) + + content, err := io.ReadAll(r) + require.NoError(t, err) + require.Equal(t, "piped content", string(content)) + }) + + t.Run("FilePathValueRealFile", func(t *testing.T) { + + tmpDir := t.TempDir() + writeTestFile(t, tmpDir, "test.txt", "file content") + + stdin := &onceStdinReader{stdinReader: strings.NewReader("unused stdin")} + + withEmbedded, err := embedFiles(map[string]any{"file": FilePathValue(filepath.Join(tmpDir, "test.txt"))}, EmbedText, stdin) + require.NoError(t, err) + require.Equal(t, map[string]any{"file": "file content"}, withEmbedded) + }) +} + func writeTestFile(t *testing.T, dir, filename, content string) { t.Helper() + path := filepath.Join(dir, filename) + err := os.WriteFile(path, []byte(content), 0644) require.NoError(t, err, "failed to write test file %s", path) } From 445ffb42a38c6b8cb90a18dbf377e1949e1b0fde Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 03:16:58 +0000 Subject: [PATCH 09/11] chore: switch some CLI Go tests from `os.Chdir` to `t.Chdir` --- pkg/cmd/cmdutil_test.go | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/pkg/cmd/cmdutil_test.go b/pkg/cmd/cmdutil_test.go index 8487408..550c995 100644 --- a/pkg/cmd/cmdutil_test.go +++ b/pkg/cmd/cmdutil_test.go @@ -67,10 +67,7 @@ func TestWriteBinaryResponse(t *testing.T) { func TestCreateDownloadFile(t *testing.T) { t.Run("creates file with filename from header", func(t *testing.T) { - tmpDir := t.TempDir() - oldWd, _ := os.Getwd() - os.Chdir(tmpDir) - defer os.Chdir(oldWd) + t.Chdir(t.TempDir()) resp := &http.Response{ Header: http.Header{ @@ -96,10 +93,7 @@ func TestCreateDownloadFile(t *testing.T) { }) t.Run("creates temp file when no header", func(t *testing.T) { - tmpDir := t.TempDir() - oldWd, _ := os.Getwd() - os.Chdir(tmpDir) - defer os.Chdir(oldWd) + t.Chdir(t.TempDir()) resp := &http.Response{Header: http.Header{}} file, err := createDownloadFile(resp, []byte("test content")) @@ -109,10 +103,7 @@ func TestCreateDownloadFile(t *testing.T) { }) t.Run("prevents directory traversal", func(t *testing.T) { - tmpDir := t.TempDir() - oldWd, _ := os.Getwd() - os.Chdir(tmpDir) - defer os.Chdir(oldWd) + t.Chdir(t.TempDir()) resp := &http.Response{ Header: http.Header{ From 273bb76c4ca2923aaf4ca99ad527b1ebb93f6be9 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 03:17:17 +0000 Subject: [PATCH 10/11] chore: mark all CLI-related tests in Go with `t.Parallel()` --- internal/apiform/form_test.go | 4 ++ internal/apiquery/query_test.go | 4 ++ internal/autocomplete/autocomplete_test.go | 40 +++++++++++++++ internal/jsonview/explorer_test.go | 6 +++ internal/requestflag/innerflag_test.go | 28 +++++++++++ internal/requestflag/requestflag_test.go | 58 ++++++++++++++++++++++ pkg/cmd/flagoptions_test.go | 12 +++++ 7 files changed, 152 insertions(+) diff --git a/internal/apiform/form_test.go b/internal/apiform/form_test.go index 2cf5bdd..f68cfd1 100644 --- a/internal/apiform/form_test.go +++ b/internal/apiform/form_test.go @@ -85,8 +85,12 @@ var tests = map[string]struct { } func TestEncode(t *testing.T) { + t.Parallel() + for name, test := range tests { t.Run(name, func(t *testing.T) { + t.Parallel() + buf := bytes.NewBuffer(nil) writer := multipart.NewWriter(buf) writer.SetBoundary("xxx") diff --git a/internal/apiquery/query_test.go b/internal/apiquery/query_test.go index 8bee784..3791ec9 100644 --- a/internal/apiquery/query_test.go +++ b/internal/apiquery/query_test.go @@ -6,6 +6,8 @@ import ( ) func TestEncode(t *testing.T) { + t.Parallel() + tests := map[string]struct { val any settings QuerySettings @@ -114,6 +116,8 @@ func TestEncode(t *testing.T) { for name, test := range tests { t.Run(name, func(t *testing.T) { + t.Parallel() + query := map[string]any{"query": test.val} values, err := MarshalWithSettings(query, test.settings) if err != nil { diff --git a/internal/autocomplete/autocomplete_test.go b/internal/autocomplete/autocomplete_test.go index 3e8aa33..2338924 100644 --- a/internal/autocomplete/autocomplete_test.go +++ b/internal/autocomplete/autocomplete_test.go @@ -8,6 +8,8 @@ import ( ) func TestGetCompletions_EmptyArgs(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "generate", Usage: "Generate SDK"}, @@ -26,6 +28,8 @@ func TestGetCompletions_EmptyArgs(t *testing.T) { } func TestGetCompletions_SubcommandPrefix(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "generate", Usage: "Generate SDK"}, @@ -43,6 +47,8 @@ func TestGetCompletions_SubcommandPrefix(t *testing.T) { } func TestGetCompletions_HiddenCommand(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "visible", Usage: "Visible command"}, @@ -57,6 +63,8 @@ func TestGetCompletions_HiddenCommand(t *testing.T) { } func TestGetCompletions_NestedSubcommand(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ { @@ -79,6 +87,8 @@ func TestGetCompletions_NestedSubcommand(t *testing.T) { } func TestGetCompletions_FlagCompletion(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ { @@ -102,6 +112,8 @@ func TestGetCompletions_FlagCompletion(t *testing.T) { } func TestGetCompletions_ShortFlagCompletion(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ { @@ -123,6 +135,8 @@ func TestGetCompletions_ShortFlagCompletion(t *testing.T) { } func TestGetCompletions_FileFlagBehavior(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ { @@ -142,6 +156,8 @@ func TestGetCompletions_FileFlagBehavior(t *testing.T) { } func TestGetCompletions_NonBoolFlagValue(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ { @@ -161,6 +177,8 @@ func TestGetCompletions_NonBoolFlagValue(t *testing.T) { } func TestGetCompletions_BoolFlagDoesNotBlockCompletion(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ { @@ -185,6 +203,8 @@ func TestGetCompletions_BoolFlagDoesNotBlockCompletion(t *testing.T) { } func TestGetCompletions_ColonCommands_NoColonTyped(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "config:get", Usage: "Get config value"}, @@ -202,6 +222,8 @@ func TestGetCompletions_ColonCommands_NoColonTyped(t *testing.T) { } func TestGetCompletions_ColonCommands_ColonTyped_Bash(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "config:get", Usage: "Get config value"}, @@ -221,6 +243,8 @@ func TestGetCompletions_ColonCommands_ColonTyped_Bash(t *testing.T) { } func TestGetCompletions_ColonCommands_ColonTyped_Zsh(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "config:get", Usage: "Get config value"}, @@ -240,6 +264,8 @@ func TestGetCompletions_ColonCommands_ColonTyped_Zsh(t *testing.T) { } func TestGetCompletions_BashStyleColonCompletion(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "config:get", Usage: "Get config value"}, @@ -257,6 +283,8 @@ func TestGetCompletions_BashStyleColonCompletion(t *testing.T) { } func TestGetCompletions_BashStyleColonCompletion_NoMatch(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "config:get", Usage: "Get config value"}, @@ -271,6 +299,8 @@ func TestGetCompletions_BashStyleColonCompletion_NoMatch(t *testing.T) { } func TestGetCompletions_ZshStyleColonCompletion(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "config:get", Usage: "Get config value"}, @@ -287,6 +317,8 @@ func TestGetCompletions_ZshStyleColonCompletion(t *testing.T) { } func TestGetCompletions_MixedColonAndRegularCommands(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "generate", Usage: "Generate SDK"}, @@ -305,6 +337,8 @@ func TestGetCompletions_MixedColonAndRegularCommands(t *testing.T) { } func TestGetCompletions_FlagWithBoolFlagSkipsValue(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ { @@ -329,6 +363,8 @@ func TestGetCompletions_FlagWithBoolFlagSkipsValue(t *testing.T) { } func TestGetCompletions_MultipleFlagsBeforeSubcommand(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ { @@ -353,6 +389,8 @@ func TestGetCompletions_MultipleFlagsBeforeSubcommand(t *testing.T) { } func TestGetCompletions_CommandAliases(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "generate", Aliases: []string{"gen", "g"}, Usage: "Generate SDK"}, @@ -372,6 +410,8 @@ func TestGetCompletions_CommandAliases(t *testing.T) { } func TestGetCompletions_AllFlagsWhenNoPrefix(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ { diff --git a/internal/jsonview/explorer_test.go b/internal/jsonview/explorer_test.go index c559254..67ee730 100644 --- a/internal/jsonview/explorer_test.go +++ b/internal/jsonview/explorer_test.go @@ -10,6 +10,8 @@ import ( ) func TestNavigateForward_EmptyRowData(t *testing.T) { + t.Parallel() + // An empty JSON array produces a TableView with no rows. emptyArray := gjson.Parse("[]") view, err := newTableView("", emptyArray, false) @@ -38,6 +40,8 @@ type rawJSONItem struct { func (r rawJSONItem) RawJSON() string { return r.raw } func TestMarshalItemsToJSONArray_WithHasRawJSON(t *testing.T) { + t.Parallel() + items := []any{ rawJSONItem{raw: `{"id":1,"name":"alice"}`}, rawJSONItem{raw: `{"id":2,"name":"bob"}`}, @@ -49,6 +53,8 @@ func TestMarshalItemsToJSONArray_WithHasRawJSON(t *testing.T) { } func TestMarshalItemsToJSONArray_WithoutHasRawJSON(t *testing.T) { + t.Parallel() + items := []any{ map[string]any{"id": 1, "name": "alice"}, map[string]any{"id": 2, "name": "bob"}, diff --git a/internal/requestflag/innerflag_test.go b/internal/requestflag/innerflag_test.go index 3f204c9..133e8b4 100644 --- a/internal/requestflag/innerflag_test.go +++ b/internal/requestflag/innerflag_test.go @@ -8,6 +8,8 @@ import ( ) func TestInnerFlagSet(t *testing.T) { + t.Parallel() + tests := []struct { name string flagType string @@ -27,6 +29,8 @@ func TestInnerFlagSet(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + outerFlag := &Flag[map[string]any]{ Name: "test-flag", } @@ -81,6 +85,8 @@ func TestInnerFlagSet(t *testing.T) { } func TestInnerFlagValidator(t *testing.T) { + t.Parallel() + outerFlag := &Flag[map[string]any]{Name: "test-flag"} innerFlag := &InnerFlag[int64]{ @@ -105,6 +111,8 @@ func TestInnerFlagValidator(t *testing.T) { } func TestWithInnerFlags(t *testing.T) { + t.Parallel() + outerFlag := &Flag[map[string]any]{Name: "outer"} innerFlag := &InnerFlag[string]{ Name: "outer.baz", @@ -126,6 +134,8 @@ func TestWithInnerFlags(t *testing.T) { } func TestInnerFlagTypeNames(t *testing.T) { + t.Parallel() + tests := []struct { name string flag cli.DocGenerationFlag @@ -143,6 +153,8 @@ func TestInnerFlagTypeNames(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + typeName := tt.flag.TypeName() assert.Equal(t, tt.expected, typeName, "Expected type name %q, got %q", tt.expected, typeName) }) @@ -150,8 +162,12 @@ func TestInnerFlagTypeNames(t *testing.T) { } func TestInnerYamlHandling(t *testing.T) { + t.Parallel() + // Test with map value t.Run("Parse YAML to map", func(t *testing.T) { + t.Parallel() + outerFlag := &Flag[map[string]any]{Name: "outer"} innerFlag := &InnerFlag[map[string]any]{ Name: "outer.baz", @@ -176,6 +192,8 @@ func TestInnerYamlHandling(t *testing.T) { // Test with invalid YAML t.Run("Parse invalid YAML", func(t *testing.T) { + t.Parallel() + outerFlag := &Flag[map[string]any]{Name: "outer"} innerFlag := &InnerFlag[map[string]any]{ Name: "outer.baz", @@ -190,6 +208,8 @@ func TestInnerYamlHandling(t *testing.T) { // Test setting inner flags on a map multiple times t.Run("Set inner flags on map multiple times", func(t *testing.T) { + t.Parallel() + outerFlag := &Flag[map[string]any]{Name: "outer"} // Set first inner flag @@ -219,6 +239,8 @@ func TestInnerYamlHandling(t *testing.T) { // Test setting YAML and then an inner flag t.Run("Set YAML and then inner flag", func(t *testing.T) { + t.Parallel() + outerFlag := &Flag[map[string]any]{Name: "outer"} // First set the outer flag with YAML @@ -246,7 +268,11 @@ func TestInnerYamlHandling(t *testing.T) { } func TestInnerFlagWithSliceType(t *testing.T) { + t.Parallel() + t.Run("Setting inner flags on slice of maps", func(t *testing.T) { + t.Parallel() + outerFlag := &Flag[[]map[string]any]{Name: "outer"} // Set first inner flag (should create first item) @@ -284,6 +310,8 @@ func TestInnerFlagWithSliceType(t *testing.T) { }) t.Run("Appending to existing slice", func(t *testing.T) { + t.Parallel() + // Initialize with existing items outerFlag := &Flag[[]map[string]any]{Name: "outer"} err := outerFlag.Set(outerFlag.Name, `{name: initial}`) diff --git a/internal/requestflag/requestflag_test.go b/internal/requestflag/requestflag_test.go index 9751904..0e86e07 100644 --- a/internal/requestflag/requestflag_test.go +++ b/internal/requestflag/requestflag_test.go @@ -11,6 +11,8 @@ import ( ) func TestDateValueParse(t *testing.T) { + t.Parallel() + tests := []struct { name string input string @@ -56,6 +58,8 @@ func TestDateValueParse(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var d DateValue err := d.Parse(tt.input) @@ -70,6 +74,8 @@ func TestDateValueParse(t *testing.T) { } func TestDateTimeValueParse(t *testing.T) { + t.Parallel() + tests := []struct { name string input string @@ -119,6 +125,8 @@ func TestDateTimeValueParse(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var d DateTimeValue err := d.Parse(tt.input) @@ -136,6 +144,8 @@ func TestDateTimeValueParse(t *testing.T) { } func TestTimeValueParse(t *testing.T) { + t.Parallel() + tests := []struct { name string input string @@ -181,6 +191,8 @@ func TestTimeValueParse(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var tv TimeValue err := tv.Parse(tt.input) @@ -195,7 +207,11 @@ func TestTimeValueParse(t *testing.T) { } func TestRequestParams(t *testing.T) { + t.Parallel() + t.Run("map body type", func(t *testing.T) { + t.Parallel() + // Create a mock command with flags cmd := &cli.Command{ Name: "test", @@ -283,6 +299,8 @@ func TestRequestParams(t *testing.T) { }) t.Run("non-map body type", func(t *testing.T) { + t.Parallel() + // Create a mock command with flags cmd := &cli.Command{ Name: "test", @@ -304,6 +322,8 @@ func TestRequestParams(t *testing.T) { } func TestFlagSet(t *testing.T) { + t.Parallel() + strFlag := &Flag[string]{ Name: "string-flag", Default: "default-string", @@ -327,38 +347,52 @@ func TestFlagSet(t *testing.T) { // Test initialization and setting t.Run("PreParse initialization", func(t *testing.T) { + t.Parallel() + assert.NoError(t, strFlag.PreParse()) assert.True(t, strFlag.applied) assert.Equal(t, "default-string", strFlag.Get()) }) t.Run("Set string flag", func(t *testing.T) { + t.Parallel() + assert.NoError(t, strFlag.Set("string-flag", "new-value")) assert.Equal(t, "new-value", strFlag.Get()) assert.True(t, strFlag.IsSet()) }) t.Run("Set int flag with valid value", func(t *testing.T) { + t.Parallel() + assert.NoError(t, superstitiousIntFlag.Set("int-flag", "100")) assert.Equal(t, int64(100), superstitiousIntFlag.Get()) assert.True(t, superstitiousIntFlag.IsSet()) }) t.Run("Set int flag with invalid value", func(t *testing.T) { + t.Parallel() + assert.Error(t, superstitiousIntFlag.Set("int-flag", "not-an-int")) }) t.Run("Set int flag with validator failing", func(t *testing.T) { + t.Parallel() + assert.Error(t, superstitiousIntFlag.Set("int-flag", "13")) }) t.Run("Set bool flag", func(t *testing.T) { + t.Parallel() + assert.NoError(t, boolFlag.Set("bool-flag", "true")) assert.Equal(t, true, boolFlag.Get()) assert.True(t, boolFlag.IsSet()) }) t.Run("Set slice flag with multiple values", func(t *testing.T) { + t.Parallel() + sliceFlag := &Flag[[]int64]{ Name: "slice-flag", Default: []int64{}, @@ -381,6 +415,8 @@ func TestFlagSet(t *testing.T) { }) t.Run("Set slice flag with a nonempty default", func(t *testing.T) { + t.Parallel() + sliceFlag := &Flag[[]int64]{ Name: "slice-flag", Default: []int64{99, 100}, @@ -400,6 +436,8 @@ func TestFlagSet(t *testing.T) { } func TestParseTimeWithFormats(t *testing.T) { + t.Parallel() + tests := []struct { name string input string @@ -439,6 +477,8 @@ func TestParseTimeWithFormats(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := parseTimeWithFormats(tt.input, tt.formats) if tt.wantErr { @@ -452,8 +492,12 @@ func TestParseTimeWithFormats(t *testing.T) { } func TestYamlHandling(t *testing.T) { + t.Parallel() + // Test with any value t.Run("Parse YAML to any", func(t *testing.T) { + t.Parallel() + cv := &cliValue[any]{} err := cv.Set("name: test\nvalue: 42\n") assert.NoError(t, err) @@ -478,6 +522,8 @@ func TestYamlHandling(t *testing.T) { // Test with array t.Run("Parse YAML array", func(t *testing.T) { + t.Parallel() + cv := &cliValue[any]{} err := cv.Set("- item1\n- item2\n- item3\n") assert.NoError(t, err) @@ -495,6 +541,8 @@ func TestYamlHandling(t *testing.T) { }) t.Run("Parse @file.txt as YAML", func(t *testing.T) { + t.Parallel() + flag := &Flag[any]{ Name: "file-flag", Default: nil, @@ -507,6 +555,8 @@ func TestYamlHandling(t *testing.T) { }) t.Run("Parse @file.txt list as YAML", func(t *testing.T) { + t.Parallel() + flag := &Flag[[]any]{ Name: "file-flag", Default: nil, @@ -520,6 +570,8 @@ func TestYamlHandling(t *testing.T) { }) t.Run("Parse identifiers as YAML", func(t *testing.T) { + t.Parallel() + tests := []string{ "hello", "e4e355fa-b03b-4c57-a73d-25c9733eec79", @@ -555,6 +607,8 @@ func TestYamlHandling(t *testing.T) { // Test with invalid YAML t.Run("Parse invalid YAML", func(t *testing.T) { + t.Parallel() + invalidYaml := `[not closed` cv := &cliValue[any]{} err := cv.Set(invalidYaml) @@ -563,6 +617,8 @@ func TestYamlHandling(t *testing.T) { } func TestFlagTypeNames(t *testing.T) { + t.Parallel() + tests := []struct { name string flag cli.DocGenerationFlag @@ -583,6 +639,8 @@ func TestFlagTypeNames(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + typeName := tt.flag.TypeName() assert.Equal(t, tt.expected, typeName, "Expected type name %q, got %q", tt.expected, typeName) }) diff --git a/pkg/cmd/flagoptions_test.go b/pkg/cmd/flagoptions_test.go index 9a7fe3b..039b9ff 100644 --- a/pkg/cmd/flagoptions_test.go +++ b/pkg/cmd/flagoptions_test.go @@ -13,6 +13,7 @@ import ( ) func TestIsUTF8TextFile(t *testing.T) { + t.Parallel() tests := []struct { content []byte @@ -35,6 +36,7 @@ func TestIsUTF8TextFile(t *testing.T) { } func TestEmbedFiles(t *testing.T) { + t.Parallel() // Create temporary directory for test files tmpDir := t.TempDir() @@ -220,6 +222,7 @@ func TestEmbedFiles(t *testing.T) { for _, tt := range tests { t.Run(tt.name+" text", func(t *testing.T) { + t.Parallel() got, err := embedFiles(tt.input, EmbedText, nil) if tt.wantErr { @@ -231,6 +234,7 @@ func TestEmbedFiles(t *testing.T) { }) t.Run(tt.name+" io.Reader", func(t *testing.T) { + t.Parallel() _, err := embedFiles(tt.input, EmbedIOReader, nil) if tt.wantErr { @@ -243,8 +247,10 @@ func TestEmbedFiles(t *testing.T) { } func TestEmbedFilesStdin(t *testing.T) { + t.Parallel() t.Run("FilePathValueDash", func(t *testing.T) { + t.Parallel() stdin := &onceStdinReader{stdinReader: strings.NewReader("stdin content")} @@ -254,6 +260,7 @@ func TestEmbedFilesStdin(t *testing.T) { }) t.Run("FilePathValueDevStdin", func(t *testing.T) { + t.Parallel() stdin := &onceStdinReader{stdinReader: strings.NewReader("stdin content")} @@ -263,6 +270,7 @@ func TestEmbedFilesStdin(t *testing.T) { }) t.Run("MultipleFilePathValueDashesError", func(t *testing.T) { + t.Parallel() stdin := &onceStdinReader{stdinReader: strings.NewReader("stdin content")} @@ -275,6 +283,7 @@ func TestEmbedFilesStdin(t *testing.T) { }) t.Run("FilePathValueDashUnavailableStdin", func(t *testing.T) { + t.Parallel() stdin := &onceStdinReader{failureReason: "stdin is already being used for the request body"} @@ -285,6 +294,7 @@ func TestEmbedFilesStdin(t *testing.T) { }) t.Run("AtDashEmbedText", func(t *testing.T) { + t.Parallel() stdin := &onceStdinReader{stdinReader: strings.NewReader("piped content")} @@ -294,6 +304,7 @@ func TestEmbedFilesStdin(t *testing.T) { }) t.Run("AtDashEmbedIOReader", func(t *testing.T) { + t.Parallel() stdin := &onceStdinReader{stdinReader: strings.NewReader("piped content")} @@ -309,6 +320,7 @@ func TestEmbedFilesStdin(t *testing.T) { }) t.Run("FilePathValueRealFile", func(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() writeTestFile(t, tmpDir, "test.txt", "file content") From 555919188e0cfd2a0e70f57884bc0b5af9f8f454 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 03:17:31 +0000 Subject: [PATCH 11/11] release: 0.3.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 24 ++++++++++++++++++++++++ pkg/cmd/version.go | 2 +- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 10f3091..6b7b74c 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.2.0" + ".": "0.3.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b79b72..e87f0eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## 0.3.0 (2026-04-03) + +Full Changelog: [v0.2.0...v0.3.0](https://github.com/Xquik-dev/x-twitter-scraper-cli/compare/v0.2.0...v0.3.0) + +### Features + +* allow `-` as value representing stdin to binary-only file parameters in CLIs ([712746c](https://github.com/Xquik-dev/x-twitter-scraper-cli/commit/712746c9735661862689301f04ff1a90ebac1d8a)) +* **api:** api update ([2939f30](https://github.com/Xquik-dev/x-twitter-scraper-cli/commit/2939f308c9443e7e0fe53e1ac47f43413b6d78a5)) +* **api:** api update ([600dade](https://github.com/Xquik-dev/x-twitter-scraper-cli/commit/600dadedbb67605a0619ff305b6859ac2d2b22f2)) +* better error message if scheme forgotten in CLI `*_BASE_URL`/`--base-url` ([07a4005](https://github.com/Xquik-dev/x-twitter-scraper-cli/commit/07a40054367cdbd2ebba05fc271f0f8924adc3b3)) +* binary-only parameters become CLI flags that take filenames only ([ce4ff99](https://github.com/Xquik-dev/x-twitter-scraper-cli/commit/ce4ff99c2fd457bd041238b36255282dec76aa1a)) + + +### Bug Fixes + +* handle empty data set using `--format explore` ([fb087bb](https://github.com/Xquik-dev/x-twitter-scraper-cli/commit/fb087bb19f7783f5c6b0d34a6c800da8d6ff6337)) +* use `RawJSON` when iterating items with `--format explore` in the CLI ([9b49da1](https://github.com/Xquik-dev/x-twitter-scraper-cli/commit/9b49da128f9110d987a9174a6fa84419ae337e9b)) + + +### Chores + +* mark all CLI-related tests in Go with `t.Parallel()` ([273bb76](https://github.com/Xquik-dev/x-twitter-scraper-cli/commit/273bb76c4ca2923aaf4ca99ad527b1ebb93f6be9)) +* switch some CLI Go tests from `os.Chdir` to `t.Chdir` ([445ffb4](https://github.com/Xquik-dev/x-twitter-scraper-cli/commit/445ffb42a38c6b8cb90a18dbf377e1949e1b0fde)) + ## 0.2.0 (2026-04-01) Full Changelog: [v0.1.0...v0.2.0](https://github.com/Xquik-dev/x-twitter-scraper-cli/compare/v0.1.0...v0.2.0) diff --git a/pkg/cmd/version.go b/pkg/cmd/version.go index 10d2893..5266c84 100644 --- a/pkg/cmd/version.go +++ b/pkg/cmd/version.go @@ -2,4 +2,4 @@ package cmd -const Version = "0.2.0" // x-release-please-version +const Version = "0.3.0" // x-release-please-version