diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 5547f83..10f3091 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.1.1" + ".": "0.2.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 5de4fdd..33feccb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## 0.2.0 (2026-03-05) + +Full Changelog: [v0.1.1...v0.2.0](https://github.com/beeper/desktop-api-cli/compare/v0.1.1...v0.2.0) + +### Features + +* add `--max-items` flag for paginated/streaming endpoints ([d2cd184](https://github.com/beeper/desktop-api-cli/commit/d2cd184ffe8bd8bef28411f35c23c9e0bbed958f)) +* add support for file downloads from binary response endpoints ([2e0b0a7](https://github.com/beeper/desktop-api-cli/commit/2e0b0a7080adef772b1c2b9f33d957f267d354ad)) +* improved documentation and flags for client options ([46e772c](https://github.com/beeper/desktop-api-cli/commit/46e772c72af1ea406d17ddaa7aeaf58e8b917a16)) +* support passing required body params through pipes ([4aa3f99](https://github.com/beeper/desktop-api-cli/commit/4aa3f993788d31566b0035a34134b4745ccd6643)) + + +### Bug Fixes + +* add missing client parameter flags to test cases ([83e3537](https://github.com/beeper/desktop-api-cli/commit/83e35370ae87614bba0c4deacdafcbd6614ed4ee)) +* avoid printing usage errors twice ([62fbdae](https://github.com/beeper/desktop-api-cli/commit/62fbdaedc3cf1724fc9102eae7dcb87e148aabb7)) +* fix for encoding arrays with `any` type items ([148ce2c](https://github.com/beeper/desktop-api-cli/commit/148ce2c1a8fe9b284fb29a4397c8904a0e366824)) +* more gracefully handle empty stdin input ([d758018](https://github.com/beeper/desktop-api-cli/commit/d75801861b11dfd1cb5568e2b5da3eb0176b7c21)) + + +### Chores + +* **internal:** codegen related update ([2fcd536](https://github.com/beeper/desktop-api-cli/commit/2fcd53660f6195309b786d5b50aaeb22595a5404)) +* **test:** do not count install time for mock server timeout ([c89d312](https://github.com/beeper/desktop-api-cli/commit/c89d31244a0735738964709d9c15961c2f9a2a71)) +* zip READMEs as part of build artifact ([d1a1267](https://github.com/beeper/desktop-api-cli/commit/d1a12679c7c0366a8a50972010874bc9e9a6d643)) + ## 0.1.1 (2026-02-25) Full Changelog: [v0.1.0...v0.1.1](https://github.com/beeper/desktop-api-cli/compare/v0.1.0...v0.1.1) diff --git a/README.md b/README.md index 018d3f6..24b5143 100644 --- a/README.md +++ b/README.md @@ -53,25 +53,22 @@ beeper-desktop-cli [resource] [flags...] ```sh beeper-desktop-cli chats search \ - --account-id local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc \ - --account-id local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI \ - --cursor '1725489123456|c29tZUltc2dQYWdl' \ - --direction before \ - --inbox primary \ --include-muted \ - --last-activity-after 2019-12-27T18:11:19.117Z \ - --last-activity-before 2019-12-27T18:11:19.117Z \ --limit 3 \ - --query x \ - --scope titles \ - --type single \ - --unread-only + --type single ``` For details about specific commands, use the `--help` flag. -### Global Flags +### Environment variables +| Environment variable | Description | Required | +| --------------------- | ----------------------------------------------------------------------------------------------------- | -------- | +| `BEEPER_ACCESS_TOKEN` | Bearer access token obtained via OAuth2 PKCE flow or created in-app. Required for all API operations. | yes | + +### Global flags + +- `--access-token` - Bearer access token obtained via OAuth2 PKCE flow or created in-app. Required for all API operations. (can also be set with `BEEPER_ACCESS_TOKEN` env var) - `--help` - Show command line usage - `--debug` - Enable debug logging (includes HTTP request/response details) - `--version`, `-v` - Show the CLI version diff --git a/cmd/beeper-desktop-cli/main.go b/cmd/beeper-desktop-cli/main.go index 1a40218..e27225a 100644 --- a/cmd/beeper-desktop-cli/main.go +++ b/cmd/beeper-desktop-cli/main.go @@ -42,7 +42,11 @@ func main() { fmt.Fprintf(os.Stderr, "%s\n", err.Error()) } } else { - fmt.Fprintf(os.Stderr, "%s\n", err.Error()) + if cmd.CommandErrorBuffer.Len() > 0 { + os.Stderr.Write(cmd.CommandErrorBuffer.Bytes()) + } else { + fmt.Fprintf(os.Stderr, "%s\n", err.Error()) + } } os.Exit(exitCode) } diff --git a/internal/apiform/encoder.go b/internal/apiform/encoder.go index 16ca44b..857fe14 100644 --- a/internal/apiform/encoder.go +++ b/internal/apiform/encoder.go @@ -101,6 +101,15 @@ func (e *encoder) encodeArray(key string, val reflect.Value, writer *multipart.W var values []string for i := 0; i < val.Len(); i++ { item := val.Index(i) + if (item.Kind() == reflect.Pointer || item.Kind() == reflect.Interface) && item.IsNil() { + // Null values are sent as an empty string + values = append(values, "") + continue + } + // If item is an interface, reduce it to the concrete type + if item.Kind() == reflect.Interface { + item = item.Elem() + } var strValue string switch item.Kind() { case reflect.String: diff --git a/internal/binaryparam/binary_param.go b/internal/binaryparam/binary_param.go index 79357a3..40d4ecf 100644 --- a/internal/binaryparam/binary_param.go +++ b/internal/binaryparam/binary_param.go @@ -17,7 +17,7 @@ func FileOrStdin(stdin io.ReadCloser, path string) (io.ReadCloser, bool, error) // When the special glyph "-" is used, read from stdin. Although probably less necessary, also support // special Unix files that refer to stdin. switch path { - case stdinGlyph, "/dev/fd/0", "/dev/stdin": + case "", stdinGlyph, "/dev/fd/0", "/dev/stdin": return stdin, true, nil } diff --git a/internal/binaryparam/binary_param_test.go b/internal/binaryparam/binary_param_test.go index bdac3e9..7a66682 100644 --- a/internal/binaryparam/binary_param_test.go +++ b/internal/binaryparam/binary_param_test.go @@ -28,57 +28,32 @@ func TestFileOrStdin(t *testing.T) { require.False(t, stdinInUse) }) - t.Run("WithStdinGlyph", func(t *testing.T) { - tempFile := t.TempDir() + "/test_file.txt" - require.NoError(t, os.WriteFile(tempFile, []byte(expectedContents), 0600)) - - stubStdin, err := os.Open(tempFile) - require.NoError(t, err) - t.Cleanup(func() { require.NoError(t, stubStdin.Close()) }) - - readCloser, stdinInUse, err := FileOrStdin(stubStdin, "-") - require.NoError(t, err) - - actualContents, err := io.ReadAll(readCloser) - require.NoError(t, err) - require.Equal(t, expectedContents, string(actualContents)) - - require.True(t, stdinInUse) - }) - - t.Run("WithDevFD0File", func(t *testing.T) { - tempFile := t.TempDir() + "/dev_fd_0" - require.NoError(t, os.WriteFile(tempFile, []byte(expectedContents), 0600)) - - stubStdin, err := os.Open(tempFile) - require.NoError(t, err) - t.Cleanup(func() { require.NoError(t, stubStdin.Close()) }) - - readCloser, stdinInUse, err := FileOrStdin(stubStdin, "/dev/fd/0") - require.NoError(t, err) - - actualContents, err := io.ReadAll(readCloser) - require.NoError(t, err) - require.Equal(t, expectedContents, string(actualContents)) - - require.True(t, stdinInUse) - }) - - t.Run("WithDevStdinFile", func(t *testing.T) { - tempFile := t.TempDir() + "/dev_stdin" - require.NoError(t, os.WriteFile(tempFile, []byte(expectedContents), 0600)) - - stubStdin, err := os.Open(tempFile) - require.NoError(t, err) - t.Cleanup(func() { require.NoError(t, stubStdin.Close()) }) - - readCloser, stdinInUse, err := FileOrStdin(stubStdin, "/dev/stdin") - require.NoError(t, err) - - actualContents, err := io.ReadAll(readCloser) - require.NoError(t, err) - require.Equal(t, expectedContents, string(actualContents)) - - require.True(t, stdinInUse) - }) + stdinTests := []struct { + testName string + path string + }{ + {"TestEmptyString", ""}, + {"TestDash", "-"}, + {"TestDevStdin", "/dev/stdin"}, + {"TestDevFD0", "/dev/fd/0"}, + } + for _, test := range stdinTests { + t.Run(test.testName, func(t *testing.T) { + tempFile := t.TempDir() + "/test_file.txt" + require.NoError(t, os.WriteFile(tempFile, []byte(expectedContents), 0600)) + + stubStdin, err := os.Open(tempFile) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, stubStdin.Close()) }) + + readCloser, stdinInUse, err := FileOrStdin(stubStdin, test.path) + require.NoError(t, err) + + actualContents, err := io.ReadAll(readCloser) + require.NoError(t, err) + require.Equal(t, expectedContents, string(actualContents)) + + require.True(t, stdinInUse) + }) + } } diff --git a/internal/mocktest/mocktest.go b/internal/mocktest/mocktest.go index 5735b6b..e51ceb5 100644 --- a/internal/mocktest/mocktest.go +++ b/internal/mocktest/mocktest.go @@ -1,6 +1,7 @@ package mocktest import ( + "bytes" "context" "fmt" "net" @@ -14,6 +15,7 @@ import ( "testing" "time" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -54,8 +56,14 @@ func restoreNetwork(origClient, origDefault http.RoundTripper) { } // TestRunMockTestWithFlags runs a test against a mock server with the provided -// CLI flags and ensures it succeeds -func TestRunMockTestWithFlags(t *testing.T, flags ...string) { +// CLI args and ensures it succeeds +func TestRunMockTestWithFlags(t *testing.T, args ...string) { + TestRunMockTestWithPipeAndFlags(t, nil, args...) +} + +// TestRunMockTestWithPipeAndFlags runs a test against a mock server with the provided +// data piped over stdin and CLI args and ensures it succeeds +func TestRunMockTestWithPipeAndFlags(t *testing.T, pipeData []byte, args ...string) { origClient, origDefault := blockNetworkExceptMockServer() defer restoreNetwork(origClient, origDefault) @@ -71,52 +79,18 @@ func TestRunMockTestWithFlags(t *testing.T, flags ...string) { _, filename, _, ok := runtime.Caller(0) require.True(t, ok, "Could not get current file path") dirPath := filepath.Dir(filename) - project := filepath.Join(dirPath, "..", "..", "cmd", "...") - - args := []string{"run", project, "--base-url", mockServerURL.String()} - args = append(args, flags...) - - t.Logf("Testing command: beeper-desktop-cli %s", strings.Join(args[4:], " ")) - - cliCmd := exec.Command("go", args...) - - // Pipe the CLI tool's output into `head` so it doesn't hang when simulating - // paginated or streamed endpoints. 100 lines of output should be enough to - // test that the API endpoint worked, or report back a meaningful amount of - // data if something went wrong. - headCmd := exec.Command("head", "-n", "100") - pipe, err := cliCmd.StdoutPipe() - require.NoError(t, err, "Failed to create pipe for CLI command") - headCmd.Stdin = pipe - - // Capture `head` output and CLI command stderr outputs: - var output strings.Builder - headCmd.Stdout = &output - headCmd.Stderr = &output - cliCmd.Stderr = &output - - // First start `head`, so it's ready for data to come in: - err = headCmd.Start() - require.NoError(t, err, "Failed to start `head` command") - - // Next start the CLI command so it can pipe data to `head` without - // buffering any data in advance: - err = cliCmd.Start() - require.NoError(t, err, "Failed to start CLI command") - - // Ensure that the stdout pipe is closed as soon as `head` exits, to let the - // CLI tool know that no more output is needed and it can stop streaming - // test data for streaming/paginated endpoints. This needs to happen before - // calling `cliCmd.Wait()`, otherwise there will be a deadlock. - err = headCmd.Wait() - pipe.Close() - require.NoError(t, err, "`head` command finished with an error") - - // Finally, wait for the CLI tool to finish up: - err = cliCmd.Wait() - require.NoError(t, err, "CLI command failed\n%s", output.String()) - - t.Logf("Test passed successfully\nOutput:\n%s", output.String()) + project := filepath.Join(dirPath, "..", "..", "cmd", "beeper-desktop-cli") + + args = append([]string{"run", project, "--base-url", mockServerURL.String()}, args...) + + t.Logf("Testing command: go run ./cmd/beeper-desktop-cli %s", strings.Join(args[2:], " ")) + + cmd := exec.Command("go", args...) + cmd.Stdin = bytes.NewReader(pipeData) + output, err := cmd.CombinedOutput() + assert.NoError(t, err, "Test failed\nError: %v\nOutput: %s", err, output) + + t.Logf("Test passed successfully\nOutput:\n%s", string(output)) } func TestFile(t *testing.T, contents string) string { diff --git a/internal/requestflag/requestflag.go b/internal/requestflag/requestflag.go index 519fdce..986afed 100644 --- a/internal/requestflag/requestflag.go +++ b/internal/requestflag/requestflag.go @@ -20,16 +20,17 @@ type Flag[ []float64 | []int64 | []bool | any | map[string]any | DateTimeValue | DateValue | TimeValue | string | float64 | int64 | bool, ] struct { - Name string // name of the flag - Category string // category of the flag, if any - DefaultText string // default text of the flag for usage purposes - HideDefault bool // whether to hide the default value in output - Usage string // usage string for help output - Required bool // whether the flag is required or not - Hidden bool // whether to hide the flag in help output - Default T // default value for this flag if not set by from any source - Aliases []string // aliases that are allowed for this flag - Validator func(T) error // custom function to validate this flag value + Name string // name of the flag + Category string // category of the flag, if any + DefaultText string // default text of the flag for usage purposes + HideDefault bool // whether to hide the default value in output + Usage string // usage string for help output + Sources cli.ValueSourceChain // sources to load flag value from + Required bool // whether the flag is required or not + Hidden bool // whether to hide the flag in help output + Default T // default value for this flag if not set by from any source + Aliases []string // aliases that are allowed for this flag + Validator func(T) error // custom function to validate this flag value QueryPath string // location in the request query string to put this flag's value HeaderPath string // location in the request header to put this flag's value @@ -109,6 +110,40 @@ func ExtractRequestContents(cmd *cli.Command) RequestContents { return res } +func GetMissingRequiredFlags(cmd *cli.Command, body any) []cli.Flag { + missing := []cli.Flag{} + for _, flag := range cmd.Flags { + if flag.IsSet() { + continue + } + + if required, ok := flag.(cli.RequiredFlag); ok && required.IsRequired() { + missing = append(missing, flag) + continue + } + + if r, ok := flag.(RequiredFlagOrStdin); !ok || !r.IsRequiredAsFlagOrStdin() { + continue + } + + if toSend, ok := flag.(InRequest); ok { + if toSend.IsBodyRoot() { + if body != nil { + continue + } + } else if bodyPath := toSend.GetBodyPath(); bodyPath != "" { + if bodyMap, ok := body.(map[string]any); ok { + if _, found := bodyMap[bodyPath]; found { + continue + } + } + } + } + missing = append(missing, flag) + } + return missing +} + // Implementation of the cli.Flag interface var _ cli.Flag = (*Flag[any])(nil) // Type assertion to ensure interface compliance @@ -127,6 +162,22 @@ func (f *Flag[T]) PreParse() error { } func (f *Flag[T]) PostParse() error { + if !f.hasBeenSet { + if val, source, found := f.Sources.LookupWithSource(); found { + if val != "" || reflect.TypeOf(f.value).Kind() == reflect.String { + if err := f.Set(f.Name, val); err != nil { + return fmt.Errorf( + "could not parse %[1]q as %[2]T value from %[3]s for flag %[4]s: %[5]s", + val, f.value, source, f.Name, err, + ) + } + } else if val == "" && reflect.TypeOf(f.value).Kind() == reflect.Bool { + _ = f.Set(f.Name, "false") + } + + f.hasBeenSet = true + } + } return nil } @@ -204,6 +255,19 @@ func (f *Flag[T]) SetCategory(c string) { var _ cli.RequiredFlag = (*Flag[any])(nil) // Type assertion to ensure interface compliance func (f *Flag[T]) IsRequired() bool { + // Intentionally don't use `f.Required`, because request flags may be passed + // over stdin as well as by flag. + if f.BodyPath != "" || f.BodyRoot { + return false + } + return f.Required +} + +type RequiredFlagOrStdin interface { + IsRequiredAsFlagOrStdin() bool +} + +func (f *Flag[T]) IsRequiredAsFlagOrStdin() bool { return f.Required } @@ -230,8 +294,9 @@ func (f *Flag[T]) GetDefaultText() string { return f.DefaultText } +// GetEnvVars returns the env vars for this flag func (f *Flag[T]) GetEnvVars() []string { - return nil + return f.Sources.EnvKeys() } func (f *Flag[T]) IsDefaultVisible() bool { diff --git a/pkg/cmd/account_test.go b/pkg/cmd/account_test.go index cd633e7..02c4f22 100644 --- a/pkg/cmd/account_test.go +++ b/pkg/cmd/account_test.go @@ -9,8 +9,10 @@ import ( ) func TestAccountsList(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "accounts", "list", - ) + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, "accounts", "list", + "--access-token", "string", + ) + }) } diff --git a/pkg/cmd/accountcontact.go b/pkg/cmd/accountcontact.go index 493397a..76cc862 100644 --- a/pkg/cmd/accountcontact.go +++ b/pkg/cmd/accountcontact.go @@ -46,6 +46,10 @@ var accountsContactsList = cli.Command{ Usage: "Optional search query for blended contact lookup.", QueryPath: "query", }, + &requestflag.Flag[int64]{ + Name: "max-items", + Usage: "The maximum number of items to return (use -1 for unlimited).", + }, }, Action: handleAccountsContactsList, HideHelpCommand: true, @@ -119,7 +123,11 @@ func handleAccountsContactsList(ctx context.Context, cmd *cli.Command) error { params, options..., ) - return ShowJSONIterator(os.Stdout, "accounts:contacts list", iter, format, transform) + maxItems := int64(-1) + if cmd.IsSet("max-items") { + maxItems = cmd.Value("max-items").(int64) + } + return ShowJSONIterator(os.Stdout, "accounts:contacts list", iter, format, transform, maxItems) } } diff --git a/pkg/cmd/accountcontact_test.go b/pkg/cmd/accountcontact_test.go index a9aca95..6bc7110 100644 --- a/pkg/cmd/accountcontact_test.go +++ b/pkg/cmd/accountcontact_test.go @@ -9,22 +9,27 @@ import ( ) func TestAccountsContactsList(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "accounts:contacts", "list", - "--account-id", "accountID", - "--cursor", "1725489123456|c29tZUltc2dQYWdl", - "--direction", "before", - "--limit", "1", - "--query", "x", - ) + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, "accounts:contacts", "list", + "--access-token", "string", + "--max-items", "10", + "--account-id", "accountID", + "--cursor", "1725489123456|c29tZUltc2dQYWdl", + "--direction", "before", + "--limit", "1", + "--query", "x", + ) + }) } func TestAccountsContactsSearch(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "accounts:contacts", "search", - "--account-id", "accountID", - "--query", "x", - ) + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, "accounts:contacts", "search", + "--access-token", "string", + "--account-id", "accountID", + "--query", "x", + ) + }) } diff --git a/pkg/cmd/asset_test.go b/pkg/cmd/asset_test.go index 036f0ec..d7f0b07 100644 --- a/pkg/cmd/asset_test.go +++ b/pkg/cmd/asset_test.go @@ -9,37 +9,77 @@ import ( ) func TestAssetsDownload(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "assets", "download", - "--url", "mxc://example.org/Q4x9CqGz1pB3Oa6XgJ", - ) + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, "assets", "download", + "--access-token", "string", + "--url", "mxc://example.org/Q4x9CqGz1pB3Oa6XgJ", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("url: mxc://example.org/Q4x9CqGz1pB3Oa6XgJ") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, "assets", "download", + "--access-token", "string", + ) + }) } func TestAssetsServe(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "assets", "serve", - "--url", "x", - ) + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, "assets", "serve", + "--access-token", "string", + "--url", "x", + ) + }) } func TestAssetsUpload(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "assets", "upload", - "--file", "", - "--file-name", "fileName", - "--mime-type", "mimeType", - ) + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, "assets", "upload", + "--access-token", "string", + "--file", "...", + "--file-name", "fileName", + "--mime-type", "mimeType", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("" + + "fileName: fileName\n" + + "mimeType: mimeType\n") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, "assets", "upload", + "--access-token", "string", + ) + }) } func TestAssetsUploadBase64(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "assets", "upload-base64", - "--content", "x", - "--file-name", "fileName", - "--mime-type", "mimeType", - ) + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, "assets", "upload-base64", + "--access-token", "string", + "--content", "x", + "--file-name", "fileName", + "--mime-type", "mimeType", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("" + + "content: x\n" + + "fileName: fileName\n" + + "mimeType: mimeType\n") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, "assets", "upload-base64", + "--access-token", "string", + ) + }) } diff --git a/pkg/cmd/beeperdesktopapi_test.go b/pkg/cmd/beeperdesktopapi_test.go index 1cfef7d..a418bd7 100644 --- a/pkg/cmd/beeperdesktopapi_test.go +++ b/pkg/cmd/beeperdesktopapi_test.go @@ -9,20 +9,37 @@ import ( ) func TestFocus(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "focus", - "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--draft-attachment-path", "draftAttachmentPath", - "--draft-text", "draftText", - "--message-id", "messageID", - ) + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, "focus", + "--access-token", "string", + "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", + "--draft-attachment-path", "draftAttachmentPath", + "--draft-text", "draftText", + "--message-id", "messageID", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("" + + "chatID: '!NCdzlIaMjZUmvmvyHU:beeper.com'\n" + + "draftAttachmentPath: draftAttachmentPath\n" + + "draftText: draftText\n" + + "messageID: messageID\n") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, "focus", + "--access-token", "string", + ) + }) } func TestSearch(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "search", - "--query", "x", - ) + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, "search", + "--access-token", "string", + "--query", "x", + ) + }) } diff --git a/pkg/cmd/chat.go b/pkg/cmd/chat.go index 0b828d9..41a766b 100644 --- a/pkg/cmd/chat.go +++ b/pkg/cmd/chat.go @@ -136,6 +136,10 @@ var chatsList = cli.Command{ Usage: "Pagination direction used with 'cursor': 'before' fetches older results, 'after' fetches newer results. Defaults to 'before' when only 'cursor' is provided.", QueryPath: "direction", }, + &requestflag.Flag[int64]{ + Name: "max-items", + Usage: "The maximum number of items to return (use -1 for unlimited).", + }, }, Action: handleChatsList, HideHelpCommand: true, @@ -231,6 +235,10 @@ var chatsSearch = cli.Command{ Usage: "Set to true to only retrieve chats that have unread messages", QueryPath: "unreadOnly", }, + &requestflag.Flag[int64]{ + Name: "max-items", + Usage: "The maximum number of items to return (use -1 for unlimited).", + }, }, Action: handleChatsSearch, HideHelpCommand: true, @@ -346,7 +354,11 @@ func handleChatsList(ctx context.Context, cmd *cli.Command) error { return ShowJSON(os.Stdout, "chats list", obj, format, transform) } else { iter := client.Chats.ListAutoPaging(ctx, params, options...) - return ShowJSONIterator(os.Stdout, "chats list", iter, format, transform) + maxItems := int64(-1) + if cmd.IsSet("max-items") { + maxItems = cmd.Value("max-items").(int64) + } + return ShowJSONIterator(os.Stdout, "chats list", iter, format, transform, maxItems) } } @@ -416,6 +428,10 @@ func handleChatsSearch(ctx context.Context, cmd *cli.Command) error { return ShowJSON(os.Stdout, "chats search", obj, format, transform) } else { iter := client.Chats.SearchAutoPaging(ctx, params, options...) - return ShowJSONIterator(os.Stdout, "chats search", iter, format, transform) + maxItems := int64(-1) + if cmd.IsSet("max-items") { + maxItems = cmd.Value("max-items").(int64) + } + return ShowJSONIterator(os.Stdout, "chats search", iter, format, transform, maxItems) } } diff --git a/pkg/cmd/chat_test.go b/pkg/cmd/chat_test.go index 8739818..f6be069 100644 --- a/pkg/cmd/chat_test.go +++ b/pkg/cmd/chat_test.go @@ -10,86 +10,133 @@ import ( ) func TestChatsCreate(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "chats", "create", - "--account-id", "accountID", - "--allow-invite=true", - "--message-text", "messageText", - "--mode", "create", - "--participant-id", "string", - "--title", "title", - "--type", "single", - "--user", "{id: id, email: email, fullName: fullName, phoneNumber: phoneNumber, username: username}", - ) + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, "chats", "create", + "--access-token", "string", + "--account-id", "accountID", + "--allow-invite=true", + "--message-text", "messageText", + "--mode", "create", + "--participant-id", "string", + "--title", "title", + "--type", "single", + "--user", "{id: id, email: email, fullName: fullName, phoneNumber: phoneNumber, username: username}", + ) + }) - // Check that inner flags have been set up correctly - requestflag.CheckInnerFlags(chatsCreate) + t.Run("inner flags", func(t *testing.T) { + // Check that inner flags have been set up correctly + requestflag.CheckInnerFlags(chatsCreate) - // Alternative argument passing style using inner flags - mocktest.TestRunMockTestWithFlags( - t, - "chats", "create", - "--account-id", "accountID", - "--allow-invite=true", - "--message-text", "messageText", - "--mode", "create", - "--participant-id", "string", - "--title", "title", - "--type", "single", - "--user.id", "id", - "--user.email", "email", - "--user.full-name", "fullName", - "--user.phone-number", "phoneNumber", - "--user.username", "username", - ) + // Alternative argument passing style using inner flags + mocktest.TestRunMockTestWithFlags( + t, "chats", "create", + "--access-token", "string", + "--account-id", "accountID", + "--allow-invite=true", + "--message-text", "messageText", + "--mode", "create", + "--participant-id", "string", + "--title", "title", + "--type", "single", + "--user.id", "id", + "--user.email", "email", + "--user.full-name", "fullName", + "--user.phone-number", "phoneNumber", + "--user.username", "username", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("" + + "accountID: accountID\n" + + "allowInvite: true\n" + + "messageText: messageText\n" + + "mode: create\n" + + "participantIDs:\n" + + " - string\n" + + "title: title\n" + + "type: single\n" + + "user:\n" + + " id: id\n" + + " email: email\n" + + " fullName: fullName\n" + + " phoneNumber: phoneNumber\n" + + " username: username\n") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, "chats", "create", + "--access-token", "string", + ) + }) } func TestChatsRetrieve(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "chats", "retrieve", - "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--max-participant-count", "50", - ) + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, "chats", "retrieve", + "--access-token", "string", + "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", + "--max-participant-count", "50", + ) + }) } func TestChatsList(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "chats", "list", - "--account-id", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - "--account-id", "local-instagram_ba_eRfQMmnSNy_p7Ih7HL7RduRpKFU", - "--cursor", "1725489123456|c29tZUltc2dQYWdl", - "--direction", "before", - ) + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, "chats", "list", + "--access-token", "string", + "--max-items", "10", + "--account-id", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + "--account-id", "local-instagram_ba_eRfQMmnSNy_p7Ih7HL7RduRpKFU", + "--cursor", "1725489123456|c29tZUltc2dQYWdl", + "--direction", "before", + ) + }) } func TestChatsArchive(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "chats", "archive", - "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--archived=true", - ) + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, "chats", "archive", + "--access-token", "string", + "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", + "--archived=true", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("archived: true") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, "chats", "archive", + "--access-token", "string", + "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + }) } func TestChatsSearch(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "chats", "search", - "--account-id", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - "--account-id", "local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI", - "--cursor", "1725489123456|c29tZUltc2dQYWdl", - "--direction", "before", - "--inbox", "primary", - "--include-muted=true", - "--last-activity-after", "2019-12-27T18:11:19.117Z", - "--last-activity-before", "2019-12-27T18:11:19.117Z", - "--limit", "1", - "--query", "x", - "--scope", "titles", - "--type", "single", - "--unread-only=true", - ) + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, "chats", "search", + "--access-token", "string", + "--max-items", "10", + "--account-id", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + "--account-id", "local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI", + "--cursor", "1725489123456|c29tZUltc2dQYWdl", + "--direction", "before", + "--inbox", "primary", + "--include-muted=true", + "--last-activity-after", "'2019-12-27T18:11:19.117Z'", + "--last-activity-before", "'2019-12-27T18:11:19.117Z'", + "--limit", "1", + "--query", "x", + "--scope", "titles", + "--type", "single", + "--unread-only=true", + ) + }) } diff --git a/pkg/cmd/chatmessagereaction_test.go b/pkg/cmd/chatmessagereaction_test.go index 3a42a97..7a26ed2 100644 --- a/pkg/cmd/chatmessagereaction_test.go +++ b/pkg/cmd/chatmessagereaction_test.go @@ -9,22 +9,39 @@ import ( ) func TestChatsMessagesReactionsDelete(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "chats:messages:reactions", "delete", - "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--message-id", "messageID", - "--reaction-key", "x", - ) + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, "chats:messages:reactions", "delete", + "--access-token", "string", + "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", + "--message-id", "messageID", + "--reaction-key", "x", + ) + }) } func TestChatsMessagesReactionsAdd(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "chats:messages:reactions", "add", - "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--message-id", "messageID", - "--reaction-key", "x", - "--transaction-id", "transactionID", - ) + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, "chats:messages:reactions", "add", + "--access-token", "string", + "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", + "--message-id", "messageID", + "--reaction-key", "x", + "--transaction-id", "transactionID", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("" + + "reactionKey: x\n" + + "transactionID: transactionID\n") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, "chats:messages:reactions", "add", + "--access-token", "string", + "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", + "--message-id", "messageID", + ) + }) } diff --git a/pkg/cmd/chatreminder_test.go b/pkg/cmd/chatreminder_test.go index 88b17c0..eeaaaec 100644 --- a/pkg/cmd/chatreminder_test.go +++ b/pkg/cmd/chatreminder_test.go @@ -10,30 +10,49 @@ import ( ) func TestChatsRemindersCreate(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "chats:reminders", "create", - "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--reminder", "{remindAtMs: 0, dismissOnIncomingMessage: true}", - ) - - // Check that inner flags have been set up correctly - requestflag.CheckInnerFlags(chatsRemindersCreate) - - // Alternative argument passing style using inner flags - mocktest.TestRunMockTestWithFlags( - t, - "chats:reminders", "create", - "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--reminder.remind-at-ms", "0", - "--reminder.dismiss-on-incoming-message=true", - ) + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, "chats:reminders", "create", + "--access-token", "string", + "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", + "--reminder", "{remindAtMs: 0, dismissOnIncomingMessage: true}", + ) + }) + + t.Run("inner flags", func(t *testing.T) { + // Check that inner flags have been set up correctly + requestflag.CheckInnerFlags(chatsRemindersCreate) + + // Alternative argument passing style using inner flags + mocktest.TestRunMockTestWithFlags( + t, "chats:reminders", "create", + "--access-token", "string", + "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", + "--reminder.remind-at-ms", "0", + "--reminder.dismiss-on-incoming-message=true", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("" + + "reminder:\n" + + " remindAtMs: 0\n" + + " dismissOnIncomingMessage: true\n") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, "chats:reminders", "create", + "--access-token", "string", + "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + }) } func TestChatsRemindersDelete(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "chats:reminders", "delete", - "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - ) + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, "chats:reminders", "delete", + "--access-token", "string", + "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + }) } diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index d0c40cf..3351e7f 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -3,6 +3,7 @@ package cmd import ( + "bytes" "compress/gzip" "context" "fmt" @@ -12,20 +13,23 @@ import ( "strings" "github.com/beeper/desktop-api-cli/internal/autocomplete" + "github.com/beeper/desktop-api-cli/internal/requestflag" docs "github.com/urfave/cli-docs/v3" "github.com/urfave/cli/v3" ) var ( - Command *cli.Command + Command *cli.Command + CommandErrorBuffer bytes.Buffer ) func init() { Command = &cli.Command{ - Name: "beeper-desktop-cli", - Usage: "CLI for the beeperdesktop API", - Suggest: true, - Version: Version, + Name: "beeper-desktop-cli", + Usage: "CLI for the beeperdesktop API", + Suggest: true, + Version: Version, + ErrWriter: &CommandErrorBuffer, Flags: []cli.Flag{ &cli.BoolFlag{ Name: "debug", @@ -66,6 +70,11 @@ func init() { Name: "transform-error", Usage: "The GJSON transformation for errors.", }, + &requestflag.Flag[string]{ + Name: "access-token", + Usage: "Bearer access token obtained via OAuth2 PKCE flow or created in-app. Required for all API operations.", + Sources: cli.EnvVars("BEEPER_ACCESS_TOKEN"), + }, }, Commands: []*cli.Command{ &focus, diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go index 86c83c6..d5bea76 100644 --- a/pkg/cmd/cmdutil.go +++ b/pkg/cmd/cmdutil.go @@ -6,11 +6,13 @@ import ( "fmt" "io" "log" + "mime" "net/http" "net/http/httputil" "os" "os/exec" "os/signal" + "path/filepath" "strings" "syscall" @@ -34,6 +36,7 @@ func getDefaultRequestOptions(cmd *cli.Command) []option.RequestOption { option.WithHeader("X-Stainless-Package-Version", Version), option.WithHeader("X-Stainless-Runtime", "cli"), option.WithHeader("X-Stainless-CLI-Command", cmd.FullName()), + option.WithAccessToken(cmd.String("access-token")), } // Override base URL if the --base-url flag is provided @@ -153,6 +156,108 @@ func streamToStdout(generateOutput func(w *os.File) error) error { return err } +func writeBinaryResponse(response *http.Response, outfile string) (string, error) { + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil { + return "", err + } + switch outfile { + case "-", "/dev/stdout": + _, err := os.Stdout.Write(body) + return "", err + case "": + // If output file is unspecified, then print to stdout for plain text or + // if stdout is not a terminal: + if !isTerminal(os.Stdout) || isUTF8TextFile(body) { + _, err := os.Stdout.Write(body) + return "", err + } + + // If response has a suggested filename in the content-disposition + // header, then use that (with an optional suffix to ensure uniqueness): + file, err := createDownloadFile(response, body) + if err != nil { + return "", err + } + defer file.Close() + if _, err := file.Write(body); err != nil { + return "", err + } + return fmt.Sprintf("Wrote output to: %s", file.Name()), nil + default: + if err := os.WriteFile(outfile, body, 0644); err != nil { + return "", err + } + return fmt.Sprintf("Wrote output to: %s", outfile), nil + } +} + +// Return a writable file handle to a new file, which attempts to choose a good filename +// based on the Content-Disposition header or sniffing the MIME filetype of the response. +func createDownloadFile(response *http.Response, data []byte) (*os.File, error) { + filename := "file" + // If the header provided an output filename, use that + disp := response.Header.Get("Content-Disposition") + _, params, err := mime.ParseMediaType(disp) + if err == nil { + if dispFilename, ok := params["filename"]; ok { + // Only use the last path component to prevent directory traversal + filename = filepath.Base(dispFilename) + // Try to create the file with exclusive flag to avoid race conditions + file, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644) + if err == nil { + return file, nil + } + } + } + + // If file already exists, create a unique filename using CreateTemp + ext := filepath.Ext(filename) + if ext == "" { + ext = guessExtension(data) + } + base := strings.TrimSuffix(filename, ext) + return os.CreateTemp(".", base+"-*"+ext) +} + +func guessExtension(data []byte) string { + ct := http.DetectContentType(data) + + // Prefer common extensions over obscure ones + switch ct { + case "application/gzip": + return ".gz" + case "application/pdf": + return ".pdf" + case "application/zip": + return ".zip" + case "audio/mpeg": + return ".mp3" + case "image/bmp": + return ".bmp" + case "image/gif": + return ".gif" + case "image/jpeg": + return ".jpg" + case "image/png": + return ".png" + case "image/webp": + return ".webp" + case "video/mp4": + return ".mp4" + } + + exts, err := mime.ExtensionsByType(ct) + if err == nil && len(exts) > 0 { + return exts[0] + } else if isUTF8TextFile(data) { + return ".txt" + } else { + return ".bin" + } +} + func shouldUseColors(w io.Writer) bool { force, ok := os.LookupEnv("FORCE_COLOR") if ok { @@ -245,7 +350,7 @@ type HasRawJSON interface { // For an iterator over different value types, display its values to the user in // different formats. -func ShowJSONIterator[T any](stdout *os.File, title string, iter jsonview.Iterator[T], format string, transform string) error { +func ShowJSONIterator[T any](stdout *os.File, title string, iter jsonview.Iterator[T], format string, transform string, itemsToDisplay int64) error { if format == "explore" { return jsonview.ExploreJSONStream(title, iter) } @@ -261,6 +366,9 @@ func ShowJSONIterator[T any](stdout *os.File, title string, iter jsonview.Iterat output := []byte{} numberOfNewlines := 0 for iter.Next() { + if itemsToDisplay == 0 { + break + } item := iter.Current() var obj gjson.Result if hasRaw, ok := any(item).(HasRawJSON); ok { @@ -278,6 +386,7 @@ func ShowJSONIterator[T any](stdout *os.File, title string, iter jsonview.Iterat } output = append(output, json...) + itemsToDisplay -= 1 numberOfNewlines += countTerminalLines(json, terminalWidth) // If the output won't fit in the terminal window, stream it to a pager @@ -304,6 +413,9 @@ func ShowJSONIterator[T any](stdout *os.File, title string, iter jsonview.Iterat } for iter.Next() { + if itemsToDisplay == 0 { + break + } item := iter.Current() var obj gjson.Result if hasRaw, ok := any(item).(HasRawJSON); ok { @@ -318,6 +430,7 @@ func ShowJSONIterator[T any](stdout *os.File, title string, iter jsonview.Iterat if err := ShowJSON(pager, title, obj, format, transform); err != nil { return err } + itemsToDisplay -= 1 } return iter.Err() }) diff --git a/pkg/cmd/cmdutil_test.go b/pkg/cmd/cmdutil_test.go index 027f3d4..0a46fd1 100644 --- a/pkg/cmd/cmdutil_test.go +++ b/pkg/cmd/cmdutil_test.go @@ -1,8 +1,15 @@ package cmd import ( + "bytes" + "io" + "net/http" "os" + "path/filepath" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestStreamOutput(t *testing.T) { @@ -15,3 +22,106 @@ func TestStreamOutput(t *testing.T) { t.Errorf("streamOutput failed: %v", err) } } + +func TestWriteBinaryResponse(t *testing.T) { + t.Run("write to explicit file", func(t *testing.T) { + tmpDir := t.TempDir() + outfile := tmpDir + "/output.txt" + body := []byte("test content") + resp := &http.Response{ + Body: io.NopCloser(bytes.NewReader(body)), + } + + msg, err := writeBinaryResponse(resp, outfile) + + require.NoError(t, err) + assert.Contains(t, msg, outfile) + + content, err := os.ReadFile(outfile) + require.NoError(t, err) + assert.Equal(t, body, content) + }) + + t.Run("write to stdout", func(t *testing.T) { + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + body := []byte("stdout content") + resp := &http.Response{ + Body: io.NopCloser(bytes.NewReader(body)), + } + msg, err := writeBinaryResponse(resp, "-") + + w.Close() + os.Stdout = oldStdout + + require.NoError(t, err) + assert.Empty(t, msg) + + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + assert.Equal(t, body, buf.Bytes()) + }) +} + +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) + + resp := &http.Response{ + Header: http.Header{ + "Content-Disposition": []string{`attachment; filename="test.txt"`}, + }, + } + file, err := createDownloadFile(resp, []byte("test content")) + require.NoError(t, err) + defer file.Close() + assert.Equal(t, "test.txt", filepath.Base(file.Name())) + + // Create a second file with the same name to ensure it doesn't clobber the first + resp2 := &http.Response{ + Header: http.Header{ + "Content-Disposition": []string{`attachment; filename="test.txt"`}, + }, + } + file2, err := createDownloadFile(resp2, []byte("second content")) + require.NoError(t, err) + defer file2.Close() + assert.NotEqual(t, file.Name(), file2.Name(), "second file should have a different name") + assert.Contains(t, filepath.Base(file2.Name()), "test") + }) + + 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) + + resp := &http.Response{Header: http.Header{}} + file, err := createDownloadFile(resp, []byte("test content")) + require.NoError(t, err) + defer file.Close() + assert.Contains(t, filepath.Base(file.Name()), "file-") + }) + + t.Run("prevents directory traversal", func(t *testing.T) { + tmpDir := t.TempDir() + oldWd, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(oldWd) + + resp := &http.Response{ + Header: http.Header{ + "Content-Disposition": []string{`attachment; filename="../../../etc/passwd"`}, + }, + } + file, err := createDownloadFile(resp, []byte("test content")) + require.NoError(t, err) + defer file.Close() + assert.Equal(t, "passwd", filepath.Base(file.Name())) + }) +} diff --git a/pkg/cmd/flagoptions.go b/pkg/cmd/flagoptions.go index b314e9a..6f5bdcb 100644 --- a/pkg/cmd/flagoptions.go +++ b/pkg/cmd/flagoptions.go @@ -41,6 +41,9 @@ const ( ) func embedFiles(obj any, embedStyle FileEmbedStyle) (any, error) { + if obj == nil { + return obj, nil + } v := reflect.ValueOf(obj) result, err := embedFilesValue(v, embedStyle) if err != nil { @@ -207,37 +210,53 @@ func flagOptions( // This parameter is true if stdin is already in use to pass a binary parameter by using the special value // "-". In this case, we won't attempt to read it as a JSON/YAML blob for options setting. - stdinInUse bool, + ignoreStdin bool, ) ([]option.RequestOption, error) { var options []option.RequestOption if cmd.Bool("debug") { options = append(options, option.WithMiddleware(debugmiddleware.NewRequestLogger().Middleware())) } - flagContents := requestflag.ExtractRequestContents(cmd) + requestContents := requestflag.ExtractRequestContents(cmd) - var bodyData any - if isInputPiped() && !stdinInUse { - var err error + if bodyType != ApplicationOctetStream && isInputPiped() && !ignoreStdin { pipeData, err := io.ReadAll(os.Stdin) if err != nil { return nil, err } - if err := yaml.Unmarshal(pipeData, &bodyData); err == nil { - if bodyMap, ok := bodyData.(map[string]any); ok { - if flagMap, ok := flagContents.Body.(map[string]any); ok { - maps.Copy(bodyMap, flagMap) + if len(pipeData) > 0 { + var bodyData any + if err := yaml.Unmarshal(pipeData, &bodyData); err == nil { + if bodyMap, ok := bodyData.(map[string]any); ok { + if flagMap, ok := requestContents.Body.(map[string]any); ok { + maps.Copy(bodyMap, flagMap) + requestContents.Body = bodyMap + } else { + bodyData = requestContents.Body + } + } else if flagMap, ok := requestContents.Body.(map[string]any); ok && len(flagMap) > 0 { + return nil, fmt.Errorf("Cannot merge flags with a body that is not a map: %v", bodyData) } else { - bodyData = flagContents.Body + requestContents.Body = bodyData } - } else if flagMap, ok := flagContents.Body.(map[string]any); ok && len(flagMap) > 0 { - return nil, fmt.Errorf("Cannot merge flags with a body that is not a map: %v", bodyData) } } - } else { - // No piped input, just use body flag values as a map - bodyData = flagContents.Body + } + + if missingFlags := requestflag.GetMissingRequiredFlags(cmd, requestContents.Body); len(missingFlags) > 0 { + var buf bytes.Buffer + cli.HelpPrinter(&buf, cli.SubcommandHelpTemplate, cmd) + usage := buf.String() + if len(missingFlags) == 1 { + return nil, fmt.Errorf("%sRequired flag %q not set", usage, missingFlags[0].Names()[0]) + } else { + names := []string{} + for _, flag := range missingFlags { + names = append(names, flag.Names()[0]) + } + return nil, fmt.Errorf("%sRequired flags %q not set", usage, strings.Join(names, ", ")) + } } // Embed files passed as "@file.jpg" in the request body, headers, and query: @@ -245,19 +264,22 @@ func flagOptions( if bodyType == ApplicationOctetStream || bodyType == MultipartFormEncoded { embedStyle = EmbedIOReader } - bodyData, err := embedFiles(bodyData, embedStyle) - if err != nil { + + if embedded, err := embedFiles(requestContents.Body, embedStyle); err != nil { return nil, err + } else { + requestContents.Body = embedded } - if headersWithFiles, err := embedFiles(flagContents.Headers, EmbedText); err != nil { + + if headersWithFiles, err := embedFiles(requestContents.Headers, EmbedText); err != nil { return nil, err } else { - flagContents.Headers = headersWithFiles.(map[string]any) + requestContents.Headers = headersWithFiles.(map[string]any) } - if queriesWithFiles, err := embedFiles(flagContents.Queries, EmbedText); err != nil { + if queriesWithFiles, err := embedFiles(requestContents.Queries, EmbedText); err != nil { return nil, err } else { - flagContents.Queries = queriesWithFiles.(map[string]any) + requestContents.Queries = queriesWithFiles.(map[string]any) } querySettings := apiquery.QuerySettings{ @@ -266,7 +288,7 @@ func flagOptions( } // Add query parameters: - if values, err := apiquery.MarshalWithSettings(flagContents.Queries, querySettings); err != nil { + if values, err := apiquery.MarshalWithSettings(requestContents.Queries, querySettings); err != nil { return nil, err } else { for k, vs := range values { @@ -286,7 +308,7 @@ func flagOptions( NestedFormat: apiquery.NestedQueryFormatDots, ArrayFormat: apiquery.ArrayQueryFormatRepeat, } - if values, err := apiquery.MarshalWithSettings(flagContents.Headers, headerSettings); err != nil { + if values, err := apiquery.MarshalWithSettings(requestContents.Headers, headerSettings); err != nil { return nil, err } else { for k, vs := range values { @@ -309,9 +331,9 @@ func flagOptions( writer := multipart.NewWriter(buf) // For multipart/form-encoded, we need a map structure - bodyMap, ok := bodyData.(map[string]any) + bodyMap, ok := requestContents.Body.(map[string]any) if !ok { - return nil, fmt.Errorf("Cannot send a non-map value to a form-encoded endpoint: %v\n", bodyData) + return nil, fmt.Errorf("Cannot send a non-map value to a form-encoded endpoint: %v\n", requestContents.Body) } encodingFormat := apiform.FormatRepeat if err := apiform.MarshalWithSettings(bodyMap, writer, encodingFormat); err != nil { @@ -323,19 +345,25 @@ func flagOptions( options = append(options, option.WithRequestBody(writer.FormDataContentType(), buf)) case ApplicationJSON: - bodyBytes, err := json.Marshal(bodyData) + bodyBytes, err := json.Marshal(requestContents.Body) if err != nil { return nil, err } options = append(options, option.WithRequestBody("application/json", bodyBytes)) case ApplicationOctetStream: - if bodyBytes, ok := bodyData.([]byte); ok { + // If there is a body root parameter, that will handle setting the request body, we don't need to do it here. + for _, flag := range cmd.Flags { + if toSend, ok := flag.(requestflag.InRequest); ok && toSend.IsBodyRoot() { + return options, nil + } + } + if bodyBytes, ok := requestContents.Body.([]byte); ok { options = append(options, option.WithRequestBody("application/octet-stream", bodyBytes)) - } else if bodyStr, ok := bodyData.(string); ok { + } else if bodyStr, ok := requestContents.Body.(string); ok { options = append(options, option.WithRequestBody("application/octet-stream", []byte(bodyStr))) } else { - return nil, fmt.Errorf("Unsupported body for application/octet-stream: %v", bodyData) + return nil, fmt.Errorf("Unsupported body for application/octet-stream: %v", requestContents.Body) } default: diff --git a/pkg/cmd/info_test.go b/pkg/cmd/info_test.go index cc916cd..3ce4d47 100644 --- a/pkg/cmd/info_test.go +++ b/pkg/cmd/info_test.go @@ -9,8 +9,10 @@ import ( ) func TestInfoRetrieve(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "info", "retrieve", - ) + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, "info", "retrieve", + "--access-token", "string", + ) + }) } diff --git a/pkg/cmd/message.go b/pkg/cmd/message.go index 66a2465..b65cef7 100644 --- a/pkg/cmd/message.go +++ b/pkg/cmd/message.go @@ -60,6 +60,10 @@ var messagesList = cli.Command{ Usage: "Pagination direction used with 'cursor': 'before' fetches older results, 'after' fetches newer results. Defaults to 'before' when only 'cursor' is provided.", QueryPath: "direction", }, + &requestflag.Flag[int64]{ + Name: "max-items", + Usage: "The maximum number of items to return (use -1 for unlimited).", + }, }, Action: handleMessagesList, HideHelpCommand: true, @@ -138,6 +142,10 @@ var messagesSearch = cli.Command{ Usage: "Filter by sender: 'me' (messages sent by the authenticated user), 'others' (messages sent by others), or a specific user ID string (user.id).", QueryPath: "sender", }, + &requestflag.Flag[int64]{ + Name: "max-items", + Usage: "The maximum number of items to return (use -1 for unlimited).", + }, }, Action: handleMessagesSearch, HideHelpCommand: true, @@ -297,7 +305,11 @@ func handleMessagesList(ctx context.Context, cmd *cli.Command) error { params, options..., ) - return ShowJSONIterator(os.Stdout, "messages list", iter, format, transform) + maxItems := int64(-1) + if cmd.IsSet("max-items") { + maxItems = cmd.Value("max-items").(int64) + } + return ShowJSONIterator(os.Stdout, "messages list", iter, format, transform, maxItems) } } @@ -335,7 +347,11 @@ func handleMessagesSearch(ctx context.Context, cmd *cli.Command) error { return ShowJSON(os.Stdout, "messages search", obj, format, transform) } else { iter := client.Messages.SearchAutoPaging(ctx, params, options...) - return ShowJSONIterator(os.Stdout, "messages search", iter, format, transform) + maxItems := int64(-1) + if cmd.IsSet("max-items") { + maxItems = cmd.Value("max-items").(int64) + } + return ShowJSONIterator(os.Stdout, "messages search", iter, format, transform, maxItems) } } diff --git a/pkg/cmd/message_test.go b/pkg/cmd/message_test.go index 08bb115..36eddf3 100644 --- a/pkg/cmd/message_test.go +++ b/pkg/cmd/message_test.go @@ -10,72 +10,116 @@ import ( ) func TestMessagesUpdate(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "messages", "update", - "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--message-id", "messageID", - "--text", "x", - ) + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, "messages", "update", + "--access-token", "string", + "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", + "--message-id", "messageID", + "--text", "x", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("text: x") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, "messages", "update", + "--access-token", "string", + "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", + "--message-id", "messageID", + ) + }) } func TestMessagesList(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "messages", "list", - "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--cursor", "1725489123456|c29tZUltc2dQYWdl", - "--direction", "before", - ) + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, "messages", "list", + "--access-token", "string", + "--max-items", "10", + "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", + "--cursor", "1725489123456|c29tZUltc2dQYWdl", + "--direction", "before", + ) + }) } func TestMessagesSearch(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "messages", "search", - "--account-id", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - "--account-id", "local-instagram_ba_eRfQMmnSNy_p7Ih7HL7RduRpKFU", - "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--chat-id", "1231073", - "--chat-type", "group", - "--cursor", "1725489123456|c29tZUltc2dQYWdl", - "--date-after", "2025-08-01T00:00:00Z", - "--date-before", "2025-08-31T23:59:59Z", - "--direction", "before", - "--exclude-low-priority=true", - "--include-muted=true", - "--limit", "20", - "--media-type", "any", - "--query", "dinner", - "--sender", "sender", - ) + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, "messages", "search", + "--access-token", "string", + "--max-items", "10", + "--account-id", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", + "--account-id", "local-instagram_ba_eRfQMmnSNy_p7Ih7HL7RduRpKFU", + "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", + "--chat-id", "1231073", + "--chat-type", "group", + "--cursor", "1725489123456|c29tZUltc2dQYWdl", + "--date-after", "'2025-08-01T00:00:00Z'", + "--date-before", "'2025-08-31T23:59:59Z'", + "--direction", "before", + "--exclude-low-priority=true", + "--include-muted=true", + "--limit", "20", + "--media-type", "any", + "--query", "dinner", + "--sender", "sender", + ) + }) } func TestMessagesSend(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "messages", "send", - "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--attachment", "{uploadID: uploadID, duration: 0, fileName: fileName, mimeType: mimeType, size: {height: 0, width: 0}, type: gif}", - "--reply-to-message-id", "replyToMessageID", - "--text", "text", - ) + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, "messages", "send", + "--access-token", "string", + "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", + "--attachment", "{uploadID: uploadID, duration: 0, fileName: fileName, mimeType: mimeType, size: {height: 0, width: 0}, type: gif}", + "--reply-to-message-id", "replyToMessageID", + "--text", "text", + ) + }) + + t.Run("inner flags", func(t *testing.T) { + // Check that inner flags have been set up correctly + requestflag.CheckInnerFlags(messagesSend) - // Check that inner flags have been set up correctly - requestflag.CheckInnerFlags(messagesSend) + // Alternative argument passing style using inner flags + mocktest.TestRunMockTestWithFlags( + t, "messages", "send", + "--access-token", "string", + "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", + "--attachment.upload-id", "uploadID", + "--attachment.duration", "0", + "--attachment.file-name", "fileName", + "--attachment.mime-type", "mimeType", + "--attachment.size", "{height: 0, width: 0}", + "--attachment.type", "gif", + "--reply-to-message-id", "replyToMessageID", + "--text", "text", + ) + }) - // Alternative argument passing style using inner flags - mocktest.TestRunMockTestWithFlags( - t, - "messages", "send", - "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--attachment.upload-id", "uploadID", - "--attachment.duration", "0", - "--attachment.file-name", "fileName", - "--attachment.mime-type", "mimeType", - "--attachment.size", "{height: 0, width: 0}", - "--attachment.type", "gif", - "--reply-to-message-id", "replyToMessageID", - "--text", "text", - ) + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("" + + "attachment:\n" + + " uploadID: uploadID\n" + + " duration: 0\n" + + " fileName: fileName\n" + + " mimeType: mimeType\n" + + " size:\n" + + " height: 0\n" + + " width: 0\n" + + " type: gif\n" + + "replyToMessageID: replyToMessageID\n" + + "text: text\n") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, "messages", "send", + "--access-token", "string", + "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + }) } diff --git a/pkg/cmd/version.go b/pkg/cmd/version.go index e6caf60..10d2893 100644 --- a/pkg/cmd/version.go +++ b/pkg/cmd/version.go @@ -2,4 +2,4 @@ package cmd -const Version = "0.1.1" // x-release-please-version +const Version = "0.2.0" // x-release-please-version diff --git a/scripts/mock b/scripts/mock index 0b28f6e..bcf3b39 100755 --- a/scripts/mock +++ b/scripts/mock @@ -21,11 +21,22 @@ echo "==> Starting mock server with URL ${URL}" # Run prism mock on the given spec if [ "$1" == "--daemon" ]; then + # Pre-install the package so the download doesn't eat into the startup timeout + npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism --version + npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" &> .prism.log & - # Wait for server to come online + # Wait for server to come online (max 30s) echo -n "Waiting for server" + attempts=0 while ! grep -q "✖ fatal\|Prism is listening" ".prism.log" ; do + attempts=$((attempts + 1)) + if [ "$attempts" -ge 300 ]; then + echo + echo "Timed out waiting for Prism server to start" + cat .prism.log + exit 1 + fi echo -n "." sleep 0.1 done diff --git a/scripts/utils/upload-artifact.sh b/scripts/utils/upload-artifact.sh index d453066..9848696 100755 --- a/scripts/utils/upload-artifact.sh +++ b/scripts/utils/upload-artifact.sh @@ -5,10 +5,15 @@ BINARY_NAME="beeper-desktop-cli" DIST_DIR="dist" FILENAME="dist.zip" -mapfile -d '' files < <( - find "$DIST_DIR" -regextype posix-extended -type f \ - -regex ".*/[^/]*(amd64|arm64)[^/]*/${BINARY_NAME}(\\.exe)?$" -print0 -) +files=() +while IFS= read -r -d '' file; do + files+=("$file") +done < <(find "$DIST_DIR" -type f \( \ + -path "*amd64*/$BINARY_NAME" -o \ + -path "*arm64*/$BINARY_NAME" -o \ + -path "*amd64*/${BINARY_NAME}.exe" -o \ + -path "*arm64*/${BINARY_NAME}.exe" \ + \) -print0) if [[ ${#files[@]} -eq 0 ]]; then echo -e "\033[31mNo binaries found for packaging.\033[0m" @@ -20,7 +25,8 @@ rm -f "${DIST_DIR}/${FILENAME}" while IFS= read -r -d '' dir; do printf "Remove the quarantine attribute before running the executable:\n\nxattr -d com.apple.quarantine %s\n" \ "$BINARY_NAME" >"${dir}/README.txt" -done < <(find "$DIST_DIR" -type d -name '*macos*' -print0) + files+=("${dir}/README.txt") +done < <(find "$DIST_DIR" -type d -path '*macos*' -print0) relative_files=() for file in "${files[@]}"; do @@ -46,7 +52,7 @@ UPLOAD_RESPONSE=$(curl -v -X PUT \ if echo "$UPLOAD_RESPONSE" | grep -q "HTTP/[0-9.]* 200"; then echo -e "\033[32mUploaded build to Stainless storage.\033[0m" - echo -e "\033[32mInstallation: Download and unzip: 'https://pkg.stainless.com/s/beeper-desktop-api-cli/$SHA/$FILENAME'. On macOS, run `xattr -d com.apple.quarantine {executable name}.`\033[0m" + echo -e "\033[32mInstallation: Download and unzip: 'https://pkg.stainless.com/s/beeper-desktop-api-cli/$SHA'. On macOS, run 'xattr -d com.apple.quarantine {executable name}'.\033[0m" else echo -e "\033[31mFailed to upload artifact.\033[0m" exit 1