23 Commits
Author SHA1 Message Date
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
28 changed files with 2020 additions and 1512 deletions
+25
View File
@@ -1,5 +1,30 @@
# Changelog # Changelog
## 0.4.1 — Unreleased
## 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
+52 -23
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
@@ -51,31 +67,32 @@ bird query-ids --fresh
- `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 (macOS): Safari, Chrome, Firefox (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,18 +105,15 @@ 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
@@ -109,7 +123,23 @@ Environment shortcuts:
## 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 +154,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 +186,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`).
+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.
+1 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@steipete/bird", "name": "@steipete/bird",
"version": "0.2.0", "version": "0.4.0",
"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",
@@ -29,7 +29,6 @@
}, },
"dependencies": { "dependencies": {
"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"
}, },
-8
View File
@@ -11,9 +11,6 @@ importers:
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
@@ -469,9 +466,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==}
@@ -1003,8 +997,6 @@ snapshots:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
[email protected]: {}
[email protected]: {} [email protected]: {}
[email protected]: [email protected]:
+1 -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';
+73 -533
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,7 +29,6 @@ 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';
@@ -52,17 +46,35 @@ const collect = (value: string, previous: string[] = []) => {
return previous; return previous;
}; };
const COOKIE_SOURCES: CookieSource[] = ['safari', 'chrome', 'firefox'];
function parseCookieSource(value: string): CookieSource {
const normalized = value.trim().toLowerCase();
if (normalized === 'safari' || normalized === 'chrome' || normalized === 'firefox') return normalized;
throw new Error(`Invalid --cookie-source "${value}". Allowed: safari, chrome, firefox.`);
}
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[] = []) => {
previous.push(parseCookieSource(value));
return previous;
};
const p = (kind: Parameters<typeof statusPrefix>[0]) => statusPrefix(kind, output); const p = (kind: Parameters<typeof statusPrefix>[0]) => statusPrefix(kind, output);
const l = (kind: Parameters<typeof labelPrefix>[0]) => labelPrefix(kind, output); const l = (kind: Parameters<typeof labelPrefix>[0]) => labelPrefix(kind, output);
function requireSweetisticsApiKey(apiKey: string | null): string {
if (!apiKey) {
console.error(`${p('err')}Sweetistics API key missing. Set SWEETISTICS_API_KEY or pass --sweetistics-api-key.`);
process.exit(1);
}
return apiKey;
}
function applyOutputFromCommand(command: Command) { function applyOutputFromCommand(command: Command) {
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);
@@ -83,13 +95,9 @@ 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;
}; };
@@ -162,39 +170,46 @@ 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) {
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;
@@ -354,67 +369,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 +404,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 +427,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 +465,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 +481,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 +509,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 +524,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 +542,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 +557,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 +575,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 +592,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 +609,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 +626,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 +635,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}`);
@@ -1015,27 +652,10 @@ program
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))) { console.error(
console.error( `${p('err')}Could not determine current user (${who.error ?? 'Unknown error'}). Use --user <handle>.`,
`${p('warn')}Could not determine current user (${who.error ?? 'Unknown error'}); trying Sweetistics fallback...`, );
); process.exit(1);
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 {
console.error(
`${p('err')}Could not determine current user (${who.error ?? 'Unknown error'}). Use --user <handle>.`,
);
process.exit(1);
}
} else { } else {
query = `@${handle}`; query = `@${handle}`;
} }
@@ -1045,19 +665,6 @@ program
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);
@@ -1071,51 +678,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 +702,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 +716,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 +748,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);
+234 -36
View File
@@ -1,17 +1,18 @@
/** /**
* Chrome cookie extraction for Twitter authentication * Browser cookie extraction for Twitter authentication
* Uses macOS sqlite3 CLI and keychain for decryption - no native dependencies! * Uses sqlite3 CLI where possible and binarycookies parsing for Safari.
*/ */
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import { createDecipheriv, pbkdf2Sync } from 'node:crypto'; import { createDecipheriv, pbkdf2Sync } from 'node:crypto';
import { copyFileSync, existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs'; import { copyFileSync, existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; 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,6 +21,8 @@ export interface CookieExtractionResult {
warnings: string[]; warnings: string[];
} }
export type CookieSource = 'safari' | 'chrome' | 'firefox';
function normalizeValue(value: unknown): string | null { function normalizeValue(value: unknown): string | null {
if (typeof value === 'string') { if (typeof value === 'string') {
const trimmed = value.trim(); const trimmed = value.trim();
@@ -71,6 +74,173 @@ function getFirefoxCookiesPath(profile?: string): string | null {
return pickFirefoxProfile(profilesRoot, profile); return pickFirefoxProfile(profilesRoot, profile);
} }
function getSafariCookiesPath(): string | null {
if (process.platform !== 'darwin') return null;
const home = process.env.HOME || '';
const candidates = [
join(home, 'Library', 'Cookies', 'Cookies.binarycookies'),
join(home, 'Library', 'Containers', 'com.apple.Safari', 'Data', 'Library', 'Cookies', 'Cookies.binarycookies'),
];
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate;
}
return null;
}
const SAFARI_COOKIE_DOMAINS = ['x.com', 'twitter.com'];
const SAFARI_PAGE_SIGNATURE = Buffer.from([0x00, 0x00, 0x01, 0x00]);
function matchesSafariDomain(domain: string | null): boolean {
if (!domain) return false;
const normalized = (domain.startsWith('.') ? domain.slice(1) : domain).toLowerCase();
return SAFARI_COOKIE_DOMAINS.some((target) => normalized === target || normalized.endsWith(`.${target}`));
}
function readSafariCString(buffer: Buffer, start: number, end: number): string | null {
if (start < 0 || start >= end) return null;
let cursor = start;
while (cursor < end && buffer[cursor] !== 0) cursor += 1;
if (cursor >= end) return null;
return buffer.toString('utf8', start, cursor);
}
function serializeCookieJar(jar: Record<string, string>): string {
const entries = Object.entries(jar)
.filter(([, value]) => typeof value === 'string' && value.length > 0)
.sort(([a], [b]) => a.localeCompare(b));
return entries.map(([name, value]) => `${name}=${value}`).join('; ');
}
function parseSafariCookieRecord(
page: Buffer,
offset: number,
jar: Record<string, string>,
cookies: TwitterCookies,
): void {
if (offset < 0 || offset + 4 > page.length) return;
const recordSize = page.readUInt32LE(offset);
const recordEnd = offset + recordSize;
if (recordSize <= 0 || recordEnd > page.length) return;
const headerStart = offset + 4;
const headerSize = 4 + 4 + 4 + 4 + 4 + 4 + 4; // unknown1 + flags + unknown2 + domain + name + path + value
if (headerStart + headerSize > recordEnd) return;
const domainOffset = page.readUInt32LE(headerStart + 12);
const nameOffset = page.readUInt32LE(headerStart + 16);
const valueOffset = page.readUInt32LE(headerStart + 24);
const domain = readSafariCString(page, offset + domainOffset, recordEnd);
const name = readSafariCString(page, offset + nameOffset, recordEnd);
const value = readSafariCString(page, offset + valueOffset, recordEnd);
if (!name || !value || !matchesSafariDomain(domain)) return;
const normalizedValue = normalizeValue(value);
if (!normalizedValue) return;
jar[name] = normalizedValue;
if (name === 'auth_token' && !cookies.authToken) {
cookies.authToken = normalizedValue;
} else if (name === 'ct0' && !cookies.ct0) {
cookies.ct0 = normalizedValue;
}
}
function parseSafariCookiePage(page: Buffer, jar: Record<string, string>, cookies: TwitterCookies): void {
if (page.length < 12) return;
if (!page.subarray(0, 4).equals(SAFARI_PAGE_SIGNATURE)) return;
const cookieCount = page.readUInt32LE(4);
if (!cookieCount) return;
const offsets: number[] = [];
let cursor = 8;
for (let i = 0; i < cookieCount; i += 1) {
if (cursor + 4 > page.length) return;
offsets.push(page.readUInt32LE(cursor));
cursor += 4;
}
for (const offset of offsets) {
parseSafariCookieRecord(page, offset, jar, cookies);
}
}
function parseSafariCookies(data: Buffer, jar: Record<string, string>, cookies: TwitterCookies): void {
if (data.length < 8) return;
if (data.subarray(0, 4).toString('utf8') !== 'cook') return;
const pageCount = data.readUInt32BE(4);
let cursor = 8;
const pageSizes: number[] = [];
for (let i = 0; i < pageCount; i += 1) {
if (cursor + 4 > data.length) return;
pageSizes.push(data.readUInt32BE(cursor));
cursor += 4;
}
for (const pageSize of pageSizes) {
if (cursor + pageSize > data.length) return;
const page = data.subarray(cursor, cursor + pageSize);
parseSafariCookiePage(page, jar, cookies);
cursor += pageSize;
}
}
/**
* Extract Twitter cookies from Safari browser using Cookies.binarycookies
*/
export async function extractCookiesFromSafari(): Promise<CookieExtractionResult> {
const warnings: string[] = [];
const cookies: TwitterCookies = {
authToken: null,
ct0: null,
cookieHeader: null,
source: null,
};
const cookiesPath = getSafariCookiesPath();
if (!cookiesPath) {
warnings.push('Safari cookies database not found.');
return { cookies, warnings };
}
let tempDir: string | null = null;
try {
const jar: Record<string, string> = {};
tempDir = mkdtempSync(join(tmpdir(), 'twitter-cli-'));
const tempCookiesPath = join(tempDir, 'Cookies.binarycookies');
copyFileSync(cookiesPath, tempCookiesPath);
const data = readFileSync(tempCookiesPath);
parseSafariCookies(data, jar, cookies);
if (Object.keys(jar).length > 0) {
cookies.cookieHeader = serializeCookieJar(jar);
}
if (cookies.authToken || cookies.ct0) {
cookies.source = 'Safari';
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
warnings.push(`Failed to read Safari cookies: ${message}`);
} finally {
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 Safari. Make sure you are logged into x.com in Safari.');
}
return { cookies, warnings };
}
/** /**
* Decrypt Chrome cookie value using macOS keychain * Decrypt Chrome cookie value using macOS keychain
* Chrome encrypts cookies with a key stored in the keychain * Chrome encrypts cookies with a key stored in the keychain
@@ -139,6 +309,7 @@ export async function extractCookiesFromChrome(profile?: string): Promise<Cookie
const cookies: TwitterCookies = { const cookies: TwitterCookies = {
authToken: null, authToken: null,
ct0: null, ct0: null,
cookieHeader: null,
source: null, source: null,
}; };
@@ -167,8 +338,10 @@ export async function extractCookiesFromChrome(profile?: string): Promise<Cookie
copyFileSync(shmPath, `${tempDbPath}-shm`); copyFileSync(shmPath, `${tempDbPath}-shm`);
} }
const jar: Record<string, string> = {};
// Use sqlite3 CLI to query cookies (no native deps required!) // Use sqlite3 CLI to query cookies (no native deps required!)
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');`; const query = `SELECT name, hex(encrypted_value) as encrypted_hex FROM cookies WHERE host_key IN ('.x.com', '.twitter.com', 'x.com', 'twitter.com');`;
const result = execSync(`sqlite3 -separator '|' "${tempDbPath}" "${query}"`, { const result = execSync(`sqlite3 -separator '|' "${tempDbPath}" "${query}"`, {
encoding: 'utf8', encoding: 'utf8',
@@ -182,6 +355,7 @@ export async function extractCookiesFromChrome(profile?: string): Promise<Cookie
const decryptedValue = decryptCookieValue(encryptedHex); const decryptedValue = decryptCookieValue(encryptedHex);
if (decryptedValue) { if (decryptedValue) {
jar[name] = decryptedValue;
if (name === 'auth_token' && !cookies.authToken) { if (name === 'auth_token' && !cookies.authToken) {
cookies.authToken = decryptedValue; cookies.authToken = decryptedValue;
} else if (name === 'ct0' && !cookies.ct0) { } else if (name === 'ct0' && !cookies.ct0) {
@@ -191,6 +365,10 @@ export async function extractCookiesFromChrome(profile?: string): Promise<Cookie
} }
} }
if (Object.keys(jar).length > 0) {
cookies.cookieHeader = serializeCookieJar(jar);
}
if (cookies.authToken || cookies.ct0) { if (cookies.authToken || cookies.ct0) {
cookies.source = profile ? `Chrome profile "${profile}"` : 'Chrome default profile'; cookies.source = profile ? `Chrome profile "${profile}"` : 'Chrome default profile';
} }
@@ -224,6 +402,7 @@ export async function extractCookiesFromFirefox(profile?: string): Promise<Cooki
const cookies: TwitterCookies = { const cookies: TwitterCookies = {
authToken: null, authToken: null,
ct0: null, ct0: null,
cookieHeader: null,
source: null, source: null,
}; };
@@ -241,7 +420,9 @@ export async function extractCookiesFromFirefox(profile?: string): Promise<Cooki
const tempDbPath = join(tempDir, 'cookies.sqlite'); const tempDbPath = join(tempDir, 'cookies.sqlite');
copyFileSync(cookiesPath, tempDbPath); 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 jar: Record<string, string> = {};
const query = `SELECT name, value FROM moz_cookies WHERE host IN ('.x.com', '.twitter.com', 'x.com', 'twitter.com');`;
const result = execSync(`sqlite3 -separator '|' "${tempDbPath}" "${query}"`, { const result = execSync(`sqlite3 -separator '|' "${tempDbPath}" "${query}"`, {
encoding: 'utf8', encoding: 'utf8',
@@ -252,6 +433,7 @@ export async function extractCookiesFromFirefox(profile?: string): Promise<Cooki
for (const line of result.split('\n')) { for (const line of result.split('\n')) {
const [name, value] = line.split('|'); const [name, value] = line.split('|');
if (!name || !value) continue; if (!name || !value) continue;
jar[name] = value;
if (name === 'auth_token' && !cookies.authToken) { if (name === 'auth_token' && !cookies.authToken) {
cookies.authToken = value; cookies.authToken = value;
} else if (name === 'ct0' && !cookies.ct0) { } else if (name === 'ct0' && !cookies.ct0) {
@@ -260,6 +442,10 @@ export async function extractCookiesFromFirefox(profile?: string): Promise<Cooki
} }
} }
if (Object.keys(jar).length > 0) {
cookies.cookieHeader = serializeCookieJar(jar);
}
if (cookies.authToken || cookies.ct0) { if (cookies.authToken || cookies.ct0) {
cookies.source = profile ? `Firefox profile "${profile}"` : 'Firefox default profile'; cookies.source = profile ? `Firefox profile "${profile}"` : 'Firefox default profile';
} }
@@ -287,23 +473,25 @@ export async function extractCookiesFromFirefox(profile?: string): Promise<Cooki
/** /**
* Resolve Twitter credentials from multiple sources * Resolve Twitter credentials from multiple sources
* Priority: CLI args > environment variables > Chrome cookies * Priority: CLI args > environment variables > Safari > Chrome > Firefox
*/ */
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: TwitterCookies = {
authToken: null, authToken: null,
ct0: null, ct0: null,
cookieHeader: null,
source: null, source: null,
}; };
const cookieSource = options.cookieSource;
// 1. CLI arguments (highest priority) // 1. CLI arguments (highest priority)
if (options.authToken) { if (options.authToken) {
cookies.authToken = options.authToken; cookies.authToken = options.authToken;
@@ -340,47 +528,57 @@ export async function resolveCredentials(options: {
} }
} }
const allowFirefox = options.allowFirefox ?? true; if (cookies.authToken && cookies.ct0) {
const allowChrome = options.allowChrome ?? true; cookies.cookieHeader = `auth_token=${cookies.authToken}; ct0=${cookies.ct0}`;
return { cookies, warnings };
// 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) const sourcesToTry: CookieSource[] = Array.isArray(cookieSource)
if (allowChrome && (!cookies.authToken || !cookies.ct0)) { ? cookieSource
const chromeResult = await extractCookiesFromChrome(options.chromeProfile); : cookieSource
warnings.push(...chromeResult.warnings); ? [cookieSource]
: ['safari', 'chrome', 'firefox'];
if (!cookies.authToken && chromeResult.cookies.authToken) { for (const source of sourcesToTry) {
cookies.authToken = chromeResult.cookies.authToken; if (source === 'safari') {
cookies.source = chromeResult.cookies.source; const safariResult = await extractCookiesFromSafari();
warnings.push(...safariResult.warnings);
if (safariResult.cookies.authToken && safariResult.cookies.ct0) {
return { cookies: safariResult.cookies, warnings };
}
continue;
} }
if (!cookies.ct0 && chromeResult.cookies.ct0) {
cookies.ct0 = chromeResult.cookies.ct0; if (source === 'chrome') {
if (!cookies.source) cookies.source = chromeResult.cookies.source; const chromeResult = await extractCookiesFromChrome(options.chromeProfile);
warnings.push(...chromeResult.warnings);
if (chromeResult.cookies.authToken && chromeResult.cookies.ct0) {
return { cookies: chromeResult.cookies, warnings };
}
continue;
}
if (source === 'firefox') {
const firefoxResult = await extractCookiesFromFirefox(options.firefoxProfile);
warnings.push(...firefoxResult.warnings);
if (firefoxResult.cookies.authToken && firefoxResult.cookies.ct0) {
return { cookies: firefoxResult.cookies, warnings };
}
} }
} }
// Validation // 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 = `auth_token=${cookies.authToken}; ct0=${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;
}
+8
View File
@@ -76,3 +76,11 @@ export function formatStatsLine(
if (!cfg.emoji) 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)}`;
}
-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);
}
}
+165 -13
View File
@@ -2,6 +2,7 @@
* 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';
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 +11,7 @@ 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';
// 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
@@ -179,11 +181,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 +260,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 +273,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> {
@@ -343,19 +356,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> {
@@ -1278,6 +1306,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 +1314,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 +1331,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,7 +1350,9 @@ 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;
@@ -1341,9 +1373,11 @@ 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 +1401,123 @@ 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
*/ */
@@ -1543,6 +1694,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 +1717,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,
}, },
}); });
+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', () => {
+188 -12
View File
@@ -14,10 +14,78 @@ vi.mock('node:fs', () => {
copyFileSync: vi.fn(), copyFileSync: vi.fn(),
mkdtempSync: vi.fn(() => '/tmp/test-dir'), mkdtempSync: vi.fn(() => '/tmp/test-dir'),
readdirSync: vi.fn(() => []), readdirSync: vi.fn(() => []),
readFileSync: vi.fn(() => Buffer.alloc(0)),
rmSync: vi.fn(), rmSync: vi.fn(),
}; };
}); });
const itIfDarwin = process.platform === 'darwin' ? it : it.skip;
function buildSafariCookieRecord(input: { domain: string; name: string; value: string; path?: string }): Buffer {
const domain = Buffer.from(input.domain, 'utf8');
const name = Buffer.from(input.name, 'utf8');
const path = Buffer.from(input.path ?? '/', 'utf8');
const value = Buffer.from(input.value, 'utf8');
const headerSize = 56;
const domainOffset = headerSize;
const nameOffset = domainOffset + domain.length + 1;
const pathOffset = nameOffset + name.length + 1;
const valueOffset = pathOffset + path.length + 1;
const recordSize = valueOffset + value.length + 1;
const record = Buffer.alloc(recordSize);
record.writeUInt32LE(recordSize, 0);
record.writeUInt32LE(0, 4);
record.writeUInt32LE(0, 8);
record.writeUInt32LE(0, 12);
record.writeUInt32LE(domainOffset, 16);
record.writeUInt32LE(nameOffset, 20);
record.writeUInt32LE(pathOffset, 24);
record.writeUInt32LE(valueOffset, 28);
domain.copy(record, domainOffset);
record[domainOffset + domain.length] = 0;
name.copy(record, nameOffset);
record[nameOffset + name.length] = 0;
path.copy(record, pathOffset);
record[pathOffset + path.length] = 0;
value.copy(record, valueOffset);
record[valueOffset + value.length] = 0;
return record;
}
function buildSafariCookiesFile(records: Buffer[]): Buffer {
const cookieCount = records.length;
const headerSize = 4 + 4 + 4 * cookieCount + 4;
const offsets: number[] = [];
let cursor = headerSize;
for (const record of records) {
offsets.push(cursor);
cursor += record.length;
}
const pageSize = cursor;
const page = Buffer.alloc(pageSize);
page.writeUInt32BE(0x00000100, 0);
page.writeUInt32LE(cookieCount, 4);
offsets.forEach((offset, index) => {
page.writeUInt32LE(offset, 8 + index * 4);
});
page.writeUInt32LE(0, 8 + cookieCount * 4);
offsets.forEach((offset, index) => {
records[index].copy(page, offset);
});
const header = Buffer.alloc(12);
header.write('cook', 0, 'ascii');
header.writeUInt32BE(1, 4);
header.writeUInt32BE(pageSize, 8);
return Buffer.concat([header, page]);
}
describe('cookies', () => { describe('cookies', () => {
const originalEnv = process.env; const originalEnv = process.env;
@@ -37,6 +105,66 @@ describe('cookies', () => {
}); });
describe('resolveCredentials', () => { describe('resolveCredentials', () => {
it('honors cookieSource=firefox even when Safari has cookies', async () => {
const { resolveCredentials } = await import('../src/lib/cookies.js');
const fs = await import('node:fs');
const { execSync } = await import('node:child_process');
// Safari cookie file exists + contains different cookies
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => {
const lower = path.toLowerCase();
if (lower.endsWith('cookies.binarycookies')) return true;
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.readFileSync as unknown as vi.Mock).mockReturnValue(
buildSafariCookiesFile([
buildSafariCookieRecord({ domain: '.x.com', name: 'auth_token', value: 'safari_auth' }),
buildSafariCookieRecord({ domain: '.x.com', name: 'ct0', value: 'safari_ct0' }),
]),
);
// Firefox sqlite3 extraction returns different cookies
(execSync as unknown as vi.Mock).mockImplementation((cmd: string) => {
if (cmd.includes('sqlite3')) return 'auth_token|firefox_auth\nct0|firefox_ct0';
return '';
});
const result = await resolveCredentials({ cookieSource: 'firefox' });
expect(result.cookies.authToken).toBe('firefox_auth');
expect(result.cookies.ct0).toBe('firefox_ct0');
expect(result.cookies.source).toContain('Firefox');
});
itIfDarwin('honors cookieSource=safari', async () => {
const { resolveCredentials } = await import('../src/lib/cookies.js');
const fs = await import('node:fs');
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) =>
path.toLowerCase().endsWith('cookies.binarycookies'),
);
(fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {});
(fs.mkdtempSync as unknown as vi.Mock).mockReturnValue('/tmp/test-dir');
(fs.readFileSync as unknown as vi.Mock).mockReturnValue(
buildSafariCookiesFile([
buildSafariCookieRecord({ domain: '.x.com', name: 'auth_token', value: 'safari_auth' }),
buildSafariCookieRecord({ domain: '.x.com', name: 'ct0', value: 'safari_ct0' }),
]),
);
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 () => { it('uses firefox when enabled and returns cookies', async () => {
const { resolveCredentials } = await import('../src/lib/cookies.js'); const { resolveCredentials } = await import('../src/lib/cookies.js');
const fs = await import('node:fs'); const fs = await import('node:fs');
@@ -58,14 +186,12 @@ describe('cookies', () => {
const { execSync } = await import('node:child_process'); const { execSync } = await import('node:child_process');
(execSync as unknown as vi.Mock).mockReturnValue('auth_token|firefox_auth\nct0|firefox_ct0'); (execSync as unknown as vi.Mock).mockReturnValue('auth_token|firefox_auth\nct0|firefox_ct0');
const result = await resolveCredentials({ const result = await resolveCredentials({ cookieSource: 'firefox', firefoxProfile: 'abc.default-release' });
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.cookieHeader).toContain('auth_token=firefox_auth');
expect(result.cookies.cookieHeader).toContain('ct0=firefox_ct0');
expect(result.cookies.source).toContain('Firefox'); expect(result.cookies.source).toContain('Firefox');
}); });
@@ -81,6 +207,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 +216,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 +228,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 +250,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,13 +259,13 @@ 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',
); );
}); });
@@ -155,7 +282,7 @@ describe('cookies', () => {
}); });
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');
@@ -163,6 +290,55 @@ describe('cookies', () => {
}); });
}); });
describe('extractCookiesFromSafari', () => {
itIfDarwin('returns cookies from Safari binarycookies', async () => {
const fs = await import('node:fs');
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) =>
path.toLowerCase().endsWith('cookies.binarycookies'),
);
(fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {});
(fs.mkdtempSync as unknown as vi.Mock).mockReturnValue('/tmp/test-dir');
(fs.readFileSync as unknown as vi.Mock).mockReturnValue(
buildSafariCookiesFile([
buildSafariCookieRecord({ domain: '.x.com', name: 'auth_token', value: 'safari_auth' }),
buildSafariCookieRecord({ domain: '.x.com', name: 'ct0', value: 'safari_ct0' }),
]),
);
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');
});
itIfDarwin('prefers Safari over Chrome when both are available', async () => {
const fs = await import('node:fs');
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => {
const lower = path.toLowerCase();
if (lower.endsWith('cookies.binarycookies')) return true;
if (lower.includes('chrome') && lower.endsWith('cookies')) return true;
return false;
});
(fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {});
(fs.mkdtempSync as unknown as vi.Mock).mockReturnValue('/tmp/test-dir');
(fs.readFileSync as unknown as vi.Mock).mockReturnValue(
buildSafariCookiesFile([
buildSafariCookieRecord({ domain: '.x.com', name: 'auth_token', value: 'safari_auth' }),
buildSafariCookieRecord({ domain: '.x.com', name: 'ct0', value: 'safari_ct0' }),
]),
);
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 sqlite yields hex values', async () => {
const fs = await import('node:fs'); const fs = await import('node:fs');
-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);
});
});
+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);
});
}); });
-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');
});
});
+240 -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,47 @@ 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('conversation helpers', () => { describe('conversation helpers', () => {
+108 -9
View File
@@ -1,7 +1,18 @@
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, 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 +21,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 +32,98 @@ 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 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('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('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)');
});
}); });
}); });
+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: 70,
branches: 70,
functions: 70,
lines: 70,
},
}, },
}, },
}); });