From 1744f4cc248a3922a33ee145de214de3f0d0f272 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 26 Dec 2025 21:17:20 +0100 Subject: [PATCH] feat(auth): allow selecting cookie source --- CHANGELOG.md | 3 ++ README.md | 4 +- src/index.ts | 121 +++++++++++++----------------------------- src/lib/cookies.ts | 100 +++++++++++++++++++++------------- tests/cookies.test.ts | 58 ++++++++++++++++++++ 5 files changed, 165 insertions(+), 121 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da898b8..a5deb08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.3.1 — Unreleased +### Added +- Cookie source selection: `--cookie-source auto|safari|chrome|firefox` + `cookieSource` config option. + ## 0.3.0 — 2025-12-26 ### Added diff --git a/README.md b/README.md index 231d25e..b5d8d7f 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ Global options: - `--plain`: stable output (no emoji, no color). - `--no-emoji`: disable emoji output. - `--no-color`: disable ANSI colors (or set `NO_COLOR=1`). +- `--cookie-source `: choose which browser cookies to use (default `auto`). ## Authentication (GraphQL) @@ -72,7 +73,7 @@ X endpoints and authenticates via cookies (`auth_token`, `ct0`). 1. CLI flags: `--auth-token`, `--ct0` 2. Environment variables: `AUTH_TOKEN`, `CT0` (fallback: `TWITTER_AUTH_TOKEN`, `TWITTER_CT0`) -3. Browser cookies (macOS): Safari, Chrome, Firefox +3. Browser cookies (macOS): Safari, Chrome, Firefox (override via `--cookie-source`) Browser cookie sources: - Safari: `~/Library/Cookies/Cookies.binarycookies` (fallback: `~/Library/Containers/com.apple.Safari/Data/Library/Cookies/Cookies.binarycookies`) @@ -90,6 +91,7 @@ Example `~/.config/bird/config.json5`: ```json5 { + cookieSource: "auto", // or: "firefox" / "chrome" / "safari" firefoxProfile: "default-release", allowSafari: true, allowFirefox: true, diff --git a/src/index.ts b/src/index.ts index 7201716..e8f24a8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,11 +12,11 @@ import { existsSync, readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; -import { Command } from 'commander'; +import { Command, Option } from 'commander'; import JSON5 from 'json5'; import kleur from 'kleur'; import { resolveCliInvocation } from './lib/cli-args.js'; -import { resolveCredentials } from './lib/cookies.js'; +import { type CookieSource, resolveCredentials } from './lib/cookies.js'; import { extractTweetId } from './lib/extract-tweet-id.js'; import { mentionsQueryFromUserOption, normalizeHandle } from './lib/normalize-handle.js'; import { @@ -70,6 +70,7 @@ const colors = { type BirdConfig = { chromeProfile?: string; firefoxProfile?: string; + cookieSource?: CookieSource; allowSafari?: boolean; allowChrome?: boolean; allowFirefox?: boolean; @@ -145,6 +146,11 @@ program .option('--ct0 ', 'Twitter ct0 cookie') .option('--chrome-profile ', 'Chrome profile name for cookie extraction', config.chromeProfile) .option('--firefox-profile ', 'Firefox profile name for cookie extraction', config.firefoxProfile) + .addOption( + new Option('--cookie-source ', 'Cookie source for browser cookie extraction') + .choices(['auto', 'safari', 'chrome', 'firefox']) + .default(config.cookieSource ?? 'auto'), + ) .option('--media ', 'Attach media file (repeatable, up to 4 images or 1 video)', collect, []) .option('--alt ', 'Alt text for the corresponding --media (repeatable)', collect, []) .option('--timeout ', 'Request timeout in milliseconds') @@ -152,6 +158,27 @@ program .option('--no-emoji', 'Disable emoji output') .option('--no-color', 'Disable ANSI colors (or set NO_COLOR)'); +type CredentialsOptions = { + authToken?: string; + ct0?: string; + chromeProfile?: string; + firefoxProfile?: string; + cookieSource?: CookieSource; +}; + +function resolveCredentialsFromOptions(opts: CredentialsOptions) { + return resolveCredentials({ + authToken: opts.authToken, + ct0: opts.ct0, + cookieSource: opts.cookieSource ?? config.cookieSource ?? 'auto', + chromeProfile: opts.chromeProfile || config.chromeProfile, + firefoxProfile: opts.firefoxProfile || config.firefoxProfile, + allowSafari: config.allowSafari ?? true, + allowChrome: config.allowChrome ?? true, + allowFirefox: config.allowFirefox ?? true, + }); +} + program.hook('preAction', (_thisCommand, actionCommand) => { applyOutputFromCommand(actionCommand); }); @@ -318,15 +345,7 @@ program process.exit(1); } - const { cookies, warnings } = await resolveCredentials({ - authToken: opts.authToken, - ct0: opts.ct0, - chromeProfile: opts.chromeProfile || config.chromeProfile, - firefoxProfile: opts.firefoxProfile || config.firefoxProfile, - allowSafari: config.allowSafari ?? true, - allowChrome: config.allowChrome ?? true, - allowFirefox: config.allowFirefox ?? true, - }); + const { cookies, warnings } = await resolveCredentialsFromOptions(opts); for (const warning of warnings) { console.error(`${p('warn')}${warning}`); @@ -385,15 +404,7 @@ program } const tweetId = extractTweetId(tweetIdOrUrl); - const { cookies, warnings } = await resolveCredentials({ - authToken: opts.authToken, - ct0: opts.ct0, - chromeProfile: opts.chromeProfile || config.chromeProfile, - firefoxProfile: opts.firefoxProfile || config.firefoxProfile, - allowSafari: config.allowSafari ?? true, - allowChrome: config.allowChrome ?? true, - allowFirefox: config.allowFirefox ?? true, - }); + const { cookies, warnings } = await resolveCredentialsFromOptions(opts); for (const warning of warnings) { console.error(`${p('warn')}${warning}`); @@ -448,15 +459,7 @@ program const tweetId = extractTweetId(tweetIdOrUrl); - const { cookies, warnings } = await resolveCredentials({ - authToken: opts.authToken, - ct0: opts.ct0, - chromeProfile: opts.chromeProfile || config.chromeProfile, - firefoxProfile: opts.firefoxProfile || config.firefoxProfile, - allowSafari: config.allowSafari ?? true, - allowChrome: config.allowChrome ?? true, - allowFirefox: config.allowFirefox ?? true, - }); + const { cookies, warnings } = await resolveCredentialsFromOptions(opts); for (const warning of warnings) { console.error(`${p('warn')}${warning}`); @@ -498,15 +501,7 @@ program const timeoutMs = resolveTimeoutFromOptions(opts); const tweetId = extractTweetId(tweetIdOrUrl); - const { cookies, warnings } = await resolveCredentials({ - authToken: opts.authToken, - ct0: opts.ct0, - chromeProfile: opts.chromeProfile || config.chromeProfile, - firefoxProfile: opts.firefoxProfile || config.firefoxProfile, - allowSafari: config.allowSafari ?? true, - allowChrome: config.allowChrome ?? true, - allowFirefox: config.allowFirefox ?? true, - }); + const { cookies, warnings } = await resolveCredentialsFromOptions(opts); for (const warning of warnings) { console.error(`${p('warn')}${warning}`); @@ -539,15 +534,7 @@ program const timeoutMs = resolveTimeoutFromOptions(opts); const tweetId = extractTweetId(tweetIdOrUrl); - const { cookies, warnings } = await resolveCredentials({ - authToken: opts.authToken, - ct0: opts.ct0, - chromeProfile: opts.chromeProfile || config.chromeProfile, - firefoxProfile: opts.firefoxProfile || config.firefoxProfile, - allowSafari: config.allowSafari ?? true, - allowChrome: config.allowChrome ?? true, - allowFirefox: config.allowFirefox ?? true, - }); + const { cookies, warnings } = await resolveCredentialsFromOptions(opts); for (const warning of warnings) { console.error(`${p('warn')}${warning}`); @@ -581,15 +568,7 @@ program const timeoutMs = resolveTimeoutFromOptions(opts); const count = Number.parseInt(cmdOpts.count || '10', 10); - const { cookies, warnings } = await resolveCredentials({ - authToken: opts.authToken, - ct0: opts.ct0, - chromeProfile: opts.chromeProfile || config.chromeProfile, - firefoxProfile: opts.firefoxProfile || config.firefoxProfile, - allowSafari: config.allowSafari ?? true, - allowChrome: config.allowChrome ?? true, - allowFirefox: config.allowFirefox ?? true, - }); + const { cookies, warnings } = await resolveCredentialsFromOptions(opts); for (const warning of warnings) { console.error(`${p('warn')}${warning}`); @@ -631,15 +610,7 @@ program let query: string | null = fromUserOpt.query; - const { cookies, warnings } = await resolveCredentials({ - authToken: opts.authToken, - ct0: opts.ct0, - chromeProfile: opts.chromeProfile || config.chromeProfile, - firefoxProfile: opts.firefoxProfile || config.firefoxProfile, - allowSafari: config.allowSafari ?? true, - allowChrome: config.allowChrome ?? true, - allowFirefox: config.allowFirefox ?? true, - }); + const { cookies, warnings } = await resolveCredentialsFromOptions(opts); for (const warning of warnings) { console.error(`${p('warn')}${warning}`); @@ -683,15 +654,7 @@ program const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); - const { cookies, warnings } = await resolveCredentials({ - authToken: opts.authToken, - ct0: opts.ct0, - chromeProfile: opts.chromeProfile || config.chromeProfile, - firefoxProfile: opts.firefoxProfile || config.firefoxProfile, - allowSafari: config.allowSafari ?? true, - allowChrome: config.allowChrome ?? true, - allowFirefox: config.allowFirefox ?? true, - }); + const { cookies, warnings } = await resolveCredentialsFromOptions(opts); for (const warning of warnings) { console.error(`${p('warn')}${warning}`); @@ -728,15 +691,7 @@ program .description('Check credential availability') .action(async () => { const opts = program.opts(); - const { cookies, warnings } = await resolveCredentials({ - authToken: opts.authToken, - ct0: opts.ct0, - chromeProfile: opts.chromeProfile || config.chromeProfile, - firefoxProfile: opts.firefoxProfile || config.firefoxProfile, - allowSafari: config.allowSafari ?? true, - allowChrome: config.allowChrome ?? true, - allowFirefox: config.allowFirefox ?? true, - }); + const { cookies, warnings } = await resolveCredentialsFromOptions(opts); console.log(`${p('info')}Credential check`); console.log('─'.repeat(40)); diff --git a/src/lib/cookies.ts b/src/lib/cookies.ts index 68b1696..2d9170d 100644 --- a/src/lib/cookies.ts +++ b/src/lib/cookies.ts @@ -20,6 +20,8 @@ export interface CookieExtractionResult { warnings: string[]; } +export type CookieSource = 'auto' | 'safari' | 'chrome' | 'firefox'; + function normalizeValue(value: unknown): string | null { if (typeof value === 'string') { const trimmed = value.trim(); @@ -444,6 +446,7 @@ export async function extractCookiesFromFirefox(profile?: string): Promise> = + cookieSource === 'auto' ? ['safari', 'chrome', 'firefox'] : [cookieSource]; - if (!cookies.authToken && safariResult.cookies.authToken) { - cookies.authToken = safariResult.cookies.authToken; - cookies.source = safariResult.cookies.source; - } - if (!cookies.ct0 && safariResult.cookies.ct0) { - cookies.ct0 = safariResult.cookies.ct0; - if (!cookies.source) cookies.source = safariResult.cookies.source; - } - } + for (const source of sourcesToTry) { + if (cookies.authToken && cookies.ct0) break; - // 4. Chrome cookies (secondary browser fallback) - if (allowChrome && (!cookies.authToken || !cookies.ct0)) { - const chromeResult = await extractCookiesFromChrome(options.chromeProfile); - warnings.push(...chromeResult.warnings); + if (source === 'safari') { + if (!allowSafari) { + warnings.push('Safari cookie extraction disabled (allowSafari=false).'); + continue; + } - if (!cookies.authToken && chromeResult.cookies.authToken) { - cookies.authToken = chromeResult.cookies.authToken; - cookies.source = chromeResult.cookies.source; - } - if (!cookies.ct0 && chromeResult.cookies.ct0) { - cookies.ct0 = chromeResult.cookies.ct0; - if (!cookies.source) cookies.source = chromeResult.cookies.source; - } - } + const safariResult = await extractCookiesFromSafari(); + warnings.push(...safariResult.warnings); - // 5. Firefox cookies (tertiary browser fallback) - if (allowFirefox && (!cookies.authToken || !cookies.ct0)) { - const firefoxResult = await extractCookiesFromFirefox(options.firefoxProfile); - warnings.push(...firefoxResult.warnings); - - if (!cookies.authToken && firefoxResult.cookies.authToken) { - cookies.authToken = firefoxResult.cookies.authToken; - cookies.source = firefoxResult.cookies.source; + if (!cookies.authToken && safariResult.cookies.authToken) { + cookies.authToken = safariResult.cookies.authToken; + cookies.source = safariResult.cookies.source; + } + if (!cookies.ct0 && safariResult.cookies.ct0) { + cookies.ct0 = safariResult.cookies.ct0; + if (!cookies.source) cookies.source = safariResult.cookies.source; + } + continue; } - if (!cookies.ct0 && firefoxResult.cookies.ct0) { - cookies.ct0 = firefoxResult.cookies.ct0; - if (!cookies.source) cookies.source = firefoxResult.cookies.source; + + if (source === 'chrome') { + if (!allowChrome) { + warnings.push('Chrome cookie extraction disabled (allowChrome=false).'); + continue; + } + + const chromeResult = await extractCookiesFromChrome(options.chromeProfile); + warnings.push(...chromeResult.warnings); + + if (!cookies.authToken && chromeResult.cookies.authToken) { + cookies.authToken = chromeResult.cookies.authToken; + cookies.source = chromeResult.cookies.source; + } + if (!cookies.ct0 && chromeResult.cookies.ct0) { + cookies.ct0 = chromeResult.cookies.ct0; + if (!cookies.source) cookies.source = chromeResult.cookies.source; + } + continue; + } + + if (source === 'firefox') { + if (!allowFirefox) { + warnings.push('Firefox cookie extraction disabled (allowFirefox=false).'); + continue; + } + + const firefoxResult = await extractCookiesFromFirefox(options.firefoxProfile); + warnings.push(...firefoxResult.warnings); + + if (!cookies.authToken && firefoxResult.cookies.authToken) { + cookies.authToken = firefoxResult.cookies.authToken; + cookies.source = firefoxResult.cookies.source; + } + if (!cookies.ct0 && firefoxResult.cookies.ct0) { + cookies.ct0 = firefoxResult.cookies.ct0; + if (!cookies.source) cookies.source = firefoxResult.cookies.source; + } } } diff --git a/tests/cookies.test.ts b/tests/cookies.test.ts index 533aaae..c66efad 100644 --- a/tests/cookies.test.ts +++ b/tests/cookies.test.ts @@ -105,6 +105,64 @@ describe('cookies', () => { }); describe('resolveCredentials', () => { + it('honors cookieSource=firefox even when Safari has cookies', async () => { + const { resolveCredentials } = await import('../src/lib/cookies.js'); + const fs = await import('node:fs'); + const { execSync } = await import('node:child_process'); + + // Safari cookie file exists + contains different cookies + (fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => { + const lower = path.toLowerCase(); + if (lower.endsWith('cookies.binarycookies')) return true; + if (lower.endsWith('cookies.sqlite')) return true; + if (lower.includes('firefox')) return true; + return false; + }); + (fs.readdirSync as unknown as vi.Mock).mockReturnValue([ + { isDirectory: () => true, name: 'abc.default-release' }, + ]); + (fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {}); + (fs.readFileSync as unknown as vi.Mock).mockReturnValue( + buildSafariCookiesFile([ + buildSafariCookieRecord({ domain: '.x.com', name: 'auth_token', value: 'safari_auth' }), + buildSafariCookieRecord({ domain: '.x.com', name: 'ct0', value: 'safari_ct0' }), + ]), + ); + + // Firefox sqlite3 extraction returns different cookies + (execSync as unknown as vi.Mock).mockImplementation((cmd: string) => { + if (cmd.includes('sqlite3')) return 'auth_token|firefox_auth\nct0|firefox_ct0'; + return ''; + }); + + const result = await resolveCredentials({ cookieSource: 'firefox', allowChrome: false }); + expect(result.cookies.authToken).toBe('firefox_auth'); + expect(result.cookies.ct0).toBe('firefox_ct0'); + expect(result.cookies.source).toContain('Firefox'); + }); + + it('honors cookieSource=safari', async () => { + const { resolveCredentials } = await import('../src/lib/cookies.js'); + const fs = await import('node:fs'); + + (fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => + path.toLowerCase().endsWith('cookies.binarycookies'), + ); + (fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {}); + (fs.mkdtempSync as unknown as vi.Mock).mockReturnValue('/tmp/test-dir'); + (fs.readFileSync as unknown as vi.Mock).mockReturnValue( + buildSafariCookiesFile([ + buildSafariCookieRecord({ domain: '.x.com', name: 'auth_token', value: 'safari_auth' }), + buildSafariCookieRecord({ domain: '.x.com', name: 'ct0', value: 'safari_ct0' }), + ]), + ); + + const result = await resolveCredentials({ cookieSource: 'safari', allowChrome: false, allowFirefox: false }); + expect(result.cookies.authToken).toBe('safari_auth'); + expect(result.cookies.ct0).toBe('safari_ct0'); + expect(result.cookies.source).toBe('Safari'); + }); + it('uses firefox when enabled and returns cookies', async () => { const { resolveCredentials } = await import('../src/lib/cookies.js'); const fs = await import('node:fs');