34 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
41 changed files with 3533 additions and 1987 deletions
+1
View File
@@ -0,0 +1 @@
auto-install-peers=false
+38
View File
@@ -1,5 +1,43 @@
# 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 ## 0.2.0 — 2025-12-26
### Added ### Added
+57 -24
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
@@ -32,6 +48,9 @@ bird search "from:steipete" -n 5
bird mentions -n 5 bird mentions -n 5
bird mentions --user @steipete -n 5 bird mentions --user @steipete -n 5
# Bookmarks
bird bookmarks -n 5
# Refresh GraphQL query IDs cache (no rebuild) # Refresh GraphQL query IDs cache (no rebuild)
bird query-ids --fresh bird query-ids --fresh
``` ```
@@ -48,34 +67,36 @@ bird query-ids --fresh
- `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] [--user @handle] [--json]` — find tweets mentioning a user (defaults to the authenticated user). - `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).
Note: fallback to Sweetistics only happens in `--engine auto`. `--engine graphql` is strict (no fallback).
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). - `--plain`: stable output (no emoji, no color).
- `--no-emoji`: disable emoji output. - `--no-emoji`: disable emoji output.
- `--no-color`: disable ANSI colors (or set `NO_COLOR=1`). - `--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)
@@ -88,28 +109,41 @@ 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). - Use `--plain` for stable, script-friendly output (no emoji, no color).
## Query IDs (GraphQL) ## Query IDs (GraphQL)
X rotates GraphQL query IDs frequently. `bird` caches refreshed IDs on disk and retries automatically on 404s. 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: Refresh on demand:
@@ -124,19 +158,18 @@ Exit codes:
## Version ## Version
`bird --version` prints `package.json` version plus current git sha when available, e.g. `0.2.0 (3df7969b)`. `bird --version` prints `package.json` version plus current git sha when available, e.g. `0.3.0 (3df7969b)`.
## Media uploads ## 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/GIFs, 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.
- `--engine graphql`: images/GIFs + 1 video supported (uploads via Twitter legacy upload endpoint + cookies; video may take longer to process). - Images/GIFs + 1 video supported (uploads via Twitter legacy upload endpoint + cookies; video may take longer to process).
- `--engine sweetistics`: images + video supported (requires `SWEETISTICS_API_KEY`).
Example: Example:
```bash ```bash
bird --engine sweetistics tweet "hi" --media img.png --alt "desc" bird tweet "hi" --media img.png --alt "desc"
``` ```
## Development ## Development
@@ -157,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.
+11 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "@steipete/bird", "name": "@steipete/bird",
"version": "0.2.0", "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",
@@ -21,15 +21,19 @@
"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]: {}
+2 -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];
+161 -551
View File
@@ -16,17 +16,12 @@ import { Command } from 'commander';
import JSON5 from 'json5'; import JSON5 from 'json5';
import kleur from 'kleur'; import kleur from 'kleur';
import { resolveCliInvocation } from './lib/cli-args.js'; import { resolveCliInvocation } from './lib/cli-args.js';
import { resolveCredentials } from './lib/cookies.js'; import { type CookieSource, resolveCredentials } from './lib/cookies.js';
import {
type EngineMode,
resolveEngineMode,
shouldAllowSweetisticsFallback,
shouldUseSweetistics,
} from './lib/engine.js';
import { extractTweetId } from './lib/extract-tweet-id.js'; import { extractTweetId } from './lib/extract-tweet-id.js';
import { mentionsQueryFromUserOption, normalizeHandle } from './lib/normalize-handle.js'; import { mentionsQueryFromUserOption, normalizeHandle } from './lib/normalize-handle.js';
import { import {
formatStatsLine, formatStatsLine,
formatTweetUrlLine,
labelPrefix, labelPrefix,
type OutputConfig, type OutputConfig,
resolveOutputConfigFromArgv, resolveOutputConfigFromArgv,
@@ -34,36 +29,62 @@ import {
statusPrefix, statusPrefix,
} from './lib/output.js'; } from './lib/output.js';
import { runtimeQueryIds } from './lib/runtime-query-ids.js'; import { runtimeQueryIds } from './lib/runtime-query-ids.js';
import { SweetisticsClient } from './lib/sweetistics-client.js';
import { type TweetData, TwitterClient } from './lib/twitter-client.js'; import { type TweetData, TwitterClient } from './lib/twitter-client.js';
import { getCliVersion } from './lib/version.js'; import { getCliVersion } from './lib/version.js';
const program = new Command(); const program: Command = new Command();
const rawArgs = process.argv.slice(2); const rawArgs: string[] = process.argv.slice(2);
const normalizedArgs = rawArgs[0] === '--' ? rawArgs.slice(1) : rawArgs; const normalizedArgs: string[] = rawArgs[0] === '--' ? rawArgs.slice(1) : rawArgs;
const isTty = process.stdout.isTTY; const isTty: boolean = process.stdout.isTTY;
let output: OutputConfig = resolveOutputConfigFromArgv(normalizedArgs, process.env, isTty); let output: OutputConfig = resolveOutputConfigFromArgv(normalizedArgs, process.env, isTty);
kleur.enabled = output.color; kleur.enabled = output.color;
const wrap = (styler: (text: string) => string) => (text: string) => (isTty ? styler(text) : text); const wrap =
const collect = (value: string, previous: string[] = []) => { (styler: (text: string) => string): ((text: string) => string) =>
(text: string): string =>
isTty ? styler(text) : text;
const collect = (value: string, previous: string[] = []): string[] => {
previous.push(value); previous.push(value);
return previous; return previous;
}; };
const p = (kind: Parameters<typeof statusPrefix>[0]) => statusPrefix(kind, output); const COOKIE_SOURCES: CookieSource[] = ['safari', 'chrome', 'firefox'];
const l = (kind: Parameters<typeof labelPrefix>[0]) => labelPrefix(kind, output);
function requireSweetisticsApiKey(apiKey: string | null): string { function parseCookieSource(value: string): CookieSource {
if (!apiKey) { const normalized = value.trim().toLowerCase();
console.error(`${p('err')}Sweetistics API key missing. Set SWEETISTICS_API_KEY or pass --sweetistics-api-key.`); if (normalized === 'safari' || normalized === 'chrome' || normalized === 'firefox') {
process.exit(1); return normalized;
} }
return apiKey; throw new Error(`Invalid --cookie-source "${value}". Allowed: safari, chrome, firefox.`);
} }
function applyOutputFromCommand(command: Command) { function resolveCookieSourceOrder(input: unknown): CookieSource[] | undefined {
if (typeof input === 'string') {
return [parseCookieSource(input)];
}
if (Array.isArray(input)) {
const result: CookieSource[] = [];
for (const entry of input) {
if (typeof entry !== 'string') {
continue;
}
result.push(parseCookieSource(entry));
}
return result.length > 0 ? result : undefined;
}
return undefined;
}
const collectCookieSource = (value: string, previous: CookieSource[] = []): CookieSource[] => {
previous.push(parseCookieSource(value));
return previous;
};
const p = (kind: Parameters<typeof statusPrefix>[0]): string => statusPrefix(kind, output);
const l = (kind: Parameters<typeof labelPrefix>[0]): string => labelPrefix(kind, output);
function applyOutputFromCommand(command: Command): void {
const opts = command.optsWithGlobals() as { plain?: boolean; emoji?: boolean; color?: boolean }; const opts = command.optsWithGlobals() as { plain?: boolean; emoji?: boolean; color?: boolean };
output = resolveOutputConfigFromCommander(opts, process.env, isTty); output = resolveOutputConfigFromCommander(opts, process.env, isTty);
kleur.enabled = output.color; kleur.enabled = output.color;
@@ -83,18 +104,16 @@ const colors = {
}; };
type BirdConfig = { type BirdConfig = {
engine?: EngineMode;
chromeProfile?: string; chromeProfile?: string;
firefoxProfile?: string; firefoxProfile?: string;
sweetisticsApiKey?: string; cookieSource?: CookieSource | CookieSource[];
sweetisticsBaseUrl?: string;
allowChrome?: boolean;
allowFirefox?: boolean;
timeoutMs?: number; timeoutMs?: number;
}; };
function readConfigFile(path: string): Partial<BirdConfig> { function readConfigFile(path: string): Partial<BirdConfig> {
if (!existsSync(path)) return {}; if (!existsSync(path)) {
return {};
}
try { try {
const raw = readFileSync(path, 'utf8'); const raw = readFileSync(path, 'utf8');
const parsed = JSON5.parse(raw) as Partial<BirdConfig>; const parsed = JSON5.parse(raw) as Partial<BirdConfig>;
@@ -130,6 +149,7 @@ const KNOWN_COMMANDS = new Set([
'thread', 'thread',
'search', 'search',
'mentions', 'mentions',
'bookmarks',
'help', 'help',
'whoami', 'whoami',
'check', 'check',
@@ -142,7 +162,7 @@ program.addHelpText(
program.name('bird').description('Post tweets and replies via Twitter/X GraphQL API').version(getCliVersion()); program.name('bird').description('Post tweets and replies via Twitter/X GraphQL API').version(getCliVersion());
const formatExample = (command: string, description: string) => const formatExample = (command: string, description: string): string =>
`${colors.command(` ${command}`)}\n${colors.muted(` ${description}`)}`; `${colors.command(` ${command}`)}\n${colors.muted(` ${description}`)}`;
program.addHelpText( program.addHelpText(
@@ -162,44 +182,55 @@ program
.option('--ct0 <token>', 'Twitter ct0 cookie') .option('--ct0 <token>', 'Twitter ct0 cookie')
.option('--chrome-profile <name>', 'Chrome profile name for cookie extraction', config.chromeProfile) .option('--chrome-profile <name>', 'Chrome profile name for cookie extraction', config.chromeProfile)
.option('--firefox-profile <name>', 'Firefox profile name for cookie extraction', config.firefoxProfile) .option('--firefox-profile <name>', 'Firefox profile name for cookie extraction', config.firefoxProfile)
.option(
'--cookie-source <source>',
'Cookie source for browser cookie extraction (repeatable)',
collectCookieSource,
[],
)
.option('--media <path>', 'Attach media file (repeatable, up to 4 images or 1 video)', collect, []) .option('--media <path>', 'Attach media file (repeatable, up to 4 images or 1 video)', collect, [])
.option('--alt <text>', 'Alt text for the corresponding --media (repeatable)', collect, []) .option('--alt <text>', 'Alt text for the corresponding --media (repeatable)', collect, [])
.option('--sweetistics-api-key <key>', 'Sweetistics API key (or set SWEETISTICS_API_KEY)')
.option(
'--sweetistics-base-url <url>',
'Sweetistics base URL',
config.sweetisticsBaseUrl || process.env.SWEETISTICS_BASE_URL || 'https://sweetistics.com',
)
.option(
'--engine <engine>',
'Engine: graphql | sweetistics | auto',
process.env.BIRD_ENGINE || config.engine || 'auto',
)
.option('--timeout <ms>', 'Request timeout in milliseconds') .option('--timeout <ms>', 'Request timeout in milliseconds')
.option('--plain', 'Plain output (stable, no emoji, no color)') .option('--plain', 'Plain output (stable, no emoji, no color)')
.option('--no-emoji', 'Disable emoji output') .option('--no-emoji', 'Disable emoji output')
.option('--no-color', 'Disable ANSI colors (or set NO_COLOR)'); .option('--no-color', 'Disable ANSI colors (or set NO_COLOR)');
type CredentialsOptions = {
authToken?: string;
ct0?: string;
chromeProfile?: string;
firefoxProfile?: string;
cookieSource?: CookieSource[];
};
function resolveCredentialsFromOptions(opts: CredentialsOptions): ReturnType<typeof resolveCredentials> {
const cookieSource = opts.cookieSource?.length
? opts.cookieSource
: (resolveCookieSourceOrder(config.cookieSource) ?? COOKIE_SOURCES);
return resolveCredentials({
authToken: opts.authToken,
ct0: opts.ct0,
cookieSource,
chromeProfile: opts.chromeProfile || config.chromeProfile,
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
});
}
program.hook('preAction', (_thisCommand, actionCommand) => { program.hook('preAction', (_thisCommand, actionCommand) => {
applyOutputFromCommand(actionCommand); applyOutputFromCommand(actionCommand);
}); });
type MediaSpec = { path: string; alt?: string; mime: string; buffer: Buffer }; type MediaSpec = { path: string; alt?: string; mime: string; buffer: Buffer };
function resolveSweetisticsConfig(options: { sweetisticsApiKey?: string; sweetisticsBaseUrl?: string }) {
const apiKey =
options.sweetisticsApiKey || process.env.SWEETISTICS_API_KEY || process.env.SWEETISTICS_LOCALHOST_API_KEY || null;
const baseUrl = options.sweetisticsBaseUrl || process.env.SWEETISTICS_BASE_URL || 'https://sweetistics.com';
return { apiKey, baseUrl };
}
function resolveTimeoutMs(...values: Array<string | number | undefined | null>): number | undefined { function resolveTimeoutMs(...values: Array<string | number | undefined | null>): number | undefined {
for (const value of values) { for (const value of values) {
if (value === undefined || value === null || value === '') continue; if (value === undefined || value === null || value === '') {
continue;
}
const parsed = typeof value === 'number' ? value : Number(value); const parsed = typeof value === 'number' ? value : Number(value);
if (Number.isFinite(parsed) && parsed > 0) return parsed; if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
} }
return undefined; return undefined;
} }
@@ -210,17 +241,31 @@ function resolveTimeoutFromOptions(options: { timeout?: string | number }): numb
function detectMime(path: string): string | null { function detectMime(path: string): string | null {
const ext = path.toLowerCase(); const ext = path.toLowerCase();
if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) return 'image/jpeg'; if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) {
if (ext.endsWith('.png')) return 'image/png'; return 'image/jpeg';
if (ext.endsWith('.webp')) return 'image/webp'; }
if (ext.endsWith('.gif')) return 'image/gif'; if (ext.endsWith('.png')) {
if (ext.endsWith('.mp4') || ext.endsWith('.m4v')) return 'video/mp4'; return 'image/png';
if (ext.endsWith('.mov')) return 'video/quicktime'; }
if (ext.endsWith('.webp')) {
return 'image/webp';
}
if (ext.endsWith('.gif')) {
return 'image/gif';
}
if (ext.endsWith('.mp4') || ext.endsWith('.m4v')) {
return 'video/mp4';
}
if (ext.endsWith('.mov')) {
return 'video/quicktime';
}
return null; return null;
} }
function loadMedia(opts: { media: string[]; alts: string[] }): MediaSpec[] { function loadMedia(opts: { media: string[]; alts: string[] }): MediaSpec[] {
if (opts.media.length === 0) return []; if (opts.media.length === 0) {
return [];
}
const specs: MediaSpec[] = []; const specs: MediaSpec[] = [];
for (const [index, path] of opts.media.entries()) { for (const [index, path] of opts.media.entries()) {
const mime = detectMime(path); const mime = detectMime(path);
@@ -232,9 +277,15 @@ function loadMedia(opts: { media: string[]; alts: string[] }): MediaSpec[] {
} }
const videoCount = specs.filter((m) => m.mime.startsWith('video/')).length; const videoCount = specs.filter((m) => m.mime.startsWith('video/')).length;
if (videoCount > 1) throw new Error('Only one video can be attached'); if (videoCount > 1) {
if (videoCount === 1 && specs.length > 1) throw new Error('Video cannot be combined with other media'); throw new Error('Only one video can be attached');
if (specs.length > 4) throw new Error('Maximum 4 media attachments'); }
if (videoCount === 1 && specs.length > 1) {
throw new Error('Video cannot be combined with other media');
}
if (specs.length > 4) {
throw new Error('Maximum 4 media attachments');
}
return specs; return specs;
} }
@@ -295,6 +346,7 @@ program
'TweetDetail', 'TweetDetail',
'SearchTimeline', 'SearchTimeline',
'UserArticlesTweets', 'UserArticlesTweets',
'Bookmarks',
]; ];
if (cmdOpts.fresh) { if (cmdOpts.fresh) {
@@ -354,67 +406,8 @@ program
console.error(`${p('err')}${error instanceof Error ? error.message : String(error)}`); console.error(`${p('err')}${error instanceof Error ? error.message : String(error)}`);
process.exit(1); process.exit(1);
} }
const sweetistics = resolveSweetisticsConfig({
sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey,
sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl,
});
const engine = resolveEngineMode(opts.engine);
const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey));
if (useSweetistics) { const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
if (!sweetistics.apiKey) {
console.error(`${p('err')}Sweetistics engine selected but no API key provided.`);
process.exit(1);
}
try {
const client = new SweetisticsClient({
baseUrl: sweetistics.baseUrl,
apiKey: sweetistics.apiKey,
timeoutMs,
});
let mediaIds: string[] | undefined;
if (media.length > 0) {
const uploaded: string[] = [];
for (const item of media) {
const res = await client.uploadMedia({
data: item.buffer.toString('base64'),
mimeType: item.mime,
alt: item.alt,
});
if (!res.success || !res.mediaId) {
throw new Error(res.error ?? 'Media upload failed');
}
uploaded.push(res.mediaId);
}
mediaIds = uploaded;
}
const result = await client.tweet(text, undefined, mediaIds);
if (result.success) {
console.log(`${p('ok')}Tweet posted via Sweetistics!`);
if (result.tweetId) {
console.log(`${l('url')}https://x.com/i/status/${result.tweetId}`);
}
return;
}
console.error(`${p('err')}Sweetistics post failed: ${result.error ?? 'Unknown error'}`);
process.exit(1);
} catch (error) {
console.error(`${p('err')}Sweetistics error: ${error instanceof Error ? error.message : String(error)}`);
if (error instanceof Error && /(^|\\b)INTERNAL_ERROR(\\b|$)|An unexpected error occurred/.test(error.message)) {
console.error(`${p('hint')}Sweetistics media upload likely needs Twitter OAuth with \`media.write\` scope.`);
}
process.exit(1);
}
}
const { cookies, warnings } = await resolveCredentials({
authToken: opts.authToken,
ct0: opts.ct0,
chromeProfile: opts.chromeProfile || config.chromeProfile,
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
allowChrome: config.allowChrome ?? true,
allowFirefox: config.allowFirefox ?? true,
});
for (const warning of warnings) { for (const warning of warnings) {
console.error(`${p('warn')}${warning}`); console.error(`${p('warn')}${warning}`);
@@ -448,23 +441,7 @@ program
if (result.success) { if (result.success) {
console.log(`${p('ok')}Tweet posted successfully!`); console.log(`${p('ok')}Tweet posted successfully!`);
console.log(`${l('url')}https://x.com/i/status/${result.tweetId}`); console.log(formatTweetUrlLine(result.tweetId, output));
} else if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) {
console.error(`${p('warn')}GraphQL tweet failed (${result.error}); trying Sweetistics fallback...`);
const fallback = await new SweetisticsClient({
baseUrl: sweetistics.baseUrl,
apiKey: requireSweetisticsApiKey(sweetistics.apiKey),
timeoutMs,
}).tweet(text);
if (fallback.success) {
console.log(`${p('ok')}Tweet posted via Sweetistics (fallback)!`);
if (fallback.tweetId) {
console.log(`${l('url')}https://x.com/i/status/${fallback.tweetId}`);
}
} else {
console.error(`${p('err')}Failed to post tweet: ${result.error} | Sweetistics fallback: ${fallback.error}`);
process.exit(1);
}
} else { } else {
console.error(`${p('err')}Failed to post tweet: ${result.error}`); console.error(`${p('err')}Failed to post tweet: ${result.error}`);
process.exit(1); process.exit(1);
@@ -487,68 +464,9 @@ program
console.error(`${p('err')}${error instanceof Error ? error.message : String(error)}`); console.error(`${p('err')}${error instanceof Error ? error.message : String(error)}`);
process.exit(1); process.exit(1);
} }
const sweetistics = resolveSweetisticsConfig({
sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey,
sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl,
});
const engine = resolveEngineMode(opts.engine);
const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey));
const tweetId = extractTweetId(tweetIdOrUrl); const tweetId = extractTweetId(tweetIdOrUrl);
if (useSweetistics) { const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
if (!sweetistics.apiKey) {
console.error(`${p('err')}Sweetistics engine selected but no API key provided.`);
process.exit(1);
}
try {
const client = new SweetisticsClient({
baseUrl: sweetistics.baseUrl,
apiKey: sweetistics.apiKey,
timeoutMs,
});
let mediaIds: string[] | undefined;
if (media.length > 0) {
const uploaded: string[] = [];
for (const item of media) {
const res = await client.uploadMedia({
data: item.buffer.toString('base64'),
mimeType: item.mime,
alt: item.alt,
});
if (!res.success || !res.mediaId) {
throw new Error(res.error ?? 'Media upload failed');
}
uploaded.push(res.mediaId);
}
mediaIds = uploaded;
}
const result = await client.tweet(text, tweetId, mediaIds);
if (result.success) {
console.log(`${p('ok')}Reply posted via Sweetistics!`);
if (result.tweetId) {
console.log(`${l('url')}https://x.com/i/status/${result.tweetId}`);
}
return;
}
console.error(`${p('err')}Sweetistics reply failed: ${result.error ?? 'Unknown error'}`);
process.exit(1);
} catch (error) {
console.error(`${p('err')}Sweetistics error: ${error instanceof Error ? error.message : String(error)}`);
if (error instanceof Error && /(^|\\b)INTERNAL_ERROR(\\b|$)|An unexpected error occurred/.test(error.message)) {
console.error(`${p('hint')}Sweetistics media upload likely needs Twitter OAuth with \`media.write\` scope.`);
}
process.exit(1);
}
}
const { cookies, warnings } = await resolveCredentials({
authToken: opts.authToken,
ct0: opts.ct0,
chromeProfile: opts.chromeProfile || config.chromeProfile,
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
allowChrome: config.allowChrome ?? true,
allowFirefox: config.allowFirefox ?? true,
});
for (const warning of warnings) { for (const warning of warnings) {
console.error(`${p('warn')}${warning}`); console.error(`${p('warn')}${warning}`);
@@ -584,23 +502,7 @@ program
if (result.success) { if (result.success) {
console.log(`${p('ok')}Reply posted successfully!`); console.log(`${p('ok')}Reply posted successfully!`);
console.log(`${l('url')}https://x.com/i/status/${result.tweetId}`); console.log(formatTweetUrlLine(result.tweetId, output));
} else if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) {
console.error(`${p('warn')}GraphQL reply failed (${result.error}); trying Sweetistics fallback...`);
const fallback = await new SweetisticsClient({
baseUrl: sweetistics.baseUrl,
apiKey: requireSweetisticsApiKey(sweetistics.apiKey),
timeoutMs,
}).tweet(text, tweetId);
if (fallback.success) {
console.log(`${p('ok')}Reply posted via Sweetistics (fallback)!`);
if (fallback.tweetId) {
console.log(`${l('url')}https://x.com/i/status/${fallback.tweetId}`);
}
} else {
console.error(`${p('err')}Failed to post reply: ${result.error} | Sweetistics fallback: ${fallback.error}`);
process.exit(1);
}
} else { } else {
console.error(`${p('err')}Failed to post reply: ${result.error}`); console.error(`${p('err')}Failed to post reply: ${result.error}`);
process.exit(1); process.exit(1);
@@ -616,50 +518,10 @@ program
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => { .action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
const opts = program.opts(); const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts); const timeoutMs = resolveTimeoutFromOptions(opts);
const sweetistics = resolveSweetisticsConfig({
sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey,
sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl,
});
const engine = resolveEngineMode(opts.engine);
const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey));
const tweetId = extractTweetId(tweetIdOrUrl); const tweetId = extractTweetId(tweetIdOrUrl);
if (useSweetistics) {
if (!sweetistics.apiKey) {
console.error(`${p('err')}Sweetistics engine selected but no API key provided.`);
process.exit(1);
}
const client = new SweetisticsClient({
baseUrl: sweetistics.baseUrl,
apiKey: sweetistics.apiKey,
timeoutMs,
});
const result = await client.read(tweetId);
if (result.success && result.tweet) {
if (cmdOpts.json) {
console.log(JSON.stringify(result.tweet, null, 2));
} else {
console.log(`@${result.tweet.author.username} (${result.tweet.author.name}):`);
console.log(result.tweet.text);
if (result.tweet.createdAt) {
console.log(`\n${l('date')}${result.tweet.createdAt}`);
}
console.log(formatStatsLine(result.tweet, output));
}
return;
}
console.error(`${p('err')}Failed to read tweet via Sweetistics: ${result.error ?? 'Unknown error'}`);
process.exit(1);
}
const { cookies, warnings } = await resolveCredentials({ const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
authToken: opts.authToken,
ct0: opts.ct0,
chromeProfile: opts.chromeProfile || config.chromeProfile,
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
allowChrome: config.allowChrome ?? true,
allowFirefox: config.allowFirefox ?? true,
});
for (const warning of warnings) { for (const warning of warnings) {
console.error(`${p('warn')}${warning}`); console.error(`${p('warn')}${warning}`);
@@ -684,28 +546,6 @@ program
} }
console.log(formatStatsLine(result.tweet, output)); console.log(formatStatsLine(result.tweet, output));
} }
} else if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) {
console.error(`${p('warn')}GraphQL read failed (${result.error}); trying Sweetistics fallback...`);
const fallback = await new SweetisticsClient({
baseUrl: sweetistics.baseUrl,
apiKey: requireSweetisticsApiKey(sweetistics.apiKey),
timeoutMs,
}).read(tweetId);
if (fallback.success && fallback.tweet) {
if (cmdOpts.json) {
console.log(JSON.stringify(fallback.tweet, null, 2));
} else {
console.log(`@${fallback.tweet.author.username} (${fallback.tweet.author.name}):`);
console.log(fallback.tweet.text);
if (fallback.tweet.createdAt) {
console.log(`\n${l('date')}${fallback.tweet.createdAt}`);
}
console.log(formatStatsLine(fallback.tweet, output));
}
} else {
console.error(`${p('err')}Failed to read tweet: ${result.error} | Sweetistics fallback: ${fallback.error}`);
process.exit(1);
}
} else { } else {
console.error(`${p('err')}Failed to read tweet: ${result.error}`); console.error(`${p('err')}Failed to read tweet: ${result.error}`);
process.exit(1); process.exit(1);
@@ -721,38 +561,9 @@ program
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => { .action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
const opts = program.opts(); const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts); const timeoutMs = resolveTimeoutFromOptions(opts);
const sweetistics = resolveSweetisticsConfig({
sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey,
sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl,
});
const engine = resolveEngineMode(opts.engine);
const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey));
const tweetId = extractTweetId(tweetIdOrUrl); const tweetId = extractTweetId(tweetIdOrUrl);
if (useSweetistics) {
if (!sweetistics.apiKey) {
console.error(`${p('err')}Sweetistics engine selected but no API key provided.`);
process.exit(1);
}
const client = new SweetisticsClient({
baseUrl: sweetistics.baseUrl,
apiKey: sweetistics.apiKey,
timeoutMs,
});
const result = await client.replies(tweetId);
if (result.success && result.tweets) {
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No replies found.' });
return;
}
console.error(`${p('err')}Failed to fetch replies via Sweetistics: ${result.error}`);
process.exit(1);
}
const { cookies, warnings } = await resolveCredentials({ const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
authToken: opts.authToken,
ct0: opts.ct0,
chromeProfile: opts.chromeProfile || config.chromeProfile,
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
});
for (const warning of warnings) { for (const warning of warnings) {
console.error(`${p('warn')}${warning}`); console.error(`${p('warn')}${warning}`);
@@ -768,19 +579,6 @@ program
if (result.success && result.tweets) { if (result.success && result.tweets) {
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No replies found.' }); printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No replies found.' });
} else if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) {
console.error(`${p('warn')}GraphQL replies failed (${result.error}); trying Sweetistics fallback...`);
const fallback = await new SweetisticsClient({
baseUrl: sweetistics.baseUrl,
apiKey: requireSweetisticsApiKey(sweetistics.apiKey),
timeoutMs,
}).replies(tweetId);
if (fallback.success && fallback.tweets) {
printTweets(fallback.tweets, { json: cmdOpts.json, emptyMessage: 'No replies found.' });
} else {
console.error(`${p('err')}Failed to fetch replies: ${result.error} | Sweetistics fallback: ${fallback.error}`);
process.exit(1);
}
} else { } else {
console.error(`${p('err')}Failed to fetch replies: ${result.error}`); console.error(`${p('err')}Failed to fetch replies: ${result.error}`);
process.exit(1); process.exit(1);
@@ -796,38 +594,9 @@ program
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => { .action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
const opts = program.opts(); const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts); const timeoutMs = resolveTimeoutFromOptions(opts);
const sweetistics = resolveSweetisticsConfig({
sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey,
sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl,
});
const engine = resolveEngineMode(opts.engine);
const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey));
const tweetId = extractTweetId(tweetIdOrUrl); const tweetId = extractTweetId(tweetIdOrUrl);
if (useSweetistics) {
if (!sweetistics.apiKey) {
console.error(`${p('err')}Sweetistics engine selected but no API key provided.`);
process.exit(1);
}
const client = new SweetisticsClient({
baseUrl: sweetistics.baseUrl,
apiKey: sweetistics.apiKey,
timeoutMs,
});
const result = await client.thread(tweetId);
if (result.success && result.tweets) {
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No thread tweets found.' });
return;
}
console.error(`${p('err')}Failed to fetch thread via Sweetistics: ${result.error}`);
process.exit(1);
}
const { cookies, warnings } = await resolveCredentials({ const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
authToken: opts.authToken,
ct0: opts.ct0,
chromeProfile: opts.chromeProfile || config.chromeProfile,
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
});
for (const warning of warnings) { for (const warning of warnings) {
console.error(`${p('warn')}${warning}`); console.error(`${p('warn')}${warning}`);
@@ -843,19 +612,6 @@ program
if (result.success && result.tweets) { if (result.success && result.tweets) {
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No thread tweets found.' }); printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No thread tweets found.' });
} else if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) {
console.error(`${p('warn')}GraphQL thread failed (${result.error}); trying Sweetistics fallback...`);
const fallback = await new SweetisticsClient({
baseUrl: sweetistics.baseUrl,
apiKey: requireSweetisticsApiKey(sweetistics.apiKey),
timeoutMs,
}).thread(tweetId);
if (fallback.success && fallback.tweets) {
printTweets(fallback.tweets, { json: cmdOpts.json, emptyMessage: 'No thread tweets found.' });
} else {
console.error(`${p('err')}Failed to fetch thread: ${result.error} | Sweetistics fallback: ${fallback.error}`);
process.exit(1);
}
} else { } else {
console.error(`${p('err')}Failed to fetch thread: ${result.error}`); console.error(`${p('err')}Failed to fetch thread: ${result.error}`);
process.exit(1); process.exit(1);
@@ -873,38 +629,8 @@ program
const opts = program.opts(); const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts); const timeoutMs = resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '10', 10); const count = Number.parseInt(cmdOpts.count || '10', 10);
const sweetistics = resolveSweetisticsConfig({
sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey,
sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl,
});
const engine = resolveEngineMode(opts.engine);
const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey));
if (useSweetistics) { const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
if (!sweetistics.apiKey) {
console.error(`${p('err')}Sweetistics engine selected but no API key provided.`);
process.exit(1);
}
const client = new SweetisticsClient({
baseUrl: sweetistics.baseUrl,
apiKey: sweetistics.apiKey,
timeoutMs,
});
const result = await client.search(query, count);
if (result.success && result.tweets) {
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No tweets found.' });
return;
}
console.error(`${p('err')}Search failed via Sweetistics: ${result.error}`);
process.exit(1);
}
const { cookies, warnings } = await resolveCredentials({
authToken: opts.authToken,
ct0: opts.ct0,
chromeProfile: opts.chromeProfile || config.chromeProfile,
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
});
for (const warning of warnings) { for (const warning of warnings) {
console.error(`${p('warn')}${warning}`); console.error(`${p('warn')}${warning}`);
@@ -920,19 +646,6 @@ program
if (result.success && result.tweets) { if (result.success && result.tweets) {
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No tweets found.' }); printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No tweets found.' });
} else if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) {
console.error(`${p('warn')}GraphQL search failed (${result.error}); trying Sweetistics fallback...`);
const fallback = await new SweetisticsClient({
baseUrl: sweetistics.baseUrl,
apiKey: requireSweetisticsApiKey(sweetistics.apiKey),
timeoutMs,
}).search(query, count);
if (fallback.success && fallback.tweets) {
printTweets(fallback.tweets, { json: cmdOpts.json, emptyMessage: 'No tweets found.' });
} else {
console.error(`${p('err')}Search failed: ${result.error} | Sweetistics fallback: ${fallback.error}`);
process.exit(1);
}
} else { } else {
console.error(`${p('err')}Search failed: ${result.error}`); console.error(`${p('err')}Search failed: ${result.error}`);
process.exit(1); process.exit(1);
@@ -950,9 +663,6 @@ program
const opts = program.opts(); const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts); const timeoutMs = resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '10', 10); const count = Number.parseInt(cmdOpts.count || '10', 10);
const sweetistics = resolveSweetisticsConfig(opts);
const engine = resolveEngineMode(opts.engine);
const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey));
const fromUserOpt = mentionsQueryFromUserOption(cmdOpts.user); const fromUserOpt = mentionsQueryFromUserOption(cmdOpts.user);
if (fromUserOpt.error) { if (fromUserOpt.error) {
@@ -962,43 +672,7 @@ program
let query: string | null = fromUserOpt.query; let query: string | null = fromUserOpt.query;
if (useSweetistics) { const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
if (!sweetistics.apiKey) {
console.error(`${p('err')}Sweetistics engine selected but no API key provided.`);
process.exit(1);
}
const client = new SweetisticsClient({
baseUrl: sweetistics.baseUrl,
apiKey: sweetistics.apiKey,
timeoutMs,
});
if (!query) {
const who = await client.getCurrentUser();
const handle = normalizeHandle(who.user?.username);
if (!handle) {
console.error(
`${p('err')}Could not determine current user (${who.error ?? 'Unknown error'}). Use --user <handle>.`,
);
process.exit(1);
}
query = `@${handle}`;
}
const result = await client.search(query, count);
if (result.success && result.tweets) {
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' });
return;
}
console.error(`${p('err')}Failed to fetch mentions via Sweetistics: ${result.error}`);
process.exit(1);
}
const { cookies, warnings } = await resolveCredentials({
authToken: opts.authToken,
ct0: opts.ct0,
chromeProfile: opts.chromeProfile,
});
for (const warning of warnings) { for (const warning of warnings) {
console.error(`${p('warn')}${warning}`); console.error(`${p('warn')}${warning}`);
@@ -1014,56 +688,59 @@ program
if (!query) { if (!query) {
const who = await client.getCurrentUser(); const who = await client.getCurrentUser();
const handle = normalizeHandle(who.user?.username); const handle = normalizeHandle(who.user?.username);
if (!handle) { if (handle) {
if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) { query = `@${handle}`;
console.error(
`${p('warn')}Could not determine current user (${who.error ?? 'Unknown error'}); trying Sweetistics fallback...`,
);
const fallbackWho = await new SweetisticsClient({
baseUrl: sweetistics.baseUrl,
apiKey: requireSweetisticsApiKey(sweetistics.apiKey),
timeoutMs,
}).getCurrentUser();
const fallbackHandle = normalizeHandle(fallbackWho.user?.username);
if (!fallbackHandle) {
console.error(`${p('err')}Could not determine current user. Use --user <handle>.`);
process.exit(1);
}
query = `@${fallbackHandle}`;
} else { } else {
console.error( console.error(
`${p('err')}Could not determine current user (${who.error ?? 'Unknown error'}). Use --user <handle>.`, `${p('err')}Could not determine current user (${who.error ?? 'Unknown error'}). Use --user <handle>.`,
); );
process.exit(1); process.exit(1);
} }
} else {
query = `@${handle}`;
}
} }
const result = await client.search(query, count); const result = await client.search(query, count);
if (result.success && result.tweets) { if (result.success && result.tweets) {
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' }); printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' });
} else if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) {
console.error(`${p('warn')}GraphQL mentions failed (${result.error}); trying Sweetistics fallback...`);
const fallback = await new SweetisticsClient({
baseUrl: sweetistics.baseUrl,
apiKey: requireSweetisticsApiKey(sweetistics.apiKey),
timeoutMs,
}).search(query, count);
if (fallback.success && fallback.tweets) {
printTweets(fallback.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' });
} else {
console.error(`${p('err')}Failed to fetch mentions: ${result.error} | Sweetistics fallback: ${fallback.error}`);
process.exit(1);
}
} else { } else {
console.error(`${p('err')}Failed to fetch mentions: ${result.error}`); console.error(`${p('err')}Failed to fetch mentions: ${result.error}`);
process.exit(1); process.exit(1);
} }
}); });
// Bookmarks command - get user's bookmarks
program
.command('bookmarks')
.description('Get your bookmarked tweets')
.option('-n, --count <number>', 'Number of bookmarks to fetch', '20')
.option('--json', 'Output as JSON')
.action(async (cmdOpts: { count?: string; json?: boolean }) => {
const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs });
const result = await client.getBookmarks(count);
if (result.success && result.tweets) {
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No bookmarks found.' });
} else {
console.error(`${p('err')}Failed to fetch bookmarks: ${result.error}`);
process.exit(1);
}
});
// Whoami command - show the logged-in account // Whoami command - show the logged-in account
program program
.command('whoami') .command('whoami')
@@ -1071,51 +748,8 @@ program
.action(async () => { .action(async () => {
const opts = program.opts(); const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts); const timeoutMs = resolveTimeoutFromOptions(opts);
const sweetistics = resolveSweetisticsConfig({
sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey,
sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl,
});
const engine = resolveEngineMode(opts.engine || config.engine);
const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey));
const resolvedEngine = useSweetistics ? 'sweetistics' : 'graphql';
if (useSweetistics) {
if (!sweetistics.apiKey) {
console.error(`${p('err')}Sweetistics engine selected but no API key provided.`);
process.exit(1);
}
const client = new SweetisticsClient({ const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
baseUrl: sweetistics.baseUrl,
apiKey: sweetistics.apiKey,
timeoutMs,
});
const result = await client.getCurrentUser();
if (result.success && result.user) {
const handle = result.user.username ? `@${result.user.username}` : '(no handle)';
const name = result.user.name || handle;
console.log(`${l('user')}${handle} (${name})`);
console.log(`${l('userId')}${result.user.id}`);
if (result.user.email) {
console.log(`${l('email')}${result.user.email}`);
}
console.log(`${l('engine')}${resolvedEngine}`);
console.log(`${l('credentials')}Sweetistics API key`);
return;
}
console.error(`${p('err')}Failed to determine Sweetistics user: ${result.error ?? 'Unknown error'}`);
process.exit(1);
}
const { cookies, warnings } = await resolveCredentials({
authToken: opts.authToken,
ct0: opts.ct0,
chromeProfile: opts.chromeProfile || config.chromeProfile,
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
allowChrome: config.allowChrome ?? true,
allowFirefox: config.allowFirefox ?? true,
});
for (const warning of warnings) { for (const warning of warnings) {
console.error(`${p('warn')}${warning}`); console.error(`${p('warn')}${warning}`);
@@ -1138,29 +772,9 @@ program
if (result.success && result.user) { if (result.success && result.user) {
console.log(`${l('user')}@${result.user.username} (${result.user.name})`); console.log(`${l('user')}@${result.user.username} (${result.user.name})`);
console.log(`${l('userId')}${result.user.id}`); console.log(`${l('userId')}${result.user.id}`);
console.log(`${l('engine')}${resolvedEngine}`); console.log(`${l('engine')}graphql`);
console.log(`${l('credentials')}${credentialSource}`); console.log(`${l('credentials')}${credentialSource}`);
} else { } else {
// Fallback: try Sweetistics if available
if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) {
const fallback = await new SweetisticsClient({
baseUrl: sweetistics.baseUrl,
apiKey: requireSweetisticsApiKey(sweetistics.apiKey),
timeoutMs,
}).getCurrentUser();
if (fallback.success && fallback.user) {
const handle = fallback.user.username ? `@${fallback.user.username}` : '(no handle)';
const name = fallback.user.name || handle;
console.log(`${l('user')}${handle} (${name})`);
console.log(`${l('userId')}${fallback.user.id}`);
if (fallback.user.email) {
console.log(`${l('email')}${fallback.user.email}`);
}
console.log(`${l('engine')}sweetistics (fallback)`);
console.log(`${l('credentials')}Sweetistics API key`);
return;
}
}
console.error(`${p('err')}Failed to determine current user: ${result.error ?? 'Unknown error'}`); console.error(`${p('err')}Failed to determine current user: ${result.error ?? 'Unknown error'}`);
process.exit(1); process.exit(1);
} }
@@ -1172,11 +786,7 @@ program
.description('Check credential availability') .description('Check credential availability')
.action(async () => { .action(async () => {
const opts = program.opts(); const opts = program.opts();
const { cookies, warnings } = await resolveCredentials({ const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
authToken: opts.authToken,
ct0: opts.ct0,
chromeProfile: opts.chromeProfile,
});
console.log(`${p('info')}Credential check`); console.log(`${p('info')}Credential check`);
console.log('─'.repeat(40)); console.log('─'.repeat(40));
@@ -1208,7 +818,7 @@ program
console.log(`\n${p('ok')}Ready to tweet!`); console.log(`\n${p('ok')}Ready to tweet!`);
} else { } else {
console.log(`\n${p('err')}Missing credentials. Options:`); console.log(`\n${p('err')}Missing credentials. Options:`);
console.log(' 1. Login to x.com in Chrome'); console.log(' 1. Login to x.com in Safari/Chrome/Firefox');
console.log(' 2. Set AUTH_TOKEN and CT0 environment variables'); console.log(' 2. Set AUTH_TOKEN and CT0 environment variables');
console.log(' 3. Use --auth-token and --ct0 flags'); console.log(' 3. Use --auth-token and --ct0 flags');
process.exit(1); process.exit(1);
+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 };
-19
View File
@@ -1,19 +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
}
export function shouldAllowSweetisticsFallback(engine: EngineMode, hasApiKey: boolean): boolean {
return engine === 'auto' && hasApiKey;
}
+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];
} }
+14 -4
View File
@@ -1,13 +1,21 @@
const HANDLE_REGEX = /^[A-Za-z0-9_]{1,15}$/;
export function normalizeHandle(input?: string | null): string | null { export function normalizeHandle(input?: string | null): string | null {
const raw = (input ?? '').trim(); const raw = (input ?? '').trim();
if (!raw) return null; if (!raw) {
return null;
}
const withoutAt = raw.startsWith('@') ? raw.slice(1) : raw; const withoutAt = raw.startsWith('@') ? raw.slice(1) : raw;
const handle = withoutAt.trim(); const handle = withoutAt.trim();
if (!handle) return null; if (!handle) {
return null;
}
// X/Twitter handles are traditionally max 15 chars; keep strict to avoid surprising queries. // X/Twitter handles are traditionally max 15 chars; keep strict to avoid surprising queries.
if (!/^[A-Za-z0-9_]{1,15}$/.test(handle)) return null; if (!HANDLE_REGEX.test(handle)) {
return null;
}
return handle; return handle;
} }
@@ -16,7 +24,9 @@ export function mentionsQueryFromUserOption(userOption?: string | null): {
query: string | null; query: string | null;
error: string | null; error: string | null;
} { } {
if (typeof userOption === 'undefined') return { query: null, error: null }; if (typeof userOption === 'undefined') {
return { query: null, error: null };
}
const handle = normalizeHandle(userOption); const handle = normalizeHandle(userOption);
if (!handle) { if (!handle) {
+26 -6
View File
@@ -53,14 +53,22 @@ export function resolveOutputConfigFromCommander(
} }
export function statusPrefix(kind: StatusKind, cfg: OutputConfig): string { export function statusPrefix(kind: StatusKind, cfg: OutputConfig): string {
if (cfg.plain) return `${STATUS[kind].plain} `; if (cfg.plain) {
if (cfg.emoji) return `${STATUS[kind].emoji} `; return `${STATUS[kind].plain} `;
}
if (cfg.emoji) {
return `${STATUS[kind].emoji} `;
}
return `${STATUS[kind].text} `; return `${STATUS[kind].text} `;
} }
export function labelPrefix(kind: LabelKind, cfg: OutputConfig): string { export function labelPrefix(kind: LabelKind, cfg: OutputConfig): string {
if (cfg.plain) return `${LABELS[kind].plain} `; if (cfg.plain) {
if (cfg.emoji) return `${LABELS[kind].emoji} `; return `${LABELS[kind].plain} `;
}
if (cfg.emoji) {
return `${LABELS[kind].emoji} `;
}
return `${LABELS[kind].text} `; return `${LABELS[kind].text} `;
} }
@@ -72,7 +80,19 @@ export function formatStatsLine(
const retweetCount = stats.retweetCount ?? 0; const retweetCount = stats.retweetCount ?? 0;
const replyCount = stats.replyCount ?? 0; const replyCount = stats.replyCount ?? 0;
if (cfg.plain) return `likes: ${likeCount} retweets: ${retweetCount} replies: ${replyCount}`; if (cfg.plain) {
if (!cfg.emoji) return `Likes ${likeCount} Retweets ${retweetCount} Replies ${replyCount}`; return `likes: ${likeCount} retweets: ${retweetCount} replies: ${replyCount}`;
}
if (!cfg.emoji) {
return `Likes ${likeCount} Retweets ${retweetCount} Replies ${replyCount}`;
}
return `❤️ ${likeCount} 🔁 ${retweetCount} 💬 ${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)}`;
}
+59 -21
View File
@@ -3,9 +3,9 @@ import { homedir } from 'node:os';
import path from 'node:path'; import path from 'node:path';
const DEFAULT_CACHE_FILENAME = 'query-ids-cache.json'; const DEFAULT_CACHE_FILENAME = 'query-ids-cache.json';
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; const DEFAULT_TTL_MS: number = 24 * 60 * 60 * 1000;
const DISCOVERY_PAGES = [ const DISCOVERY_PAGES: string[] = [
'https://x.com/?lang=en', 'https://x.com/?lang=en',
'https://x.com/explore', 'https://x.com/explore',
'https://x.com/notifications', 'https://x.com/notifications',
@@ -13,6 +13,7 @@ const DISCOVERY_PAGES = [
]; ];
const BUNDLE_URL_REGEX = /https:\/\/abs\.twimg\.com\/responsive-web\/client-web(?:-legacy)?\/[A-Za-z0-9.-]+\.js/g; 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 = [ const OPERATION_PATTERNS = [
{ {
@@ -67,6 +68,15 @@ export type RuntimeQueryIdsOptions = {
fetchImpl?: typeof fetch; 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> { async function fetchText(fetchImpl: typeof fetch, url: string): Promise<string> {
const response = await fetchImpl(url, { headers: HEADERS }); const response = await fetchImpl(url, { headers: HEADERS });
if (!response.ok) { if (!response.ok) {
@@ -85,7 +95,9 @@ function resolveDefaultCachePath(): string {
} }
function parseSnapshot(raw: unknown): RuntimeQueryIdSnapshot | null { function parseSnapshot(raw: unknown): RuntimeQueryIdSnapshot | null {
if (!raw || typeof raw !== 'object') return null; if (!raw || typeof raw !== 'object') {
return null;
}
const record = raw as Record<string, unknown>; const record = raw as Record<string, unknown>;
const fetchedAt = typeof record.fetchedAt === 'string' ? record.fetchedAt : null; const fetchedAt = typeof record.fetchedAt === 'string' ? record.fetchedAt : null;
const ttlMs = typeof record.ttlMs === 'number' && Number.isFinite(record.ttlMs) ? record.ttlMs : null; const ttlMs = typeof record.ttlMs === 'number' && Number.isFinite(record.ttlMs) ? record.ttlMs : null;
@@ -93,10 +105,14 @@ function parseSnapshot(raw: unknown): RuntimeQueryIdSnapshot | null {
const discovery = const discovery =
record.discovery && typeof record.discovery === 'object' ? (record.discovery as Record<string, unknown>) : null; record.discovery && typeof record.discovery === 'object' ? (record.discovery as Record<string, unknown>) : null;
if (!fetchedAt || !ttlMs || !ids || !discovery) return null; if (!fetchedAt || !ttlMs || !ids || !discovery) {
return null;
}
const pages = Array.isArray(discovery.pages) ? discovery.pages : null; const pages = Array.isArray(discovery.pages) ? discovery.pages : null;
const bundles = Array.isArray(discovery.bundles) ? discovery.bundles : null; const bundles = Array.isArray(discovery.bundles) ? discovery.bundles : null;
if (!pages || !bundles) return null; if (!pages || !bundles) {
return null;
}
const normalizedIds: Record<string, string> = {}; const normalizedIds: Record<string, string> = {};
for (const [key, value] of Object.entries(ids)) { for (const [key, value] of Object.entries(ids)) {
@@ -120,9 +136,7 @@ async function readSnapshotFromDisk(cachePath: string): Promise<RuntimeQueryIdSn
try { try {
const raw = await readFile(cachePath, 'utf8'); const raw = await readFile(cachePath, 'utf8');
return parseSnapshot(JSON.parse(raw)); return parseSnapshot(JSON.parse(raw));
} catch (error) { } catch {
const code = error && typeof error === 'object' ? String((error as { code?: unknown }).code ?? '') : '';
if (code !== 'ENOENT') return null;
return null; return null;
} }
} }
@@ -161,15 +175,27 @@ function extractOperations(
pattern.regex.lastIndex = 0; pattern.regex.lastIndex = 0;
while (true) { while (true) {
const match = pattern.regex.exec(bundleContents); const match = pattern.regex.exec(bundleContents);
if (match === null) break; if (match === null) {
break;
}
const operationName = match[pattern.operationGroup]; const operationName = match[pattern.operationGroup];
const queryId = match[pattern.queryIdGroup]; const queryId = match[pattern.queryIdGroup];
if (!operationName || !queryId) continue; if (!operationName || !queryId) {
if (!targets.has(operationName)) continue; continue;
if (!/^[a-zA-Z0-9_-]+$/.test(queryId)) continue; }
if (discovered.has(operationName)) continue; if (!targets.has(operationName)) {
continue;
}
if (!QUERY_ID_REGEX.test(queryId)) {
continue;
}
if (discovered.has(operationName)) {
continue;
}
discovered.set(operationName, { queryId, bundle: bundleLabel }); discovered.set(operationName, { queryId, bundle: bundleLabel });
if (discovered.size === targets.size) return; if (discovered.size === targets.size) {
return;
}
} }
} }
} }
@@ -186,7 +212,9 @@ async function fetchAndExtract(
const chunk = bundleUrls.slice(i, i + CONCURRENCY); const chunk = bundleUrls.slice(i, i + CONCURRENCY);
await Promise.all( await Promise.all(
chunk.map(async (url) => { chunk.map(async (url) => {
if (discovered.size === targets.size) return; if (discovered.size === targets.size) {
return;
}
const label = url.split('/').at(-1) ?? url; const label = url.split('/').at(-1) ?? url;
try { try {
const js = await fetchText(fetchImpl, url); const js = await fetchText(fetchImpl, url);
@@ -196,13 +224,15 @@ async function fetchAndExtract(
} }
}), }),
); );
if (discovered.size === targets.size) break; if (discovered.size === targets.size) {
break;
}
} }
return discovered; return discovered;
} }
export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {}) { export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {}): RuntimeQueryIdStore {
const fetchImpl = options.fetchImpl ?? fetch; const fetchImpl = options.fetchImpl ?? fetch;
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS; const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
const cachePath = options.cachePath ? path.resolve(options.cachePath) : resolveDefaultCachePath(); const cachePath = options.cachePath ? path.resolve(options.cachePath) : resolveDefaultCachePath();
@@ -212,7 +242,9 @@ export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {})
let refreshInFlight: Promise<RuntimeQueryIdSnapshotInfo | null> | null = null; let refreshInFlight: Promise<RuntimeQueryIdSnapshotInfo | null> | null = null;
const loadSnapshot = async (): Promise<RuntimeQueryIdSnapshot | null> => { const loadSnapshot = async (): Promise<RuntimeQueryIdSnapshot | null> => {
if (memorySnapshot) return memorySnapshot; if (memorySnapshot) {
return memorySnapshot;
}
if (!loadOnce) { if (!loadOnce) {
loadOnce = (async () => { loadOnce = (async () => {
const fromDisk = await readSnapshotFromDisk(cachePath); const fromDisk = await readSnapshotFromDisk(cachePath);
@@ -225,7 +257,9 @@ export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {})
const getSnapshotInfo = async (): Promise<RuntimeQueryIdSnapshotInfo | null> => { const getSnapshotInfo = async (): Promise<RuntimeQueryIdSnapshotInfo | null> => {
const snapshot = await loadSnapshot(); const snapshot = await loadSnapshot();
if (!snapshot) return null; if (!snapshot) {
return null;
}
const fetchedAtMs = new Date(snapshot.fetchedAt).getTime(); const fetchedAtMs = new Date(snapshot.fetchedAt).getTime();
const ageMs = Number.isFinite(fetchedAtMs) ? Math.max(0, Date.now() - fetchedAtMs) : Number.POSITIVE_INFINITY; const ageMs = Number.isFinite(fetchedAtMs) ? Math.max(0, Date.now() - fetchedAtMs) : Number.POSITIVE_INFINITY;
const effectiveTtl = Number.isFinite(snapshot.ttlMs) ? snapshot.ttlMs : ttlMs; const effectiveTtl = Number.isFinite(snapshot.ttlMs) ? snapshot.ttlMs : ttlMs;
@@ -235,7 +269,9 @@ export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {})
const getQueryId = async (operationName: string): Promise<string | null> => { const getQueryId = async (operationName: string): Promise<string | null> => {
const info = await getSnapshotInfo(); const info = await getSnapshotInfo();
if (!info) return null; if (!info) {
return null;
}
return info.snapshot.ids[operationName] ?? null; return info.snapshot.ids[operationName] ?? null;
}; };
@@ -243,7 +279,9 @@ export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {})
operationNames: string[], operationNames: string[],
opts: { force?: boolean } = {}, opts: { force?: boolean } = {},
): Promise<RuntimeQueryIdSnapshotInfo | null> => { ): Promise<RuntimeQueryIdSnapshotInfo | null> => {
if (refreshInFlight) return refreshInFlight; if (refreshInFlight) {
return refreshInFlight;
}
refreshInFlight = (async () => { refreshInFlight = (async () => {
const current = await getSnapshotInfo(); const current = await getSnapshotInfo();
-537
View File
@@ -1,537 +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;
const errorCode =
typeof (body as { code?: unknown })?.code === 'string' ? (body as { code: string }).code : undefined;
if (!response.ok || !mediaId) {
const details = [errorCode, errorMessage].filter(Boolean).join(': ');
return { success: false, error: details.length > 0 ? details : `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);
}
}
+387 -43
View File
@@ -2,7 +2,9 @@
* Twitter GraphQL API client for posting tweets and replies * Twitter GraphQL API client for posting tweets and replies
*/ */
import { randomBytes, randomUUID } from 'node:crypto';
import type { TwitterCookies } from './cookies.js'; import type { TwitterCookies } from './cookies.js';
// biome-ignore lint/correctness/useImportExtensions: JSON module import doesn't use .js extension.
import queryIds from './query-ids.json' with { type: 'json' }; import queryIds from './query-ids.json' with { type: 'json' };
import { runtimeQueryIds } from './runtime-query-ids.js'; import { runtimeQueryIds } from './runtime-query-ids.js';
@@ -10,6 +12,10 @@ const TWITTER_API_BASE = 'https://x.com/i/api/graphql';
const TWITTER_GRAPHQL_POST_URL = 'https://x.com/i/api/graphql'; const TWITTER_GRAPHQL_POST_URL = 'https://x.com/i/api/graphql';
const TWITTER_UPLOAD_URL = 'https://upload.twitter.com/i/media/upload.json'; const TWITTER_UPLOAD_URL = 'https://upload.twitter.com/i/media/upload.json';
const TWITTER_MEDIA_METADATA_URL = 'https://x.com/i/api/1.1/media/metadata/create.json'; const TWITTER_MEDIA_METADATA_URL = 'https://x.com/i/api/1.1/media/metadata/create.json';
const TWITTER_STATUS_UPDATE_URL = 'https://x.com/i/api/1.1/statuses/update.json';
const SETTINGS_SCREEN_NAME_REGEX = /"screen_name":"([^"]+)"/;
const SETTINGS_USER_ID_REGEX = /"user_id"\s*:\s*"(\d+)"/;
const SETTINGS_NAME_REGEX = /"name":"([^"\\]*(?:\\.[^"\\]*)*)"/;
// Query IDs rotate frequently; the values in query-ids.json are refreshed by // Query IDs rotate frequently; the values in query-ids.json are refreshed by
// scripts/update-query-ids.ts. The fallback values keep the client usable if // scripts/update-query-ids.ts. The fallback values keep the client usable if
@@ -21,6 +27,7 @@ const FALLBACK_QUERY_IDS = {
TweetDetail: '97JF30KziU00483E_8elBA', TweetDetail: '97JF30KziU00483E_8elBA',
SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ', SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ',
UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg', UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg',
Bookmarks: 'RV1g3b8n_SGOHwkqKYSCFw',
} as const; } as const;
type OperationName = keyof typeof FALLBACK_QUERY_IDS; type OperationName = keyof typeof FALLBACK_QUERY_IDS;
@@ -179,11 +186,15 @@ type GraphqlTweetResult = {
}; };
}; };
export interface TweetResult { export type TweetResult =
success: boolean; | {
tweetId?: string; success: true;
error?: string; tweetId: string;
} }
| {
success: false;
error: string;
};
export interface UploadMediaResult { export interface UploadMediaResult {
success: boolean; success: boolean;
@@ -254,8 +265,12 @@ interface CreateTweetResponse {
export class TwitterClient { export class TwitterClient {
private authToken: string; private authToken: string;
private ct0: string; private ct0: string;
private cookieHeader: string;
private userAgent: string; private userAgent: string;
private timeoutMs?: number; private timeoutMs?: number;
private clientUuid: string;
private clientDeviceId: string;
private clientUserId?: string;
constructor(options: TwitterClientOptions) { constructor(options: TwitterClientOptions) {
if (!options.cookies.authToken || !options.cookies.ct0) { if (!options.cookies.authToken || !options.cookies.ct0) {
@@ -263,10 +278,13 @@ export class TwitterClient {
} }
this.authToken = options.cookies.authToken; this.authToken = options.cookies.authToken;
this.ct0 = options.cookies.ct0; this.ct0 = options.cookies.ct0;
this.cookieHeader = options.cookies.cookieHeader || `auth_token=${this.authToken}; ct0=${this.ct0}`;
this.userAgent = this.userAgent =
options.userAgent || options.userAgent ||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
this.timeoutMs = options.timeoutMs; this.timeoutMs = options.timeoutMs;
this.clientUuid = randomUUID();
this.clientDeviceId = randomUUID();
} }
private async getQueryId(operationName: OperationName): Promise<string> { private async getQueryId(operationName: OperationName): Promise<string> {
@@ -325,7 +343,9 @@ export class TwitterClient {
| undefined, | undefined,
tweetId: string, tweetId: string,
) { ) {
if (!instructions) return undefined; if (!instructions) {
return undefined;
}
for (const instruction of instructions) { for (const instruction of instructions) {
for (const entry of instruction.entries || []) { for (const entry of instruction.entries || []) {
@@ -343,19 +363,34 @@ export class TwitterClient {
return this.getJsonHeaders(); return this.getJsonHeaders();
} }
private createTransactionId(): string {
return randomBytes(16).toString('hex');
}
private getBaseHeaders(): Record<string, string> { private getBaseHeaders(): Record<string, string> {
return { const headers: Record<string, string> = {
accept: '*/*',
'accept-language': 'en-US,en;q=0.9',
authorization: authorization:
'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA', 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA',
'x-csrf-token': this.ct0, 'x-csrf-token': this.ct0,
'x-twitter-auth-type': 'OAuth2Session', 'x-twitter-auth-type': 'OAuth2Session',
'x-twitter-active-user': 'yes', 'x-twitter-active-user': 'yes',
'x-twitter-client-language': 'en', 'x-twitter-client-language': 'en',
cookie: `auth_token=${this.authToken}; ct0=${this.ct0}`, 'x-client-uuid': this.clientUuid,
'x-twitter-client-deviceid': this.clientDeviceId,
'x-client-transaction-id': this.createTransactionId(),
cookie: this.cookieHeader,
'user-agent': this.userAgent, 'user-agent': this.userAgent,
origin: 'https://x.com', origin: 'https://x.com',
referer: 'https://x.com/', referer: 'https://x.com/',
}; };
if (this.clientUserId) {
headers['x-twitter-client-user-id'] = this.clientUserId;
}
return headers;
} }
private getJsonHeaders(): Record<string, string> { private getJsonHeaders(): Record<string, string> {
@@ -372,10 +407,14 @@ export class TwitterClient {
private mediaCategoryForMime(mimeType: string): string | null { private mediaCategoryForMime(mimeType: string): string | null {
if (mimeType.startsWith('image/')) { if (mimeType.startsWith('image/')) {
if (mimeType === 'image/gif') return 'tweet_gif'; if (mimeType === 'image/gif') {
return 'tweet_gif';
}
return 'tweet_image'; return 'tweet_image';
} }
if (mimeType.startsWith('video/')) return 'tweet_video'; if (mimeType.startsWith('video/')) {
return 'tweet_video';
}
return null; return null;
} }
@@ -385,7 +424,9 @@ export class TwitterClient {
async uploadMedia(input: { data: Uint8Array; mimeType: string; alt?: string }): Promise<UploadMediaResult> { async uploadMedia(input: { data: Uint8Array; mimeType: string; alt?: string }): Promise<UploadMediaResult> {
const category = this.mediaCategoryForMime(input.mimeType); const category = this.mediaCategoryForMime(input.mimeType);
if (!category) return { success: false, error: `Unsupported media type: ${input.mimeType}` }; if (!category) {
return { success: false, error: `Unsupported media type: ${input.mimeType}` };
}
try { try {
const initParams = new URLSearchParams({ const initParams = new URLSearchParams({
@@ -413,7 +454,9 @@ export class TwitterClient {
: initBody.media_id !== undefined : initBody.media_id !== undefined
? String(initBody.media_id) ? String(initBody.media_id)
: undefined; : undefined;
if (!mediaId) return { success: false, error: 'Media upload INIT did not return media_id' }; if (!mediaId) {
return { success: false, error: 'Media upload INIT did not return media_id' };
}
const chunkSize = 5 * 1024 * 1024; const chunkSize = 5 * 1024 * 1024;
let segmentIndex = 0; let segmentIndex = 0;
@@ -469,7 +512,10 @@ export class TwitterClient {
const delaySecs = Number.isFinite(info.check_after_secs) ? Math.max(1, info.check_after_secs as number) : 2; const delaySecs = Number.isFinite(info.check_after_secs) ? Math.max(1, info.check_after_secs as number) : 2;
await this.sleep(delaySecs * 1000); await this.sleep(delaySecs * 1000);
const statusUrl = `${TWITTER_UPLOAD_URL}?${new URLSearchParams({ command: 'STATUS', media_id: mediaId })}`; const statusUrl = `${TWITTER_UPLOAD_URL}?${new URLSearchParams({
command: 'STATUS',
media_id: mediaId,
}).toString()}`;
const statusResp = await this.fetchWithTimeout(statusUrl, { const statusResp = await this.fetchWithTimeout(statusUrl, {
method: 'GET', method: 'GET',
headers: this.getUploadHeaders(), headers: this.getUploadHeaders(),
@@ -487,11 +533,15 @@ export class TwitterClient {
error?: { message?: string; name?: string }; error?: { message?: string; name?: string };
}; };
}; };
if (!statusBody.processing_info) break; if (!statusBody.processing_info) {
break;
}
info.state = statusBody.processing_info.state; info.state = statusBody.processing_info.state;
info.check_after_secs = statusBody.processing_info.check_after_secs; info.check_after_secs = statusBody.processing_info.check_after_secs;
info.error = statusBody.processing_info.error; info.error = statusBody.processing_info.error;
if (info.state === 'succeeded') break; if (info.state === 'succeeded') {
break;
}
attempts += 1; attempts += 1;
} }
} }
@@ -518,15 +568,21 @@ export class TwitterClient {
for (const value of values) { for (const value of values) {
if (typeof value === 'string') { if (typeof value === 'string') {
const trimmed = value.trim(); const trimmed = value.trim();
if (trimmed) return trimmed; if (trimmed) {
return trimmed;
}
} }
} }
return undefined; return undefined;
} }
private collectTextFields(value: unknown, keys: Set<string>, output: string[]): void { private collectTextFields(value: unknown, keys: Set<string>, output: string[]): void {
if (!value) return; if (!value) {
if (typeof value === 'string') return; return;
}
if (typeof value === 'string') {
return;
}
if (Array.isArray(value)) { if (Array.isArray(value)) {
for (const item of value) { for (const item of value) {
@@ -540,7 +596,9 @@ export class TwitterClient {
if (keys.has(key)) { if (keys.has(key)) {
if (typeof nested === 'string') { if (typeof nested === 'string') {
const trimmed = nested.trim(); const trimmed = nested.trim();
if (trimmed) output.push(trimmed); if (trimmed) {
output.push(trimmed);
}
continue; continue;
} }
} }
@@ -553,7 +611,9 @@ export class TwitterClient {
const seen = new Set<string>(); const seen = new Set<string>();
const result: string[] = []; const result: string[] = [];
for (const value of values) { for (const value of values) {
if (seen.has(value)) continue; if (seen.has(value)) {
continue;
}
seen.add(value); seen.add(value);
result.push(value); result.push(value);
} }
@@ -562,7 +622,9 @@ export class TwitterClient {
private extractArticleText(result: GraphqlTweetResult | undefined): string | undefined { private extractArticleText(result: GraphqlTweetResult | undefined): string | undefined {
const article = result?.article; const article = result?.article;
if (!article) return undefined; if (!article) {
return undefined;
}
const articleResult = article.article_results?.result ?? article; const articleResult = article.article_results?.result ?? article;
if (process.env.BIRD_DEBUG_ARTICLE === '1') { if (process.env.BIRD_DEBUG_ARTICLE === '1') {
@@ -627,7 +689,9 @@ export class TwitterClient {
private extractNoteTweetText(result: GraphqlTweetResult | undefined): string | undefined { private extractNoteTweetText(result: GraphqlTweetResult | undefined): string | undefined {
const note = result?.note_tweet?.note_tweet_results?.result; const note = result?.note_tweet?.note_tweet_results?.result;
if (!note) return undefined; if (!note) {
return undefined;
}
return this.firstText( return this.firstText(
note.text, note.text,
@@ -652,10 +716,14 @@ export class TwitterClient {
const username = userLegacy?.screen_name ?? userCore?.screen_name; const username = userLegacy?.screen_name ?? userCore?.screen_name;
const name = userLegacy?.name ?? userCore?.name ?? username; const name = userLegacy?.name ?? userCore?.name ?? username;
const userId = userResult?.rest_id; const userId = userResult?.rest_id;
if (!result?.rest_id || !username) return undefined; if (!result?.rest_id || !username) {
return undefined;
}
const text = this.extractTweetText(result); const text = this.extractTweetText(result);
if (!text) return undefined; if (!text) {
return undefined;
}
return { return {
id: result.rest_id, id: result.rest_id,
@@ -713,7 +781,9 @@ export class TwitterClient {
}): GraphqlTweetResult[] { }): GraphqlTweetResult[] {
const results: GraphqlTweetResult[] = []; const results: GraphqlTweetResult[] = [];
const pushResult = (result?: GraphqlTweetResult) => { const pushResult = (result?: GraphqlTweetResult) => {
if (result?.rest_id) results.push(result); if (result?.rest_id) {
results.push(result);
}
}; };
const content = entry.content; const content = entry.content;
@@ -780,7 +850,9 @@ export class TwitterClient {
const results = this.collectTweetResultsFromEntry(entry); const results = this.collectTweetResultsFromEntry(entry);
for (const result of results) { for (const result of results) {
const mapped = this.mapTweetResult(result); const mapped = this.mapTweetResult(result);
if (!mapped || seen.has(mapped.id)) continue; if (!mapped || seen.has(mapped.id)) {
continue;
}
seen.add(mapped.id); seen.add(mapped.id);
tweets.push(mapped); tweets.push(mapped);
} }
@@ -927,7 +999,7 @@ export class TwitterClient {
}); });
const queryId = await this.getQueryId('UserArticlesTweets'); const queryId = await this.getQueryId('UserArticlesTweets');
const url = `${TWITTER_API_BASE}/${queryId}/UserArticlesTweets?${params}`; const url = `${TWITTER_API_BASE}/${queryId}/UserArticlesTweets?${params.toString()}`;
try { try {
const response = await this.fetchWithTimeout(url, { method: 'GET', headers: this.getHeaders() }); const response = await this.fetchWithTimeout(url, { method: 'GET', headers: this.getHeaders() });
@@ -961,7 +1033,9 @@ export class TwitterClient {
for (const instruction of instructions) { for (const instruction of instructions) {
for (const entry of instruction.entries ?? []) { for (const entry of instruction.entries ?? []) {
const result = entry.content?.itemContent?.tweet_results?.result; const result = entry.content?.itemContent?.tweet_results?.result;
if (result?.rest_id !== tweetId) continue; if (result?.rest_id !== tweetId) {
continue;
}
const articleResult = result.article?.article_results?.result; const articleResult = result.article?.article_results?.result;
const title = this.firstText(articleResult?.title, result.article?.title); const title = this.firstText(articleResult?.title, result.article?.title);
const plainText = this.firstText(articleResult?.plain_text, result.article?.plain_text); const plainText = this.firstText(articleResult?.plain_text, result.article?.plain_text);
@@ -1070,7 +1144,7 @@ export class TwitterClient {
const queryIds = await this.getTweetDetailQueryIds(); const queryIds = await this.getTweetDetailQueryIds();
for (const queryId of queryIds) { for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/TweetDetail?${params}`; const url = `${TWITTER_API_BASE}/${queryId}/TweetDetail?${params.toString()}`;
const response = await this.fetchWithTimeout(url, { const response = await this.fetchWithTimeout(url, {
method: 'GET', method: 'GET',
headers: this.getHeaders(), headers: this.getHeaders(),
@@ -1278,6 +1352,7 @@ export class TwitterClient {
variables: Record<string, unknown>, variables: Record<string, unknown>,
features: Record<string, boolean>, features: Record<string, boolean>,
): Promise<TweetResult> { ): Promise<TweetResult> {
await this.ensureClientUserId();
let queryId = await this.getQueryId('CreateTweet'); let queryId = await this.getQueryId('CreateTweet');
let urlWithOperation = `${TWITTER_API_BASE}/${queryId}/CreateTweet`; let urlWithOperation = `${TWITTER_API_BASE}/${queryId}/CreateTweet`;
@@ -1285,9 +1360,10 @@ export class TwitterClient {
let body = buildBody(); let body = buildBody();
try { try {
const headers = { ...this.getHeaders(), referer: 'https://x.com/compose/post' };
let response = await this.fetchWithTimeout(urlWithOperation, { let response = await this.fetchWithTimeout(urlWithOperation, {
method: 'POST', method: 'POST',
headers: this.getHeaders(), headers,
body, body,
}); });
@@ -1301,14 +1377,14 @@ export class TwitterClient {
response = await this.fetchWithTimeout(urlWithOperation, { response = await this.fetchWithTimeout(urlWithOperation, {
method: 'POST', method: 'POST',
headers: this.getHeaders(), headers: { ...this.getHeaders(), referer: 'https://x.com/compose/post' },
body, body,
}); });
if (response.status === 404) { if (response.status === 404) {
const retry = await this.fetchWithTimeout(TWITTER_GRAPHQL_POST_URL, { const retry = await this.fetchWithTimeout(TWITTER_GRAPHQL_POST_URL, {
method: 'POST', method: 'POST',
headers: this.getHeaders(), headers: { ...this.getHeaders(), referer: 'https://x.com/compose/post' },
body, body,
}); });
@@ -1320,11 +1396,17 @@ export class TwitterClient {
const data = (await retry.json()) as CreateTweetResponse; const data = (await retry.json()) as CreateTweetResponse;
if (data.errors && data.errors.length > 0) { if (data.errors && data.errors.length > 0) {
return { success: false, error: data.errors.map((e) => e.message).join(', ') }; const fallback = await this.tryStatusUpdateFallback(data.errors, variables);
if (fallback) {
return fallback;
}
return { success: false, error: this.formatErrors(data.errors) };
} }
const tweetId = data.data?.create_tweet?.tweet_results?.result?.rest_id; const tweetId = data.data?.create_tweet?.tweet_results?.result?.rest_id;
if (tweetId) return { success: true, tweetId }; if (tweetId) {
return { success: true, tweetId };
}
return { success: false, error: 'Tweet created but no ID returned' }; return { success: false, error: 'Tweet created but no ID returned' };
} }
@@ -1341,9 +1423,13 @@ export class TwitterClient {
const data = (await response.json()) as CreateTweetResponse; const data = (await response.json()) as CreateTweetResponse;
if (data.errors && data.errors.length > 0) { if (data.errors && data.errors.length > 0) {
const fallback = await this.tryStatusUpdateFallback(data.errors, variables);
if (fallback) {
return fallback;
}
return { return {
success: false, success: false,
error: data.errors.map((e) => e.message).join(', '), error: this.formatErrors(data.errors),
}; };
} }
@@ -1367,6 +1453,137 @@ export class TwitterClient {
} }
} }
private formatErrors(errors: Array<{ message: string; code?: number }>): string {
return errors
.map((error) => (typeof error.code === 'number' ? `${error.message} (${error.code})` : error.message))
.join(', ');
}
private statusUpdateInputFromCreateTweetVariables(variables: Record<string, unknown>): {
text: string;
inReplyToTweetId?: string;
mediaIds?: string[];
} | null {
const text = typeof variables.tweet_text === 'string' ? variables.tweet_text : null;
if (!text) {
return null;
}
const reply = variables.reply;
const inReplyToTweetId =
reply &&
typeof reply === 'object' &&
typeof (reply as { in_reply_to_tweet_id?: unknown }).in_reply_to_tweet_id === 'string'
? (reply as { in_reply_to_tweet_id: string }).in_reply_to_tweet_id
: undefined;
const media = variables.media;
const mediaEntities =
media && typeof media === 'object' ? (media as { media_entities?: unknown }).media_entities : undefined;
const mediaIds = Array.isArray(mediaEntities)
? mediaEntities
.map((entity) =>
entity && typeof entity === 'object' && 'media_id' in (entity as Record<string, unknown>)
? (entity as { media_id?: unknown }).media_id
: undefined,
)
.filter((value): value is string | number => typeof value === 'string' || typeof value === 'number')
.map((value) => String(value))
: undefined;
return { text, inReplyToTweetId, mediaIds: mediaIds && mediaIds.length > 0 ? mediaIds : undefined };
}
private async postStatusUpdate(input: {
text: string;
inReplyToTweetId?: string;
mediaIds?: string[];
}): Promise<TweetResult> {
const params = new URLSearchParams();
params.set('status', input.text);
if (input.inReplyToTweetId) {
params.set('in_reply_to_status_id', input.inReplyToTweetId);
params.set('auto_populate_reply_metadata', 'true');
}
if (input.mediaIds && input.mediaIds.length > 0) {
params.set('media_ids', input.mediaIds.join(','));
}
try {
const response = await this.fetchWithTimeout(TWITTER_STATUS_UPDATE_URL, {
method: 'POST',
headers: {
...this.getBaseHeaders(),
'content-type': 'application/x-www-form-urlencoded',
referer: 'https://x.com/compose/post',
},
body: params.toString(),
});
if (!response.ok) {
const text = await response.text();
return { success: false, error: `HTTP ${response.status}: ${text.slice(0, 200)}` };
}
const data = (await response.json()) as {
id_str?: string;
id?: string | number;
errors?: Array<{ message: string; code?: number }>;
};
if (data.errors && data.errors.length > 0) {
return { success: false, error: this.formatErrors(data.errors) };
}
const tweetId =
typeof data.id_str === 'string' ? data.id_str : data.id !== undefined ? String(data.id) : undefined;
if (tweetId) {
return { success: true, tweetId };
}
return { success: false, error: 'Tweet created but no ID returned' };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
private async tryStatusUpdateFallback(
errors: Array<{ message: string; code?: number }>,
variables: Record<string, unknown>,
): Promise<TweetResult | null> {
if (!errors.some((error) => error.code === 226)) {
return null;
}
const input = this.statusUpdateInputFromCreateTweetVariables(variables);
if (!input) {
return null;
}
const fallback = await this.postStatusUpdate(input);
if (fallback.success) {
return fallback;
}
return {
success: false,
error: `${this.formatErrors(errors)} | fallback: ${fallback.error ?? 'Unknown error'}`,
};
}
private async ensureClientUserId(): Promise<void> {
if (process.env.NODE_ENV === 'test') {
return;
}
if (this.clientUserId) {
return;
}
const result = await this.getCurrentUser();
if (result.success && result.user?.id) {
this.clientUserId = result.user.id;
}
}
/** /**
* Search for tweets matching a query * Search for tweets matching a query
*/ */
@@ -1390,7 +1607,7 @@ export class TwitterClient {
const queryIds = await this.getSearchTimelineQueryIds(); const queryIds = await this.getSearchTimelineQueryIds();
for (const queryId of queryIds) { for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/SearchTimeline?${params}`; const url = `${TWITTER_API_BASE}/${queryId}/SearchTimeline?${params.toString()}`;
try { try {
const response = await this.fetchWithTimeout(url, { const response = await this.fetchWithTimeout(url, {
@@ -1470,12 +1687,16 @@ export class TwitterClient {
}; };
const firstAttempt = await tryOnce(); const firstAttempt = await tryOnce();
if (firstAttempt.success) return { success: true, tweets: firstAttempt.tweets }; if (firstAttempt.success) {
return { success: true, tweets: firstAttempt.tweets };
}
if (firstAttempt.had404) { if (firstAttempt.had404) {
await this.refreshQueryIds(); await this.refreshQueryIds();
const secondAttempt = await tryOnce(); const secondAttempt = await tryOnce();
if (secondAttempt.success) return { success: true, tweets: secondAttempt.tweets }; if (secondAttempt.success) {
return { success: true, tweets: secondAttempt.tweets };
}
return { success: false, error: secondAttempt.error }; return { success: false, error: secondAttempt.error };
} }
@@ -1543,6 +1764,7 @@ export class TwitterClient {
: null; : null;
if (username && userId) { if (username && userId) {
this.clientUserId = userId;
return { return {
success: true, success: true,
user: { user: {
@@ -1565,7 +1787,7 @@ export class TwitterClient {
try { try {
const response = await this.fetchWithTimeout(page, { const response = await this.fetchWithTimeout(page, {
headers: { headers: {
cookie: `auth_token=${this.authToken}; ct0=${this.ct0}`, cookie: this.cookieHeader,
'user-agent': this.userAgent, 'user-agent': this.userAgent,
}, },
}); });
@@ -1576,9 +1798,9 @@ export class TwitterClient {
} }
const html = await response.text(); const html = await response.text();
const usernameMatch = html.match(/"screen_name":"([^"]+)"/); const usernameMatch = SETTINGS_SCREEN_NAME_REGEX.exec(html);
const idMatch = html.match(/"user_id"\s*:\s*"(\d+)"/); const idMatch = SETTINGS_USER_ID_REGEX.exec(html);
const nameMatch = html.match(/"name":"([^"\\]*(?:\\.[^"\\]*)*)"/); const nameMatch = SETTINGS_NAME_REGEX.exec(html);
const username = usernameMatch?.[1]; const username = usernameMatch?.[1];
const userId = idMatch?.[1]; const userId = idMatch?.[1];
@@ -1612,7 +1834,9 @@ export class TwitterClient {
*/ */
async getReplies(tweetId: string): Promise<SearchResult> { async getReplies(tweetId: string): Promise<SearchResult> {
const response = await this.fetchTweetDetail(tweetId); const response = await this.fetchTweetDetail(tweetId);
if (!response.success) return response; if (!response.success) {
return response;
}
const instructions = response.data.threaded_conversation_with_injections_v2?.instructions; const instructions = response.data.threaded_conversation_with_injections_v2?.instructions;
const tweets = this.parseTweetsFromInstructions(instructions); const tweets = this.parseTweetsFromInstructions(instructions);
@@ -1626,7 +1850,9 @@ export class TwitterClient {
*/ */
async getThread(tweetId: string): Promise<SearchResult> { async getThread(tweetId: string): Promise<SearchResult> {
const response = await this.fetchTweetDetail(tweetId); const response = await this.fetchTweetDetail(tweetId);
if (!response.success) return response; if (!response.success) {
return response;
}
const instructions = response.data.threaded_conversation_with_injections_v2?.instructions; const instructions = response.data.threaded_conversation_with_injections_v2?.instructions;
const tweets = this.parseTweetsFromInstructions(instructions); const tweets = this.parseTweetsFromInstructions(instructions);
@@ -1643,4 +1869,122 @@ export class TwitterClient {
return { success: true, tweets: thread }; return { success: true, tweets: thread };
} }
private buildBookmarksFeatures(): Record<string, boolean> {
return {
...this.buildSearchFeatures(),
graphql_timeline_v2_bookmark_timeline: true,
blue_business_profile_image_shape_enabled: true,
responsive_web_text_conversations_enabled: false,
tweetypie_unmention_optimization_enabled: true,
vibe_api_enabled: true,
responsive_web_twitter_blue_verified_badge_is_enabled: true,
interactive_text_enabled: true,
longform_notetweets_richtext_consumption_enabled: true,
responsive_web_media_download_video_enabled: false,
};
}
private async getBookmarksQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('Bookmarks');
return Array.from(new Set([primary, 'RV1g3b8n_SGOHwkqKYSCFw', 'tmd4ifV8RHltzn8ymGg1aw']));
}
/**
* Get the authenticated user's bookmarks
*/
async getBookmarks(count = 20): Promise<SearchResult> {
const variables = {
count,
includePromotedContent: false,
withDownvotePerspective: false,
withReactionsMetadata: false,
withReactionsPerspective: false,
};
const features = this.buildBookmarksFeatures();
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
});
const tryOnce = async () => {
let lastError: string | undefined;
let had404 = false;
const queryIds = await this.getBookmarksQueryIds();
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/Bookmarks?${params.toString()}`;
try {
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (response.status === 404) {
had404 = true;
lastError = `HTTP ${response.status}`;
continue;
}
if (!response.ok) {
const text = await response.text();
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
}
const data = (await response.json()) as {
data?: {
bookmark_timeline_v2?: {
timeline?: {
instructions?: Array<{
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
}>;
}>;
};
};
};
errors?: Array<{ message: string }>;
};
if (data.errors && data.errors.length > 0) {
return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 };
}
const instructions = data.data?.bookmark_timeline_v2?.timeline?.instructions;
const tweets = this.parseTweetsFromInstructions(instructions);
return { success: true as const, tweets, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return { success: false as const, error: lastError ?? 'Unknown error fetching bookmarks', had404 };
};
const firstAttempt = await tryOnce();
if (firstAttempt.success) {
return { success: true, tweets: firstAttempt.tweets };
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
const secondAttempt = await tryOnce();
if (secondAttempt.success) {
return { success: true, tweets: secondAttempt.tweets };
}
return { success: false, error: secondAttempt.error };
}
return { success: false, error: firstAttempt.error };
}
} }
+46 -17
View File
@@ -5,6 +5,9 @@ import { fileURLToPath } from 'node:url';
declare const __dirname: string | undefined; declare const __dirname: string | undefined;
export const FALLBACK_VERSION = 'unknown'; 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 { function readPackageVersionFromJsonFile(candidate: string): string | null {
try { try {
@@ -37,7 +40,9 @@ function resolveStartDir(importMetaUrl?: string): string {
} }
} }
if (typeof __dirname === 'string' && __dirname.length > 0) return __dirname; if (typeof __dirname === 'string' && __dirname.length > 0) {
return __dirname;
}
return process.cwd(); return process.cwd();
} }
@@ -47,27 +52,37 @@ export function resolvePackageVersion(importMetaUrl?: string): string {
typeof process !== 'undefined' && typeof process.env.BIRD_VERSION === 'string' typeof process !== 'undefined' && typeof process.env.BIRD_VERSION === 'string'
? process.env.BIRD_VERSION.trim() ? process.env.BIRD_VERSION.trim()
: ''; : '';
if (injected.length > 0) return injected; if (injected.length > 0) {
return injected;
}
let dir = resolveStartDir(importMetaUrl); let dir = resolveStartDir(importMetaUrl);
for (let i = 0; i < 10; i += 1) { for (let i = 0; i < 10; i += 1) {
const version = const version =
readPackageVersionFromJsonFile(path.join(dir, 'package.json')) ?? readPackageVersionFromJsonFile(path.join(dir, 'package.json')) ??
readVersionFromTextFile(path.join(dir, 'VERSION')); readVersionFromTextFile(path.join(dir, 'VERSION'));
if (version) return version; if (version) {
return version;
}
const parent = path.dirname(dir); const parent = path.dirname(dir);
if (parent === dir) break; if (parent === dir) {
break;
}
dir = parent; dir = parent;
} }
return FALLBACK_VERSION; return FALLBACK_VERSION;
} }
function truncateSha(sha: string, length = 8): string { function truncateSha(sha: string, length: number = 8): string {
const trimmed = sha.trim(); const trimmed = sha.trim();
if (!trimmed) return ''; if (!trimmed) {
if (trimmed.length <= length) return trimmed; return '';
}
if (trimmed.length <= length) {
return trimmed;
}
return trimmed.slice(0, length); return trimmed.slice(0, length);
} }
@@ -80,14 +95,18 @@ function resolveGitShaFromGitDir(gitDir: string): string | null {
return null; return null;
} }
if (!head) return null; if (!head) {
return null;
}
if (!head.startsWith('ref:')) { if (!head.startsWith('ref:')) {
const sha = truncateSha(head); const sha = truncateSha(head);
return sha.length > 0 ? sha : null; return sha.length > 0 ? sha : null;
} }
const ref = head.replace(/^ref:\s*/i, '').trim(); const ref = head.replace(REF_PREFIX_REGEX, '').trim();
if (!ref) return null; if (!ref) {
return null;
}
const refPath = path.join(gitDir, ref); const refPath = path.join(gitDir, ref);
try { try {
@@ -100,9 +119,11 @@ function resolveGitShaFromGitDir(gitDir: string): string | null {
const packedRefsPath = path.join(gitDir, 'packed-refs'); const packedRefsPath = path.join(gitDir, 'packed-refs');
try { try {
const packed = fs.readFileSync(packedRefsPath, 'utf8'); const packed = fs.readFileSync(packedRefsPath, 'utf8');
const lines = packed.split(/\r?\n/); const lines = packed.split(LINE_SPLIT_REGEX);
for (const line of lines) { for (const line of lines) {
if (!line || line.startsWith('#') || line.startsWith('^')) continue; if (!line || line.startsWith('#') || line.startsWith('^')) {
continue;
}
const [shaRaw, refName] = line.split(' '); const [shaRaw, refName] = line.split(' ');
if (refName?.trim() === ref) { if (refName?.trim() === ref) {
const sha = truncateSha(shaRaw ?? ''); const sha = truncateSha(shaRaw ?? '');
@@ -121,7 +142,9 @@ export function resolveGitSha(importMetaUrl?: string): string | null {
typeof process !== 'undefined' && typeof process.env.BIRD_GIT_SHA === 'string' typeof process !== 'undefined' && typeof process.env.BIRD_GIT_SHA === 'string'
? process.env.BIRD_GIT_SHA.trim() ? process.env.BIRD_GIT_SHA.trim()
: ''; : '';
if (injected.length > 0) return truncateSha(injected); if (injected.length > 0) {
return truncateSha(injected);
}
let dir = resolveStartDir(importMetaUrl); let dir = resolveStartDir(importMetaUrl);
for (let i = 0; i < 10; i += 1) { for (let i = 0; i < 10; i += 1) {
@@ -130,15 +153,19 @@ export function resolveGitSha(importMetaUrl?: string): string | null {
const stat = fs.statSync(dotGit); const stat = fs.statSync(dotGit);
if (stat.isDirectory()) { if (stat.isDirectory()) {
const sha = resolveGitShaFromGitDir(dotGit); const sha = resolveGitShaFromGitDir(dotGit);
if (sha) return sha; if (sha) {
return sha;
}
} else if (stat.isFile()) { } else if (stat.isFile()) {
const txt = fs.readFileSync(dotGit, 'utf8'); const txt = fs.readFileSync(dotGit, 'utf8');
const match = txt.match(/gitdir:\s*(.+)\s*$/i); const match = GITDIR_REGEX.exec(txt);
const gitDir = match?.[1]?.trim(); const gitDir = match?.[1]?.trim();
if (gitDir) { if (gitDir) {
const resolved = path.isAbsolute(gitDir) ? gitDir : path.resolve(dir, gitDir); const resolved = path.isAbsolute(gitDir) ? gitDir : path.resolve(dir, gitDir);
const sha = resolveGitShaFromGitDir(resolved); const sha = resolveGitShaFromGitDir(resolved);
if (sha) return sha; if (sha) {
return sha;
}
} }
} }
} catch { } catch {
@@ -146,7 +173,9 @@ export function resolveGitSha(importMetaUrl?: string): string | null {
} }
const parent = path.dirname(dir); const parent = path.dirname(dir);
if (parent === dir) break; if (parent === dir) {
break;
}
dir = parent; dir = parent;
} }
+2 -9
View File
@@ -45,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');
-29
View File
@@ -1,29 +0,0 @@
import { describe, expect, it } from 'vitest';
import { resolveEngineMode, shouldAllowSweetisticsFallback, 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);
});
it('only allows Sweetistics fallback in auto mode', () => {
expect(shouldAllowSweetisticsFallback('auto', true)).toBe(true);
expect(shouldAllowSweetisticsFallback('auto', false)).toBe(false);
expect(shouldAllowSweetisticsFallback('graphql', true)).toBe(false);
expect(shouldAllowSweetisticsFallback('sweetistics', true)).toBe(false);
});
});
+3 -1
View File
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { mentionsQueryFromUserOption, normalizeHandle } from '../src/lib/normalize-handle.js'; import { mentionsQueryFromUserOption, normalizeHandle } from '../src/lib/normalize-handle.js';
const INVALID_HANDLE_REGEX = /Invalid --user handle/;
describe('normalizeHandle', () => { describe('normalizeHandle', () => {
it('accepts bare handle', () => { it('accepts bare handle', () => {
expect(normalizeHandle('steipete')).toBe('steipete'); expect(normalizeHandle('steipete')).toBe('steipete');
@@ -36,6 +38,6 @@ describe('mentionsQueryFromUserOption', () => {
it('returns error for invalid handle', () => { it('returns error for invalid handle', () => {
const result = mentionsQueryFromUserOption('@stei-pete'); const result = mentionsQueryFromUserOption('@stei-pete');
expect(result.query).toBeNull(); expect(result.query).toBeNull();
expect(result.error).toMatch(/Invalid --user handle/); expect(result.error).toMatch(INVALID_HANDLE_REGEX);
}); });
}); });
+54 -1
View File
@@ -1,7 +1,19 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { labelPrefix, resolveOutputConfigFromArgv, statusPrefix } from '../src/lib/output.js'; import {
formatStatsLine,
formatTweetUrlLine,
labelPrefix,
resolveOutputConfigFromArgv,
resolveOutputConfigFromCommander,
statusPrefix,
} from '../src/lib/output.js';
describe('output', () => { 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', () => { it('plain disables emoji + color', () => {
const cfg = resolveOutputConfigFromArgv(['--plain'], {}, true); const cfg = resolveOutputConfigFromArgv(['--plain'], {}, true);
expect(cfg).toEqual({ plain: true, emoji: false, color: false }); expect(cfg).toEqual({ plain: true, emoji: false, color: false });
@@ -14,9 +26,50 @@ describe('output', () => {
expect(cfg.color).toBe(false); 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', () => { it('--no-emoji switches to text prefixes', () => {
const cfg = resolveOutputConfigFromArgv(['--no-emoji'], {}, true); const cfg = resolveOutputConfigFromArgv(['--no-emoji'], {}, true);
expect(cfg.emoji).toBe(false); expect(cfg.emoji).toBe(false);
expect(statusPrefix('warn', cfg)).toBe('Warning: '); 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);
});
}); });
+81 -1
View File
@@ -1,5 +1,5 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { mkdir, readFile } from 'node:fs/promises'; import { mkdir, readFile, writeFile } from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -8,6 +8,7 @@ import { createRuntimeQueryIdStore } from '../src/lib/runtime-query-ids.js';
describe('runtime-query-ids', () => { describe('runtime-query-ids', () => {
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
delete process.env.BIRD_QUERY_IDS_CACHE;
}); });
it('refreshes IDs by scanning discovered bundles and persists cache', async () => { it('refreshes IDs by scanning discovered bundles and persists cache', async () => {
@@ -60,4 +61,83 @@ describe('runtime-query-ids', () => {
const parsed = JSON.parse(raw) as { ids?: Record<string, string> }; const parsed = JSON.parse(raw) as { ids?: Record<string, string> };
expect(parsed.ids?.SearchTimeline).toBe('EEE'); 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');
});
});
});
+312 -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');
}); });
@@ -169,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,
@@ -348,6 +461,42 @@ describe('TwitterClient', () => {
expect(body.features.rweb_video_screen_enabled).toBe(true); expect(body.features.rweb_video_screen_enabled).toBe(true);
expect(body.features.creator_subscriptions_tweet_preview_api_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');
});
}); });
describe('getTweet', () => { describe('getTweet', () => {
@@ -753,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', () => {
@@ -832,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', () => {
+166 -9
View File
@@ -1,7 +1,24 @@
import { readFileSync } from 'node:fs'; import fs from 'node:fs';
import { fileURLToPath } from 'node:url'; import os from 'node:os';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { afterEach, describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it } from 'vitest';
import { formatVersionLine, resolveGitSha, resolvePackageVersion } from '../src/lib/version.js'; 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', () => { describe('getCliVersion', () => {
afterEach(() => { afterEach(() => {
@@ -10,7 +27,7 @@ describe('getCliVersion', () => {
}); });
it('reads package.json version when available', () => { it('reads package.json version when available', () => {
const raw = readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'); const raw = fs.readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8');
const pkg = JSON.parse(raw) as { version?: unknown }; const pkg = JSON.parse(raw) as { version?: unknown };
expect(resolvePackageVersion()).toBe(pkg.version); expect(resolvePackageVersion()).toBe(pkg.version);
}); });
@@ -21,10 +38,150 @@ describe('getCliVersion', () => {
expect(formatVersionLine('not a url')).toBe('9.9.9 (abcdef12)'); expect(formatVersionLine('not a url')).toBe('9.9.9 (abcdef12)');
}); });
it('adds git sha when available', () => { it('resolves package.json version from importMetaUrl root', () => {
const version = resolvePackageVersion(); withTempDir((dir) => {
const sha = resolveGitSha(); fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ version: '1.2.3' }));
expect(sha).toMatch(/^[0-9a-f]{8}$/); const entry = path.join(dir, 'src', 'entry.ts');
expect(formatVersionLine()).toBe(`${version} (${sha})`); 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,
},
}, },
}, },
}); });