52 Commits
Author SHA1 Message Date
Peter Steinberger 34615de95d chore(release): 0.4.1 2025-12-31 20:11:24 +01:00
Peter Steinberger 2a2a3a7c59 chore: run oxlint type-aware in lint 2025-12-31 19:59:31 +01:00
Peter Steinberger 94de794588 chore: tighten biome rules further 2025-12-31 19:52:23 +01:00
Peter Steinberger 354bf11471 docs: update changelog 2025-12-31 19:41:58 +01:00
Peter Steinberger fc77190929 chore: tighten lint rules and tests 2025-12-31 19:36:33 +01:00
Peter Steinberger 464b50b2fd chore(deps): use sweet-cookie 0.1.0 2025-12-31 12:16:42 +01:00
Peter Steinberger d820d7fbae Merge pull request #4 from alexknowshtml/feat/bookmarks-command
feat: add bookmarks command
2025-12-31 12:16:07 +01:00
Peter Steinberger 2ba0eff1e6 test: cover bookmarks 2025-12-31 12:04:24 +01:00
Alex HillmanandClaude Opus 4.5 9c553e46bf feat: add bookmarks command
Add `bird bookmarks` command to fetch the authenticated user's bookmarked tweets.

- New `getBookmarks()` method in TwitterClient
- Uses graphql_timeline_v2_bookmark_timeline feature flag
- Supports `--count` and `--json` options
- Follows existing patterns for query ID rotation and retry logic

Usage:
  bird bookmarks           # Get your recent bookmarks
  bird bookmarks -n 50     # Get 50 bookmarks
  bird bookmarks --json    # Output as JSON

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-30 22:44:52 +00:00
Peter Steinberger 5532e7ddc2 docs: note sweet-cookie cookie backend 2025-12-27 13:30:29 +01:00
Peter Steinberger c495fb5078 refactor(cookies): use sweet-cookie 2025-12-27 13:23:39 +01:00
Peter Steinberger c16726e9d8 fix: narrow tweet results 2025-12-26 22:40:45 +01:00
Peter Steinberger 2483e32c11 docs: release notes for 0.4.0 2025-12-26 22:39:13 +01:00
Peter Steinberger dfdb7452f6 docs: add undocumented API disclaimer 2025-12-26 22:34:25 +01:00
Peter Steinberger e8bc711c9e docs: mark 0.4.0 unreleased 2025-12-26 22:33:20 +01:00
Peter Steinberger a39948fbbd feat: add bird.fast GitHub Pages site 2025-12-26 22:24:13 +01:00
Peter Steinberger a518df5bb8 Create CNAME 2025-12-26 22:22:21 +01:00
Peter Steinberger 3db2145c2c chore(release): 0.4.0 2025-12-26 22:08:07 +01:00
Peter Steinberger a3d1ba29f7 test(cli): always print tweet URL 2025-12-26 22:04:08 +01:00
Peter Steinberger 1007d051e4 fix(tweet): fallback to statuses/update on 226 2025-12-26 21:57:36 +01:00
Peter Steinberger e8e98e641a refactor(auth)!: replace allow toggles with cookieSource 2025-12-26 21:28:27 +01:00
Peter Steinberger 008e550654 test: skip safari cookieSource on non-darwin 2025-12-26 21:18:41 +01:00
Peter Steinberger 1744f4cc24 feat(auth): allow selecting cookie source 2025-12-26 21:17:20 +01:00
Peter Steinberger bd777f6acc docs(changelog): start 0.3.1 2025-12-26 21:03:51 +01:00
Peter Steinberger fe07d133c5 chore: undo 0.3.1 bump 2025-12-26 20:55:31 +01:00
Peter Steinberger 9a18b49b58 chore: bump to 0.3.1 2025-12-26 20:53:21 +01:00
Peter Steinberger 67ed76b58f chore(release): 0.3.0 2025-12-26 20:49:55 +01:00
Peter Steinberger b5e701770a docs(readme): add homebrew install 2025-12-26 20:34:56 +01:00
Peter Steinberger ca5dabf302 docs(changelog): note coverage thresholds 2025-12-26 20:08:38 +01:00
Peter Steinberger b46b24a8c5 test: enforce coverage thresholds 2025-12-26 18:23:59 +01:00
Peter Steinberger a4686f90ee feat: add Safari cookie extraction 2025-12-26 17:51:41 +01:00
Peter Steinberger 02d12b4e87 refactor!: remove Sweetistics engine
bird is GraphQL-only; bump to 0.3.0
2025-12-26 17:41:43 +01:00
Peter Steinberger 013fa5da95 docs: explain GraphQL query ID refresh 2025-12-26 16:47:39 +01:00
Peter Steinberger f3495247a3 chore: bump to 0.2.1 2025-12-26 16:29:36 +01:00
Peter Steinberger 9f38efc9bf chore(release): 0.2.0 2025-12-26 16:27:00 +01:00
Peter Steinberger 0bf7190c95 feat(graphql): support video uploads 2025-12-26 15:27:35 +01:00
Peter Steinberger 3be2e3ae7e feat(graphql): upload media 2025-12-26 15:22:53 +01:00
Peter Steinberger dee304235b fix(engine): disable Sweetistics fallback when forcing graphql 2025-12-26 15:10:10 +01:00
Peter Steinberger 9a4465f05d docs(changelog): mention runtime query-id cache 2025-12-26 15:03:48 +01:00
Peter Steinberger 978d07f60f feat(graphql): runtime query-id refresh 2025-12-26 14:58:03 +01:00
Peter Steinberger 472d666eee docs: document output flags + help 2025-12-26 14:26:29 +01:00
Peter Steinberger c35e937b2a feat(cli): add plain/no-emoji/no-color + help 2025-12-26 14:14:31 +01:00
Peter Steinberger 4730267c2a fix(sweetistics): improve media upload errors 2025-12-26 13:10:54 +01:00
Peter Steinberger 8257d52532 fix(twitter): improve createTweet posting 2025-12-26 13:10:49 +01:00
Peter Steinberger 80a57c825f fix(graphql): keep query ids fresh 2025-12-26 13:10:43 +01:00
Peter Steinberger 88651f669d chore: bump version to 0.2.0 2025-12-26 12:41:48 +01:00
Peter Steinberger 6669ba9912 test(cli): validate mentions handle 2025-12-26 12:34:16 +01:00
Peter Steinberger 3df7969b16 feat(cli): mentions target user 2025-12-26 12:23:00 +01:00
Peter Steinberger 5613caa149 feat(cli): include git sha in --version 2025-12-26 12:12:16 +01:00
Peter Steinberger b4e1ddbf2e docs(changelog): note version lookup 2025-12-26 12:06:31 +01:00
Peter Steinberger 9222908ab7 fix(cli): read version from file 2025-12-26 12:05:52 +01:00
Peter Steinberger 674fee3b8a docs(changelog): add 0.1.2 unreleased 2025-12-26 10:36:39 +01:00
43 changed files with 5030 additions and 2017 deletions
+1
View File
@@ -0,0 +1 @@
auto-install-peers=false
+55
View File
@@ -1,5 +1,60 @@
# Changelog # Changelog
## 0.4.1 — 2025-12-31
### Added
- `bookmarks` command to list your bookmarked tweets.
### Changed
- Cookie extraction now uses `@steipete/sweet-cookie` (drops `sqlite3` CLI + custom browser readers in `bird`).
- Query ID updater now tracks the Bookmarks GraphQL operation.
- Lint rules stricter (block statements, no-negation-else, useConst/useTemplate, top-level regex, import extension enforcement).
- `pnpm lint` now runs both Biome and oxlint (type-aware).
### Tests
- Coverage thresholds raised to 90% statements/lines/functions (80% branches).
- Added targeted Twitter client coverage suites.
## 0.4.0 — 2025-12-26
### Added
- Cookie source selection: `--cookie-source safari|chrome|firefox` (repeatable) + `cookieSource` config (string or array).
### Fixed
- `tweet`/`reply`: fallback to `statuses/update.json` when GraphQL `CreateTweet` returns error 226 (“automated request”).
### Breaking
- Remove `allowSafari`/`allowChrome`/`allowFirefox` config toggles in favor of `cookieSource` ordering.
## 0.3.0 — 2025-12-26
### Added
- Safari cookie extraction (`Cookies.binarycookies`) + `allowSafari` config toggle.
### Changed
- Removed the Sweetistics engine + fallback. `bird` is GraphQL-only.
- Browser cookie fallback order: Safari → Chrome → Firefox.
### Tests
- Enforce coverage thresholds (>= 70% statements/branches/functions/lines) + expand unit coverage for version/output/Twitter client branches.
## 0.2.0 — 2025-12-26
### Added
- Output controls: `--plain`, `--no-emoji`, `--no-color` (respects `NO_COLOR`).
- `help` command: `bird help <command>`.
- Runtime GraphQL query ID refresh: `bird query-ids --fresh` (cached on disk; auto-retry on 404; override cache via `BIRD_QUERY_IDS_CACHE`).
- GraphQL media uploads via `--media` (up to 4 images/GIFs, or 1 video).
### Fixed
- CLI `--version`: read version from `package.json`/`VERSION` (no hardcoded string) + append git sha when available.
### Changed
- `mentions`: no hardcoded user; defaults to authenticated user or accepts `--user @handle`.
- GraphQL query ID updater: correctly pairs `operationName` ↔ `queryId` (CreateTweet/CreateRetweet/etc).
- `build:dist`: copies `src/lib/query-ids.json` into `dist/lib/query-ids.json` (keeps `dist/` in sync).
- `--engine graphql`: strict GraphQL-only (disables Sweetistics fallback).
## 0.1.1 — 2025-12-26 ## 0.1.1 — 2025-12-26
### Changed ### Changed
+91 -21
View File
@@ -1,6 +1,11 @@
# bird 🐦 — fast X CLI for tweeting, replying, and reading # bird 🐦 — fast X CLI for tweeting, replying, and reading
`bird` is a fast X CLI for tweeting, replying, and reading. It uses either GraphQL cookies or the Sweetistics API. `bird` is a fast X CLI for tweeting, replying, and reading via X/Twitter GraphQL (cookie auth).
## Disclaimer
This project uses X/Twitter’s **undocumented** web GraphQL API (and cookie auth). X can change endpoints, query IDs,
and anti-bot behavior at any time — **expect this to break without notice**.
## Install ## Install
@@ -8,6 +13,17 @@
npm install -g @steipete/bird npm install -g @steipete/bird
# or # or
pnpm add -g @steipete/bird pnpm add -g @steipete/bird
# or
bun add -g @steipete/bird
# one-shot (no install)
bunx @steipete/bird whoami
```
Homebrew (macOS, prebuilt Bun binary):
```bash
brew install steipete/tap/bird
``` ```
## Quickstart ## Quickstart
@@ -16,6 +32,9 @@ pnpm add -g @steipete/bird
# Show the logged-in account # Show the logged-in account
bird whoami bird whoami
# Discover command help
bird help whoami
# Read a tweet (URL or ID) # Read a tweet (URL or ID)
bird read https://x.com/user/status/1234567890123456789 bird read https://x.com/user/status/1234567890123456789
bird 1234567890123456789 --json bird 1234567890123456789 --json
@@ -27,41 +46,57 @@ bird replies 1234567890123456789
# Search + mentions # Search + mentions
bird search "from:steipete" -n 5 bird search "from:steipete" -n 5
bird mentions -n 5 bird mentions -n 5
bird mentions --user @steipete -n 5
# Bookmarks
bird bookmarks -n 5
# Refresh GraphQL query IDs cache (no rebuild)
bird query-ids --fresh
``` ```
## Commands ## Commands
- `bird tweet "<text>"` — post a new tweet. - `bird tweet "<text>"` — post a new tweet.
- `bird reply <tweet-id-or-url> "<text>"` — reply to a tweet using its ID or URL. - `bird reply <tweet-id-or-url> "<text>"` — reply to a tweet using its ID or URL.
- `bird help [command]` — show help (or help for a subcommand).
- `bird query-ids [--fresh] [--json]` — inspect or refresh cached GraphQL query IDs.
- `bird read <tweet-id-or-url> [--json]` — fetch tweet content as text or JSON. - `bird read <tweet-id-or-url> [--json]` — fetch tweet content as text or JSON.
- `bird <tweet-id-or-url> [--json]` — shorthand for `read` when only a URL or ID is provided. - `bird <tweet-id-or-url> [--json]` — shorthand for `read` when only a URL or ID is provided.
- `bird replies <tweet-id-or-url> [--json]` — list replies to a tweet. - `bird replies <tweet-id-or-url> [--json]` — list replies to a tweet.
- `bird thread <tweet-id-or-url> [--json]` — show the full conversation thread. - `bird thread <tweet-id-or-url> [--json]` — show the full conversation thread.
- `bird search "<query>" [-n count] [--json]` — search for tweets matching a query. - `bird search "<query>" [-n count] [--json]` — search for tweets matching a query.
- `bird mentions [-n count] [--json]` — find tweets mentioning @clawdbot. - `bird mentions [-n count] [--user @handle] [--json]` — find tweets mentioning a user (defaults to the authenticated user).
- `bird bookmarks [-n count] [--json]` — list your bookmarked tweets.
- `bird whoami` — print which Twitter account your cookies belong to. - `bird whoami` — print which Twitter account your cookies belong to.
- `bird check` — show which credentials are available and where they were sourced from. - `bird check` — show which credentials are available and where they were sourced from.
## Engines
- `--engine auto` (default) — use GraphQL first; if a Sweetistics API key is available, fall back on errors.
- `--engine graphql` — use Twitter/X GraphQL with cookies (Chrome/Firefox/env/flags).
- `--engine sweetistics` — use Sweetistics API key (no browser cookies needed).
Global options: Global options:
- `--timeout <ms>`: abort requests after the given timeout (milliseconds). - `--timeout <ms>`: abort requests after the given timeout (milliseconds).
- `--plain`: stable output (no emoji, no color).
- `--no-emoji`: disable emoji output.
- `--no-color`: disable ANSI colors (or set `NO_COLOR=1`).
- `--cookie-source <safari|chrome|firefox>`: choose browser cookie source (repeatable; order matters).
## Authentication (GraphQL) ## Authentication (GraphQL)
GraphQL mode uses your existing X/Twitter web session (no password prompt). It sends requests to internal
X endpoints and authenticates via cookies (`auth_token`, `ct0`).
Write operations:
- `tweet`/`reply` primarily use GraphQL (`CreateTweet`).
- If GraphQL returns error `226` (“automated request”), `bird` falls back to the legacy `statuses/update.json` endpoint.
`bird` resolves credentials in this order: `bird` resolves credentials in this order:
1. CLI flags: `--auth-token`, `--ct0` 1. CLI flags: `--auth-token`, `--ct0`
2. Environment variables: `AUTH_TOKEN`, `CT0` (fallback: `TWITTER_AUTH_TOKEN`, `TWITTER_CT0`) 2. Environment variables: `AUTH_TOKEN`, `CT0` (fallback: `TWITTER_AUTH_TOKEN`, `TWITTER_CT0`)
3. Browser cookies (macOS): Firefox or Chrome profiles 3. Browser cookies via `@steipete/sweet-cookie` (override via `--cookie-source` order)
Browser cookie sources: Browser cookie sources:
- Firefox (default): `~/Library/Application Support/Firefox/Profiles/<profile>/cookies.sqlite` - Safari: `~/Library/Cookies/Cookies.binarycookies` (fallback: `~/Library/Containers/com.apple.Safari/Data/Library/Cookies/Cookies.binarycookies`)
- Chrome: `~/Library/Application Support/Google/Chrome/<Profile>/Cookies` - Chrome: `~/Library/Application Support/Google/Chrome/<Profile>/Cookies`
- Firefox: `~/Library/Application Support/Firefox/Profiles/<profile>/cookies.sqlite`
## Config (JSON5) ## Config (JSON5)
@@ -74,33 +109,67 @@ Example `~/.config/bird/config.json5`:
```json5 ```json5
{ {
engine: "auto", // Cookie source order for browser extraction (string or array)
cookieSource: ["firefox", "safari"],
firefoxProfile: "default-release", firefoxProfile: "default-release",
sweetisticsApiKey: "sweet-...",
allowFirefox: true,
allowChrome: false,
timeoutMs: 20000 timeoutMs: 20000
} }
``` ```
Environment shortcuts: Environment shortcuts:
- `SWEETISTICS_API_KEY`, `SWEETISTICS_BASE_URL` - `BIRD_TIMEOUT_MS`
- `BIRD_ENGINE`, `BIRD_TIMEOUT_MS`
## Output ## Output
- `--json` prints raw tweet objects for read/replies/thread/search/mentions. - `--json` prints raw tweet objects for read/replies/thread/search/mentions/bookmarks.
- `read` returns full text for Notes and Articles when present. - `read` returns full text for Notes and Articles when present.
- Use `--plain` for stable, script-friendly output (no emoji, no color).
## Media uploads (Sweetistics only) ## Query IDs (GraphQL)
X rotates GraphQL “query IDs” frequently. Each GraphQL operation is addressed as:
- `operationName` (e.g. `TweetDetail`, `CreateTweet`)
- `queryId` (rotating ID baked into X’s web client bundles)
`bird` ships with a baseline mapping in `src/lib/query-ids.json` (copied into `dist/` on build). At runtime,
it can refresh that mapping by scraping X’s public web client bundles and caching the result on disk.
Runtime cache:
- Default path: `~/.config/bird/query-ids-cache.json`
- Override path: `BIRD_QUERY_IDS_CACHE=/path/to/file.json`
- TTL: 24h (stale cache is still used, but marked “not fresh”)
Auto-recovery:
- On GraphQL `404` (query ID invalid), `bird` forces a refresh once and retries.
- For `TweetDetail`/`SearchTimeline`, `bird` also rotates through a small set of known fallback IDs to reduce
breakage while refreshing.
Refresh on demand:
```bash
bird query-ids --fresh
```
Exit codes:
- `0`: success
- `1`: runtime error (network/auth/etc)
- `2`: invalid usage/validation (e.g. bad `--user` handle)
## Version
`bird --version` prints `package.json` version plus current git sha when available, e.g. `0.3.0 (3df7969b)`.
## Media uploads
- Attach media with `--media` (repeatable) and optional `--alt` per item. - Attach media with `--media` (repeatable) and optional `--alt` per item.
- Up to 4 images, or 1 video (no mixing). Supported: jpg, jpeg, png, webp, gif, mp4, mov. - Up to 4 images/GIFs, or 1 video (no mixing). Supported: jpg, jpeg, png, webp, gif, mp4, mov.
- Images/GIFs + 1 video supported (uploads via Twitter legacy upload endpoint + cookies; video may take longer to process).
Example: Example:
```bash ```bash
bird --engine sweetistics tweet "hi" --media img.png --alt "desc" bird tweet "hi" --media img.png --alt "desc"
``` ```
## Development ## Development
@@ -113,6 +182,7 @@ pnpm run build:dist # dist/ only
pnpm run build:binary pnpm run build:binary
pnpm run dev tweet "Test" pnpm run dev tweet "Test"
pnpm run dev -- --plain check
pnpm test pnpm test
pnpm run lint pnpm run lint
``` ```
@@ -120,4 +190,4 @@ pnpm run lint
## Notes ## Notes
- GraphQL uses internal X endpoints and can be rate limited (429). - GraphQL uses internal X endpoints and can be rate limited (429).
- Query IDs rotate; refresh them with `pnpm run graphql:update`. - Query IDs rotate; refresh at runtime with `bird query-ids --fresh` (or update the baked baseline via `pnpm run graphql:update`).
+23 -2
View File
@@ -11,10 +11,31 @@
"rules": { "rules": {
"recommended": true, "recommended": true,
"complexity": { "complexity": {
"noForEach": "off" "noForEach": "error"
},
"correctness": {
"useImportExtensions": {
"level": "error",
"options": {
"forceJsExtensions": true
}
}
},
"performance": {
"useTopLevelRegex": "error"
}, },
"style": { "style": {
"noNonNullAssertion": "off" "noNegationElse": "error",
"noNonNullAssertion": "error",
"useBlockStatements": "error",
"useConst": "error",
"useTemplate": "error"
},
"suspicious": {
"noExplicitAny": "error"
},
"nursery": {
"useRegexpExec": "error"
} }
} }
}, },
+1
View File
@@ -0,0 +1 @@
+43
View File
@@ -0,0 +1,43 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#0b0d10" />
<title>404 — bird.fast</title>
<link rel="icon" href="./favicon.svg" type="image/svg+xml" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,[email protected],500..900&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap"
/>
<link rel="stylesheet" href="./assets/site.css" />
</head>
<body class="is-404">
<div class="bg" aria-hidden="true">
<div class="bg__orb bg__orb--a"></div>
<div class="bg__orb bg__orb--b"></div>
<div class="bg__grid"></div>
<div class="bg__noise"></div>
</div>
<main class="notfound">
<a class="brand brand--solo" href="./" aria-label="bird.fast home">
<span class="brand__mark" aria-hidden="true">
<img src="./assets/mark.svg" width="26" height="26" alt="" />
</span>
<span class="brand__word">bird</span>
<span class="brand__dot">.fast</span>
</a>
<h1 class="title title--small">404</h1>
<p class="lede lede--small">That path doesn’t exist. The CLI does.</p>
<div class="cta">
<a class="btn btn--hot" href="./">Go home</a>
<a class="btn btn--ghost" href="https://github.com/steipete/bird">Open GitHub</a>
</div>
</main>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
bird.fast
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="none">
<path
d="M19.7 10.3c-.9-2.8-3.5-4.8-6.5-4.8-3.8 0-6.9 3.1-6.9 6.9 0 3.9 3.1 7 6.9 7 .9 0 1.8-.2 2.6-.5 1.8 1 3.8 1.2 5.8.6-2.3-.4-3.6-1.4-4.2-2.9 1.6-1.2 2.6-3.1 2.6-5.2 0-.3 0-.6-.1-.9h.9l2.5-2.1-3.6.1z"
fill="rgba(244,241,233,0.92)"
/>
<path
d="M22.9 9.6l-1.7 1.4h-1.1c-.3-1-.9-1.9-1.6-2.7l4.4-.2z"
fill="rgba(183,255,106,0.88)"
/>
</svg>

After

Width:  |  Height:  |  Size: 439 B

