From 14ad6cf73fe206c6f7a0b905906adbec39f3b0fc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Jan 2026 12:00:02 +0100 Subject: [PATCH] fix: allow cookie timeout config --- CHANGELOG.md | 1 + README.md | 3 +++ patches/@steipete__sweet-cookie.patch | 13 +++++++++++++ pnpm-lock.yaml | 9 +++++++-- pnpm-workspace.yaml | 3 +++ src/index.ts | 8 ++++++++ src/lib/cookies.ts | 13 +++++++++++++ tests/cookies.test.ts | 12 ++++++++++-- 8 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 patches/@steipete__sweet-cookie.patch diff --git a/CHANGELOG.md b/CHANGELOG.md index 8daa72e..14a3c26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Query ID updater now tracks Following/Followers GraphQL operations. - Query ID updater now tracks BookmarkFolderTimeline and keeps bookmark query IDs seeded. - `following`/`followers` JSON user fields are now camelCase (`followersCount`, `followingCount`, `isBlueVerified`, `profileImageUrl`, `createdAt`). +- Cookie extraction timeout is now configurable (default 20s on macOS) via `--cookie-timeout` / `BIRD_COOKIE_TIMEOUT_MS`. ## 0.4.1 — 2025-12-31 ### Added diff --git a/README.md b/README.md index 5eb37fb..1895ff4 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ bird query-ids --fresh Global options: - `--timeout `: abort requests after the given timeout (milliseconds). +- `--cookie-timeout `: cookie extraction timeout for keychain/OS helpers (milliseconds). - `--quote-depth `: max quoted tweet depth in JSON output (default: 1; 0 disables). - `--plain`: stable output (no emoji, no color). - `--no-emoji`: disable emoji output. @@ -128,6 +129,7 @@ Example `~/.config/bird/config.json5`: // Cookie source order for browser extraction (string or array) cookieSource: ["firefox", "safari"], firefoxProfile: "default-release", + cookieTimeoutMs: 20000, timeoutMs: 20000, quoteDepth: 1 } @@ -135,6 +137,7 @@ Example `~/.config/bird/config.json5`: Environment shortcuts: - `BIRD_TIMEOUT_MS` +- `BIRD_COOKIE_TIMEOUT_MS` - `BIRD_QUOTE_DEPTH` ## Output diff --git a/patches/@steipete__sweet-cookie.patch b/patches/@steipete__sweet-cookie.patch new file mode 100644 index 0000000..3e70cf3 --- /dev/null +++ b/patches/@steipete__sweet-cookie.patch @@ -0,0 +1,13 @@ +diff --git a/dist/providers/chromeSqliteMac.js b/dist/providers/chromeSqliteMac.js +index 71c815f77378d03e07061976ac372b3a55a61ac4..3a85a2fce102128d7e7b69145d97bc353f663008 100644 +--- a/dist/providers/chromeSqliteMac.js ++++ b/dist/providers/chromeSqliteMac.js +@@ -15,7 +15,7 @@ export async function getCookiesFromChromeSqliteMac(options, origins, allowlistN + const passwordResult = await readKeychainGenericPasswordFirst({ + account: 'Chrome', + services: ['Chrome Safe Storage'], +- timeoutMs: 3_000, ++ timeoutMs: options.timeoutMs ?? 3_000, + label: 'Chrome Safe Storage', + }); + if (!passwordResult.ok) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 65c9fbc..bab9939 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,13 +4,18 @@ settings: autoInstallPeers: false excludeLinksFromLockfile: false +patchedDependencies: + '@steipete/sweet-cookie': + hash: 05180a0fa74ba5074775f965db7d4561e59ba51978bd5e955c0e4f058a4ed340 + path: patches/@steipete__sweet-cookie.patch + importers: .: dependencies: '@steipete/sweet-cookie': specifier: 0.1.0 - version: 0.1.0 + version: 0.1.0(patch_hash=05180a0fa74ba5074775f965db7d4561e59ba51978bd5e955c0e4f058a4ed340) commander: specifier: ^14.0.2 version: 14.0.2 @@ -1051,7 +1056,7 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@steipete/sweet-cookie@0.1.0': {} + '@steipete/sweet-cookie@0.1.0(patch_hash=05180a0fa74ba5074775f965db7d4561e59ba51978bd5e955c0e4f058a4ed340)': {} '@types/chai@5.2.3': dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index efc037a..eebef26 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,5 @@ onlyBuiltDependencies: - esbuild + +patchedDependencies: + '@steipete/sweet-cookie': patches/@steipete__sweet-cookie.patch diff --git a/src/index.ts b/src/index.ts index 881339c..c20155a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -108,6 +108,7 @@ type BirdConfig = { chromeProfile?: string; firefoxProfile?: string; cookieSource?: CookieSource | CookieSource[]; + cookieTimeoutMs?: number; timeoutMs?: number; quoteDepth?: number; }; @@ -187,6 +188,7 @@ 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) + .option('--cookie-timeout ', 'Cookie extraction timeout in milliseconds (keychain/OS helpers)') .option( '--cookie-source ', 'Cookie source for browser cookie extraction (repeatable)', @@ -207,6 +209,7 @@ type CredentialsOptions = { chromeProfile?: string; firefoxProfile?: string; cookieSource?: CookieSource[]; + cookieTimeout?: string | number; }; function resolveCredentialsFromOptions(opts: CredentialsOptions): ReturnType { @@ -219,6 +222,7 @@ function resolveCredentialsFromOptions(opts: CredentialsOptions): ReturnType): number | undefined { for (const value of values) { if (value === undefined || value === null || value === '') { diff --git a/src/lib/cookies.ts b/src/lib/cookies.ts index 9df0825..5855931 100644 --- a/src/lib/cookies.ts +++ b/src/lib/cookies.ts @@ -22,6 +22,7 @@ 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/']; +const DEFAULT_COOKIE_TIMEOUT_MS = 20_000; function normalizeValue(value: unknown): string | null { if (typeof value !== 'string') { @@ -102,6 +103,7 @@ async function readTwitterCookiesFromBrowser(options: { source: CookieSource; chromeProfile?: string; firefoxProfile?: string; + cookieTimeoutMs?: number; }): Promise { const warnings: string[] = []; const out = buildEmpty(); @@ -114,6 +116,7 @@ async function readTwitterCookiesFromBrowser(options: { mode: 'merge', chromeProfile: options.chromeProfile, firefoxProfile: options.firefoxProfile, + timeoutMs: options.cookieTimeoutMs, }); warnings.push(...providerWarnings); @@ -170,9 +173,18 @@ export async function resolveCredentials(options: { cookieSource?: CookieSource | CookieSource[]; chromeProfile?: string; firefoxProfile?: string; + cookieTimeoutMs?: number; }): Promise { const warnings: string[] = []; const cookies = buildEmpty(); + const cookieTimeoutMs = + typeof options.cookieTimeoutMs === 'number' && + Number.isFinite(options.cookieTimeoutMs) && + options.cookieTimeoutMs > 0 + ? options.cookieTimeoutMs + : process.platform === 'darwin' + ? DEFAULT_COOKIE_TIMEOUT_MS + : undefined; if (options.authToken) { cookies.authToken = options.authToken; @@ -199,6 +211,7 @@ export async function resolveCredentials(options: { source, chromeProfile: options.chromeProfile, firefoxProfile: options.firefoxProfile, + cookieTimeoutMs, }); warnings.push(...res.warnings); if (res.cookies.authToken && res.cookies.ct0) { diff --git a/tests/cookies.test.ts b/tests/cookies.test.ts index c7c7f6c..9bb8cfa 100644 --- a/tests/cookies.test.ts +++ b/tests/cookies.test.ts @@ -4,11 +4,13 @@ type SweetCookieResult = { cookies: Array<{ name: string; value: string; domain? const sweet = vi.hoisted(() => ({ results: new Map(), + options: new Map(), })); vi.mock('@steipete/sweet-cookie', () => ({ - getCookies: vi.fn(async (options: { browsers?: string[] }) => { + getCookies: vi.fn(async (options: { browsers?: string[]; timeoutMs?: number }) => { const browser = options.browsers?.[0] ?? 'unknown'; + sweet.options.set(browser, options); return ( sweet.results.get(browser) ?? { cookies: [], @@ -24,6 +26,7 @@ describe('cookies', () => { beforeEach(() => { vi.resetModules(); sweet.results.clear(); + sweet.options.clear(); process.env = { ...originalEnv }; process.env.AUTH_TOKEN = undefined; process.env.TWITTER_AUTH_TOKEN = undefined; @@ -186,11 +189,16 @@ describe('cookies', () => { }); const { resolveCredentials } = await import('../src/lib/cookies.js'); - const result = await resolveCredentials({ cookieSource: 'chrome', chromeProfile: 'Default' }); + const result = await resolveCredentials({ + cookieSource: 'chrome', + chromeProfile: 'Default', + cookieTimeoutMs: 15000, + }); expect(result.cookies.authToken).toBe('test_auth'); expect(result.cookies.ct0).toBe('test_ct0'); expect(result.cookies.source).toContain('Chrome'); + expect(sweet.options.get('chrome')?.timeoutMs).toBe(15000); }); it('uses default browser order when cookieSource is omitted', async () => {