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

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

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

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

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2025-12-30 22:44:52 +00:00
Peter Steinberger 5532e7ddc2 docs: note sweet-cookie cookie backend 2025-12-27 13:30:29 +01:00
Peter Steinberger c495fb5078 refactor(cookies): use sweet-cookie 2025-12-27 13:23:39 +01:00
26 changed files with 1897 additions and 859 deletions
+1
View File
@@ -0,0 +1 @@
auto-install-peers=false
+14 -1
View File
@@ -1,6 +1,19 @@
# Changelog # Changelog
## 0.4.1 — Unreleased ## 0.4.1 — 2025-12-31
### Added
- `bookmarks` command to list your bookmarked tweets.
### Changed
- Cookie extraction now uses `@steipete/sweet-cookie` (drops `sqlite3` CLI + custom browser readers in `bird`).
- Query ID updater now tracks the Bookmarks GraphQL operation.
- Lint rules stricter (block statements, no-negation-else, useConst/useTemplate, top-level regex, import extension enforcement).
- `pnpm lint` now runs both Biome and oxlint (type-aware).
### Tests
- Coverage thresholds raised to 90% statements/lines/functions (80% branches).
- Added targeted Twitter client coverage suites.
## 0.4.0 — 2025-12-26 ## 0.4.0 — 2025-12-26
+6 -2
View File
@@ -48,6 +48,9 @@ bird search "from:steipete" -n 5
bird mentions -n 5 bird mentions -n 5
bird mentions --user @steipete -n 5 bird mentions --user @steipete -n 5
# Bookmarks
bird bookmarks -n 5
# Refresh GraphQL query IDs cache (no rebuild) # Refresh GraphQL query IDs cache (no rebuild)
bird query-ids --fresh bird query-ids --fresh
``` ```
@@ -64,6 +67,7 @@ bird query-ids --fresh
- `bird thread <tweet-id-or-url> [--json]` — show the full conversation thread. - `bird thread <tweet-id-or-url> [--json]` — show the full conversation thread.
- `bird search "<query>" [-n count] [--json]` — search for tweets matching a query. - `bird search "<query>" [-n count] [--json]` — search for tweets matching a query.
- `bird mentions [-n count] [--user @handle] [--json]` — find tweets mentioning a user (defaults to the authenticated user). - `bird mentions [-n count] [--user @handle] [--json]` — find tweets mentioning a user (defaults to the authenticated user).
- `bird bookmarks [-n count] [--json]` — list your bookmarked tweets.
- `bird whoami` — print which Twitter account your cookies belong to. - `bird whoami` — print which Twitter account your cookies belong to.
- `bird check` — show which credentials are available and where they were sourced from. - `bird check` — show which credentials are available and where they were sourced from.
@@ -87,7 +91,7 @@ Write operations:
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): Safari, Chrome, Firefox (override via `--cookie-source` order) 3. Browser cookies via `@steipete/sweet-cookie` (override via `--cookie-source` order)
Browser cookie sources: Browser cookie sources:
- Safari: `~/Library/Cookies/Cookies.binarycookies` (fallback: `~/Library/Containers/com.apple.Safari/Data/Library/Cookies/Cookies.binarycookies`) - Safari: `~/Library/Cookies/Cookies.binarycookies` (fallback: `~/Library/Containers/com.apple.Safari/Data/Library/Cookies/Cookies.binarycookies`)
@@ -117,7 +121,7 @@ Environment shortcuts:
## Output ## Output
- `--json` prints raw tweet objects for read/replies/thread/search/mentions. - `--json` prints raw tweet objects for read/replies/thread/search/mentions/bookmarks.
- `read` returns full text for Notes and Articles when present. - `read` returns full text for Notes and Articles when present.
- Use `--plain` for stable, script-friendly output (no emoji, no color). - Use `--plain` for stable, script-friendly output (no emoji, no color).
+23 -2
View File
@@ -11,10 +11,31 @@
"rules": { "rules": {
"recommended": true, "recommended": true,
"complexity": { "complexity": {
"noForEach": "off" "noForEach": "error"
},
"correctness": {
"useImportExtensions": {
"level": "error",
"options": {
"forceJsExtensions": true
}
}
},
"performance": {
"useTopLevelRegex": "error"
}, },
"style": { "style": {
"noNonNullAssertion": "off" "noNegationElse": "error",
"noNonNullAssertion": "error",
"useBlockStatements": "error",
"useConst": "error",
"useTemplate": "error"
},
"suspicious": {
"noExplicitAny": "error"
},
"nursery": {
"useRegexpExec": "error"
} }
} }
}, },
+11 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@steipete/bird", "name": "@steipete/bird",
"version": "0.4.0", "version": "0.4.1",
"description": "CLI tool for tweeting and replying via Twitter/X GraphQL API", "description": "CLI tool for tweeting and replying via Twitter/X GraphQL API",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
@@ -21,13 +21,18 @@
"bird": "pnpm run build:dist && node dist/index.js", "bird": "pnpm run build:dist && node dist/index.js",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"lint": "biome check .", "lint": "pnpm run lint:biome && pnpm run lint:oxlint",
"lint:fix": "biome check --write .", "lint:biome": "biome check .",
"lint:oxlint": "oxlint --type-aware --tsconfig tsconfig.oxlint.json --import-plugin --node-plugin --vitest-plugin --deny-warnings src tests scripts",
"lint:fix": "pnpm run lint:biome:fix && pnpm run lint:oxlint:fix",
"lint:biome:fix": "biome check --write .",
"lint:oxlint:fix": "oxlint --type-aware --tsconfig tsconfig.oxlint.json --import-plugin --node-plugin --vitest-plugin --deny-warnings --fix src tests scripts",
"format": "biome format --write .", "format": "biome format --write .",
"binary": "pnpm run build:binary", "binary": "pnpm run build:binary",
"graphql:update": "tsx scripts/update-query-ids.ts" "graphql:update": "tsx scripts/update-query-ids.ts"
}, },
"dependencies": { "dependencies": {
"@steipete/sweet-cookie": "0.1.0",
"commander": "^14.0.2", "commander": "^14.0.2",
"json5": "^2.2.3", "json5": "^2.2.3",
"kleur": "^4.1.5" "kleur": "^4.1.5"
@@ -36,11 +41,13 @@
"@biomejs/biome": "^2.3.10", "@biomejs/biome": "^2.3.10",
"@types/node": "^25.0.3", "@types/node": "^25.0.3",
"@vitest/coverage-v8": "4.0.16", "@vitest/coverage-v8": "4.0.16",
"oxlint": "^1.36.0",
"oxlint-tsgolint": "^0.10.0",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vitest": "^4.0.16" "vitest": "^4.0.16"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=22"
} }
} }
+163 -1
View File
@@ -1,13 +1,16 @@
lockfileVersion: '9.0' lockfileVersion: '9.0'
settings: settings:
autoInstallPeers: true autoInstallPeers: false
excludeLinksFromLockfile: false excludeLinksFromLockfile: false
importers: importers:
.: .:
dependencies: dependencies:
'@steipete/sweet-cookie':
specifier: 0.1.0
version: 0.1.0
commander: commander:
specifier: ^14.0.2 specifier: ^14.0.2
version: 14.0.2 version: 14.0.2
@@ -27,6 +30,12 @@ importers:
'@vitest/coverage-v8': '@vitest/coverage-v8':
specifier: 4.0.16 specifier: 4.0.16
version: 4.0.16([email protected](@types/[email protected])([email protected])) version: 4.0.16([email protected](@types/[email protected])([email protected]))
oxlint:
specifier: ^1.36.0
version: 1.36.0([email protected])
oxlint-tsgolint:
specifier: ^0.10.0
version: 0.10.0
tsx: tsx:
specifier: ^4.21.0 specifier: ^4.21.0
version: 4.21.0 version: 4.21.0
@@ -279,6 +288,76 @@ packages:
'@jridgewell/[email protected]': '@jridgewell/[email protected]':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@oxlint-tsgolint/[email protected]':
resolution: {integrity: sha512-mhBF/pjey0UdLL1ocU46Fqta+uJuRfqrLfDpcViRg17BtDiUNd8JY9iN2FOoS2HGSCAgCUjZ0AZkwkHwFs/VTw==}
cpu: [arm64]
os: [darwin]
'@oxlint-tsgolint/[email protected]':
resolution: {integrity: sha512-roLi34mw/i1z+NS7luboix55SXyhVv38dNUTcRDkk+0lNPzI9ngrM+1y1N2oBSUmz5o9OZGnfJJ7BSGCw/fFEQ==}
cpu: [x64]
os: [darwin]
'@oxlint-tsgolint/[email protected]':
resolution: {integrity: sha512-HL9NThPH1V2F6l9XhwNmhQZUknN4m4yQYEvQFFGfZTYN6cvEEBIiqfF4KvBUg8c0xadMbQlW+Ug7/ybA9Nn+CA==}
cpu: [arm64]
os: [linux]
'@oxlint-tsgolint/[email protected]':
resolution: {integrity: sha512-Tw8QNq8ab+4+qE5krvJyMA66v6XE3GoiISRD5WmJ7YOxUnu//jSw/bBm7OYf/TNEZyeV0BTR7zXzhT5R+VFWlQ==}
cpu: [x64]
os: [linux]
'@oxlint-tsgolint/[email protected]':
resolution: {integrity: sha512-LTogmTRwpwQqVaH1Ama8Wd5/VVZWBSF8v5qTbeT628+1F5Kt1V5eHBvyFh4oN18UCZlgqrh7DqkDhsieXUaC8Q==}
cpu: [arm64]
os: [win32]
'@oxlint-tsgolint/[email protected]':
resolution: {integrity: sha512-ygqxx8EmNWy9/wCQS5uXq9k/o2EyYNwNxY1ZHNzlmZC/kV06Aemx5OBDafefawBNqH7xTZPfccUrjdiy+QlTrw==}
cpu: [x64]
os: [win32]
'@oxlint/[email protected]':
resolution: {integrity: sha512-MJkj82GH+nhvWKJhSIM6KlZ8tyGKdogSQXtNdpIyP02r/tTayFJQaAEWayG2Jhsn93kske+nimg5MYFhwO/rlg==}
cpu: [arm64]
os: [darwin]
'@oxlint/[email protected]':
resolution: {integrity: sha512-VvEhfkqj/99dCTqOcfkyFXOSbx4lIy5u2m2GHbK4WCMDySokOcMTNRHGw8fH/WgQ5cDrDMSTYIGQTmnBGi9tiQ==}
cpu: [x64]
os: [darwin]
'@oxlint/[email protected]':
resolution: {integrity: sha512-EMx92X5q+hHc3olTuj/kgkx9+yP0p/AVs4yvHbUfzZhBekXNpUWxWvg4hIKmQWn+Ee2j4o80/0ACGO0hDYJ9mg==}
cpu: [arm64]
os: [linux]
'@oxlint/[email protected]':
resolution: {integrity: sha512-7YCxtrPIctVYLqWrWkk8pahdCxch6PtsaucfMLC7TOlDt4nODhnQd4yzEscKqJ8Gjrw1bF4g+Ngob1gB+Qr9Fw==}
cpu: [arm64]
os: [linux]
'@oxlint/[email protected]':
resolution: {integrity: sha512-lnaJVlx5r3NWmoOMesfQXJSf78jHTn8Z+sdAf795Kgteo72+qGC1Uax2SToCJVN2J8PNG3oRV5bLriiCNR2i6Q==}
cpu: [x64]
os: [linux]
'@oxlint/[email protected]':
resolution: {integrity: sha512-AhuEU2Qdl66lSfTGu/Htirq8r/8q2YnZoG3yEXLMQWnPMn7efy8spD/N1NA7kH0Hll+cdfwgQkQqC2G4MS2lPQ==}
cpu: [x64]
os: [linux]
'@oxlint/[email protected]':
resolution: {integrity: sha512-GlWCBjUJY2QgvBFuNRkiRJu7K/djLmM0UQKfZV8IN+UXbP/JbjZHWKRdd4LXlQmzoz7M5Hd6p+ElCej8/90FCg==}
cpu: [arm64]
os: [win32]
'@oxlint/[email protected]':
resolution: {integrity: sha512-J+Vc00Utcf8p77lZPruQgb0QnQXuKnFogN88kCnOqs2a83I+vTBB8ILr0+L9sTwVRvIDMSC0pLdeQH4svWGFZg==}
cpu: [x64]
os: [win32]
'@rollup/[email protected]': '@rollup/[email protected]':
resolution: {integrity: sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==} resolution: {integrity: sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==}
cpu: [arm] cpu: [arm]
@@ -392,6 +471,10 @@ packages:
'@standard-schema/[email protected]': '@standard-schema/[email protected]':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@steipete/[email protected]':
resolution: {integrity: sha512-cF8hS6rTohAFI1NAm2/HM9omBlw6V1DpwF6q5e9FB+l274HjtyirCgCc6QK2Rtuh7uDoqWHshFQZPsceyJuVgw==}
engines: {node: '>=22'}
'@types/[email protected]': '@types/[email protected]':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@@ -554,6 +637,20 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
[email protected]:
resolution: {integrity: sha512-LDDSIu5J/4D4gFUuQQIEQpAC6maNEbMg4nC8JL/+Pe0cUDR86dtVZ09E2x5MwCh8f9yfktoaxt5x6UIVyzrajg==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-IicUdXfXgI8OKrDPnoSjvBfeEF8PkKtm+CoLlg4LYe4ypc8U+T4r7730XYshdBGZdelg+JRw8GtCb2w/KaaZvw==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
oxlint-tsgolint: '>=0.10.0'
peerDependenciesMeta:
oxlint-tsgolint:
optional: true
[email protected]: [email protected]:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@@ -844,6 +941,48 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@oxlint-tsgolint/[email protected]':
optional: true
'@oxlint-tsgolint/[email protected]':
optional: true
'@oxlint-tsgolint/[email protected]':
optional: true
'@oxlint-tsgolint/[email protected]':
optional: true
'@oxlint-tsgolint/[email protected]':
optional: true
'@oxlint-tsgolint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@rollup/[email protected]': '@rollup/[email protected]':
optional: true optional: true
@@ -912,6 +1051,8 @@ snapshots:
'@standard-schema/[email protected]': {} '@standard-schema/[email protected]': {}
'@steipete/[email protected]': {}
'@types/[email protected]': '@types/[email protected]':
dependencies: dependencies:
'@types/deep-eql': 4.0.2 '@types/deep-eql': 4.0.2
@@ -1096,6 +1237,27 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]:
optionalDependencies:
'@oxlint-tsgolint/darwin-arm64': 0.10.0
'@oxlint-tsgolint/darwin-x64': 0.10.0
'@oxlint-tsgolint/linux-arm64': 0.10.0
'@oxlint-tsgolint/linux-x64': 0.10.0
'@oxlint-tsgolint/win32-arm64': 0.10.0
'@oxlint-tsgolint/win32-x64': 0.10.0
[email protected]([email protected]):
optionalDependencies:
'@oxlint/darwin-arm64': 1.36.0
'@oxlint/darwin-x64': 1.36.0
'@oxlint/linux-arm64-gnu': 1.36.0
'@oxlint/linux-arm64-musl': 1.36.0
'@oxlint/linux-x64-gnu': 1.36.0
'@oxlint/linux-x64-musl': 1.36.0
'@oxlint/win32-arm64': 1.36.0
'@oxlint/win32-x64': 1.36.0
oxlint-tsgolint: 0.10.0
[email protected]: {} [email protected]: {}
[email protected]: {} [email protected]: {}
+1
View File
@@ -13,6 +13,7 @@ const TARGET_OPERATIONS = [
'FavoriteTweet', 'FavoriteTweet',
'TweetDetail', 'TweetDetail',
'SearchTimeline', 'SearchTimeline',
'Bookmarks',
] as const; ] as const;
type OperationName = (typeof TARGET_OPERATIONS)[number]; type OperationName = (typeof TARGET_OPERATIONS)[number];
+101 -31
View File
@@ -32,16 +32,19 @@ import { runtimeQueryIds } from './lib/runtime-query-ids.js';
import { type TweetData, TwitterClient } from './lib/twitter-client.js'; import { type TweetData, TwitterClient } from './lib/twitter-client.js';
import { getCliVersion } from './lib/version.js'; import { getCliVersion } from './lib/version.js';
const program = new Command(); const program: Command = new Command();
const rawArgs = process.argv.slice(2); const rawArgs: string[] = process.argv.slice(2);
const normalizedArgs = rawArgs[0] === '--' ? rawArgs.slice(1) : rawArgs; const normalizedArgs: string[] = rawArgs[0] === '--' ? rawArgs.slice(1) : rawArgs;
const isTty = process.stdout.isTTY; const isTty: boolean = process.stdout.isTTY;
let output: OutputConfig = resolveOutputConfigFromArgv(normalizedArgs, process.env, isTty); let output: OutputConfig = resolveOutputConfigFromArgv(normalizedArgs, process.env, isTty);
kleur.enabled = output.color; kleur.enabled = output.color;
const wrap = (styler: (text: string) => string) => (text: string) => (isTty ? styler(text) : text); const wrap =
const collect = (value: string, previous: string[] = []) => { (styler: (text: string) => string): ((text: string) => string) =>
(text: string): string =>
isTty ? styler(text) : text;
const collect = (value: string, previous: string[] = []): string[] => {
previous.push(value); previous.push(value);
return previous; return previous;
}; };
@@ -50,16 +53,22 @@ const COOKIE_SOURCES: CookieSource[] = ['safari', 'chrome', 'firefox'];
function parseCookieSource(value: string): CookieSource { function parseCookieSource(value: string): CookieSource {
const normalized = value.trim().toLowerCase(); const normalized = value.trim().toLowerCase();
if (normalized === 'safari' || normalized === 'chrome' || normalized === 'firefox') return normalized; if (normalized === 'safari' || normalized === 'chrome' || normalized === 'firefox') {
return normalized;
}
throw new Error(`Invalid --cookie-source "${value}". Allowed: safari, chrome, firefox.`); throw new Error(`Invalid --cookie-source "${value}". Allowed: safari, chrome, firefox.`);
} }
function resolveCookieSourceOrder(input: unknown): CookieSource[] | undefined { function resolveCookieSourceOrder(input: unknown): CookieSource[] | undefined {
if (typeof input === 'string') return [parseCookieSource(input)]; if (typeof input === 'string') {
return [parseCookieSource(input)];
}
if (Array.isArray(input)) { if (Array.isArray(input)) {
const result: CookieSource[] = []; const result: CookieSource[] = [];
for (const entry of input) { for (const entry of input) {
if (typeof entry !== 'string') continue; if (typeof entry !== 'string') {
continue;
}
result.push(parseCookieSource(entry)); result.push(parseCookieSource(entry));
} }
return result.length > 0 ? result : undefined; return result.length > 0 ? result : undefined;
@@ -67,15 +76,15 @@ function resolveCookieSourceOrder(input: unknown): CookieSource[] | undefined {
return undefined; return undefined;
} }
const collectCookieSource = (value: string, previous: CookieSource[] = []) => { const collectCookieSource = (value: string, previous: CookieSource[] = []): CookieSource[] => {
previous.push(parseCookieSource(value)); previous.push(parseCookieSource(value));
return previous; return previous;
}; };
const p = (kind: Parameters<typeof statusPrefix>[0]) => statusPrefix(kind, output); const p = (kind: Parameters<typeof statusPrefix>[0]): string => statusPrefix(kind, output);
const l = (kind: Parameters<typeof labelPrefix>[0]) => labelPrefix(kind, output); const l = (kind: Parameters<typeof labelPrefix>[0]): string => labelPrefix(kind, output);
function applyOutputFromCommand(command: Command) { function applyOutputFromCommand(command: Command): void {
const opts = command.optsWithGlobals() as { plain?: boolean; emoji?: boolean; color?: boolean }; const opts = command.optsWithGlobals() as { plain?: boolean; emoji?: boolean; color?: boolean };
output = resolveOutputConfigFromCommander(opts, process.env, isTty); output = resolveOutputConfigFromCommander(opts, process.env, isTty);
kleur.enabled = output.color; kleur.enabled = output.color;
@@ -102,7 +111,9 @@ type BirdConfig = {
}; };
function readConfigFile(path: string): Partial<BirdConfig> { function readConfigFile(path: string): Partial<BirdConfig> {
if (!existsSync(path)) return {}; if (!existsSync(path)) {
return {};
}
try { try {
const raw = readFileSync(path, 'utf8'); const raw = readFileSync(path, 'utf8');
const parsed = JSON5.parse(raw) as Partial<BirdConfig>; const parsed = JSON5.parse(raw) as Partial<BirdConfig>;
@@ -138,6 +149,7 @@ const KNOWN_COMMANDS = new Set([
'thread', 'thread',
'search', 'search',
'mentions', 'mentions',
'bookmarks',
'help', 'help',
'whoami', 'whoami',
'check', 'check',
@@ -150,7 +162,7 @@ program.addHelpText(
program.name('bird').description('Post tweets and replies via Twitter/X GraphQL API').version(getCliVersion()); program.name('bird').description('Post tweets and replies via Twitter/X GraphQL API').version(getCliVersion());
const formatExample = (command: string, description: string) => const formatExample = (command: string, description: string): string =>
`${colors.command(` ${command}`)}\n${colors.muted(` ${description}`)}`; `${colors.command(` ${command}`)}\n${colors.muted(` ${description}`)}`;
program.addHelpText( program.addHelpText(
@@ -191,7 +203,7 @@ type CredentialsOptions = {
cookieSource?: CookieSource[]; cookieSource?: CookieSource[];
}; };
function resolveCredentialsFromOptions(opts: CredentialsOptions) { function resolveCredentialsFromOptions(opts: CredentialsOptions): ReturnType<typeof resolveCredentials> {
const cookieSource = opts.cookieSource?.length const cookieSource = opts.cookieSource?.length
? opts.cookieSource ? opts.cookieSource
: (resolveCookieSourceOrder(config.cookieSource) ?? COOKIE_SOURCES); : (resolveCookieSourceOrder(config.cookieSource) ?? COOKIE_SOURCES);
@@ -212,9 +224,13 @@ type MediaSpec = { path: string; alt?: string; mime: string; buffer: Buffer };
function resolveTimeoutMs(...values: Array<string | number | undefined | null>): number | undefined { function resolveTimeoutMs(...values: Array<string | number | undefined | null>): number | undefined {
for (const value of values) { for (const value of values) {
if (value === undefined || value === null || value === '') continue; if (value === undefined || value === null || value === '') {
continue;
}
const parsed = typeof value === 'number' ? value : Number(value); const parsed = typeof value === 'number' ? value : Number(value);
if (Number.isFinite(parsed) && parsed > 0) return parsed; if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
} }
return undefined; return undefined;
} }
@@ -225,17 +241,31 @@ function resolveTimeoutFromOptions(options: { timeout?: string | number }): numb
function detectMime(path: string): string | null { function detectMime(path: string): string | null {
const ext = path.toLowerCase(); const ext = path.toLowerCase();
if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) return 'image/jpeg'; if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) {
if (ext.endsWith('.png')) return 'image/png'; return 'image/jpeg';
if (ext.endsWith('.webp')) return 'image/webp'; }
if (ext.endsWith('.gif')) return 'image/gif'; if (ext.endsWith('.png')) {
if (ext.endsWith('.mp4') || ext.endsWith('.m4v')) return 'video/mp4'; return 'image/png';
if (ext.endsWith('.mov')) return 'video/quicktime'; }
if (ext.endsWith('.webp')) {
return 'image/webp';
}
if (ext.endsWith('.gif')) {
return 'image/gif';
}
if (ext.endsWith('.mp4') || ext.endsWith('.m4v')) {
return 'video/mp4';
}
if (ext.endsWith('.mov')) {
return 'video/quicktime';
}
return null; return null;
} }
function loadMedia(opts: { media: string[]; alts: string[] }): MediaSpec[] { function loadMedia(opts: { media: string[]; alts: string[] }): MediaSpec[] {
if (opts.media.length === 0) return []; if (opts.media.length === 0) {
return [];
}
const specs: MediaSpec[] = []; const specs: MediaSpec[] = [];
for (const [index, path] of opts.media.entries()) { for (const [index, path] of opts.media.entries()) {
const mime = detectMime(path); const mime = detectMime(path);
@@ -247,9 +277,15 @@ function loadMedia(opts: { media: string[]; alts: string[] }): MediaSpec[] {
} }
const videoCount = specs.filter((m) => m.mime.startsWith('video/')).length; const videoCount = specs.filter((m) => m.mime.startsWith('video/')).length;
if (videoCount > 1) throw new Error('Only one video can be attached'); if (videoCount > 1) {
if (videoCount === 1 && specs.length > 1) throw new Error('Video cannot be combined with other media'); throw new Error('Only one video can be attached');
if (specs.length > 4) throw new Error('Maximum 4 media attachments'); }
if (videoCount === 1 && specs.length > 1) {
throw new Error('Video cannot be combined with other media');
}
if (specs.length > 4) {
throw new Error('Maximum 4 media attachments');
}
return specs; return specs;
} }
@@ -310,6 +346,7 @@ program
'TweetDetail', 'TweetDetail',
'SearchTimeline', 'SearchTimeline',
'UserArticlesTweets', 'UserArticlesTweets',
'Bookmarks',
]; ];
if (cmdOpts.fresh) { if (cmdOpts.fresh) {
@@ -651,13 +688,13 @@ program
if (!query) { if (!query) {
const who = await client.getCurrentUser(); const who = await client.getCurrentUser();
const handle = normalizeHandle(who.user?.username); const handle = normalizeHandle(who.user?.username);
if (!handle) { if (handle) {
query = `@${handle}`;
} else {
console.error( console.error(
`${p('err')}Could not determine current user (${who.error ?? 'Unknown error'}). Use --user <handle>.`, `${p('err')}Could not determine current user (${who.error ?? 'Unknown error'}). Use --user <handle>.`,
); );
process.exit(1); process.exit(1);
} else {
query = `@${handle}`;
} }
} }
@@ -671,6 +708,39 @@ program
} }
}); });
// Bookmarks command - get user's bookmarks
program
.command('bookmarks')
.description('Get your bookmarked tweets')
.option('-n, --count <number>', 'Number of bookmarks to fetch', '20')
.option('--json', 'Output as JSON')
.action(async (cmdOpts: { count?: string; json?: boolean }) => {
const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs });
const result = await client.getBookmarks(count);
if (result.success && result.tweets) {
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No bookmarks found.' });
} else {
console.error(`${p('err')}Failed to fetch bookmarks: ${result.error}`);
process.exit(1);
}
});
// Whoami command - show the logged-in account // Whoami command - show the logged-in account
program program
.command('whoami') .command('whoami')
+3 -1
View File
@@ -8,7 +8,9 @@ const TWEET_ID_REGEX = /^\d{8,}$/;
export function looksLikeTweetInput(value: string): boolean { export function looksLikeTweetInput(value: string): boolean {
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed) return false; if (!trimmed) {
return false;
}
return TWEET_URL_REGEX.test(trimmed) || TWEET_ID_REGEX.test(trimmed); return TWEET_URL_REGEX.test(trimmed) || TWEET_ID_REGEX.test(trimmed);
} }
+142 -505
View File
@@ -1,13 +1,9 @@
/** /**
* Browser cookie extraction for Twitter authentication * Browser cookie extraction for Twitter authentication.
* Uses sqlite3 CLI where possible and binarycookies parsing for Safari. * Delegates to @steipete/sweet-cookie for Safari/Chrome/Firefox reads.
*/ */
import { execSync } from 'node:child_process'; import { getCookies } from '@steipete/sweet-cookie';
import { createDecipheriv, pbkdf2Sync } from 'node:crypto';
import { copyFileSync, existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
export interface TwitterCookies { export interface TwitterCookies {
authToken: string | null; authToken: string | null;
@@ -23,457 +19,150 @@ export interface CookieExtractionResult {
export type CookieSource = 'safari' | 'chrome' | 'firefox'; export type CookieSource = 'safari' | 'chrome' | 'firefox';
const TWITTER_COOKIE_NAMES = ['auth_token', 'ct0'] as const;
const TWITTER_URL = 'https://x.com/';
const TWITTER_ORIGINS: string[] = ['https://x.com/', 'https://twitter.com/'];
function normalizeValue(value: unknown): string | null { function normalizeValue(value: unknown): string | null {
if (typeof value === 'string') { if (typeof value !== 'string') {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
return null;
}
function getChromeCookiesPath(profile?: string): string {
const home = process.env.HOME || '';
const profileDir = profile || 'Default';
return join(home, 'Library', 'Application Support', 'Google', 'Chrome', profileDir, 'Cookies');
}
function getFirefoxProfilesRoot(): string | null {
const home = process.env.HOME || '';
if (process.platform === 'darwin') {
return join(home, 'Library', 'Application Support', 'Firefox', 'Profiles');
}
if (process.platform === 'linux') {
return join(home, '.mozilla', 'firefox');
}
if (process.platform === 'win32') {
const appData = process.env.APPDATA;
if (!appData) return null;
return join(appData, 'Mozilla', 'Firefox', 'Profiles');
}
return null;
}
function pickFirefoxProfile(profilesRoot: string, profile?: string): string | null {
if (profile) {
const candidate = join(profilesRoot, profile, 'cookies.sqlite');
return existsSync(candidate) ? candidate : null;
}
const entries = readdirSync(profilesRoot, { withFileTypes: true });
const defaultRelease = entries.find((entry) => entry.isDirectory() && entry.name.includes('default-release'));
const targetDir = defaultRelease?.name ?? entries.find((e) => e.isDirectory())?.name;
if (!targetDir) return null;
const candidate = join(profilesRoot, targetDir, 'cookies.sqlite');
return existsSync(candidate) ? candidate : null;
}
function getFirefoxCookiesPath(profile?: string): string | null {
const profilesRoot = getFirefoxProfilesRoot();
if (!profilesRoot || !existsSync(profilesRoot)) return null;
return pickFirefoxProfile(profilesRoot, profile);
}
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
* Chrome encrypts cookies with a key stored in the keychain
*/
function decryptCookieValue(encryptedHex: string): string | null {
try {
// Convert hex to buffer
const encryptedValue = Buffer.from(encryptedHex, 'hex');
if (encryptedValue.length < 4) {
return null;
}
const version = encryptedValue.subarray(0, 3).toString('utf8');
if (version !== 'v10' && version !== 'v11') {
// Not encrypted, just return as string
return encryptedValue.toString('utf8');
}
// Get encryption key from keychain
const keyOutput = execSync('security find-generic-password -s "Chrome Safe Storage" -w 2>/dev/null || echo ""', {
encoding: 'utf8',
}).trim();
if (!keyOutput) {
return null;
}
// Derive the key using PBKDF2
const salt = 'saltysalt';
const iterations = 1003;
const keyLength = 16;
const derivedKey = pbkdf2Sync(keyOutput, salt, iterations, keyLength, 'sha1');
// Decrypt using AES-128-CBC with empty IV (16 bytes of 0x20/space)
const iv = Buffer.alloc(16, 0x20);
const encryptedData = encryptedValue.subarray(3); // Skip "v10" or "v11" prefix
const decipher = createDecipheriv('aes-128-cbc', derivedKey, iv);
decipher.setAutoPadding(true);
let decrypted = decipher.update(encryptedData);
decrypted = Buffer.concat([decrypted, decipher.final()]);
// Chrome v10 cookies have key material prepended before the actual value
// Twitter cookies are hex strings, so extract the longest hex sequence
const decryptedStr = decrypted.toString('utf8');
const hexMatch = decryptedStr.match(/[a-f0-9]{32,}/i);
if (hexMatch) {
return hexMatch[0];
}
// Fallback: keep only printable ASCII characters
return decryptedStr.replace(/[^\x20-\x7E]/g, '');
} catch {
return null; return null;
} }
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
} }
/** function cookieHeader(authToken: string, ct0: string): string {
* Extract Twitter cookies from Chrome browser using sqlite3 CLI return `auth_token=${authToken}; ct0=${ct0}`;
* @param profile - Chrome profile name (optional, uses Default if not specified) }
*/
export async function extractCookiesFromChrome(profile?: string): Promise<CookieExtractionResult> { function buildEmpty(): TwitterCookies {
return { authToken: null, ct0: null, cookieHeader: null, source: null };
}
function readEnvCookie(cookies: TwitterCookies, keys: readonly string[], field: 'authToken' | 'ct0'): void {
if (cookies[field]) {
return;
}
for (const key of keys) {
const value = normalizeValue(process.env[key]);
if (!value) {
continue;
}
cookies[field] = value;
if (!cookies.source) {
cookies.source = `env ${key}`;
}
break;
}
}
function resolveSources(cookieSource?: CookieSource | CookieSource[]): CookieSource[] {
if (Array.isArray(cookieSource)) {
return cookieSource;
}
if (cookieSource) {
return [cookieSource];
}
return ['safari', 'chrome', 'firefox'];
}
function labelForSource(source: CookieSource, profile?: string): string {
if (source === 'safari') {
return 'Safari';
}
if (source === 'chrome') {
return profile ? `Chrome profile "${profile}"` : 'Chrome default profile';
}
return profile ? `Firefox profile "${profile}"` : 'Firefox default profile';
}
function pickCookieValue(
cookies: Array<{ name?: string; value?: string; domain?: string }>,
name: (typeof TWITTER_COOKIE_NAMES)[number],
): string | null {
const matches = cookies.filter((c) => c?.name === name && typeof c.value === 'string');
if (matches.length === 0) {
return null;
}
const preferred = matches.find((c) => (c.domain ?? '').endsWith('x.com'));
if (preferred?.value) {
return preferred.value;
}
const twitter = matches.find((c) => (c.domain ?? '').endsWith('twitter.com'));
if (twitter?.value) {
return twitter.value;
}
return matches[0]?.value ?? null;
}
async function readTwitterCookiesFromBrowser(options: {
source: CookieSource;
chromeProfile?: string;
firefoxProfile?: string;
}): Promise<CookieExtractionResult> {
const warnings: string[] = []; const warnings: string[] = [];
const cookies: TwitterCookies = { const out = buildEmpty();
authToken: null,
ct0: null,
cookieHeader: null,
source: null,
};
const cookiesPath = getChromeCookiesPath(profile); const { cookies, warnings: providerWarnings } = await getCookies({
url: TWITTER_URL,
origins: TWITTER_ORIGINS,
names: [...TWITTER_COOKIE_NAMES],
browsers: [options.source],
mode: 'merge',
chromeProfile: options.chromeProfile,
firefoxProfile: options.firefoxProfile,
});
warnings.push(...providerWarnings);
if (!existsSync(cookiesPath)) { const authToken = pickCookieValue(cookies, 'auth_token');
warnings.push(`Chrome cookies database not found at: ${cookiesPath}`); const ct0 = pickCookieValue(cookies, 'ct0');
return { cookies, warnings }; if (authToken) {
out.authToken = authToken;
}
if (ct0) {
out.ct0 = ct0;
} }
// Chrome locks the database, so we need to copy it if (out.authToken && out.ct0) {
let tempDir: string | null = null; out.cookieHeader = cookieHeader(out.authToken, out.ct0);
out.source = labelForSource(
try { options.source,
tempDir = mkdtempSync(join(tmpdir(), 'twitter-cli-')); options.source === 'chrome' ? options.chromeProfile : options.firefoxProfile,
const tempDbPath = join(tempDir, 'Cookies'); );
copyFileSync(cookiesPath, tempDbPath); return { cookies: out, warnings };
// Also copy the WAL and SHM files if they exist
const walPath = `${cookiesPath}-wal`;
const shmPath = `${cookiesPath}-shm`;
if (existsSync(walPath)) {
copyFileSync(walPath, `${tempDbPath}-wal`);
}
if (existsSync(shmPath)) {
copyFileSync(shmPath, `${tempDbPath}-shm`);
}
const jar: Record<string, string> = {};
// 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');`;
const result = execSync(`sqlite3 -separator '|' "${tempDbPath}" "${query}"`, {
encoding: 'utf8',
maxBuffer: 1024 * 1024,
}).trim();
if (result) {
for (const line of result.split('\n')) {
const [name, encryptedHex] = line.split('|');
if (!name || !encryptedHex) continue;
const decryptedValue = decryptCookieValue(encryptedHex);
if (decryptedValue) {
jar[name] = decryptedValue;
if (name === 'auth_token' && !cookies.authToken) {
cookies.authToken = decryptedValue;
} else if (name === 'ct0' && !cookies.ct0) {
cookies.ct0 = decryptedValue;
}
}
}
}
if (Object.keys(jar).length > 0) {
cookies.cookieHeader = serializeCookieJar(jar);
}
if (cookies.authToken || cookies.ct0) {
cookies.source = profile ? `Chrome profile "${profile}"` : 'Chrome default profile';
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
warnings.push(`Failed to read Chrome cookies: ${message}`);
} finally {
// Cleanup temp files
if (tempDir) {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
}
} }
if (!cookies.authToken && !cookies.ct0) { if (options.source === 'safari') {
warnings.push('No Twitter cookies found in Safari. Make sure you are logged into x.com in Safari.');
} else if (options.source === 'chrome') {
warnings.push('No Twitter cookies found in Chrome. Make sure you are logged into x.com in Chrome.'); warnings.push('No Twitter cookies found in Chrome. Make sure you are logged into x.com in Chrome.');
} } else {
return { cookies, warnings };
}
/**
* Extract Twitter cookies from Firefox browser using sqlite3 CLI
* @param profile - Firefox profile directory name (optional, auto-detected)
*/
export async function extractCookiesFromFirefox(profile?: string): Promise<CookieExtractionResult> {
const warnings: string[] = [];
const cookies: TwitterCookies = {
authToken: null,
ct0: null,
cookieHeader: null,
source: null,
};
const cookiesPath = getFirefoxCookiesPath(profile);
if (!cookiesPath) {
warnings.push('Firefox cookies database not found.');
return { cookies, warnings };
}
let tempDir: string | null = null;
try {
tempDir = mkdtempSync(join(tmpdir(), 'twitter-cli-'));
const tempDbPath = join(tempDir, 'cookies.sqlite');
copyFileSync(cookiesPath, tempDbPath);
const 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}"`, {
encoding: 'utf8',
maxBuffer: 1024 * 1024,
}).trim();
if (result) {
for (const line of result.split('\n')) {
const [name, value] = line.split('|');
if (!name || !value) continue;
jar[name] = value;
if (name === 'auth_token' && !cookies.authToken) {
cookies.authToken = value;
} else if (name === 'ct0' && !cookies.ct0) {
cookies.ct0 = value;
}
}
}
if (Object.keys(jar).length > 0) {
cookies.cookieHeader = serializeCookieJar(jar);
}
if (cookies.authToken || cookies.ct0) {
cookies.source = profile ? `Firefox profile "${profile}"` : 'Firefox default profile';
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
warnings.push(`Failed to read Firefox cookies: ${message}`);
} finally {
if (tempDir) {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {
// ignore cleanup errors
}
}
}
if (!cookies.authToken && !cookies.ct0) {
warnings.push( warnings.push(
'No Twitter cookies found in Firefox. Make sure you are logged into x.com in Firefox and the profile exists.', 'No Twitter cookies found in Firefox. Make sure you are logged into x.com in Firefox and the profile exists.',
); );
} }
return { cookies, warnings }; return { cookies: out, warnings };
}
export async function extractCookiesFromSafari(): Promise<CookieExtractionResult> {
return readTwitterCookiesFromBrowser({ source: 'safari' });
}
export async function extractCookiesFromChrome(profile?: string): Promise<CookieExtractionResult> {
return readTwitterCookiesFromBrowser({ source: 'chrome', chromeProfile: profile });
}
export async function extractCookiesFromFirefox(profile?: string): Promise<CookieExtractionResult> {
return readTwitterCookiesFromBrowser({ source: 'firefox', firefoxProfile: profile });
} }
/** /**
* Resolve Twitter credentials from multiple sources * Resolve Twitter credentials from multiple sources.
* Priority: CLI args > environment variables > Safari > Chrome > Firefox * Priority: CLI args > environment variables > browsers (ordered).
*/ */
export async function resolveCredentials(options: { export async function resolveCredentials(options: {
authToken?: string; authToken?: string;
@@ -483,91 +172,40 @@ export async function resolveCredentials(options: {
firefoxProfile?: string; firefoxProfile?: string;
}): Promise<CookieExtractionResult> { }): Promise<CookieExtractionResult> {
const warnings: string[] = []; const warnings: string[] = [];
const cookies: TwitterCookies = { const cookies = buildEmpty();
authToken: null,
ct0: null,
cookieHeader: null,
source: null,
};
const cookieSource = options.cookieSource;
// 1. CLI arguments (highest priority)
if (options.authToken) { if (options.authToken) {
cookies.authToken = options.authToken; cookies.authToken = options.authToken;
cookies.source = 'CLI argument'; cookies.source = 'CLI argument';
} }
if (options.ct0) { if (options.ct0) {
cookies.ct0 = options.ct0; cookies.ct0 = options.ct0;
if (!cookies.source) cookies.source = 'CLI argument'; if (!cookies.source) {
} cookies.source = 'CLI argument';
// 2. Environment variables
const envAuthKeys = ['AUTH_TOKEN', 'TWITTER_AUTH_TOKEN'];
const envCt0Keys = ['CT0', 'TWITTER_CT0'];
if (!cookies.authToken) {
for (const key of envAuthKeys) {
const value = normalizeValue(process.env[key]);
if (value) {
cookies.authToken = value;
cookies.source = `env ${key}`;
break;
}
} }
} }
if (!cookies.ct0) { readEnvCookie(cookies, ['AUTH_TOKEN', 'TWITTER_AUTH_TOKEN'], 'authToken');
for (const key of envCt0Keys) { readEnvCookie(cookies, ['CT0', 'TWITTER_CT0'], 'ct0');
const value = normalizeValue(process.env[key]);
if (value) {
cookies.ct0 = value;
if (!cookies.source) cookies.source = `env ${key}`;
break;
}
}
}
if (cookies.authToken && cookies.ct0) { if (cookies.authToken && cookies.ct0) {
cookies.cookieHeader = `auth_token=${cookies.authToken}; ct0=${cookies.ct0}`; cookies.cookieHeader = cookieHeader(cookies.authToken, cookies.ct0);
return { cookies, warnings }; return { cookies, warnings };
} }
const sourcesToTry: CookieSource[] = Array.isArray(cookieSource) const sourcesToTry = resolveSources(options.cookieSource);
? cookieSource
: cookieSource
? [cookieSource]
: ['safari', 'chrome', 'firefox'];
for (const source of sourcesToTry) { for (const source of sourcesToTry) {
if (source === 'safari') { const res = await readTwitterCookiesFromBrowser({
const safariResult = await extractCookiesFromSafari(); source,
warnings.push(...safariResult.warnings); chromeProfile: options.chromeProfile,
if (safariResult.cookies.authToken && safariResult.cookies.ct0) { firefoxProfile: options.firefoxProfile,
return { cookies: safariResult.cookies, warnings }; });
} warnings.push(...res.warnings);
continue; if (res.cookies.authToken && res.cookies.ct0) {
} return { cookies: res.cookies, warnings };
if (source === 'chrome') {
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
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 Safari/Chrome/Firefox', 'Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or login to x.com in Safari/Chrome/Firefox',
@@ -576,9 +214,8 @@ export async function resolveCredentials(options: {
if (!cookies.ct0) { if (!cookies.ct0) {
warnings.push('Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Safari/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) { if (cookies.authToken && cookies.ct0) {
cookies.cookieHeader = `auth_token=${cookies.authToken}; ct0=${cookies.ct0}`; cookies.cookieHeader = cookieHeader(cookies.authToken, cookies.ct0);
} }
return { cookies, warnings }; return { cookies, warnings };
+3 -1
View File
@@ -1,9 +1,11 @@
/** /**
* Extract tweet ID from a Twitter/X URL or return the input unchanged if it's already an ID. * Extract tweet ID from a Twitter/X URL or return the input unchanged if it's already an ID.
*/ */
const TWEET_URL_REGEX = /(?:twitter\.com|x\.com)\/\w+\/status\/(\d+)/;
export function extractTweetId(input: string): string { export function extractTweetId(input: string): string {
// If it's a URL, extract the tweet ID // If it's a URL, extract the tweet ID
const urlMatch = input.match(/(?:twitter\.com|x\.com)\/\w+\/status\/(\d+)/); const urlMatch = TWEET_URL_REGEX.exec(input);
if (urlMatch) { if (urlMatch) {
return urlMatch[1]; return urlMatch[1];
} }
+14 -4
View File
@@ -1,13 +1,21 @@
const HANDLE_REGEX = /^[A-Za-z0-9_]{1,15}$/;
export function normalizeHandle(input?: string | null): string | null { export function normalizeHandle(input?: string | null): string | null {
const raw = (input ?? '').trim(); const raw = (input ?? '').trim();
if (!raw) return null; if (!raw) {
return null;
}
const withoutAt = raw.startsWith('@') ? raw.slice(1) : raw; const withoutAt = raw.startsWith('@') ? raw.slice(1) : raw;
const handle = withoutAt.trim(); const handle = withoutAt.trim();
if (!handle) return null; if (!handle) {
return null;
}
// X/Twitter handles are traditionally max 15 chars; keep strict to avoid surprising queries. // X/Twitter handles are traditionally max 15 chars; keep strict to avoid surprising queries.
if (!/^[A-Za-z0-9_]{1,15}$/.test(handle)) return null; if (!HANDLE_REGEX.test(handle)) {
return null;
}
return handle; return handle;
} }
@@ -16,7 +24,9 @@ export function mentionsQueryFromUserOption(userOption?: string | null): {
query: string | null; query: string | null;
error: string | null; error: string | null;
} { } {
if (typeof userOption === 'undefined') return { query: null, error: null }; if (typeof userOption === 'undefined') {
return { query: null, error: null };
}
const handle = normalizeHandle(userOption); const handle = normalizeHandle(userOption);
if (!handle) { if (!handle) {
+18 -6
View File
@@ -53,14 +53,22 @@ export function resolveOutputConfigFromCommander(
} }
export function statusPrefix(kind: StatusKind, cfg: OutputConfig): string { export function statusPrefix(kind: StatusKind, cfg: OutputConfig): string {
if (cfg.plain) return `${STATUS[kind].plain} `; if (cfg.plain) {
if (cfg.emoji) return `${STATUS[kind].emoji} `; return `${STATUS[kind].plain} `;
}
if (cfg.emoji) {
return `${STATUS[kind].emoji} `;
}
return `${STATUS[kind].text} `; return `${STATUS[kind].text} `;
} }
export function labelPrefix(kind: LabelKind, cfg: OutputConfig): string { export function labelPrefix(kind: LabelKind, cfg: OutputConfig): string {
if (cfg.plain) return `${LABELS[kind].plain} `; if (cfg.plain) {
if (cfg.emoji) return `${LABELS[kind].emoji} `; return `${LABELS[kind].plain} `;
}
if (cfg.emoji) {
return `${LABELS[kind].emoji} `;
}
return `${LABELS[kind].text} `; return `${LABELS[kind].text} `;
} }
@@ -72,8 +80,12 @@ export function formatStatsLine(
const retweetCount = stats.retweetCount ?? 0; const retweetCount = stats.retweetCount ?? 0;
const replyCount = stats.replyCount ?? 0; const replyCount = stats.replyCount ?? 0;
if (cfg.plain) return `likes: ${likeCount} retweets: ${retweetCount} replies: ${replyCount}`; if (cfg.plain) {
if (!cfg.emoji) return `Likes ${likeCount} Retweets ${retweetCount} Replies ${replyCount}`; return `likes: ${likeCount} retweets: ${retweetCount} replies: ${replyCount}`;
}
if (!cfg.emoji) {
return `Likes ${likeCount} Retweets ${retweetCount} Replies ${replyCount}`;
}
return `❤️ ${likeCount} 🔁 ${retweetCount} 💬 ${replyCount}`; return `❤️ ${likeCount} 🔁 ${retweetCount} 💬 ${replyCount}`;
} }
+59 -21
View File
@@ -3,9 +3,9 @@ import { homedir } from 'node:os';
import path from 'node:path'; import path from 'node:path';
const DEFAULT_CACHE_FILENAME = 'query-ids-cache.json'; const DEFAULT_CACHE_FILENAME = 'query-ids-cache.json';
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; const DEFAULT_TTL_MS: number = 24 * 60 * 60 * 1000;
const DISCOVERY_PAGES = [ const DISCOVERY_PAGES: string[] = [
'https://x.com/?lang=en', 'https://x.com/?lang=en',
'https://x.com/explore', 'https://x.com/explore',
'https://x.com/notifications', 'https://x.com/notifications',
@@ -13,6 +13,7 @@ const DISCOVERY_PAGES = [
]; ];
const BUNDLE_URL_REGEX = /https:\/\/abs\.twimg\.com\/responsive-web\/client-web(?:-legacy)?\/[A-Za-z0-9.-]+\.js/g; const BUNDLE_URL_REGEX = /https:\/\/abs\.twimg\.com\/responsive-web\/client-web(?:-legacy)?\/[A-Za-z0-9.-]+\.js/g;
const QUERY_ID_REGEX = /^[a-zA-Z0-9_-]+$/;
const OPERATION_PATTERNS = [ const OPERATION_PATTERNS = [
{ {
@@ -67,6 +68,15 @@ export type RuntimeQueryIdsOptions = {
fetchImpl?: typeof fetch; fetchImpl?: typeof fetch;
}; };
export type RuntimeQueryIdStore = {
cachePath: string;
ttlMs: number;
getSnapshotInfo: () => Promise<RuntimeQueryIdSnapshotInfo | null>;
getQueryId: (operationName: string) => Promise<string | null>;
refresh: (operationNames: string[], opts?: { force?: boolean }) => Promise<RuntimeQueryIdSnapshotInfo | null>;
clearMemory: () => void;
};
async function fetchText(fetchImpl: typeof fetch, url: string): Promise<string> { async function fetchText(fetchImpl: typeof fetch, url: string): Promise<string> {
const response = await fetchImpl(url, { headers: HEADERS }); const response = await fetchImpl(url, { headers: HEADERS });
if (!response.ok) { if (!response.ok) {
@@ -85,7 +95,9 @@ function resolveDefaultCachePath(): string {
} }
function parseSnapshot(raw: unknown): RuntimeQueryIdSnapshot | null { function parseSnapshot(raw: unknown): RuntimeQueryIdSnapshot | null {
if (!raw || typeof raw !== 'object') return null; if (!raw || typeof raw !== 'object') {
return null;
}
const record = raw as Record<string, unknown>; const record = raw as Record<string, unknown>;
const fetchedAt = typeof record.fetchedAt === 'string' ? record.fetchedAt : null; const fetchedAt = typeof record.fetchedAt === 'string' ? record.fetchedAt : null;
const ttlMs = typeof record.ttlMs === 'number' && Number.isFinite(record.ttlMs) ? record.ttlMs : null; const ttlMs = typeof record.ttlMs === 'number' && Number.isFinite(record.ttlMs) ? record.ttlMs : null;
@@ -93,10 +105,14 @@ function parseSnapshot(raw: unknown): RuntimeQueryIdSnapshot | null {
const discovery = const discovery =
record.discovery && typeof record.discovery === 'object' ? (record.discovery as Record<string, unknown>) : null; record.discovery && typeof record.discovery === 'object' ? (record.discovery as Record<string, unknown>) : null;
if (!fetchedAt || !ttlMs || !ids || !discovery) return null; if (!fetchedAt || !ttlMs || !ids || !discovery) {
return null;
}
const pages = Array.isArray(discovery.pages) ? discovery.pages : null; const pages = Array.isArray(discovery.pages) ? discovery.pages : null;
const bundles = Array.isArray(discovery.bundles) ? discovery.bundles : null; const bundles = Array.isArray(discovery.bundles) ? discovery.bundles : null;
if (!pages || !bundles) return null; if (!pages || !bundles) {
return null;
}
const normalizedIds: Record<string, string> = {}; const normalizedIds: Record<string, string> = {};
for (const [key, value] of Object.entries(ids)) { for (const [key, value] of Object.entries(ids)) {
@@ -120,9 +136,7 @@ async function readSnapshotFromDisk(cachePath: string): Promise<RuntimeQueryIdSn
try { try {
const raw = await readFile(cachePath, 'utf8'); const raw = await readFile(cachePath, 'utf8');
return parseSnapshot(JSON.parse(raw)); return parseSnapshot(JSON.parse(raw));
} catch (error) { } catch {
const code = error && typeof error === 'object' ? String((error as { code?: unknown }).code ?? '') : '';
if (code !== 'ENOENT') return null;
return null; return null;
} }
} }
@@ -161,15 +175,27 @@ function extractOperations(
pattern.regex.lastIndex = 0; pattern.regex.lastIndex = 0;
while (true) { while (true) {
const match = pattern.regex.exec(bundleContents); const match = pattern.regex.exec(bundleContents);
if (match === null) break; if (match === null) {
break;
}
const operationName = match[pattern.operationGroup]; const operationName = match[pattern.operationGroup];
const queryId = match[pattern.queryIdGroup]; const queryId = match[pattern.queryIdGroup];
if (!operationName || !queryId) continue; if (!operationName || !queryId) {
if (!targets.has(operationName)) continue; continue;
if (!/^[a-zA-Z0-9_-]+$/.test(queryId)) continue; }
if (discovered.has(operationName)) continue; if (!targets.has(operationName)) {
continue;
}
if (!QUERY_ID_REGEX.test(queryId)) {
continue;
}
if (discovered.has(operationName)) {
continue;
}
discovered.set(operationName, { queryId, bundle: bundleLabel }); discovered.set(operationName, { queryId, bundle: bundleLabel });
if (discovered.size === targets.size) return; if (discovered.size === targets.size) {
return;
}
} }
} }
} }
@@ -186,7 +212,9 @@ async function fetchAndExtract(
const chunk = bundleUrls.slice(i, i + CONCURRENCY); const chunk = bundleUrls.slice(i, i + CONCURRENCY);
await Promise.all( await Promise.all(
chunk.map(async (url) => { chunk.map(async (url) => {
if (discovered.size === targets.size) return; if (discovered.size === targets.size) {
return;
}
const label = url.split('/').at(-1) ?? url; const label = url.split('/').at(-1) ?? url;
try { try {
const js = await fetchText(fetchImpl, url); const js = await fetchText(fetchImpl, url);
@@ -196,13 +224,15 @@ async function fetchAndExtract(
} }
}), }),
); );
if (discovered.size === targets.size) break; if (discovered.size === targets.size) {
break;
}
} }
return discovered; return discovered;
} }
export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {}) { export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {}): RuntimeQueryIdStore {
const fetchImpl = options.fetchImpl ?? fetch; const fetchImpl = options.fetchImpl ?? fetch;
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS; const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
const cachePath = options.cachePath ? path.resolve(options.cachePath) : resolveDefaultCachePath(); const cachePath = options.cachePath ? path.resolve(options.cachePath) : resolveDefaultCachePath();
@@ -212,7 +242,9 @@ export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {})
let refreshInFlight: Promise<RuntimeQueryIdSnapshotInfo | null> | null = null; let refreshInFlight: Promise<RuntimeQueryIdSnapshotInfo | null> | null = null;
const loadSnapshot = async (): Promise<RuntimeQueryIdSnapshot | null> => { const loadSnapshot = async (): Promise<RuntimeQueryIdSnapshot | null> => {
if (memorySnapshot) return memorySnapshot; if (memorySnapshot) {
return memorySnapshot;
}
if (!loadOnce) { if (!loadOnce) {
loadOnce = (async () => { loadOnce = (async () => {
const fromDisk = await readSnapshotFromDisk(cachePath); const fromDisk = await readSnapshotFromDisk(cachePath);
@@ -225,7 +257,9 @@ export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {})
const getSnapshotInfo = async (): Promise<RuntimeQueryIdSnapshotInfo | null> => { const getSnapshotInfo = async (): Promise<RuntimeQueryIdSnapshotInfo | null> => {
const snapshot = await loadSnapshot(); const snapshot = await loadSnapshot();
if (!snapshot) return null; if (!snapshot) {
return null;
}
const fetchedAtMs = new Date(snapshot.fetchedAt).getTime(); const fetchedAtMs = new Date(snapshot.fetchedAt).getTime();
const ageMs = Number.isFinite(fetchedAtMs) ? Math.max(0, Date.now() - fetchedAtMs) : Number.POSITIVE_INFINITY; const ageMs = Number.isFinite(fetchedAtMs) ? Math.max(0, Date.now() - fetchedAtMs) : Number.POSITIVE_INFINITY;
const effectiveTtl = Number.isFinite(snapshot.ttlMs) ? snapshot.ttlMs : ttlMs; const effectiveTtl = Number.isFinite(snapshot.ttlMs) ? snapshot.ttlMs : ttlMs;
@@ -235,7 +269,9 @@ export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {})
const getQueryId = async (operationName: string): Promise<string | null> => { const getQueryId = async (operationName: string): Promise<string | null> => {
const info = await getSnapshotInfo(); const info = await getSnapshotInfo();
if (!info) return null; if (!info) {
return null;
}
return info.snapshot.ids[operationName] ?? null; return info.snapshot.ids[operationName] ?? null;
}; };
@@ -243,7 +279,9 @@ export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {})
operationNames: string[], operationNames: string[],
opts: { force?: boolean } = {}, opts: { force?: boolean } = {},
): Promise<RuntimeQueryIdSnapshotInfo | null> => { ): Promise<RuntimeQueryIdSnapshotInfo | null> => {
if (refreshInFlight) return refreshInFlight; if (refreshInFlight) {
return refreshInFlight;
}
refreshInFlight = (async () => { refreshInFlight = (async () => {
const current = await getSnapshotInfo(); const current = await getSnapshotInfo();
+232 -40
View File
@@ -4,6 +4,7 @@
import { randomBytes, randomUUID } from 'node:crypto'; import { randomBytes, randomUUID } from 'node:crypto';
import type { TwitterCookies } from './cookies.js'; import type { TwitterCookies } from './cookies.js';
// biome-ignore lint/correctness/useImportExtensions: JSON module import doesn't use .js extension.
import queryIds from './query-ids.json' with { type: 'json' }; import queryIds from './query-ids.json' with { type: 'json' };
import { runtimeQueryIds } from './runtime-query-ids.js'; import { runtimeQueryIds } from './runtime-query-ids.js';
@@ -12,6 +13,9 @@ const TWITTER_GRAPHQL_POST_URL = 'https://x.com/i/api/graphql';
const TWITTER_UPLOAD_URL = 'https://upload.twitter.com/i/media/upload.json'; const TWITTER_UPLOAD_URL = 'https://upload.twitter.com/i/media/upload.json';
const TWITTER_MEDIA_METADATA_URL = 'https://x.com/i/api/1.1/media/metadata/create.json'; const TWITTER_MEDIA_METADATA_URL = 'https://x.com/i/api/1.1/media/metadata/create.json';
const TWITTER_STATUS_UPDATE_URL = 'https://x.com/i/api/1.1/statuses/update.json'; const TWITTER_STATUS_UPDATE_URL = 'https://x.com/i/api/1.1/statuses/update.json';
const SETTINGS_SCREEN_NAME_REGEX = /"screen_name":"([^"]+)"/;
const SETTINGS_USER_ID_REGEX = /"user_id"\s*:\s*"(\d+)"/;
const SETTINGS_NAME_REGEX = /"name":"([^"\\]*(?:\\.[^"\\]*)*)"/;
// Query IDs rotate frequently; the values in query-ids.json are refreshed by // Query IDs rotate frequently; the values in query-ids.json are refreshed by
// scripts/update-query-ids.ts. The fallback values keep the client usable if // scripts/update-query-ids.ts. The fallback values keep the client usable if
@@ -23,6 +27,7 @@ const FALLBACK_QUERY_IDS = {
TweetDetail: '97JF30KziU00483E_8elBA', TweetDetail: '97JF30KziU00483E_8elBA',
SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ', SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ',
UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg', UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg',
Bookmarks: 'RV1g3b8n_SGOHwkqKYSCFw',
} as const; } as const;
type OperationName = keyof typeof FALLBACK_QUERY_IDS; type OperationName = keyof typeof FALLBACK_QUERY_IDS;
@@ -338,7 +343,9 @@ export class TwitterClient {
| undefined, | undefined,
tweetId: string, tweetId: string,
) { ) {
if (!instructions) return undefined; if (!instructions) {
return undefined;
}
for (const instruction of instructions) { for (const instruction of instructions) {
for (const entry of instruction.entries || []) { for (const entry of instruction.entries || []) {
@@ -400,10 +407,14 @@ export class TwitterClient {
private mediaCategoryForMime(mimeType: string): string | null { private mediaCategoryForMime(mimeType: string): string | null {
if (mimeType.startsWith('image/')) { if (mimeType.startsWith('image/')) {
if (mimeType === 'image/gif') return 'tweet_gif'; if (mimeType === 'image/gif') {
return 'tweet_gif';
}
return 'tweet_image'; return 'tweet_image';
} }
if (mimeType.startsWith('video/')) return 'tweet_video'; if (mimeType.startsWith('video/')) {
return 'tweet_video';
}
return null; return null;
} }
@@ -413,7 +424,9 @@ export class TwitterClient {
async uploadMedia(input: { data: Uint8Array; mimeType: string; alt?: string }): Promise<UploadMediaResult> { async uploadMedia(input: { data: Uint8Array; mimeType: string; alt?: string }): Promise<UploadMediaResult> {
const category = this.mediaCategoryForMime(input.mimeType); const category = this.mediaCategoryForMime(input.mimeType);
if (!category) return { success: false, error: `Unsupported media type: ${input.mimeType}` }; if (!category) {
return { success: false, error: `Unsupported media type: ${input.mimeType}` };
}
try { try {
const initParams = new URLSearchParams({ const initParams = new URLSearchParams({
@@ -441,7 +454,9 @@ export class TwitterClient {
: initBody.media_id !== undefined : initBody.media_id !== undefined
? String(initBody.media_id) ? String(initBody.media_id)
: undefined; : undefined;
if (!mediaId) return { success: false, error: 'Media upload INIT did not return media_id' }; if (!mediaId) {
return { success: false, error: 'Media upload INIT did not return media_id' };
}
const chunkSize = 5 * 1024 * 1024; const chunkSize = 5 * 1024 * 1024;
let segmentIndex = 0; let segmentIndex = 0;
@@ -497,7 +512,10 @@ export class TwitterClient {
const delaySecs = Number.isFinite(info.check_after_secs) ? Math.max(1, info.check_after_secs as number) : 2; const delaySecs = Number.isFinite(info.check_after_secs) ? Math.max(1, info.check_after_secs as number) : 2;
await this.sleep(delaySecs * 1000); await this.sleep(delaySecs * 1000);
const statusUrl = `${TWITTER_UPLOAD_URL}?${new URLSearchParams({ command: 'STATUS', media_id: mediaId })}`; const statusUrl = `${TWITTER_UPLOAD_URL}?${new URLSearchParams({
command: 'STATUS',
media_id: mediaId,
}).toString()}`;
const statusResp = await this.fetchWithTimeout(statusUrl, { const statusResp = await this.fetchWithTimeout(statusUrl, {
method: 'GET', method: 'GET',
headers: this.getUploadHeaders(), headers: this.getUploadHeaders(),
@@ -515,11 +533,15 @@ export class TwitterClient {
error?: { message?: string; name?: string }; error?: { message?: string; name?: string };
}; };
}; };
if (!statusBody.processing_info) break; if (!statusBody.processing_info) {
break;
}
info.state = statusBody.processing_info.state; info.state = statusBody.processing_info.state;
info.check_after_secs = statusBody.processing_info.check_after_secs; info.check_after_secs = statusBody.processing_info.check_after_secs;
info.error = statusBody.processing_info.error; info.error = statusBody.processing_info.error;
if (info.state === 'succeeded') break; if (info.state === 'succeeded') {
break;
}
attempts += 1; attempts += 1;
} }
} }
@@ -546,15 +568,21 @@ export class TwitterClient {
for (const value of values) { for (const value of values) {
if (typeof value === 'string') { if (typeof value === 'string') {
const trimmed = value.trim(); const trimmed = value.trim();
if (trimmed) return trimmed; if (trimmed) {
return trimmed;
}
} }
} }
return undefined; return undefined;
} }
private collectTextFields(value: unknown, keys: Set<string>, output: string[]): void { private collectTextFields(value: unknown, keys: Set<string>, output: string[]): void {
if (!value) return; if (!value) {
if (typeof value === 'string') return; return;
}
if (typeof value === 'string') {
return;
}
if (Array.isArray(value)) { if (Array.isArray(value)) {
for (const item of value) { for (const item of value) {
@@ -568,7 +596,9 @@ export class TwitterClient {
if (keys.has(key)) { if (keys.has(key)) {
if (typeof nested === 'string') { if (typeof nested === 'string') {
const trimmed = nested.trim(); const trimmed = nested.trim();
if (trimmed) output.push(trimmed); if (trimmed) {
output.push(trimmed);
}
continue; continue;
} }
} }
@@ -581,7 +611,9 @@ export class TwitterClient {
const seen = new Set<string>(); const seen = new Set<string>();
const result: string[] = []; const result: string[] = [];
for (const value of values) { for (const value of values) {
if (seen.has(value)) continue; if (seen.has(value)) {
continue;
}
seen.add(value); seen.add(value);
result.push(value); result.push(value);
} }
@@ -590,7 +622,9 @@ export class TwitterClient {
private extractArticleText(result: GraphqlTweetResult | undefined): string | undefined { private extractArticleText(result: GraphqlTweetResult | undefined): string | undefined {
const article = result?.article; const article = result?.article;
if (!article) return undefined; if (!article) {
return undefined;
}
const articleResult = article.article_results?.result ?? article; const articleResult = article.article_results?.result ?? article;
if (process.env.BIRD_DEBUG_ARTICLE === '1') { if (process.env.BIRD_DEBUG_ARTICLE === '1') {
@@ -655,7 +689,9 @@ export class TwitterClient {
private extractNoteTweetText(result: GraphqlTweetResult | undefined): string | undefined { private extractNoteTweetText(result: GraphqlTweetResult | undefined): string | undefined {
const note = result?.note_tweet?.note_tweet_results?.result; const note = result?.note_tweet?.note_tweet_results?.result;
if (!note) return undefined; if (!note) {
return undefined;
}
return this.firstText( return this.firstText(
note.text, note.text,
@@ -680,10 +716,14 @@ export class TwitterClient {
const username = userLegacy?.screen_name ?? userCore?.screen_name; const username = userLegacy?.screen_name ?? userCore?.screen_name;
const name = userLegacy?.name ?? userCore?.name ?? username; const name = userLegacy?.name ?? userCore?.name ?? username;
const userId = userResult?.rest_id; const userId = userResult?.rest_id;
if (!result?.rest_id || !username) return undefined; if (!result?.rest_id || !username) {
return undefined;
}
const text = this.extractTweetText(result); const text = this.extractTweetText(result);
if (!text) return undefined; if (!text) {
return undefined;
}
return { return {
id: result.rest_id, id: result.rest_id,
@@ -741,7 +781,9 @@ export class TwitterClient {
}): GraphqlTweetResult[] { }): GraphqlTweetResult[] {
const results: GraphqlTweetResult[] = []; const results: GraphqlTweetResult[] = [];
const pushResult = (result?: GraphqlTweetResult) => { const pushResult = (result?: GraphqlTweetResult) => {
if (result?.rest_id) results.push(result); if (result?.rest_id) {
results.push(result);
}
}; };
const content = entry.content; const content = entry.content;
@@ -808,7 +850,9 @@ export class TwitterClient {
const results = this.collectTweetResultsFromEntry(entry); const results = this.collectTweetResultsFromEntry(entry);
for (const result of results) { for (const result of results) {
const mapped = this.mapTweetResult(result); const mapped = this.mapTweetResult(result);
if (!mapped || seen.has(mapped.id)) continue; if (!mapped || seen.has(mapped.id)) {
continue;
}
seen.add(mapped.id); seen.add(mapped.id);
tweets.push(mapped); tweets.push(mapped);
} }
@@ -955,7 +999,7 @@ export class TwitterClient {
}); });
const queryId = await this.getQueryId('UserArticlesTweets'); const queryId = await this.getQueryId('UserArticlesTweets');
const url = `${TWITTER_API_BASE}/${queryId}/UserArticlesTweets?${params}`; const url = `${TWITTER_API_BASE}/${queryId}/UserArticlesTweets?${params.toString()}`;
try { try {
const response = await this.fetchWithTimeout(url, { method: 'GET', headers: this.getHeaders() }); const response = await this.fetchWithTimeout(url, { method: 'GET', headers: this.getHeaders() });
@@ -989,7 +1033,9 @@ export class TwitterClient {
for (const instruction of instructions) { for (const instruction of instructions) {
for (const entry of instruction.entries ?? []) { for (const entry of instruction.entries ?? []) {
const result = entry.content?.itemContent?.tweet_results?.result; const result = entry.content?.itemContent?.tweet_results?.result;
if (result?.rest_id !== tweetId) continue; if (result?.rest_id !== tweetId) {
continue;
}
const articleResult = result.article?.article_results?.result; const articleResult = result.article?.article_results?.result;
const title = this.firstText(articleResult?.title, result.article?.title); const title = this.firstText(articleResult?.title, result.article?.title);
const plainText = this.firstText(articleResult?.plain_text, result.article?.plain_text); const plainText = this.firstText(articleResult?.plain_text, result.article?.plain_text);
@@ -1098,7 +1144,7 @@ export class TwitterClient {
const queryIds = await this.getTweetDetailQueryIds(); const queryIds = await this.getTweetDetailQueryIds();
for (const queryId of queryIds) { for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/TweetDetail?${params}`; const url = `${TWITTER_API_BASE}/${queryId}/TweetDetail?${params.toString()}`;
const response = await this.fetchWithTimeout(url, { const response = await this.fetchWithTimeout(url, {
method: 'GET', method: 'GET',
headers: this.getHeaders(), headers: this.getHeaders(),
@@ -1351,12 +1397,16 @@ export class TwitterClient {
if (data.errors && data.errors.length > 0) { if (data.errors && data.errors.length > 0) {
const fallback = await this.tryStatusUpdateFallback(data.errors, variables); const fallback = await this.tryStatusUpdateFallback(data.errors, variables);
if (fallback) return fallback; if (fallback) {
return fallback;
}
return { success: false, error: this.formatErrors(data.errors) }; return { success: false, error: this.formatErrors(data.errors) };
} }
const tweetId = data.data?.create_tweet?.tweet_results?.result?.rest_id; const tweetId = data.data?.create_tweet?.tweet_results?.result?.rest_id;
if (tweetId) return { success: true, tweetId }; if (tweetId) {
return { success: true, tweetId };
}
return { success: false, error: 'Tweet created but no ID returned' }; return { success: false, error: 'Tweet created but no ID returned' };
} }
@@ -1374,7 +1424,9 @@ export class TwitterClient {
if (data.errors && data.errors.length > 0) { if (data.errors && data.errors.length > 0) {
const fallback = await this.tryStatusUpdateFallback(data.errors, variables); const fallback = await this.tryStatusUpdateFallback(data.errors, variables);
if (fallback) return fallback; if (fallback) {
return fallback;
}
return { return {
success: false, success: false,
error: this.formatErrors(data.errors), error: this.formatErrors(data.errors),
@@ -1413,7 +1465,9 @@ export class TwitterClient {
mediaIds?: string[]; mediaIds?: string[];
} | null { } | null {
const text = typeof variables.tweet_text === 'string' ? variables.tweet_text : null; const text = typeof variables.tweet_text === 'string' ? variables.tweet_text : null;
if (!text) return null; if (!text) {
return null;
}
const reply = variables.reply; const reply = variables.reply;
const inReplyToTweetId = const inReplyToTweetId =
@@ -1485,7 +1539,9 @@ export class TwitterClient {
const tweetId = const tweetId =
typeof data.id_str === 'string' ? data.id_str : data.id !== undefined ? String(data.id) : undefined; typeof data.id_str === 'string' ? data.id_str : data.id !== undefined ? String(data.id) : undefined;
if (tweetId) return { success: true, tweetId }; if (tweetId) {
return { success: true, tweetId };
}
return { success: false, error: 'Tweet created but no ID returned' }; return { success: false, error: 'Tweet created but no ID returned' };
} catch (error) { } catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }; return { success: false, error: error instanceof Error ? error.message : String(error) };
@@ -1496,12 +1552,18 @@ export class TwitterClient {
errors: Array<{ message: string; code?: number }>, errors: Array<{ message: string; code?: number }>,
variables: Record<string, unknown>, variables: Record<string, unknown>,
): Promise<TweetResult | null> { ): Promise<TweetResult | null> {
if (!errors.some((error) => error.code === 226)) return null; if (!errors.some((error) => error.code === 226)) {
return null;
}
const input = this.statusUpdateInputFromCreateTweetVariables(variables); const input = this.statusUpdateInputFromCreateTweetVariables(variables);
if (!input) return null; if (!input) {
return null;
}
const fallback = await this.postStatusUpdate(input); const fallback = await this.postStatusUpdate(input);
if (fallback.success) return fallback; if (fallback.success) {
return fallback;
}
return { return {
success: false, success: false,
@@ -1510,8 +1572,12 @@ export class TwitterClient {
} }
private async ensureClientUserId(): Promise<void> { private async ensureClientUserId(): Promise<void> {
if (process.env.NODE_ENV === 'test') return; if (process.env.NODE_ENV === 'test') {
if (this.clientUserId) return; return;
}
if (this.clientUserId) {
return;
}
const result = await this.getCurrentUser(); const result = await this.getCurrentUser();
if (result.success && result.user?.id) { if (result.success && result.user?.id) {
this.clientUserId = result.user.id; this.clientUserId = result.user.id;
@@ -1541,7 +1607,7 @@ export class TwitterClient {
const queryIds = await this.getSearchTimelineQueryIds(); const queryIds = await this.getSearchTimelineQueryIds();
for (const queryId of queryIds) { for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/SearchTimeline?${params}`; const url = `${TWITTER_API_BASE}/${queryId}/SearchTimeline?${params.toString()}`;
try { try {
const response = await this.fetchWithTimeout(url, { const response = await this.fetchWithTimeout(url, {
@@ -1621,12 +1687,16 @@ export class TwitterClient {
}; };
const firstAttempt = await tryOnce(); const firstAttempt = await tryOnce();
if (firstAttempt.success) return { success: true, tweets: firstAttempt.tweets }; if (firstAttempt.success) {
return { success: true, tweets: firstAttempt.tweets };
}
if (firstAttempt.had404) { if (firstAttempt.had404) {
await this.refreshQueryIds(); await this.refreshQueryIds();
const secondAttempt = await tryOnce(); const secondAttempt = await tryOnce();
if (secondAttempt.success) return { success: true, tweets: secondAttempt.tweets }; if (secondAttempt.success) {
return { success: true, tweets: secondAttempt.tweets };
}
return { success: false, error: secondAttempt.error }; return { success: false, error: secondAttempt.error };
} }
@@ -1728,9 +1798,9 @@ export class TwitterClient {
} }
const html = await response.text(); const html = await response.text();
const usernameMatch = html.match(/"screen_name":"([^"]+)"/); const usernameMatch = SETTINGS_SCREEN_NAME_REGEX.exec(html);
const idMatch = html.match(/"user_id"\s*:\s*"(\d+)"/); const idMatch = SETTINGS_USER_ID_REGEX.exec(html);
const nameMatch = html.match(/"name":"([^"\\]*(?:\\.[^"\\]*)*)"/); const nameMatch = SETTINGS_NAME_REGEX.exec(html);
const username = usernameMatch?.[1]; const username = usernameMatch?.[1];
const userId = idMatch?.[1]; const userId = idMatch?.[1];
@@ -1764,7 +1834,9 @@ export class TwitterClient {
*/ */
async getReplies(tweetId: string): Promise<SearchResult> { async getReplies(tweetId: string): Promise<SearchResult> {
const response = await this.fetchTweetDetail(tweetId); const response = await this.fetchTweetDetail(tweetId);
if (!response.success) return response; if (!response.success) {
return response;
}
const instructions = response.data.threaded_conversation_with_injections_v2?.instructions; const instructions = response.data.threaded_conversation_with_injections_v2?.instructions;
const tweets = this.parseTweetsFromInstructions(instructions); const tweets = this.parseTweetsFromInstructions(instructions);
@@ -1778,7 +1850,9 @@ export class TwitterClient {
*/ */
async getThread(tweetId: string): Promise<SearchResult> { async getThread(tweetId: string): Promise<SearchResult> {
const response = await this.fetchTweetDetail(tweetId); const response = await this.fetchTweetDetail(tweetId);
if (!response.success) return response; if (!response.success) {
return response;
}
const instructions = response.data.threaded_conversation_with_injections_v2?.instructions; const instructions = response.data.threaded_conversation_with_injections_v2?.instructions;
const tweets = this.parseTweetsFromInstructions(instructions); const tweets = this.parseTweetsFromInstructions(instructions);
@@ -1795,4 +1869,122 @@ export class TwitterClient {
return { success: true, tweets: thread }; return { success: true, tweets: thread };
} }
private buildBookmarksFeatures(): Record<string, boolean> {
return {
...this.buildSearchFeatures(),
graphql_timeline_v2_bookmark_timeline: true,
blue_business_profile_image_shape_enabled: true,
responsive_web_text_conversations_enabled: false,
tweetypie_unmention_optimization_enabled: true,
vibe_api_enabled: true,
responsive_web_twitter_blue_verified_badge_is_enabled: true,
interactive_text_enabled: true,
longform_notetweets_richtext_consumption_enabled: true,
responsive_web_media_download_video_enabled: false,
};
}
private async getBookmarksQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('Bookmarks');
return Array.from(new Set([primary, 'RV1g3b8n_SGOHwkqKYSCFw', 'tmd4ifV8RHltzn8ymGg1aw']));
}
/**
* Get the authenticated user's bookmarks
*/
async getBookmarks(count = 20): Promise<SearchResult> {
const variables = {
count,
includePromotedContent: false,
withDownvotePerspective: false,
withReactionsMetadata: false,
withReactionsPerspective: false,
};
const features = this.buildBookmarksFeatures();
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
});
const tryOnce = async () => {
let lastError: string | undefined;
let had404 = false;
const queryIds = await this.getBookmarksQueryIds();
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/Bookmarks?${params.toString()}`;
try {
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (response.status === 404) {
had404 = true;
lastError = `HTTP ${response.status}`;
continue;
}
if (!response.ok) {
const text = await response.text();
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
}
const data = (await response.json()) as {
data?: {
bookmark_timeline_v2?: {
timeline?: {
instructions?: Array<{
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
}>;
}>;
};
};
};
errors?: Array<{ message: string }>;
};
if (data.errors && data.errors.length > 0) {
return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 };
}
const instructions = data.data?.bookmark_timeline_v2?.timeline?.instructions;
const tweets = this.parseTweetsFromInstructions(instructions);
return { success: true as const, tweets, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return { success: false as const, error: lastError ?? 'Unknown error fetching bookmarks', had404 };
};
const firstAttempt = await tryOnce();
if (firstAttempt.success) {
return { success: true, tweets: firstAttempt.tweets };
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
const secondAttempt = await tryOnce();
if (secondAttempt.success) {
return { success: true, tweets: secondAttempt.tweets };
}
return { success: false, error: secondAttempt.error };
}
return { success: false, error: firstAttempt.error };
}
} }
+46 -17
View File
@@ -5,6 +5,9 @@ import { fileURLToPath } from 'node:url';
declare const __dirname: string | undefined; declare const __dirname: string | undefined;
export const FALLBACK_VERSION = 'unknown'; export const FALLBACK_VERSION = 'unknown';
const REF_PREFIX_REGEX = /^ref:\s*/i;
const LINE_SPLIT_REGEX = /\r?\n/;
const GITDIR_REGEX = /gitdir:\s*(.+)\s*$/i;
function readPackageVersionFromJsonFile(candidate: string): string | null { function readPackageVersionFromJsonFile(candidate: string): string | null {
try { try {
@@ -37,7 +40,9 @@ function resolveStartDir(importMetaUrl?: string): string {
} }
} }
if (typeof __dirname === 'string' && __dirname.length > 0) return __dirname; if (typeof __dirname === 'string' && __dirname.length > 0) {
return __dirname;
}
return process.cwd(); return process.cwd();
} }
@@ -47,27 +52,37 @@ export function resolvePackageVersion(importMetaUrl?: string): string {
typeof process !== 'undefined' && typeof process.env.BIRD_VERSION === 'string' typeof process !== 'undefined' && typeof process.env.BIRD_VERSION === 'string'
? process.env.BIRD_VERSION.trim() ? process.env.BIRD_VERSION.trim()
: ''; : '';
if (injected.length > 0) return injected; if (injected.length > 0) {
return injected;
}
let dir = resolveStartDir(importMetaUrl); let dir = resolveStartDir(importMetaUrl);
for (let i = 0; i < 10; i += 1) { for (let i = 0; i < 10; i += 1) {
const version = const version =
readPackageVersionFromJsonFile(path.join(dir, 'package.json')) ?? readPackageVersionFromJsonFile(path.join(dir, 'package.json')) ??
readVersionFromTextFile(path.join(dir, 'VERSION')); readVersionFromTextFile(path.join(dir, 'VERSION'));
if (version) return version; if (version) {
return version;
}
const parent = path.dirname(dir); const parent = path.dirname(dir);
if (parent === dir) break; if (parent === dir) {
break;
}
dir = parent; dir = parent;
} }
return FALLBACK_VERSION; return FALLBACK_VERSION;
} }
function truncateSha(sha: string, length = 8): string { function truncateSha(sha: string, length: number = 8): string {
const trimmed = sha.trim(); const trimmed = sha.trim();
if (!trimmed) return ''; if (!trimmed) {
if (trimmed.length <= length) return trimmed; return '';
}
if (trimmed.length <= length) {
return trimmed;
}
return trimmed.slice(0, length); return trimmed.slice(0, length);
} }
@@ -80,14 +95,18 @@ function resolveGitShaFromGitDir(gitDir: string): string | null {
return null; return null;
} }
if (!head) return null; if (!head) {
return null;
}
if (!head.startsWith('ref:')) { if (!head.startsWith('ref:')) {
const sha = truncateSha(head); const sha = truncateSha(head);
return sha.length > 0 ? sha : null; return sha.length > 0 ? sha : null;
} }
const ref = head.replace(/^ref:\s*/i, '').trim(); const ref = head.replace(REF_PREFIX_REGEX, '').trim();
if (!ref) return null; if (!ref) {
return null;
}
const refPath = path.join(gitDir, ref); const refPath = path.join(gitDir, ref);
try { try {
@@ -100,9 +119,11 @@ function resolveGitShaFromGitDir(gitDir: string): string | null {
const packedRefsPath = path.join(gitDir, 'packed-refs'); const packedRefsPath = path.join(gitDir, 'packed-refs');
try { try {
const packed = fs.readFileSync(packedRefsPath, 'utf8'); const packed = fs.readFileSync(packedRefsPath, 'utf8');
const lines = packed.split(/\r?\n/); const lines = packed.split(LINE_SPLIT_REGEX);
for (const line of lines) { for (const line of lines) {
if (!line || line.startsWith('#') || line.startsWith('^')) continue; if (!line || line.startsWith('#') || line.startsWith('^')) {
continue;
}
const [shaRaw, refName] = line.split(' '); const [shaRaw, refName] = line.split(' ');
if (refName?.trim() === ref) { if (refName?.trim() === ref) {
const sha = truncateSha(shaRaw ?? ''); const sha = truncateSha(shaRaw ?? '');
@@ -121,7 +142,9 @@ export function resolveGitSha(importMetaUrl?: string): string | null {
typeof process !== 'undefined' && typeof process.env.BIRD_GIT_SHA === 'string' typeof process !== 'undefined' && typeof process.env.BIRD_GIT_SHA === 'string'
? process.env.BIRD_GIT_SHA.trim() ? process.env.BIRD_GIT_SHA.trim()
: ''; : '';
if (injected.length > 0) return truncateSha(injected); if (injected.length > 0) {
return truncateSha(injected);
}
let dir = resolveStartDir(importMetaUrl); let dir = resolveStartDir(importMetaUrl);
for (let i = 0; i < 10; i += 1) { for (let i = 0; i < 10; i += 1) {
@@ -130,15 +153,19 @@ export function resolveGitSha(importMetaUrl?: string): string | null {
const stat = fs.statSync(dotGit); const stat = fs.statSync(dotGit);
if (stat.isDirectory()) { if (stat.isDirectory()) {
const sha = resolveGitShaFromGitDir(dotGit); const sha = resolveGitShaFromGitDir(dotGit);
if (sha) return sha; if (sha) {
return sha;
}
} else if (stat.isFile()) { } else if (stat.isFile()) {
const txt = fs.readFileSync(dotGit, 'utf8'); const txt = fs.readFileSync(dotGit, 'utf8');
const match = txt.match(/gitdir:\s*(.+)\s*$/i); const match = GITDIR_REGEX.exec(txt);
const gitDir = match?.[1]?.trim(); const gitDir = match?.[1]?.trim();
if (gitDir) { if (gitDir) {
const resolved = path.isAbsolute(gitDir) ? gitDir : path.resolve(dir, gitDir); const resolved = path.isAbsolute(gitDir) ? gitDir : path.resolve(dir, gitDir);
const sha = resolveGitShaFromGitDir(resolved); const sha = resolveGitShaFromGitDir(resolved);
if (sha) return sha; if (sha) {
return sha;
}
} }
} }
} catch { } catch {
@@ -146,7 +173,9 @@ export function resolveGitSha(importMetaUrl?: string): string | null {
} }
const parent = path.dirname(dir); const parent = path.dirname(dir);
if (parent === dir) break; if (parent === dir) {
break;
}
dir = parent; dir = parent;
} }
+153 -216
View File
@@ -1,98 +1,30 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock child_process.execSync to prevent actual shell commands type SweetCookieResult = { cookies: Array<{ name: string; value: string; domain?: string }>; warnings: string[] };
vi.mock('node:child_process', () => ({
execSync: vi.fn(() => ''), const sweet = vi.hoisted(() => ({
results: new Map<string, SweetCookieResult>(),
})); }));
// Mock fs to prevent actual file operations vi.mock('@steipete/sweet-cookie', () => ({
vi.mock('node:fs', () => { getCookies: vi.fn(async (options: { browsers?: string[] }) => {
const fs = vi.importActual<typeof import('node:fs')>('node:fs'); const browser = options.browsers?.[0] ?? 'unknown';
return { return (
...fs, sweet.results.get(browser) ?? {
existsSync: vi.fn(() => false), cookies: [],
copyFileSync: vi.fn(), warnings: [],
mkdtempSync: vi.fn(() => '/tmp/test-dir'), }
readdirSync: vi.fn(() => []), );
readFileSync: vi.fn(() => Buffer.alloc(0)), }),
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;
beforeEach(() => { beforeEach(() => {
vi.resetModules(); vi.resetModules();
sweet.results.clear();
process.env = { ...originalEnv }; process.env = { ...originalEnv };
// Clear Twitter-related env vars
process.env.AUTH_TOKEN = undefined; process.env.AUTH_TOKEN = undefined;
process.env.TWITTER_AUTH_TOKEN = undefined; process.env.TWITTER_AUTH_TOKEN = undefined;
process.env.CT0 = undefined; process.env.CT0 = undefined;
@@ -106,58 +38,44 @@ describe('cookies', () => {
describe('resolveCredentials', () => { describe('resolveCredentials', () => {
it('honors cookieSource=firefox even when Safari has cookies', async () => { it('honors cookieSource=firefox even when Safari has cookies', async () => {
sweet.results.set('safari', {
cookies: [
{ name: 'auth_token', value: 'safari_auth', domain: 'x.com' },
{ name: 'ct0', value: 'safari_ct0', domain: 'x.com' },
],
warnings: [],
});
sweet.results.set('firefox', {
cookies: [
{ name: 'auth_token', value: 'firefox_auth', domain: 'x.com' },
{ name: 'ct0', value: 'firefox_ct0', domain: 'x.com' },
],
warnings: [],
});
const { resolveCredentials } = await import('../src/lib/cookies.js'); const { resolveCredentials } = await import('../src/lib/cookies.js');
const fs = await import('node:fs');
const { 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' }); const result = await resolveCredentials({ cookieSource: 'firefox' });
expect(result.cookies.authToken).toBe('firefox_auth'); expect(result.cookies.authToken).toBe('firefox_auth');
expect(result.cookies.ct0).toBe('firefox_ct0'); expect(result.cookies.ct0).toBe('firefox_ct0');
expect(result.cookies.source).toContain('Firefox'); expect(result.cookies.source).toContain('Firefox');
}); });
itIfDarwin('honors cookieSource=safari', async () => { it('honors cookieSource=safari', async () => {
if (process.platform !== 'darwin') {
return;
}
sweet.results.set('safari', {
cookies: [
{ name: 'auth_token', value: 'safari_auth', domain: 'x.com' },
{ name: 'ct0', value: 'safari_ct0', domain: 'x.com' },
],
warnings: [],
});
const { resolveCredentials } = await import('../src/lib/cookies.js'); const { 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' }); const result = await resolveCredentials({ cookieSource: 'safari' });
expect(result.cookies.authToken).toBe('safari_auth'); expect(result.cookies.authToken).toBe('safari_auth');
expect(result.cookies.ct0).toBe('safari_ct0'); expect(result.cookies.ct0).toBe('safari_ct0');
expect(result.cookies.cookieHeader).toContain('auth_token=safari_auth'); expect(result.cookies.cookieHeader).toContain('auth_token=safari_auth');
@@ -166,26 +84,15 @@ describe('cookies', () => {
}); });
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'); sweet.results.set('firefox', {
const fs = await import('node:fs'); cookies: [
{ name: 'auth_token', value: 'firefox_auth', domain: 'x.com' },
// Firefox present with cookies.sqlite { name: 'ct0', value: 'firefox_ct0', domain: 'x.com' },
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => { ],
const lower = path.toLowerCase(); warnings: [],
if (lower.endsWith('cookies.sqlite')) return true;
if (lower.includes('firefox')) return true;
return false;
}); });
(fs.readdirSync as unknown as vi.Mock).mockReturnValue([
{ isDirectory: () => true, name: 'abc.default-release' },
]);
(fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {});
(fs.mkdtempSync as unknown as vi.Mock).mockReturnValue('/tmp/test-dir');
// sqlite3 output for firefox
const { execSync } = await import('node:child_process');
(execSync as unknown as vi.Mock).mockReturnValue('auth_token|firefox_auth\nct0|firefox_ct0');
const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({ cookieSource: 'firefox', firefoxProfile: 'abc.default-release' }); const result = await resolveCredentials({ cookieSource: 'firefox', firefoxProfile: 'abc.default-release' });
expect(result.cookies.authToken).toBe('firefox_auth'); expect(result.cookies.authToken).toBe('firefox_auth');
@@ -270,15 +177,12 @@ describe('cookies', () => {
}); });
it('falls back to Chrome when enabled and Firefox disabled', async () => { it('falls back to Chrome when enabled and Firefox disabled', async () => {
const fs = await import('node:fs'); sweet.results.set('chrome', {
const { execSync } = await import('node:child_process'); cookies: [
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => path.includes('Cookies')); { name: 'auth_token', value: 'test_auth', domain: 'x.com' },
(fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {}); { name: 'ct0', value: 'test_ct0', domain: 'x.com' },
(execSync as unknown as vi.Mock).mockImplementation((cmd: string) => { ],
if (cmd.includes('sqlite3')) { warnings: [],
return 'auth_token|746573745f61757468\nct0|746573745f637430';
}
return '';
}); });
const { resolveCredentials } = await import('../src/lib/cookies.js'); const { resolveCredentials } = await import('../src/lib/cookies.js');
@@ -288,22 +192,71 @@ describe('cookies', () => {
expect(result.cookies.ct0).toBe('test_ct0'); expect(result.cookies.ct0).toBe('test_ct0');
expect(result.cookies.source).toContain('Chrome'); expect(result.cookies.source).toContain('Chrome');
}); });
it('uses default browser order when cookieSource is omitted', async () => {
sweet.results.set('safari', { cookies: [], warnings: [] });
sweet.results.set('chrome', { cookies: [], warnings: [] });
sweet.results.set('firefox', {
cookies: [
{ name: 'auth_token', value: 'firefox_auth', domain: 'x.com' },
{ name: 'ct0', value: 'firefox_ct0', domain: 'x.com' },
],
warnings: [],
});
const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({});
expect(result.cookies.authToken).toBe('firefox_auth');
expect(result.cookies.ct0).toBe('firefox_ct0');
expect(result.cookies.source).toContain('Firefox');
});
it('prefers twitter.com cookies when x.com is missing', async () => {
sweet.results.set('chrome', {
cookies: [
{ name: 'auth_token', value: 'twitter_auth', domain: 'twitter.com' },
{ name: 'ct0', value: 'twitter_ct0', domain: 'twitter.com' },
],
warnings: [],
});
const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({ cookieSource: 'chrome' });
expect(result.cookies.authToken).toBe('twitter_auth');
expect(result.cookies.ct0).toBe('twitter_ct0');
});
it('falls back to the first cookie when no domain matches', async () => {
sweet.results.set('firefox', {
cookies: [
{ name: 'auth_token', value: 'first_auth', domain: 'example.com' },
{ name: 'ct0', value: 'first_ct0', domain: 'example.com' },
],
warnings: [],
});
const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({ cookieSource: 'firefox' });
expect(result.cookies.authToken).toBe('first_auth');
expect(result.cookies.ct0).toBe('first_ct0');
});
}); });
describe('extractCookiesFromSafari', () => { describe('extractCookiesFromSafari', () => {
itIfDarwin('returns cookies from Safari binarycookies', async () => { it('returns cookies from Safari', async () => {
const fs = await import('node:fs'); if (process.platform !== 'darwin') {
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => return;
path.toLowerCase().endsWith('cookies.binarycookies'), }
); sweet.results.set('safari', {
(fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {}); cookies: [
(fs.mkdtempSync as unknown as vi.Mock).mockReturnValue('/tmp/test-dir'); { name: 'auth_token', value: 'safari_auth', domain: 'x.com' },
(fs.readFileSync as unknown as vi.Mock).mockReturnValue( { name: 'ct0', value: 'safari_ct0', domain: 'x.com' },
buildSafariCookiesFile([ ],
buildSafariCookieRecord({ domain: '.x.com', name: 'auth_token', value: 'safari_auth' }), warnings: [],
buildSafariCookieRecord({ domain: '.x.com', name: 'ct0', value: 'safari_ct0' }), });
]),
);
const { extractCookiesFromSafari } = await import('../src/lib/cookies.js'); const { extractCookiesFromSafari } = await import('../src/lib/cookies.js');
const result = await extractCookiesFromSafari(); const result = await extractCookiesFromSafari();
@@ -313,23 +266,24 @@ describe('cookies', () => {
expect(result.cookies.source).toBe('Safari'); expect(result.cookies.source).toBe('Safari');
}); });
itIfDarwin('prefers Safari over Chrome when both are available', async () => { it('prefers Safari over Chrome when both are available', async () => {
const fs = await import('node:fs'); if (process.platform !== 'darwin') {
return;
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => { }
const lower = path.toLowerCase(); sweet.results.set('safari', {
if (lower.endsWith('cookies.binarycookies')) return true; cookies: [
if (lower.includes('chrome') && lower.endsWith('cookies')) return true; { name: 'auth_token', value: 'safari_auth', domain: 'x.com' },
return false; { name: 'ct0', value: 'safari_ct0', domain: 'x.com' },
],
warnings: [],
});
sweet.results.set('chrome', {
cookies: [
{ name: 'auth_token', value: 'chrome_auth', domain: 'x.com' },
{ name: 'ct0', value: 'chrome_ct0', domain: 'x.com' },
],
warnings: [],
}); });
(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 { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({ cookieSource: ['safari', 'chrome'] }); const result = await resolveCredentials({ cookieSource: ['safari', 'chrome'] });
@@ -340,18 +294,13 @@ describe('cookies', () => {
}); });
describe('extractCookiesFromChrome', () => { describe('extractCookiesFromChrome', () => {
it('returns cookies when sqlite yields hex values', async () => { it('returns cookies when Chrome yields values', async () => {
const fs = await import('node:fs'); sweet.results.set('chrome', {
const { execSync } = await import('node:child_process'); cookies: [
// Pretend Chrome cookie DB exists { name: 'auth_token', value: 'test_auth', domain: 'x.com' },
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => path.includes('Cookies')); { name: 'ct0', value: 'test_ct0', domain: 'x.com' },
(fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {}); ],
// sqlite output with hex strings ("test_auth", "test_ct0") warnings: [],
(execSync as unknown as vi.Mock).mockImplementation((cmd: string) => {
if (cmd.includes('sqlite3')) {
return 'auth_token|746573745f61757468\nct0|746573745f637430';
}
return '';
}); });
const { extractCookiesFromChrome } = await import('../src/lib/cookies.js'); const { extractCookiesFromChrome } = await import('../src/lib/cookies.js');
@@ -363,12 +312,8 @@ describe('cookies', () => {
expect(result.warnings).toHaveLength(0); expect(result.warnings).toHaveLength(0);
}); });
it('warns when Chrome DB exists but contains no cookies', async () => { it('warns when Chrome returns no cookies', async () => {
const fs = await import('node:fs'); sweet.results.set('chrome', { cookies: [], warnings: [] });
const { execSync } = await import('node:child_process');
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => path.includes('Cookies'));
(fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {});
(execSync as unknown as vi.Mock).mockReturnValue('');
const { extractCookiesFromChrome } = await import('../src/lib/cookies.js'); const { extractCookiesFromChrome } = await import('../src/lib/cookies.js');
const result = await extractCookiesFromChrome('Default'); const result = await extractCookiesFromChrome('Default');
@@ -381,6 +326,11 @@ describe('cookies', () => {
describe('extractCookiesFromFirefox', () => { describe('extractCookiesFromFirefox', () => {
it('warns when Firefox cookies database is missing', async () => { it('warns when Firefox cookies database is missing', async () => {
sweet.results.set('firefox', {
cookies: [],
warnings: ['Firefox cookies database not found.'],
});
const { extractCookiesFromFirefox } = await import('../src/lib/cookies.js'); const { extractCookiesFromFirefox } = await import('../src/lib/cookies.js');
const result = await extractCookiesFromFirefox('missing-profile'); const result = await extractCookiesFromFirefox('missing-profile');
@@ -389,21 +339,8 @@ describe('cookies', () => {
expect(result.warnings).toContain('Firefox cookies database not found.'); expect(result.warnings).toContain('Firefox cookies database not found.');
}); });
it('warns when Firefox DB exists but contains no cookies', async () => { it('warns when Firefox returns no cookies', async () => {
const fs = await import('node:fs'); sweet.results.set('firefox', { cookies: [], warnings: [] });
const { execSync } = await import('node:child_process');
(fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => {
const lower = path.toLowerCase();
if (lower.endsWith('cookies.sqlite')) return true;
if (lower.includes('firefox')) return true;
return false;
});
(fs.readdirSync as unknown as vi.Mock).mockReturnValue([
{ isDirectory: () => true, name: 'abc.default-release' },
]);
(fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {});
(fs.mkdtempSync as unknown as vi.Mock).mockReturnValue('/tmp/test-dir');
(execSync as unknown as vi.Mock).mockReturnValue('');
const { extractCookiesFromFirefox } = await import('../src/lib/cookies.js'); const { extractCookiesFromFirefox } = await import('../src/lib/cookies.js');
const result = await extractCookiesFromFirefox('abc.default-release'); const result = await extractCookiesFromFirefox('abc.default-release');
+3 -1
View File
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { mentionsQueryFromUserOption, normalizeHandle } from '../src/lib/normalize-handle.js'; import { mentionsQueryFromUserOption, normalizeHandle } from '../src/lib/normalize-handle.js';
const INVALID_HANDLE_REGEX = /Invalid --user handle/;
describe('normalizeHandle', () => { describe('normalizeHandle', () => {
it('accepts bare handle', () => { it('accepts bare handle', () => {
expect(normalizeHandle('steipete')).toBe('steipete'); expect(normalizeHandle('steipete')).toBe('steipete');
@@ -36,6 +38,6 @@ describe('mentionsQueryFromUserOption', () => {
it('returns error for invalid handle', () => { it('returns error for invalid handle', () => {
const result = mentionsQueryFromUserOption('@stei-pete'); const result = mentionsQueryFromUserOption('@stei-pete');
expect(result.query).toBeNull(); expect(result.query).toBeNull();
expect(result.error).toMatch(/Invalid --user handle/); expect(result.error).toMatch(INVALID_HANDLE_REGEX);
}); });
}); });
+81 -1
View File
@@ -1,5 +1,5 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { mkdir, readFile } from 'node:fs/promises'; import { mkdir, readFile, writeFile } from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -8,6 +8,7 @@ import { createRuntimeQueryIdStore } from '../src/lib/runtime-query-ids.js';
describe('runtime-query-ids', () => { describe('runtime-query-ids', () => {
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
delete process.env.BIRD_QUERY_IDS_CACHE;
}); });
it('refreshes IDs by scanning discovered bundles and persists cache', async () => { it('refreshes IDs by scanning discovered bundles and persists cache', async () => {
@@ -60,4 +61,83 @@ describe('runtime-query-ids', () => {
const parsed = JSON.parse(raw) as { ids?: Record<string, string> }; const parsed = JSON.parse(raw) as { ids?: Record<string, string> };
expect(parsed.ids?.SearchTimeline).toBe('EEE'); expect(parsed.ids?.SearchTimeline).toBe('EEE');
}); });
it('uses env cache path override and returns fresh snapshot without refreshing', async () => {
const cacheDir = path.join(os.tmpdir(), `bird-test-${randomUUID()}`);
await mkdir(cacheDir, { recursive: true });
const cachePath = path.join(cacheDir, 'query-ids-cache.json');
process.env.BIRD_QUERY_IDS_CACHE = cachePath;
const snapshot = {
fetchedAt: new Date().toISOString(),
ttlMs: 60_000,
ids: { CreateTweet: 'AAA' },
discovery: { pages: ['https://x.com/'], bundles: ['main.js'] },
};
await writeFile(cachePath, JSON.stringify(snapshot), 'utf8');
const fetchMock = vi.fn();
const store = createRuntimeQueryIdStore({ fetchImpl: fetchMock as unknown as typeof fetch });
const info = await store.refresh(['CreateTweet']);
expect(store.cachePath).toBe(cachePath);
expect(info?.snapshot.ids.CreateTweet).toBe('AAA');
expect(fetchMock).not.toHaveBeenCalled();
store.clearMemory();
});
it('returns current snapshot when refresh finds no matching operations', async () => {
const cacheDir = path.join(os.tmpdir(), `bird-test-${randomUUID()}`);
await mkdir(cacheDir, { recursive: true });
const cachePath = path.join(cacheDir, 'query-ids.json');
const snapshot = {
fetchedAt: new Date(0).toISOString(),
ttlMs: 1,
ids: { CreateTweet: 'AAA' },
discovery: { pages: ['https://x.com/'], bundles: ['main.test.js'] },
};
await writeFile(cachePath, JSON.stringify(snapshot), 'utf8');
const html = `<script src="https://abs.twimg.com/responsive-web/client-web/main.test.js"></script>`;
const js = 'console.log("no ops here")';
const fetchMock = vi.fn(async (url: string | URL) => {
const asString = String(url);
if (asString.startsWith('https://x.com/')) {
return new Response(html, { status: 200 });
}
if (asString.startsWith('https://abs.twimg.com/')) {
return new Response(js, { status: 200 });
}
return new Response('not found', { status: 404 });
});
const store = createRuntimeQueryIdStore({
cachePath,
ttlMs: 1,
fetchImpl: fetchMock as unknown as typeof fetch,
});
const info = await store.refresh(['CreateTweet']);
expect(info?.snapshot.ids.CreateTweet).toBe('AAA');
});
it('throws when no bundles can be discovered', async () => {
const cacheDir = path.join(os.tmpdir(), `bird-test-${randomUUID()}`);
await mkdir(cacheDir, { recursive: true });
const cachePath = path.join(cacheDir, 'query-ids.json');
const fetchMock = vi.fn(async () => new Response('nope', { status: 500 }));
const store = createRuntimeQueryIdStore({
cachePath,
fetchImpl: fetchMock as unknown as typeof fetch,
});
await expect(store.refresh(['CreateTweet'], { force: true })).rejects.toThrow(
'No client bundles discovered; x.com layout may have changed.',
);
});
}); });
+207
View File
@@ -0,0 +1,207 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
const validCookies = {
authToken: 'test_auth_token',
ct0: 'test_ct0_token',
cookieHeader: 'auth_token=test_auth_token; ct0=test_ct0_token',
source: 'test',
};
type ResponseLike = {
ok: boolean;
status: number;
json: () => Promise<unknown>;
text: () => Promise<string>;
};
type TwitterClientApiPrivate = TwitterClient & {
getBookmarksQueryIds: () => Promise<string[]>;
};
const makeResponse = (overrides: Partial<ResponseLike> = {}): ResponseLike => ({
ok: true,
status: 200,
json: async (): Promise<unknown> => ({}),
text: async (): Promise<string> => '',
...overrides,
});
describe('TwitterClient API coverage', () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('createTweet fallback paths', () => {
it('returns an error when retry response is not ok', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' }))
.mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' }))
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'boom' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.tweet('hi');
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
it('returns API errors when retry response contains errors', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' }))
.mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' }))
.mockResolvedValueOnce(
makeResponse({
json: async () => ({ errors: [{ message: 'rate limited', code: 1 }] }),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.tweet('hi');
expect(result.success).toBe(false);
expect(result.error).toContain('rate limited');
});
it('returns an error when retry response has no tweet id', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' }))
.mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' }))
.mockResolvedValueOnce(
makeResponse({
json: async () => ({ data: { create_tweet: { tweet_results: { result: {} } } } }),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.tweet('hi');
expect(result.success).toBe(false);
expect(result.error).toContain('no ID');
});
});
describe('search error paths', () => {
it('returns an error for non-ok responses', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.search('test');
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
it('returns errors from payloads', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
makeResponse({
json: async () => ({ errors: [{ message: 'bad' }] }),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.search('test');
expect(result.success).toBe(false);
expect(result.error).toBe('bad');
});
it('returns an error when fetching throws', async () => {
const mockFetch = vi.fn().mockRejectedValue(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.search('test');
expect(result.success).toBe(false);
expect(result.error).toContain('boom');
});
it('returns the second attempt error after 404s', async () => {
const mockFetch = vi.fn().mockResolvedValue(makeResponse({ ok: false, status: 404, text: async () => 'nope' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.search('test');
expect(result.success).toBe(false);
expect(result.error).toBe('HTTP 404');
});
});
describe('bookmarks error paths', () => {
it('returns an error for non-ok responses', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarks(1);
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
it('returns API errors from payloads', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
makeResponse({
json: async () => ({ errors: [{ message: 'bad' }] }),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarks(1);
expect(result.success).toBe(false);
expect(result.error).toBe('bad');
});
it('returns an error when fetching throws', async () => {
const mockFetch = vi.fn().mockRejectedValue(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarks(1);
expect(result.success).toBe(false);
expect(result.error).toContain('boom');
});
it('returns unknown error when no query ids are available', async () => {
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getBookmarksQueryIds = async () => [];
const result = await client.getBookmarks(1);
expect(result.success).toBe(false);
expect(result.error).toBe('Unknown error fetching bookmarks');
});
it('returns the second attempt error after 404s', async () => {
const mockFetch = vi.fn().mockResolvedValue(makeResponse({ ok: false, status: 404, text: async () => 'nope' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarks(1);
expect(result.success).toBe(false);
expect(result.error).toBe('HTTP 404');
});
});
});
+248
View File
@@ -0,0 +1,248 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { runtimeQueryIds } from '../src/lib/runtime-query-ids.js';
import { TwitterClient } from '../src/lib/twitter-client.js';
const validCookies = {
authToken: 'test_auth_token',
ct0: 'test_ct0_token',
cookieHeader: 'auth_token=test_auth_token; ct0=test_ct0_token',
source: 'test',
};
type ResponseLike = {
ok: boolean;
status: number;
json: () => Promise<unknown>;
text: () => Promise<string>;
};
type TwitterClientPrivate = TwitterClient & {
refreshQueryIds: () => Promise<void>;
fetchWithTimeout: (url: string, init: RequestInit) => Promise<ResponseLike>;
ensureClientUserId: () => Promise<void>;
getBaseHeaders: () => Record<string, string>;
fetchUserArticlePlainText: (userId: string, tweetId: string) => Promise<{ title?: string; plainText?: string }>;
fetchTweetDetail: (tweetId: string) => Promise<{ success: true } | { success: false; error: string }>;
postStatusUpdate: (input: { text: string }) => Promise<{ success: boolean; error?: string }>;
};
const makeResponse = (overrides: Partial<ResponseLike> = {}): ResponseLike => ({
ok: true,
status: 200,
json: async (): Promise<unknown> => ({}),
text: async (): Promise<string> => '',
...overrides,
});
describe('TwitterClient core coverage', () => {
const originalFetch = global.fetch;
const originalNodeEnv = process.env.NODE_ENV;
afterEach(() => {
global.fetch = originalFetch;
process.env.NODE_ENV = originalNodeEnv;
delete process.env.BIRD_DEBUG_ARTICLE;
vi.restoreAllMocks();
});
it('refreshQueryIds calls runtime refresh outside test env', async () => {
const client = new TwitterClient({ cookies: validCookies });
const refreshSpy = vi.spyOn(runtimeQueryIds, 'refresh').mockResolvedValue(null);
process.env.NODE_ENV = 'production';
const clientPrivate = client as unknown as TwitterClientPrivate;
await clientPrivate.refreshQueryIds();
expect(refreshSpy).toHaveBeenCalled();
});
it('fetchWithTimeout uses an abort signal when timeout is set', async () => {
const mockFetch = vi.fn(async (_url: string, init?: RequestInit) => {
expect(init?.signal).toBeInstanceOf(AbortSignal);
return makeResponse();
});
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies, timeoutMs: 5 });
const clientPrivate = client as unknown as TwitterClientPrivate;
await clientPrivate.fetchWithTimeout('https://example.com', { method: 'GET' });
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('returns a not found error when tweet data is missing', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
makeResponse({
json: async () => ({
data: {
threaded_conversation_with_injections_v2: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'other',
legacy: { full_text: 'nope', created_at: '2024-01-01T00:00:00Z' },
core: { user_results: { result: { legacy: { screen_name: 'user', name: 'User' } } } },
},
},
},
},
},
],
},
],
},
},
}),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getTweet('123');
expect(result.success).toBe(false);
expect(result.error).toBe('Tweet not found in response');
});
it('adds client user id header after ensureClientUserId', async () => {
const client = new TwitterClient({ cookies: validCookies });
process.env.NODE_ENV = 'production';
vi.spyOn(client, 'getCurrentUser').mockResolvedValue({
success: true,
user: { id: '42', username: 'tester', name: 'Tester' },
});
const clientPrivate = client as unknown as TwitterClientPrivate;
await clientPrivate.ensureClientUserId();
const headers = clientPrivate.getBaseHeaders();
expect(headers['x-twitter-client-user-id']).toBe('42');
});
describe('fetchUserArticlePlainText failures', () => {
it('returns empty object when response is not ok', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(makeResponse({ ok: false, status: 500 }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.fetchUserArticlePlainText('user', 'tweet');
expect(result).toEqual({});
});
it('returns empty object when fetching throws', async () => {
const mockFetch = vi.fn().mockRejectedValueOnce(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.fetchUserArticlePlainText('user', 'tweet');
expect(result).toEqual({});
});
it('returns empty object when no matching tweet is found', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
makeResponse({
json: async () => ({
data: {
user: { result: { timeline: { timeline: { instructions: [] } } } },
},
}),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.fetchUserArticlePlainText('user', 'tweet');
expect(result).toEqual({});
});
});
describe('fetchTweetDetail error handling', () => {
it('returns API errors from response payloads', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
makeResponse({
json: async () => ({ errors: [{ message: 'bad news' }] }),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.fetchTweetDetail('1');
expect(result.success).toBe(false);
expect(result.error).toBe('bad news');
});
it('parses POST responses when GET returns 404', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' }))
.mockResolvedValueOnce(
makeResponse({
json: async () => ({
data: {
tweetResult: {
result: {
rest_id: '1',
legacy: {
full_text: 'hi',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
},
core: { user_results: { result: { legacy: { screen_name: 'user', name: 'User' } } } },
},
},
},
}),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.fetchTweetDetail('1');
expect(result.success).toBe(true);
});
it('returns an error when fetch throws', async () => {
const mockFetch = vi.fn().mockRejectedValueOnce(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.fetchTweetDetail('1');
expect(result.success).toBe(false);
expect(result.error).toContain('boom');
});
});
describe('postStatusUpdate error handling', () => {
it('returns an error when postStatusUpdate throws', async () => {
const mockFetch = vi.fn().mockRejectedValueOnce(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.postStatusUpdate({ text: 'hello' });
expect(result.success).toBe(false);
expect(result.error).toContain('boom');
});
});
});
@@ -0,0 +1,224 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
const validCookies = {
authToken: 'test_auth_token',
ct0: 'test_ct0_token',
cookieHeader: 'auth_token=test_auth_token; ct0=test_ct0_token',
source: 'test',
};
type ResponseLike = {
ok: boolean;
status: number;
json: () => Promise<unknown>;
text: () => Promise<string>;
};
type TwitterClientUploadPrivate = TwitterClient & {
sleep: (ms: number) => Promise<void>;
extractArticleText: (result: unknown) => string | undefined;
};
const makeResponse = (overrides: Partial<ResponseLike> = {}): ResponseLike => ({
ok: true,
status: 200,
json: async (): Promise<unknown> => ({}),
text: async (): Promise<string> => '',
...overrides,
});
describe('TwitterClient upload coverage', () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
delete process.env.BIRD_DEBUG_ARTICLE;
vi.restoreAllMocks();
});
describe('uploadMedia error paths', () => {
it('rejects unsupported media types', async () => {
global.fetch = vi.fn() as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.uploadMedia({ data: new Uint8Array([1, 2, 3]), mimeType: 'application/pdf' });
expect(result.success).toBe(false);
expect(global.fetch).not.toHaveBeenCalled();
});
it('returns an error when INIT fails', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'nope' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png' });
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
it('returns an error when APPEND fails', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ json: async () => ({ media_id_string: '1' }) }))
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'nope' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png' });
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
it('returns an error when FINALIZE fails', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ json: async () => ({ media_id_string: '1' }) }))
.mockResolvedValueOnce(makeResponse())
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'nope' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png' });
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
it('returns an error when media processing fails', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ json: async () => ({ media_id_string: '1' }) }))
.mockResolvedValueOnce(makeResponse())
.mockResolvedValueOnce(
makeResponse({
json: async () => ({ processing_info: { state: 'failed', error: { message: 'processing failed' } } }),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'video/mp4' });
expect(result.success).toBe(false);
expect(result.error).toBe('processing failed');
});
it('returns an error when STATUS fails', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ json: async () => ({ media_id_string: '1' }) }))
.mockResolvedValueOnce(makeResponse())
.mockResolvedValueOnce(
makeResponse({
json: async () => ({ processing_info: { state: 'pending', check_after_secs: 0 } }),
}),
)
.mockResolvedValueOnce(makeResponse({ ok: false, status: 503, text: async () => 'down' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientUploadPrivate;
clientPrivate.sleep = vi.fn().mockResolvedValue(undefined);
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'video/mp4' });
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 503');
});
it('retries STATUS and continues when processing is pending', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ json: async () => ({ media_id_string: '1' }) }))
.mockResolvedValueOnce(makeResponse())
.mockResolvedValueOnce(
makeResponse({
json: async () => ({ processing_info: { state: 'pending', check_after_secs: 0 } }),
}),
)
.mockResolvedValueOnce(
makeResponse({
json: async () => ({ processing_info: { state: 'pending', check_after_secs: 0 } }),
}),
)
.mockResolvedValueOnce(makeResponse({ json: async () => ({ processing_info: { state: 'succeeded' } }) }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientUploadPrivate;
clientPrivate.sleep = vi.fn().mockResolvedValue(undefined);
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'video/mp4' });
expect(result.success).toBe(true);
});
it('returns an error when metadata upload fails', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ json: async () => ({ media_id_string: '1' }) }))
.mockResolvedValueOnce(makeResponse())
.mockResolvedValueOnce(makeResponse({ json: async () => ({}) }))
.mockResolvedValueOnce(makeResponse({ ok: false, status: 400, text: async () => 'bad' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png', alt: 'alt' });
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 400');
});
it('returns an error when upload throws', async () => {
const mockFetch = vi.fn().mockRejectedValueOnce(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png' });
expect(result.success).toBe(false);
expect(result.error).toContain('boom');
});
});
describe('article extraction edge cases', () => {
it('logs article payloads when debug flag is set', () => {
const client = new TwitterClient({ cookies: validCookies });
process.env.BIRD_DEBUG_ARTICLE = '1';
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const clientPrivate = client as unknown as TwitterClientUploadPrivate;
const result = clientPrivate.extractArticleText({
rest_id: '1',
article: {
title: 'Title',
plain_text: 'Body',
article_results: { result: { title: 'Title', plain_text: 'Body' } },
},
});
expect(result).toBe('Title\n\nBody');
expect(errorSpy).toHaveBeenCalled();
});
it('drops duplicate body when it matches the title', () => {
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientUploadPrivate;
const result = clientPrivate.extractArticleText({
rest_id: '1',
article: {
title: 'Same',
plain_text: 'Same',
article_results: { result: { title: 'Same', plain_text: 'Same' } },
},
});
expect(result).toBe('Same');
});
});
});
+72
View File
@@ -1072,6 +1072,78 @@ describe('TwitterClient', () => {
}); });
}); });
describe('bookmarks', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
it('fetches bookmarks and parses tweet results', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
bookmark_timeline_v2: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '1',
legacy: {
full_text: 'saved',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '1',
},
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'root', name: 'Root' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarks(2);
expect(result.success).toBe(true);
expect(result.tweets?.[0].id).toBe('1');
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, options] = mockFetch.mock.calls[0];
expect(options.method).toBe('GET');
expect(String(url)).toContain('/Bookmarks?');
const parsedVars = JSON.parse(new URL(url as string).searchParams.get('variables') as string);
expect(parsedVars.count).toBe(2);
const parsedFeatures = JSON.parse(new URL(url as string).searchParams.get('features') as string);
expect(parsedFeatures.graphql_timeline_v2_bookmark_timeline).toBe(true);
});
});
describe('conversation helpers', () => { describe('conversation helpers', () => {
let mockFetch: ReturnType<typeof vi.fn>; let mockFetch: ReturnType<typeof vi.fn>;
+59 -1
View File
@@ -3,7 +3,13 @@ import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url'; import { fileURLToPath, pathToFileURL } from 'node:url';
import { afterEach, describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it } from 'vitest';
import { FALLBACK_VERSION, formatVersionLine, resolveGitSha, resolvePackageVersion } from '../src/lib/version.js'; import {
FALLBACK_VERSION,
formatVersionLine,
getCliVersion,
resolveGitSha,
resolvePackageVersion,
} from '../src/lib/version.js';
function withTempDir<T>(fn: (dir: string) => T): T { function withTempDir<T>(fn: (dir: string) => T): T {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bird-version-test-')); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bird-version-test-'));
@@ -53,6 +59,17 @@ describe('getCliVersion', () => {
}); });
}); });
it('falls back to VERSION file when package.json has no version', () => {
withTempDir((dir) => {
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({}));
fs.writeFileSync(path.join(dir, 'VERSION'), '5.5.5\n');
const entry = path.join(dir, 'entry.ts');
fs.writeFileSync(entry, '// noop');
expect(resolvePackageVersion(pathToFileURL(entry).href)).toBe('5.5.5');
});
});
it('falls back to unknown when no version files exist', () => { it('falls back to unknown when no version files exist', () => {
withTempDir((dir) => { withTempDir((dir) => {
const entry = path.join(dir, 'a', 'b', 'c', 'entry.ts'); const entry = path.join(dir, 'a', 'b', 'c', 'entry.ts');
@@ -63,6 +80,31 @@ describe('getCliVersion', () => {
}); });
}); });
it('returns null when git HEAD is missing', () => {
withTempDir((dir) => {
const gitDir = path.join(dir, '.git');
fs.mkdirSync(gitDir, { recursive: true });
const entry = path.join(dir, 'entry.ts');
fs.writeFileSync(entry, '// noop');
expect(resolveGitSha(pathToFileURL(entry).href)).toBeNull();
});
});
it('returns null when git ref cannot be resolved', () => {
withTempDir((dir) => {
const gitDir = path.join(dir, '.git');
fs.mkdirSync(gitDir, { recursive: true });
fs.writeFileSync(path.join(gitDir, 'HEAD'), 'ref: refs/heads/main\n');
const entry = path.join(dir, 'entry.ts');
fs.writeFileSync(entry, '// noop');
expect(resolveGitSha(pathToFileURL(entry).href)).toBeNull();
});
});
it('resolves git sha from .git directory HEAD', () => { it('resolves git sha from .git directory HEAD', () => {
withTempDir((dir) => { withTempDir((dir) => {
const gitDir = path.join(dir, '.git'); const gitDir = path.join(dir, '.git');
@@ -113,6 +155,16 @@ describe('getCliVersion', () => {
}); });
}); });
it('returns null when no git directory exists', () => {
withTempDir((dir) => {
const entry = path.join(dir, 'nested', 'entry.ts');
fs.mkdirSync(path.dirname(entry), { recursive: true });
fs.writeFileSync(entry, '// noop');
expect(resolveGitSha(pathToFileURL(entry).href)).toBeNull();
});
});
it('formats version line with version + sha when both available', () => { it('formats version line with version + sha when both available', () => {
withTempDir((dir) => { withTempDir((dir) => {
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ version: '3.3.3' })); fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ version: '3.3.3' }));
@@ -126,4 +178,10 @@ describe('getCliVersion', () => {
expect(formatVersionLine(pathToFileURL(entry).href)).toBe('3.3.3 (dddddddd)'); expect(formatVersionLine(pathToFileURL(entry).href)).toBe('3.3.3 (dddddddd)');
}); });
}); });
it('returns a CLI version string', () => {
const value = getCliVersion();
expect(typeof value).toBe('string');
expect(value.length).toBeGreaterThan(0);
});
}); });
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"rootDir": "."
},
"include": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.ts"],
"exclude": ["node_modules", "dist", "coverage"]
}
+4 -4
View File
@@ -11,10 +11,10 @@ export default defineConfig({
include: ['src/**/*.ts'], include: ['src/**/*.ts'],
exclude: ['src/index.ts'], exclude: ['src/index.ts'],
thresholds: { thresholds: {
statements: 70, statements: 90,
branches: 70, branches: 80,
functions: 70, functions: 90,
lines: 70, lines: 90,
}, },
}, },
}, },