+479
View File
@@ -0,0 +1,479 @@
:root {
--bg: #0b0d10;
--fg: #f4f1e9;
--muted: rgba(244, 241, 233, 0.66);
--dim: rgba(244, 241, 233, 0.12);
--edge: rgba(244, 241, 233, 0.16);
--hot: #b7ff6a;
--hot2: #6afcff;
--ink: rgba(8, 9, 12, 0.72);
--serif: "Fraunces", ui-serif, "Iowan Old Style", "Apple Garamond", serif;
--sans: "IBM Plex Sans", ui-sans-serif, system-ui, -apple-system, sans-serif;
--mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
--radius: 18px;
--shadow: 0 24px 80px rgba(0, 0, 0, 0.55);
--max: 1080px;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: var(--sans);
color: var(--fg);
background: var(--bg);
overflow-x: hidden;
}
a { color: inherit; text-underline-offset: 4px; }
a:hover { text-decoration-thickness: 2px; }
.skip {
position: absolute;
left: 12px;
top: 12px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--edge);
background: rgba(0, 0, 0, 0.7);
transform: translateY(-140%);
transition: transform 180ms ease;
z-index: 999;
}
.skip:focus { transform: translateY(0); outline: none; }
.bg { position: fixed; inset: 0; pointer-events: none; z-index: 0; }
.bg__orb {
position: absolute;
width: 900px;
height: 900px;
border-radius: 999px;
filter: blur(48px);
opacity: 0.55;
transform: translate3d(0, 0, 0);
}
.bg__orb--a {
left: -320px;
top: -280px;
background: radial-gradient(circle at 30% 30%, rgba(183, 255, 106, 0.95), rgba(183, 255, 106, 0.0) 62%);
}
.bg__orb--b {
right: -360px;
bottom: -320px;
background: radial-gradient(circle at 30% 30%, rgba(106, 252, 255, 0.95), rgba(106, 252, 255, 0.0) 62%);
}
.bg__grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(to right, rgba(244, 241, 233, 0.06) 1px, transparent 1px),
linear-gradient(to bottom, rgba(244, 241, 233, 0.06) 1px, transparent 1px);
background-size: 64px 64px;
opacity: 0.22;
mask-image: radial-gradient(circle at 48% 25%, black 0%, black 45%, transparent 72%);
}
.bg__noise {
position: absolute;
inset: 0;
opacity: 0.09;
mix-blend-mode: overlay;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)' opacity='.48'/%3E%3C/svg%3E");
}
.top { position: sticky; top: 0; z-index: 10; backdrop-filter: blur(14px); }
.top__inner {
max-width: var(--max);
margin: 0 auto;
padding: 18px 18px;
display: flex;
align-items: center;
justify-content: space-between;
}
.top::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(244, 241, 233, 0.16), transparent);
}
.brand {
display: inline-flex;
align-items: center;
gap: 10px;
text-decoration: none;
}
.brand__mark {
width: 28px;
height: 28px;
display: grid;
place-items: center;
border-radius: 10px;
border: 1px solid rgba(244, 241, 233, 0.18);
background: rgba(0, 0, 0, 0.18);
box-shadow: 0 10px 38px rgba(0, 0, 0, 0.38);
}
.brand__word {
font-family: var(--serif);
letter-spacing: -0.02em;
font-variation-settings: "opsz" 144;
font-weight: 780;
font-size: 18px;
}
.brand__dot {
font-family: var(--mono);
font-weight: 500;
font-size: 13px;
color: var(--muted);
transform: translateY(1px);
}
.nav { display: flex; gap: 14px; }
.nav__link {
font-size: 13px;
color: var(--muted);
text-decoration: none;
padding: 10px 10px;
border-radius: 12px;
border: 1px solid transparent;
}
.nav__link:hover {
color: var(--fg);
border-color: rgba(244, 241, 233, 0.14);
background: rgba(0, 0, 0, 0.18);
}
.nav__link:focus-visible { outline: 2px solid rgba(183, 255, 106, 0.55); outline-offset: 2px; }
main { position: relative; z-index: 1; }
.hero { padding: 64px 18px 26px; }
.hero__inner { max-width: var(--max); margin: 0 auto; }
.kicker {
margin: 0 0 10px;
font-family: var(--mono);
font-size: 12px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(244, 241, 233, 0.62);
}
.title {
margin: 0 0 14px;
font-family: var(--serif);
font-weight: 820;
letter-spacing: -0.05em;
line-height: 0.95;
font-size: clamp(54px, 8vw, 118px);
font-variation-settings: "opsz" 144;
text-shadow: 0 22px 70px rgba(0, 0, 0, 0.55);
}
.title__dot {
font-family: var(--mono);
font-weight: 500;
letter-spacing: -0.02em;
font-size: 0.7em;
margin-left: 8px;
color: rgba(183, 255, 106, 0.92);
text-shadow: 0 0 0 transparent;
}
.lede {
margin: 0 0 24px;
max-width: 70ch;
font-size: 16px;
line-height: 1.6;
color: var(--muted);
}
.cta { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 12px 14px;
border-radius: 14px;
text-decoration: none;
border: 1px solid rgba(244, 241, 233, 0.14);
background: rgba(0, 0, 0, 0.14);
color: var(--fg);
font-size: 13px;
box-shadow: 0 16px 60px rgba(0, 0, 0, 0.32);
}
.btn:hover { border-color: rgba(244, 241, 233, 0.22); }
.btn:focus-visible { outline: 2px solid rgba(106, 252, 255, 0.55); outline-offset: 2px; }
.btn--hot {
border-color: rgba(183, 255, 106, 0.35);
background: linear-gradient(180deg, rgba(183, 255, 106, 0.22), rgba(183, 255, 106, 0.08));
}
.btn--hot:hover { border-color: rgba(183, 255, 106, 0.55); }
.btn--ghost { color: rgba(244, 241, 233, 0.78); }
.terminal {
margin-top: 24px;
border-radius: var(--radius);
border: 1px solid rgba(244, 241, 233, 0.14);
background: rgba(0, 0, 0, 0.30);
box-shadow: var(--shadow);
overflow: hidden;
}
.terminal__bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: rgba(0, 0, 0, 0.25);
border-bottom: 1px solid rgba(244, 241, 233, 0.10);
}
.terminal__dots { display: inline-flex; gap: 6px; }
.terminal__dots i {
width: 9px;
height: 9px;
border-radius: 999px;
background: rgba(244, 241, 233, 0.16);
}
.terminal__label {
font-family: var(--mono);
font-size: 11px;
color: rgba(244, 241, 233, 0.55);
}
.terminal__body { position: relative; padding: 14px 14px 16px; }
.terminal pre { margin: 0; overflow-x: auto; }
.terminal code {
font-family: var(--mono);
font-size: 13px;
line-height: 1.55;
color: rgba(244, 241, 233, 0.86);
}
.copy {
position: absolute;
right: 12px;
top: 12px;
border: 1px solid rgba(244, 241, 233, 0.14);
border-radius: 12px;
padding: 10px 12px;
background: rgba(0, 0, 0, 0.22);
color: rgba(244, 241, 233, 0.86);
font-family: var(--mono);
font-size: 12px;
cursor: pointer;
transition: transform 120ms ease, border-color 120ms ease;
}
.copy:hover { border-color: rgba(244, 241, 233, 0.25); transform: translateY(-1px); }
.copy:focus-visible { outline: 2px solid rgba(183, 255, 106, 0.55); outline-offset: 2px; }
.copy__done { display: none; }
.copy.is-done .copy__idle { display: none; }
.copy.is-done .copy__done { display: inline; color: rgba(183, 255, 106, 0.92); }
.badges {
margin-top: 16px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.badge {
text-decoration: none;
border-radius: 16px;
border: 1px solid rgba(244, 241, 233, 0.12);
background: rgba(0, 0, 0, 0.14);
padding: 12px 12px;
display: flex;
justify-content: space-between;
gap: 12px;
box-shadow: 0 12px 44px rgba(0, 0, 0, 0.28);
}
.badge:hover { border-color: rgba(244, 241, 233, 0.22); }
.badge__k { font-family: var(--mono); font-size: 12px; color: rgba(244, 241, 233, 0.62); }
.badge__v { font-family: var(--mono); font-size: 12px; color: rgba(244, 241, 233, 0.90); }
.stripe { padding: 44px 18px; }
.stripe__inner { max-width: var(--max); margin: 0 auto; }
.stripe--tight { padding-top: 28px; }
.stripe--end { padding-bottom: 64px; }
.h2 {
margin: 0 0 10px;
font-family: var(--serif);
font-weight: 760;
letter-spacing: -0.03em;
font-size: 28px;
font-variation-settings: "opsz" 144;
}
.h3 { margin: 0 0 8px; font-weight: 650; font-size: 16px; }
.p { margin: 0 0 14px; line-height: 1.6; color: rgba(244, 241, 233, 0.80); }
.p--muted { color: var(--muted); }
.cards { display: grid; gap: 12px; grid-template-columns: repeat(3, minmax(0, 1fr)); }
.card {
border-radius: var(--radius);
border: 1px solid rgba(244, 241, 233, 0.12);
background: rgba(0, 0, 0, 0.16);
padding: 16px 16px 14px;
box-shadow: 0 18px 70px rgba(0, 0, 0, 0.28);
}
.code {
margin: 12px 0 0;
border-radius: 14px;
padding: 12px 12px;
background: rgba(0, 0, 0, 0.30);
border: 1px solid rgba(244, 241, 233, 0.10);
overflow-x: auto;
}
.code code { font-family: var(--mono); font-size: 12px; line-height: 1.55; color: rgba(244, 241, 233, 0.86); }
.cmds { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; }
.cmd {
border-radius: 999px;
padding: 10px 12px;
border: 1px solid rgba(244, 241, 233, 0.14);
background: rgba(0, 0, 0, 0.14);
color: rgba(244, 241, 233, 0.78);
font-family: var(--mono);
font-size: 12px;
cursor: pointer;
}
.cmd:hover { border-color: rgba(244, 241, 233, 0.24); color: rgba(244, 241, 233, 0.92); }
.cmd.is-on {
border-color: rgba(183, 255, 106, 0.42);
background: linear-gradient(180deg, rgba(183, 255, 106, 0.18), rgba(183, 255, 106, 0.05));
color: rgba(244, 241, 233, 0.96);
}
.cmd:focus-visible { outline: 2px solid rgba(183, 255, 106, 0.55); outline-offset: 2px; }
.demo {
margin-top: 14px;
position: relative;
border-radius: var(--radius);
border: 1px solid rgba(244, 241, 233, 0.12);
background: rgba(0, 0, 0, 0.20);
padding: 14px 14px 16px;
box-shadow: 0 18px 70px rgba(0, 0, 0, 0.28);
}
.demo__pre { margin: 0; overflow-x: auto; }
.demo__pre code { font-family: var(--mono); font-size: 13px; line-height: 1.55; color: rgba(244, 241, 233, 0.86); }
.copy--demo { top: 10px; right: 10px; }
.steps { display: grid; gap: 12px; grid-template-columns: repeat(3, minmax(0, 1fr)); margin-top: 14px; }
.step {
border-radius: var(--radius);
border: 1px solid rgba(244, 241, 233, 0.12);
background: rgba(0, 0, 0, 0.14);
padding: 16px 16px 14px;
box-shadow: 0 18px 70px rgba(0, 0, 0, 0.26);
}
.step__num {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.12em;
color: rgba(244, 241, 233, 0.56);
margin-bottom: 10px;
}
.pill {
display: inline-block;
padding: 1px 7px 2px;
border-radius: 999px;
border: 1px solid rgba(244, 241, 233, 0.14);
background: rgba(0, 0, 0, 0.20);
font-family: var(--mono);
font-size: 12px;
color: rgba(244, 241, 233, 0.86);
}
.footerline {
margin-top: 16px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
color: rgba(244, 241, 233, 0.60);
}
.tiny { font-size: 12px; color: rgba(244, 241, 233, 0.72); }
.tiny--muted { color: rgba(244, 241, 233, 0.52); }
.sep { opacity: 0.5; }
.notice {
margin-top: 18px;
border-radius: var(--radius);
border: 1px solid rgba(244, 241, 233, 0.12);
background: rgba(0, 0, 0, 0.22);
padding: 14px 14px;
display: grid;
grid-template-columns: 34px 1fr;
gap: 12px;
align-items: start;
box-shadow: 0 18px 70px rgba(0, 0, 0, 0.26);
}
.notice__icon {
width: 34px;
height: 34px;
border-radius: 12px;
display: grid;
place-items: center;
font-family: var(--mono);
font-weight: 600;
border: 1px solid rgba(244, 241, 233, 0.14);
background: rgba(0, 0, 0, 0.18);
color: rgba(244, 241, 233, 0.86);
}
.notice__title {
margin: 0 0 4px;
font-weight: 700;
letter-spacing: -0.01em;
}
.notice__text { margin: 0; color: var(--muted); line-height: 1.6; }
.notice--warn {
border-color: rgba(183, 255, 106, 0.18);
background: linear-gradient(180deg, rgba(183, 255, 106, 0.10), rgba(0, 0, 0, 0.22));
}
.notice--warn .notice__icon {
border-color: rgba(183, 255, 106, 0.28);
background: rgba(183, 255, 106, 0.08);
color: rgba(183, 255, 106, 0.92);
}
.bottom {
position: relative;
z-index: 1;
padding: 28px 18px 40px;
}
.bottom__inner { max-width: var(--max); margin: 0 auto; }
/* 404 */
body.is-404 { display: grid; place-items: center; }
.notfound { max-width: 640px; padding: 18px; text-align: left; }
.brand--solo { margin-bottom: 26px; }
.title--small { font-size: clamp(56px, 10vw, 96px); margin-bottom: 6px; }
.lede--small { max-width: 54ch; }
/* reveal */
[data-reveal] { opacity: 0; transform: translateY(14px); filter: blur(6px); }
body.is-ready [data-reveal] {
opacity: 1;
transform: translateY(0);
filter: blur(0);
transition: opacity 620ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 620ms cubic-bezier(0.2, 0.8, 0.2, 1),
filter 620ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
body.is-ready [data-reveal]:nth-child(1) { transition-delay: 70ms; }
body.is-ready [data-reveal]:nth-child(2) { transition-delay: 140ms; }
body.is-ready [data-reveal]:nth-child(3) { transition-delay: 210ms; }
body.is-ready [data-reveal]:nth-child(4) { transition-delay: 280ms; }
body.is-ready [data-reveal]:nth-child(5) { transition-delay: 350ms; }
body.is-ready [data-reveal]:nth-child(6) { transition-delay: 420ms; }
@media (prefers-reduced-motion: reduce) {
[data-reveal] { opacity: 1; transform: none; filter: none; }
body.is-ready [data-reveal] { transition: none; }
.copy { transition: none; }
}
@media (max-width: 920px) {
.badges { grid-template-columns: 1fr; }
.cards { grid-template-columns: 1fr; }
.steps { grid-template-columns: 1fr; }
.nav { display: none; }
}
+80
View File
@@ -0,0 +1,80 @@
const by = (sel, root = document) => root.querySelector(sel);
const all = (sel, root = document) => Array.from(root.querySelectorAll(sel));
const demoSnippets = {
tweet: `bird tweet "ship it"`,
reply: `bird reply https://x.com/user/status/1234567890123456789 "same"`,
read: `bird read https://x.com/user/status/1234567890123456789`,
thread: `bird thread 1234567890123456789`,
search: `bird search "from:steipete" -n 5`,
mentions: `bird mentions -n 5`,
};
const setDemo = (key) => {
const code = by("#demoCode");
if (!code) return;
const value = demoSnippets[key] ?? demoSnippets.tweet;
code.textContent = value;
};
const wireTabs = () => {
const buttons = all("[data-cmd]");
if (buttons.length === 0) return;
buttons.forEach((b) => {
b.addEventListener("click", () => {
const key = b.getAttribute("data-cmd") ?? "tweet";
buttons.forEach((x) => {
const on = x === b;
x.classList.toggle("is-on", on);
x.setAttribute("aria-selected", on ? "true" : "false");
});
setDemo(key);
});
});
};
const wireCopy = () => {
all("[data-copy]").forEach((btn) => {
btn.addEventListener("click", async () => {
const sel = btn.getAttribute("data-copy");
if (!sel) return;
const node = by(sel);
if (!node) return;
const text = node.textContent ?? "";
try {
await navigator.clipboard.writeText(text);
btn.classList.add("is-done");
window.setTimeout(() => btn.classList.remove("is-done"), 900);
} catch {
// noop
}
});
});
};
const wireOrbs = () => {
const a = by(".bg__orb--a");
const b = by(".bg__orb--b");
if (!a || !b) return;
let raf = 0;
const onMove = (e) => {
if (raf) cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
const x = e.clientX / window.innerWidth;
const y = e.clientY / window.innerHeight;
a.style.transform = `translate3d(${(x - 0.4) * 80}px, ${(y - 0.4) * 70}px, 0)`;
b.style.transform = `translate3d(${(x - 0.6) * 90}px, ${(y - 0.6) * 80}px, 0)`;
});
};
window.addEventListener("mousemove", onMove, { passive: true });
};
document.documentElement.classList.add("js");
window.setTimeout(() => document.body.classList.add("is-ready"), 20);
wireTabs();
wireCopy();
wireOrbs();
+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="g" x1="0" x2="1" y1="0" y2="1">
<stop offset="0" stop-color="#b7ff6a" stop-opacity="0.95" />
<stop offset="1" stop-color="#6afcff" stop-opacity="0.85" />
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill="#0b0d10" />
<rect x="5" y="5" width="54" height="54" rx="12" fill="rgba(255,255,255,0.06)" stroke="rgba(255,255,255,0.10)" />
<path
d="M46 24c-2.1-6.5-8.1-11-15.2-11C21.9 13 15 19.9 15 28.8 15 37.8 21.9 45 30.8 45c2.1 0 4.2-.4 6.1-1.2 4.1 2.2 8.8 2.7 13.4 1.3-5.4-.9-8.4-3.3-9.7-6.7 3.7-2.9 6-7.4 6-12.1 0-.7-.1-1.4-.2-2.1H48l6-5-8.4.1z"
fill="url(#g)"
/>
</svg>

After

Width:  |  Height:  |  Size: 715 B

