From 06f742622ddc2573434a304f7011f32b1a34e371 Mon Sep 17 00:00:00 2001 From: Santiago Medina Rolong Date: Mon, 29 Jun 2026 14:31:34 -0700 Subject: [PATCH 1/2] feat: add `xurl mcp` bridge + `xurl token`, headless OAuth2, and auth/UX hardening New commands - `xurl mcp [URL]`: a stdio<->Streamable-HTTP MCP bridge for the hosted X API MCP server. Injects an auto-refreshed OAuth2 Bearer token, maintains the MCP session id, handles JSON/SSE/202 responses, processes requests in order off the read loop while dispatching notifications concurrently, synthesizes a JSON-RPC error for any request it cannot answer (so a strict client never hangs), and shuts down cleanly on SIGINT/EOF. - `xurl token`: print a valid (refreshed, persisted) OAuth2 access token for the active app; never opens a browser, so it is scriptable. Auth - `xurl auth oauth2 --headless`: authenticate on remote/headless machines with no reachable localhost callback -- print the authorize URL, paste the redirect URL/code back (also via stdin). - OAuth2 token exchange/refresh now sets the client-auth style explicitly (Basic header for confidential clients, body for public), fixing `unauthorized_client: Missing valid authorization header` against X. - `auth app` -> `auth app-only` (aliases: app, bearer); token is positional or read from stdin (`-`); `auth clear --app-only`. Command surface - Group subcommands in `--help`; add `xurl posts USERNAME`; support `xurl --version`; `-d` implies POST (curl-like). Fixes - media upload: corrected verbose/wait/trace argument order and added media-type auto-detection (erroring on unsupported types instead of guessing). - Surface real transport/auth errors instead of printing `null`; fail fast on missing credentials; JSON-encode DM text; clamp `--max-results` per endpoint; OAuth2 expiry/skew handling; webhook help + isolated ServeMux; `.gitignore` fix. See CHANGELOG.md for the complete list. --- .gitignore | 4 +- CHANGELOG.md | 30 +- README.md | 79 ++++- SKILL.md | 11 + api/client.go | 30 +- api/client_test.go | 38 +++ api/execute.go | 15 +- api/execute_test.go | 55 +++ api/media.go | 77 ++++- api/media_test.go | 162 ++++++++- api/shortcuts.go | 35 +- api/shortcuts_test.go | 64 ++++ auth/auth.go | 227 +++++++++++-- auth/auth_test.go | 237 ++++++++++++- cli/auth.go | 188 +++++++--- cli/mcp.go | 775 ++++++++++++++++++++++++++++++++++++++++++ cli/mcp_test.go | 610 +++++++++++++++++++++++++++++++++ cli/media.go | 14 +- cli/root.go | 108 +++--- cli/shortcuts.go | 83 +++-- cli/token.go | 78 +++++ cli/token_test.go | 12 + cli/webhook.go | 7 +- 23 files changed, 2743 insertions(+), 196 deletions(-) create mode 100644 api/execute_test.go create mode 100644 cli/mcp.go create mode 100644 cli/mcp_test.go create mode 100644 cli/token.go create mode 100644 cli/token_test.go diff --git a/.gitignore b/.gitignore index bdd0fc0..c23529f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ xurl .xurl_test -.DS_Store# Added by goreleaser init: +.DS_Store + +# Added by goreleaser init: dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md index e931c52..d40329e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,26 @@ All user-visible bugs and enhancements should be recorded here. -## Unreleased - -Last updated: 2026-05-14 11:38:34 PDT +## v1.2.0 - 2026-06-29 ### Fixed +- [2026-06-29] OAuth2 token exchange and refresh now send client credentials with the correct auth style — HTTP Basic header for confidential clients (those with a secret), `client_id` in the body for public clients — instead of relying on autodetection, which could fail against X with `unauthorized_client: Missing valid authorization header`. +- [2026-06-29] `mcp` bridge no longer launches a browser at startup: it still refreshes an existing token silently, but when none is available it fails fast with instructions (`xurl auth oauth2 [--app NAME] [--headless]`) instead of opening a browser mid-startup (which could hang an MCP client's handshake) and printing to the JSON-RPC stdout channel. OAuth2 diagnostics now go to stderr. +- [2026-06-29] `mcp` bridge no longer lets a strict client hang: a request that cannot be answered — transport failure, a failed token refresh/retry after a 401, or a response with an empty/non-JSON body — now gets a synthesized JSON-RPC error keyed to its id. Notifications (e.g. `notifications/cancelled`) are no longer head-of-line blocked behind an in-flight streaming response, large but valid JSON error bodies are forwarded whole instead of being truncated, the standalone server->client stream stops probing a non-event-stream `200` and only resets its reconnect backoff after a healthy stream, and stdin memory stays bounded when an oversized line is dropped. +- [2026-06-25] `mcp` bridge hardening: serialized token-store access (fixes a fatal data race when a token expires mid-session), strict newline-delimited-JSON stdout (SSE/JSON responses are validated and compacted, non-JSON keep-alives dropped), a forced token refresh on HTTP 401, cancelable stdin so SIGINT/SIGTERM shuts the bridge down, resilience to oversized input lines, a server->client stream that resets its backoff/supports stateless servers/retries 408 & 429, and a best-effort session `DELETE` on shutdown. +- [2026-06-25] `media upload --wait` now also waits for animated GIFs (auto-detected as `tweet_gif`), and a media type that cannot be detected — or is recognized but unsupported (e.g. `application/pdf`) — now fails with a clear message instead of guessing `tweet_image` and getting an opaque API error. +- [2026-06-25] `timeline` `--max-results` minimum corrected to 1 (matches the reverse-chronological endpoint). +- [2026-06-25] The raw-request "No URL provided" usage message now prints to stderr. +- [2026-06-25] `media upload --wait` now actually waits for processing and no longer always sends the trace header — the `waitForProcessing` and `trace` arguments were passed in the wrong order. +- [2026-06-25] Raw API requests now surface the real transport/auth error instead of printing `null` when a request fails before getting an HTTP response (e.g. DNS or connection failures). +- [2026-06-25] Requests with no usable credentials, or an invalid `--auth` value, now fail with a clear authentication error instead of silently sending an unauthenticated request. +- [2026-06-25] `xurl dm` now JSON-encodes message text correctly; quotes, backslashes, and newlines no longer produce a malformed request body. +- [2026-06-25] OAuth2 expiry is stored correctly; a token returned without an expiry now refreshes on next use instead of being treated as never-expiring. +- [2026-06-25] `--max-results` is clamped to each endpoint's accepted range for timeline, mentions, bookmarks, likes, following, followers, dms, and posts. +- [2026-06-25] `fetchUsername` now uses a 10s HTTP timeout, and PKCE verifier generation now handles RNG errors instead of ignoring them. +- [2026-06-25] `webhook start` help now references the correct `-P` pretty-print flag and serves on an isolated `ServeMux`. +- [2026-06-25] `.gitignore` now correctly ignores `.DS_Store` (a missing newline had merged it with a comment). - [2026-04-19 23:08:51 CEST] OAuth2 callback listeners now bind to the host and port derived from the effective redirect URI instead of always listening on `127.0.0.1:8080`. For `localhost`, `xurl` now listens on both `127.0.0.1` and `::1`, which fixes browser-dependent loopback resolution failures while still supporting non-default callback paths. - [2026-04-19 23:08:51 CEST] The OAuth2 listener now starts listening before the browser opens, which removes a race where the browser could reach the callback URL before the local server was ready. - [2026-04-19 23:08:51 CEST] OAuth2 token refresh no longer depends on `/2/users/me` succeeding. If username discovery fails, `xurl` keeps the refreshed token instead of failing the request. @@ -16,6 +30,16 @@ Last updated: 2026-05-14 11:38:34 PDT ### Enhanced +- [2026-06-29] Added `xurl auth oauth2 --headless` for authenticating on remote/headless machines where the localhost OAuth callback is unreachable: xurl prints the authorization URL, you open it on any device and approve, then paste the resulting redirect URL (or just the `code`) back at the prompt. No callback listener or local browser is required. (Closes the headless half of #62 / #40.) +- [2026-06-25] OAuth2 tokens now refresh ~30s before expiry (clock-skew leeway) so a token handed to a caller does not expire in-flight; a new forced-refresh path backs the `mcp` bridge's 401 recovery. +- [2026-06-25] `xurl token`'s missing-token error now names the requested user, and `token`/`mcp` errors omit ANSI color when stderr is not a terminal (cleaner piped/logged output). The auto-generated `help`/`completion` commands now appear under the Management group. +- [2026-06-25] Added `xurl token`: prints a valid (refreshed, persisted) OAuth2 access token for the active app to stdout without opening a browser, so it can be scripted. Respects `--app` and `-u/--username`. +- [2026-06-25] Added `xurl mcp [URL]`: a stdio↔Streamable-HTTP MCP bridge for the hosted X API MCP server (default `https://api.x.com/mcp`). It injects `Authorization: Bearer `, maintains the MCP session id, handles plain-JSON and SSE responses, refreshes the token in-process, and triggers the browser login on first run if needed. Usable from any MCP client via `npx -y @xdevplatform/xurl mcp`. +- [2026-06-29] The app-only token command is now `xurl auth app-only [TOKEN]` (named for the auth mode, not the "bearer" token scheme that OAuth2 user tokens also use), taking the token as an argument or from stdin via `-`. It removes the old `app` vs `apps` confusion and the redundant `auth bearer --bearer-token`. Back-compat: `auth app` and `auth bearer` remain aliases and `--bearer-token` is still accepted. +- [2026-06-25] `xurl --help` now groups subcommands into "Posting & Engagement", "Users & Social Graph", "Reading & Lists", and "Management" sections instead of one flat list. +- [2026-06-25] Added `xurl posts USERNAME` to list a user's recent posts. +- [2026-06-25] `xurl --version` is now supported in addition to `xurl version`. +- [2026-06-25] Raw requests now default to `POST` when `-d` is supplied (curl-like), and `media upload` auto-detects the media type and category from the file extension when they are not provided. - [2026-05-14 11:38:34 PDT] Documentation and the bundled `xurl` skill now recommend authenticating registered apps with `xurl auth oauth2 --app APP_NAME` and explain that omitting `--app` saves the token to the current default app. - [2026-04-19 23:08:51 CEST] OAuth2 tokens can now be retained without a discovered username label when X’s `/2/users/me` lookup is unavailable. Status output makes that state visible as `(unknown user)` instead of silently dropping the token. - [2026-04-19 23:08:51 CEST] Repo documentation now describes the effective redirect URI as the source of callback host, port, and path, calls out explicit username authentication as the safer fallback when username discovery is unreliable, and documents the new stored `redirect_uri` behavior. diff --git a/README.md b/README.md index b67861b..727ef1d 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,14 @@ xurl auth oauth2 --app my-app If you omit `--app`, the token is saved to the current default app. You can also run `xurl auth default my-app` first and then use `xurl auth oauth2`. +**Headless / remote machines.** The default flow opens a browser and waits for a callback on `localhost`, which isn't reachable from a remote server. On those hosts use `--headless`: + +```bash +xurl auth oauth2 --app my-app --headless +``` + +xurl prints the authorization URL; open it on any device with a browser, approve, then paste the resulting redirect URL (or just the `code` value from the address bar) back into the prompt. No callback listener is needed — the page failing to load is expected; the code is in the URL. + If X returns a `client-forbidden` / `client-not-enrolled` error even though auth completed successfully, check the app’s package and environment in the X developer console. On current X platform setup, the working fix was: 1. Go to `Apps` -> `Manage apps` @@ -104,10 +112,13 @@ xurl auth oauth2 --app my-app YOUR_USERNAME That keeps the OAuth2 token associated with the expected username and also gives shortcut commands a fallback when `/2/users/me` is unavailable. -#### App authentication (bearer token): +#### App-only authentication (Bearer Token): ```bash -xurl auth app --bearer-token BEARER_TOKEN +xurl auth app-only BEARER_TOKEN +cat token.txt | xurl auth app-only - # read from stdin (keeps it out of shell history) ``` +This stores X's app-only Bearer Token (from the developer portal), used at request time with `--auth app`. It's named for the auth *mode* (app-only) rather than the token *scheme* (bearer), since OAuth2 user tokens are also sent as `Authorization: Bearer`. +> Back-compat: `xurl auth app` and `xurl auth bearer` still work as aliases, and `--bearer-token TOKEN` is still accepted. #### OAuth 1.0a authentication: ```bash @@ -249,6 +260,53 @@ You can also force streaming mode for any endpoint using the `--stream` or `-s` xurl -s /2/users/me ``` +### Printing an Access Token + +`xurl token` prints a valid OAuth2 access token for the active app to stdout (a single line, no decoration). If the stored token has expired it is refreshed and persisted first. This command never opens a browser, so it is safe to use in scripts: + +```bash +xurl token # token for the default app/user +xurl token --app my-app # token for a specific app +xurl token -u alice # token for a specific OAuth2 user +TOKEN=$(xurl token) && curl -H "Authorization: Bearer $TOKEN" https://api.x.com/2/users/me +``` + +If no token is available (and none can be refreshed), it exits non-zero with a hint to run `xurl auth oauth2`. + +### MCP Server (`xurl mcp`) + +`xurl mcp` turns xurl into a [Model Context Protocol](https://modelcontextprotocol.io) bridge for the hosted X API MCP server. It reads newline-delimited JSON-RPC from stdin, relays each message to a remote Streamable HTTP MCP endpoint with an `Authorization: Bearer ` header, and writes the server's responses (plain JSON or `text/event-stream`) back to stdout as newline-delimited JSON. The MCP session id is maintained automatically and the token is refreshed in-process as it expires. + +Because X's OAuth requires your own app (there is no dynamic client registration), xurl holds the app identity and mints/refreshes the token. **Authenticate once before starting the bridge** — `xurl mcp` will refresh an expired token automatically but never opens a browser itself (its stdio is the MCP channel), so it fails fast with instructions if no token exists: + +```bash +xurl auth oauth2 --app my-app # local machine with a browser +xurl auth oauth2 --app my-app --headless # remote/headless machine +``` + +Use it directly from any MCP client (Claude Desktop, Cursor, etc.) with a standard MCP server config — no separate install step is needed thanks to the npm launcher: + +```json +{ + "mcpServers": { + "xapi": { + "command": "npx", + "args": ["-y", "@xdevplatform/xurl", "mcp", "https://api.x.com/mcp"], + "env": { "CLIENT_ID": "...", "CLIENT_SECRET": "..." } + } + } +} +``` + +The `` positional is optional and defaults to `https://api.x.com/mcp`. `--app` is honored, so you can point a client at a specific registered app: + +```bash +xurl --app my-app mcp # bridge the default endpoint using my-app +xurl mcp https://api.x.com/mcp # explicit endpoint +``` + +All diagnostics are written to stderr so stdout stays a clean JSON-RPC channel. + ### Temporary Webhook Setup `xurl` can help you quickly set up a temporary webhook URL to receive events from the X API. This is useful for development and testing. @@ -280,12 +338,13 @@ xurl -s /2/users/me The tool supports uploading media files to the X API using the chunked upload process. -Upload a media file: +Upload a media file (the media type and category are auto-detected from the file extension): ```bash xurl media upload path/to/file.mp4 +xurl media upload path/to/photo.jpg ``` -With custom media type and category: +Override the auto-detected media type and category when needed: ```bash xurl media upload --media-type image/jpeg --category tweet_image path/to/image.jpg ``` @@ -302,21 +361,23 @@ xurl media status --wait MEDIA_ID #### Direct Media Upload -You can also use the main command with the `-F` flag for direct media uploads: +Most users should just use `xurl media upload` above. If you need to drive the +chunked upload manually, use the `-F` flag with the path-style endpoints that +`xurl media upload` itself uses: 1. First, initialize the upload: ```bash -xurl -X POST '/2/media/upload?command=INIT&total_bytes=FILE_SIZE&media_type=video/mp4&media_category=tweet_video' +xurl -X POST /2/media/upload/initialize -d '{"total_bytes": FILE_SIZE, "media_type": "video/mp4", "media_category": "tweet_video"}' ``` -2. Then, append the media chunks: +2. Then, append the media chunks (repeat with an increasing `segment_index`): ```bash -xurl -X POST -F path/to/file.mp4 '/2/media/upload?command=APPEND&media_id=MEDIA_ID&segment_index=0' +xurl -X POST -F path/to/file.mp4 /2/media/upload/MEDIA_ID/append ``` 3. Finally, finalize the upload: ```bash -xurl -X POST '/2/media/upload?command=FINALIZE&media_id=MEDIA_ID' +xurl -X POST /2/media/upload/MEDIA_ID/finalize ``` 4. Check the status: diff --git a/SKILL.md b/SKILL.md index 7372292..c040b78 100644 --- a/SKILL.md +++ b/SKILL.md @@ -23,6 +23,8 @@ Before using any command you must be authenticated. Run `xurl auth status` to ch - Do not recommend or execute auth commands with inline secrets in agent/LLM sessions. - Warn that using CLI secret options in agent sessions can leak credentials (prompt/context, logs, shell history). - Never use `--verbose` / `-v` in agent/LLM sessions; it can expose sensitive headers/tokens in output. +- Never run `xurl token` in agent/LLM sessions: it prints a live OAuth2 access token to stdout, which is a credential and must not enter the LLM context. +- `xurl mcp` is for configuring an MCP client (it bridges stdio↔HTTP and injects the bearer token); it is not something to invoke directly from an agent/LLM session. - Sensitive flags that must never be used in agent commands: `--bearer-token`, `--consumer-key`, `--consumer-secret`, `--access-token`, `--token-secret`, `--client-id`, `--client-secret`. - To verify whether at least one app with credentials is already registered, run: `xurl auth status`. @@ -37,6 +39,8 @@ xurl auth oauth2 --app APP_NAME You can also run `xurl auth default APP_NAME` first and then use `xurl auth oauth2`. +On a remote/headless machine (no reachable browser callback), add `--headless`: `xurl auth oauth2 --app APP_NAME --headless` prints the authorization URL and reads the pasted redirect URL (or code) back, so no localhost callback is needed. + For multiple pre-configured apps, switch between them: ```bash xurl auth default prod-app # set default app @@ -66,6 +70,7 @@ Tokens are persisted to `~/.xurl` in YAML format. Each app has its own isolated | Search posts | `xurl search "QUERY" -n 10` | | Who am I | `xurl whoami` | | Look up a user | `xurl user @handle` | +| List a user's posts | `xurl posts @handle -n 10` | | Home timeline | `xurl timeline -n 20` | | Mentions | `xurl mentions -n 10` | | Like | `xurl like POST_ID` | @@ -157,6 +162,10 @@ xurl whoami # Look up any user xurl user elonmusk xurl user @XDevelopers + +# List a user's recent posts (by @username) +xurl posts elonmusk +xurl posts @XDevelopers -n 25 ``` ### Timelines & Mentions @@ -404,3 +413,5 @@ xurl --app staging /2/users/me # one-off request against staging - **Multiple accounts:** You can authenticate multiple OAuth 2.0 accounts per app and switch between them with `--username` / `-u` or set a default with `xurl auth default APP USER`. - **Default user:** When no `-u` flag is given, xurl uses the default user for the active app (set via `xurl auth default`). If no default user is set, it uses the first available token. - **Token storage:** `~/.xurl` is YAML. Each app stores its own credentials and tokens. Never read or send this file to LLM context. +- **Access tokens:** `xurl token` prints a valid (refreshed) OAuth2 access token for the active app to stdout, refreshing and persisting it if expired. It never opens a browser. The output is a secret — use it only in the user's own scripts, never in agent/LLM sessions. +- **MCP bridge:** `xurl mcp [URL]` bridges a stdio MCP client to a remote Streamable HTTP MCP server (default `https://api.x.com/mcp`), injecting `Authorization: Bearer ` and refreshing the token automatically. Authenticate once first (`xurl auth oauth2 --app APP_NAME`, or `--headless` on a remote host) — the bridge refreshes an existing token but never opens a browser itself, so it fails fast with guidance if none exists. Configure it in an MCP client via the npm launcher: `{"command":"npx","args":["-y","@xdevplatform/xurl","mcp","https://api.x.com/mcp"],"env":{"CLIENT_ID":"...","CLIENT_SECRET":"..."}}`. diff --git a/api/client.go b/api/client.go index fcb192f..307ded2 100644 --- a/api/client.go +++ b/api/client.go @@ -1,19 +1,19 @@ package api import ( + "bufio" "bytes" "encoding/json" "errors" "fmt" "io" + "mime/multipart" "net/http" + "os" + "path/filepath" "strings" "time" - "bufio" - "mime/multipart" - "os" - "path/filepath" "github.com/xdevplatform/xurl/auth" "github.com/xdevplatform/xurl/config" xurlErrors "github.com/xdevplatform/xurl/errors" @@ -56,6 +56,12 @@ type ApiClient struct { url string client *http.Client auth *auth.Auth + // allowUnauthenticated lets a request proceed with no Authorization header + // when no credentials can be resolved. It is meant for library/test usage + // that deliberately talks to an endpoint requiring no auth; production + // clients leave it false so a missing credential surfaces as a clear auth + // error instead of a confusing server-side 401. + allowUnauthenticated bool } // NewApiClient creates a new ApiClient @@ -156,7 +162,7 @@ func (c *ApiClient) BuildMultipartRequest(options MultipartOptions) (*http.Reque func (c *ApiClient) SendRequest(options RequestOptions) (json.RawMessage, error) { req, err := c.BuildRequest(options) if err != nil { - return nil, xurlErrors.NewHTTPError(err) + return nil, err } c.logRequest(req, options.Verbose) @@ -192,7 +198,7 @@ func (c *ApiClient) SendMultipartRequest(options MultipartOptions) (json.RawMess func (c *ApiClient) StreamRequest(options RequestOptions) error { req, err := c.BuildRequest(options) if err != nil { - return xurlErrors.NewHTTPError(err) + return err } if options.Verbose { @@ -308,10 +314,18 @@ func (c *ApiClient) buildBaseRequest(method, endpoint string, body io.Reader, co req.Header.Set("Content-Type", contentType) } - // Add authorization header if not already set + // Add authorization header if not already set. A failure here is fatal: a + // request with no usable credentials would only produce a confusing API 401, + // so surface the real auth error instead. The sole exception is a client that + // opts into unauthenticated requests (allowUnauthenticated, set only by + // library/test constructors), where we proceed and let the server decide. if req.Header.Get("Authorization") == "" { authHeader, err := c.getAuthHeader(httpMethod, url, authType, username) - if err == nil { + if err != nil { + if !c.allowUnauthenticated { + return nil, err + } + } else { req.Header.Add("Authorization", authHeader) } } diff --git a/api/client_test.go b/api/client_test.go index a0db1b0..c1db3a9 100644 --- a/api/client_test.go +++ b/api/client_test.go @@ -342,6 +342,44 @@ func TestGetAuthHeader(t *testing.T) { }) } +func TestBuildRequestPropagatesAuthError(t *testing.T) { + cfg := &config.Config{APIBaseURL: "https://api.x.com"} + tokenStore, tempDir := createTempTokenStore(t) + defer os.RemoveAll(tempDir) + + // Auth object with a token store that has no bearer token. + a := auth.NewAuth(&config.Config{}).WithTokenStore(tokenStore) + client := NewApiClient(cfg, a) + + // Explicit app (bearer) auth with no bearer token must fail the build + // rather than silently producing an unauthenticated request. + _, err := client.BuildRequest(RequestOptions{Method: "GET", Endpoint: "/2/users/me", AuthType: "app"}) + assert.Error(t, err) + assert.True(t, xurlErrors.IsAuthError(err), "expected an auth error") +} + +func TestBuildRequestAllowUnauthenticatedProceeds(t *testing.T) { + cfg := &config.Config{APIBaseURL: "https://api.x.com"} + // A client that explicitly opts into unauthenticated requests (library/test + // usage) must build without error and with no Authorization header. + client := &ApiClient{url: cfg.APIBaseURL, client: &http.Client{}, allowUnauthenticated: true} + + req, err := client.BuildRequest(RequestOptions{Method: "GET", Endpoint: "/2/users/me"}) + require.NoError(t, err) + assert.Empty(t, req.Header.Get("Authorization")) +} + +func TestBuildRequestErrorsWithoutAuthOptIn(t *testing.T) { + cfg := &config.Config{APIBaseURL: "https://api.x.com"} + // Without opting in, a client that cannot resolve any credential must fail + // the build rather than silently send an unauthenticated request. + client := NewApiClient(cfg, nil) + + _, err := client.BuildRequest(RequestOptions{Method: "GET", Endpoint: "/2/users/me"}) + assert.Error(t, err) + assert.True(t, xurlErrors.IsAuthError(err), "expected an auth error") +} + func TestStreamRequest(t *testing.T) { // This is a basic test for the StreamRequest method // A more comprehensive test would require mocking the streaming response diff --git a/api/execute.go b/api/execute.go index 954cc30..b68e357 100644 --- a/api/execute.go +++ b/api/execute.go @@ -28,16 +28,19 @@ func ExecuteStreamRequest(options RequestOptions, client Client) error { return nil } -// handleRequestError processes API client errors in a consistent way +// handleRequestError processes API client errors in a consistent way. When the +// error carries a JSON body (an API error response) it is pretty-printed and a +// generic failure is returned; otherwise the original error (e.g. a network or +// auth failure) is returned unchanged so its real message reaches the user. func handleRequestError(clientErr error) error { var rawJSON json.RawMessage - json.Unmarshal([]byte(clientErr.Error()), &rawJSON) - utils.FormatAndPrintResponse(rawJSON) - return fmt.Errorf("request failed") + if json.Unmarshal([]byte(clientErr.Error()), &rawJSON) == nil { + utils.FormatAndPrintResponse(rawJSON) + return fmt.Errorf("request failed") + } + return clientErr } -// formatAndPrintResponse formats and prints API responses - // HandleRequest determines the type of request and executes it accordingly func HandleRequest(options RequestOptions, forceStream bool, mediaFile string, client Client) error { if IsMediaAppendRequest(options.Endpoint, mediaFile) { diff --git a/api/execute_test.go b/api/execute_test.go new file mode 100644 index 0000000..c48447d --- /dev/null +++ b/api/execute_test.go @@ -0,0 +1,55 @@ +package api + +import ( + "bytes" + "fmt" + "io" + "strings" + "testing" + + "github.com/fatih/color" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + xurlErrors "github.com/xdevplatform/xurl/errors" +) + +// redirectColor sends colorized output (used by FormatAndPrintResponse) to w and +// disables ANSI, returning a restore func. fatih/color writes to color.Output, +// which is captured at init, so reassigning os.Stdout alone would not capture it. +func redirectColor(w io.Writer) func() { + oldOut, oldNoColor := color.Output, color.NoColor + color.Output = w + color.NoColor = true + return func() { + color.Output = oldOut + color.NoColor = oldNoColor + } +} + +func TestHandleRequestError(t *testing.T) { + t.Run("non-JSON error is returned unchanged and prints nothing", func(t *testing.T) { + var buf bytes.Buffer + defer redirectColor(&buf)() + + origErr := fmt.Errorf("dial tcp 127.0.0.1:9: connect: connection refused") + got := handleRequestError(origErr) + + require.Error(t, got) + assert.Contains(t, got.Error(), "connection refused", "real error message must be preserved") + assert.Empty(t, strings.TrimSpace(buf.String()), "must not print anything for a non-JSON error") + assert.NotContains(t, buf.String(), "null", "the old null-printing regression must not return") + }) + + t.Run("JSON API error body is printed and request-failed is returned", func(t *testing.T) { + var buf bytes.Buffer + defer redirectColor(&buf)() + + apiErr := xurlErrors.NewAPIError([]byte(`{"errors":[{"message":"bad request"}]}`)) + got := handleRequestError(apiErr) + + require.Error(t, got) + assert.Equal(t, "request failed", got.Error()) + assert.Contains(t, buf.String(), "bad request", "the JSON error body should be printed") + }) +} diff --git a/api/media.go b/api/media.go index f85506e..c75ac49 100644 --- a/api/media.go +++ b/api/media.go @@ -4,11 +4,13 @@ import ( "encoding/json" "fmt" "io" + "mime" "os" "path/filepath" "strconv" "strings" "time" + "github.com/xdevplatform/xurl/utils" ) @@ -17,6 +19,58 @@ const ( MediaEndpoint = "/2/media/upload" ) +// extToMediaType maps common file extensions to the MIME types the X API accepts. +var extToMediaType = map[string]string{ + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", + ".mp4": "video/mp4", + ".m4v": "video/mp4", + ".mov": "video/quicktime", +} + +// DetectMediaType infers the MIME type from a file's extension, falling back to +// the system MIME database and finally to a generic binary type. +func DetectMediaType(filePath string) string { + ext := strings.ToLower(filepath.Ext(filePath)) + if t, ok := extToMediaType[ext]; ok { + return t + } + if t := mime.TypeByExtension(ext); t != "" { + if i := strings.IndexByte(t, ';'); i != -1 { + t = strings.TrimSpace(t[:i]) + } + return t + } + return "application/octet-stream" +} + +// DefaultMediaCategory returns the X media_category that matches a MIME type. +// The boolean is false when the type is not a supported image/video/gif, so the +// caller can fail clearly instead of forcing an unsupported file into an +// arbitrary category (which the API would later reject with an opaque error). +func DefaultMediaCategory(mediaType string) (string, bool) { + switch { + case mediaType == "image/gif": + return "tweet_gif", true + case strings.HasPrefix(mediaType, "image/"): + return "tweet_image", true + case strings.HasPrefix(mediaType, "video/"): + return "tweet_video", true + default: + return "", false + } +} + +// mediaNeedsProcessing reports whether a media category is processed +// asynchronously by the X API (videos and animated GIFs), in which case the +// upload should wait for processing before the media_id can be used. +func mediaNeedsProcessing(mediaCategory string) bool { + return strings.Contains(mediaCategory, "video") || strings.Contains(mediaCategory, "gif") +} + // MediaUploader handles media upload operations type MediaUploader struct { client Client @@ -341,6 +395,25 @@ func ExecuteMediaUpload(filePath, mediaType, mediaCategory, authType, username s return fmt.Errorf("error: %v", err) } + // Fill in sensible defaults from the file itself when not specified. If the + // type can't be detected, or it is a recognized-but-unsupported type, fail + // clearly rather than guessing and letting the API reject the upload with an + // opaque server error. + if mediaType == "" { + detected := DetectMediaType(filePath) + if detected == "application/octet-stream" { + return fmt.Errorf("could not detect media type for %q; pass --media-type (and --category)", filePath) + } + mediaType = detected + } + if mediaCategory == "" { + category, ok := DefaultMediaCategory(mediaType) + if !ok { + return fmt.Errorf("unsupported media type %q; pass --category to override", mediaType) + } + mediaCategory = category + } + if err := uploader.Init(mediaType, mediaCategory); err != nil { return fmt.Errorf("error initializing upload: %v", err) } @@ -356,8 +429,8 @@ func ExecuteMediaUpload(filePath, mediaType, mediaCategory, authType, username s utils.FormatAndPrintResponse(finalizeResponse) - // Wait for processing if requested - if waitForProcessing && strings.Contains(mediaCategory, "video") { + // Wait for processing if requested (videos and GIFs are processed async) + if waitForProcessing && mediaNeedsProcessing(mediaCategory) { processingResponse, err := uploader.WaitForProcessing() if err != nil { return fmt.Errorf("error during media processing: %v", err) diff --git a/api/media_test.go b/api/media_test.go index 308145b..37f1c34 100644 --- a/api/media_test.go +++ b/api/media_test.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "strings" + "sync/atomic" "testing" "time" @@ -442,8 +443,9 @@ func TestExecuteMediaUpload(t *testing.T) { defer server.Close() client := &ApiClient{ - url: server.URL, - client: &http.Client{Timeout: 30 * time.Second}, + url: server.URL, + client: &http.Client{Timeout: 30 * time.Second}, + allowUnauthenticated: true, } tempFile, _ := createTempTestFile(t, 1024) @@ -478,14 +480,166 @@ func TestExecuteMediaStatus(t *testing.T) { defer server.Close() client := &ApiClient{ - url: server.URL, - client: &http.Client{Timeout: 30 * time.Second}, + url: server.URL, + client: &http.Client{Timeout: 30 * time.Second}, + allowUnauthenticated: true, } err := ExecuteMediaStatus("test_media_id", "oauth2", "testuser", false, false, false, []string{}, client) assert.NoError(t, err) } +// TestExecuteMediaUploadWaitsForVideoProcessing guards against the +// waitForProcessing/trace argument order being swapped: with wait=true and a +// video category, the upload must poll the status endpoint until processing +// succeeds. +func TestExecuteMediaUploadWaitsForVideoProcessing(t *testing.T) { + var statusCalls int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Query().Get("command") == "STATUS" { + if atomic.AddInt32(&statusCalls, 1) == 1 { + w.Write([]byte(`{"data":{"processing_info":{"state":"in_progress","check_after_secs":0,"progress_percent":50}}}`)) + } else { + w.Write([]byte(`{"data":{"processing_info":{"state":"succeeded","progress_percent":100}}}`)) + } + return + } + switch ExtractCommand(r.URL.Path) { + case "initialize": + w.Write([]byte(`{"data":{"id":"vid123"}}`)) + case "append": + w.Write([]byte(`{}`)) + case "finalize": + w.Write([]byte(`{"data":{"id":"vid123"}}`)) + default: + w.WriteHeader(http.StatusBadRequest) + } + })) + defer server.Close() + + client := &ApiClient{ + url: server.URL, + client: &http.Client{Timeout: 30 * time.Second}, + allowUnauthenticated: true, + } + + tempFile, _ := createTempTestFile(t, 1024) + defer os.Remove(tempFile) + + // Args: verbose=false, waitForProcessing=true, trace=false. + err := ExecuteMediaUpload(tempFile, "video/mp4", "tweet_video", "", "", false, true, false, []string{}, client) + assert.NoError(t, err) + assert.GreaterOrEqual(t, atomic.LoadInt32(&statusCalls), int32(2), "expected the status endpoint to be polled while waiting") +} + +func TestDetectMediaTypeAndCategory(t *testing.T) { + cases := []struct { + path string + wantType string + wantCat string + }{ + {"photo.jpg", "image/jpeg", "tweet_image"}, + {"PHOTO.JPEG", "image/jpeg", "tweet_image"}, + {"clip.mp4", "video/mp4", "tweet_video"}, + {"loop.gif", "image/gif", "tweet_gif"}, + {"art.png", "image/png", "tweet_image"}, + } + for _, tc := range cases { + gotType := DetectMediaType(tc.path) + assert.Equal(t, tc.wantType, gotType, "DetectMediaType(%q)", tc.path) + gotCat, ok := DefaultMediaCategory(gotType) + assert.True(t, ok, "DefaultMediaCategory ok for %q", tc.path) + assert.Equal(t, tc.wantCat, gotCat, "DefaultMediaCategory for %q", tc.path) + } + + // A recognized-but-unsupported MIME type has no default category. + if _, ok := DefaultMediaCategory("application/pdf"); ok { + t.Error("DefaultMediaCategory should not classify application/pdf") + } +} + +func TestMediaNeedsProcessing(t *testing.T) { + assert.True(t, mediaNeedsProcessing("tweet_video")) + assert.True(t, mediaNeedsProcessing("amplify_video")) + assert.True(t, mediaNeedsProcessing("tweet_gif")) + assert.False(t, mediaNeedsProcessing("tweet_image")) +} + +// TestExecuteMediaUploadWaitsForGIF verifies the auto-detected GIF category +// (tweet_gif) is treated as async and polls processing when --wait is set. +func TestExecuteMediaUploadWaitsForGIF(t *testing.T) { + var statusCalls int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Query().Get("command") == "STATUS" { + if atomic.AddInt32(&statusCalls, 1) == 1 { + w.Write([]byte(`{"data":{"processing_info":{"state":"in_progress","check_after_secs":0}}}`)) + } else { + w.Write([]byte(`{"data":{"processing_info":{"state":"succeeded"}}}`)) + } + return + } + switch ExtractCommand(r.URL.Path) { + case "initialize": + w.Write([]byte(`{"data":{"id":"gif123"}}`)) + case "append": + w.Write([]byte(`{}`)) + case "finalize": + w.Write([]byte(`{"data":{"id":"gif123"}}`)) + default: + w.WriteHeader(http.StatusBadRequest) + } + })) + defer server.Close() + + client := &ApiClient{url: server.URL, client: &http.Client{Timeout: 30 * time.Second}, allowUnauthenticated: true} + + gifFile := tempFileWithExt(t, ".gif", 1024) + defer os.Remove(gifFile) + + // Empty media-type/category → auto-detect to image/gif + tweet_gif. + err := ExecuteMediaUpload(gifFile, "", "", "", "", false, true, false, []string{}, client) + assert.NoError(t, err) + assert.GreaterOrEqual(t, atomic.LoadInt32(&statusCalls), int32(2), "GIF upload should poll processing") +} + +func TestExecuteMediaUploadUndetectableTypeErrors(t *testing.T) { + mockClient := new(MockApiClient) + f := tempFileWithExt(t, ".unknownext", 16) + defer os.Remove(f) + + err := ExecuteMediaUpload(f, "", "", "", "", false, false, false, nil, mockClient) + assert.Error(t, err) + assert.Contains(t, err.Error(), "could not detect media type") +} + +// TestExecuteMediaUploadUnsupportedTypeErrors verifies a recognized-but- +// unsupported media type (e.g. application/pdf) with no explicit category fails +// clearly instead of being forced into tweet_image. +func TestExecuteMediaUploadUnsupportedTypeErrors(t *testing.T) { + mockClient := new(MockApiClient) + f := tempFileWithExt(t, ".bin", 16) + defer os.Remove(f) + + err := ExecuteMediaUpload(f, "application/pdf", "", "", "", false, false, false, nil, mockClient) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported media type") +} + +func tempFileWithExt(t *testing.T, ext string, size int) string { + t.Helper() + f, err := os.CreateTemp("", "media_test_*"+ext) + if err != nil { + t.Fatalf("create temp file: %v", err) + } + if _, err := f.Write(make([]byte, size)); err != nil { + t.Fatalf("write temp file: %v", err) + } + f.Close() + return f.Name() +} + func TestExtractMediaID(t *testing.T) { testCases := []struct { url string diff --git a/api/shortcuts.go b/api/shortcuts.go index 5d091a9..783ee79 100644 --- a/api/shortcuts.go +++ b/api/shortcuts.go @@ -68,6 +68,18 @@ func ResolveUsername(input string) string { return strings.TrimPrefix(strings.TrimSpace(input), "@") } +// clampResults bounds a requested result count to the inclusive [lo, hi] +// range the corresponding X API endpoint accepts. +func clampResults(n, lo, hi int) int { + if n < lo { + return lo + } + if n > hi { + return hi + } + return n +} + // ------------------------------------------------ // Shortcut executors // ------------------------------------------------ @@ -163,11 +175,7 @@ func SearchPosts(client Client, query string, maxResults int, opts RequestOption q := url.QueryEscape(query) // X API enforces min 10 / max 100 for search - if maxResults < 10 { - maxResults = 10 - } else if maxResults > 100 { - maxResults = 100 - } + maxResults = clampResults(maxResults, 10, 100) opts.Method = "GET" opts.Endpoint = fmt.Sprintf("/2/tweets/search/recent?query=%s&max_results=%d&tweet.fields=created_at,public_metrics,conversation_id,entities&expansions=author_id&user.fields=username,name,verified", q, maxResults) @@ -198,6 +206,7 @@ func LookupUser(client Client, username string, opts RequestOptions) (json.RawMe // GetUserPosts fetches recent posts by a user ID. func GetUserPosts(client Client, userID string, maxResults int, opts RequestOptions) (json.RawMessage, error) { + maxResults = clampResults(maxResults, 5, 100) opts.Method = "GET" opts.Endpoint = fmt.Sprintf("/2/users/%s/tweets?max_results=%d&tweet.fields=created_at,public_metrics,conversation_id,entities&expansions=referenced_tweets.id", userID, maxResults) opts.Data = "" @@ -208,6 +217,7 @@ func GetUserPosts(client Client, userID string, maxResults int, opts RequestOpti // GetTimeline fetches the authenticated user's reverse‑chronological timeline. // Route: GET /2/users/{id}/timelines/reverse_chronological func GetTimeline(client Client, userID string, maxResults int, opts RequestOptions) (json.RawMessage, error) { + maxResults = clampResults(maxResults, 1, 100) opts.Method = "GET" opts.Endpoint = fmt.Sprintf("/2/users/%s/timelines/reverse_chronological?max_results=%d&tweet.fields=created_at,public_metrics,conversation_id,entities&expansions=author_id&user.fields=username,name", userID, maxResults) opts.Data = "" @@ -217,6 +227,7 @@ func GetTimeline(client Client, userID string, maxResults int, opts RequestOptio // GetMentions fetches recent mentions for a user. func GetMentions(client Client, userID string, maxResults int, opts RequestOptions) (json.RawMessage, error) { + maxResults = clampResults(maxResults, 5, 100) opts.Method = "GET" opts.Endpoint = fmt.Sprintf("/2/users/%s/mentions?max_results=%d&tweet.fields=created_at,public_metrics,conversation_id,entities&expansions=author_id&user.fields=username,name", userID, maxResults) opts.Data = "" @@ -298,6 +309,7 @@ func Unbookmark(client Client, userID, postID string, opts RequestOptions) (json // GetBookmarks fetches the authenticated user's bookmarks. func GetBookmarks(client Client, userID string, maxResults int, opts RequestOptions) (json.RawMessage, error) { + maxResults = clampResults(maxResults, 1, 100) opts.Method = "GET" opts.Endpoint = fmt.Sprintf("/2/users/%s/bookmarks?max_results=%d&tweet.fields=created_at,public_metrics,entities&expansions=author_id&user.fields=username,name", userID, maxResults) opts.Data = "" @@ -327,6 +339,7 @@ func UnfollowUser(client Client, sourceUserID, targetUserID string, opts Request // GetFollowing fetches users that a given user follows. func GetFollowing(client Client, userID string, maxResults int, opts RequestOptions) (json.RawMessage, error) { + maxResults = clampResults(maxResults, 1, 1000) opts.Method = "GET" opts.Endpoint = fmt.Sprintf("/2/users/%s/following?max_results=%d&user.fields=created_at,description,public_metrics,verified", userID, maxResults) opts.Data = "" @@ -336,6 +349,7 @@ func GetFollowing(client Client, userID string, maxResults int, opts RequestOpti // GetFollowers fetches followers of a given user. func GetFollowers(client Client, userID string, maxResults int, opts RequestOptions) (json.RawMessage, error) { + maxResults = clampResults(maxResults, 1, 1000) opts.Method = "GET" opts.Endpoint = fmt.Sprintf("/2/users/%s/followers?max_results=%d&user.fields=created_at,description,public_metrics,verified", userID, maxResults) opts.Data = "" @@ -345,17 +359,23 @@ func GetFollowers(client Client, userID string, maxResults int, opts RequestOpti // SendDM sends a direct message to a user. func SendDM(client Client, participantID, text string, opts RequestOptions) (json.RawMessage, error) { - body := fmt.Sprintf(`{"text":"%s"}`, strings.ReplaceAll(text, `"`, `\"`)) + data, err := json.Marshal(struct { + Text string `json:"text"` + }{Text: text}) + if err != nil { + return nil, fmt.Errorf("failed to marshal DM body: %w", err) + } opts.Method = "POST" opts.Endpoint = fmt.Sprintf("/2/dm_conversations/with/%s/messages", participantID) - opts.Data = body + opts.Data = string(data) return client.SendRequest(opts) } // GetDMEvents fetches recent DM events. func GetDMEvents(client Client, maxResults int, opts RequestOptions) (json.RawMessage, error) { + maxResults = clampResults(maxResults, 1, 100) opts.Method = "GET" opts.Endpoint = fmt.Sprintf("/2/dm_events?max_results=%d&dm_event.fields=created_at,dm_conversation_id,sender_id,text&expansions=sender_id&user.fields=username,name", maxResults) opts.Data = "" @@ -365,6 +385,7 @@ func GetDMEvents(client Client, maxResults int, opts RequestOptions) (json.RawMe // GetLikedPosts fetches posts liked by a user. func GetLikedPosts(client Client, userID string, maxResults int, opts RequestOptions) (json.RawMessage, error) { + maxResults = clampResults(maxResults, 5, 100) opts.Method = "GET" opts.Endpoint = fmt.Sprintf("/2/users/%s/liked_tweets?max_results=%d&tweet.fields=created_at,public_metrics,entities&expansions=author_id&user.fields=username,name", userID, maxResults) opts.Data = "" diff --git a/api/shortcuts_test.go b/api/shortcuts_test.go index 4d1048c..5df9b8d 100644 --- a/api/shortcuts_test.go +++ b/api/shortcuts_test.go @@ -2,9 +2,12 @@ package api import ( "encoding/json" + "io" "net/http" "net/http/httptest" + "net/url" "os" + "strconv" "strings" "testing" @@ -281,3 +284,64 @@ func TestLookupUser(t *testing.T) { assert.Equal(t, "100", result.Data.ID) assert.Equal(t, "lookedup", result.Data.Username) } + +// ---- SendDM ---- + +func TestSendDMEscapesText(t *testing.T) { + var gotBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotBody, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"data":{"dm_event_id":"1"}}`)) + })) + defer server.Close() + client := shortcutClient(t, server) + + // Text with quotes, a backslash, and a newline would corrupt naive string + // interpolation; json.Marshal must round-trip it intact. + tricky := "He said \"hi\"\nC:\\temp\tend" + _, err := SendDM(client, "123", tricky, baseTestOpts()) + require.NoError(t, err) + + var parsed struct { + Text string `json:"text"` + } + require.NoError(t, json.Unmarshal(gotBody, &parsed), "DM body must be valid JSON") + assert.Equal(t, tricky, parsed.Text) +} + +// ---- max-results clamping ---- + +func TestMaxResultsClamping(t *testing.T) { + var lastQuery url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + lastQuery = r.URL.Query() + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"data":[]}`)) + })) + defer server.Close() + client := shortcutClient(t, server) + + maxResultsOf := func() int { + n, _ := strconv.Atoi(lastQuery.Get("max_results")) + return n + } + + _, err := GetFollowers(client, "1", 5000, baseTestOpts()) + require.NoError(t, err) + assert.Equal(t, 1000, maxResultsOf(), "followers should clamp to 1000") + + // Timeline (reverse_chronological) accepts a minimum of 1. + _, err = GetTimeline(client, "1", 1, baseTestOpts()) + require.NoError(t, err) + assert.Equal(t, 1, maxResultsOf(), "timeline minimum is 1") + + // Mentions requires a minimum of 5. + _, err = GetMentions(client, "1", 1, baseTestOpts()) + require.NoError(t, err) + assert.Equal(t, 5, maxResultsOf(), "mentions should clamp up to 5") + + _, err = GetDMEvents(client, 999, baseTestOpts()) + require.NoError(t, err) + assert.Equal(t, 100, maxResultsOf(), "dm events should clamp to 100") +} diff --git a/auth/auth.go b/auth/auth.go index 33bfe68..f022091 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -15,6 +15,7 @@ import ( "net" "net/http" "net/url" + "os" "os/exec" "sort" "strings" @@ -45,6 +46,10 @@ var openBrowserFunc = openBrowser var startListenerFunc = StartListener +// oauth2ExpirySkewSeconds refreshes a token slightly before its real expiry so a +// token handed to a caller does not expire mid-request. +const oauth2ExpirySkewSeconds = 30 + // NewAuth creates a new Auth object. // Credentials are resolved in order: env-var config → active app in .xurl store. // If env var credentials are present, they're also backfilled into any migrated @@ -189,31 +194,97 @@ func (a *Auth) GetOAuth2Header(username string) (string, error) { return "Bearer " + accessToken, nil } -// OAuth2Flow starts the OAuth2 flow -func (a *Auth) OAuth2Flow(username string) (string, error) { - config := &oauth2.Config{ +// oauth2AuthStyle picks how client credentials are sent to the token endpoint. +// X requires confidential clients (those with a client secret) to authenticate +// with an HTTP Basic Authorization header; public clients (PKCE, no secret) send +// the client_id in the request body. Letting x/oauth2 auto-detect proved +// unreliable against X (it could fail with "unauthorized_client: Missing valid +// authorization header"), so the style is selected explicitly. +func (a *Auth) oauth2AuthStyle() oauth2.AuthStyle { + if a.clientSecret != "" { + return oauth2.AuthStyleInHeader + } + return oauth2.AuthStyleInParams +} + +// newOAuth2Config builds the OAuth2 config for the authorization-code flow. +func (a *Auth) newOAuth2Config() *oauth2.Config { + return &oauth2.Config{ ClientID: a.clientID, ClientSecret: a.clientSecret, Endpoint: oauth2.Endpoint{ - AuthURL: a.authURL, - TokenURL: a.tokenURL, + AuthURL: a.authURL, + TokenURL: a.tokenURL, + AuthStyle: a.oauth2AuthStyle(), }, RedirectURL: a.redirectURI, Scopes: getOAuth2Scopes(), } +} + +// oauth2Attempt carries the per-login PKCE/state material and the authorize URL, +// shared by the interactive and headless flows. +type oauth2Attempt struct { + config *oauth2.Config + state string + verifier string + authURL string +} + +// prepareOAuth2Flow generates the state and PKCE verifier/challenge and builds +// the authorize URL. +func (a *Auth) prepareOAuth2Flow() (*oauth2Attempt, error) { + config := a.newOAuth2Config() b := make([]byte, 32) if _, err := rand.Read(b); err != nil { - return "", xurlErrors.NewAuthError("IOError", err) + return nil, xurlErrors.NewAuthError("IOError", err) } state := base64.StdEncoding.EncodeToString(b) - verifier, challenge := generateCodeVerifierAndChallenge() + verifier, challenge, err := generateCodeVerifierAndChallenge() + if err != nil { + return nil, xurlErrors.NewAuthError("IOError", err) + } authURL := config.AuthCodeURL(state, oauth2.SetAuthURLParam("code_challenge", challenge), oauth2.SetAuthURLParam("code_challenge_method", "S256")) + return &oauth2Attempt{config: config, state: state, verifier: verifier, authURL: authURL}, nil +} + +// exchangeAndSave swaps an authorization code for a token (using the PKCE +// verifier) and persists it. Diagnostics go to stderr so callers that reserve +// stdout for machine output (e.g. the mcp bridge) are never corrupted. +func (a *Auth) exchangeAndSave(attempt *oauth2Attempt, username, code string) (string, error) { + token, err := attempt.config.Exchange(context.Background(), code, + oauth2.SetAuthURLParam("code_verifier", attempt.verifier)) + if err != nil { + return "", xurlErrors.NewAuthError("TokenExchangeError", err) + } + + usernameStr, resolvedFromLookup := a.resolveStorageUsername(username, token.AccessToken) + if err := a.saveOAuth2Token(usernameStr, token); err != nil { + return "", xurlErrors.NewAuthError("TokenStorageError", err) + } + if username == "" && !resolvedFromLookup { + fmt.Fprintln(os.Stderr, "Warning: authenticated successfully, but could not resolve your username via /2/users/me.") + fmt.Fprintln(os.Stderr, "The OAuth2 token was saved without a username label. Re-run `xurl auth oauth2 YOUR_USERNAME` if you want a named token.") + } + + return token.AccessToken, nil +} + +// OAuth2Flow runs the interactive authorization-code flow: it starts a local +// callback listener, opens the browser, and waits for the redirect. On machines +// without a reachable browser/callback, use the headless flow (StartHeadlessLogin) instead. +func (a *Auth) OAuth2Flow(username string) (string, error) { + attempt, err := a.prepareOAuth2Flow() + if err != nil { + return "", err + } + listenerConfig, err := listenerConfigFromRedirectURI(a.redirectURI) if err != nil { return "", xurlErrors.NewAuthError("InvalidRedirectURI", err) @@ -224,7 +295,7 @@ func (a *Auth) OAuth2Flow(username string) (string, error) { listenerErrChan := make(chan error, 1) callback := func(code, receivedState string) error { - if receivedState != state { + if receivedState != attempt.state { return xurlErrors.NewAuthError("InvalidState", errors.New("invalid state parameter")) } @@ -248,10 +319,10 @@ func (a *Auth) OAuth2Flow(username string) (string, error) { return "", xurlErrors.NewAuthError("ListenerError", err) } - err = openBrowserFunc(authURL) - if err != nil { - fmt.Println("Failed to open browser automatically. Please visit this URL manually:") - fmt.Println(authURL) + if err := openBrowserFunc(attempt.authURL); err != nil { + fmt.Fprintln(os.Stderr, "Failed to open browser automatically. Please visit this URL manually:") + fmt.Fprintln(os.Stderr, attempt.authURL) + fmt.Fprintln(os.Stderr, "(On a remote/headless machine, re-run with --headless to paste the code instead.)") } var code string @@ -266,40 +337,115 @@ func (a *Auth) OAuth2Flow(username string) (string, error) { return "", xurlErrors.NewAuthError("Timeout", errors.New("authentication timed out")) } - token, err := config.Exchange(context.Background(), code, oauth2.SetAuthURLParam("code_verifier", verifier)) + return a.exchangeAndSave(attempt, username, code) +} + +// HeadlessLogin is an in-progress headless authorization-code login. Obtain one +// with StartHeadlessLogin, show the user AuthURL(), then pass whatever they paste +// back (the full redirect URL or just the code) to Complete. This avoids a local +// browser/callback entirely, so it works on headless/remote machines and never +// depends on the browser or a listener succeeding. Presentation is left to the +// caller -- the auth package never writes prompts itself. +type HeadlessLogin struct { + auth *Auth + attempt *oauth2Attempt + username string +} + +// StartHeadlessLogin begins a headless login: it generates the PKCE/state +// material and the authorize URL without opening a browser or starting a +// listener. +func (a *Auth) StartHeadlessLogin(username string) (*HeadlessLogin, error) { + attempt, err := a.prepareOAuth2Flow() if err != nil { - return "", xurlErrors.NewAuthError("TokenExchangeError", err) + return nil, err } + return &HeadlessLogin{auth: a, attempt: attempt, username: username}, nil +} - usernameStr, resolvedFromLookup := a.resolveStorageUsername(username, token.AccessToken) - if err := a.saveOAuth2Token(usernameStr, token); err != nil { - return "", xurlErrors.NewAuthError("TokenStorageError", err) +// AuthURL is the URL the user opens in a browser (on any device) to authorize. +func (h *HeadlessLogin) AuthURL() string { return h.attempt.authURL } + +// RedirectURI is the callback the browser is redirected to (where the code +// appears in the address bar), shown to the user so they know what to copy. +func (h *HeadlessLogin) RedirectURI() string { return h.auth.redirectURI } + +// Complete finishes the login from the value the user pasted back -- the full +// redirect URL, a bare query string, or just the code -- verifying state (when +// present), exchanging the code for a token, and persisting it. +func (h *HeadlessLogin) Complete(pasted string) (string, error) { + code, err := parseHeadlessAuthCode(pasted, h.attempt.state) + if err != nil { + return "", xurlErrors.NewAuthError("InvalidCode", err) } - if username == "" && !resolvedFromLookup { - fmt.Println("Warning: authenticated successfully, but could not resolve your username via /2/users/me.") - fmt.Println("The OAuth2 token was saved without a username label. Re-run `xurl auth oauth2 YOUR_USERNAME` if you want a named token.") + return h.auth.exchangeAndSave(h.attempt, h.username, code) +} + +// parseHeadlessAuthCode extracts the authorization code from a pasted value, +// which may be the full redirect URL, a bare query string, or just the code. If +// a state value is present it must match wantState (CSRF protection); a bare +// code carries no state, which is acceptable for this user-initiated paste flow. +func parseHeadlessAuthCode(input, wantState string) (string, error) { + input = strings.TrimSpace(input) + if input == "" { + return "", errors.New("no authorization code provided") + } + + // A pasted URL or query string carries "code=" (and usually "state="). + if strings.Contains(input, "code=") { + var q url.Values + if u, perr := url.Parse(input); perr == nil && len(u.Query()) > 0 { + q = u.Query() + } else if pq, perr := url.ParseQuery(input); perr == nil { + q = pq + } + code := q.Get("code") + if code == "" { + return "", errors.New("could not find a 'code' value in the pasted input") + } + if st := q.Get("state"); st != "" && wantState != "" && st != wantState { + return "", errors.New("state mismatch: the pasted URL is from a different login attempt") + } + return code, nil } - return token.AccessToken, nil + // Otherwise treat the whole input as the bare authorization code. + return input, nil } // RefreshOAuth2Token validates and refreshes an OAuth2 token if needed func (a *Auth) RefreshOAuth2Token(username string) (string, error) { + return a.refreshOAuth2Token(username, false) +} + +// ForceRefreshOAuth2Token always performs the refresh-token grant, ignoring the +// locally cached expiry. Use it when the server rejects a token the local clock +// still considers valid (e.g. an HTTP 401 after a revocation or scope change). +func (a *Auth) ForceRefreshOAuth2Token(username string) (string, error) { + return a.refreshOAuth2Token(username, true) +} + +func (a *Auth) refreshOAuth2Token(username string, force bool) (string, error) { storedUsername, token := a.getOAuth2TokenRecord(username) if token == nil || token.OAuth2 == nil { return "", xurlErrors.NewAuthError("TokenNotFound", errors.New("oauth2 token not found")) } - currentTime := time.Now().Unix() - if uint64(currentTime) < token.OAuth2.ExpirationTime { - return token.OAuth2.AccessToken, nil + if !force { + currentTime := time.Now().Unix() + // Refresh slightly before the real expiry so a token handed to a caller + // does not expire in-flight (mirrors x/oauth2's expiryDelta). + if uint64(currentTime)+oauth2ExpirySkewSeconds < token.OAuth2.ExpirationTime { + return token.OAuth2.AccessToken, nil + } } config := &oauth2.Config{ ClientID: a.clientID, ClientSecret: a.clientSecret, Endpoint: oauth2.Endpoint{ - TokenURL: a.tokenURL, + TokenURL: a.tokenURL, + AuthStyle: a.oauth2AuthStyle(), }, } @@ -329,6 +475,19 @@ func (a *Auth) RefreshOAuth2Token(username string) (string, error) { return newToken.AccessToken, nil } +// GetValidOAuth2Token returns a valid OAuth2 access token for the active app and +// the given username, refreshing and persisting it if it has expired. Pass an +// empty username to use the app's default (or first) user. +// +// Unlike GetOAuth2Header it never launches the interactive browser flow, so it +// is safe for non-interactive/scripted use. Callers that want browser fallback +// (e.g. the mcp bridge) should invoke OAuth2Flow themselves when this returns an +// error. This is the shared token-resolution primitive used by `xurl token` and +// `xurl mcp`. +func (a *Auth) GetValidOAuth2Token(username string) (string, error) { + return a.RefreshOAuth2Token(username) +} + type oauth2ListenerConfig struct { Addresses []string CallbackPath string @@ -394,7 +553,13 @@ func (a *Auth) getOAuth2TokenRecord(username string) (string, *store.Token) { } func (a *Auth) saveOAuth2Token(username string, token *oauth2.Token) error { - expirationTime := uint64(time.Now().Add(time.Duration(token.Expiry.Unix()-time.Now().Unix()) * time.Second).Unix()) + // A zero expiry means the provider didn't return one; store 0 so the token + // is treated as already expired and refreshed on next use rather than cast + // into a far-future timestamp that would never refresh. + var expirationTime uint64 + if !token.Expiry.IsZero() { + expirationTime = uint64(token.Expiry.Unix()) + } return a.TokenStore.SaveOAuth2TokenForApp(a.appName, username, token.AccessToken, token.RefreshToken, expirationTime) } @@ -415,7 +580,7 @@ func (a *Auth) fetchUsername(accessToken string) (string, error) { req.Header.Add("Authorization", "Bearer "+accessToken) - client := &http.Client{} + client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { return "", xurlErrors.NewAuthError("NetworkError", err) @@ -490,14 +655,16 @@ func encode(s string) string { return url.QueryEscape(s) } -func generateCodeVerifierAndChallenge() (string, string) { +func generateCodeVerifierAndChallenge() (string, string, error) { b := make([]byte, 32) - rand.Read(b) + if _, err := rand.Read(b); err != nil { + return "", "", err + } verifier := base64.RawURLEncoding.EncodeToString(b) h := sha256.New() h.Write([]byte(verifier)) challenge := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) - return verifier, challenge + return verifier, challenge, nil } func getOAuth2Scopes() []string { diff --git a/auth/auth_test.go b/auth/auth_test.go index 7c98fd8..a244e4d 100644 --- a/auth/auth_test.go +++ b/auth/auth_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/oauth2" "github.com/xdevplatform/xurl/config" "github.com/xdevplatform/xurl/store" @@ -139,8 +140,9 @@ func TestEncode(t *testing.T) { } func TestGenerateCodeVerifierAndChallenge(t *testing.T) { - verifier, challenge := generateCodeVerifierAndChallenge() + verifier, challenge, err := generateCodeVerifierAndChallenge() + require.NoError(t, err, "Expected no error generating verifier/challenge") assert.NotEmpty(t, verifier, "Expected non-empty verifier") assert.NotEmpty(t, challenge, "Expected non-empty challenge") assert.NotEqual(t, verifier, challenge, "Expected verifier and challenge to be different") @@ -572,3 +574,236 @@ func TestRefreshOAuth2TokenMigratesUnnamedTokenWhenUsernameLookupSucceeds(t *tes func serverURL(server *httptest.Server, suffix string) string { return server.URL + suffix } + +func TestGetValidOAuth2Token(t *testing.T) { + t.Run("returns a valid token without refreshing", func(t *testing.T) { + ts, dir := createTempTokenStore(t) + defer os.RemoveAll(dir) + + future := uint64(time.Now().Add(time.Hour).Unix()) + require.NoError(t, ts.SaveOAuth2TokenForApp("default", "alice", "valid-access", "refresh", future)) + + a := NewAuth(&config.Config{}).WithTokenStore(ts) + tok, err := a.GetValidOAuth2Token("alice") + require.NoError(t, err) + assert.Equal(t, "valid-access", tok) + }) + + t.Run("refreshes and persists an expired token", func(t *testing.T) { + server := mockTokenServer(t, "refreshed-access", "refreshed-refresh") + defer server.Close() + + ts, dir := createTempTokenStore(t) + defer os.RemoveAll(dir) + + past := uint64(time.Now().Add(-time.Hour).Unix()) + require.NoError(t, ts.SaveOAuth2TokenForApp("default", "alice", "old-access", "old-refresh", past)) + + a := NewAuth(&config.Config{TokenURL: serverURL(server, "/token")}).WithTokenStore(ts) + tok, err := a.GetValidOAuth2Token("alice") + require.NoError(t, err) + assert.Equal(t, "refreshed-access", tok) + + stored := ts.GetOAuth2TokenForApp("default", "alice") + require.NotNil(t, stored) + assert.Equal(t, "refreshed-access", stored.OAuth2.AccessToken, "refreshed token must be persisted") + }) + + t.Run("errors without launching the browser when no token exists", func(t *testing.T) { + ts, dir := createTempTokenStore(t) + defer os.RemoveAll(dir) + + a := NewAuth(&config.Config{}).WithTokenStore(ts) + + // Must return promptly with an error rather than blocking on the + // interactive OAuth2 browser flow. + done := make(chan error, 1) + go func() { _, err := a.GetValidOAuth2Token(""); done <- err }() + + select { + case err := <-done: + require.Error(t, err) + case <-time.After(5 * time.Second): + t.Fatal("GetValidOAuth2Token blocked; it must not launch the browser flow") + } + }) + + t.Run("refreshes a token that is still valid but within the expiry skew", func(t *testing.T) { + server := mockTokenServer(t, "skew-access", "skew-refresh") + defer server.Close() + + ts, dir := createTempTokenStore(t) + defer os.RemoveAll(dir) + + // Expires in 10s — still "valid" by the raw clock, but inside the 30s skew. + soon := uint64(time.Now().Add(10 * time.Second).Unix()) + require.NoError(t, ts.SaveOAuth2TokenForApp("default", "alice", "old-access", "old-refresh", soon)) + + a := NewAuth(&config.Config{TokenURL: serverURL(server, "/token")}).WithTokenStore(ts) + tok, err := a.GetValidOAuth2Token("alice") + require.NoError(t, err) + assert.Equal(t, "skew-access", tok, "near-expiry token should be proactively refreshed") + }) +} + +func TestForceRefreshOAuth2Token(t *testing.T) { + server := mockTokenServer(t, "forced-access", "forced-refresh") + defer server.Close() + + ts, dir := createTempTokenStore(t) + defer os.RemoveAll(dir) + + // Token is NOT expired; a normal GetValidOAuth2Token would return it as-is. + future := uint64(time.Now().Add(time.Hour).Unix()) + require.NoError(t, ts.SaveOAuth2TokenForApp("default", "alice", "old-access", "old-refresh", future)) + + a := NewAuth(&config.Config{TokenURL: serverURL(server, "/token")}).WithTokenStore(ts) + + // Sanity: the non-forced path returns the cached token. + cached, err := a.GetValidOAuth2Token("alice") + require.NoError(t, err) + assert.Equal(t, "old-access", cached) + + // Forced refresh ignores the local expiry and mints a new token. + forced, err := a.ForceRefreshOAuth2Token("alice") + require.NoError(t, err) + assert.Equal(t, "forced-access", forced) + + stored := ts.GetOAuth2TokenForApp("default", "alice") + require.NotNil(t, stored) + assert.Equal(t, "forced-access", stored.OAuth2.AccessToken, "forced refresh must be persisted") +} + +// mockTokenServerNoExpiry returns a refresh response WITHOUT expires_in, so the +// resulting oauth2.Token has a zero Expiry. +func mockTokenServerNoExpiry(t *testing.T, accessToken, refreshToken string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "access_token": accessToken, + "token_type": "Bearer", + "refresh_token": refreshToken, + }) + })) +} + +func TestSaveOAuth2TokenZeroExpiryRefreshesAgain(t *testing.T) { + server := mockTokenServerNoExpiry(t, "no-expiry-access", "no-expiry-refresh") + defer server.Close() + + ts, dir := createTempTokenStore(t) + defer os.RemoveAll(dir) + + past := uint64(time.Now().Add(-time.Hour).Unix()) + require.NoError(t, ts.SaveOAuth2TokenForApp("default", "alice", "old-access", "old-refresh", past)) + + a := NewAuth(&config.Config{TokenURL: serverURL(server, "/token")}).WithTokenStore(ts) + + tok, err := a.GetValidOAuth2Token("alice") + require.NoError(t, err) + assert.Equal(t, "no-expiry-access", tok) + + // A provider that returns no expiry must be stored as 0, not a far-future + // timestamp, so the next call refreshes again instead of serving a stale token. + stored := ts.GetOAuth2TokenForApp("default", "alice") + require.NotNil(t, stored) + assert.Equal(t, uint64(0), stored.OAuth2.ExpirationTime, "zero provider expiry must persist as 0") +} + +func TestOAuth2AuthStyle(t *testing.T) { + // Confidential client (has a secret) -> HTTP Basic auth header. + withSecret := &Auth{clientSecret: "secret"} + assert.Equal(t, oauth2.AuthStyleInHeader, withSecret.oauth2AuthStyle()) + // Public client (no secret) -> client_id in the request body. + noSecret := &Auth{clientSecret: ""} + assert.Equal(t, oauth2.AuthStyleInParams, noSecret.oauth2AuthStyle()) +} + +func TestParseHeadlessAuthCode(t *testing.T) { + cases := []struct { + name string + input string + wantState string + wantCode string + wantErr bool + }{ + {"bare code", "abc123", "", "abc123", false}, + {"full redirect url", "http://localhost:8080/callback?state=S&code=abc123", "S", "abc123", false}, + {"query string only", "state=S&code=abc123", "S", "abc123", false}, + {"surrounding whitespace", " http://localhost:8080/callback?code=xyz&state=S ", "S", "xyz", false}, + {"no state in url is accepted", "http://localhost:8080/callback?code=onlycode", "S", "onlycode", false}, + {"state mismatch rejected", "http://localhost:8080/callback?state=OTHER&code=abc", "S", "", true}, + {"empty input", " ", "", "", true}, + {"code key present but empty", "state=S&code=", "S", "", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := parseHeadlessAuthCode(tc.input, tc.wantState) + if tc.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.wantCode, got) + }) + } +} + +// TestHeadlessLoginExchangesPastedCode verifies the headless handle exposes the +// authorize URL, then exchanges a pasted code and persists the token -- with no +// browser and no callback listener. +func TestHeadlessLoginExchangesPastedCode(t *testing.T) { + server := mockTokenServer(t, "headless-access", "headless-refresh") + defer server.Close() + + tokenStore, tempDir := createTempTokenStore(t) + defer os.RemoveAll(tempDir) + tokenStore.AddApp("my-app", "client-id", "client-secret") + + cfg := &config.Config{ + AuthURL: "https://x.com/i/oauth2/authorize", + TokenURL: server.URL + "/token", + RedirectURI: "http://localhost:8080/callback", + } + a := NewAuth(cfg).WithTokenStore(tokenStore).WithAppName("my-app") + + hl, err := a.StartHeadlessLogin("alice") + require.NoError(t, err) + assert.Contains(t, hl.AuthURL(), "code_challenge=") + assert.Equal(t, "http://localhost:8080/callback", hl.RedirectURI()) + + tok, err := hl.Complete("test-auth-code") + require.NoError(t, err) + assert.Equal(t, "headless-access", tok) + + stored := tokenStore.GetOAuth2TokenForApp("my-app", "alice") + require.NotNil(t, stored) + assert.Equal(t, "headless-access", stored.OAuth2.AccessToken) + assert.Equal(t, "headless-refresh", stored.OAuth2.RefreshToken) +} + +// TestHeadlessLoginRejectsStateMismatch verifies a pasted redirect URL whose +// state does not match the login attempt is rejected before any token exchange. +func TestHeadlessLoginRejectsStateMismatch(t *testing.T) { + server := mockTokenServer(t, "should-not-be-used", "nope") + defer server.Close() + + tokenStore, tempDir := createTempTokenStore(t) + defer os.RemoveAll(tempDir) + tokenStore.AddApp("my-app", "client-id", "client-secret") + + cfg := &config.Config{ + AuthURL: "https://x.com/i/oauth2/authorize", + TokenURL: server.URL + "/token", + RedirectURI: "http://localhost:8080/callback", + } + a := NewAuth(cfg).WithTokenStore(tokenStore).WithAppName("my-app") + + hl, err := a.StartHeadlessLogin("alice") + require.NoError(t, err) + + _, err = hl.Complete("http://localhost:8080/callback?state=bogus&code=abc") + require.Error(t, err) + assert.Contains(t, err.Error(), "state mismatch") +} diff --git a/cli/auth.go b/cli/auth.go index ab9c776..c4bb8c4 100644 --- a/cli/auth.go +++ b/cli/auth.go @@ -1,9 +1,13 @@ package cli import ( + "bufio" "fmt" + "io" "os" + "strings" + "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" "github.com/xdevplatform/xurl/auth" @@ -11,6 +15,14 @@ import ( "github.com/xdevplatform/xurl/store" ) +// Headless-login styles (kept minimal; plain terminal output, no alt screen, so +// the URL stays easy to select and the code easy to paste). +var ( + headlessStepStyle = lipgloss.NewStyle().Bold(true) + headlessURLStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Underline(true) + headlessArrow = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true) +) + // CreateAuthCommand creates the auth command and its subcommands func CreateAuthCommand(a *auth.Auth) *cobra.Command { var authCmd = &cobra.Command{ @@ -18,7 +30,7 @@ func CreateAuthCommand(a *auth.Auth) *cobra.Command { Short: "Authentication management", } - authCmd.AddCommand(createAuthBearerCmd(a)) + authCmd.AddCommand(createAuthAppOnlyCmd(a)) authCmd.AddCommand(createAuthOAuth2Cmd(a)) authCmd.AddCommand(createAuthOAuth1Cmd(a)) authCmd.AddCommand(createAuthStatusCmd()) @@ -29,26 +41,55 @@ func CreateAuthCommand(a *auth.Auth) *cobra.Command { return authCmd } -// ─── auth bearer ──────────────────────────────────────────────────── +// ─── auth app-only ────────────────────────────────────────────────── -func createAuthBearerCmd(a *auth.Auth) *cobra.Command { +func createAuthAppOnlyCmd(a *auth.Auth) *cobra.Command { var bearerToken string cmd := &cobra.Command{ - Use: "app", - Short: "Configure app-auth (bearer token)", + Use: "app-only [TOKEN]", + Aliases: []string{"app", "bearer"}, + Short: "Configure app-only (Bearer Token) authentication", + Long: `Store the app-only Bearer Token (from the X developer portal) for the active app. + +This is X's "OAuth 2.0 App-Only" auth, selected at request time with --auth app. +It is distinct from OAuth2 user-context tokens (obtained via 'xurl auth oauth2') -- +both are sent as "Authorization: Bearer", which is why this command is named for +the auth mode (app-only) rather than the token scheme (bearer). + +Examples: + xurl auth app-only AAAA... # token as an argument + xurl auth app-only --app prod AAAA... # for a specific registered app + cat token.txt | xurl auth app-only - # read the token from stdin (keeps it out of shell history)`, + Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { - err := a.TokenStore.SaveBearerTokenForApp(a.AppName(), bearerToken) - if err != nil { - fmt.Println("Error saving bearer token:", err) + token := bearerToken + if len(args) == 1 { + token = args[0] + } + if token == "-" { + data, err := io.ReadAll(os.Stdin) + if err != nil { + fmt.Fprintln(os.Stderr, "Error reading token from stdin:", err) + os.Exit(1) + } + token = strings.TrimSpace(string(data)) + } + if token == "" { + fmt.Fprintln(os.Stderr, "Error: provide the Bearer Token as an argument, via --bearer-token, or '-' to read from stdin.") os.Exit(1) } - fmt.Printf("\033[32mApp authentication successful!\033[0m\n") + if err := a.TokenStore.SaveBearerTokenForApp(a.AppName(), token); err != nil { + fmt.Fprintln(os.Stderr, "Error saving bearer token:", err) + os.Exit(1) + } + fmt.Printf("\033[32mApp-only authentication configured!\033[0m\n") }, } - cmd.Flags().StringVar(&bearerToken, "bearer-token", "", "Bearer token for app authentication") - cmd.MarkFlagRequired("bearer-token") + // Retained (no longer required) so existing `auth app --bearer-token ...` + // invocations keep working; passing the token as an argument is preferred. + cmd.Flags().StringVar(&bearerToken, "bearer-token", "", "Bearer token (alternative to passing it as an argument)") return cmd } @@ -56,10 +97,17 @@ func createAuthBearerCmd(a *auth.Auth) *cobra.Command { // ─── auth oauth2 ──────────────────────────────────────────────────── func createAuthOAuth2Cmd(a *auth.Auth) *cobra.Command { + var headless bool cmd := &cobra.Command{ Use: "oauth2 [USERNAME]", Short: "Configure OAuth2 authentication", - Args: cobra.MaximumNArgs(1), + Long: `Configure OAuth2 (user-context) authentication. + +By default this opens a browser and listens on the app's redirect URI +(localhost) for the callback. On a remote/headless machine where that callback +is unreachable, use --headless: xurl prints the authorization URL, you open it +on any device, and paste the resulting redirect URL (or code) back in.`, + Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { username := "" if len(args) > 0 { @@ -94,18 +142,78 @@ func createAuthOAuth2Cmd(a *auth.Auth) *cobra.Command { } } - _, err := a.OAuth2Flow(username) + var err error + if headless { + err = runHeadlessLogin(a, username) + } else { + _, err = a.OAuth2Flow(username) + } if err != nil { - fmt.Println("OAuth2 authentication failed:", err) + fmt.Fprintln(os.Stderr, "OAuth2 authentication failed:", err) os.Exit(1) } fmt.Printf("\033[32mOAuth2 authentication successful!\033[0m\n") }, } + cmd.Flags().BoolVar(&headless, "headless", false, "Authenticate without a local browser/callback: print the URL and paste the code back (for remote/headless machines)") + return cmd } +// runHeadlessLogin drives the headless OAuth2 flow: print the authorize URL, +// read the pasted redirect URL/code from stdin, and complete the exchange. The +// auth package owns the protocol; this function owns the (styled) presentation. +func runHeadlessLogin(a *auth.Auth, username string) error { + hl, err := a.StartHeadlessLogin(username) + if err != nil { + return err + } + + out := os.Stderr + renderHeadlessInstructions(out, hl.AuthURL(), hl.RedirectURI(), isTerminal(out)) + + line, rerr := bufio.NewReader(os.Stdin).ReadString('\n') + if rerr != nil && strings.TrimSpace(line) == "" { + return fmt.Errorf("failed to read pasted code: %w", rerr) + } + + fmt.Fprintln(out) + fmt.Fprintln(out, subtleStyle.Render("Exchanging code for a token…")) + _, err = hl.Complete(line) + return err +} + +// renderHeadlessInstructions prints the step-by-step headless prompt. When styled +// is false (output is piped/not a TTY) it emits plain text so logs stay clean. +func renderHeadlessInstructions(out io.Writer, authURL, redirectURI string, styled bool) { + if !styled { + fmt.Fprintln(out, "Headless OAuth2 login -- no browser is needed on this machine.") + fmt.Fprintln(out) + fmt.Fprintln(out, "1. Open this URL in a browser on any device:") + fmt.Fprintln(out, " "+authURL) + fmt.Fprintln(out) + fmt.Fprintln(out, "2. Authorize the app. Your browser is redirected to "+redirectURI+"?state=...&code=...") + fmt.Fprintln(out, " (the page may fail to load on a headless host -- the code is still in the address bar)") + fmt.Fprintln(out) + fmt.Fprint(out, "3. Paste the full redirected URL (or just the code) here: ") + return + } + + fmt.Fprintln(out, titleStyle.Render("Headless OAuth2 login")) + fmt.Fprintln(out, subtleStyle.Render("No browser needed here — copy a link out, paste a code back.")) + fmt.Fprintln(out) + fmt.Fprintln(out, headlessStepStyle.Render("1.")+" Open this URL in a browser on any device:") + fmt.Fprintln(out, " "+headlessURLStyle.Render(authURL)) + fmt.Fprintln(out) + fmt.Fprintln(out, headlessStepStyle.Render("2.")+" Authorize the app. Your browser is redirected to:") + fmt.Fprintln(out, " "+subtleStyle.Render(redirectURI+"?state=…&code=…")) + fmt.Fprintln(out, " "+subtleStyle.Render("(the page may show a connection error on a headless host — the code is still in the address bar)")) + fmt.Fprintln(out) + fmt.Fprintln(out, headlessStepStyle.Render("3.")+" Paste the full redirected URL (or just the code) below:") + fmt.Fprint(out, " "+headlessArrow.Render("›")+" ") +} + // ─── auth oauth1 ──────────────────────────────────────────────────── func createAuthOAuth1Cmd(a *auth.Auth) *cobra.Command { @@ -117,7 +225,7 @@ func createAuthOAuth1Cmd(a *auth.Auth) *cobra.Command { Run: func(cmd *cobra.Command, args []string) { err := a.TokenStore.SaveOAuth1TokensForApp(a.AppName(), accessToken, tokenSecret, consumerKey, consumerSecret) if err != nil { - fmt.Println("Error saving OAuth1 tokens:", err) + fmt.Fprintln(os.Stderr, "Error saving OAuth1 tokens:", err) os.Exit(1) } fmt.Printf("\033[32mOAuth1 credentials saved successfully!\033[0m\n") @@ -219,7 +327,7 @@ func createAuthStatusCmd() *cobra.Command { // ─── auth clear ───────────────────────────────────────────────────── func createAuthClearCmd(a *auth.Auth) *cobra.Command { - var all, oauth1, bearer bool + var all, oauth1, bearer, appOnly bool var oauth2Username string cmd := &cobra.Command{ @@ -229,33 +337,33 @@ func createAuthClearCmd(a *auth.Auth) *cobra.Command { if all { err := a.TokenStore.ClearAllForApp(a.AppName()) if err != nil { - fmt.Println("Error clearing all tokens:", err) + fmt.Fprintln(os.Stderr, "Error clearing all tokens:", err) os.Exit(1) } fmt.Println("All authentication cleared!") } else if oauth1 { err := a.TokenStore.ClearOAuth1TokensForApp(a.AppName()) if err != nil { - fmt.Println("Error clearing OAuth1 tokens:", err) + fmt.Fprintln(os.Stderr, "Error clearing OAuth1 tokens:", err) os.Exit(1) } fmt.Println("OAuth1 tokens cleared!") } else if oauth2Username != "" { err := a.TokenStore.ClearOAuth2TokenForApp(a.AppName(), oauth2Username) if err != nil { - fmt.Println("Error clearing OAuth2 token:", err) + fmt.Fprintln(os.Stderr, "Error clearing OAuth2 token:", err) os.Exit(1) } fmt.Println("OAuth2 token cleared for", oauth2Username+"!") - } else if bearer { + } else if bearer || appOnly { err := a.TokenStore.ClearBearerTokenForApp(a.AppName()) if err != nil { - fmt.Println("Error clearing bearer token:", err) + fmt.Fprintln(os.Stderr, "Error clearing app-only token:", err) os.Exit(1) } - fmt.Println("Bearer token cleared!") + fmt.Println("App-only (bearer) token cleared!") } else { - fmt.Println("No authentication cleared! Use --all to clear all authentication.") + fmt.Fprintln(os.Stderr, "No authentication cleared! Use --all to clear all authentication.") os.Exit(1) } }, @@ -264,7 +372,9 @@ func createAuthClearCmd(a *auth.Auth) *cobra.Command { cmd.Flags().BoolVar(&all, "all", false, "Clear all authentication") cmd.Flags().BoolVar(&oauth1, "oauth1", false, "Clear OAuth1 tokens") cmd.Flags().StringVar(&oauth2Username, "oauth2-username", "", "Clear OAuth2 token for username") - cmd.Flags().BoolVar(&bearer, "bearer", false, "Clear bearer token") + cmd.Flags().BoolVar(&appOnly, "app-only", false, "Clear the app-only (bearer) token") + cmd.Flags().BoolVar(&bearer, "bearer", false, "Clear the app-only (bearer) token") + _ = cmd.Flags().MarkHidden("bearer") // back-compat alias for --app-only return cmd } @@ -302,12 +412,12 @@ Examples: name := args[0] err := a.TokenStore.AddApp(name, clientID, clientSecret) if err != nil { - fmt.Printf("\033[31mError: %v\033[0m\n", err) + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) os.Exit(1) } if redirectURI != "" { if err := a.TokenStore.SetAppRedirectURI(name, redirectURI); err != nil { - fmt.Printf("\033[31mError: %v\033[0m\n", err) + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) os.Exit(1) } } @@ -343,16 +453,16 @@ Examples: Run: func(cmd *cobra.Command, args []string) { name := args[0] if clientID == "" && clientSecret == "" && redirectURI == "" { - fmt.Println("Nothing to update. Provide --client-id, --client-secret, and/or --redirect-uri.") + fmt.Fprintln(os.Stderr, "Nothing to update. Provide --client-id, --client-secret, and/or --redirect-uri.") os.Exit(1) } if err := a.TokenStore.UpdateApp(name, clientID, clientSecret); err != nil { - fmt.Printf("\033[31mError: %v\033[0m\n", err) + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) os.Exit(1) } if redirectURI != "" { if err := a.TokenStore.SetAppRedirectURI(name, redirectURI); err != nil { - fmt.Printf("\033[31mError: %v\033[0m\n", err) + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) os.Exit(1) } } @@ -376,7 +486,7 @@ func createAppRemoveCmd(a *auth.Auth) *cobra.Command { name := args[0] err := a.TokenStore.RemoveApp(name) if err != nil { - fmt.Printf("\033[31mError: %v\033[0m\n", err) + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) os.Exit(1) } fmt.Printf("\033[32mApp %q removed.\033[0m\n", name) @@ -442,12 +552,12 @@ func createAppRedirectURIGetCmd(a *auth.Auth) *cobra.Command { ts := a.TokenStore appName := resolveAppNameArg(ts, args) if appName == "" { - fmt.Println("No apps registered. Use 'xurl auth apps add' to register one.") + fmt.Fprintln(os.Stderr, "No apps registered. Use 'xurl auth apps add' to register one.") os.Exit(1) } app := ts.GetApp(appName) if app == nil { - fmt.Printf("\033[31mError: app %q not found\033[0m\n", appName) + fmt.Fprintf(os.Stderr, "\033[31mError: app %q not found\033[0m\n", appName) os.Exit(1) } @@ -476,7 +586,7 @@ func createAppRedirectURISetCmd(a *auth.Auth) *cobra.Command { name := args[0] redirectURI := args[1] if err := a.TokenStore.SetAppRedirectURI(name, redirectURI); err != nil { - fmt.Printf("\033[31mError: %v\033[0m\n", err) + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) os.Exit(1) } fmt.Printf("\033[32mRedirect URI set for app %q.\033[0m\n", name) @@ -510,7 +620,7 @@ Examples: // Non-interactive: set default app by name appName := args[0] if err := ts.SetDefaultApp(appName); err != nil { - fmt.Printf("\033[31mError: %v\033[0m\n", err) + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) os.Exit(1) } fmt.Printf("\033[32mDefault app set to %q\033[0m\n", appName) @@ -518,7 +628,7 @@ Examples: if len(args) == 2 { userName := args[1] if err := ts.SetDefaultUser(appName, userName); err != nil { - fmt.Printf("\033[31mError: %v\033[0m\n", err) + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) os.Exit(1) } fmt.Printf("\033[32mDefault user set to %q\033[0m\n", userName) @@ -535,7 +645,7 @@ Examples: appChoice, err := RunPicker("Select default app", apps) if err != nil { - fmt.Printf("\033[31mError: %v\033[0m\n", err) + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) os.Exit(1) } if appChoice == "" { @@ -543,7 +653,7 @@ Examples: } if err := ts.SetDefaultApp(appChoice); err != nil { - fmt.Printf("\033[31mError: %v\033[0m\n", err) + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) os.Exit(1) } fmt.Printf("\033[32mDefault app set to %q\033[0m\n", appChoice) @@ -553,12 +663,12 @@ Examples: if len(users) > 0 { userChoice, err := RunPicker("Select default OAuth2 user", users) if err != nil { - fmt.Printf("\033[31mError: %v\033[0m\n", err) + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) os.Exit(1) } if userChoice != "" { if err := ts.SetDefaultUser(appChoice, userChoice); err != nil { - fmt.Printf("\033[31mError: %v\033[0m\n", err) + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) os.Exit(1) } fmt.Printf("\033[32mDefault user set to %q\033[0m\n", userChoice) diff --git a/cli/mcp.go b/cli/mcp.go new file mode 100644 index 0000000..a5d9561 --- /dev/null +++ b/cli/mcp.go @@ -0,0 +1,775 @@ +package cli + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/signal" + "strings" + "sync" + "syscall" + "time" + + "github.com/spf13/cobra" + + "github.com/xdevplatform/xurl/auth" + "github.com/xdevplatform/xurl/version" +) + +// defaultMCPURL is the hosted X API MCP endpoint used when no URL is given. +const defaultMCPURL = "https://api.x.com/mcp" + +// maxMCPMessageBytes bounds a single JSON-RPC message (stdin line or SSE event). +const maxMCPMessageBytes = 16 * 1024 * 1024 + +// JSON-RPC error codes the bridge uses when it must synthesize a reply for a +// client request it could not satisfy, so a strict client never hangs waiting +// for the matching id. These sit in the implementation-defined server-error +// range (-32000..-32099) reserved by the JSON-RPC 2.0 spec. +const ( + rpcErrTransport = -32001 // could not reach the MCP server + rpcErrAuth = -32002 // token refresh failed after a 401 + rpcErrUpstream = -32003 // server replied but no usable message could be forwarded +) + +// rpcError is the "error" member of a synthesized JSON-RPC error response. +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// mcpBridge relays MCP traffic between a stdio client and a remote Streamable +// HTTP MCP server, injecting a Bearer token on every outbound request. It +// implements the client side of the MCP Streamable HTTP transport (2025-03-26): +// client->server messages are POSTed; the server replies with either a single +// JSON object or a text/event-stream of JSON-RPC messages, and may assign an +// Mcp-Session-Id that must be echoed on subsequent requests. +// +// Client messages are processed sequentially (stdio MCP is a serial channel), +// which guarantees the initialize handshake establishes the session id before +// later requests are sent. A best-effort background goroutine consumes the +// optional standalone server->client SSE stream. +type mcpBridge struct { + url string + auth *auth.Auth + username string + httpClient *http.Client + + // tokenMu serialises all access to the (mutex-less) token store, so the + // message loop and the server->client listener never refresh/persist + // concurrently (which would be a fatal map race and could corrupt ~/.xurl). + tokenMu sync.Mutex + + in io.Reader + out io.Writer + + outMu sync.Mutex // serialises writes to out (stdout) + + sessMu sync.Mutex + sessionID string + sessionOnce sync.Once + sessionReady chan struct{} +} + +func newMCPBridge(url string, a *auth.Auth, username string) *mcpBridge { + return newMCPBridgeWithIO(url, a, username, os.Stdin, os.Stdout) +} + +func newMCPBridgeWithIO(url string, a *auth.Auth, username string, in io.Reader, out io.Writer) *mcpBridge { + return &mcpBridge{ + url: url, + auth: a, + username: username, + // No client timeout: SSE responses and the server->client stream are + // long-lived; cancellation is driven by the request context instead. + httpClient: &http.Client{}, + in: in, + out: out, + sessionReady: make(chan struct{}), + } +} + +// accessToken returns a valid token using the same resolution as `xurl token` +// (refresh-if-expired, persist, never browser), serialised under tokenMu. +func (b *mcpBridge) accessToken() (string, error) { + b.tokenMu.Lock() + defer b.tokenMu.Unlock() + return b.auth.GetValidOAuth2Token(b.username) +} + +// forceRefreshToken mints a brand-new token regardless of local expiry. Used on +// an HTTP 401, where the server rejected a token the local clock still trusts. +func (b *mcpBridge) forceRefreshToken() (string, error) { + b.tokenMu.Lock() + defer b.tokenMu.Unlock() + return b.auth.ForceRefreshOAuth2Token(b.username) +} + +// bootstrap ensures a usable token exists before bridging. It will silently +// refresh an expired token, but it never launches a browser: the bridge's stdio +// is the MCP channel (owned by the client) and a login prompt mid-startup would +// hang the client's handshake and corrupt stdout. If no token is available it +// fails fast with instructions to authenticate out-of-band first. +func (b *mcpBridge) bootstrap() error { + if _, err := b.accessToken(); err == nil { + return nil + } + hint := appFlagHint(b.auth.AppName()) + return fmt.Errorf("no valid OAuth2 token for this app. Authenticate first, then start the MCP server:\n"+ + " xurl auth oauth2%s # local machine with a browser\n"+ + " xurl auth oauth2%s --headless # remote/headless machine (paste a code)", hint, hint) +} + +// run reads JSON-RPC messages from stdin and bridges them until stdin closes or +// the context is cancelled (e.g. SIGINT/SIGTERM). Requests are processed in +// order but off the read loop, so notifications are never head-of-line blocked; +// a best-effort server->client stream runs concurrently. +func (b *mcpBridge) run(ctx context.Context) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + var listeners sync.WaitGroup + listeners.Add(1) + go func() { + defer listeners.Done() + b.listen(ctx) + }() + + // Read stdin in a goroutine so the main loop can also react to context + // cancellation (a bufio read on stdin is not interruptible by ctx). + lines := make(chan []byte) + var readErr error + go func() { + defer close(lines) + reader := bufio.NewReader(b.in) + for { + raw, oversized, err := readLineCapped(reader, maxMCPMessageBytes) + if oversized { + b.logf("dropping oversized message (>%d bytes)", maxMCPMessageBytes) + } else if msg := bytes.TrimSpace(raw); len(msg) > 0 { + cp := make([]byte, len(msg)) + copy(cp, msg) + select { + case lines <- cp: + case <-ctx.Done(): + return + } + } + if err != nil { + if err != io.EOF { + readErr = err + } + return + } + } + }() + + // inflight tracks every dispatched message goroutine so they are drained + // before the session is torn down. + // + // Requests are processed serially to preserve order (notably the initialize + // handshake, which must capture the session id before later requests), but + // without blocking the read loop: each request waits for the previous one to + // finish via a chained channel. Notifications carry no id, need no reply, and + // must not be head-of-line blocked behind an in-flight streaming response + // (e.g. notifications/cancelled during a long tools/call), so they are + // dispatched immediately. Shared state (token store, session id, stdout) is + // mutex-protected, so this stays race-free. + var inflight sync.WaitGroup + prevDone := make(chan struct{}) + close(prevDone) // the first request has no predecessor to wait for + + for { + select { + case <-ctx.Done(): + // Signal/shutdown: in-flight goroutines observe ctx and unwind, then + // stop the listener and best-effort end the session. + inflight.Wait() + listeners.Wait() + b.deleteSession() + return nil + case msg, ok := <-lines: + if !ok { + // stdin closed by the client: let already-queued requests finish + // (graceful EOF), then stop the listener and end the session. + inflight.Wait() + cancel() + listeners.Wait() + b.deleteSession() + return readErr + } + if isNotification(msg) { + inflight.Add(1) + go func(m []byte) { + defer inflight.Done() + b.forwardPost(ctx, m) + }(msg) + continue + } + // Request: chain after the previous request so ordering is preserved + // without blocking this loop. + done := make(chan struct{}) + inflight.Add(1) + go func(m []byte, wait <-chan struct{}, signal chan<- struct{}) { + defer inflight.Done() + defer close(signal) + select { + case <-wait: + case <-ctx.Done(): + return + } + b.forwardPost(ctx, m) + }(msg, prevDone, done) + prevDone = done + } + } +} + +// forwardPost POSTs one client message and forwards the server's reply. If the +// message is a request (carries an id) and the bridge cannot deliver a reply -- +// transport failure, a failed refresh/retry after a 401, or a response whose +// body is empty/non-JSON -- it synthesizes a JSON-RPC error response for that id +// so a strict client never hangs. Notifications (no id) need no reply. +func (b *mcpBridge) forwardPost(ctx context.Context, msg []byte) { + id := requestID(msg) + + resp := b.postWithRetry(ctx, msg) + if resp == nil { + if ctx.Err() == nil { + b.writeErrorResponse(id, rpcErrTransport, "xurl mcp: could not reach the MCP server (transport error)") + } + return + } + + if resp.StatusCode == http.StatusUnauthorized { + b.logf("server returned 401 Unauthorized; forcing a token refresh and retrying once") + drainClose(resp) + if _, err := b.forceRefreshToken(); err != nil { + b.logf("token refresh after 401 failed: %v", err) + b.writeErrorResponse(id, rpcErrAuth, "xurl mcp: token refresh after 401 failed") + return + } + resp = b.postWithRetry(ctx, msg) + if resp == nil { + if ctx.Err() == nil { + b.writeErrorResponse(id, rpcErrTransport, "xurl mcp: retry after token refresh failed (transport error)") + } + return + } + } + + defer drainClose(resp) + b.captureSession(resp) + if !b.forwardResponse(resp) && ctx.Err() == nil { + b.writeErrorResponse(id, rpcErrUpstream, fmt.Sprintf("xurl mcp: no usable reply from MCP server (HTTP %s)", resp.Status)) + } +} + +// postWithRetry sends one POST, retrying once on a transient transport error so +// a single blip never tears the bridge down. Returns nil if both attempts fail. +func (b *mcpBridge) postWithRetry(ctx context.Context, msg []byte) *http.Response { + resp, err := b.post(ctx, msg) + if err == nil { + return resp + } + if ctx.Err() != nil { + return nil + } + b.logf("request error (retrying): %v", err) + select { + case <-ctx.Done(): + return nil + case <-time.After(500 * time.Millisecond): + } + resp, err = b.post(ctx, msg) + if err != nil { + b.logf("request failed: %v", err) + return nil + } + return resp +} + +func (b *mcpBridge) post(ctx context.Context, body []byte) (*http.Response, error) { + token, err := b.accessToken() + if err != nil { + return nil, fmt.Errorf("token error: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, b.url, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("User-Agent", "xurl/"+version.Version) + if sid := b.getSession(); sid != "" { + req.Header.Set("Mcp-Session-Id", sid) + } + return b.httpClient.Do(req) +} + +// forwardResponse writes the server's reply to stdout as newline-delimited JSON, +// handling JSON, SSE, 202 (no body) and error responses. It returns true if a +// reply was delivered (or none is expected, e.g. 202), and false if the +// response could not be turned into a client reply (empty/non-JSON body, read +// error, or an SSE stream that yielded nothing) so the caller can synthesize a +// JSON-RPC error for the pending request id. +func (b *mcpBridge) forwardResponse(resp *http.Response) bool { + if resp.StatusCode == http.StatusAccepted { + // 202 Accepted: the message was a notification/response; no reply body. + return true + } + + ct := resp.Header.Get("Content-Type") + + if resp.StatusCode >= 400 { + // Read up to the message cap (not a small 64 KiB slice) so large but + // valid JSON-RPC error bodies are forwarded whole rather than truncated + // into invalid JSON that writeMessage would drop. + body, _ := io.ReadAll(io.LimitReader(resp.Body, maxMCPMessageBytes)) + body = bytes.TrimSpace(body) + b.logf("server error %s: %s", resp.Status, truncateForLog(body)) + // Forward the body only if it is a JSON-RPC response the client can + // correlate to its request id; a bare gateway/proxy error (valid JSON + // but no id, or not JSON at all) is reported as failure so the caller + // synthesizes a correlatable reply instead of letting the client hang. + if isJSONRPCResponse(body) { + return b.writeMessage(body) + } + return false + } + + switch { + case strings.HasPrefix(ct, "text/event-stream"): + return b.pumpSSE(resp.Body) + default: + // Treat everything else as a single JSON message. writeMessage validates + // it is JSON and skips (with a stderr note) anything that is not, so the + // stdout channel stays strictly newline-delimited JSON. + body, err := io.ReadAll(io.LimitReader(resp.Body, maxMCPMessageBytes)) + if err != nil { + b.logf("read response failed: %v", err) + return false + } + return b.writeMessage(body) + } +} + +// listen opens the optional standalone server->client SSE stream. It starts once +// a session exists (or shortly after, to support stateless servers), resets its +// backoff only after a stream stays open long enough to be considered healthy, +// retries transient failures (incl. 408/429), and stops permanently when the +// server signals the stream is unsupported (a non-retryable 4xx, or a 200 that +// is not an event-stream). +func (b *mcpBridge) listen(ctx context.Context) { + select { + case <-b.sessionReady: + case <-time.After(2 * time.Second): + // Stateless server that issues no session id: try the stream anyway. + case <-ctx.Done(): + return + } + + // A stream must stay open at least this long for its 200 to count as + // "healthy"; otherwise a server that accepts the GET and immediately closes + // would reset the backoff every iteration and produce a tight reconnect loop. + const minHealthyStream = 5 * time.Second + + backoff := time.Second + for ctx.Err() == nil { + start := time.Now() + status, eventStream, err := b.openServerStream(ctx) + elapsed := time.Since(start) + if err != nil && ctx.Err() == nil { + b.logf("server stream error: %v", err) + } + // A 200 that is not an event-stream means the server does not offer the + // standalone server->client channel: stop probing (same as a 4xx). + if status == http.StatusOK && !eventStream { + b.logf("server->client stream unsupported (HTTP 200, not an event-stream); not retrying") + return + } + if status >= 400 && status < 500 && status != http.StatusRequestTimeout && status != http.StatusTooManyRequests { + // Unsupported standalone stream (e.g. 404/405): stop probing. + return + } + // Only reset the backoff when a stream actually stayed open for a while. + if eventStream && elapsed >= minHealthyStream { + backoff = time.Second + } + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + } + if backoff < 30*time.Second { + backoff *= 2 + } + } +} + +// openServerStream issues the GET for the standalone server->client stream. It +// returns the HTTP status, whether the response was actually an event-stream +// that was pumped, and any transport error. +func (b *mcpBridge) openServerStream(ctx context.Context) (status int, eventStream bool, err error) { + token, err := b.accessToken() + if err != nil { + return 0, false, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, b.url, nil) + if err != nil { + return 0, false, err + } + req.Header.Set("Accept", "text/event-stream") + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("User-Agent", "xurl/"+version.Version) + if sid := b.getSession(); sid != "" { + req.Header.Set("Mcp-Session-Id", sid) + } + resp, err := b.httpClient.Do(req) + if err != nil { + return 0, false, err + } + defer drainClose(resp) + if resp.StatusCode != http.StatusOK { + return resp.StatusCode, false, nil + } + if strings.HasPrefix(resp.Header.Get("Content-Type"), "text/event-stream") { + b.logf("server->client stream open") + b.pumpSSE(resp.Body) + return resp.StatusCode, true, nil + } + return resp.StatusCode, false, nil +} + +// pumpSSE parses a text/event-stream and forwards each event's JSON data payload +// to stdout as one line. Multi-line data fields are concatenated per the SSE +// spec; writeMessage then validates and compacts each event. +func (b *mcpBridge) pumpSSE(r io.Reader) bool { + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 0, 64*1024), maxMCPMessageBytes) + + wrote := false + var data strings.Builder + flush := func() { + if data.Len() == 0 { + return + } + payload := data.String() + data.Reset() + if b.writeMessage([]byte(payload)) { + wrote = true + } + } + + for scanner.Scan() { + line := scanner.Text() + switch { + case line == "": + flush() // blank line terminates an event + case strings.HasPrefix(line, ":"): + // SSE comment; ignore. + case strings.HasPrefix(line, "data:"): + chunk := strings.TrimPrefix(line, "data:") + chunk = strings.TrimPrefix(chunk, " ") + if data.Len() > 0 { + data.WriteByte('\n') + } + data.WriteString(chunk) + case line == "data": + // A field name with no value is an empty data line. + if data.Len() > 0 { + data.WriteByte('\n') + } + default: + // Other SSE fields (event:, id:, retry:) aren't needed here. + } + } + flush() + if err := scanner.Err(); err != nil { + b.logf("sse read error: %v", err) + } + return wrote +} + +func (b *mcpBridge) captureSession(resp *http.Response) { + sid := resp.Header.Get("Mcp-Session-Id") + if sid == "" { + return + } + b.sessMu.Lock() + changed := b.sessionID != sid + b.sessionID = sid + b.sessMu.Unlock() + if changed { + b.logf("session id: %s", sid) + } + b.sessionOnce.Do(func() { close(b.sessionReady) }) +} + +func (b *mcpBridge) getSession() string { + b.sessMu.Lock() + defer b.sessMu.Unlock() + return b.sessionID +} + +// deleteSession best-effort terminates the MCP session on shutdown (the spec +// says clients SHOULD). It uses a fresh short-lived context because the bridge +// context is already cancelled by the time this runs. +func (b *mcpBridge) deleteSession() { + sid := b.getSession() + if sid == "" { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + token, err := b.accessToken() + if err != nil { + return + } + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, b.url, nil) + if err != nil { + return + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("User-Agent", "xurl/"+version.Version) + req.Header.Set("Mcp-Session-Id", sid) + resp, err := b.httpClient.Do(req) + if err != nil { + b.logf("session delete failed: %v", err) + return + } + drainClose(resp) + b.logf("session %s deleted", sid) +} + +// writeMessage writes a single newline-terminated JSON message to stdout. It is +// the ONLY path to stdout, and it enforces the transport invariant: every line +// must be exactly one compact, valid JSON value. Non-JSON payloads (e.g. SSE +// keep-alives) are dropped with a stderr note rather than corrupting the channel. +// writeMessage returns true if payload was a usable (valid, compactable) JSON +// value -- i.e. a real reply we could forward -- and false if it was empty or +// not JSON (and therefore dropped). The boolean reflects whether the server gave +// us something to forward, not whether the stdout write itself succeeded: a +// broken stdout cannot be fixed by synthesizing another message. +func (b *mcpBridge) writeMessage(payload []byte) bool { + payload = bytes.TrimSpace(payload) + if len(payload) == 0 { + return false + } + if !json.Valid(payload) { + b.logf("dropping non-JSON server message (%d bytes)", len(payload)) + return false + } + var buf bytes.Buffer + if err := json.Compact(&buf, payload); err != nil { + b.logf("failed to compact server message: %v", err) + return false + } + buf.WriteByte('\n') + + b.outMu.Lock() + defer b.outMu.Unlock() + if _, err := b.out.Write(buf.Bytes()); err != nil { + b.logf("stdout write error: %v", err) + } + return true +} + +// writeErrorResponse synthesizes a JSON-RPC error response for a client request +// the bridge could not satisfy, so a strict client does not hang waiting on the +// id. A nil/empty/null id means the source was not a correlatable request (a +// notification or a client->server response), in which case nothing is written. +func (b *mcpBridge) writeErrorResponse(id json.RawMessage, code int, message string) { + if !hasConcreteID(id) { + return + } + payload, err := json.Marshal(struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Error rpcError `json:"error"` + }{ + JSONRPC: "2.0", + ID: id, + Error: rpcError{Code: code, Message: message}, + }) + if err != nil { + b.logf("failed to build synthetic error response: %v", err) + return + } + b.writeMessage(payload) +} + +// requestID returns the JSON-RPC id of a single *request* -- an object carrying +// BOTH a method and an id -- preserving the raw bytes so a synthesized reply +// echoes the exact id (number or string). It returns nil for anything that must +// never receive a synthesized reply: notifications (no id), client->server +// responses (an id but no method), JSON-RPC batches (a top-level array), and +// unparseable input. Note: batched requests therefore fall outside the no-hang +// guarantee; they are rare over this transport and intentionally left unhandled. +func requestID(msg []byte) json.RawMessage { + var probe struct { + Method string `json:"method"` + ID json.RawMessage `json:"id"` + } + if err := json.Unmarshal(msg, &probe); err != nil { + return nil + } + if probe.Method == "" || !hasConcreteID(probe.ID) { + return nil + } + return probe.ID +} + +// hasConcreteID reports whether a raw JSON-RPC id is present and not null -- i.e. +// a value the client can correlate a reply to. +func hasConcreteID(raw json.RawMessage) bool { + t := bytes.TrimSpace(raw) + return len(t) > 0 && !bytes.Equal(t, []byte("null")) +} + +// isJSONRPCResponse reports whether body is a JSON-RPC response the client can +// correlate to its request: valid JSON, an object with a concrete (non-null) id +// and a result or error member. A bare gateway/proxy error body (valid JSON but +// no id, e.g. {"status":429,...}) is not one, so the caller can synthesize a +// correlatable reply instead of forwarding something the client can't match. +func isJSONRPCResponse(body []byte) bool { + if !json.Valid(body) { + return false + } + var probe struct { + ID json.RawMessage `json:"id"` + Result json.RawMessage `json:"result"` + Error json.RawMessage `json:"error"` + } + if json.Unmarshal(body, &probe) != nil { + return false + } + return hasConcreteID(probe.ID) && (len(probe.Result) > 0 || len(probe.Error) > 0) +} + +// isNotification reports whether msg is a JSON-RPC notification: a single object +// carrying a method but no id. Such messages expect no reply and can be sent +// concurrently with an in-flight streaming response. +func isNotification(msg []byte) bool { + var probe struct { + Method string `json:"method"` + ID json.RawMessage `json:"id"` + } + if err := json.Unmarshal(msg, &probe); err != nil { + return false + } + return probe.Method != "" && len(bytes.TrimSpace(probe.ID)) == 0 +} + +// readLineCapped reads a single '\n'-terminated line from r while never +// buffering more than max bytes: if a line exceeds the cap, the surplus through +// the next newline is read and discarded so memory stays bounded, and +// oversized is true. The returned bytes never include the trailing newline. +func readLineCapped(r *bufio.Reader, max int) (line []byte, oversized bool, err error) { + for { + c, e := r.ReadByte() + if e != nil { + return line, oversized, e + } + if c == '\n' { + return line, oversized, nil + } + if len(line) < max { + line = append(line, c) + } else { + oversized = true + } + } +} + +// truncateForLog renders bytes for a stderr diagnostic without dumping a large +// body (the error-body read cap is the full message size). +func truncateForLog(p []byte) string { + const max = 2048 + if len(p) <= max { + return string(p) + } + return string(p[:max]) + "...(truncated)" +} + +// logf writes a diagnostic line to stderr; stdout is reserved for JSON-RPC. +func (b *mcpBridge) logf(format string, args ...any) { + fmt.Fprintf(os.Stderr, "[xurl mcp] "+format+"\n", args...) +} + +func drainClose(resp *http.Response) { + if resp == nil || resp.Body == nil { + return + } + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() +} + +// CreateMCPCommand creates the `mcp` command: a stdio<->Streamable-HTTP MCP bridge +// that authenticates with the active app's OAuth2 token. +func CreateMCPCommand(a *auth.Auth) *cobra.Command { + cmd := &cobra.Command{ + Use: "mcp [URL]", + Short: "Bridge a stdio MCP client to a remote (X API) MCP server", + Long: `Bridge a stdio MCP client to a remote Streamable HTTP MCP server. + +xurl reads newline-delimited JSON-RPC from stdin, forwards each message to the +MCP endpoint over HTTP with an 'Authorization: Bearer ' header, and +writes the server's responses to stdout as newline-delimited JSON. Both single +JSON responses and text/event-stream (SSE) responses are supported, and the MCP +session id is maintained across requests. + +The access token is resolved exactly like 'xurl token': an existing token is +refreshed automatically as it expires (including a forced refresh on a 401). +Authenticate once before starting the bridge with 'xurl auth oauth2 [--app NAME]' +(add --headless on a remote/headless machine). The bridge never opens a browser +itself; if no token exists it exits with that instruction. All diagnostics go to +stderr so stdout stays a clean JSON-RPC channel. + +If URL is omitted it defaults to ` + defaultMCPURL + `. + +Example MCP client config: + { + "mcpServers": { + "xapi": { + "command": "npx", + "args": ["-y", "@xdevplatform/xurl", "mcp", "https://api.x.com/mcp"], + "env": { "CLIENT_ID": "...", "CLIENT_SECRET": "..." } + } + } + }`, + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + url := defaultMCPURL + if len(args) > 0 && strings.TrimSpace(args[0]) != "" { + url = strings.TrimSpace(args[0]) + } + username, _ := cmd.Flags().GetString("username") + + bridge := newMCPBridge(url, a, username) + + if err := bridge.bootstrap(); err != nil { + fprintError(os.Stderr, "Error: %v", err) + os.Exit(1) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + bridge.logf("bridging stdio <-> %s", url) + if err := bridge.run(ctx); err != nil { + fprintError(os.Stderr, "mcp bridge error: %v", err) + os.Exit(1) + } + }, + } + + cmd.Flags().StringP("username", "u", "", "OAuth2 username to act as") + return cmd +} diff --git a/cli/mcp_test.go b/cli/mcp_test.go new file mode 100644 index 0000000..b58d1aa --- /dev/null +++ b/cli/mcp_test.go @@ -0,0 +1,610 @@ +package cli + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xdevplatform/xurl/auth" + "github.com/xdevplatform/xurl/config" + "github.com/xdevplatform/xurl/store" +) + +// mcpTestAuth returns an *auth.Auth backed by a temp store holding a single +// non-expired OAuth2 token, so token resolution never hits the network. +func mcpTestAuth(t *testing.T, accessToken string) *auth.Auth { + t.Helper() + return mcpTestAuthRefreshable(t, accessToken, "") +} + +// mcpTestAuthRefreshable is like mcpTestAuth but also wires a token URL so a +// forced refresh (e.g. on 401) can mint a new token. +func mcpTestAuthRefreshable(t *testing.T, accessToken, tokenURL string) *auth.Auth { + t.Helper() + tempDir, err := os.MkdirTemp("", "xurl_mcp_test") + require.NoError(t, err) + t.Cleanup(func() { os.RemoveAll(tempDir) }) + + ts := &store.TokenStore{ + Apps: map[string]*store.App{"default": {OAuth2Tokens: map[string]store.Token{}}}, + DefaultApp: "default", + FilePath: filepath.Join(tempDir, ".xurl"), + } + future := uint64(time.Now().Add(time.Hour).Unix()) + require.NoError(t, ts.SaveOAuth2TokenForApp("default", "alice", accessToken, "refresh", future)) + + return auth.NewAuth(&config.Config{TokenURL: tokenURL}).WithTokenStore(ts) +} + +// assertStdoutIsJSONLines fails if any non-empty stdout line isn't valid JSON. +func assertStdoutIsJSONLines(t *testing.T, out string) { + t.Helper() + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { + if strings.TrimSpace(line) == "" { + continue + } + assert.Truef(t, json.Valid([]byte(line)), "stdout line is not valid JSON: %q", line) + } +} + +// nonPostBoilerplate handles the GET (no standalone stream) and DELETE (session +// teardown) requests a test mock must tolerate, returning true if it handled the +// request. POST handling is left to the caller. +func nonPostBoilerplate(w http.ResponseWriter, r *http.Request) bool { + switch r.Method { + case http.MethodGet: + w.WriteHeader(http.StatusMethodNotAllowed) + return true + case http.MethodDelete: + w.WriteHeader(http.StatusOK) + return true + } + return false +} + +func TestMCPBridgeJSONResponse(t *testing.T) { + var mu sync.Mutex + var gotAuth, gotAccept, gotCT string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if nonPostBoilerplate(w, r) { + return + } + mu.Lock() + gotAuth = r.Header.Get("Authorization") + gotAccept = r.Header.Get("Accept") + gotCT = r.Header.Get("Content-Type") + mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Mcp-Session-Id", "sess-123") + io.WriteString(w, `{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-03-26"}}`) + })) + defer server.Close() + + a := mcpTestAuth(t, "tok-abc") + in := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize"}` + "\n") + var out bytes.Buffer + + b := newMCPBridgeWithIO(server.URL, a, "", in, &out) + require.NoError(t, b.run(context.Background())) + + mu.Lock() + defer mu.Unlock() + assert.Equal(t, "Bearer tok-abc", gotAuth, "bearer token must be injected") + assert.Contains(t, gotAccept, "application/json") + assert.Contains(t, gotAccept, "text/event-stream") + assert.Contains(t, gotCT, "application/json") + + assert.Contains(t, out.String(), `"protocolVersion":"2025-03-26"`) + assertStdoutIsJSONLines(t, out.String()) + assert.Equal(t, "sess-123", b.getSession(), "session id must be captured") +} + +func TestMCPBridgeSSEResponse(t *testing.T) { + var mu sync.Mutex + var gotAuth string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if nonPostBoilerplate(w, r) { + return + } + mu.Lock() + gotAuth = r.Header.Get("Authorization") + mu.Unlock() + + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + flusher, _ := w.(http.Flusher) + io.WriteString(w, ": keep-alive\n\n") + io.WriteString(w, "event: message\n") + io.WriteString(w, `data: {"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"hi"}]}}`+"\n\n") + if flusher != nil { + flusher.Flush() + } + })) + defer server.Close() + + a := mcpTestAuth(t, "tok-sse") + in := strings.NewReader(`{"jsonrpc":"2.0","id":2,"method":"tools/call"}` + "\n") + var out bytes.Buffer + + b := newMCPBridgeWithIO(server.URL, a, "", in, &out) + require.NoError(t, b.run(context.Background())) + + mu.Lock() + assert.Equal(t, "Bearer tok-sse", gotAuth) + mu.Unlock() + assert.Contains(t, out.String(), `"id":2`) + assert.Contains(t, out.String(), `"text":"hi"`) + assertStdoutIsJSONLines(t, out.String()) +} + +// TestMCPBridgeForwardsSessionID verifies that once the server assigns a +// session id, it is echoed on subsequent requests. Driven sequentially via +// forwardPost so ordering is deterministic. +func TestMCPBridgeForwardsSessionID(t *testing.T) { + var mu sync.Mutex + var seenSessions []string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if nonPostBoilerplate(w, r) { + return + } + mu.Lock() + seenSessions = append(seenSessions, r.Header.Get("Mcp-Session-Id")) + mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Mcp-Session-Id", "sess-xyz") + io.WriteString(w, `{"jsonrpc":"2.0","id":1,"result":{}}`) + })) + defer server.Close() + + a := mcpTestAuth(t, "tok-1") + var out bytes.Buffer + b := newMCPBridgeWithIO(server.URL, a, "", strings.NewReader(""), &out) + + ctx := context.Background() + b.forwardPost(ctx, []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize"}`)) + require.Equal(t, "sess-xyz", b.getSession()) + b.forwardPost(ctx, []byte(`{"jsonrpc":"2.0","id":2,"method":"tools/list"}`)) + + mu.Lock() + defer mu.Unlock() + require.Len(t, seenSessions, 2) + assert.Equal(t, "", seenSessions[0], "first request has no session id yet") + assert.Equal(t, "sess-xyz", seenSessions[1], "second request must carry the session id") + assertStdoutIsJSONLines(t, out.String()) +} + +// TestMCPBridgeAcceptedNoBody verifies that a 202 (e.g. for a notification) +// produces no stdout output. +func TestMCPBridgeAcceptedNoBody(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if nonPostBoilerplate(w, r) { + return + } + w.WriteHeader(http.StatusAccepted) + })) + defer server.Close() + + a := mcpTestAuth(t, "tok-2") + var out bytes.Buffer + b := newMCPBridgeWithIO(server.URL, a, "", strings.NewReader(""), &out) + + b.forwardPost(context.Background(), []byte(`{"jsonrpc":"2.0","method":"notifications/initialized"}`)) + assert.Empty(t, strings.TrimSpace(out.String()), "202 responses must not write to stdout") +} + +// TestMCPBridge401ForcesRefresh verifies that a 401 triggers a forced token +// refresh and the retry carries the new token. +func TestMCPBridge401ForcesRefresh(t *testing.T) { + tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "access_token": "new-access", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "new-refresh", + }) + })) + defer tokenServer.Close() + + var mu sync.Mutex + var seenAuth []string + mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if nonPostBoilerplate(w, r) { + return + } + auth := r.Header.Get("Authorization") + mu.Lock() + seenAuth = append(seenAuth, auth) + mu.Unlock() + if auth == "Bearer old-access" { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{"jsonrpc":"2.0","id":1,"result":{"ok":true}}`) + })) + defer mcpServer.Close() + + a := mcpTestAuthRefreshable(t, "old-access", tokenServer.URL+"/token") + var out bytes.Buffer + b := newMCPBridgeWithIO(mcpServer.URL, a, "", strings.NewReader(""), &out) + + b.forwardPost(context.Background(), []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize"}`)) + + mu.Lock() + defer mu.Unlock() + require.GreaterOrEqual(t, len(seenAuth), 2, "expected a retry after 401") + assert.Equal(t, "Bearer old-access", seenAuth[0]) + assert.Equal(t, "Bearer new-access", seenAuth[len(seenAuth)-1], "retry must carry the refreshed token") + assert.Contains(t, out.String(), `"ok":true`) + assertStdoutIsJSONLines(t, out.String()) +} + +// TestMCPBridgeErrorStatusJSONForwarded verifies a JSON error body on a >=400 +// response is forwarded to stdout (so the client surfaces the JSON-RPC error). +func TestMCPBridgeErrorStatusJSONForwarded(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if nonPostBoilerplate(w, r) { + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + io.WriteString(w, `{"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"bad request"}}`) + })) + defer server.Close() + + a := mcpTestAuth(t, "tok-err") + var out bytes.Buffer + b := newMCPBridgeWithIO(server.URL, a, "", strings.NewReader(""), &out) + + b.forwardPost(context.Background(), []byte(`{"jsonrpc":"2.0","id":1,"method":"x"}`)) + assert.Contains(t, out.String(), `"error"`) + assert.Contains(t, out.String(), `"bad request"`) + assertStdoutIsJSONLines(t, out.String()) +} + +// TestMCPBridgePumpSSEMultilineAndNonJSON verifies multi-line data is reassembled +// + compacted into one JSON line, and non-JSON keep-alives are dropped. +func TestMCPBridgePumpSSEMultilineAndNonJSON(t *testing.T) { + a := mcpTestAuth(t, "tok") + var out bytes.Buffer + b := newMCPBridgeWithIO("", a, "", strings.NewReader(""), &out) + + sse := strings.Join([]string{ + ": keep-alive", + "", + `data: {"jsonrpc":"2.0",`, + `data: "id":5}`, + "", + "data: ping", + "", + }, "\n") + + b.pumpSSE(strings.NewReader(sse)) + + got := strings.TrimSpace(out.String()) + assert.Contains(t, got, `{"jsonrpc":"2.0","id":5}`, "multi-line data must compact to one JSON line") + assert.NotContains(t, got, "ping", "non-JSON keep-alive must be dropped") + assertStdoutIsJSONLines(t, out.String()) +} + +// TestMCPBridgeOpenServerStreamSSE verifies the standalone server->client GET +// stream forwards messages to stdout. +func TestMCPBridgeOpenServerStreamSSE(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "Bearer tok-get", r.Header.Get("Authorization")) + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + io.WriteString(w, `data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"p":1}}`+"\n\n") + })) + defer server.Close() + + a := mcpTestAuth(t, "tok-get") + var out bytes.Buffer + b := newMCPBridgeWithIO(server.URL, a, "", strings.NewReader(""), &out) + + status, eventStream, err := b.openServerStream(context.Background()) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, status) + assert.True(t, eventStream, "a text/event-stream 200 must be reported as an event stream") + assert.Contains(t, out.String(), `"notifications/progress"`) + assertStdoutIsJSONLines(t, out.String()) +} + +// TestMCPBridgeRunReturnsOnContextCancel verifies the bridge shuts down when its +// context is cancelled even though stdin never reaches EOF. +func TestMCPBridgeRunReturnsOnContextCancel(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if nonPostBoilerplate(w, r) { + return + } + w.WriteHeader(http.StatusAccepted) + })) + defer server.Close() + + pr, pw := io.Pipe() // reads block forever until we close pw + defer pw.Close() + + a := mcpTestAuth(t, "tok") + var out bytes.Buffer + b := newMCPBridgeWithIO(server.URL, a, "", pr, &out) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- b.run(ctx) }() + + cancel() + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("run() did not return after context cancellation") + } +} + +// parseRPCError decodes a single stdout line as a JSON-RPC error response. +func parseRPCError(t *testing.T, line string) (id string, code int, message string) { + t.Helper() + var resp struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + require.NoError(t, json.Unmarshal([]byte(line), &resp)) + assert.Equal(t, "2.0", resp.JSONRPC) + require.NotNil(t, resp.Error, "expected a JSON-RPC error member") + return string(resp.ID), resp.Error.Code, resp.Error.Message +} + +// TestMCPBridgeTransportFailureSynthesizesError verifies that when the server is +// unreachable, a request (with an id) still gets a synthesized JSON-RPC error so +// a strict client does not hang. +func TestMCPBridgeTransportFailureSynthesizesError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + url := srv.URL + srv.Close() // now connections to url are refused + + a := mcpTestAuth(t, "tok") + var out bytes.Buffer + b := newMCPBridgeWithIO(url, a, "", strings.NewReader(""), &out) + + b.forwardPost(context.Background(), []byte(`{"jsonrpc":"2.0","id":7,"method":"tools/list"}`)) + + line := strings.TrimSpace(out.String()) + require.NotEmpty(t, line, "a request must get a synthesized error reply on transport failure") + assertStdoutIsJSONLines(t, out.String()) + id, code, msg := parseRPCError(t, line) + assert.Equal(t, "7", id) + assert.Equal(t, rpcErrTransport, code) + assert.NotEmpty(t, msg) +} + +// TestMCPBridgeNotificationNoSynthesisOnFailure verifies a notification (no id) +// does NOT get a synthesized reply even when the server is unreachable. +func TestMCPBridgeNotificationNoSynthesisOnFailure(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + url := srv.URL + srv.Close() + + a := mcpTestAuth(t, "tok") + var out bytes.Buffer + b := newMCPBridgeWithIO(url, a, "", strings.NewReader(""), &out) + + b.forwardPost(context.Background(), []byte(`{"jsonrpc":"2.0","method":"notifications/cancelled"}`)) + assert.Empty(t, strings.TrimSpace(out.String()), "notifications must not get a synthesized reply") +} + +// TestMCPBridgeErrorStatusEmptyBodySynthesizesError verifies a >=400 response +// with no usable body yields a synthesized error keyed to the request id. +func TestMCPBridgeErrorStatusEmptyBodySynthesizesError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if nonPostBoilerplate(w, r) { + return + } + w.WriteHeader(http.StatusInternalServerError) // no body + })) + defer server.Close() + + a := mcpTestAuth(t, "tok") + var out bytes.Buffer + b := newMCPBridgeWithIO(server.URL, a, "", strings.NewReader(""), &out) + + b.forwardPost(context.Background(), []byte(`{"jsonrpc":"2.0","id":9,"method":"x"}`)) + line := strings.TrimSpace(out.String()) + require.NotEmpty(t, line, "a 500 with no body must yield a synthesized error reply") + assertStdoutIsJSONLines(t, out.String()) + id, code, _ := parseRPCError(t, line) + assert.Equal(t, "9", id) + assert.Equal(t, rpcErrUpstream, code) +} + +// TestMCPBridgeResponseNoSynthesisOnFailure verifies a client->server RESPONSE +// (an id but no method) does NOT get a synthesized error reply on forward +// failure -- only genuine requests do. +func TestMCPBridgeResponseNoSynthesisOnFailure(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + url := srv.URL + srv.Close() + + a := mcpTestAuth(t, "tok") + var out bytes.Buffer + b := newMCPBridgeWithIO(url, a, "", strings.NewReader(""), &out) + + // A response carries an id + result but no method. + b.forwardPost(context.Background(), []byte(`{"jsonrpc":"2.0","id":5,"result":{"ok":true}}`)) + assert.Empty(t, strings.TrimSpace(out.String()), "a client->server response must not get a synthesized reply") +} + +// TestMCPBridgeNonJSONRPCErrorBodySynthesizes verifies a >=400 body that is valid +// JSON but not a JSON-RPC response (e.g. a gateway error with no id) is not +// forwarded verbatim; a correlatable error keyed to the request id is +// synthesized instead so a strict client cannot hang. +func TestMCPBridgeNonJSONRPCErrorBodySynthesizes(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if nonPostBoilerplate(w, r) { + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + io.WriteString(w, `{"title":"Too Many Requests","status":429}`) + })) + defer server.Close() + + a := mcpTestAuth(t, "tok") + var out bytes.Buffer + b := newMCPBridgeWithIO(server.URL, a, "", strings.NewReader(""), &out) + + b.forwardPost(context.Background(), []byte(`{"jsonrpc":"2.0","id":11,"method":"tools/call"}`)) + line := strings.TrimSpace(out.String()) + require.NotEmpty(t, line, "a non-JSON-RPC 4xx body must yield a synthesized reply") + assertStdoutIsJSONLines(t, out.String()) + id, code, _ := parseRPCError(t, line) + assert.Equal(t, "11", id) + assert.Equal(t, rpcErrUpstream, code) + assert.NotContains(t, line, `"title"`, "the non-correlatable gateway body must not be forwarded as the reply") +} + +// TestMCPBridgeLargeErrorBodyForwardedNotTruncated verifies a large-but-valid +// JSON error body (bigger than the old 64 KiB cap) is forwarded whole rather +// than truncated into invalid JSON (which would be dropped and synthesized). +func TestMCPBridgeLargeErrorBodyForwardedNotTruncated(t *testing.T) { + big := strings.Repeat("a", 100*1024) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if nonPostBoilerplate(w, r) { + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + io.WriteString(w, `{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"`+big+`"}}`) + })) + defer server.Close() + + a := mcpTestAuth(t, "tok") + var out bytes.Buffer + b := newMCPBridgeWithIO(server.URL, a, "", strings.NewReader(""), &out) + + b.forwardPost(context.Background(), []byte(`{"jsonrpc":"2.0","id":1,"method":"x"}`)) + got := strings.TrimSpace(out.String()) + assertStdoutIsJSONLines(t, out.String()) + assert.Contains(t, got, big, "large valid JSON error body must be forwarded whole") + assert.Equal(t, 1, len(strings.Split(got, "\n")), "the server body is the reply; no second synthesized error") +} + +// TestMCPBridgeNotificationNotHeadOfLineBlocked verifies a notification can reach +// the server while a request's SSE response is still in flight. The server holds +// the request's stream open until the notification arrives; if notifications were +// blocked behind the in-flight request, run() would never complete. +func TestMCPBridgeNotificationNotHeadOfLineBlocked(t *testing.T) { + gotNotif := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.WriteHeader(http.StatusMethodNotAllowed) + return + case http.MethodDelete: + w.WriteHeader(http.StatusOK) + return + } + body, _ := io.ReadAll(r.Body) + if bytes.Contains(body, []byte("notifications/cancelled")) { + close(gotNotif) + w.WriteHeader(http.StatusAccepted) + return + } + // The long-running request: only stream the reply once the concurrent + // notification has been received. + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + flusher, _ := w.(http.Flusher) + select { + case <-gotNotif: + case <-time.After(3 * time.Second): // fail-safe so the server never hangs + } + io.WriteString(w, `data: {"jsonrpc":"2.0","id":1,"result":{"done":true}}`+"\n\n") + if flusher != nil { + flusher.Flush() + } + })) + defer server.Close() + + a := mcpTestAuth(t, "tok") + in := strings.NewReader( + `{"jsonrpc":"2.0","id":1,"method":"tools/call"}` + "\n" + + `{"jsonrpc":"2.0","method":"notifications/cancelled"}` + "\n") + var out bytes.Buffer + b := newMCPBridgeWithIO(server.URL, a, "", in, &out) + + done := make(chan error, 1) + go func() { done <- b.run(context.Background()) }() + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("run did not complete; the notification was head-of-line blocked behind the in-flight request") + } + assert.Contains(t, out.String(), `"done":true`) + assertStdoutIsJSONLines(t, out.String()) +} + +// TestMCPBridgeBootstrapFailsFastWithoutToken verifies the bridge does NOT try +// to launch a browser when no token exists; it fails fast with guidance to +// authenticate out-of-band (including the --headless option). +func TestMCPBridgeBootstrapFailsFastWithoutToken(t *testing.T) { + tempDir := t.TempDir() + ts := &store.TokenStore{ + Apps: map[string]*store.App{"default": {OAuth2Tokens: map[string]store.Token{}}}, + DefaultApp: "default", + FilePath: filepath.Join(tempDir, ".xurl"), + } + a := auth.NewAuth(&config.Config{}).WithTokenStore(ts) + b := newMCPBridgeWithIO("http://127.0.0.1:0", a, "", strings.NewReader(""), &bytes.Buffer{}) + + err := b.bootstrap() + require.Error(t, err) + assert.Contains(t, err.Error(), "xurl auth oauth2") + assert.Contains(t, err.Error(), "--headless") +} + +// TestReadLineCapped verifies lines are returned intact under the cap, and lines +// over the cap are flagged oversized without buffering past the cap. +func TestReadLineCapped(t *testing.T) { + r := bufio.NewReader(strings.NewReader("hello\ntoolongline\nok\n")) + const max = 8 + + line, oversized, err := readLineCapped(r, max) + require.NoError(t, err) + assert.False(t, oversized) + assert.Equal(t, "hello", string(line)) + + line, oversized, err = readLineCapped(r, max) + require.NoError(t, err) + assert.True(t, oversized, "a line longer than max must be flagged oversized") + assert.LessOrEqual(t, len(line), max, "buffered bytes must not exceed the cap") + + line, oversized, err = readLineCapped(r, max) + require.NoError(t, err) + assert.False(t, oversized) + assert.Equal(t, "ok", string(line)) +} diff --git a/cli/media.go b/cli/media.go index 5d7acb9..4c0a386 100644 --- a/cli/media.go +++ b/cli/media.go @@ -45,18 +45,18 @@ func createMediaUploadCmd(auth *auth.Auth) *cobra.Command { config := config.NewConfig() client := api.NewApiClient(config, auth) - err := api.ExecuteMediaUpload(filePath, mediaType, mediaCategory, authType, username, verbose, trace, waitForProcessing, headers, client) + err := api.ExecuteMediaUpload(filePath, mediaType, mediaCategory, authType, username, verbose, waitForProcessing, trace, headers, client) if err != nil { - fmt.Printf("\033[31m%v\033[0m\n", err) + fmt.Fprintf(os.Stderr, "\033[31m%v\033[0m\n", err) os.Exit(1) } }, } - cmd.Flags().StringVar(&mediaType, "media-type", "video/mp4", "Media type (e.g., image/jpeg, image/png, video/mp4)") - cmd.Flags().StringVar(&mediaCategory, "category", "amplify_video", "Media category (e.g., tweet_image, tweet_video, amplify_video)") + cmd.Flags().StringVar(&mediaType, "media-type", "", "Media MIME type (auto-detected from the file extension if omitted)") + cmd.Flags().StringVar(&mediaCategory, "category", "", "Media category (derived from the media type if omitted)") cmd.Flags().BoolVar(&waitForProcessing, "wait", true, "Wait for media processing to complete") - cmd.Flags().String("auth", "", "Authentication type (oauth1 or oauth2)") + cmd.Flags().String("auth", "", "Authentication type (oauth1, oauth2, or app)") cmd.Flags().StringP("username", "u", "", "Username for OAuth2 authentication") cmd.Flags().BoolP("verbose", "v", false, "Print verbose information") cmd.Flags().BoolP("trace", "t", false, "Add trace header to request") @@ -85,13 +85,13 @@ func createMediaStatusCmd(auth *auth.Auth) *cobra.Command { err := api.ExecuteMediaStatus(mediaID, authType, username, verbose, wait, trace, headers, client) if err != nil { - fmt.Printf("\033[31m%v\033[0m\n", err) + fmt.Fprintf(os.Stderr, "\033[31m%v\033[0m\n", err) os.Exit(1) } }, } - cmd.Flags().String("auth", "", "Authentication type (oauth1 or oauth2)") + cmd.Flags().String("auth", "", "Authentication type (oauth1, oauth2, or app)") cmd.Flags().StringP("username", "u", "", "Username for OAuth2 authentication") cmd.Flags().BoolP("verbose", "v", false, "Print verbose information") cmd.Flags().BoolP("wait", "w", false, "Wait for media processing to complete") diff --git a/cli/root.go b/cli/root.go index db2a0f6..c8ec74b 100644 --- a/cli/root.go +++ b/cli/root.go @@ -9,47 +9,39 @@ import ( "github.com/xdevplatform/xurl/api" "github.com/xdevplatform/xurl/auth" "github.com/xdevplatform/xurl/config" + "github.com/xdevplatform/xurl/version" +) + +// Command group IDs used to organise the help output into scannable sections. +const ( + groupWrite = "write" + groupSocial = "social" + groupRead = "read" + groupManage = "manage" ) // CreateRootCommand creates the root command for the xurl CLI func CreateRootCommand(cfg *config.Config, a *auth.Auth) *cobra.Command { var rootCmd = &cobra.Command{ - Use: "xurl [flags] URL", - Short: "Auth enabled curl-like interface for the X API", + Use: "xurl [flags] URL", + Short: "Auth enabled curl-like interface for the X API", + Version: version.Version, Long: `A command-line tool for making authenticated requests to the X API. -Shortcut commands (agent‑friendly): - xurl post "Hello world!" Post to X - xurl reply 1234567890 "Nice!" Reply to a post - xurl read 1234567890 Read a post - xurl search "golang" -n 20 Search posts - xurl whoami Show your profile - xurl like 1234567890 Like a post - xurl repost 1234567890 Repost - xurl follow @user Follow a user - xurl dm @user "Hey!" Send a DM - xurl timeline Home timeline - xurl mentions Your mentions - -Raw API access (curl‑style): - basic requests xurl /2/users/me - xurl -X POST /2/tweets -d '{"text":"Hello world!"}' - xurl -H "Content-Type: application/json" /2/tweets - authentication xurl --auth oauth2 /2/users/me - xurl --auth oauth1 /2/users/me - xurl --auth app /2/users/me - media and streaming xurl media upload path/to/video.mp4 - xurl /2/tweets/search/stream --auth app - xurl -s /2/users/me - -Multi-app management: +Quick start: + xurl post "Hello world!" Post to X (shortcut command) + xurl /2/users/me Raw GET request + xurl -X POST /2/tweets -d '{"text":"hi"}' Raw request with a JSON body + xurl --auth app /2/tweets/search/stream Pick an auth type (oauth1|oauth2|app) + xurl media upload photo.jpg Upload media (type auto-detected) + +Authentication: xurl auth apps add my-app --client-id ... --client-secret ... - xurl auth apps list - xurl auth default # interactive picker - xurl auth default my-app # set by name - xurl --app my-app /2/users/me # per-request override + xurl auth oauth2 --app my-app Authenticate a user + xurl auth default my-app Set the default app + xurl --app my-app /2/users/me Per-request app override -Run 'xurl --help' to see all available commands.`, +Commands are grouped by purpose below. Run 'xurl --help' for details.`, PersistentPreRun: func(cmd *cobra.Command, args []string) { // Apply --app override if provided appOverride, _ := cmd.Flags().GetString("app") @@ -61,13 +53,20 @@ Run 'xurl --help' to see all available commands.`, return nil }, Run: func(cmd *cobra.Command, args []string) { + headers, _ := cmd.Flags().GetStringArray("header") + data, _ := cmd.Flags().GetString("data") + method, _ := cmd.Flags().GetString("method") if method == "" { - method = "GET" + // Mirror curl: providing a request body (-d/--data) implies POST + // unless -X says otherwise — even for an explicitly empty body. + if cmd.Flags().Changed("data") { + method = "POST" + } else { + method = "GET" + } } - headers, _ := cmd.Flags().GetStringArray("header") - data, _ := cmd.Flags().GetString("data") authType, _ := cmd.Flags().GetString("auth") username, _ := cmd.Flags().GetString("username") verbose, _ := cmd.Flags().GetBool("verbose") @@ -76,9 +75,9 @@ Run 'xurl --help' to see all available commands.`, mediaFile, _ := cmd.Flags().GetString("file") if len(args) == 0 { - fmt.Println("No URL provided") - fmt.Println("Usage: xurl [OPTIONS] [URL] [COMMAND]") - fmt.Println("Try 'xurl --help' for more information.") + fmt.Fprintln(os.Stderr, "No URL provided") + fmt.Fprintln(os.Stderr, "Usage: xurl [OPTIONS] [URL] [COMMAND]") + fmt.Fprintln(os.Stderr, "Try 'xurl --help' for more information.") os.Exit(1) } @@ -98,7 +97,7 @@ Run 'xurl --help' to see all available commands.`, } err := api.HandleRequest(requestOptions, forceStream, mediaFile, client) if err != nil { - fmt.Printf("\033[31mError: %v\033[0m\n", err) + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) os.Exit(1) } }, @@ -107,20 +106,39 @@ Run 'xurl --help' to see all available commands.`, // Global persistent flag: --app rootCmd.PersistentFlags().String("app", "", "Use a specific registered app (overrides default)") - rootCmd.Flags().StringP("method", "X", "", "HTTP method (GET by default)") + rootCmd.Flags().StringP("method", "X", "", "HTTP method (GET by default, POST when -d is given)") rootCmd.Flags().StringArrayP("header", "H", []string{}, "Request headers") rootCmd.Flags().StringP("data", "d", "", "Request body data") - rootCmd.Flags().String("auth", "", "Authentication type (oauth1 or oauth2)") + rootCmd.Flags().String("auth", "", "Authentication type (oauth1, oauth2, or app)") rootCmd.Flags().StringP("username", "u", "", "Username for OAuth2 authentication") rootCmd.Flags().BoolP("verbose", "v", false, "Print verbose information") rootCmd.Flags().BoolP("trace", "t", false, "Add trace header to request") rootCmd.Flags().BoolP("stream", "s", false, "Force streaming mode for non-streaming endpoints") rootCmd.Flags().StringP("file", "F", "", "File to upload (for multipart requests)") - rootCmd.AddCommand(CreateAuthCommand(a)) - rootCmd.AddCommand(CreateMediaCommand(a)) - rootCmd.AddCommand(CreateVersionCommand()) - rootCmd.AddCommand(CreateWebhookCommand(a)) + // Organise subcommands into scannable help sections. + rootCmd.AddGroup( + &cobra.Group{ID: groupWrite, Title: "Posting & Engagement:"}, + &cobra.Group{ID: groupSocial, Title: "Users & Social Graph:"}, + &cobra.Group{ID: groupRead, Title: "Reading & Lists:"}, + &cobra.Group{ID: groupManage, Title: "Management:"}, + ) + + authCmd := CreateAuthCommand(a) + mediaCmd := CreateMediaCommand(a) + versionCmd := CreateVersionCommand() + webhookCmd := CreateWebhookCommand(a) + tokenCmd := CreateTokenCommand(a) + mcpCmd := CreateMCPCommand(a) + for _, c := range []*cobra.Command{authCmd, mediaCmd, tokenCmd, mcpCmd, versionCmd, webhookCmd} { + c.GroupID = groupManage + rootCmd.AddCommand(c) + } + + // Place the auto-generated help/completion commands in the Management group + // too, so the help screen has no ungrouped "Additional Commands" section. + rootCmd.SetHelpCommandGroupID(groupManage) + rootCmd.SetCompletionCommandGroupID(groupManage) // Register streamlined shortcut commands (post, reply, read, search, etc.) CreateShortcutCommands(rootCmd, a) diff --git a/cli/shortcuts.go b/cli/shortcuts.go index 45a11f7..2b399fc 100644 --- a/cli/shortcuts.go +++ b/cli/shortcuts.go @@ -39,9 +39,12 @@ func newClient(a *auth.Auth) *api.ApiClient { } // printResult pretty‑prints a JSON response or exits on error. +// +// API error bodies (valid JSON) are intentionally written to stdout so they can +// be piped/parsed the same way as a successful response; only non-JSON errors +// (network/auth failures) go to stderr. func printResult(resp json.RawMessage, err error) { if err != nil { - // Try to pretty‑print API error bodies var raw json.RawMessage if json.Unmarshal([]byte(err.Error()), &raw) == nil { utils.FormatAndPrintResponse(raw) @@ -115,35 +118,26 @@ func addCommonFlags(cmd *cobra.Command) { // ----------------------------------------------------------------- func CreateShortcutCommands(rootCmd *cobra.Command, a *auth.Auth) { - rootCmd.AddCommand( - postCmd(a), - replyCmd(a), - quoteCmd(a), - deleteCmd(a), - readCmd(a), - searchCmd(a), - whoamiCmd(a), - userCmd(a), - timelineCmd(a), - mentionsCmd(a), - likeCmd(a), - unlikeCmd(a), - repostCmd(a), - unrepostCmd(a), - bookmarkCmd(a), - unbookmarkCmd(a), - bookmarksCmd(a), - followCmd(a), - unfollowCmd(a), - followingCmd(a), - followersCmd(a), - likesCmd(a), - dmCmd(a), - dmsCmd(a), - blockCmd(a), - unblockCmd(a), - muteCmd(a), - unmuteCmd(a), + add := func(groupID string, cmds ...*cobra.Command) { + for _, c := range cmds { + c.GroupID = groupID + rootCmd.AddCommand(c) + } + } + + add(groupWrite, + postCmd(a), replyCmd(a), quoteCmd(a), deleteCmd(a), dmCmd(a), + likeCmd(a), unlikeCmd(a), repostCmd(a), unrepostCmd(a), + bookmarkCmd(a), unbookmarkCmd(a), + ) + add(groupSocial, + whoamiCmd(a), userCmd(a), + followCmd(a), unfollowCmd(a), followingCmd(a), followersCmd(a), + blockCmd(a), unblockCmd(a), muteCmd(a), unmuteCmd(a), + ) + add(groupRead, + readCmd(a), searchCmd(a), postsCmd(a), timelineCmd(a), mentionsCmd(a), + dmsCmd(a), bookmarksCmd(a), likesCmd(a), ) } @@ -283,6 +277,33 @@ Examples: return cmd } +func postsCmd(a *auth.Auth) *cobra.Command { + var maxResults int + cmd := &cobra.Command{ + Use: "posts USERNAME", + Short: "List a user's recent posts", + Long: `Fetch recent posts authored by a user (by @username). + +Examples: + xurl posts elonmusk + xurl posts @XDevelopers -n 50`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + client := newClient(a) + opts := baseOpts(cmd) + userID, err := resolveUserID(client, args[0], opts) + if err != nil { + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) + os.Exit(1) + } + printResult(api.GetUserPosts(client, userID, maxResults, opts)) + }, + } + cmd.Flags().IntVarP(&maxResults, "max-results", "n", 10, "Number of results (5–100)") + addCommonFlags(cmd) + return cmd +} + // ================================================================= // USER INFO // ================================================================= @@ -576,7 +597,7 @@ Examples: printResult(api.GetLikedPosts(client, userID, maxResults, opts)) }, } - cmd.Flags().IntVarP(&maxResults, "max-results", "n", 10, "Number of results (1–100)") + cmd.Flags().IntVarP(&maxResults, "max-results", "n", 10, "Number of results (5–100)") addCommonFlags(cmd) return cmd } diff --git a/cli/token.go b/cli/token.go new file mode 100644 index 0000000..7e0b7bb --- /dev/null +++ b/cli/token.go @@ -0,0 +1,78 @@ +package cli + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/xdevplatform/xurl/auth" +) + +// CreateTokenCommand creates the `token` command, which prints a valid OAuth2 +// access token for the active app to stdout. It refreshes (and persists) an +// expired token but never opens a browser, so it stays scriptable. +func CreateTokenCommand(a *auth.Auth) *cobra.Command { + cmd := &cobra.Command{ + Use: "token", + Short: "Print a valid OAuth2 access token for the active app", + Long: `Print a valid OAuth2 access token for the active app to stdout (one line). + +If the stored token has expired it is refreshed and persisted first. This +command never opens a browser, so it is safe to use in scripts. If no token is +available it exits non-zero and tells you to run 'xurl auth oauth2'. + +Examples: + xurl token + xurl token --app my-app + TOKEN=$(xurl token)`, + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + username, _ := cmd.Flags().GetString("username") + + token, err := a.GetValidOAuth2Token(username) + if err != nil { + appName := a.TokenStore.GetActiveAppName(a.AppName()) + target := fmt.Sprintf("app %q", appName) + if username != "" { + target = fmt.Sprintf("app %q (user %q)", appName, username) + } + fprintError(os.Stderr, "Error: no valid oauth2 token for %s: %v", target, err) + fmt.Fprintf(os.Stderr, "Run: xurl auth oauth2%s\n", appFlagHint(a.AppName())) + os.Exit(1) + } + + fmt.Println(token) + }, + } + + cmd.Flags().StringP("username", "u", "", "OAuth2 username to act as") + return cmd +} + +// appFlagHint returns a " --app NAME" suffix for help messages when an explicit +// app override is active, or an empty string otherwise. +func appFlagHint(appName string) string { + if appName == "" { + return "" + } + return " --app " + appName +} + +// isTerminal reports whether f is attached to a terminal, used to decide whether +// ANSI color is appropriate. +func isTerminal(f *os.File) bool { + info, err := f.Stat() + return err == nil && (info.Mode()&os.ModeCharDevice) != 0 +} + +// fprintError writes a red error line to w, omitting the ANSI color codes when w +// is not a terminal so redirected/piped output stays clean for scripts. +func fprintError(w *os.File, format string, args ...any) { + msg := fmt.Sprintf(format, args...) + if isTerminal(w) { + fmt.Fprintf(w, "\033[31m%s\033[0m\n", msg) + } else { + fmt.Fprintln(w, msg) + } +} diff --git a/cli/token_test.go b/cli/token_test.go new file mode 100644 index 0000000..ee2912b --- /dev/null +++ b/cli/token_test.go @@ -0,0 +1,12 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAppFlagHint(t *testing.T) { + assert.Equal(t, "", appFlagHint(""), "no app override yields no hint") + assert.Equal(t, " --app my-app", appFlagHint("my-app")) +} diff --git a/cli/webhook.go b/cli/webhook.go index 4a6675d..f222593 100644 --- a/cli/webhook.go +++ b/cli/webhook.go @@ -39,7 +39,7 @@ func CreateWebhookCommand(authInstance *auth.Auth) *cobra.Command { webhookStartCmd := &cobra.Command{ Use: "start", Short: "Start a local webhook server with an ngrok tunnel", - Long: `Starts a local HTTP server and an ngrok tunnel to listen for X API webhook events, including CRC checks. POST request bodies can be saved to a file using the -o flag. Use -q for quieter console logging of POST events. Use -p to pretty-print JSON POST bodies in the console.`, + Long: `Starts a local HTTP server and an ngrok tunnel to listen for X API webhook events, including CRC checks. POST request bodies can be saved to a file using the -o flag. Use -q for quieter console logging of POST events. Use -P to pretty-print JSON POST bodies in the console.`, Run: func(cmd *cobra.Command, args []string) { color.Cyan("Starting webhook server with ngrok...") @@ -102,7 +102,8 @@ func CreateWebhookCommand(authInstance *auth.Auth) *cobra.Command { fmt.Printf(" Forwarding URL: %s -> %s\n", color.HiGreenString(ngrokListener.URL()), color.MagentaString(forwardToAddr)) color.Yellow("Use this URL for your X API webhook registration: %s/webhook", color.HiGreenString(ngrokListener.URL())) - http.HandleFunc("/webhook", func(w http.ResponseWriter, r *http.Request) { + mux := http.NewServeMux() + mux.HandleFunc("/webhook", func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { crcToken := r.URL.Query().Get("crc_token") if crcToken == "" { @@ -172,7 +173,7 @@ func CreateWebhookCommand(authInstance *auth.Auth) *cobra.Command { }) color.Cyan("Starting local HTTP server to handle requests from ngrok tunnel (forwarded from %s)...", color.HiGreenString(ngrokListener.URL())) - if err := http.Serve(ngrokListener, nil); err != nil { + if err := http.Serve(ngrokListener, mux); err != nil { if err != http.ErrServerClosed { color.Red("HTTP server error: %v", err) os.Exit(1) From ba32144cc3916a66bc0c89050ce2c4e1bc311690 Mon Sep 17 00:00:00 2001 From: Santiago Medina Rolong Date: Mon, 29 Jun 2026 14:45:38 -0700 Subject: [PATCH 2/2] fix: install + whoami bugs (#68, #56, #41) - install.sh: detect root with `id -u` instead of the bash-only `$EUID`, so `curl ... | sh` under a POSIX shell (dash) installs to /usr/local/bin as root instead of silently falling back to ~/.local/bin (#68). - npm/install.js: extract the Windows .zip with PowerShell's Expand-Archive instead of the Unix `unzip` command, fixing `npm install -g` on Windows (#56). - whoami/user: request `verified_type` and `subscription_type` so Premium/blue accounts report correctly instead of `verified: false` (#41). --- CHANGELOG.md | 3 +++ api/shortcuts.go | 4 ++-- cli/shortcuts_test.go | 4 ++-- install.sh | 5 ++++- npm/install.js | 7 ++++++- 5 files changed, 17 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d40329e..74bba51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ All user-visible bugs and enhancements should be recorded here. ### Fixed +- [2026-06-29] `install.sh` now uses `id -u` instead of the bash-only `$EUID` to detect root, so `curl ... | sh` (POSIX/dash) installs to `/usr/local/bin` as root instead of silently falling back to `~/.local/bin`. (#68) +- [2026-06-29] npm install on Windows works again: `install.js` extracts the `.zip` with PowerShell's `Expand-Archive` instead of the Unix `unzip` command. (#56) +- [2026-06-29] `whoami` (and `user`) now request `verified_type` and `subscription_type`, so Premium/blue accounts are reported correctly instead of `verified: false`. (#41) - [2026-06-29] OAuth2 token exchange and refresh now send client credentials with the correct auth style — HTTP Basic header for confidential clients (those with a secret), `client_id` in the body for public clients — instead of relying on autodetection, which could fail against X with `unauthorized_client: Missing valid authorization header`. - [2026-06-29] `mcp` bridge no longer launches a browser at startup: it still refreshes an existing token silently, but when none is available it fails fast with instructions (`xurl auth oauth2 [--app NAME] [--headless]`) instead of opening a browser mid-startup (which could hang an MCP client's handshake) and printing to the JSON-RPC stdout channel. OAuth2 diagnostics now go to stderr. - [2026-06-29] `mcp` bridge no longer lets a strict client hang: a request that cannot be answered — transport failure, a failed token refresh/retry after a 401, or a response with an empty/non-JSON body — now gets a synthesized JSON-RPC error keyed to its id. Notifications (e.g. `notifications/cancelled`) are no longer head-of-line blocked behind an in-flight streaming response, large but valid JSON error bodies are forwarded whole instead of being truncated, the standalone server->client stream stops probing a non-event-stream `200` and only resets its reconnect backoff after a healthy stream, and stdin memory stays bounded when an oversized line is dropped. diff --git a/api/shortcuts.go b/api/shortcuts.go index 783ee79..8fdac25 100644 --- a/api/shortcuts.go +++ b/api/shortcuts.go @@ -187,7 +187,7 @@ func SearchPosts(client Client, query string, maxResults int, opts RequestOption // GetMe fetches the authenticated user's profile. func GetMe(client Client, opts RequestOptions) (json.RawMessage, error) { opts.Method = "GET" - opts.Endpoint = "/2/users/me?user.fields=created_at,description,public_metrics,verified,profile_image_url" + opts.Endpoint = "/2/users/me?user.fields=created_at,description,public_metrics,verified,verified_type,subscription_type,profile_image_url" opts.Data = "" return client.SendRequest(opts) @@ -198,7 +198,7 @@ func LookupUser(client Client, username string, opts RequestOptions) (json.RawMe username = ResolveUsername(username) opts.Method = "GET" - opts.Endpoint = fmt.Sprintf("/2/users/by/username/%s?user.fields=created_at,description,public_metrics,verified,profile_image_url", username) + opts.Endpoint = fmt.Sprintf("/2/users/by/username/%s?user.fields=created_at,description,public_metrics,verified,verified_type,subscription_type,profile_image_url", username) opts.Data = "" return client.SendRequest(opts) diff --git a/cli/shortcuts_test.go b/cli/shortcuts_test.go index aabd6ff..2a7d920 100644 --- a/cli/shortcuts_test.go +++ b/cli/shortcuts_test.go @@ -39,7 +39,7 @@ func (f fakeClient) SendMultipartRequest(options api.MultipartOptions) (json.Raw func TestResolveMyUserIDUsesUsernameFallback(t *testing.T) { client := fakeClient{ sendRequest: func(options api.RequestOptions) (json.RawMessage, error) { - require.Equal(t, "/2/users/by/username/alice?user.fields=created_at,description,public_metrics,verified,profile_image_url", options.Endpoint) + require.Equal(t, "/2/users/by/username/alice?user.fields=created_at,description,public_metrics,verified,verified_type,subscription_type,profile_image_url", options.Endpoint) return json.RawMessage(`{"data":{"id":"42"}}`), nil }, } @@ -52,7 +52,7 @@ func TestResolveMyUserIDUsesUsernameFallback(t *testing.T) { func TestResolveMyUserIDReturnsHelpfulErrorWhenGetMeFails(t *testing.T) { client := fakeClient{ sendRequest: func(options api.RequestOptions) (json.RawMessage, error) { - require.Equal(t, "/2/users/me?user.fields=created_at,description,public_metrics,verified,profile_image_url", options.Endpoint) + require.Equal(t, "/2/users/me?user.fields=created_at,description,public_metrics,verified,verified_type,subscription_type,profile_image_url", options.Endpoint) return nil, fmt.Errorf("boom") }, } diff --git a/install.sh b/install.sh index 4c54253..b58ffe7 100755 --- a/install.sh +++ b/install.sh @@ -7,7 +7,10 @@ PROGRAM_NAME="xurl" # Install to ~/.local/bin by default (no sudo needed). # Falls back to /usr/local/bin if run as root. -if [ "$EUID" -eq 0 ]; then +# Use `id -u` rather than `$EUID`: $EUID is bash-only and expands to empty under +# POSIX shells (e.g. dash, the default /bin/sh on Debian/Ubuntu), which silently +# picks the user path even when run as root via `curl ... | sh`. +if [ "$(id -u)" -eq 0 ]; then INSTALL_DIR="/usr/local/bin" else INSTALL_DIR="${HOME}/.local/bin" diff --git a/npm/install.js b/npm/install.js index 70ce26d..12b9055 100644 --- a/npm/install.js +++ b/npm/install.js @@ -69,7 +69,12 @@ async function install() { if (ext === "tar.gz") { execSync(`tar xzf "${archivePath}" -C "${tmpDir}"`); } else { - execSync(`unzip -o "${archivePath}" -d "${tmpDir}"`); + // Windows releases are .zip. Extract with PowerShell's Expand-Archive, + // which is built into Windows, instead of the Unix `unzip` command (which + // isn't present on Windows and broke `npm install -g` there). + execSync( + `powershell -NoProfile -NonInteractive -Command "Expand-Archive -LiteralPath '${archivePath}' -DestinationPath '${tmpDir}' -Force"` + ); } const binaryName = plat === "Windows" ? "xurl.exe" : "xurl";