From 5ae8801b23e976bb5e2b252bc649666c64091ecc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 28 Nov 2025 08:09:32 +0100 Subject: [PATCH] chore: reformat with 2-space indentation (biome) --- biome.json | 61 +-- package.json | 62 +-- src/index.ts | 290 ++++++------- src/lib/cookies.ts | 350 +++++++-------- src/lib/extract-tweet-id.ts | 14 +- src/lib/twitter-client.ts | 800 +++++++++++++++++------------------ tests/cli.test.ts | 46 +- tests/cookies.test.ts | 150 +++---- tests/twitter-client.test.ts | 508 +++++++++++----------- tsconfig.json | 36 +- vitest.config.ts | 22 +- 11 files changed, 1170 insertions(+), 1169 deletions(-) diff --git a/biome.json b/biome.json index 723f358..4ab1168 100644 --- a/biome.json +++ b/biome.json @@ -1,32 +1,33 @@ { - "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", - "organizeImports": { - "enabled": true - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true, - "complexity": { - "noForEach": "off" - }, - "style": { - "noNonNullAssertion": "off" - } - } - }, - "formatter": { - "enabled": true, - "indentStyle": "tab", - "lineWidth": 120 - }, - "javascript": { - "formatter": { - "quoteStyle": "single", - "semicolons": "always" - } - }, - "files": { - "ignore": ["dist/", "node_modules/", "*.d.ts"] - } + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "complexity": { + "noForEach": "off" + }, + "style": { + "noNonNullAssertion": "off" + } + } + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 120 + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "semicolons": "always" + } + }, + "files": { + "ignore": ["dist/", "node_modules/", "*.d.ts"] + } } diff --git a/package.json b/package.json index 6d741e6..bfc7c83 100644 --- a/package.json +++ b/package.json @@ -1,33 +1,33 @@ { - "name": "bird", - "version": "0.1.0", - "description": "CLI tool for tweeting and replying via Twitter/X GraphQL API", - "type": "module", - "main": "dist/index.js", - "bin": { - "bird": "dist/index.js" - }, - "scripts": { - "build": "tsc", - "dev": "tsx src/index.ts", - "test": "vitest run", - "test:watch": "vitest", - "lint": "biome check .", - "lint:fix": "biome check --write .", - "format": "biome format --write .", - "binary": "bun build --compile --minify src/index.ts --outfile bird" - }, - "dependencies": { - "commander": "^12.1.0" - }, - "devDependencies": { - "@biomejs/biome": "^1.9.4", - "@types/node": "^22.10.0", - "tsx": "^4.19.2", - "typescript": "^5.7.2", - "vitest": "^2.1.6" - }, - "engines": { - "node": ">=20" - } + "name": "bird", + "version": "0.1.0", + "description": "CLI tool for tweeting and replying via Twitter/X GraphQL API", + "type": "module", + "main": "dist/index.js", + "bin": { + "bird": "dist/index.js" + }, + "scripts": { + "build": "tsc", + "dev": "tsx src/index.ts", + "test": "vitest run", + "test:watch": "vitest", + "lint": "biome check .", + "lint:fix": "biome check --write .", + "format": "biome format --write .", + "binary": "bun build --compile --minify src/index.ts --outfile bird" + }, + "dependencies": { + "commander": "^12.1.0" + }, + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "@types/node": "^22.10.0", + "tsx": "^4.19.2", + "typescript": "^5.7.2", + "vitest": "^2.1.6" + }, + "engines": { + "node": ">=20" + } } diff --git a/src/index.ts b/src/index.ts index 372d60d..2bec8cd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,183 +20,183 @@ program.name('bird').description('Post tweets and replies via Twitter/X GraphQL // Global options for authentication program - .option('--auth-token ', 'Twitter auth_token cookie') - .option('--ct0 ', 'Twitter ct0 cookie') - .option('--chrome-profile ', 'Chrome profile name for cookie extraction'); + .option('--auth-token ', 'Twitter auth_token cookie') + .option('--ct0 ', 'Twitter ct0 cookie') + .option('--chrome-profile ', 'Chrome profile name for cookie extraction'); // Tweet command program - .command('tweet') - .description('Post a new tweet') - .argument('', 'Tweet text') - .action(async (text: string) => { - const opts = program.opts(); - const { cookies, warnings } = await resolveCredentials({ - authToken: opts.authToken, - ct0: opts.ct0, - chromeProfile: opts.chromeProfile, - }); + .command('tweet') + .description('Post a new tweet') + .argument('', 'Tweet text') + .action(async (text: string) => { + const opts = program.opts(); + const { cookies, warnings } = await resolveCredentials({ + authToken: opts.authToken, + ct0: opts.ct0, + chromeProfile: opts.chromeProfile, + }); - for (const warning of warnings) { - console.error(`⚠️ ${warning}`); - } + for (const warning of warnings) { + console.error(`⚠️ ${warning}`); + } - if (!cookies.authToken || !cookies.ct0) { - console.error('❌ Missing required credentials'); - process.exit(1); - } + if (!cookies.authToken || !cookies.ct0) { + console.error('❌ Missing required credentials'); + process.exit(1); + } - if (cookies.source) { - console.error(`📍 Using credentials from: ${cookies.source}`); - } + if (cookies.source) { + console.error(`📍 Using credentials from: ${cookies.source}`); + } - const client = new TwitterClient({ cookies }); - const result = await client.tweet(text); + const client = new TwitterClient({ cookies }); + const result = await client.tweet(text); - if (result.success) { - console.log('✅ Tweet posted successfully!'); - console.log(`🔗 https://x.com/i/status/${result.tweetId}`); - } else { - console.error(`❌ Failed to post tweet: ${result.error}`); - process.exit(1); - } - }); + if (result.success) { + console.log('✅ Tweet posted successfully!'); + console.log(`🔗 https://x.com/i/status/${result.tweetId}`); + } else { + console.error(`❌ Failed to post tweet: ${result.error}`); + process.exit(1); + } + }); // Reply command program - .command('reply') - .description('Reply to an existing tweet') - .argument('', 'Tweet ID or URL to reply to') - .argument('', 'Reply text') - .action(async (tweetIdOrUrl: string, text: string) => { - const opts = program.opts(); - const { cookies, warnings } = await resolveCredentials({ - authToken: opts.authToken, - ct0: opts.ct0, - chromeProfile: opts.chromeProfile, - }); + .command('reply') + .description('Reply to an existing tweet') + .argument('', 'Tweet ID or URL to reply to') + .argument('', 'Reply text') + .action(async (tweetIdOrUrl: string, text: string) => { + const opts = program.opts(); + const { cookies, warnings } = await resolveCredentials({ + authToken: opts.authToken, + ct0: opts.ct0, + chromeProfile: opts.chromeProfile, + }); - for (const warning of warnings) { - console.error(`⚠️ ${warning}`); - } + for (const warning of warnings) { + console.error(`⚠️ ${warning}`); + } - if (!cookies.authToken || !cookies.ct0) { - console.error('❌ Missing required credentials'); - process.exit(1); - } + if (!cookies.authToken || !cookies.ct0) { + console.error('❌ Missing required credentials'); + process.exit(1); + } - if (cookies.source) { - console.error(`📍 Using credentials from: ${cookies.source}`); - } + if (cookies.source) { + console.error(`📍 Using credentials from: ${cookies.source}`); + } - const tweetId = extractTweetId(tweetIdOrUrl); - console.error(`📝 Replying to tweet: ${tweetId}`); + const tweetId = extractTweetId(tweetIdOrUrl); + console.error(`📝 Replying to tweet: ${tweetId}`); - const client = new TwitterClient({ cookies }); - const result = await client.reply(text, tweetId); + const client = new TwitterClient({ cookies }); + const result = await client.reply(text, tweetId); - if (result.success) { - console.log('✅ Reply posted successfully!'); - console.log(`🔗 https://x.com/i/status/${result.tweetId}`); - } else { - console.error(`❌ Failed to post reply: ${result.error}`); - process.exit(1); - } - }); + if (result.success) { + console.log('✅ Reply posted successfully!'); + console.log(`🔗 https://x.com/i/status/${result.tweetId}`); + } else { + console.error(`❌ Failed to post reply: ${result.error}`); + process.exit(1); + } + }); // Read command - fetch tweet content program - .command('read') - .description('Read/fetch a tweet by ID or URL') - .argument('', 'Tweet ID or URL to read') - .option('--json', 'Output as JSON') - .action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => { - const opts = program.opts(); - const { cookies, warnings } = await resolveCredentials({ - authToken: opts.authToken, - ct0: opts.ct0, - chromeProfile: opts.chromeProfile, - }); + .command('read') + .description('Read/fetch a tweet by ID or URL') + .argument('', 'Tweet ID or URL to read') + .option('--json', 'Output as JSON') + .action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => { + const opts = program.opts(); + const { cookies, warnings } = await resolveCredentials({ + authToken: opts.authToken, + ct0: opts.ct0, + chromeProfile: opts.chromeProfile, + }); - for (const warning of warnings) { - console.error(`⚠️ ${warning}`); - } + for (const warning of warnings) { + console.error(`⚠️ ${warning}`); + } - if (!cookies.authToken || !cookies.ct0) { - console.error('❌ Missing required credentials'); - process.exit(1); - } + if (!cookies.authToken || !cookies.ct0) { + console.error('❌ Missing required credentials'); + process.exit(1); + } - const tweetId = extractTweetId(tweetIdOrUrl); - const client = new TwitterClient({ cookies }); - const result = await client.getTweet(tweetId); + const tweetId = extractTweetId(tweetIdOrUrl); + const client = new TwitterClient({ cookies }); + const result = await client.getTweet(tweetId); - if (result.success && result.tweet) { - if (cmdOpts.json) { - console.log(JSON.stringify(result.tweet, null, 2)); - } else { - console.log(`@${result.tweet.author.username} (${result.tweet.author.name}):`); - console.log(result.tweet.text); - if (result.tweet.createdAt) { - console.log(`\n📅 ${result.tweet.createdAt}`); - } - console.log( - `❤️ ${result.tweet.likeCount ?? 0} 🔁 ${result.tweet.retweetCount ?? 0} 💬 ${result.tweet.replyCount ?? 0}`, - ); - } - } else { - console.error(`❌ Failed to read tweet: ${result.error}`); - process.exit(1); - } - }); + if (result.success && result.tweet) { + if (cmdOpts.json) { + console.log(JSON.stringify(result.tweet, null, 2)); + } else { + console.log(`@${result.tweet.author.username} (${result.tweet.author.name}):`); + console.log(result.tweet.text); + if (result.tweet.createdAt) { + console.log(`\n📅 ${result.tweet.createdAt}`); + } + console.log( + `❤️ ${result.tweet.likeCount ?? 0} 🔁 ${result.tweet.retweetCount ?? 0} 💬 ${result.tweet.replyCount ?? 0}`, + ); + } + } else { + console.error(`❌ Failed to read tweet: ${result.error}`); + process.exit(1); + } + }); // Check command - verify credentials program - .command('check') - .description('Check credential availability') - .action(async () => { - const opts = program.opts(); - const { cookies, warnings } = await resolveCredentials({ - authToken: opts.authToken, - ct0: opts.ct0, - chromeProfile: opts.chromeProfile, - }); + .command('check') + .description('Check credential availability') + .action(async () => { + const opts = program.opts(); + const { cookies, warnings } = await resolveCredentials({ + authToken: opts.authToken, + ct0: opts.ct0, + chromeProfile: opts.chromeProfile, + }); - console.log('🔍 Credential Check'); - console.log('─'.repeat(40)); + console.log('🔍 Credential Check'); + console.log('─'.repeat(40)); - if (cookies.authToken) { - console.log(`✅ auth_token: ${cookies.authToken.slice(0, 10)}...`); - } else { - console.log('❌ auth_token: not found'); - } + if (cookies.authToken) { + console.log(`✅ auth_token: ${cookies.authToken.slice(0, 10)}...`); + } else { + console.log('❌ auth_token: not found'); + } - if (cookies.ct0) { - console.log(`✅ ct0: ${cookies.ct0.slice(0, 10)}...`); - } else { - console.log('❌ ct0: not found'); - } + if (cookies.ct0) { + console.log(`✅ ct0: ${cookies.ct0.slice(0, 10)}...`); + } else { + console.log('❌ ct0: not found'); + } - if (cookies.source) { - console.log(`📍 Source: ${cookies.source}`); - } + if (cookies.source) { + console.log(`📍 Source: ${cookies.source}`); + } - if (warnings.length > 0) { - console.log('\n⚠️ Warnings:'); - for (const warning of warnings) { - console.log(` - ${warning}`); - } - } + if (warnings.length > 0) { + console.log('\n⚠️ Warnings:'); + for (const warning of warnings) { + console.log(` - ${warning}`); + } + } - if (cookies.authToken && cookies.ct0) { - console.log('\n✅ Ready to tweet!'); - } else { - console.log('\n❌ Missing credentials. Options:'); - console.log(' 1. Login to x.com in Chrome'); - console.log(' 2. Set AUTH_TOKEN and CT0 environment variables'); - console.log(' 3. Use --auth-token and --ct0 flags'); - process.exit(1); - } - }); + if (cookies.authToken && cookies.ct0) { + console.log('\n✅ Ready to tweet!'); + } else { + console.log('\n❌ Missing credentials. Options:'); + console.log(' 1. Login to x.com in Chrome'); + console.log(' 2. Set AUTH_TOKEN and CT0 environment variables'); + console.log(' 3. Use --auth-token and --ct0 flags'); + process.exit(1); + } + }); program.parse(); diff --git a/src/lib/cookies.ts b/src/lib/cookies.ts index 9ffb8ce..be1b5a7 100644 --- a/src/lib/cookies.ts +++ b/src/lib/cookies.ts @@ -10,28 +10,28 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; export interface TwitterCookies { - authToken: string | null; - ct0: string | null; - source: string | null; + authToken: string | null; + ct0: string | null; + source: string | null; } export interface CookieExtractionResult { - cookies: TwitterCookies; - warnings: string[]; + cookies: TwitterCookies; + warnings: string[]; } function normalizeValue(value: unknown): string | null { - if (typeof value === 'string') { - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; - } - return null; + 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'); + const home = process.env.HOME || ''; + const profileDir = profile || 'Default'; + return join(home, 'Library', 'Application Support', 'Google', 'Chrome', profileDir, 'Cookies'); } /** @@ -39,50 +39,50 @@ function getChromeCookiesPath(profile?: string): string { * 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'); + try { + // Convert hex to buffer + const encryptedValue = Buffer.from(encryptedHex, 'hex'); - if (encryptedValue.length < 4) { - return null; - } + 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'); - } + 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(); + // 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; - } + if (!keyOutput) { + return null; + } - // Derive the key using PBKDF2 - const salt = 'saltysalt'; - const iterations = 1003; - const keyLength = 16; + // Derive the key using PBKDF2 + const salt = 'saltysalt'; + const iterations = 1003; + const keyLength = 16; - const derivedKey = pbkdf2Sync(keyOutput, salt, iterations, keyLength, 'sha1'); + 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 + // 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); + const decipher = createDecipheriv('aes-128-cbc', derivedKey, iv); + decipher.setAutoPadding(true); - let decrypted = decipher.update(encryptedData); - decrypted = Buffer.concat([decrypted, decipher.final()]); + let decrypted = decipher.update(encryptedData); + decrypted = Buffer.concat([decrypted, decipher.final()]); - return decrypted.toString('utf8'); - } catch { - return null; - } + return decrypted.toString('utf8'); + } catch { + return null; + } } /** @@ -90,84 +90,84 @@ function decryptCookieValue(encryptedHex: string): string | null { * @param profile - Chrome profile name (optional, uses Default if not specified) */ export async function extractCookiesFromChrome(profile?: string): Promise { - const warnings: string[] = []; - const cookies: TwitterCookies = { - authToken: null, - ct0: null, - source: null, - }; + const warnings: string[] = []; + const cookies: TwitterCookies = { + authToken: null, + ct0: null, + source: null, + }; - const cookiesPath = getChromeCookiesPath(profile); + const cookiesPath = getChromeCookiesPath(profile); - if (!existsSync(cookiesPath)) { - warnings.push(`Chrome cookies database not found at: ${cookiesPath}`); - return { cookies, warnings }; - } + if (!existsSync(cookiesPath)) { + warnings.push(`Chrome cookies database not found at: ${cookiesPath}`); + return { cookies, warnings }; + } - // Chrome locks the database, so we need to copy it - let tempDir: string | null = null; + // Chrome locks the database, so we need to copy it + let tempDir: string | null = null; - try { - tempDir = mkdtempSync(join(tmpdir(), 'twitter-cli-')); - const tempDbPath = join(tempDir, 'Cookies'); - copyFileSync(cookiesPath, tempDbPath); + try { + tempDir = mkdtempSync(join(tmpdir(), 'twitter-cli-')); + const tempDbPath = join(tempDir, 'Cookies'); + copyFileSync(cookiesPath, tempDbPath); - // 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`); - } + // Also copy the WAL and SHM files if they exist + const walPath = `${cookiesPath}-wal`; + const shmPath = `${cookiesPath}-shm`; + if (existsSync(walPath)) { + copyFileSync(walPath, `${tempDbPath}-wal`); + } + if (existsSync(shmPath)) { + copyFileSync(shmPath, `${tempDbPath}-shm`); + } - // Use sqlite3 CLI to query cookies (no native deps required!) - const query = `SELECT name, hex(encrypted_value) as encrypted_hex FROM cookies WHERE host_key IN ('.x.com', '.twitter.com', 'x.com', 'twitter.com') AND name IN ('auth_token', 'ct0');`; + // Use sqlite3 CLI to query cookies (no native deps required!) + const query = `SELECT name, hex(encrypted_value) as encrypted_hex FROM cookies WHERE host_key IN ('.x.com', '.twitter.com', 'x.com', 'twitter.com') AND name IN ('auth_token', 'ct0');`; - const result = execSync(`sqlite3 -separator '|' "${tempDbPath}" "${query}"`, { - encoding: 'utf8', - maxBuffer: 1024 * 1024, - }).trim(); + 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; + if (result) { + for (const line of result.split('\n')) { + const [name, encryptedHex] = line.split('|'); + if (!name || !encryptedHex) continue; - const decryptedValue = decryptCookieValue(encryptedHex); - if (decryptedValue) { - if (name === 'auth_token' && !cookies.authToken) { - cookies.authToken = decryptedValue; - } else if (name === 'ct0' && !cookies.ct0) { - cookies.ct0 = decryptedValue; - } - } - } - } + const decryptedValue = decryptCookieValue(encryptedHex); + if (decryptedValue) { + if (name === 'auth_token' && !cookies.authToken) { + cookies.authToken = decryptedValue; + } else if (name === 'ct0' && !cookies.ct0) { + cookies.ct0 = decryptedValue; + } + } + } + } - if (cookies.authToken || cookies.ct0) { - cookies.source = profile ? `Chrome profile "${profile}"` : 'Chrome default profile'; - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - warnings.push(`Failed to read Chrome cookies: ${message}`); - } finally { - // Cleanup temp files - if (tempDir) { - try { - rmSync(tempDir, { recursive: true, force: true }); - } catch { - // Ignore cleanup errors - } - } - } + if (cookies.authToken || cookies.ct0) { + cookies.source = profile ? `Chrome profile "${profile}"` : 'Chrome default profile'; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + warnings.push(`Failed to read Chrome cookies: ${message}`); + } finally { + // Cleanup temp files + if (tempDir) { + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + } + } - if (!cookies.authToken && !cookies.ct0) { - warnings.push('No Twitter cookies found in Chrome. Make sure you are logged into x.com in Chrome.'); - } + if (!cookies.authToken && !cookies.ct0) { + warnings.push('No Twitter cookies found in Chrome. Make sure you are logged into x.com in Chrome.'); + } - return { cookies, warnings }; + return { cookies, warnings }; } /** @@ -175,75 +175,75 @@ export async function extractCookiesFromChrome(profile?: string): Promise environment variables > Chrome cookies */ export async function resolveCredentials(options: { - authToken?: string; - ct0?: string; - chromeProfile?: string; + authToken?: string; + ct0?: string; + chromeProfile?: string; }): Promise { - const warnings: string[] = []; - const cookies: TwitterCookies = { - authToken: null, - ct0: null, - source: null, - }; + const warnings: string[] = []; + const cookies: TwitterCookies = { + authToken: null, + ct0: null, + source: null, + }; - // 1. CLI arguments (highest priority) - if (options.authToken) { - cookies.authToken = options.authToken; - cookies.source = 'CLI argument'; - } - if (options.ct0) { - cookies.ct0 = options.ct0; - if (!cookies.source) cookies.source = 'CLI argument'; - } + // 1. CLI arguments (highest priority) + if (options.authToken) { + cookies.authToken = options.authToken; + cookies.source = 'CLI argument'; + } + if (options.ct0) { + cookies.ct0 = options.ct0; + if (!cookies.source) cookies.source = 'CLI argument'; + } - // 2. Environment variables - const envAuthKeys = ['AUTH_TOKEN', 'TWITTER_AUTH_TOKEN']; - const envCt0Keys = ['CT0', 'TWITTER_CT0']; + // 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.authToken) { + for (const key of envAuthKeys) { + const value = normalizeValue(process.env[key]); + if (value) { + cookies.authToken = value; + cookies.source = `env ${key}`; + break; + } + } + } - if (!cookies.ct0) { - for (const key of envCt0Keys) { - const value = normalizeValue(process.env[key]); - if (value) { - cookies.ct0 = value; - if (!cookies.source) cookies.source = `env ${key}`; - break; - } - } - } + if (!cookies.ct0) { + for (const key of envCt0Keys) { + const value = normalizeValue(process.env[key]); + if (value) { + cookies.ct0 = value; + if (!cookies.source) cookies.source = `env ${key}`; + break; + } + } + } - // 3. Chrome cookies (fallback) - if (!cookies.authToken || !cookies.ct0) { - const chromeResult = await extractCookiesFromChrome(options.chromeProfile); - warnings.push(...chromeResult.warnings); + // 3. Chrome cookies (fallback) + if (!cookies.authToken || !cookies.ct0) { + 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; - } - } + 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; + } + } - // Validation - if (!cookies.authToken) { - warnings.push('Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or login to x.com in Chrome'); - } - if (!cookies.ct0) { - warnings.push('Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Chrome'); - } + // Validation + if (!cookies.authToken) { + warnings.push('Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or login to x.com in Chrome'); + } + if (!cookies.ct0) { + warnings.push('Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Chrome'); + } - return { cookies, warnings }; + return { cookies, warnings }; } diff --git a/src/lib/extract-tweet-id.ts b/src/lib/extract-tweet-id.ts index 6b90954..f8df6f6 100644 --- a/src/lib/extract-tweet-id.ts +++ b/src/lib/extract-tweet-id.ts @@ -2,11 +2,11 @@ * Extract tweet ID from a Twitter/X URL or return the input unchanged if it's already an ID. */ export function extractTweetId(input: string): string { - // If it's a URL, extract the tweet ID - const urlMatch = input.match(/(?:twitter\.com|x\.com)\/\w+\/status\/(\d+)/); - if (urlMatch) { - return urlMatch[1]; - } - // Assume it's already an ID - return input; + // If it's a URL, extract the tweet ID + const urlMatch = input.match(/(?:twitter\.com|x\.com)\/\w+\/status\/(\d+)/); + if (urlMatch) { + return urlMatch[1]; + } + // Assume it's already an ID + return input; } diff --git a/src/lib/twitter-client.ts b/src/lib/twitter-client.ts index 999ff7e..b259a3d 100644 --- a/src/lib/twitter-client.ts +++ b/src/lib/twitter-client.ts @@ -9,454 +9,454 @@ const TWITTER_API_BASE = 'https://x.com/i/api/graphql'; // Twitter GraphQL query IDs - these may need to be updated periodically // as Twitter rotates them. Check browser network tab for current values. const QUERY_IDS = { - CreateTweet: 'znCVAd692dKBq9MgkEhKPQ', - CreateRetweet: 'ojPdsZsimiJrUGLR1sjUtA', - FavoriteTweet: 'lI07N6Otwv1PhnEgXILM7A', - TweetDetail: 'nBS-WpgA6ZG0CyNHD517JQ', + CreateTweet: 'znCVAd692dKBq9MgkEhKPQ', + CreateRetweet: 'ojPdsZsimiJrUGLR1sjUtA', + FavoriteTweet: 'lI07N6Otwv1PhnEgXILM7A', + TweetDetail: 'nBS-WpgA6ZG0CyNHD517JQ', }; export interface TweetResult { - success: boolean; - tweetId?: string; - error?: string; + success: boolean; + tweetId?: string; + error?: string; } export interface TweetData { - id: string; - text: string; - author: { - username: string; - name: string; - }; - createdAt?: string; - replyCount?: number; - retweetCount?: number; - likeCount?: number; + id: string; + text: string; + author: { + username: string; + name: string; + }; + createdAt?: string; + replyCount?: number; + retweetCount?: number; + likeCount?: number; } export interface GetTweetResult { - success: boolean; - tweet?: TweetData; - error?: string; + success: boolean; + tweet?: TweetData; + error?: string; } export interface TwitterClientOptions { - cookies: TwitterCookies; - userAgent?: string; + cookies: TwitterCookies; + userAgent?: string; } interface CreateTweetResponse { - data?: { - create_tweet?: { - tweet_results?: { - result?: { - rest_id?: string; - legacy?: { - full_text?: string; - }; - }; - }; - }; - }; - errors?: Array<{ message: string; code?: number }>; + data?: { + create_tweet?: { + tweet_results?: { + result?: { + rest_id?: string; + legacy?: { + full_text?: string; + }; + }; + }; + }; + }; + errors?: Array<{ message: string; code?: number }>; } export class TwitterClient { - private authToken: string; - private ct0: string; - private userAgent: string; + private authToken: string; + private ct0: string; + private userAgent: string; - constructor(options: TwitterClientOptions) { - if (!options.cookies.authToken || !options.cookies.ct0) { - throw new Error('Both authToken and ct0 cookies are required'); - } - this.authToken = options.cookies.authToken; - this.ct0 = options.cookies.ct0; - this.userAgent = - options.userAgent || - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; - } + constructor(options: TwitterClientOptions) { + if (!options.cookies.authToken || !options.cookies.ct0) { + throw new Error('Both authToken and ct0 cookies are required'); + } + this.authToken = options.cookies.authToken; + this.ct0 = options.cookies.ct0; + this.userAgent = + options.userAgent || + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; + } - private findTweetInInstructions( - instructions: - | Array<{ - entries?: Array<{ - content?: { - itemContent?: { - tweet_results?: { - result?: { - rest_id?: string; - legacy?: { - full_text?: string; - created_at?: string; - reply_count?: number; - retweet_count?: number; - favorite_count?: number; - }; - core?: { - user_results?: { - result?: { - legacy?: { - screen_name?: string; - name?: string; - }; - }; - }; - }; - }; - }; - }; - }; - }>; - }> - | undefined, - tweetId: string, - ) { - if (!instructions) return undefined; + private findTweetInInstructions( + instructions: + | Array<{ + entries?: Array<{ + content?: { + itemContent?: { + tweet_results?: { + result?: { + rest_id?: string; + legacy?: { + full_text?: string; + created_at?: string; + reply_count?: number; + retweet_count?: number; + favorite_count?: number; + }; + core?: { + user_results?: { + result?: { + legacy?: { + screen_name?: string; + name?: string; + }; + }; + }; + }; + }; + }; + }; + }; + }>; + }> + | undefined, + tweetId: string, + ) { + if (!instructions) return undefined; - for (const instruction of instructions) { - for (const entry of instruction.entries || []) { - const result = entry.content?.itemContent?.tweet_results?.result; - if (result?.rest_id === tweetId) { - return result; - } - } - } + for (const instruction of instructions) { + for (const entry of instruction.entries || []) { + const result = entry.content?.itemContent?.tweet_results?.result; + if (result?.rest_id === tweetId) { + return result; + } + } + } - return undefined; - } + return undefined; + } - private getHeaders(): Record { - return { - authorization: - 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA', - 'content-type': 'application/json', - 'x-csrf-token': this.ct0, - 'x-twitter-auth-type': 'OAuth2Session', - 'x-twitter-active-user': 'yes', - 'x-twitter-client-language': 'en', - cookie: `auth_token=${this.authToken}; ct0=${this.ct0}`, - 'user-agent': this.userAgent, - origin: 'https://x.com', - referer: 'https://x.com/', - }; - } + private getHeaders(): Record { + return { + authorization: + 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA', + 'content-type': 'application/json', + 'x-csrf-token': this.ct0, + 'x-twitter-auth-type': 'OAuth2Session', + 'x-twitter-active-user': 'yes', + 'x-twitter-client-language': 'en', + cookie: `auth_token=${this.authToken}; ct0=${this.ct0}`, + 'user-agent': this.userAgent, + origin: 'https://x.com', + referer: 'https://x.com/', + }; + } - /** - * Get tweet details by ID - */ - async getTweet(tweetId: string): Promise { - const variables = { - focalTweetId: tweetId, - with_rux_injections: false, - rankingMode: 'Relevance', - includePromotedContent: true, - withCommunity: true, - withQuickPromoteEligibilityTweetFields: true, - withBirdwatchNotes: true, - withVoice: true, - }; + /** + * Get tweet details by ID + */ + async getTweet(tweetId: string): Promise { + const variables = { + focalTweetId: tweetId, + with_rux_injections: false, + rankingMode: 'Relevance', + includePromotedContent: true, + withCommunity: true, + withQuickPromoteEligibilityTweetFields: true, + withBirdwatchNotes: true, + withVoice: true, + }; - const features = { - rweb_tipjar_consumption_enabled: true, - responsive_web_graphql_exclude_directive_enabled: true, - verified_phone_label_enabled: false, - creator_subscriptions_tweet_preview_api_enabled: true, - responsive_web_graphql_timeline_navigation_enabled: true, - responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, - communities_web_enable_tweet_community_results_fetch: true, - c9s_tweet_anatomy_moderator_badge_enabled: true, - articles_preview_enabled: true, - responsive_web_edit_tweet_api_enabled: true, - graphql_is_translatable_rweb_tweet_is_translatable_enabled: true, - view_counts_everywhere_api_enabled: true, - longform_notetweets_consumption_enabled: true, - responsive_web_twitter_article_tweet_consumption_enabled: true, - tweet_awards_web_tipping_enabled: false, - creator_subscriptions_quote_tweet_preview_enabled: false, - freedom_of_speech_not_reach_fetch_enabled: true, - standardized_nudges_misinfo: true, - tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true, - rweb_video_timestamps_enabled: true, - longform_notetweets_rich_text_read_enabled: true, - longform_notetweets_inline_media_enabled: true, - responsive_web_enhance_cards_enabled: false, - }; + const features = { + rweb_tipjar_consumption_enabled: true, + responsive_web_graphql_exclude_directive_enabled: true, + verified_phone_label_enabled: false, + creator_subscriptions_tweet_preview_api_enabled: true, + responsive_web_graphql_timeline_navigation_enabled: true, + responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, + communities_web_enable_tweet_community_results_fetch: true, + c9s_tweet_anatomy_moderator_badge_enabled: true, + articles_preview_enabled: true, + responsive_web_edit_tweet_api_enabled: true, + graphql_is_translatable_rweb_tweet_is_translatable_enabled: true, + view_counts_everywhere_api_enabled: true, + longform_notetweets_consumption_enabled: true, + responsive_web_twitter_article_tweet_consumption_enabled: true, + tweet_awards_web_tipping_enabled: false, + creator_subscriptions_quote_tweet_preview_enabled: false, + freedom_of_speech_not_reach_fetch_enabled: true, + standardized_nudges_misinfo: true, + tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true, + rweb_video_timestamps_enabled: true, + longform_notetweets_rich_text_read_enabled: true, + longform_notetweets_inline_media_enabled: true, + responsive_web_enhance_cards_enabled: false, + }; - const params = new URLSearchParams({ - variables: JSON.stringify(variables), - features: JSON.stringify(features), - }); + const params = new URLSearchParams({ + variables: JSON.stringify(variables), + features: JSON.stringify(features), + }); - const url = `${TWITTER_API_BASE}/${QUERY_IDS.TweetDetail}/TweetDetail?${params}`; + const url = `${TWITTER_API_BASE}/${QUERY_IDS.TweetDetail}/TweetDetail?${params}`; - try { - const response = await fetch(url, { - method: 'GET', - headers: this.getHeaders(), - }); + try { + const response = await fetch(url, { + method: 'GET', + headers: this.getHeaders(), + }); - if (!response.ok) { - const text = await response.text(); - return { - success: false, - error: `HTTP ${response.status}: ${text.slice(0, 200)}`, - }; - } + if (!response.ok) { + const text = await response.text(); + return { + success: false, + error: `HTTP ${response.status}: ${text.slice(0, 200)}`, + }; + } - const data = (await response.json()) as { - data?: { - tweetResult?: { - result?: { - rest_id?: string; - legacy?: { - full_text?: string; - created_at?: string; - reply_count?: number; - retweet_count?: number; - favorite_count?: number; - }; - core?: { - user_results?: { - result?: { - legacy?: { - screen_name?: string; - name?: string; - }; - }; - }; - }; - }; - }; - threaded_conversation_with_injections_v2?: { - instructions?: Array<{ - entries?: Array<{ - content?: { - itemContent?: { - tweet_results?: { - result?: { - rest_id?: string; - legacy?: { - full_text?: string; - created_at?: string; - reply_count?: number; - retweet_count?: number; - favorite_count?: number; - }; - core?: { - user_results?: { - result?: { - legacy?: { - screen_name?: string; - name?: string; - }; - }; - }; - }; - }; - }; - }; - }; - }>; - }>; - }; - }; - errors?: Array<{ message: string }>; - }; + const data = (await response.json()) as { + data?: { + tweetResult?: { + result?: { + rest_id?: string; + legacy?: { + full_text?: string; + created_at?: string; + reply_count?: number; + retweet_count?: number; + favorite_count?: number; + }; + core?: { + user_results?: { + result?: { + legacy?: { + screen_name?: string; + name?: string; + }; + }; + }; + }; + }; + }; + threaded_conversation_with_injections_v2?: { + instructions?: Array<{ + entries?: Array<{ + content?: { + itemContent?: { + tweet_results?: { + result?: { + rest_id?: string; + legacy?: { + full_text?: string; + created_at?: string; + reply_count?: number; + retweet_count?: number; + favorite_count?: number; + }; + core?: { + user_results?: { + result?: { + legacy?: { + screen_name?: string; + name?: string; + }; + }; + }; + }; + }; + }; + }; + }; + }>; + }>; + }; + }; + errors?: Array<{ message: string }>; + }; - if (data.errors && data.errors.length > 0) { - return { - success: false, - error: data.errors.map((e) => e.message).join(', '), - }; - } + if (data.errors && data.errors.length > 0) { + return { + success: false, + error: data.errors.map((e) => e.message).join(', '), + }; + } - // Prefer direct tweetResult if present, otherwise search the conversation thread - const tweetResult = - data.data?.tweetResult?.result ?? - this.findTweetInInstructions(data.data?.threaded_conversation_with_injections_v2?.instructions, tweetId); + // Prefer direct tweetResult if present, otherwise search the conversation thread + const tweetResult = + data.data?.tweetResult?.result ?? + this.findTweetInInstructions(data.data?.threaded_conversation_with_injections_v2?.instructions, tweetId); - if (!tweetResult) { - return { - success: false, - error: 'Tweet not found in response', - }; - } + if (!tweetResult) { + return { + success: false, + error: 'Tweet not found in response', + }; + } - const legacy = tweetResult.legacy; - const userLegacy = tweetResult.core?.user_results?.result?.legacy; + const legacy = tweetResult.legacy; + const userLegacy = tweetResult.core?.user_results?.result?.legacy; - if (!legacy?.full_text || !userLegacy?.screen_name) { - return { - success: false, - error: 'Incomplete tweet data', - }; - } + if (!legacy?.full_text || !userLegacy?.screen_name) { + return { + success: false, + error: 'Incomplete tweet data', + }; + } - return { - success: true, - tweet: { - id: tweetResult.rest_id || tweetId, - text: legacy.full_text, - author: { - username: userLegacy.screen_name, - name: userLegacy.name || userLegacy.screen_name, - }, - createdAt: legacy.created_at, - replyCount: legacy.reply_count, - retweetCount: legacy.retweet_count, - likeCount: legacy.favorite_count, - }, - }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : String(error), - }; - } - } + return { + success: true, + tweet: { + id: tweetResult.rest_id || tweetId, + text: legacy.full_text, + author: { + username: userLegacy.screen_name, + name: userLegacy.name || userLegacy.screen_name, + }, + createdAt: legacy.created_at, + replyCount: legacy.reply_count, + retweetCount: legacy.retweet_count, + likeCount: legacy.favorite_count, + }, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } - /** - * Post a new tweet - */ - async tweet(text: string): Promise { - const variables = { - tweet_text: text, - dark_request: false, - media: { - media_entities: [], - possibly_sensitive: false, - }, - semantic_annotation_ids: [], - }; + /** + * Post a new tweet + */ + async tweet(text: string): Promise { + const variables = { + tweet_text: text, + dark_request: false, + media: { + media_entities: [], + possibly_sensitive: false, + }, + semantic_annotation_ids: [], + }; - const features = { - communities_web_enable_tweet_community_results_fetch: true, - c9s_tweet_anatomy_moderator_badge_enabled: true, - responsive_web_edit_tweet_api_enabled: true, - graphql_is_translatable_rweb_tweet_is_translatable_enabled: true, - view_counts_everywhere_api_enabled: true, - longform_notetweets_consumption_enabled: true, - responsive_web_twitter_article_tweet_consumption_enabled: true, - tweet_awards_web_tipping_enabled: false, - creator_subscriptions_quote_tweet_preview_enabled: false, - longform_notetweets_rich_text_read_enabled: true, - longform_notetweets_inline_media_enabled: true, - articles_preview_enabled: true, - rweb_video_timestamps_enabled: true, - rweb_tipjar_consumption_enabled: true, - responsive_web_graphql_exclude_directive_enabled: true, - verified_phone_label_enabled: false, - freedom_of_speech_not_reach_fetch_enabled: true, - standardized_nudges_misinfo: true, - tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true, - responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, - responsive_web_graphql_timeline_navigation_enabled: true, - responsive_web_enhance_cards_enabled: false, - }; + const features = { + communities_web_enable_tweet_community_results_fetch: true, + c9s_tweet_anatomy_moderator_badge_enabled: true, + responsive_web_edit_tweet_api_enabled: true, + graphql_is_translatable_rweb_tweet_is_translatable_enabled: true, + view_counts_everywhere_api_enabled: true, + longform_notetweets_consumption_enabled: true, + responsive_web_twitter_article_tweet_consumption_enabled: true, + tweet_awards_web_tipping_enabled: false, + creator_subscriptions_quote_tweet_preview_enabled: false, + longform_notetweets_rich_text_read_enabled: true, + longform_notetweets_inline_media_enabled: true, + articles_preview_enabled: true, + rweb_video_timestamps_enabled: true, + rweb_tipjar_consumption_enabled: true, + responsive_web_graphql_exclude_directive_enabled: true, + verified_phone_label_enabled: false, + freedom_of_speech_not_reach_fetch_enabled: true, + standardized_nudges_misinfo: true, + tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true, + responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, + responsive_web_graphql_timeline_navigation_enabled: true, + responsive_web_enhance_cards_enabled: false, + }; - return this.createTweet(variables, features); - } + return this.createTweet(variables, features); + } - /** - * Reply to an existing tweet - */ - async reply(text: string, replyToTweetId: string): Promise { - const variables = { - tweet_text: text, - reply: { - in_reply_to_tweet_id: replyToTweetId, - exclude_reply_user_ids: [], - }, - dark_request: false, - media: { - media_entities: [], - possibly_sensitive: false, - }, - semantic_annotation_ids: [], - }; + /** + * Reply to an existing tweet + */ + async reply(text: string, replyToTweetId: string): Promise { + const variables = { + tweet_text: text, + reply: { + in_reply_to_tweet_id: replyToTweetId, + exclude_reply_user_ids: [], + }, + dark_request: false, + media: { + media_entities: [], + possibly_sensitive: false, + }, + semantic_annotation_ids: [], + }; - const features = { - communities_web_enable_tweet_community_results_fetch: true, - c9s_tweet_anatomy_moderator_badge_enabled: true, - responsive_web_edit_tweet_api_enabled: true, - graphql_is_translatable_rweb_tweet_is_translatable_enabled: true, - view_counts_everywhere_api_enabled: true, - longform_notetweets_consumption_enabled: true, - responsive_web_twitter_article_tweet_consumption_enabled: true, - tweet_awards_web_tipping_enabled: false, - creator_subscriptions_quote_tweet_preview_enabled: false, - longform_notetweets_rich_text_read_enabled: true, - longform_notetweets_inline_media_enabled: true, - articles_preview_enabled: true, - rweb_video_timestamps_enabled: true, - rweb_tipjar_consumption_enabled: true, - responsive_web_graphql_exclude_directive_enabled: true, - verified_phone_label_enabled: false, - freedom_of_speech_not_reach_fetch_enabled: true, - standardized_nudges_misinfo: true, - tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true, - responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, - responsive_web_graphql_timeline_navigation_enabled: true, - responsive_web_enhance_cards_enabled: false, - }; + const features = { + communities_web_enable_tweet_community_results_fetch: true, + c9s_tweet_anatomy_moderator_badge_enabled: true, + responsive_web_edit_tweet_api_enabled: true, + graphql_is_translatable_rweb_tweet_is_translatable_enabled: true, + view_counts_everywhere_api_enabled: true, + longform_notetweets_consumption_enabled: true, + responsive_web_twitter_article_tweet_consumption_enabled: true, + tweet_awards_web_tipping_enabled: false, + creator_subscriptions_quote_tweet_preview_enabled: false, + longform_notetweets_rich_text_read_enabled: true, + longform_notetweets_inline_media_enabled: true, + articles_preview_enabled: true, + rweb_video_timestamps_enabled: true, + rweb_tipjar_consumption_enabled: true, + responsive_web_graphql_exclude_directive_enabled: true, + verified_phone_label_enabled: false, + freedom_of_speech_not_reach_fetch_enabled: true, + standardized_nudges_misinfo: true, + tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true, + responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, + responsive_web_graphql_timeline_navigation_enabled: true, + responsive_web_enhance_cards_enabled: false, + }; - return this.createTweet(variables, features); - } + return this.createTweet(variables, features); + } - private async createTweet( - variables: Record, - features: Record, - ): Promise { - const url = `${TWITTER_API_BASE}/${QUERY_IDS.CreateTweet}/CreateTweet`; + private async createTweet( + variables: Record, + features: Record, + ): Promise { + const url = `${TWITTER_API_BASE}/${QUERY_IDS.CreateTweet}/CreateTweet`; - const body = JSON.stringify({ - variables, - features, - queryId: QUERY_IDS.CreateTweet, - }); + const body = JSON.stringify({ + variables, + features, + queryId: QUERY_IDS.CreateTweet, + }); - try { - const response = await fetch(url, { - method: 'POST', - headers: this.getHeaders(), - body, - }); + try { + const response = await fetch(url, { + method: 'POST', + headers: this.getHeaders(), + body, + }); - if (!response.ok) { - const text = await response.text(); - return { - success: false, - error: `HTTP ${response.status}: ${text.slice(0, 200)}`, - }; - } + if (!response.ok) { + const text = await response.text(); + return { + success: false, + error: `HTTP ${response.status}: ${text.slice(0, 200)}`, + }; + } - const data = (await response.json()) as CreateTweetResponse; + const data = (await response.json()) as CreateTweetResponse; - if (data.errors && data.errors.length > 0) { - return { - success: false, - error: data.errors.map((e) => e.message).join(', '), - }; - } + if (data.errors && data.errors.length > 0) { + return { + success: false, + error: data.errors.map((e) => e.message).join(', '), + }; + } - const tweetId = data.data?.create_tweet?.tweet_results?.result?.rest_id; - if (tweetId) { - return { - success: true, - tweetId, - }; - } + const tweetId = data.data?.create_tweet?.tweet_results?.result?.rest_id; + if (tweetId) { + return { + success: true, + tweetId, + }; + } - return { - success: false, - error: 'Tweet created but no ID returned', - }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : String(error), - }; - } - } + return { + success: false, + error: 'Tweet created but no ID returned', + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } } diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 5e6d6f5..9c62bc4 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -2,31 +2,31 @@ import { describe, expect, it } from 'vitest'; import { extractTweetId } from '../src/lib/extract-tweet-id.js'; describe('CLI utilities', () => { - describe('extractTweetId', () => { - it('should extract ID from x.com URL', () => { - const url = 'https://x.com/steipete/status/1234567890123456789'; - expect(extractTweetId(url)).toBe('1234567890123456789'); - }); + describe('extractTweetId', () => { + it('should extract ID from x.com URL', () => { + const url = 'https://x.com/steipete/status/1234567890123456789'; + expect(extractTweetId(url)).toBe('1234567890123456789'); + }); - it('should extract ID from twitter.com URL', () => { - const url = 'https://twitter.com/steipete/status/1234567890123456789'; - expect(extractTweetId(url)).toBe('1234567890123456789'); - }); + it('should extract ID from twitter.com URL', () => { + const url = 'https://twitter.com/steipete/status/1234567890123456789'; + expect(extractTweetId(url)).toBe('1234567890123456789'); + }); - it('should extract ID from URL with query params', () => { - const url = 'https://x.com/steipete/status/1234567890123456789?s=20'; - expect(extractTweetId(url)).toBe('1234567890123456789'); - }); + it('should extract ID from URL with query params', () => { + const url = 'https://x.com/steipete/status/1234567890123456789?s=20'; + expect(extractTweetId(url)).toBe('1234567890123456789'); + }); - it('should return ID as-is if already an ID', () => { - const id = '1234567890123456789'; - expect(extractTweetId(id)).toBe('1234567890123456789'); - }); + it('should return ID as-is if already an ID', () => { + const id = '1234567890123456789'; + expect(extractTweetId(id)).toBe('1234567890123456789'); + }); - it('should handle URLs with www prefix', () => { - // Note: our regex handles this because \w+ matches any word chars after the domain - const url = 'https://x.com/user_name/status/1234567890123456789'; - expect(extractTweetId(url)).toBe('1234567890123456789'); - }); - }); + it('should handle URLs with www prefix', () => { + // Note: our regex handles this because \w+ matches any word chars after the domain + const url = 'https://x.com/user_name/status/1234567890123456789'; + expect(extractTweetId(url)).toBe('1234567890123456789'); + }); + }); }); diff --git a/tests/cookies.test.ts b/tests/cookies.test.ts index bab4ec5..6f1ef79 100644 --- a/tests/cookies.test.ts +++ b/tests/cookies.test.ts @@ -2,105 +2,105 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Mock child_process.execSync to prevent actual shell commands vi.mock('node:child_process', () => ({ - execSync: vi.fn(() => ''), + execSync: vi.fn(() => ''), })); // Mock fs to prevent actual file operations vi.mock('node:fs', () => ({ - existsSync: vi.fn(() => false), - copyFileSync: vi.fn(), - mkdtempSync: vi.fn(() => '/tmp/test-dir'), - rmSync: vi.fn(), + existsSync: vi.fn(() => false), + copyFileSync: vi.fn(), + mkdtempSync: vi.fn(() => '/tmp/test-dir'), + rmSync: vi.fn(), })); describe('cookies', () => { - const originalEnv = process.env; + const originalEnv = process.env; - beforeEach(() => { - vi.resetModules(); - process.env = { ...originalEnv }; - // Clear Twitter-related env vars - process.env.AUTH_TOKEN = undefined; - process.env.TWITTER_AUTH_TOKEN = undefined; - process.env.CT0 = undefined; - process.env.TWITTER_CT0 = undefined; - }); + beforeEach(() => { + vi.resetModules(); + process.env = { ...originalEnv }; + // Clear Twitter-related env vars + process.env.AUTH_TOKEN = undefined; + process.env.TWITTER_AUTH_TOKEN = undefined; + process.env.CT0 = undefined; + process.env.TWITTER_CT0 = undefined; + }); - afterEach(() => { - process.env = originalEnv; - vi.restoreAllMocks(); - }); + afterEach(() => { + process.env = originalEnv; + vi.restoreAllMocks(); + }); - describe('resolveCredentials', () => { - it('should prioritize CLI arguments over env vars', async () => { - process.env.AUTH_TOKEN = 'env_auth'; - process.env.CT0 = 'env_ct0'; + describe('resolveCredentials', () => { + it('should prioritize CLI arguments over env vars', async () => { + process.env.AUTH_TOKEN = 'env_auth'; + process.env.CT0 = 'env_ct0'; - const { resolveCredentials } = await import('../src/lib/cookies.js'); - const result = await resolveCredentials({ - authToken: 'cli_auth', - ct0: 'cli_ct0', - }); + const { resolveCredentials } = await import('../src/lib/cookies.js'); + const result = await resolveCredentials({ + authToken: 'cli_auth', + ct0: 'cli_ct0', + }); - expect(result.cookies.authToken).toBe('cli_auth'); - expect(result.cookies.ct0).toBe('cli_ct0'); - expect(result.cookies.source).toBe('CLI argument'); - }); + expect(result.cookies.authToken).toBe('cli_auth'); + expect(result.cookies.ct0).toBe('cli_ct0'); + expect(result.cookies.source).toBe('CLI argument'); + }); - it('should use AUTH_TOKEN env var', async () => { - process.env.AUTH_TOKEN = 'test_auth_token'; - process.env.CT0 = 'test_ct0'; + it('should use AUTH_TOKEN env var', async () => { + process.env.AUTH_TOKEN = 'test_auth_token'; + process.env.CT0 = 'test_ct0'; - const { resolveCredentials } = await import('../src/lib/cookies.js'); - const result = await resolveCredentials({}); + const { resolveCredentials } = await import('../src/lib/cookies.js'); + const result = await resolveCredentials({}); - expect(result.cookies.authToken).toBe('test_auth_token'); - expect(result.cookies.ct0).toBe('test_ct0'); - expect(result.cookies.source).toBe('env AUTH_TOKEN'); - }); + expect(result.cookies.authToken).toBe('test_auth_token'); + expect(result.cookies.ct0).toBe('test_ct0'); + expect(result.cookies.source).toBe('env AUTH_TOKEN'); + }); - it('should use TWITTER_AUTH_TOKEN env var as fallback', async () => { - process.env.TWITTER_AUTH_TOKEN = 'twitter_auth'; - process.env.TWITTER_CT0 = 'twitter_ct0'; + it('should use TWITTER_AUTH_TOKEN env var as fallback', async () => { + process.env.TWITTER_AUTH_TOKEN = 'twitter_auth'; + process.env.TWITTER_CT0 = 'twitter_ct0'; - const { resolveCredentials } = await import('../src/lib/cookies.js'); - const result = await resolveCredentials({}); + const { resolveCredentials } = await import('../src/lib/cookies.js'); + const result = await resolveCredentials({}); - expect(result.cookies.authToken).toBe('twitter_auth'); - expect(result.cookies.ct0).toBe('twitter_ct0'); - }); + expect(result.cookies.authToken).toBe('twitter_auth'); + expect(result.cookies.ct0).toBe('twitter_ct0'); + }); - it('should trim whitespace from values', async () => { - process.env.AUTH_TOKEN = ' trimmed_auth '; - process.env.CT0 = ' trimmed_ct0 '; + it('should trim whitespace from values', async () => { + process.env.AUTH_TOKEN = ' trimmed_auth '; + process.env.CT0 = ' trimmed_ct0 '; - const { resolveCredentials } = await import('../src/lib/cookies.js'); - const result = await resolveCredentials({}); + const { resolveCredentials } = await import('../src/lib/cookies.js'); + const result = await resolveCredentials({}); - expect(result.cookies.authToken).toBe('trimmed_auth'); - expect(result.cookies.ct0).toBe('trimmed_ct0'); - }); + expect(result.cookies.authToken).toBe('trimmed_auth'); + expect(result.cookies.ct0).toBe('trimmed_ct0'); + }); - it('should treat empty strings as null', async () => { - process.env.AUTH_TOKEN = ' '; - process.env.CT0 = ''; + it('should treat empty strings as null', async () => { + process.env.AUTH_TOKEN = ' '; + process.env.CT0 = ''; - const { resolveCredentials } = await import('../src/lib/cookies.js'); - const result = await resolveCredentials({}); + const { resolveCredentials } = await import('../src/lib/cookies.js'); + const result = await resolveCredentials({}); - expect(result.cookies.authToken).toBeNull(); - expect(result.cookies.ct0).toBeNull(); - expect(result.warnings.length).toBeGreaterThan(0); - }); + expect(result.cookies.authToken).toBeNull(); + expect(result.cookies.ct0).toBeNull(); + expect(result.warnings.length).toBeGreaterThan(0); + }); - it('should warn when credentials are missing', async () => { - const { resolveCredentials } = await import('../src/lib/cookies.js'); - const result = await resolveCredentials({}); + it('should warn when credentials are missing', async () => { + const { resolveCredentials } = await import('../src/lib/cookies.js'); + const result = await resolveCredentials({}); - expect(result.warnings).toContain( - 'Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or login to x.com in Chrome', - ); - expect(result.warnings).toContain('Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Chrome'); - }); - }); + expect(result.warnings).toContain( + 'Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or login to x.com in Chrome', + ); + expect(result.warnings).toContain('Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Chrome'); + }); + }); }); diff --git a/tests/twitter-client.test.ts b/tests/twitter-client.test.ts index ab20465..b366d7c 100644 --- a/tests/twitter-client.test.ts +++ b/tests/twitter-client.test.ts @@ -2,298 +2,298 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { TwitterClient } from '../src/lib/twitter-client.js'; describe('TwitterClient', () => { - const originalFetch = global.fetch; - const validCookies = { - authToken: 'test_auth_token', - ct0: 'test_ct0_token', - source: 'test', - }; + const originalFetch = global.fetch; + const validCookies = { + authToken: 'test_auth_token', + ct0: 'test_ct0_token', + source: 'test', + }; - afterEach(() => { - global.fetch = originalFetch; - vi.restoreAllMocks(); - }); + afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); - describe('constructor', () => { - it('should throw if authToken is missing', () => { - expect( - () => - new TwitterClient({ - cookies: { authToken: null, ct0: 'test', source: null }, - }), - ).toThrow('Both authToken and ct0 cookies are required'); - }); + describe('constructor', () => { + it('should throw if authToken is missing', () => { + expect( + () => + new TwitterClient({ + cookies: { authToken: null, ct0: 'test', source: null }, + }), + ).toThrow('Both authToken and ct0 cookies are required'); + }); - it('should throw if ct0 is missing', () => { - expect( - () => - new TwitterClient({ - cookies: { authToken: 'test', ct0: null, source: null }, - }), - ).toThrow('Both authToken and ct0 cookies are required'); - }); + it('should throw if ct0 is missing', () => { + expect( + () => + new TwitterClient({ + cookies: { authToken: 'test', ct0: null, source: null }, + }), + ).toThrow('Both authToken and ct0 cookies are required'); + }); - it('should create client with valid cookies', () => { - const client = new TwitterClient({ cookies: validCookies }); - expect(client).toBeInstanceOf(TwitterClient); - }); - }); + it('should create client with valid cookies', () => { + const client = new TwitterClient({ cookies: validCookies }); + expect(client).toBeInstanceOf(TwitterClient); + }); + }); - describe('tweet', () => { - let mockFetch: ReturnType; + describe('tweet', () => { + let mockFetch: ReturnType; - beforeEach(() => { - mockFetch = vi.fn(); - global.fetch = mockFetch; - }); + beforeEach(() => { + mockFetch = vi.fn(); + global.fetch = mockFetch; + }); - it('should post a tweet successfully', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - data: { - create_tweet: { - tweet_results: { - result: { - rest_id: '1234567890', - legacy: { - full_text: 'Hello world!', - }, - }, - }, - }, - }, - }), - }); + it('should post a tweet successfully', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + create_tweet: { + tweet_results: { + result: { + rest_id: '1234567890', + legacy: { + full_text: 'Hello world!', + }, + }, + }, + }, + }, + }), + }); - const client = new TwitterClient({ cookies: validCookies }); - const result = await client.tweet('Hello world!'); + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.tweet('Hello world!'); - expect(result.success).toBe(true); - expect(result.tweetId).toBe('1234567890'); - expect(mockFetch).toHaveBeenCalledTimes(1); + expect(result.success).toBe(true); + expect(result.tweetId).toBe('1234567890'); + expect(mockFetch).toHaveBeenCalledTimes(1); - const [url, options] = mockFetch.mock.calls[0]; - expect(url).toContain('CreateTweet'); - expect(options.method).toBe('POST'); + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toContain('CreateTweet'); + expect(options.method).toBe('POST'); - const body = JSON.parse(options.body); - expect(body.variables.tweet_text).toBe('Hello world!'); - }); + const body = JSON.parse(options.body); + expect(body.variables.tweet_text).toBe('Hello world!'); + }); - it('should handle API errors', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - errors: [{ message: 'Rate limit exceeded', code: 88 }], - }), - }); + it('should handle API errors', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + errors: [{ message: 'Rate limit exceeded', code: 88 }], + }), + }); - const client = new TwitterClient({ cookies: validCookies }); - const result = await client.tweet('Test'); + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.tweet('Test'); - expect(result.success).toBe(false); - expect(result.error).toContain('Rate limit exceeded'); - }); + expect(result.success).toBe(false); + expect(result.error).toContain('Rate limit exceeded'); + }); - it('should handle HTTP errors', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 403, - text: async () => 'Forbidden', - }); + it('should handle HTTP errors', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 403, + text: async () => 'Forbidden', + }); - const client = new TwitterClient({ cookies: validCookies }); - const result = await client.tweet('Test'); + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.tweet('Test'); - expect(result.success).toBe(false); - expect(result.error).toContain('HTTP 403'); - }); + expect(result.success).toBe(false); + expect(result.error).toContain('HTTP 403'); + }); - it('should handle network errors', async () => { - mockFetch.mockRejectedValueOnce(new Error('Network error')); + it('should handle network errors', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); - const client = new TwitterClient({ cookies: validCookies }); - const result = await client.tweet('Test'); + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.tweet('Test'); - expect(result.success).toBe(false); - expect(result.error).toBe('Network error'); - }); + expect(result.success).toBe(false); + expect(result.error).toBe('Network error'); + }); - it('should surface missing tweet ID when API responds without rest_id', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - data: { - create_tweet: { - tweet_results: { - result: { - legacy: { full_text: 'No id' }, - }, - }, - }, - }, - }), - }); + it('should surface missing tweet ID when API responds without rest_id', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + create_tweet: { + tweet_results: { + result: { + legacy: { full_text: 'No id' }, + }, + }, + }, + }, + }), + }); - const client = new TwitterClient({ cookies: validCookies }); - const result = await client.tweet('Hello world!'); + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.tweet('Hello world!'); - expect(result.success).toBe(false); - expect(result.error).toBe('Tweet created but no ID returned'); - }); - }); + expect(result.success).toBe(false); + expect(result.error).toBe('Tweet created but no ID returned'); + }); + }); - describe('reply', () => { - let mockFetch: ReturnType; + describe('reply', () => { + let mockFetch: ReturnType; - beforeEach(() => { - mockFetch = vi.fn(); - global.fetch = mockFetch; - }); + beforeEach(() => { + mockFetch = vi.fn(); + global.fetch = mockFetch; + }); - it('should post a reply with correct reply_to_tweet_id', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - data: { - create_tweet: { - tweet_results: { - result: { - rest_id: '9876543210', - }, - }, - }, - }, - }), - }); + it('should post a reply with correct reply_to_tweet_id', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + create_tweet: { + tweet_results: { + result: { + rest_id: '9876543210', + }, + }, + }, + }, + }), + }); - const client = new TwitterClient({ cookies: validCookies }); - const result = await client.reply('This is a reply', '1234567890'); + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.reply('This is a reply', '1234567890'); - expect(result.success).toBe(true); - expect(result.tweetId).toBe('9876543210'); + expect(result.success).toBe(true); + expect(result.tweetId).toBe('9876543210'); - const [, options] = mockFetch.mock.calls[0]; - const body = JSON.parse(options.body); - expect(body.variables.reply.in_reply_to_tweet_id).toBe('1234567890'); - expect(body.variables.tweet_text).toBe('This is a reply'); - }); - }); + const [, options] = mockFetch.mock.calls[0]; + const body = JSON.parse(options.body); + expect(body.variables.reply.in_reply_to_tweet_id).toBe('1234567890'); + expect(body.variables.tweet_text).toBe('This is a reply'); + }); + }); - describe('getTweet', () => { - let mockFetch: ReturnType; + describe('getTweet', () => { + let mockFetch: ReturnType; - beforeEach(() => { - mockFetch = vi.fn(); - global.fetch = mockFetch; - }); + beforeEach(() => { + mockFetch = vi.fn(); + global.fetch = mockFetch; + }); - it('should return tweet data from root tweetResult', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - data: { - tweetResult: { - result: { - rest_id: '12345', - legacy: { - full_text: 'Root tweet text', - created_at: 'Mon Jan 01 00:00:00 +0000 2024', - reply_count: 1, - retweet_count: 2, - favorite_count: 3, - }, - core: { - user_results: { - result: { - legacy: { - screen_name: 'user', - name: 'User Name', - }, - }, - }, - }, - }, - }, - }, - }), - }); + it('should return tweet data from root tweetResult', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + tweetResult: { + result: { + rest_id: '12345', + legacy: { + full_text: 'Root tweet text', + created_at: 'Mon Jan 01 00:00:00 +0000 2024', + reply_count: 1, + retweet_count: 2, + favorite_count: 3, + }, + core: { + user_results: { + result: { + legacy: { + screen_name: 'user', + name: 'User Name', + }, + }, + }, + }, + }, + }, + }, + }), + }); - const client = new TwitterClient({ cookies: validCookies }); - const result = await client.getTweet('12345'); + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.getTweet('12345'); - expect(result.success).toBe(true); - expect(result.tweet?.id).toBe('12345'); - expect(result.tweet?.text).toBe('Root tweet text'); - expect(result.tweet?.author.username).toBe('user'); - }); + expect(result.success).toBe(true); + expect(result.tweet?.id).toBe('12345'); + expect(result.tweet?.text).toBe('Root tweet text'); + expect(result.tweet?.author.username).toBe('user'); + }); - it('should return tweet data found inside conversation instructions', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - data: { - threaded_conversation_with_injections_v2: { - instructions: [ - { - entries: [ - { - content: { - itemContent: { - tweet_results: { - result: { - rest_id: '6789', - legacy: { - full_text: 'Nested text', - created_at: 'Mon Jan 01 00:00:00 +0000 2024', - reply_count: 0, - retweet_count: 0, - favorite_count: 0, - }, - core: { - user_results: { - result: { - legacy: { - screen_name: 'nestuser', - name: 'Nested User', - }, - }, - }, - }, - }, - }, - }, - }, - }, - ], - }, - ], - }, - }, - }), - }); + it('should return tweet data found inside conversation instructions', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + threaded_conversation_with_injections_v2: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + tweet_results: { + result: { + rest_id: '6789', + legacy: { + full_text: 'Nested text', + created_at: 'Mon Jan 01 00:00:00 +0000 2024', + reply_count: 0, + retweet_count: 0, + favorite_count: 0, + }, + core: { + user_results: { + result: { + legacy: { + screen_name: 'nestuser', + name: 'Nested User', + }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + }, + ], + }, + }, + }), + }); - const client = new TwitterClient({ cookies: validCookies }); - const result = await client.getTweet('6789'); + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.getTweet('6789'); - expect(result.success).toBe(true); - expect(result.tweet?.text).toBe('Nested text'); - expect(result.tweet?.author.username).toBe('nestuser'); - }); + expect(result.success).toBe(true); + expect(result.tweet?.text).toBe('Nested text'); + expect(result.tweet?.author.username).toBe('nestuser'); + }); - it('should report HTTP errors from getTweet', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 404, - text: async () => 'Not Found', - }); + it('should report HTTP errors from getTweet', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + text: async () => 'Not Found', + }); - const client = new TwitterClient({ cookies: validCookies }); - const result = await client.getTweet('missing'); + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.getTweet('missing'); - expect(result.success).toBe(false); - expect(result.error).toContain('HTTP 404'); - }); - }); + expect(result.success).toBe(false); + expect(result.error).toContain('HTTP 404'); + }); + }); }); diff --git a/tsconfig.json b/tsconfig.json index d23c6f4..3bc5105 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,20 +1,20 @@ { - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "lib": ["ES2022"], - "outDir": "dist", - "rootDir": "src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "resolveJsonModule": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "tests"] + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] } diff --git a/vitest.config.ts b/vitest.config.ts index b3b8226..3d14848 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,15 +1,15 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ - test: { - globals: true, - environment: 'node', - include: ['tests/**/*.test.ts'], - coverage: { - provider: 'v8', - reporter: ['text', 'html'], - include: ['src/**/*.ts'], - exclude: ['src/index.ts'], - }, - }, + test: { + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'html'], + include: ['src/**/*.ts'], + exclude: ['src/index.ts'], + }, + }, });