+232
View File
@@ -0,0 +1,232 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#0b0d10" />
<title>bird.fast — fast X CLI</title>
<meta
name="description"
content="bird is a fast X CLI for tweeting, replying, and reading via the X/Twitter GraphQL API (cookie auth)."
/>
<link rel="canonical" href="https://bird.fast/" />
<link rel="icon" href="./favicon.svg" type="image/svg+xml" />
<meta property="og:title" content="bird.fast" />
<meta property="og:description" content="Fast X CLI for tweeting, replying, and reading." />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://bird.fast/" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,[email protected],500..900&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap"
/>
<link rel="stylesheet" href="./assets/site.css" />
</head>
<body>
<a class="skip" href="#content">Skip to content</a>
<div class="bg" aria-hidden="true">
<div class="bg__orb bg__orb--a"></div>
<div class="bg__orb bg__orb--b"></div>
<div class="bg__grid"></div>
<div class="bg__noise"></div>
</div>
<header class="top">
<div class="top__inner">
<a class="brand" href="./" aria-label="bird.fast home">
<span class="brand__mark" aria-hidden="true">
<img src="./assets/mark.svg" width="26" height="26" alt="" />
</span>
<span class="brand__word">bird</span>
<span class="brand__dot">.fast</span>
</a>
<nav class="nav">
<a class="nav__link" href="#install">Install</a>
<a class="nav__link" href="#commands">Commands</a>
<a class="nav__link" href="https://github.com/steipete/bird">GitHub</a>
</nav>
</div>
</header>
<main id="content">
<section class="hero">
<div class="hero__inner">
<p class="kicker" data-reveal>Fast X CLI. Cookie auth. Zero fuss.</p>
<h1 class="title" data-reveal>
<span class="title__big">bird</span><span class="title__dot">.fast</span>
</h1>
<p class="lede" data-reveal>
Tweet, reply, read threads, search, mentions — powered by X/Twitter GraphQL and your existing
browser session.
</p>
<div class="cta" data-reveal>
<a class="btn btn--hot" href="#install">Install</a>
<a class="btn btn--ghost" href="https://github.com/steipete/bird#quickstart">Quickstart</a>
</div>
<div class="terminal" data-reveal>
<div class="terminal__bar">
<span class="terminal__dots" aria-hidden="true">
<i></i><i></i><i></i>
</span>
<span class="terminal__label">copy &amp; run</span>
</div>
<div class="terminal__body">
<pre><code id="installCmd">npm install -g @steipete/bird
bird whoami
bird tweet "hi from bird.fast"</code></pre>
<button class="copy" type="button" data-copy="#installCmd">
<span class="copy__idle">Copy</span>
<span class="copy__done" aria-hidden="true">Copied</span>
</button>
</div>
</div>
<div class="badges" data-reveal>
<a class="badge" href="https://www.npmjs.com/package/@steipete/bird">
<span class="badge__k">npm</span>
<span class="badge__v">@steipete/bird</span>
</a>
<a class="badge" href="https://github.com/steipete/bird">
<span class="badge__k">repo</span>
<span class="badge__v">steipete/bird</span>
</a>
<a class="badge" href="https://github.com/steipete/bird#authentication-graphql">
<span class="badge__k">auth</span>
<span class="badge__v">cookies</span>
</a>
</div>
</div>
</section>
<section class="stripe" id="install">
<div class="stripe__inner">
<h2 class="h2">Install</h2>
<div class="cards">
<article class="card">
<h3 class="h3">npm / pnpm / bun</h3>
<p class="p">Pick your poison. Node ≥ 20.</p>
<pre class="code"><code>npm install -g @steipete/bird
pnpm add -g @steipete/bird
bun add -g @steipete/bird</code></pre>
</article>
<article class="card">
<h3 class="h3">One-shot</h3>
<p class="p">No install. Just prove it works.</p>
<pre class="code"><code>bunx @steipete/bird whoami</code></pre>
</article>
<article class="card">
<h3 class="h3">Homebrew (macOS)</h3>
<p class="p">Prebuilt Bun binary.</p>
<pre class="code"><code>brew install steipete/tap/bird</code></pre>
</article>
</div>
</div>
</section>
<section class="stripe stripe--tight" id="commands">
<div class="stripe__inner">
<h2 class="h2">Commands</h2>
<p class="p p--muted">Everything you need for a fast “read → reply → move on” loop.</p>
<div class="cmds" role="tablist" aria-label="bird commands">
<button class="cmd is-on" type="button" data-cmd="tweet" role="tab" aria-selected="true">
tweet
</button>
<button class="cmd" type="button" data-cmd="reply" role="tab" aria-selected="false">reply</button>
<button class="cmd" type="button" data-cmd="read" role="tab" aria-selected="false">read</button>
<button class="cmd" type="button" data-cmd="thread" role="tab" aria-selected="false">thread</button>
<button class="cmd" type="button" data-cmd="search" role="tab" aria-selected="false">search</button>
<button class="cmd" type="button" data-cmd="mentions" role="tab" aria-selected="false">
mentions
</button>
</div>
<div class="demo" aria-live="polite">
<pre class="demo__pre"><code id="demoCode">bird tweet "ship it"</code></pre>
<button class="copy copy--demo" type="button" data-copy="#demoCode">
<span class="copy__idle">Copy</span>
<span class="copy__done" aria-hidden="true">Copied</span>
</button>
</div>
</div>
</section>
<section class="stripe stripe--end">
<div class="stripe__inner">
<h2 class="h2">How it works</h2>
<div class="steps">
<article class="step">
<div class="step__num">01</div>
<h3 class="h3">Use your existing session</h3>
<p class="p p--muted">
Cookie auth. No password prompt. Reads from Safari/Chrome/Firefox (configurable).
</p>
</article>
<article class="step">
<div class="step__num">02</div>
<h3 class="h3">GraphQL first</h3>
<p class="p p--muted">
Talks to X’s internal endpoints. Query IDs rotate — bird auto-refreshes and caches them.
</p>
</article>
<article class="step">
<div class="step__num">03</div>
<h3 class="h3">Fast, scriptable output</h3>
<p class="p p--muted">
Human output by default. Flip to <span class="pill">--json</span> or
<span class="pill">--plain</span> when you need stability.
</p>
</article>
</div>
<aside class="notice notice--warn" role="note" aria-label="Disclaimer">
<div class="notice__icon" aria-hidden="true">!</div>
<div class="notice__body">
<p class="notice__title">Undocumented API.</p>
<p class="notice__text">
bird uses X/Twitter’s private web GraphQL endpoints and rotating query IDs. X can change things anytime —
expect breakage without notice.
</p>
</div>
</aside>
<div class="footerline">
<a class="tiny" href="https://github.com/steipete/bird#authentication-graphql">
Authentication details
</a>
<span class="sep" aria-hidden="true">•</span>
<a class="tiny" href="https://github.com/steipete/bird#config-json5">Config (JSON5)</a>
<span class="sep" aria-hidden="true">•</span>
<a class="tiny" href="https://github.com/steipete/bird/issues">Issues</a>
</div>
</div>
</section>
</main>
<footer class="bottom">
<div class="bottom__inner">
<p class="tiny">
Made by <a href="https://steipete.me">Peter</a>. MIT licensed. Built for GitHub Pages.
</p>
<p class="tiny tiny--muted">bird.fast is the landing page; the project lives on GitHub.</p>
</div>
</footer>
<script src="./assets/site.js" type="module"></script>
</body>
</html>
+1 -1
View File
@@ -53,4 +53,4 @@ If you want a single-file binary for Homebrew/GitHub assets:
## Notes ## Notes
- Scoped npm name (`@steipete/bird`) requires `--access public` on first publish. - Scoped npm name (`@steipete/bird`) requires `--access public` on first publish.
- Homebrew formula assumes macOS universal binary; adjust URL/name if you ship per-arch. - Homebrew formula assumes macOS universal binary; adjust URL/name if you ship per-arch.
- Config defaults (JSON5) and Firefox/Chrome cookie selection are documented in `README.md` — keep that in sync for each release. - Config defaults (JSON5) and Safari/Chrome/Firefox cookie selection are documented in `README.md` — keep that in sync for each release.
+13 -7
View File
@@ -1,6 +1,6 @@
{ {
"name": "@steipete/bird", "name": "@steipete/bird",
"version": "0.1.1", "version": "0.4.1",
"description": "CLI tool for tweeting and replying via Twitter/X GraphQL API", "description": "CLI tool for tweeting and replying via Twitter/X GraphQL API",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
@@ -15,21 +15,25 @@
], ],
"scripts": { "scripts": {
"build": "pnpm run build:dist && pnpm run build:binary", "build": "pnpm run build:dist && pnpm run build:binary",
"build:dist": "tsc", "build:dist": "tsc && node scripts/copy-dist-assets.js",
"build:binary": "bun build --compile --minify src/index.ts --outfile bird", "build:binary": "BIRD_VERSION=$(node -p \"require('./package.json').version\") BIRD_GIT_SHA=$(git rev-parse --short=8 HEAD 2>/dev/null || true) bun build --compile --minify --env=BIRD_* src/index.ts --outfile bird",
"dev": "tsx src/index.ts", "dev": "tsx src/index.ts",
"bird": "pnpm run build:dist && node dist/index.js", "bird": "pnpm run build:dist && node dist/index.js",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"lint": "biome check .", "lint": "pnpm run lint:biome && pnpm run lint:oxlint",
"lint:fix": "biome check --write .", "lint:biome": "biome check .",
"lint:oxlint": "oxlint --type-aware --tsconfig tsconfig.oxlint.json --import-plugin --node-plugin --vitest-plugin --deny-warnings src tests scripts",
"lint:fix": "pnpm run lint:biome:fix && pnpm run lint:oxlint:fix",
"lint:biome:fix": "biome check --write .",
"lint:oxlint:fix": "oxlint --type-aware --tsconfig tsconfig.oxlint.json --import-plugin --node-plugin --vitest-plugin --deny-warnings --fix src tests scripts",
"format": "biome format --write .", "format": "biome format --write .",
"binary": "pnpm run build:binary", "binary": "pnpm run build:binary",
"graphql:update": "tsx scripts/update-query-ids.ts" "graphql:update": "tsx scripts/update-query-ids.ts"
}, },
"dependencies": { "dependencies": {
"@steipete/sweet-cookie": "0.1.0",
"commander": "^14.0.2", "commander": "^14.0.2",
"devalue": "^5.6.1",
"json5": "^2.2.3", "json5": "^2.2.3",
"kleur": "^4.1.5" "kleur": "^4.1.5"
}, },
@@ -37,11 +41,13 @@
"@biomejs/biome": "^2.3.10", "@biomejs/biome": "^2.3.10",
"@types/node": "^25.0.3", "@types/node": "^25.0.3",
"@vitest/coverage-v8": "4.0.16", "@vitest/coverage-v8": "4.0.16",
"oxlint": "^1.36.0",
"oxlint-tsgolint": "^0.10.0",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vitest": "^4.0.16" "vitest": "^4.0.16"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=22"
} }
} }
+163 -9
View File
@@ -1,19 +1,19 @@
lockfileVersion: '9.0' lockfileVersion: '9.0'
settings: settings:
autoInstallPeers: true autoInstallPeers: false
excludeLinksFromLockfile: false excludeLinksFromLockfile: false
importers: importers:
.: .:
dependencies: dependencies:
'@steipete/sweet-cookie':
specifier: 0.1.0
version: 0.1.0
commander: commander:
specifier: ^14.0.2 specifier: ^14.0.2
version: 14.0.2 version: 14.0.2
devalue:
specifier: ^5.6.1
version: 5.6.1
json5: json5:
specifier: ^2.2.3 specifier: ^2.2.3
version: 2.2.3 version: 2.2.3
@@ -30,6 +30,12 @@ importers:
'@vitest/coverage-v8': '@vitest/coverage-v8':
specifier: 4.0.16 specifier: 4.0.16
version: 4.0.16([email protected](@types/[email protected])([email protected])) version: 4.0.16([email protected](@types/[email protected])([email protected]))
oxlint:
specifier: ^1.36.0
version: 1.36.0([email protected])
oxlint-tsgolint:
specifier: ^0.10.0
version: 0.10.0
tsx: tsx:
specifier: ^4.21.0 specifier: ^4.21.0
version: 4.21.0 version: 4.21.0
@@ -282,6 +288,76 @@ packages:
'@jridgewell/[email protected]': '@jridgewell/[email protected]':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@oxlint-tsgolint/[email protected]':
resolution: {integrity: sha512-mhBF/pjey0UdLL1ocU46Fqta+uJuRfqrLfDpcViRg17BtDiUNd8JY9iN2FOoS2HGSCAgCUjZ0AZkwkHwFs/VTw==}
cpu: [arm64]
os: [darwin]
'@oxlint-tsgolint/[email protected]':
resolution: {integrity: sha512-roLi34mw/i1z+NS7luboix55SXyhVv38dNUTcRDkk+0lNPzI9ngrM+1y1N2oBSUmz5o9OZGnfJJ7BSGCw/fFEQ==}
cpu: [x64]
os: [darwin]
'@oxlint-tsgolint/[email protected]':
resolution: {integrity: sha512-HL9NThPH1V2F6l9XhwNmhQZUknN4m4yQYEvQFFGfZTYN6cvEEBIiqfF4KvBUg8c0xadMbQlW+Ug7/ybA9Nn+CA==}
cpu: [arm64]
os: [linux]
'@oxlint-tsgolint/[email protected]':
resolution: {integrity: sha512-Tw8QNq8ab+4+qE5krvJyMA66v6XE3GoiISRD5WmJ7YOxUnu//jSw/bBm7OYf/TNEZyeV0BTR7zXzhT5R+VFWlQ==}
cpu: [x64]
os: [linux]
'@oxlint-tsgolint/[email protected]':
resolution: {integrity: sha512-LTogmTRwpwQqVaH1Ama8Wd5/VVZWBSF8v5qTbeT628+1F5Kt1V5eHBvyFh4oN18UCZlgqrh7DqkDhsieXUaC8Q==}
cpu: [arm64]
os: [win32]
'@oxlint-tsgolint/[email protected]':
resolution: {integrity: sha512-ygqxx8EmNWy9/wCQS5uXq9k/o2EyYNwNxY1ZHNzlmZC/kV06Aemx5OBDafefawBNqH7xTZPfccUrjdiy+QlTrw==}
cpu: [x64]
os: [win32]
'@oxlint/[email protected]':
resolution: {integrity: sha512-MJkj82GH+nhvWKJhSIM6KlZ8tyGKdogSQXtNdpIyP02r/tTayFJQaAEWayG2Jhsn93kske+nimg5MYFhwO/rlg==}
cpu: [arm64]
os: [darwin]
'@oxlint/[email protected]':
resolution: {integrity: sha512-VvEhfkqj/99dCTqOcfkyFXOSbx4lIy5u2m2GHbK4WCMDySokOcMTNRHGw8fH/WgQ5cDrDMSTYIGQTmnBGi9tiQ==}
cpu: [x64]
os: [darwin]
'@oxlint/[email protected]':
resolution: {integrity: sha512-EMx92X5q+hHc3olTuj/kgkx9+yP0p/AVs4yvHbUfzZhBekXNpUWxWvg4hIKmQWn+Ee2j4o80/0ACGO0hDYJ9mg==}
cpu: [arm64]
os: [linux]
'@oxlint/[email protected]':
resolution: {integrity: sha512-7YCxtrPIctVYLqWrWkk8pahdCxch6PtsaucfMLC7TOlDt4nODhnQd4yzEscKqJ8Gjrw1bF4g+Ngob1gB+Qr9Fw==}
cpu: [arm64]
os: [linux]
'@oxlint/[email protected]':
resolution: {integrity: sha512-lnaJVlx5r3NWmoOMesfQXJSf78jHTn8Z+sdAf795Kgteo72+qGC1Uax2SToCJVN2J8PNG3oRV5bLriiCNR2i6Q==}
cpu: [x64]
os: [linux]
'@oxlint/[email protected]':
resolution: {integrity: sha512-AhuEU2Qdl66lSfTGu/Htirq8r/8q2YnZoG3yEXLMQWnPMn7efy8spD/N1NA7kH0Hll+cdfwgQkQqC2G4MS2lPQ==}
cpu: [x64]
os: [linux]
'@oxlint/[email protected]':
resolution: {integrity: sha512-GlWCBjUJY2QgvBFuNRkiRJu7K/djLmM0UQKfZV8IN+UXbP/JbjZHWKRdd4LXlQmzoz7M5Hd6p+ElCej8/90FCg==}
cpu: [arm64]
os: [win32]
'@oxlint/[email protected]':
resolution: {integrity: sha512-J+Vc00Utcf8p77lZPruQgb0QnQXuKnFogN88kCnOqs2a83I+vTBB8ILr0+L9sTwVRvIDMSC0pLdeQH4svWGFZg==}
cpu: [x64]
os: [win32]
'@rollup/[email protected]': '@rollup/[email protected]':
resolution: {integrity: sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==} resolution: {integrity: sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==}
cpu: [arm] cpu: [arm]
@@ -395,6 +471,10 @@ packages:
'@standard-schema/[email protected]': '@standard-schema/[email protected]':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@steipete/[email protected]':
resolution: {integrity: sha512-cF8hS6rTohAFI1NAm2/HM9omBlw6V1DpwF6q5e9FB+l274HjtyirCgCc6QK2Rtuh7uDoqWHshFQZPsceyJuVgw==}
engines: {node: '>=22'}
'@types/[email protected]': '@types/[email protected]':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@@ -469,9 +549,6 @@ packages:
supports-color: supports-color:
optional: true optional: true
[email protected]:
resolution: {integrity: sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A==}
[email protected]: [email protected]:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
@@ -560,6 +637,20 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
[email protected]:
resolution: {integrity: sha512-LDDSIu5J/4D4gFUuQQIEQpAC6maNEbMg4nC8JL/+Pe0cUDR86dtVZ09E2x5MwCh8f9yfktoaxt5x6UIVyzrajg==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-IicUdXfXgI8OKrDPnoSjvBfeEF8PkKtm+CoLlg4LYe4ypc8U+T4r7730XYshdBGZdelg+JRw8GtCb2w/KaaZvw==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
oxlint-tsgolint: '>=0.10.0'
peerDependenciesMeta:
oxlint-tsgolint:
optional: true
[email protected]: [email protected]:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@@ -850,6 +941,48 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@oxlint-tsgolint/[email protected]':
optional: true
'@oxlint-tsgolint/[email protected]':
optional: true
'@oxlint-tsgolint/[email protected]':
optional: true
'@oxlint-tsgolint/[email protected]':
optional: true
'@oxlint-tsgolint/[email protected]':
optional: true
'@oxlint-tsgolint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@rollup/[email protected]': '@rollup/[email protected]':
optional: true optional: true
@@ -918,6 +1051,8 @@ snapshots:
'@standard-schema/[email protected]': {} '@standard-schema/[email protected]': {}
'@steipete/[email protected]': {}
'@types/[email protected]': '@types/[email protected]':
dependencies: dependencies:
'@types/deep-eql': 4.0.2 '@types/deep-eql': 4.0.2
@@ -1003,8 +1138,6 @@ snapshots:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
[email protected]: {}
[email protected]: {} [email protected]: {}
[email protected]: [email protected]:
@@ -1104,6 +1237,27 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]:
optionalDependencies:
'@oxlint-tsgolint/darwin-arm64': 0.10.0
'@oxlint-tsgolint/darwin-x64': 0.10.0
'@oxlint-tsgolint/linux-arm64': 0.10.0
'@oxlint-tsgolint/linux-x64': 0.10.0
'@oxlint-tsgolint/win32-arm64': 0.10.0
'@oxlint-tsgolint/win32-x64': 0.10.0
[email protected]([email protected]):
optionalDependencies:
'@oxlint/darwin-arm64': 1.36.0
'@oxlint/darwin-x64': 1.36.0
'@oxlint/linux-arm64-gnu': 1.36.0
'@oxlint/linux-arm64-musl': 1.36.0
'@oxlint/linux-x64-gnu': 1.36.0
'@oxlint/linux-x64-musl': 1.36.0
'@oxlint/win32-arm64': 1.36.0
'@oxlint/win32-x64': 1.36.0
oxlint-tsgolint: 0.10.0
[email protected]: {} [email protected]: {}
[email protected]: {} [email protected]: {}
+16
View File
@@ -0,0 +1,16 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
const root = resolve(import.meta.dirname, '..');
const files = [
{
from: resolve(root, 'src/lib/query-ids.json'),
to: resolve(root, 'dist/lib/query-ids.json'),
},
];
for (const file of files) {
mkdirSync(dirname(file.to), { recursive: true });
writeFileSync(file.to, readFileSync(file.from));
}
+14 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env tsx #!/usr/bin/env tsx
/** /**
* Fetches current Twitter/X GraphQL query IDs from public client bundles and * Fetches current Twitter/X GraphQL query IDs from public client bundles and
* updates src/lib/query-ids.json. Inspired by Sweetistics' extractor. * updates src/lib/query-ids.json.
*/ */
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
@@ -13,6 +13,7 @@ const TARGET_OPERATIONS = [
'FavoriteTweet', 'FavoriteTweet',
'TweetDetail', 'TweetDetail',
'SearchTimeline', 'SearchTimeline',
'Bookmarks',
] as const; ] as const;
type OperationName = (typeof TARGET_OPERATIONS)[number]; type OperationName = (typeof TARGET_OPERATIONS)[number];
@@ -28,6 +29,18 @@ const BUNDLE_URL_REGEX =
/https:\/\/abs\.twimg\.com\/responsive-web\/client-web(?:-legacy)?\/[A-Za-z0-9.-]+\.js/g; /https:\/\/abs\.twimg\.com\/responsive-web\/client-web(?:-legacy)?\/[A-Za-z0-9.-]+\.js/g;
const OPERATION_PATTERNS = [ const OPERATION_PATTERNS = [
// Modern bundles export operations like:
// e.exports={queryId:"...",operationName:"CreateTweet",operationType:"mutation",...}
{
regex: /e\.exports=\{queryId\s*:\s*["']([^"']+)["']\s*,\s*operationName\s*:\s*["']([^"']+)["']/gs,
operationGroup: 2,
queryIdGroup: 1,
},
{
regex: /e\.exports=\{operationName\s*:\s*["']([^"']+)["']\s*,\s*queryId\s*:\s*["']([^"']+)["']/gs,
operationGroup: 1,
queryIdGroup: 2,
},
{ {
regex: /operationName\s*[:=]\s*["']([^"']+)["'](.{0,4000}?)queryId\s*[:=]\s*["']([^"']+)["']/gs, regex: /operationName\s*[:=]\s*["']([^"']+)["'](.{0,4000}?)queryId\s*[:=]\s*["']([^"']+)["']/gs,
operationGroup: 1, operationGroup: 1,
+372 -571
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -8,7 +8,9 @@ const TWEET_ID_REGEX = /^\d{8,}$/;
export function looksLikeTweetInput(value: string): boolean { export function looksLikeTweetInput(value: string): boolean {
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed) return false; if (!trimmed) {
return false;
}
return TWEET_URL_REGEX.test(trimmed) || TWEET_ID_REGEX.test(trimmed); return TWEET_URL_REGEX.test(trimmed) || TWEET_ID_REGEX.test(trimmed);
} }
+146 -311
View File
@@ -1,17 +1,14 @@
/** /**
* Chrome cookie extraction for Twitter authentication * Browser cookie extraction for Twitter authentication.
* Uses macOS sqlite3 CLI and keychain for decryption - no native dependencies! * Delegates to @steipete/sweet-cookie for Safari/Chrome/Firefox reads.
*/ */
import { execSync } from 'node:child_process'; import { getCookies } from '@steipete/sweet-cookie';
import { createDecipheriv, pbkdf2Sync } from 'node:crypto';
import { copyFileSync, existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
export interface TwitterCookies { export interface TwitterCookies {
authToken: string | null; authToken: string | null;
ct0: string | null; ct0: string | null;
cookieHeader: string | null;
source: string | null; source: string | null;
} }
@@ -20,367 +17,205 @@ export interface CookieExtractionResult {
warnings: string[]; warnings: string[];
} }
export type CookieSource = 'safari' | 'chrome' | 'firefox';
const TWITTER_COOKIE_NAMES = ['auth_token', 'ct0'] as const;
const TWITTER_URL = 'https://x.com/';
const TWITTER_ORIGINS: string[] = ['https://x.com/', 'https://twitter.com/'];
function normalizeValue(value: unknown): string | null { function normalizeValue(value: unknown): string | null {
if (typeof value === 'string') { if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim(); const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null; return trimmed.length > 0 ? trimmed : null;
} }
function cookieHeader(authToken: string, ct0: string): string {
return `auth_token=${authToken}; ct0=${ct0}`;
}
function buildEmpty(): TwitterCookies {
return { authToken: null, ct0: null, cookieHeader: null, source: null };
}
function readEnvCookie(cookies: TwitterCookies, keys: readonly string[], field: 'authToken' | 'ct0'): void {
if (cookies[field]) {
return;
}
for (const key of keys) {
const value = normalizeValue(process.env[key]);
if (!value) {
continue;
}
cookies[field] = value;
if (!cookies.source) {
cookies.source = `env ${key}`;
}
break;
}
}
function resolveSources(cookieSource?: CookieSource | CookieSource[]): CookieSource[] {
if (Array.isArray(cookieSource)) {
return cookieSource;
}
if (cookieSource) {
return [cookieSource];
}
return ['safari', 'chrome', 'firefox'];
}
function labelForSource(source: CookieSource, profile?: string): string {
if (source === 'safari') {
return 'Safari';
}
if (source === 'chrome') {
return profile ? `Chrome profile "${profile}"` : 'Chrome default profile';
}
return profile ? `Firefox profile "${profile}"` : 'Firefox default profile';
}
function pickCookieValue(
cookies: Array<{ name?: string; value?: string; domain?: string }>,
name: (typeof TWITTER_COOKIE_NAMES)[number],
): string | null {
const matches = cookies.filter((c) => c?.name === name && typeof c.value === 'string');
if (matches.length === 0) {
return null; return null;
} }
function getChromeCookiesPath(profile?: string): string { const preferred = matches.find((c) => (c.domain ?? '').endsWith('x.com'));
const home = process.env.HOME || ''; if (preferred?.value) {
const profileDir = profile || 'Default'; return preferred.value;
return join(home, 'Library', 'Application Support', 'Google', 'Chrome', profileDir, 'Cookies');
} }
function getFirefoxProfilesRoot(): string | null { const twitter = matches.find((c) => (c.domain ?? '').endsWith('twitter.com'));
const home = process.env.HOME || ''; if (twitter?.value) {
if (process.platform === 'darwin') { return twitter.value;
return join(home, 'Library', 'Application Support', 'Firefox', 'Profiles');
}
if (process.platform === 'linux') {
return join(home, '.mozilla', 'firefox');
}
if (process.platform === 'win32') {
const appData = process.env.APPDATA;
if (!appData) return null;
return join(appData, 'Mozilla', 'Firefox', 'Profiles');
}
return null;
} }
function pickFirefoxProfile(profilesRoot: string, profile?: string): string | null { return matches[0]?.value ?? null;
if (profile) {
const candidate = join(profilesRoot, profile, 'cookies.sqlite');
return existsSync(candidate) ? candidate : null;
} }
const entries = readdirSync(profilesRoot, { withFileTypes: true }); async function readTwitterCookiesFromBrowser(options: {
const defaultRelease = entries.find((entry) => entry.isDirectory() && entry.name.includes('default-release')); source: CookieSource;
const targetDir = defaultRelease?.name ?? entries.find((e) => e.isDirectory())?.name; chromeProfile?: string;
if (!targetDir) return null; firefoxProfile?: string;
}): Promise<CookieExtractionResult> {
const candidate = join(profilesRoot, targetDir, 'cookies.sqlite');
return existsSync(candidate) ? candidate : null;
}
function getFirefoxCookiesPath(profile?: string): string | null {
const profilesRoot = getFirefoxProfilesRoot();
if (!profilesRoot || !existsSync(profilesRoot)) return null;
return pickFirefoxProfile(profilesRoot, profile);
}
/**
* Decrypt Chrome cookie value using macOS keychain
* Chrome encrypts cookies with a key stored in the keychain
*/
function decryptCookieValue(encryptedHex: string): string | null {
try {
// Convert hex to buffer
const encryptedValue = Buffer.from(encryptedHex, 'hex');
if (encryptedValue.length < 4) {
return null;
}
const version = encryptedValue.subarray(0, 3).toString('utf8');
if (version !== 'v10' && version !== 'v11') {
// Not encrypted, just return as string
return encryptedValue.toString('utf8');
}
// Get encryption key from keychain
const keyOutput = execSync('security find-generic-password -s "Chrome Safe Storage" -w 2>/dev/null || echo ""', {
encoding: 'utf8',
}).trim();
if (!keyOutput) {
return null;
}
// Derive the key using PBKDF2
const salt = 'saltysalt';
const iterations = 1003;
const keyLength = 16;
const derivedKey = pbkdf2Sync(keyOutput, salt, iterations, keyLength, 'sha1');
// Decrypt using AES-128-CBC with empty IV (16 bytes of 0x20/space)
const iv = Buffer.alloc(16, 0x20);
const encryptedData = encryptedValue.subarray(3); // Skip "v10" or "v11" prefix
const decipher = createDecipheriv('aes-128-cbc', derivedKey, iv);
decipher.setAutoPadding(true);
let decrypted = decipher.update(encryptedData);
decrypted = Buffer.concat([decrypted, decipher.final()]);
// Chrome v10 cookies have key material prepended before the actual value
// Twitter cookies are hex strings, so extract the longest hex sequence
const decryptedStr = decrypted.toString('utf8');
const hexMatch = decryptedStr.match(/[a-f0-9]{32,}/i);
if (hexMatch) {
return hexMatch[0];
}
// Fallback: keep only printable ASCII characters
return decryptedStr.replace(/[^\x20-\x7E]/g, '');
} catch {
return null;
}
}
/**
* Extract Twitter cookies from Chrome browser using sqlite3 CLI
* @param profile - Chrome profile name (optional, uses Default if not specified)
*/
export async function extractCookiesFromChrome(profile?: string): Promise<CookieExtractionResult> {
const warnings: string[] = []; const warnings: string[] = [];
const cookies: TwitterCookies = { const out = buildEmpty();
authToken: null,
ct0: null,
source: null,
};
const cookiesPath = getChromeCookiesPath(profile); const { cookies, warnings: providerWarnings } = await getCookies({
url: TWITTER_URL,
origins: TWITTER_ORIGINS,
names: [...TWITTER_COOKIE_NAMES],
browsers: [options.source],
mode: 'merge',
chromeProfile: options.chromeProfile,
firefoxProfile: options.firefoxProfile,
});
warnings.push(...providerWarnings);
if (!existsSync(cookiesPath)) { const authToken = pickCookieValue(cookies, 'auth_token');
warnings.push(`Chrome cookies database not found at: ${cookiesPath}`); const ct0 = pickCookieValue(cookies, 'ct0');
return { cookies, warnings }; if (authToken) {
out.authToken = authToken;
}
if (ct0) {
out.ct0 = ct0;
} }
// Chrome locks the database, so we need to copy it if (out.authToken && out.ct0) {
let tempDir: string | null = null; out.cookieHeader = cookieHeader(out.authToken, out.ct0);
out.source = labelForSource(
try { options.source,
tempDir = mkdtempSync(join(tmpdir(), 'twitter-cli-')); options.source === 'chrome' ? options.chromeProfile : options.firefoxProfile,
const tempDbPath = join(tempDir, 'Cookies'); );
copyFileSync(cookiesPath, tempDbPath); return { cookies: out, warnings };
// Also copy the WAL and SHM files if they exist
const walPath = `${cookiesPath}-wal`;
const shmPath = `${cookiesPath}-shm`;
if (existsSync(walPath)) {
copyFileSync(walPath, `${tempDbPath}-wal`);
}
if (existsSync(shmPath)) {
copyFileSync(shmPath, `${tempDbPath}-shm`);
} }
// Use sqlite3 CLI to query cookies (no native deps required!) if (options.source === 'safari') {
const query = `SELECT name, hex(encrypted_value) as encrypted_hex FROM cookies WHERE host_key IN ('.x.com', '.twitter.com', 'x.com', 'twitter.com') AND name IN ('auth_token', 'ct0');`; warnings.push('No Twitter cookies found in Safari. Make sure you are logged into x.com in Safari.');
} else if (options.source === 'chrome') {
const result = execSync(`sqlite3 -separator '|' "${tempDbPath}" "${query}"`, {
encoding: 'utf8',
maxBuffer: 1024 * 1024,
}).trim();
if (result) {
for (const line of result.split('\n')) {
const [name, encryptedHex] = line.split('|');
if (!name || !encryptedHex) continue;
const decryptedValue = decryptCookieValue(encryptedHex);
if (decryptedValue) {
if (name === 'auth_token' && !cookies.authToken) {
cookies.authToken = decryptedValue;
} else if (name === 'ct0' && !cookies.ct0) {
cookies.ct0 = decryptedValue;
}
}
}
}
if (cookies.authToken || cookies.ct0) {
cookies.source = profile ? `Chrome profile "${profile}"` : 'Chrome default profile';
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
warnings.push(`Failed to read Chrome cookies: ${message}`);
} finally {
// Cleanup temp files
if (tempDir) {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
}
}
if (!cookies.authToken && !cookies.ct0) {
warnings.push('No Twitter cookies found in Chrome. Make sure you are logged into x.com in Chrome.'); warnings.push('No Twitter cookies found in Chrome. Make sure you are logged into x.com in Chrome.');
} } else {
return { cookies, warnings };
}
/**
* Extract Twitter cookies from Firefox browser using sqlite3 CLI
* @param profile - Firefox profile directory name (optional, auto-detected)
*/
export async function extractCookiesFromFirefox(profile?: string): Promise<CookieExtractionResult> {
const warnings: string[] = [];
const cookies: TwitterCookies = {
authToken: null,
ct0: null,
source: null,
};
const cookiesPath = getFirefoxCookiesPath(profile);
if (!cookiesPath) {
warnings.push('Firefox cookies database not found.');
return { cookies, warnings };
}
let tempDir: string | null = null;
try {
tempDir = mkdtempSync(join(tmpdir(), 'twitter-cli-'));
const tempDbPath = join(tempDir, 'cookies.sqlite');
copyFileSync(cookiesPath, tempDbPath);
const query = `SELECT name, value FROM moz_cookies WHERE host IN ('.x.com', '.twitter.com', 'x.com', 'twitter.com') AND name IN ('auth_token', 'ct0');`;
const result = execSync(`sqlite3 -separator '|' "${tempDbPath}" "${query}"`, {
encoding: 'utf8',
maxBuffer: 1024 * 1024,
}).trim();
if (result) {
for (const line of result.split('\n')) {
const [name, value] = line.split('|');
if (!name || !value) continue;
if (name === 'auth_token' && !cookies.authToken) {
cookies.authToken = value;
} else if (name === 'ct0' && !cookies.ct0) {
cookies.ct0 = value;
}
}
}
if (cookies.authToken || cookies.ct0) {
cookies.source = profile ? `Firefox profile "${profile}"` : 'Firefox default profile';
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
warnings.push(`Failed to read Firefox cookies: ${message}`);
} finally {
if (tempDir) {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {
// ignore cleanup errors
}
}
}
if (!cookies.authToken && !cookies.ct0) {
warnings.push( warnings.push(
'No Twitter cookies found in Firefox. Make sure you are logged into x.com in Firefox and the profile exists.', 'No Twitter cookies found in Firefox. Make sure you are logged into x.com in Firefox and the profile exists.',
); );
} }
return { cookies, warnings }; return { cookies: out, warnings };
}
export async function extractCookiesFromSafari(): Promise<CookieExtractionResult> {
return readTwitterCookiesFromBrowser({ source: 'safari' });
}
export async function extractCookiesFromChrome(profile?: string): Promise<CookieExtractionResult> {
return readTwitterCookiesFromBrowser({ source: 'chrome', chromeProfile: profile });
}
export async function extractCookiesFromFirefox(profile?: string): Promise<CookieExtractionResult> {
return readTwitterCookiesFromBrowser({ source: 'firefox', firefoxProfile: profile });
} }
/** /**
* Resolve Twitter credentials from multiple sources * Resolve Twitter credentials from multiple sources.
* Priority: CLI args > environment variables > Chrome cookies * Priority: CLI args > environment variables > browsers (ordered).
*/ */
export async function resolveCredentials(options: { export async function resolveCredentials(options: {
authToken?: string; authToken?: string;
ct0?: string; ct0?: string;
cookieSource?: CookieSource | CookieSource[];
chromeProfile?: string; chromeProfile?: string;
firefoxProfile?: string; firefoxProfile?: string;
allowChrome?: boolean;
allowFirefox?: boolean;
}): Promise<CookieExtractionResult> { }): Promise<CookieExtractionResult> {
const warnings: string[] = []; const warnings: string[] = [];
const cookies: TwitterCookies = { const cookies = buildEmpty();
authToken: null,
ct0: null,
source: null,
};
// 1. CLI arguments (highest priority)
if (options.authToken) { if (options.authToken) {
cookies.authToken = options.authToken; cookies.authToken = options.authToken;
cookies.source = 'CLI argument'; cookies.source = 'CLI argument';
} }
if (options.ct0) { if (options.ct0) {
cookies.ct0 = options.ct0; cookies.ct0 = options.ct0;
if (!cookies.source) cookies.source = 'CLI argument'; if (!cookies.source) {
} cookies.source = 'CLI argument';
// 2. Environment variables
const envAuthKeys = ['AUTH_TOKEN', 'TWITTER_AUTH_TOKEN'];
const envCt0Keys = ['CT0', 'TWITTER_CT0'];
if (!cookies.authToken) {
for (const key of envAuthKeys) {
const value = normalizeValue(process.env[key]);
if (value) {
cookies.authToken = value;
cookies.source = `env ${key}`;
break;
}
}
}
if (!cookies.ct0) {
for (const key of envCt0Keys) {
const value = normalizeValue(process.env[key]);
if (value) {
cookies.ct0 = value;
if (!cookies.source) cookies.source = `env ${key}`;
break;
}
}
}
const allowFirefox = options.allowFirefox ?? true;
const allowChrome = options.allowChrome ?? true;
// 3. Firefox cookies (preferred browser fallback)
if (allowFirefox && (!cookies.authToken || !cookies.ct0)) {
const firefoxResult = await extractCookiesFromFirefox(options.firefoxProfile);
warnings.push(...firefoxResult.warnings);
if (!cookies.authToken && firefoxResult.cookies.authToken) {
cookies.authToken = firefoxResult.cookies.authToken;
cookies.source = firefoxResult.cookies.source;
}
if (!cookies.ct0 && firefoxResult.cookies.ct0) {
cookies.ct0 = firefoxResult.cookies.ct0;
if (!cookies.source) cookies.source = firefoxResult.cookies.source;
} }
} }
// 4. Chrome cookies (secondary browser fallback) readEnvCookie(cookies, ['AUTH_TOKEN', 'TWITTER_AUTH_TOKEN'], 'authToken');
if (allowChrome && (!cookies.authToken || !cookies.ct0)) { readEnvCookie(cookies, ['CT0', 'TWITTER_CT0'], 'ct0');
const chromeResult = await extractCookiesFromChrome(options.chromeProfile);
warnings.push(...chromeResult.warnings);
if (!cookies.authToken && chromeResult.cookies.authToken) { if (cookies.authToken && cookies.ct0) {
cookies.authToken = chromeResult.cookies.authToken; cookies.cookieHeader = cookieHeader(cookies.authToken, cookies.ct0);
cookies.source = chromeResult.cookies.source; return { cookies, warnings };
} }
if (!cookies.ct0 && chromeResult.cookies.ct0) {
cookies.ct0 = chromeResult.cookies.ct0; const sourcesToTry = resolveSources(options.cookieSource);
if (!cookies.source) cookies.source = chromeResult.cookies.source; for (const source of sourcesToTry) {
const res = await readTwitterCookiesFromBrowser({
source,
chromeProfile: options.chromeProfile,
firefoxProfile: options.firefoxProfile,
});
warnings.push(...res.warnings);
if (res.cookies.authToken && res.cookies.ct0) {
return { cookies: res.cookies, warnings };
} }
} }
// Validation
if (!cookies.authToken) { if (!cookies.authToken) {
warnings.push( warnings.push(
'Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or login to x.com in Chrome/Firefox', 'Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or login to x.com in Safari/Chrome/Firefox',
); );
} }
if (!cookies.ct0) { if (!cookies.ct0) {
warnings.push('Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Chrome/Firefox'); warnings.push('Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Safari/Chrome/Firefox');
}
if (cookies.authToken && cookies.ct0) {
cookies.cookieHeader = cookieHeader(cookies.authToken, cookies.ct0);
} }
return { cookies, warnings }; return { cookies, warnings };
-15
View File
@@ -1,15 +0,0 @@
export type EngineMode = 'graphql' | 'sweetistics' | 'auto';
export function resolveEngineMode(value?: string): EngineMode {
const normalized = (value || 'auto').toLowerCase();
if (normalized === 'graphql' || normalized === 'sweetistics' || normalized === 'auto') {
return normalized;
}
return 'auto';
}
export function shouldUseSweetistics(engine: EngineMode, _hasApiKey: boolean): boolean {
if (engine === 'sweetistics') return true;
if (engine === 'graphql') return false;
return false; // auto: GraphQL primary, Sweetistics only as fallback
}
+3 -1
View File
@@ -1,9 +1,11 @@
/** /**
* Extract tweet ID from a Twitter/X URL or return the input unchanged if it's already an ID. * Extract tweet ID from a Twitter/X URL or return the input unchanged if it's already an ID.
*/ */
const TWEET_URL_REGEX = /(?:twitter\.com|x\.com)\/\w+\/status\/(\d+)/;
export function extractTweetId(input: string): string { export function extractTweetId(input: string): string {
// If it's a URL, extract the tweet ID // If it's a URL, extract the tweet ID
const urlMatch = input.match(/(?:twitter\.com|x\.com)\/\w+\/status\/(\d+)/); const urlMatch = TWEET_URL_REGEX.exec(input);
if (urlMatch) { if (urlMatch) {
return urlMatch[1]; return urlMatch[1];
} }
+40
View File
@@ -0,0 +1,40 @@
const HANDLE_REGEX = /^[A-Za-z0-9_]{1,15}$/;
export function normalizeHandle(input?: string | null): string | null {
const raw = (input ?? '').trim();
if (!raw) {
return null;
}
const withoutAt = raw.startsWith('@') ? raw.slice(1) : raw;
const handle = withoutAt.trim();
if (!handle) {
return null;
}
// X/Twitter handles are traditionally max 15 chars; keep strict to avoid surprising queries.
if (!HANDLE_REGEX.test(handle)) {
return null;
}
return handle;
}
export function mentionsQueryFromUserOption(userOption?: string | null): {
query: string | null;
error: string | null;
} {
if (typeof userOption === 'undefined') {
return { query: null, error: null };
}
const handle = normalizeHandle(userOption);
if (!handle) {
return {
query: null,
error: 'Invalid --user handle. Expected something like @steipete (letters, digits, underscore; max 15).',
};
}
return { query: `@${handle}`, error: null };
}
+98
View File
@@ -0,0 +1,98 @@
export type OutputConfig = {
plain: boolean;
emoji: boolean;
color: boolean;
};
export type StatusKind = 'ok' | 'warn' | 'err' | 'info' | 'hint';
export type LabelKind = 'url' | 'date' | 'source' | 'engine' | 'credentials' | 'user' | 'userId' | 'email';
const STATUS: Record<StatusKind, { emoji: string; text: string; plain: string }> = {
ok: { emoji: '✅', text: 'OK:', plain: '[ok]' },
warn: { emoji: '⚠️', text: 'Warning:', plain: '[warn]' },
err: { emoji: '❌', text: 'Error:', plain: '[err]' },
info: { emoji: 'ℹ️', text: 'Info:', plain: '[info]' },
hint: { emoji: 'ℹ️', text: 'Hint:', plain: '[hint]' },
};
const LABELS: Record<LabelKind, { emoji: string; text: string; plain: string }> = {
url: { emoji: '🔗', text: 'URL:', plain: 'url:' },
date: { emoji: '📅', text: 'Date:', plain: 'date:' },
source: { emoji: '📍', text: 'Source:', plain: 'source:' },
engine: { emoji: '⚙️', text: 'Engine:', plain: 'engine:' },
credentials: { emoji: '🔑', text: 'Credentials:', plain: 'credentials:' },
user: { emoji: '🙋', text: 'User:', plain: 'user:' },
userId: { emoji: '🪪', text: 'User ID:', plain: 'user_id:' },
email: { emoji: '📧', text: 'Email:', plain: 'email:' },
};
export function resolveOutputConfigFromArgv(argv: string[], env: NodeJS.ProcessEnv, isTty: boolean): OutputConfig {
const hasNoColorEnv = Object.hasOwn(env, 'NO_COLOR') || env.TERM === 'dumb';
const defaultColor = isTty && !hasNoColorEnv;
const plain = argv.includes('--plain');
const emoji = !plain && !argv.includes('--no-emoji');
const color = !plain && !argv.includes('--no-color') && defaultColor;
return { plain, emoji, color };
}
export function resolveOutputConfigFromCommander(
opts: { plain?: boolean; emoji?: boolean; color?: boolean },
env: NodeJS.ProcessEnv,
isTty: boolean,
): OutputConfig {
const hasNoColorEnv = Object.hasOwn(env, 'NO_COLOR') || env.TERM === 'dumb';
const defaultColor = isTty && !hasNoColorEnv;
const plain = Boolean(opts.plain);
const emoji = !plain && (opts.emoji ?? true);
const color = !plain && (opts.color ?? true) && defaultColor;
return { plain, emoji, color };
}
export function statusPrefix(kind: StatusKind, cfg: OutputConfig): string {
if (cfg.plain) {
return `${STATUS[kind].plain} `;
}
if (cfg.emoji) {
return `${STATUS[kind].emoji} `;
}
return `${STATUS[kind].text} `;
}
export function labelPrefix(kind: LabelKind, cfg: OutputConfig): string {
if (cfg.plain) {
return `${LABELS[kind].plain} `;
}
if (cfg.emoji) {
return `${LABELS[kind].emoji} `;
}
return `${LABELS[kind].text} `;
}
export function formatStatsLine(
stats: { likeCount?: number | null; retweetCount?: number | null; replyCount?: number | null },
cfg: OutputConfig,
): string {
const likeCount = stats.likeCount ?? 0;
const retweetCount = stats.retweetCount ?? 0;
const replyCount = stats.replyCount ?? 0;
if (cfg.plain) {
return `likes: ${likeCount} retweets: ${retweetCount} replies: ${replyCount}`;
}
if (!cfg.emoji) {
return `Likes ${likeCount} Retweets ${retweetCount} Replies ${replyCount}`;
}
return `❤️ ${likeCount} 🔁 ${retweetCount} 💬 ${replyCount}`;
}
export function formatTweetUrl(tweetId: string): string {
return `https://x.com/i/status/${tweetId}`;
}
export function formatTweetUrlLine(tweetId: string, cfg: OutputConfig): string {
return `${labelPrefix('url', cfg)}${formatTweetUrl(tweetId)}`;
}
+4 -4
View File
@@ -1,7 +1,7 @@
{ {
"CreateTweet": "2BA2DIWTRVxHgKc4Xh2uKQ", "CreateTweet": "Uf3io9zVp1DsYxrmL5FJ7g",
"CreateRetweet": "Uf3io9zVp1DsYxrmL5FJ7g", "CreateRetweet": "LFho5rIi4xcKO90p9jwG7A",
"FavoriteTweet": "kuFUYP9eV1FPoEy4N-pi7w", "FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
"TweetDetail": "97JF30KziU00483E_8elBA", "TweetDetail": "97JF30KziU00483E_8elBA",
"SearchTimeline": "5h0kNbk3ii97rmfY6CdgAA" "SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ"
} }
+341
View File
@@ -0,0 +1,341 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import path from 'node:path';
const DEFAULT_CACHE_FILENAME = 'query-ids-cache.json';
const DEFAULT_TTL_MS: number = 24 * 60 * 60 * 1000;
const DISCOVERY_PAGES: string[] = [
'https://x.com/?lang=en',
'https://x.com/explore',
'https://x.com/notifications',
'https://x.com/settings/profile',
];
const BUNDLE_URL_REGEX = /https:\/\/abs\.twimg\.com\/responsive-web\/client-web(?:-legacy)?\/[A-Za-z0-9.-]+\.js/g;
const QUERY_ID_REGEX = /^[a-zA-Z0-9_-]+$/;
const OPERATION_PATTERNS = [
{
regex: /e\.exports=\{queryId\s*:\s*["']([^"']+)["']\s*,\s*operationName\s*:\s*["']([^"']+)["']/gs,
operationGroup: 2,
queryIdGroup: 1,
},
{
regex: /e\.exports=\{operationName\s*:\s*["']([^"']+)["']\s*,\s*queryId\s*:\s*["']([^"']+)["']/gs,
operationGroup: 1,
queryIdGroup: 2,
},
{
regex: /operationName\s*[:=]\s*["']([^"']+)["'](.{0,4000}?)queryId\s*[:=]\s*["']([^"']+)["']/gs,
operationGroup: 1,
queryIdGroup: 3,
},
{
regex: /queryId\s*[:=]\s*["']([^"']+)["'](.{0,4000}?)operationName\s*[:=]\s*["']([^"']+)["']/gs,
operationGroup: 3,
queryIdGroup: 1,
},
] as const;
const HEADERS = {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36',
Accept: 'text/html,application/json;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
};
export type RuntimeQueryIdSnapshot = {
fetchedAt: string;
ttlMs: number;
ids: Record<string, string>;
discovery: {
pages: string[];
bundles: string[];
};
};
export type RuntimeQueryIdSnapshotInfo = {
snapshot: RuntimeQueryIdSnapshot;
cachePath: string;
ageMs: number;
isFresh: boolean;
};
export type RuntimeQueryIdsOptions = {
cachePath?: string;
ttlMs?: number;
fetchImpl?: typeof fetch;
};
export type RuntimeQueryIdStore = {
cachePath: string;
ttlMs: number;
getSnapshotInfo: () => Promise<RuntimeQueryIdSnapshotInfo | null>;
getQueryId: (operationName: string) => Promise<string | null>;
refresh: (operationNames: string[], opts?: { force?: boolean }) => Promise<RuntimeQueryIdSnapshotInfo | null>;
clearMemory: () => void;
};
async function fetchText(fetchImpl: typeof fetch, url: string): Promise<string> {
const response = await fetchImpl(url, { headers: HEADERS });
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new Error(`HTTP ${response.status} for ${url}: ${body.slice(0, 120)}`);
}
return response.text();
}
function resolveDefaultCachePath(): string {
const override = process.env.BIRD_QUERY_IDS_CACHE;
if (override && override.trim().length > 0) {
return path.resolve(override.trim());
}
return path.join(homedir(), '.config', 'bird', DEFAULT_CACHE_FILENAME);
}
function parseSnapshot(raw: unknown): RuntimeQueryIdSnapshot | null {
if (!raw || typeof raw !== 'object') {
return null;
}
const record = raw as Record<string, unknown>;
const fetchedAt = typeof record.fetchedAt === 'string' ? record.fetchedAt : null;
const ttlMs = typeof record.ttlMs === 'number' && Number.isFinite(record.ttlMs) ? record.ttlMs : null;
const ids = record.ids && typeof record.ids === 'object' ? (record.ids as Record<string, unknown>) : null;
const discovery =
record.discovery && typeof record.discovery === 'object' ? (record.discovery as Record<string, unknown>) : null;
if (!fetchedAt || !ttlMs || !ids || !discovery) {
return null;
}
const pages = Array.isArray(discovery.pages) ? discovery.pages : null;
const bundles = Array.isArray(discovery.bundles) ? discovery.bundles : null;
if (!pages || !bundles) {
return null;
}
const normalizedIds: Record<string, string> = {};
for (const [key, value] of Object.entries(ids)) {
if (typeof value === 'string' && value.trim().length > 0) {
normalizedIds[key] = value.trim();
}
}
return {
fetchedAt,
ttlMs,
ids: normalizedIds,
discovery: {
pages: pages.filter((p) => typeof p === 'string') as string[],
bundles: bundles.filter((b) => typeof b === 'string') as string[],
},
};
}
async function readSnapshotFromDisk(cachePath: string): Promise<RuntimeQueryIdSnapshot | null> {
try {
const raw = await readFile(cachePath, 'utf8');
return parseSnapshot(JSON.parse(raw));
} catch {
return null;
}
}
async function writeSnapshotToDisk(cachePath: string, snapshot: RuntimeQueryIdSnapshot): Promise<void> {
await mkdir(path.dirname(cachePath), { recursive: true });
await writeFile(cachePath, `${JSON.stringify(snapshot, null, 2)}\n`, 'utf8');
}
async function discoverBundles(fetchImpl: typeof fetch): Promise<string[]> {
const bundles = new Set<string>();
for (const page of DISCOVERY_PAGES) {
try {
const html = await fetchText(fetchImpl, page);
for (const match of html.matchAll(BUNDLE_URL_REGEX)) {
bundles.add(match[0]);
}
} catch {
// ignore discovery page failures; other pages often work
}
}
const discovered = [...bundles];
if (discovered.length === 0) {
throw new Error('No client bundles discovered; x.com layout may have changed.');
}
return discovered;
}
function extractOperations(
bundleContents: string,
bundleLabel: string,
targets: Set<string>,
discovered: Map<string, { queryId: string; bundle: string }>,
): void {
for (const pattern of OPERATION_PATTERNS) {
pattern.regex.lastIndex = 0;
while (true) {
const match = pattern.regex.exec(bundleContents);
if (match === null) {
break;
}
const operationName = match[pattern.operationGroup];
const queryId = match[pattern.queryIdGroup];
if (!operationName || !queryId) {
continue;
}
if (!targets.has(operationName)) {
continue;
}
if (!QUERY_ID_REGEX.test(queryId)) {
continue;
}
if (discovered.has(operationName)) {
continue;
}
discovered.set(operationName, { queryId, bundle: bundleLabel });
if (discovered.size === targets.size) {
return;
}
}
}
}
async function fetchAndExtract(
fetchImpl: typeof fetch,
bundleUrls: string[],
targets: Set<string>,
): Promise<Map<string, { queryId: string; bundle: string }>> {
const discovered = new Map<string, { queryId: string; bundle: string }>();
const CONCURRENCY = 6;
for (let i = 0; i < bundleUrls.length; i += CONCURRENCY) {
const chunk = bundleUrls.slice(i, i + CONCURRENCY);
await Promise.all(
chunk.map(async (url) => {
if (discovered.size === targets.size) {
return;
}
const label = url.split('/').at(-1) ?? url;
try {
const js = await fetchText(fetchImpl, url);
extractOperations(js, label, targets, discovered);
} catch {
// ignore failed bundles
}
}),
);
if (discovered.size === targets.size) {
break;
}
}
return discovered;
}
export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {}): RuntimeQueryIdStore {
const fetchImpl = options.fetchImpl ?? fetch;
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
const cachePath = options.cachePath ? path.resolve(options.cachePath) : resolveDefaultCachePath();
let memorySnapshot: RuntimeQueryIdSnapshot | null = null;
let loadOnce: Promise<RuntimeQueryIdSnapshot | null> | null = null;
let refreshInFlight: Promise<RuntimeQueryIdSnapshotInfo | null> | null = null;
const loadSnapshot = async (): Promise<RuntimeQueryIdSnapshot | null> => {
if (memorySnapshot) {
return memorySnapshot;
}
if (!loadOnce) {
loadOnce = (async () => {
const fromDisk = await readSnapshotFromDisk(cachePath);
memorySnapshot = fromDisk;
return fromDisk;
})();
}
return loadOnce;
};
const getSnapshotInfo = async (): Promise<RuntimeQueryIdSnapshotInfo | null> => {
const snapshot = await loadSnapshot();
if (!snapshot) {
return null;
}
const fetchedAtMs = new Date(snapshot.fetchedAt).getTime();
const ageMs = Number.isFinite(fetchedAtMs) ? Math.max(0, Date.now() - fetchedAtMs) : Number.POSITIVE_INFINITY;
const effectiveTtl = Number.isFinite(snapshot.ttlMs) ? snapshot.ttlMs : ttlMs;
const isFresh = ageMs <= effectiveTtl;
return { snapshot, cachePath, ageMs, isFresh };
};
const getQueryId = async (operationName: string): Promise<string | null> => {
const info = await getSnapshotInfo();
if (!info) {
return null;
}
return info.snapshot.ids[operationName] ?? null;
};
const refresh = async (
operationNames: string[],
opts: { force?: boolean } = {},
): Promise<RuntimeQueryIdSnapshotInfo | null> => {
if (refreshInFlight) {
return refreshInFlight;
}
refreshInFlight = (async () => {
const current = await getSnapshotInfo();
if (!opts.force && current?.isFresh) {
return current;
}
const targets = new Set(operationNames);
const bundleUrls = await discoverBundles(fetchImpl);
const discovered = await fetchAndExtract(fetchImpl, bundleUrls, targets);
if (discovered.size === 0) {
return current ?? null;
}
const ids: Record<string, string> = {};
for (const name of operationNames) {
const entry = discovered.get(name);
if (entry?.queryId) {
ids[name] = entry.queryId;
}
}
const snapshot: RuntimeQueryIdSnapshot = {
fetchedAt: new Date().toISOString(),
ttlMs,
ids,
discovery: {
pages: [...DISCOVERY_PAGES],
bundles: bundleUrls.map((url) => url.split('/').at(-1) ?? url),
},
};
await writeSnapshotToDisk(cachePath, snapshot);
memorySnapshot = snapshot;
return getSnapshotInfo();
})().finally(() => {
refreshInFlight = null;
});
return refreshInFlight;
};
return {
cachePath,
ttlMs,
getSnapshotInfo,
getQueryId,
refresh,
clearMemory() {
memorySnapshot = null;
loadOnce = null;
},
};
}
export const runtimeQueryIds = createRuntimeQueryIdStore();
-534
View File
@@ -1,534 +0,0 @@
import { parse as devalueParse } from 'devalue';
export interface SweetisticsClientOptions {
baseUrl: string;
apiKey: string;
userAgent?: string;
timeoutMs?: number;
}
export interface SweetisticsTweetResult {
success: boolean;
tweetId?: string;
error?: string;
}
export interface SweetisticsTweet {
id: string;
text: string;
author: {
username: string;
name: string;
};
createdAt?: string;
replyCount?: number;
retweetCount?: number;
likeCount?: number;
conversationId?: string;
inReplyToStatusId?: string;
}
export interface SweetisticsTimelineResult {
success: boolean;
tweets?: SweetisticsTweet[];
error?: string;
}
export interface SweetisticsReadResult {
success: boolean;
tweet?: SweetisticsTweet;
error?: string;
}
export interface SweetisticsUserResult {
success: boolean;
user?: {
id: string;
username?: string | null;
name?: string | null;
email?: string | null;
profileImageUrl?: string | null;
};
error?: string;
}
type TrpcResponseEnvelope = { result?: { data?: unknown; error?: unknown } };
function normalizeBaseUrl(value: string): string {
const trimmed = value.trim();
if (!trimmed) return 'https://sweetistics.com';
return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed;
}
export class SweetisticsClient {
private readonly baseUrl: string;
private readonly apiKey: string;
private readonly userAgent?: string;
private readonly timeoutMs: number;
// Prevent hanging requests; keep Sweetistics calls snappy for CLI users.
private static readonly DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
constructor(options: SweetisticsClientOptions) {
this.baseUrl = normalizeBaseUrl(options.baseUrl);
this.apiKey = options.apiKey.trim();
this.userAgent = options.userAgent;
this.timeoutMs = options.timeoutMs ?? SweetisticsClient.DEFAULT_REQUEST_TIMEOUT_MS;
if (!this.apiKey) {
throw new Error('Sweetistics API key is required');
}
}
async tweet(text: string, replyToTweetId?: string, mediaIds?: string[]): Promise<SweetisticsTweetResult> {
const payload: Record<string, unknown> = { text };
if (replyToTweetId) {
payload.replyToTweetId = replyToTweetId;
}
if (mediaIds && mediaIds.length > 0) {
payload.mediaIds = mediaIds;
}
let response: Response;
try {
response = await this.fetchWithTimeout(`${this.baseUrl}/api/actions/tweet`, {
method: 'POST',
headers: {
authorization: `Bearer ${this.apiKey}`,
'content-type': 'application/json',
...(this.userAgent ? { 'user-agent': this.userAgent } : {}),
},
body: JSON.stringify(payload),
});
} catch (error) {
return { success: false, error: this.normalizeError(error) };
}
let data: unknown;
try {
data = await response.json();
} catch (error) {
return {
success: false,
error: `Sweetistics response parse failed: ${error instanceof Error ? error.message : String(error)}`,
};
}
const success =
typeof (data as { success?: unknown })?.success === 'boolean' ? (data as { success: boolean }).success : false;
const tweetId =
typeof (data as { tweetId?: unknown })?.tweetId === 'string' ? (data as { tweetId?: string }).tweetId : undefined;
const errorMessage =
typeof (data as { error?: unknown })?.error === 'string' ? (data as { error?: string }).error : undefined;
if (!response.ok || !success) {
const reason = errorMessage || `HTTP ${response.status}`;
return { success: false, error: reason };
}
return { success: true, tweetId };
}
async read(tweetId: string): Promise<SweetisticsReadResult> {
// Public REST route that returns a single tweet record
const url = `${this.baseUrl}/api/tweets/${encodeURIComponent(tweetId)}`;
let response: Response;
try {
response = await this.fetchWithTimeout(url, {
headers: {
authorization: `Bearer ${this.apiKey}`,
...(this.userAgent ? { 'user-agent': this.userAgent } : {}),
},
});
} catch (error) {
return { success: false, error: this.normalizeError(error) };
}
if (!response.ok) {
return { success: false, error: `HTTP ${response.status}` };
}
// biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed
let data: any;
try {
data = await response.json();
} catch (error) {
return {
success: false,
error: `Sweetistics response parse failed: ${error instanceof Error ? error.message : String(error)}`,
};
}
if (!data?.id || !data?.author) {
return { success: false, error: 'Malformed tweet payload from Sweetistics' };
}
const tweet: SweetisticsTweet = {
id: String(data.id),
text: String(data.text ?? ''),
author: {
username: String(data.author.username ?? ''),
name: String(data.author.name ?? ''),
},
createdAt: data.createdAt,
replyCount: data.metrics?.replyCount,
retweetCount: data.metrics?.retweetCount,
likeCount: data.metrics?.likeCount,
conversationId: data.conversationId,
// biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed
inReplyToStatusId: data.referencedTweets?.find?.((r: any) => r?.type === 'replied_to')?.id,
};
return { success: true, tweet };
}
async replies(tweetId: string): Promise<SweetisticsTimelineResult> {
return this.fetchConversation(tweetId, { excludeRoot: true });
}
async thread(tweetId: string): Promise<SweetisticsTimelineResult> {
return this.fetchConversation(tweetId, { excludeRoot: false });
}
async search(query: string, count: number): Promise<SweetisticsTimelineResult> {
const payload = {
query,
capabilities: [
{
resource: 'tweets',
source: 'postgres',
limit: Math.max(1, Math.min(count, 50)),
offset: 0,
detail: 'summary',
includeRetweets: true,
},
],
};
let response: Response;
try {
response = await this.fetchWithTimeout(`${this.baseUrl}/api/trpc/search.execute?batch=1`, {
method: 'POST',
headers: {
authorization: `Bearer ${this.apiKey}`,
'content-type': 'application/json',
...(this.userAgent ? { 'user-agent': this.userAgent } : {}),
},
body: JSON.stringify({ 0: { json: payload } }),
});
} catch (error) {
return { success: false, error: this.normalizeError(error) };
}
if (!response.ok) {
return { success: false, error: `HTTP ${response.status}` };
}
// biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed
let body: any;
try {
body = await response.json();
} catch (error) {
return {
success: false,
error: `Sweetistics response parse failed: ${error instanceof Error ? error.message : String(error)}`,
};
}
const envelope = Array.isArray(body) ? body[0] : body;
if (envelope?.error) {
const message = envelope.error?.message ?? 'Unknown search error';
return { success: false, error: message };
}
const dataField = envelope?.result?.data;
if (!dataField) {
return { success: false, error: 'Missing data in Sweetistics search response' };
}
// biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed
let parsed: any;
try {
parsed = typeof dataField === 'string' ? devalueParse(dataField) : dataField;
} catch (error) {
return {
success: false,
error: `Sweetistics search decode failed: ${error instanceof Error ? error.message : String(error)}`,
};
}
const tweetsSection = parsed?.tweets;
// biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed
const items: any[] = tweetsSection?.items ?? [];
if (items.length === 0) {
return { success: true, tweets: [] };
}
const tweets: SweetisticsTweet[] = items.map((item) => {
const authorUsername = item.authorUsername || item.username || item.full?.author?.username || '';
const authorName = item.authorName || item.full?.author?.name || authorUsername;
const createdAt =
typeof item.createdAt === 'string'
? item.createdAt
: (item.createdAt?.toString?.() ??
(Array.isArray(item.createdAt) && item.createdAt[0] === 'Date' ? item.createdAt[1] : undefined));
const metrics = item.full?.metrics ?? item.full ?? item;
return {
id: String(item.id),
text: String(item.text ?? item.full?.text ?? ''),
author: { username: String(authorUsername), name: String(authorName) },
createdAt,
replyCount: metrics?.replyCount ?? metrics?.replies ?? 0,
retweetCount: metrics?.retweetCount ?? metrics?.retweets ?? 0,
likeCount: metrics?.likeCount ?? metrics?.likes ?? 0,
conversationId: item.conversationId ?? item.full?.conversationId,
inReplyToStatusId: item.full?.inReplyToStatusId ?? undefined,
};
});
return { success: true, tweets };
}
async getCurrentUser(): Promise<SweetisticsUserResult> {
const headers = {
authorization: `Bearer ${this.apiKey}`,
'content-type': 'application/json',
...(this.userAgent ? { 'user-agent': this.userAgent } : {}),
} satisfies Record<string, string>;
let response: Response;
try {
response = await this.fetchWithTimeout(`${this.baseUrl}/api/trpc/user.getCurrent?batch=1`, {
method: 'POST',
headers,
body: JSON.stringify({ 0: { json: null } }),
});
} catch (error) {
return { success: false, error: this.normalizeError(error) };
}
// Some deployments only allow GET for queries; fall back if POST is not allowed
if (response.status === 405) {
try {
response = await this.fetchWithTimeout(
`${this.baseUrl}/api/trpc/user.getCurrent?input=${encodeURIComponent('null')}`,
{
method: 'GET',
headers,
},
);
} catch (error) {
return { success: false, error: this.normalizeError(error) };
}
}
if (!response.ok) {
return { success: false, error: `HTTP ${response.status}` };
}
// biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed
let body: any;
try {
body = await response.json();
} catch (error) {
return {
success: false,
error: `Sweetistics response parse failed: ${error instanceof Error ? error.message : String(error)}`,
};
}
const envelope = Array.isArray(body) ? body[0] : body;
const maybeError = envelope?.error?.message || envelope?.result?.error?.message;
if (maybeError) {
return { success: false, error: String(maybeError) };
}
let data = envelope?.result?.data?.json ?? envelope?.result?.data ?? envelope?.json ?? envelope;
try {
if (typeof data === 'string') {
data = devalueParse(data);
} else if (data && typeof data === 'object' && 'json' in (data as Record<string, unknown>)) {
const inner = (data as Record<string, unknown>).json;
data = typeof inner === 'string' ? devalueParse(inner) : inner;
}
} catch (error) {
return {
success: false,
error: `Sweetistics response decode failed: ${error instanceof Error ? error.message : String(error)}`,
};
}
const user = data && typeof data === 'object' ? data : null;
// biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed
if (!user || typeof (user as any).id !== 'string') {
return { success: false, error: 'Malformed user payload from Sweetistics' };
}
return {
success: true,
user: {
// biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed
id: String((user as any).id),
username:
// biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed
(user as any).username ?? (user as any).twitterUsername ?? null,
// biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed
name: (user as any).name ?? null,
// biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed
email: (user as any).email ?? null,
// biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed
profileImageUrl: (user as any).profileImageUrl ?? null,
},
};
}
private async fetchConversation(
tweetId: string,
options: { excludeRoot: boolean; force?: boolean } = { excludeRoot: false, force: true },
): Promise<SweetisticsTimelineResult> {
const url = new URL(`${this.baseUrl}/api/trpc/tweets.getConversation`);
const input: Record<string, unknown> = { tweetId };
// Always request a fresh conversation to avoid cached Sweetistics data unless explicitly disabled
if (options.force ?? true) {
input.force = 'true';
}
url.searchParams.set('input', JSON.stringify(input));
let response: Response;
try {
response = await this.fetchWithTimeout(url.toString(), {
headers: {
authorization: `Bearer ${this.apiKey}`,
...(this.userAgent ? { 'user-agent': this.userAgent } : {}),
},
});
} catch (error) {
return { success: false, error: this.normalizeError(error) };
}
if (!response.ok) {
return { success: false, error: `HTTP ${response.status}` };
}
let envelope: TrpcResponseEnvelope;
try {
envelope = (await response.json()) as TrpcResponseEnvelope;
} catch (error) {
return {
success: false,
error: `Sweetistics response parse failed: ${error instanceof Error ? error.message : String(error)}`,
};
}
if (!envelope?.result?.data) {
return { success: false, error: 'Missing data in Sweetistics response' };
}
// biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed
let parsed: any;
try {
const raw =
typeof envelope.result.data === 'string' ? devalueParse(envelope.result.data as string) : envelope.result.data;
parsed = raw;
} catch (error) {
return {
success: false,
error: `Sweetistics response decode failed: ${error instanceof Error ? error.message : String(error)}`,
};
}
const tweetIds: string[] = Array.isArray(parsed?.tweetIds) ? parsed.tweetIds.map(String) : [];
// biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed
const tweetsMap: Record<string, any> = parsed?.tweets ?? {};
if (tweetIds.length === 0 || Object.keys(tweetsMap).length === 0) {
return { success: false, error: 'Conversation empty or unavailable' };
}
const rootId = tweetId;
const filteredIds = options.excludeRoot ? tweetIds.filter((id) => id !== rootId) : tweetIds;
const tweets: SweetisticsTweet[] = filteredIds
.map((id) => tweetsMap[id])
.filter(Boolean)
// biome-ignore lint/suspicious/noExplicitAny: Sweetistics responses are loosely typed
.map((item: any) => ({
id: String(item.id),
text: String(item.text ?? ''),
author: {
username: String(item.author?.username ?? ''),
name: String(item.author?.name ?? ''),
},
createdAt: item.createdAt ?? item.timestamp ?? undefined,
replyCount: item.replyCount ?? item.metrics?.replyCount,
retweetCount: item.retweetCount ?? item.metrics?.retweetCount,
likeCount: item.likeCount ?? item.metrics?.likeCount,
conversationId: item.conversationId ?? undefined,
inReplyToStatusId: item.inReplyToStatusId ?? undefined,
}));
return { success: true, tweets };
}
async uploadMedia(input: {
data: string;
mimeType: string;
alt?: string;
}): Promise<{ success: boolean; mediaId?: string; error?: string }> {
try {
const response = await this.fetchWithTimeout(`${this.baseUrl}/api/actions/media/upload`, {
method: 'POST',
headers: {
authorization: `Bearer ${this.apiKey}`,
'content-type': 'application/json',
...(this.userAgent ? { 'user-agent': this.userAgent } : {}),
},
body: JSON.stringify(input),
});
let body: unknown;
try {
body = await response.json();
} catch (error) {
return { success: false, error: this.normalizeError(error) };
}
const mediaId =
typeof (body as { mediaId?: unknown })?.mediaId === 'string'
? (body as { mediaId: string }).mediaId
: undefined;
const errorMessage =
typeof (body as { error?: unknown })?.error === 'string' ? (body as { error: string }).error : undefined;
if (!response.ok || !mediaId) {
return { success: false, error: errorMessage ?? `HTTP ${response.status}` };
}
return { success: true, mediaId };
} catch (error) {
return { success: false, error: this.normalizeError(error) };
}
}
private async fetchWithTimeout(url: string, init?: RequestInit): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(
() => controller.abort(new Error(`Request timed out after ${this.timeoutMs}ms`)),
this.timeoutMs,
);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timeoutId);
}
}
private normalizeError(error: unknown): string {
if (error instanceof Error) {
if ((error as { name?: string }).name === 'AbortError') {
return 'Request timed out';
}
return error.message;
}
return String(error);
}
}
+668 -56
View File
File diff suppressed because it is too large Load Diff
+193
View File
@@ -0,0 +1,193 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
declare const __dirname: string | undefined;
export const FALLBACK_VERSION = 'unknown';
const REF_PREFIX_REGEX = /^ref:\s*/i;
const LINE_SPLIT_REGEX = /\r?\n/;
const GITDIR_REGEX = /gitdir:\s*(.+)\s*$/i;
function readPackageVersionFromJsonFile(candidate: string): string | null {
try {
const raw = fs.readFileSync(candidate, 'utf8');
const json = JSON.parse(raw) as { version?: unknown } | null;
if (json && typeof json.version === 'string' && json.version.trim().length > 0) {
return json.version.trim();
}
return null;
} catch {
return null;
}
}
function readVersionFromTextFile(candidate: string): string | null {
try {
const raw = fs.readFileSync(candidate, 'utf8').trim();
return raw.length > 0 ? raw : null;
} catch {
return null;
}
}
function resolveStartDir(importMetaUrl?: string): string {
if (typeof importMetaUrl === 'string' && importMetaUrl.trim().length > 0) {
try {
return path.dirname(fileURLToPath(importMetaUrl));
} catch {
// ignore
}
}
if (typeof __dirname === 'string' && __dirname.length > 0) {
return __dirname;
}
return process.cwd();
}
export function resolvePackageVersion(importMetaUrl?: string): string {
const injected =
typeof process !== 'undefined' && typeof process.env.BIRD_VERSION === 'string'
? process.env.BIRD_VERSION.trim()
: '';
if (injected.length > 0) {
return injected;
}
let dir = resolveStartDir(importMetaUrl);
for (let i = 0; i < 10; i += 1) {
const version =
readPackageVersionFromJsonFile(path.join(dir, 'package.json')) ??
readVersionFromTextFile(path.join(dir, 'VERSION'));
if (version) {
return version;
}
const parent = path.dirname(dir);
if (parent === dir) {
break;
}
dir = parent;
}
return FALLBACK_VERSION;
}
function truncateSha(sha: string, length: number = 8): string {
const trimmed = sha.trim();
if (!trimmed) {
return '';
}
if (trimmed.length <= length) {
return trimmed;
}
return trimmed.slice(0, length);
}
function resolveGitShaFromGitDir(gitDir: string): string | null {
const headPath = path.join(gitDir, 'HEAD');
let head = '';
try {
head = fs.readFileSync(headPath, 'utf8').trim();
} catch {
return null;
}
if (!head) {
return null;
}
if (!head.startsWith('ref:')) {
const sha = truncateSha(head);
return sha.length > 0 ? sha : null;
}
const ref = head.replace(REF_PREFIX_REGEX, '').trim();
if (!ref) {
return null;
}
const refPath = path.join(gitDir, ref);
try {
const sha = truncateSha(fs.readFileSync(refPath, 'utf8'));
return sha.length > 0 ? sha : null;
} catch {
// fall through to packed-refs
}
const packedRefsPath = path.join(gitDir, 'packed-refs');
try {
const packed = fs.readFileSync(packedRefsPath, 'utf8');
const lines = packed.split(LINE_SPLIT_REGEX);
for (const line of lines) {
if (!line || line.startsWith('#') || line.startsWith('^')) {
continue;
}
const [shaRaw, refName] = line.split(' ');
if (refName?.trim() === ref) {
const sha = truncateSha(shaRaw ?? '');
return sha.length > 0 ? sha : null;
}
}
} catch {
// ignore
}
return null;
}
export function resolveGitSha(importMetaUrl?: string): string | null {
const injected =
typeof process !== 'undefined' && typeof process.env.BIRD_GIT_SHA === 'string'
? process.env.BIRD_GIT_SHA.trim()
: '';
if (injected.length > 0) {
return truncateSha(injected);
}
let dir = resolveStartDir(importMetaUrl);
for (let i = 0; i < 10; i += 1) {
const dotGit = path.join(dir, '.git');
try {
const stat = fs.statSync(dotGit);
if (stat.isDirectory()) {
const sha = resolveGitShaFromGitDir(dotGit);
if (sha) {
return sha;
}
} else if (stat.isFile()) {
const txt = fs.readFileSync(dotGit, 'utf8');
const match = GITDIR_REGEX.exec(txt);
const gitDir = match?.[1]?.trim();
if (gitDir) {
const resolved = path.isAbsolute(gitDir) ? gitDir : path.resolve(dir, gitDir);
const sha = resolveGitShaFromGitDir(resolved);
if (sha) {
return sha;
}
}
}
} catch {
// ignore
}
const parent = path.dirname(dir);
if (parent === dir) {
break;
}
dir = parent;
}
return null;
}
export function formatVersionLine(importMetaUrl?: string): string {
const version = resolvePackageVersion(importMetaUrl);
const sha = resolveGitSha(importMetaUrl);
return sha ? `${version} (${sha})` : version;
}
export function getCliVersion(): string {
return formatVersionLine(import.meta.url);
}
+15 -10
View File
@@ -2,7 +2,19 @@ import { describe, expect, it } from 'vitest';
import { looksLikeTweetInput, resolveCliInvocation } from '../src/lib/cli-args.js'; import { looksLikeTweetInput, resolveCliInvocation } from '../src/lib/cli-args.js';
describe('cli-args', () => { describe('cli-args', () => {
const known = new Set(['tweet', 'reply', 'read', 'replies', 'thread', 'search', 'mentions', 'whoami', 'check']); const known = new Set([
'tweet',
'reply',
'query-ids',
'read',
'replies',
'thread',
'search',
'mentions',
'help',
'whoami',
'check',
]);
it('detects tweet URLs', () => { it('detects tweet URLs', () => {
expect(looksLikeTweetInput('https://x.com/user/status/1234567890')).toBe(true); expect(looksLikeTweetInput('https://x.com/user/status/1234567890')).toBe(true);
@@ -33,15 +45,8 @@ describe('cli-args', () => {
}); });
it('preserves leading options before inferred read command', () => { it('preserves leading options before inferred read command', () => {
const result = resolveCliInvocation(['--engine', 'sweetistics', 'https://x.com/user/status/1234567890'], known); const result = resolveCliInvocation(['--plain', 'https://x.com/user/status/1234567890'], known);
expect(result.argv).toEqual([ expect(result.argv).toEqual(['node', 'bird', '--plain', 'read', 'https://x.com/user/status/1234567890']);
'node',
'bird',
'--engine',
'sweetistics',
'read',
'https://x.com/user/status/1234567890',
]);
}); });
it('does not rewrite when a known command is provided', () => { it('does not rewrite when a known command is provided', () => {
+203 -90
View File
@@ -1,30 +1,30 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock child_process.execSync to prevent actual shell commands type SweetCookieResult = { cookies: Array<{ name: string; value: string; domain?: string }>; warnings: string[] };
vi.mock('node:child_process', () => ({
execSync: vi.fn(() => ''), const sweet = vi.hoisted(() => ({
results: new Map<string, SweetCookieResult>(),
})); }));
// Mock fs to prevent actual file operations vi.mock('@steipete/sweet-cookie', () => ({
vi.mock('node:fs', () => { getCookies: vi.fn(async (options: { browsers?: string[] }) => {
const fs = vi.importActual<typeof import('node:fs')>('node:fs'); const browser = options.browsers?.[0] ?? 'unknown';
return { return (
...fs, sweet.results.get(browser) ?? {
existsSync: vi.fn(() => false), cookies: [],
copyFileSync: vi.fn(), warnings: [],
mkdtempSync: vi.fn(() => '/tmp/test-dir'), }
readdirSync: vi.fn(() => []), );
rmSync: vi.fn(), }),
}; }));
});
describe('cookies', () => { describe('cookies', () => {
const originalEnv = process.env; const originalEnv = process.env;
beforeEach(() => { beforeEach(() => {
vi.resetModules(); vi.resetModules();
sweet.results.clear();
process.env = { ...originalEnv }; process.env = { ...originalEnv };
// Clear Twitter-related env vars
process.env.AUTH_TOKEN = undefined; process.env.AUTH_TOKEN = undefined;
process.env.TWITTER_AUTH_TOKEN = undefined; process.env.TWITTER_AUTH_TOKEN = undefined;
process.env.CT0 = undefined; process.env.CT0 = undefined;
@@ -37,38 +37,71 @@ describe('cookies', () => {
}); });
describe('resolveCredentials', () => { describe('resolveCredentials', () => {
it('uses firefox when enabled and returns cookies', async () => { it('honors cookieSource=firefox even when Safari has cookies', async () => {
sweet.results.set('safari', {
cookies: [
{ name: 'auth_token', value: 'safari_auth', domain: 'x.com' },
{ name: 'ct0', value: 'safari_ct0', domain: 'x.com' },
],
warnings: [],
});
sweet.results.set('firefox', {
cookies: [
{ name: 'auth_token', value: 'firefox_auth', domain: 'x.com' },
{ name: 'ct0', value: 'firefox_ct0', domain: 'x.com' },
],
warnings: [],
});
const { resolveCredentials } = await import('../src/lib/cookies.js'); const { resolveCredentials } = await import('../src/lib/cookies.js');
const fs = await import('node:fs'); const result = await resolveCredentials({ cookieSource: 'firefox' });
// Firefox present with cookies.sqlite
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => {
const lower = path.toLowerCase();
if (lower.endsWith('cookies.sqlite')) return true;
if (lower.includes('firefox')) return true;
return false;
});
(fs.readdirSync as unknown as vi.Mock).mockReturnValue([
{ isDirectory: () => true, name: 'abc.default-release' },
]);
(fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {});
(fs.mkdtempSync as unknown as vi.Mock).mockReturnValue('/tmp/test-dir');
// sqlite3 output for firefox
const { execSync } = await import('node:child_process');
(execSync as unknown as vi.Mock).mockReturnValue('auth_token|firefox_auth\nct0|firefox_ct0');
const result = await resolveCredentials({
allowFirefox: true,
allowChrome: false,
firefoxProfile: 'abc.default-release',
});
expect(result.cookies.authToken).toBe('firefox_auth'); expect(result.cookies.authToken).toBe('firefox_auth');
expect(result.cookies.ct0).toBe('firefox_ct0'); expect(result.cookies.ct0).toBe('firefox_ct0');
expect(result.cookies.source).toContain('Firefox'); expect(result.cookies.source).toContain('Firefox');
}); });
it('honors cookieSource=safari', async () => {
if (process.platform !== 'darwin') {
return;
}
sweet.results.set('safari', {
cookies: [
{ name: 'auth_token', value: 'safari_auth', domain: 'x.com' },
{ name: 'ct0', value: 'safari_ct0', domain: 'x.com' },
],
warnings: [],
});
const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({ cookieSource: 'safari' });
expect(result.cookies.authToken).toBe('safari_auth');
expect(result.cookies.ct0).toBe('safari_ct0');
expect(result.cookies.cookieHeader).toContain('auth_token=safari_auth');
expect(result.cookies.cookieHeader).toContain('ct0=safari_ct0');
expect(result.cookies.source).toBe('Safari');
});
it('uses firefox when enabled and returns cookies', async () => {
sweet.results.set('firefox', {
cookies: [
{ name: 'auth_token', value: 'firefox_auth', domain: 'x.com' },
{ name: 'ct0', value: 'firefox_ct0', domain: 'x.com' },
],
warnings: [],
});
const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({ cookieSource: 'firefox', firefoxProfile: 'abc.default-release' });
expect(result.cookies.authToken).toBe('firefox_auth');
expect(result.cookies.ct0).toBe('firefox_ct0');
expect(result.cookies.cookieHeader).toContain('auth_token=firefox_auth');
expect(result.cookies.cookieHeader).toContain('ct0=firefox_ct0');
expect(result.cookies.source).toContain('Firefox');
});
it('should prioritize CLI arguments over env vars', async () => { it('should prioritize CLI arguments over env vars', async () => {
process.env.AUTH_TOKEN = 'env_auth'; process.env.AUTH_TOKEN = 'env_auth';
process.env.CT0 = 'env_ct0'; process.env.CT0 = 'env_ct0';
@@ -81,6 +114,7 @@ describe('cookies', () => {
expect(result.cookies.authToken).toBe('cli_auth'); expect(result.cookies.authToken).toBe('cli_auth');
expect(result.cookies.ct0).toBe('cli_ct0'); expect(result.cookies.ct0).toBe('cli_ct0');
expect(result.cookies.cookieHeader).toBe('auth_token=cli_auth; ct0=cli_ct0');
expect(result.cookies.source).toBe('CLI argument'); expect(result.cookies.source).toBe('CLI argument');
}); });
@@ -89,7 +123,7 @@ describe('cookies', () => {
process.env.CT0 = 'test_ct0'; process.env.CT0 = 'test_ct0';
const { resolveCredentials } = await import('../src/lib/cookies.js'); const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({ allowFirefox: false, allowChrome: false }); const result = await resolveCredentials({ cookieSource: 'safari' });
expect(result.cookies.authToken).toBe('test_auth_token'); expect(result.cookies.authToken).toBe('test_auth_token');
expect(result.cookies.ct0).toBe('test_ct0'); expect(result.cookies.ct0).toBe('test_ct0');
@@ -101,7 +135,7 @@ describe('cookies', () => {
process.env.TWITTER_CT0 = 'twitter_ct0'; process.env.TWITTER_CT0 = 'twitter_ct0';
const { resolveCredentials } = await import('../src/lib/cookies.js'); const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({ allowFirefox: false, allowChrome: false }); const result = await resolveCredentials({ cookieSource: 'safari' });
expect(result.cookies.authToken).toBe('twitter_auth'); expect(result.cookies.authToken).toBe('twitter_auth');
expect(result.cookies.ct0).toBe('twitter_ct0'); expect(result.cookies.ct0).toBe('twitter_ct0');
@@ -123,7 +157,7 @@ describe('cookies', () => {
process.env.CT0 = ''; process.env.CT0 = '';
const { resolveCredentials } = await import('../src/lib/cookies.js'); const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({ allowFirefox: false, allowChrome: false }); const result = await resolveCredentials({ cookieSource: 'safari' });
expect(result.cookies.authToken).toBeNull(); expect(result.cookies.authToken).toBeNull();
expect(result.cookies.ct0).toBeNull(); expect(result.cookies.ct0).toBeNull();
@@ -132,50 +166,141 @@ describe('cookies', () => {
it('should warn when credentials are missing', async () => { it('should warn when credentials are missing', async () => {
const { resolveCredentials } = await import('../src/lib/cookies.js'); const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({ allowFirefox: false, allowChrome: false }); const result = await resolveCredentials({ cookieSource: 'safari' });
expect(result.warnings).toContain( expect(result.warnings).toContain(
'Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or login to x.com in Chrome/Firefox', 'Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or login to x.com in Safari/Chrome/Firefox',
); );
expect(result.warnings).toContain( expect(result.warnings).toContain(
'Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Chrome/Firefox', 'Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Safari/Chrome/Firefox',
); );
}); });
it('falls back to Chrome when enabled and Firefox disabled', async () => { it('falls back to Chrome when enabled and Firefox disabled', async () => {
const fs = await import('node:fs'); sweet.results.set('chrome', {
const { execSync } = await import('node:child_process'); cookies: [
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => path.includes('Cookies')); { name: 'auth_token', value: 'test_auth', domain: 'x.com' },
(fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {}); { name: 'ct0', value: 'test_ct0', domain: 'x.com' },
(execSync as unknown as vi.Mock).mockImplementation((cmd: string) => { ],
if (cmd.includes('sqlite3')) { warnings: [],
return 'auth_token|746573745f61757468\nct0|746573745f637430';
}
return '';
}); });
const { resolveCredentials } = await import('../src/lib/cookies.js'); const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({ allowFirefox: false, allowChrome: true, chromeProfile: 'Default' }); const result = await resolveCredentials({ cookieSource: 'chrome', chromeProfile: 'Default' });
expect(result.cookies.authToken).toBe('test_auth'); expect(result.cookies.authToken).toBe('test_auth');
expect(result.cookies.ct0).toBe('test_ct0'); expect(result.cookies.ct0).toBe('test_ct0');
expect(result.cookies.source).toContain('Chrome'); expect(result.cookies.source).toContain('Chrome');
}); });
it('uses default browser order when cookieSource is omitted', async () => {
sweet.results.set('safari', { cookies: [], warnings: [] });
sweet.results.set('chrome', { cookies: [], warnings: [] });
sweet.results.set('firefox', {
cookies: [
{ name: 'auth_token', value: 'firefox_auth', domain: 'x.com' },
{ name: 'ct0', value: 'firefox_ct0', domain: 'x.com' },
],
warnings: [],
});
const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({});
expect(result.cookies.authToken).toBe('firefox_auth');
expect(result.cookies.ct0).toBe('firefox_ct0');
expect(result.cookies.source).toContain('Firefox');
});
it('prefers twitter.com cookies when x.com is missing', async () => {
sweet.results.set('chrome', {
cookies: [
{ name: 'auth_token', value: 'twitter_auth', domain: 'twitter.com' },
{ name: 'ct0', value: 'twitter_ct0', domain: 'twitter.com' },
],
warnings: [],
});
const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({ cookieSource: 'chrome' });
expect(result.cookies.authToken).toBe('twitter_auth');
expect(result.cookies.ct0).toBe('twitter_ct0');
});
it('falls back to the first cookie when no domain matches', async () => {
sweet.results.set('firefox', {
cookies: [
{ name: 'auth_token', value: 'first_auth', domain: 'example.com' },
{ name: 'ct0', value: 'first_ct0', domain: 'example.com' },
],
warnings: [],
});
const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({ cookieSource: 'firefox' });
expect(result.cookies.authToken).toBe('first_auth');
expect(result.cookies.ct0).toBe('first_ct0');
});
});
describe('extractCookiesFromSafari', () => {
it('returns cookies from Safari', async () => {
if (process.platform !== 'darwin') {
return;
}
sweet.results.set('safari', {
cookies: [
{ name: 'auth_token', value: 'safari_auth', domain: 'x.com' },
{ name: 'ct0', value: 'safari_ct0', domain: 'x.com' },
],
warnings: [],
});
const { extractCookiesFromSafari } = await import('../src/lib/cookies.js');
const result = await extractCookiesFromSafari();
expect(result.cookies.authToken).toBe('safari_auth');
expect(result.cookies.ct0).toBe('safari_ct0');
expect(result.cookies.source).toBe('Safari');
});
it('prefers Safari over Chrome when both are available', async () => {
if (process.platform !== 'darwin') {
return;
}
sweet.results.set('safari', {
cookies: [
{ name: 'auth_token', value: 'safari_auth', domain: 'x.com' },
{ name: 'ct0', value: 'safari_ct0', domain: 'x.com' },
],
warnings: [],
});
sweet.results.set('chrome', {
cookies: [
{ name: 'auth_token', value: 'chrome_auth', domain: 'x.com' },
{ name: 'ct0', value: 'chrome_ct0', domain: 'x.com' },
],
warnings: [],
});
const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({ cookieSource: ['safari', 'chrome'] });
expect(result.cookies.authToken).toBe('safari_auth');
expect(result.cookies.ct0).toBe('safari_ct0');
});
}); });
describe('extractCookiesFromChrome', () => { describe('extractCookiesFromChrome', () => {
it('returns cookies when sqlite yields hex values', async () => { it('returns cookies when Chrome yields values', async () => {
const fs = await import('node:fs'); sweet.results.set('chrome', {
const { execSync } = await import('node:child_process'); cookies: [
// Pretend Chrome cookie DB exists { name: 'auth_token', value: 'test_auth', domain: 'x.com' },
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => path.includes('Cookies')); { name: 'ct0', value: 'test_ct0', domain: 'x.com' },
(fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {}); ],
// sqlite output with hex strings ("test_auth", "test_ct0") warnings: [],
(execSync as unknown as vi.Mock).mockImplementation((cmd: string) => {
if (cmd.includes('sqlite3')) {
return 'auth_token|746573745f61757468\nct0|746573745f637430';
}
return '';
}); });
const { extractCookiesFromChrome } = await import('../src/lib/cookies.js'); const { extractCookiesFromChrome } = await import('../src/lib/cookies.js');
@@ -187,12 +312,8 @@ describe('cookies', () => {
expect(result.warnings).toHaveLength(0); expect(result.warnings).toHaveLength(0);
}); });
it('warns when Chrome DB exists but contains no cookies', async () => { it('warns when Chrome returns no cookies', async () => {
const fs = await import('node:fs'); sweet.results.set('chrome', { cookies: [], warnings: [] });
const { execSync } = await import('node:child_process');
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => path.includes('Cookies'));
(fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {});
(execSync as unknown as vi.Mock).mockReturnValue('');
const { extractCookiesFromChrome } = await import('../src/lib/cookies.js'); const { extractCookiesFromChrome } = await import('../src/lib/cookies.js');
const result = await extractCookiesFromChrome('Default'); const result = await extractCookiesFromChrome('Default');
@@ -205,6 +326,11 @@ describe('cookies', () => {
describe('extractCookiesFromFirefox', () => { describe('extractCookiesFromFirefox', () => {
it('warns when Firefox cookies database is missing', async () => { it('warns when Firefox cookies database is missing', async () => {
sweet.results.set('firefox', {
cookies: [],
warnings: ['Firefox cookies database not found.'],
});
const { extractCookiesFromFirefox } = await import('../src/lib/cookies.js'); const { extractCookiesFromFirefox } = await import('../src/lib/cookies.js');
const result = await extractCookiesFromFirefox('missing-profile'); const result = await extractCookiesFromFirefox('missing-profile');
@@ -213,21 +339,8 @@ describe('cookies', () => {
expect(result.warnings).toContain('Firefox cookies database not found.'); expect(result.warnings).toContain('Firefox cookies database not found.');
}); });
it('warns when Firefox DB exists but contains no cookies', async () => { it('warns when Firefox returns no cookies', async () => {
const fs = await import('node:fs'); sweet.results.set('firefox', { cookies: [], warnings: [] });
const { execSync } = await import('node:child_process');
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => {
const lower = path.toLowerCase();
if (lower.endsWith('cookies.sqlite')) return true;
if (lower.includes('firefox')) return true;
return false;
});
(fs.readdirSync as unknown as vi.Mock).mockReturnValue([
{ isDirectory: () => true, name: 'abc.default-release' },
]);
(fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {});
(fs.mkdtempSync as unknown as vi.Mock).mockReturnValue('/tmp/test-dir');
(execSync as unknown as vi.Mock).mockReturnValue('');
const { extractCookiesFromFirefox } = await import('../src/lib/cookies.js'); const { extractCookiesFromFirefox } = await import('../src/lib/cookies.js');
const result = await extractCookiesFromFirefox('abc.default-release'); const result = await extractCookiesFromFirefox('abc.default-release');
-22
View File
@@ -1,22 +0,0 @@
import { describe, expect, it } from 'vitest';
import { resolveEngineMode, shouldUseSweetistics } from '../src/lib/engine.js';
describe('engine', () => {
it('normalizes engine values and defaults to auto', () => {
expect(resolveEngineMode()).toBe('auto');
expect(resolveEngineMode('graphql')).toBe('graphql');
expect(resolveEngineMode('sweetistics')).toBe('sweetistics');
expect(resolveEngineMode('AUTO')).toBe('auto');
expect(resolveEngineMode('nope')).toBe('auto');
});
it('uses GraphQL as primary in auto mode', () => {
expect(shouldUseSweetistics('auto', false)).toBe(false);
expect(shouldUseSweetistics('auto', true)).toBe(false);
});
it('forces engine selection when explicitly set', () => {
expect(shouldUseSweetistics('sweetistics', false)).toBe(true);
expect(shouldUseSweetistics('graphql', true)).toBe(false);
});
});
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { mentionsQueryFromUserOption, normalizeHandle } from '../src/lib/normalize-handle.js';
const INVALID_HANDLE_REGEX = /Invalid --user handle/;
describe('normalizeHandle', () => {
it('accepts bare handle', () => {
expect(normalizeHandle('steipete')).toBe('steipete');
});
it('accepts @handle and trims whitespace', () => {
expect(normalizeHandle(' @steipete ')).toBe('steipete');
});
it('rejects empty input', () => {
expect(normalizeHandle('')).toBeNull();
expect(normalizeHandle(' ')).toBeNull();
expect(normalizeHandle(null)).toBeNull();
});
it('rejects invalid characters and too-long handles', () => {
expect(normalizeHandle('@stei-pete')).toBeNull();
expect(normalizeHandle('@steipete!')).toBeNull();
expect(normalizeHandle('a'.repeat(16))).toBeNull();
});
});
describe('mentionsQueryFromUserOption', () => {
it('returns null query when option omitted', () => {
expect(mentionsQueryFromUserOption(undefined)).toEqual({ query: null, error: null });
});
it('returns normalized @query for valid handle', () => {
expect(mentionsQueryFromUserOption('@steipete')).toEqual({ query: '@steipete', error: null });
expect(mentionsQueryFromUserOption(' steipete ')).toEqual({ query: '@steipete', error: null });
});
it('returns error for invalid handle', () => {
const result = mentionsQueryFromUserOption('@stei-pete');
expect(result.query).toBeNull();
expect(result.error).toMatch(INVALID_HANDLE_REGEX);
});
});
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import {
formatStatsLine,
formatTweetUrlLine,
labelPrefix,
resolveOutputConfigFromArgv,
resolveOutputConfigFromCommander,
statusPrefix,
} from '../src/lib/output.js';
describe('output', () => {
it('defaults to emoji + color on TTY', () => {
const cfg = resolveOutputConfigFromArgv([], {}, true);
expect(cfg).toEqual({ plain: false, emoji: true, color: true });
});
it('plain disables emoji + color', () => {
const cfg = resolveOutputConfigFromArgv(['--plain'], {}, true);
expect(cfg).toEqual({ plain: true, emoji: false, color: false });
expect(statusPrefix('ok', cfg)).toBe('[ok] ');
expect(labelPrefix('url', cfg)).toBe('url: ');
});
it('NO_COLOR disables colors by default', () => {
const cfg = resolveOutputConfigFromArgv([], { NO_COLOR: '1' }, true);
expect(cfg.color).toBe(false);
});
it('TERM=dumb disables colors by default', () => {
const cfg = resolveOutputConfigFromArgv([], { TERM: 'dumb' }, true);
expect(cfg.color).toBe(false);
});
it('--no-color disables colors', () => {
const cfg = resolveOutputConfigFromArgv(['--no-color'], {}, true);
expect(cfg).toEqual({ plain: false, emoji: true, color: false });
});
it('--no-emoji switches to text prefixes', () => {
const cfg = resolveOutputConfigFromArgv(['--no-emoji'], {}, true);
expect(cfg.emoji).toBe(false);
expect(statusPrefix('warn', cfg)).toBe('Warning: ');
});
it('commander opts override defaults', () => {
const cfg = resolveOutputConfigFromCommander({ emoji: false, color: false }, {}, true);
expect(cfg).toEqual({ plain: false, emoji: false, color: false });
expect(statusPrefix('info', cfg)).toBe('Info: ');
expect(labelPrefix('date', cfg)).toBe('Date: ');
});
it('commander plain wins over emoji/color', () => {
const cfg = resolveOutputConfigFromCommander({ plain: true, emoji: true, color: true }, {}, true);
expect(cfg).toEqual({ plain: true, emoji: false, color: false });
});
it('formats stats line for all modes', () => {
const stats = { likeCount: null, retweetCount: undefined, replyCount: 2 };
expect(formatStatsLine(stats, { plain: true, emoji: false, color: false })).toBe(
'likes: 0 retweets: 0 replies: 2',
);
expect(formatStatsLine(stats, { plain: false, emoji: false, color: false })).toBe('Likes 0 Retweets 0 Replies 2');
expect(formatStatsLine(stats, { plain: false, emoji: true, color: false })).toBe('❤️ 0 🔁 0 💬 2');
});
it('always includes tweet URL in all modes', () => {
const id = '1234567890';
const url = `https://x.com/i/status/${id}`;
expect(formatTweetUrlLine(id, { plain: true, emoji: false, color: false })).toContain(url);
expect(formatTweetUrlLine(id, { plain: false, emoji: false, color: false })).toContain(url);
expect(formatTweetUrlLine(id, { plain: false, emoji: true, color: false })).toContain(url);
});
});
+143
View File
@@ -0,0 +1,143 @@
import { randomUUID } from 'node:crypto';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createRuntimeQueryIdStore } from '../src/lib/runtime-query-ids.js';
describe('runtime-query-ids', () => {
afterEach(() => {
vi.unstubAllGlobals();
delete process.env.BIRD_QUERY_IDS_CACHE;
});
it('refreshes IDs by scanning discovered bundles and persists cache', async () => {
const cacheDir = path.join(os.tmpdir(), `bird-test-${randomUUID()}`);
await mkdir(cacheDir, { recursive: true });
const cachePath = path.join(cacheDir, 'query-ids.json');
const html = `
<html>
<script src="https://abs.twimg.com/responsive-web/client-web/main.test.js"></script>
</html>
`;
const js = [
`e.exports={queryId:"AAA",operationName:"CreateTweet"}`,
`e.exports={queryId:"BBB",operationName:"CreateRetweet"}`,
`e.exports={queryId:"CCC",operationName:"FavoriteTweet"}`,
`e.exports={queryId:"DDD",operationName:"TweetDetail"}`,
`e.exports={queryId:"EEE",operationName:"SearchTimeline"}`,
`e.exports={queryId:"FFF",operationName:"UserArticlesTweets"}`,
].join('\n');
const fetchMock = vi.fn(async (url: string | URL) => {
const asString = String(url);
if (asString.startsWith('https://x.com/')) {
return new Response(html, { status: 200 });
}
if (asString.startsWith('https://abs.twimg.com/')) {
return new Response(js, { status: 200 });
}
return new Response('not found', { status: 404 });
});
vi.stubGlobal('fetch', fetchMock);
const store = createRuntimeQueryIdStore({
cachePath,
ttlMs: 1000,
fetchImpl: fetchMock as unknown as typeof fetch,
});
const info = await store.refresh(
['CreateTweet', 'CreateRetweet', 'FavoriteTweet', 'TweetDetail', 'SearchTimeline', 'UserArticlesTweets'],
{ force: true },
);
expect(info?.snapshot.ids.CreateTweet).toBe('AAA');
expect(info?.snapshot.ids.TweetDetail).toBe('DDD');
expect(info?.cachePath).toBe(cachePath);
const raw = await readFile(cachePath, 'utf8');
const parsed = JSON.parse(raw) as { ids?: Record<string, string> };
expect(parsed.ids?.SearchTimeline).toBe('EEE');
});
it('uses env cache path override and returns fresh snapshot without refreshing', async () => {
const cacheDir = path.join(os.tmpdir(), `bird-test-${randomUUID()}`);
await mkdir(cacheDir, { recursive: true });
const cachePath = path.join(cacheDir, 'query-ids-cache.json');
process.env.BIRD_QUERY_IDS_CACHE = cachePath;
const snapshot = {
fetchedAt: new Date().toISOString(),
ttlMs: 60_000,
ids: { CreateTweet: 'AAA' },
discovery: { pages: ['https://x.com/'], bundles: ['main.js'] },
};
await writeFile(cachePath, JSON.stringify(snapshot), 'utf8');
const fetchMock = vi.fn();
const store = createRuntimeQueryIdStore({ fetchImpl: fetchMock as unknown as typeof fetch });
const info = await store.refresh(['CreateTweet']);
expect(store.cachePath).toBe(cachePath);
expect(info?.snapshot.ids.CreateTweet).toBe('AAA');
expect(fetchMock).not.toHaveBeenCalled();
store.clearMemory();
});
it('returns current snapshot when refresh finds no matching operations', async () => {
const cacheDir = path.join(os.tmpdir(), `bird-test-${randomUUID()}`);
await mkdir(cacheDir, { recursive: true });
const cachePath = path.join(cacheDir, 'query-ids.json');
const snapshot = {
fetchedAt: new Date(0).toISOString(),
ttlMs: 1,
ids: { CreateTweet: 'AAA' },
discovery: { pages: ['https://x.com/'], bundles: ['main.test.js'] },
};
await writeFile(cachePath, JSON.stringify(snapshot), 'utf8');
const html = `<script src="https://abs.twimg.com/responsive-web/client-web/main.test.js"></script>`;
const js = 'console.log("no ops here")';
const fetchMock = vi.fn(async (url: string | URL) => {
const asString = String(url);
if (asString.startsWith('https://x.com/')) {
return new Response(html, { status: 200 });
}
if (asString.startsWith('https://abs.twimg.com/')) {
return new Response(js, { status: 200 });
}
return new Response('not found', { status: 404 });
});
const store = createRuntimeQueryIdStore({
cachePath,
ttlMs: 1,
fetchImpl: fetchMock as unknown as typeof fetch,
});
const info = await store.refresh(['CreateTweet']);
expect(info?.snapshot.ids.CreateTweet).toBe('AAA');
});
it('throws when no bundles can be discovered', async () => {
const cacheDir = path.join(os.tmpdir(), `bird-test-${randomUUID()}`);
await mkdir(cacheDir, { recursive: true });
const cachePath = path.join(cacheDir, 'query-ids.json');
const fetchMock = vi.fn(async () => new Response('nope', { status: 500 }));
const store = createRuntimeQueryIdStore({
cachePath,
fetchImpl: fetchMock as unknown as typeof fetch,
});
await expect(store.refresh(['CreateTweet'], { force: true })).rejects.toThrow(
'No client bundles discovered; x.com layout may have changed.',
);
});
});
-277
View File
@@ -1,277 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SweetisticsClient } from '../src/lib/sweetistics-client.js';
describe('SweetisticsClient', () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
vi.resetAllMocks();
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('posts tweet with bearer token and reply id', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ success: true, tweetId: '123' }),
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' });
const result = await client.tweet('hello world', '456');
expect(result.success).toBe(true);
expect(result.tweetId).toBe('123');
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('https://api.example.com/api/actions/tweet');
expect((init as RequestInit).headers).toMatchObject({ authorization: 'Bearer sweet-test' });
expect(JSON.parse((init as RequestInit).body as string)).toEqual({ text: 'hello world', replyToTweetId: '456' });
});
it('returns error when API responds with failure', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 401,
json: async () => ({ success: false, error: 'Unauthorized' }),
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' });
const result = await client.tweet('test');
expect(result.success).toBe(false);
expect(result.error).toContain('Unauthorized');
});
it('uploads media and passes mediaIds into tweet', async () => {
const fetchMock = vi
.fn()
// upload
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ mediaId: 'mid-1' }),
})
// tweet
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ success: true, tweetId: '789' }),
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' });
const uploadRes = await client.uploadMedia({ data: 'Zm9v', mimeType: 'image/png', alt: 'foo' });
expect(uploadRes.success).toBe(true);
expect(uploadRes.mediaId).toBe('mid-1');
const tweetRes = await client.tweet('hi', undefined, ['mid-1']);
expect(tweetRes.success).toBe(true);
// First call: upload; second call: tweet with mediaIds
const [, tweetInit] = fetchMock.mock.calls[1];
expect(JSON.parse((tweetInit as RequestInit).body as string)).toMatchObject({ mediaIds: ['mid-1'] });
});
it('reads a tweet', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
id: '1',
text: 'hi',
author: { username: 'u', name: 'User' },
metrics: { likeCount: 5 },
}),
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' });
const result = await client.read('1');
expect(result.success).toBe(true);
expect(result.tweet?.id).toBe('1');
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('https://api.example.com/api/tweets/1');
expect((init as RequestInit).headers).toMatchObject({ authorization: 'Bearer sweet-test' });
});
it('fetches replies', async () => {
const conversationPayload = {
tweetIds: ['1', '2'],
tweets: {
1: {
id: '1',
text: 'root',
author: { username: 'root', name: 'Root' },
conversationId: 'c1',
},
2: {
id: '2',
text: 'reply',
author: { username: 'reply', name: 'Reply' },
conversationId: 'c1',
inReplyToStatusId: '1',
},
},
};
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
result: { data: conversationPayload },
}),
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' });
const result = await client.replies('1');
expect(result.success).toBe(true);
expect(result.tweets?.[0].id).toBe('2');
const [url, init] = fetchMock.mock.calls[0];
expect(url).toContain('/api/trpc/tweets.getConversation');
const params = new URL(url as string).searchParams.get('input');
expect(params).toBe(JSON.stringify({ tweetId: '1', force: 'true' }));
expect((init as RequestInit).headers).toMatchObject({ authorization: 'Bearer sweet-test' });
});
it('fetches thread', async () => {
const conversationPayload = {
tweetIds: ['1', '2'],
tweets: {
1: { id: '1', text: 'root', author: { username: 'root', name: 'Root' }, conversationId: 'c1' },
2: { id: '2', text: 'reply', author: { username: 'reply', name: 'Reply' }, conversationId: 'c1' },
},
};
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
result: { data: conversationPayload },
}),
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' });
const result = await client.thread('1');
expect(result.success).toBe(true);
expect(result.tweets?.length).toBe(2);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toContain('/api/trpc/tweets.getConversation');
const params = new URL(url as string).searchParams.get('input');
expect(params).toBe(JSON.stringify({ tweetId: '1', force: 'true' }));
expect((init as RequestInit).headers).toMatchObject({ authorization: 'Bearer sweet-test' });
});
it('searches tweets', async () => {
const searchPayload = {
tweets: {
items: [
{
id: '3',
text: 'needle',
authorUsername: 'n',
authorName: 'Needle',
createdAt: '2024-01-01',
metrics: { replyCount: 1, retweetCount: 2, likeCount: 3 },
},
],
},
};
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => [{ result: { data: searchPayload } }],
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' });
const result = await client.search('needle', 5);
expect(result.success).toBe(true);
expect(result.tweets?.[0].id).toBe('3');
const [url, init] = fetchMock.mock.calls[0];
expect(url).toContain('/api/trpc/search.execute');
expect((init as RequestInit).headers).toMatchObject({ authorization: 'Bearer sweet-test' });
});
it('fetches current user', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => [{ result: { data: { json: { id: 'u1', username: 'tester', name: 'Test User' } } } }],
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' });
const result = await client.getCurrentUser();
expect(result.success).toBe(true);
expect(result.user?.id).toBe('u1');
expect(result.user?.username).toBe('tester');
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('https://api.example.com/api/trpc/user.getCurrent?batch=1');
expect((init as RequestInit).headers).toMatchObject({ authorization: 'Bearer sweet-test' });
});
it('falls back to GET when POST is not allowed', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce({ ok: false, status: 405, json: async () => ({}) })
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ result: { data: { json: { id: 'u2', username: 'fallback' } } } }),
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' });
const result = await client.getCurrentUser();
expect(result.success).toBe(true);
expect(result.user?.id).toBe('u2');
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[1][0]).toContain('input=null');
});
it('propagates Sweetistics user errors', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => [{ result: { error: { message: 'bad key' } } }],
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' });
const result = await client.getCurrentUser();
expect(result.success).toBe(false);
expect(result.error).toBe('bad key');
});
it('propagates Sweetistics error message', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 500,
json: async () => [{ error: { message: 'boom' } }],
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' });
const result = await client.search('needle', 1);
expect(result.success).toBe(false);
expect(result.error).toBe('boom');
});
});
+207
View File
@@ -0,0 +1,207 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
const validCookies = {
authToken: 'test_auth_token',
ct0: 'test_ct0_token',
cookieHeader: 'auth_token=test_auth_token; ct0=test_ct0_token',
source: 'test',
};
type ResponseLike = {
ok: boolean;
status: number;
json: () => Promise<unknown>;
text: () => Promise<string>;
};
type TwitterClientApiPrivate = TwitterClient & {
getBookmarksQueryIds: () => Promise<string[]>;
};
const makeResponse = (overrides: Partial<ResponseLike> = {}): ResponseLike => ({
ok: true,
status: 200,
json: async (): Promise<unknown> => ({}),
text: async (): Promise<string> => '',
...overrides,
});
describe('TwitterClient API coverage', () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('createTweet fallback paths', () => {
it('returns an error when retry response is not ok', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' }))
.mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' }))
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'boom' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.tweet('hi');
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
it('returns API errors when retry response contains errors', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' }))
.mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' }))
.mockResolvedValueOnce(
makeResponse({
json: async () => ({ errors: [{ message: 'rate limited', code: 1 }] }),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.tweet('hi');
expect(result.success).toBe(false);
expect(result.error).toContain('rate limited');
});
it('returns an error when retry response has no tweet id', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' }))
.mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' }))
.mockResolvedValueOnce(
makeResponse({
json: async () => ({ data: { create_tweet: { tweet_results: { result: {} } } } }),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.tweet('hi');
expect(result.success).toBe(false);
expect(result.error).toContain('no ID');
});
});
describe('search error paths', () => {
it('returns an error for non-ok responses', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.search('test');
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
it('returns errors from payloads', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
makeResponse({
json: async () => ({ errors: [{ message: 'bad' }] }),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.search('test');
expect(result.success).toBe(false);
expect(result.error).toBe('bad');
});
it('returns an error when fetching throws', async () => {
const mockFetch = vi.fn().mockRejectedValue(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.search('test');
expect(result.success).toBe(false);
expect(result.error).toContain('boom');
});
it('returns the second attempt error after 404s', async () => {
const mockFetch = vi.fn().mockResolvedValue(makeResponse({ ok: false, status: 404, text: async () => 'nope' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.search('test');
expect(result.success).toBe(false);
expect(result.error).toBe('HTTP 404');
});
});
describe('bookmarks error paths', () => {
it('returns an error for non-ok responses', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarks(1);
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
it('returns API errors from payloads', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
makeResponse({
json: async () => ({ errors: [{ message: 'bad' }] }),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarks(1);
expect(result.success).toBe(false);
expect(result.error).toBe('bad');
});
it('returns an error when fetching throws', async () => {
const mockFetch = vi.fn().mockRejectedValue(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarks(1);
expect(result.success).toBe(false);
expect(result.error).toContain('boom');
});
it('returns unknown error when no query ids are available', async () => {
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getBookmarksQueryIds = async () => [];
const result = await client.getBookmarks(1);
expect(result.success).toBe(false);
expect(result.error).toBe('Unknown error fetching bookmarks');
});
it('returns the second attempt error after 404s', async () => {
const mockFetch = vi.fn().mockResolvedValue(makeResponse({ ok: false, status: 404, text: async () => 'nope' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarks(1);
expect(result.success).toBe(false);
expect(result.error).toBe('HTTP 404');
});
});
});
+248
View File
@@ -0,0 +1,248 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { runtimeQueryIds } from '../src/lib/runtime-query-ids.js';
import { TwitterClient } from '../src/lib/twitter-client.js';
const validCookies = {
authToken: 'test_auth_token',
ct0: 'test_ct0_token',
cookieHeader: 'auth_token=test_auth_token; ct0=test_ct0_token',
source: 'test',
};
type ResponseLike = {
ok: boolean;
status: number;
json: () => Promise<unknown>;
text: () => Promise<string>;
};
type TwitterClientPrivate = TwitterClient & {
refreshQueryIds: () => Promise<void>;
fetchWithTimeout: (url: string, init: RequestInit) => Promise<ResponseLike>;
ensureClientUserId: () => Promise<void>;
getBaseHeaders: () => Record<string, string>;
fetchUserArticlePlainText: (userId: string, tweetId: string) => Promise<{ title?: string; plainText?: string }>;
fetchTweetDetail: (tweetId: string) => Promise<{ success: true } | { success: false; error: string }>;
postStatusUpdate: (input: { text: string }) => Promise<{ success: boolean; error?: string }>;
};
const makeResponse = (overrides: Partial<ResponseLike> = {}): ResponseLike => ({
ok: true,
status: 200,
json: async (): Promise<unknown> => ({}),
text: async (): Promise<string> => '',
...overrides,
});
describe('TwitterClient core coverage', () => {
const originalFetch = global.fetch;
const originalNodeEnv = process.env.NODE_ENV;
afterEach(() => {
global.fetch = originalFetch;
process.env.NODE_ENV = originalNodeEnv;
delete process.env.BIRD_DEBUG_ARTICLE;
vi.restoreAllMocks();
});
it('refreshQueryIds calls runtime refresh outside test env', async () => {
const client = new TwitterClient({ cookies: validCookies });
const refreshSpy = vi.spyOn(runtimeQueryIds, 'refresh').mockResolvedValue(null);
process.env.NODE_ENV = 'production';
const clientPrivate = client as unknown as TwitterClientPrivate;
await clientPrivate.refreshQueryIds();
expect(refreshSpy).toHaveBeenCalled();
});
it('fetchWithTimeout uses an abort signal when timeout is set', async () => {
const mockFetch = vi.fn(async (_url: string, init?: RequestInit) => {
expect(init?.signal).toBeInstanceOf(AbortSignal);
return makeResponse();
});
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies, timeoutMs: 5 });
const clientPrivate = client as unknown as TwitterClientPrivate;
await clientPrivate.fetchWithTimeout('https://example.com', { method: 'GET' });
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('returns a not found error when tweet data is missing', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
makeResponse({
json: async () => ({
data: {
threaded_conversation_with_injections_v2: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'other',
legacy: { full_text: 'nope', created_at: '2024-01-01T00:00:00Z' },
core: { user_results: { result: { legacy: { screen_name: 'user', name: 'User' } } } },
},
},
},
},
},
],
},
],
},
},
}),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getTweet('123');
expect(result.success).toBe(false);
expect(result.error).toBe('Tweet not found in response');
});
it('adds client user id header after ensureClientUserId', async () => {
const client = new TwitterClient({ cookies: validCookies });
process.env.NODE_ENV = 'production';
vi.spyOn(client, 'getCurrentUser').mockResolvedValue({
success: true,
user: { id: '42', username: 'tester', name: 'Tester' },
});
const clientPrivate = client as unknown as TwitterClientPrivate;
await clientPrivate.ensureClientUserId();
const headers = clientPrivate.getBaseHeaders();
expect(headers['x-twitter-client-user-id']).toBe('42');
});
describe('fetchUserArticlePlainText failures', () => {
it('returns empty object when response is not ok', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(makeResponse({ ok: false, status: 500 }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.fetchUserArticlePlainText('user', 'tweet');
expect(result).toEqual({});
});
it('returns empty object when fetching throws', async () => {
const mockFetch = vi.fn().mockRejectedValueOnce(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.fetchUserArticlePlainText('user', 'tweet');
expect(result).toEqual({});
});
it('returns empty object when no matching tweet is found', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
makeResponse({
json: async () => ({
data: {
user: { result: { timeline: { timeline: { instructions: [] } } } },
},
}),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.fetchUserArticlePlainText('user', 'tweet');
expect(result).toEqual({});
});
});
describe('fetchTweetDetail error handling', () => {
it('returns API errors from response payloads', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
makeResponse({
json: async () => ({ errors: [{ message: 'bad news' }] }),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.fetchTweetDetail('1');
expect(result.success).toBe(false);
expect(result.error).toBe('bad news');
});
it('parses POST responses when GET returns 404', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' }))
.mockResolvedValueOnce(
makeResponse({
json: async () => ({
data: {
tweetResult: {
result: {
rest_id: '1',
legacy: {
full_text: 'hi',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
},
core: { user_results: { result: { legacy: { screen_name: 'user', name: 'User' } } } },
},
},
},
}),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.fetchTweetDetail('1');
expect(result.success).toBe(true);
});
it('returns an error when fetch throws', async () => {
const mockFetch = vi.fn().mockRejectedValueOnce(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.fetchTweetDetail('1');
expect(result.success).toBe(false);
expect(result.error).toContain('boom');
});
});
describe('postStatusUpdate error handling', () => {
it('returns an error when postStatusUpdate throws', async () => {
const mockFetch = vi.fn().mockRejectedValueOnce(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.postStatusUpdate({ text: 'hello' });
expect(result.success).toBe(false);
expect(result.error).toContain('boom');
});
});
});
@@ -0,0 +1,224 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
const validCookies = {
authToken: 'test_auth_token',
ct0: 'test_ct0_token',
cookieHeader: 'auth_token=test_auth_token; ct0=test_ct0_token',
source: 'test',
};
type ResponseLike = {
ok: boolean;
status: number;
json: () => Promise<unknown>;
text: () => Promise<string>;
};
type TwitterClientUploadPrivate = TwitterClient & {
sleep: (ms: number) => Promise<void>;
extractArticleText: (result: unknown) => string | undefined;
};
const makeResponse = (overrides: Partial<ResponseLike> = {}): ResponseLike => ({
ok: true,
status: 200,
json: async (): Promise<unknown> => ({}),
text: async (): Promise<string> => '',
...overrides,
});
describe('TwitterClient upload coverage', () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
delete process.env.BIRD_DEBUG_ARTICLE;
vi.restoreAllMocks();
});
describe('uploadMedia error paths', () => {
it('rejects unsupported media types', async () => {
global.fetch = vi.fn() as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.uploadMedia({ data: new Uint8Array([1, 2, 3]), mimeType: 'application/pdf' });
expect(result.success).toBe(false);
expect(global.fetch).not.toHaveBeenCalled();
});
it('returns an error when INIT fails', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'nope' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png' });
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
it('returns an error when APPEND fails', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ json: async () => ({ media_id_string: '1' }) }))
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'nope' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png' });
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
it('returns an error when FINALIZE fails', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ json: async () => ({ media_id_string: '1' }) }))
.mockResolvedValueOnce(makeResponse())
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'nope' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png' });
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
it('returns an error when media processing fails', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ json: async () => ({ media_id_string: '1' }) }))
.mockResolvedValueOnce(makeResponse())
.mockResolvedValueOnce(
makeResponse({
json: async () => ({ processing_info: { state: 'failed', error: { message: 'processing failed' } } }),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'video/mp4' });
expect(result.success).toBe(false);
expect(result.error).toBe('processing failed');
});
it('returns an error when STATUS fails', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ json: async () => ({ media_id_string: '1' }) }))
.mockResolvedValueOnce(makeResponse())
.mockResolvedValueOnce(
makeResponse({
json: async () => ({ processing_info: { state: 'pending', check_after_secs: 0 } }),
}),
)
.mockResolvedValueOnce(makeResponse({ ok: false, status: 503, text: async () => 'down' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientUploadPrivate;
clientPrivate.sleep = vi.fn().mockResolvedValue(undefined);
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'video/mp4' });
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 503');
});
it('retries STATUS and continues when processing is pending', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ json: async () => ({ media_id_string: '1' }) }))
.mockResolvedValueOnce(makeResponse())
.mockResolvedValueOnce(
makeResponse({
json: async () => ({ processing_info: { state: 'pending', check_after_secs: 0 } }),
}),
)
.mockResolvedValueOnce(
makeResponse({
json: async () => ({ processing_info: { state: 'pending', check_after_secs: 0 } }),
}),
)
.mockResolvedValueOnce(makeResponse({ json: async () => ({ processing_info: { state: 'succeeded' } }) }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientUploadPrivate;
clientPrivate.sleep = vi.fn().mockResolvedValue(undefined);
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'video/mp4' });
expect(result.success).toBe(true);
});
it('returns an error when metadata upload fails', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ json: async () => ({ media_id_string: '1' }) }))
.mockResolvedValueOnce(makeResponse())
.mockResolvedValueOnce(makeResponse({ json: async () => ({}) }))
.mockResolvedValueOnce(makeResponse({ ok: false, status: 400, text: async () => 'bad' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png', alt: 'alt' });
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 400');
});
it('returns an error when upload throws', async () => {
const mockFetch = vi.fn().mockRejectedValueOnce(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png' });
expect(result.success).toBe(false);
expect(result.error).toContain('boom');
});
});
describe('article extraction edge cases', () => {
it('logs article payloads when debug flag is set', () => {
const client = new TwitterClient({ cookies: validCookies });
process.env.BIRD_DEBUG_ARTICLE = '1';
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const clientPrivate = client as unknown as TwitterClientUploadPrivate;
const result = clientPrivate.extractArticleText({
rest_id: '1',
article: {
title: 'Title',
plain_text: 'Body',
article_results: { result: { title: 'Title', plain_text: 'Body' } },
},
});
expect(result).toBe('Title\n\nBody');
expect(errorSpy).toHaveBeenCalled();
});
it('drops duplicate body when it matches the title', () => {
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientUploadPrivate;
const result = clientPrivate.extractArticleText({
rest_id: '1',
article: {
title: 'Same',
plain_text: 'Same',
article_results: { result: { title: 'Same', plain_text: 'Same' } },
},
});
expect(result).toBe('Same');
});
});
});
+481 -2
View File
@@ -6,6 +6,7 @@ describe('TwitterClient', () => {
const validCookies = { const validCookies = {
authToken: 'test_auth_token', authToken: 'test_auth_token',
ct0: 'test_ct0_token', ct0: 'test_ct0_token',
cookieHeader: 'auth_token=test_auth_token; ct0=test_ct0_token',
source: 'test', source: 'test',
}; };
@@ -19,7 +20,7 @@ describe('TwitterClient', () => {
expect( expect(
() => () =>
new TwitterClient({ new TwitterClient({
cookies: { authToken: null, ct0: 'test', source: null }, cookies: { authToken: null, ct0: 'test', cookieHeader: null, source: null },
}), }),
).toThrow('Both authToken and ct0 cookies are required'); ).toThrow('Both authToken and ct0 cookies are required');
}); });
@@ -28,7 +29,7 @@ describe('TwitterClient', () => {
expect( expect(
() => () =>
new TwitterClient({ new TwitterClient({
cookies: { authToken: 'test', ct0: null, source: null }, cookies: { authToken: 'test', ct0: null, cookieHeader: null, source: null },
}), }),
).toThrow('Both authToken and ct0 cookies are required'); ).toThrow('Both authToken and ct0 cookies are required');
}); });
@@ -79,6 +80,79 @@ describe('TwitterClient', () => {
const body = JSON.parse(options.body); const body = JSON.parse(options.body);
expect(body.variables.tweet_text).toBe('Hello world!'); expect(body.variables.tweet_text).toBe('Hello world!');
expect(body.features.rweb_video_screen_enabled).toBe(true);
expect(body.features.creator_subscriptions_tweet_preview_api_enabled).toBe(true);
});
it('supports attaching media IDs', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
create_tweet: {
tweet_results: {
result: {
rest_id: '1234567890',
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.tweet('Hello world!', ['111', '222']);
expect(result.success).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(1);
const [, options] = mockFetch.mock.calls[0];
const body = JSON.parse(options.body);
expect(body.variables.media.media_entities).toEqual([
{ media_id: '111', tagged_users: [] },
{ media_id: '222', tagged_users: [] },
]);
});
it('retries CreateTweet via /i/api/graphql when operation URL 404s', async () => {
mockFetch
.mockResolvedValueOnce({
ok: false,
status: 404,
text: async () => '',
})
.mockResolvedValueOnce({
ok: false,
status: 404,
text: async () => '',
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
create_tweet: {
tweet_results: {
result: {
rest_id: '1234567890',
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.tweet('Hello world!');
expect(result.success).toBe(true);
expect(result.tweetId).toBe('1234567890');
expect(mockFetch).toHaveBeenCalledTimes(3);
const [firstUrl] = mockFetch.mock.calls[0];
const [thirdUrl] = mockFetch.mock.calls[2];
expect(String(firstUrl)).toContain('/CreateTweet');
expect(String(thirdUrl)).toBe('https://x.com/i/api/graphql');
}); });
it('should handle API errors', async () => { it('should handle API errors', async () => {
@@ -96,6 +170,118 @@ describe('TwitterClient', () => {
expect(result.error).toContain('Rate limit exceeded'); expect(result.error).toContain('Rate limit exceeded');
}); });
it('falls back to statuses/update.json when CreateTweet returns code 226', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: async () => ({
errors: [
{
message: 'Authorization: This request looks like it might be automated.',
code: 226,
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
id_str: '1234567890',
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.tweet('Hello world!');
expect(result.success).toBe(true);
expect(result.tweetId).toBe('1234567890');
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(String(mockFetch.mock.calls[1][0])).toContain('statuses/update.json');
});
it('surfaces statuses/update.json failure when CreateTweet returns code 226', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: async () => ({
errors: [
{
message: 'Authorization: This request looks like it might be automated.',
code: 226,
},
],
}),
})
.mockResolvedValueOnce({
ok: false,
status: 403,
text: async () => 'Forbidden',
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.tweet('Hello world!');
expect(result.success).toBe(false);
expect(result.error).toContain('(226)');
expect(result.error).toContain('fallback: HTTP 403');
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(String(mockFetch.mock.calls[1][0])).toContain('statuses/update.json');
});
it('surfaces statuses/update.json API errors when CreateTweet returns code 226', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: async () => ({
errors: [
{
message: 'Authorization: This request looks like it might be automated.',
code: 226,
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
errors: [{ message: 'Nope', code: 999 }],
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.tweet('Hello world!');
expect(result.success).toBe(false);
expect(result.error).toContain('(226)');
expect(result.error).toContain('fallback: Nope (999)');
});
it('surfaces statuses/update.json missing id when CreateTweet returns code 226', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: async () => ({
errors: [
{
message: 'Authorization: This request looks like it might be automated.',
code: 226,
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.tweet('Hello world!');
expect(result.success).toBe(false);
expect(result.error).toContain('(226)');
expect(result.error).toContain('fallback: Tweet created but no ID returned');
});
it('should handle HTTP errors', async () => { it('should handle HTTP errors', async () => {
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValueOnce({
ok: false, ok: false,
@@ -144,6 +330,100 @@ describe('TwitterClient', () => {
}); });
}); });
describe('uploadMedia', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch;
});
it('uploads an image and sets alt text', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: async () => ({ media_id_string: '999' }),
})
.mockResolvedValueOnce({
ok: true,
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({}),
})
.mockResolvedValueOnce({
ok: true,
});
const client = new TwitterClient({ cookies: validCookies });
const data = new Uint8Array([1, 2, 3, 4]);
const result = await client.uploadMedia({ data, mimeType: 'image/png', alt: 'alt text' });
expect(result.success).toBe(true);
expect(result.mediaId).toBe('999');
expect(mockFetch).toHaveBeenCalledTimes(4);
const [initUrl, initOptions] = mockFetch.mock.calls[0];
expect(String(initUrl)).toContain('upload.twitter.com');
expect(initOptions.method).toBe('POST');
expect(initOptions.body).toBeInstanceOf(URLSearchParams);
expect((initOptions.body as URLSearchParams).get('command')).toBe('INIT');
expect((initOptions.body as URLSearchParams).get('media_type')).toBe('image/png');
const [, appendOptions] = mockFetch.mock.calls[1];
expect(appendOptions.method).toBe('POST');
expect(appendOptions.body).toBeInstanceOf(FormData);
const appendBody = appendOptions.body as FormData;
expect(appendBody.get('command')).toBe('APPEND');
expect(appendBody.get('media_id')).toBe('999');
expect(appendBody.get('segment_index')).toBe('0');
expect(appendBody.get('media')).toBeInstanceOf(Blob);
const [, finalizeOptions] = mockFetch.mock.calls[2];
expect(finalizeOptions.body).toBeInstanceOf(URLSearchParams);
expect((finalizeOptions.body as URLSearchParams).get('command')).toBe('FINALIZE');
expect((finalizeOptions.body as URLSearchParams).get('media_id')).toBe('999');
const [metaUrl, metaOptions] = mockFetch.mock.calls[3];
expect(String(metaUrl)).toContain('/media/metadata/create.json');
expect(metaOptions.method).toBe('POST');
expect(JSON.parse(metaOptions.body)).toEqual({ media_id: '999', alt_text: { text: 'alt text' } });
});
it('uploads a video and polls processing status', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: async () => ({ media_id_string: '777' }),
})
.mockResolvedValueOnce({
ok: true,
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ processing_info: { state: 'pending', check_after_secs: 0 } }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ processing_info: { state: 'succeeded' } }),
});
const client = new TwitterClient({ cookies: validCookies });
const data = new Uint8Array([1, 2, 3, 4, 5]);
const result = await client.uploadMedia({ data, mimeType: 'video/mp4', alt: 'ignored' });
expect(result.success).toBe(true);
expect(result.mediaId).toBe('777');
expect(mockFetch).toHaveBeenCalledTimes(4);
const [, finalizeOptions] = mockFetch.mock.calls[2];
expect((finalizeOptions.body as URLSearchParams).get('command')).toBe('FINALIZE');
const [statusUrl] = mockFetch.mock.calls[3];
expect(String(statusUrl)).toContain('command=STATUS');
});
});
describe('reply', () => { describe('reply', () => {
let mockFetch: ReturnType<typeof vi.fn>; let mockFetch: ReturnType<typeof vi.fn>;
@@ -178,6 +458,44 @@ describe('TwitterClient', () => {
const body = JSON.parse(options.body); const body = JSON.parse(options.body);
expect(body.variables.reply.in_reply_to_tweet_id).toBe('1234567890'); expect(body.variables.reply.in_reply_to_tweet_id).toBe('1234567890');
expect(body.variables.tweet_text).toBe('This is a reply'); expect(body.variables.tweet_text).toBe('This is a reply');
expect(body.features.rweb_video_screen_enabled).toBe(true);
expect(body.features.creator_subscriptions_tweet_preview_api_enabled).toBe(true);
});
it('falls back to statuses/update.json for replies when CreateTweet returns code 226', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: async () => ({
errors: [
{
message: 'Authorization: This request looks like it might be automated.',
code: 226,
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
id_str: '999',
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.reply('This is a reply', '1234567890', ['111', '222']);
expect(result.success).toBe(true);
expect(result.tweetId).toBe('999');
expect(mockFetch).toHaveBeenCalledTimes(2);
const [, options] = mockFetch.mock.calls[1];
expect(String(mockFetch.mock.calls[1][0])).toContain('statuses/update.json');
expect(options.method).toBe('POST');
expect(options.body).toContain('status=This+is+a+reply');
expect(options.body).toContain('in_reply_to_status_id=1234567890');
expect(options.body).toContain('auto_populate_reply_metadata=true');
expect(options.body).toContain('media_ids=111%2C222');
}); });
}); });
@@ -584,6 +902,54 @@ describe('TwitterClient', () => {
expect(result.user?.id).toBe('999'); expect(result.user?.id).toBe('999');
expect(mockFetch).toHaveBeenCalledTimes(5); expect(mockFetch).toHaveBeenCalledTimes(5);
}); });
it('skips an endpoint when JSON parsing fails', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => {
throw new Error('bad json');
},
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
user_id: '12345',
screen_name: 'tester',
name: 'Test User',
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getCurrentUser();
expect(result.success).toBe(true);
expect(result.user).toEqual({ id: '12345', username: 'tester', name: 'Test User' });
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('continues on fetch errors and still succeeds via HTML fallback', async () => {
mockFetch
.mockRejectedValueOnce(new Error('boom'))
.mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'not found' })
.mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'not found' })
.mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'not found' })
.mockRejectedValueOnce(new Error('settings boom'))
.mockResolvedValueOnce({
ok: true,
status: 200,
text: async () => '<html>"screen_name":"fallback","user_id":"999"</html>',
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getCurrentUser();
expect(result.success).toBe(true);
expect(result.user?.username).toBe('fallback');
expect(result.user?.id).toBe('999');
});
}); });
describe('search', () => { describe('search', () => {
@@ -663,6 +1029,119 @@ describe('TwitterClient', () => {
const parsed = JSON.parse(urlVars as string) as { rawQuery?: string }; const parsed = JSON.parse(urlVars as string) as { rawQuery?: string };
expect(parsed.rawQuery).toBe('needle'); expect(parsed.rawQuery).toBe('needle');
}); });
it('refreshes query IDs when all search endpoints 404', async () => {
mockFetch
.mockResolvedValueOnce({ ok: false, status: 404, text: async () => '' })
.mockResolvedValueOnce({ ok: false, status: 404, text: async () => '' })
.mockResolvedValueOnce({ ok: false, status: 404, text: async () => '' })
.mockResolvedValueOnce({ ok: false, status: 404, text: async () => '' })
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
search_by_raw_query: {
search_timeline: {
timeline: {
instructions: [],
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.search('hello', 5);
expect(result.success).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(5);
});
it('returns an unknown error when no query IDs are available', async () => {
const client = new TwitterClient({ cookies: validCookies });
(client as unknown as { getSearchTimelineQueryIds: () => Promise<string[]> }).getSearchTimelineQueryIds =
async () => [];
const result = await client.search('hello', 5);
expect(result.success).toBe(false);
expect(result.error).toContain('Unknown error fetching search results');
expect(mockFetch).not.toHaveBeenCalled();
});
});
describe('bookmarks', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
it('fetches bookmarks and parses tweet results', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
bookmark_timeline_v2: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '1',
legacy: {
full_text: 'saved',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '1',
},
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'root', name: 'Root' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarks(2);
expect(result.success).toBe(true);
expect(result.tweets?.[0].id).toBe('1');
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, options] = mockFetch.mock.calls[0];
expect(options.method).toBe('GET');
expect(String(url)).toContain('/Bookmarks?');
const parsedVars = JSON.parse(new URL(url as string).searchParams.get('variables') as string);
expect(parsedVars.count).toBe(2);
const parsedFeatures = JSON.parse(new URL(url as string).searchParams.get('features') as string);
expect(parsedFeatures.graphql_timeline_v2_bookmark_timeline).toBe(true);
});
}); });
describe('conversation helpers', () => { describe('conversation helpers', () => {
+187
View File
@@ -0,0 +1,187 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { afterEach, describe, expect, it } from 'vitest';
import {
FALLBACK_VERSION,
formatVersionLine,
getCliVersion,
resolveGitSha,
resolvePackageVersion,
} from '../src/lib/version.js';
function withTempDir<T>(fn: (dir: string) => T): T {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bird-version-test-'));
try {
return fn(dir);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
describe('getCliVersion', () => {
afterEach(() => {
delete process.env.BIRD_VERSION;
delete process.env.BIRD_GIT_SHA;
});
it('reads package.json version when available', () => {
const raw = fs.readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8');
const pkg = JSON.parse(raw) as { version?: unknown };
expect(resolvePackageVersion()).toBe(pkg.version);
});
it('formats injected version + sha', () => {
process.env.BIRD_VERSION = '9.9.9';
process.env.BIRD_GIT_SHA = 'abcdef123456';
expect(formatVersionLine('not a url')).toBe('9.9.9 (abcdef12)');
});
it('resolves package.json version from importMetaUrl root', () => {
withTempDir((dir) => {
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ version: '1.2.3' }));
const entry = path.join(dir, 'src', 'entry.ts');
fs.mkdirSync(path.dirname(entry), { recursive: true });
fs.writeFileSync(entry, '// noop');
expect(resolvePackageVersion(pathToFileURL(entry).href)).toBe('1.2.3');
});
});
it('falls back to VERSION file when package.json missing', () => {
withTempDir((dir) => {
fs.writeFileSync(path.join(dir, 'VERSION'), '2.0.0\n');
const entry = path.join(dir, 'entry.ts');
fs.writeFileSync(entry, '// noop');
expect(resolvePackageVersion(pathToFileURL(entry).href)).toBe('2.0.0');
});
});
it('falls back to VERSION file when package.json has no version', () => {
withTempDir((dir) => {
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({}));
fs.writeFileSync(path.join(dir, 'VERSION'), '5.5.5\n');
const entry = path.join(dir, 'entry.ts');
fs.writeFileSync(entry, '// noop');
expect(resolvePackageVersion(pathToFileURL(entry).href)).toBe('5.5.5');
});
});
it('falls back to unknown when no version files exist', () => {
withTempDir((dir) => {
const entry = path.join(dir, 'a', 'b', 'c', 'entry.ts');
fs.mkdirSync(path.dirname(entry), { recursive: true });
fs.writeFileSync(entry, '// noop');
expect(resolvePackageVersion(pathToFileURL(entry).href)).toBe(FALLBACK_VERSION);
});
});
it('returns null when git HEAD is missing', () => {
withTempDir((dir) => {
const gitDir = path.join(dir, '.git');
fs.mkdirSync(gitDir, { recursive: true });
const entry = path.join(dir, 'entry.ts');
fs.writeFileSync(entry, '// noop');
expect(resolveGitSha(pathToFileURL(entry).href)).toBeNull();
});
});
it('returns null when git ref cannot be resolved', () => {
withTempDir((dir) => {
const gitDir = path.join(dir, '.git');
fs.mkdirSync(gitDir, { recursive: true });
fs.writeFileSync(path.join(gitDir, 'HEAD'), 'ref: refs/heads/main\n');
const entry = path.join(dir, 'entry.ts');
fs.writeFileSync(entry, '// noop');
expect(resolveGitSha(pathToFileURL(entry).href)).toBeNull();
});
});
it('resolves git sha from .git directory HEAD', () => {
withTempDir((dir) => {
const gitDir = path.join(dir, '.git');
fs.mkdirSync(gitDir, { recursive: true });
fs.writeFileSync(path.join(gitDir, 'HEAD'), '0123456789abcdef0123456789abcdef01234567\n');
const entry = path.join(dir, 'entry.ts');
fs.writeFileSync(entry, '// noop');
expect(resolveGitSha(pathToFileURL(entry).href)).toBe('01234567');
});
});
it('resolves git sha from ref file and packed-refs', () => {
withTempDir((dir) => {
const gitDir = path.join(dir, '.git');
fs.mkdirSync(path.join(gitDir, 'refs', 'heads'), { recursive: true });
fs.writeFileSync(path.join(gitDir, 'HEAD'), 'ref: refs/heads/main\n');
fs.writeFileSync(path.join(gitDir, 'refs', 'heads', 'main'), 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n');
const entry = path.join(dir, 'entry.ts');
fs.writeFileSync(entry, '// noop');
expect(resolveGitSha(pathToFileURL(entry).href)).toBe('aaaaaaaa');
fs.rmSync(path.join(gitDir, 'refs', 'heads', 'main'), { force: true });
fs.writeFileSync(
path.join(gitDir, 'packed-refs'),
['# pack-refs with: peeled fully-peeled', 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb refs/heads/main', ''].join(
'\n',
),
);
expect(resolveGitSha(pathToFileURL(entry).href)).toBe('bbbbbbbb');
});
});
it('resolves git sha from .git file gitdir pointer', () => {
withTempDir((dir) => {
const realGitDir = path.join(dir, '.realgit');
fs.mkdirSync(realGitDir, { recursive: true });
fs.writeFileSync(path.join(realGitDir, 'HEAD'), 'cccccccccccccccccccccccccccccccccccccccc\n');
fs.writeFileSync(path.join(dir, '.git'), 'gitdir: .realgit\n');
const entry = path.join(dir, 'entry.ts');
fs.writeFileSync(entry, '// noop');
expect(resolveGitSha(pathToFileURL(entry).href)).toBe('cccccccc');
});
});
it('returns null when no git directory exists', () => {
withTempDir((dir) => {
const entry = path.join(dir, 'nested', 'entry.ts');
fs.mkdirSync(path.dirname(entry), { recursive: true });
fs.writeFileSync(entry, '// noop');
expect(resolveGitSha(pathToFileURL(entry).href)).toBeNull();
});
});
it('formats version line with version + sha when both available', () => {
withTempDir((dir) => {
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ version: '3.3.3' }));
const gitDir = path.join(dir, '.git');
fs.mkdirSync(gitDir, { recursive: true });
fs.writeFileSync(path.join(gitDir, 'HEAD'), 'dddddddddddddddddddddddddddddddddddddddd\n');
const entry = path.join(dir, 'entry.ts');
fs.writeFileSync(entry, '// noop');
expect(formatVersionLine(pathToFileURL(entry).href)).toBe('3.3.3 (dddddddd)');
});
});
it('returns a CLI version string', () => {
const value = getCliVersion();
expect(typeof value).toBe('string');
expect(value.length).toBeGreaterThan(0);
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"rootDir": "."
},
"include": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.ts"],
"exclude": ["node_modules", "dist", "coverage"]
}
+6
View File
@@ -10,6 +10,12 @@ export default defineConfig({
reporter: ['text', 'html'], reporter: ['text', 'html'],
include: ['src/**/*.ts'], include: ['src/**/*.ts'],
exclude: ['src/index.ts'], exclude: ['src/index.ts'],
thresholds: {
statements: 90,
branches: 80,
functions: 90,
lines: 90,
},
}, },
}, },
}); });