From 011476f60747802bed881d76ce11d5459c6d1637 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 28 Nov 2025 06:04:06 +0000 Subject: [PATCH] Initial commit: twitter-cli for posting tweets via GraphQL API Features: - Post tweets and replies via Twitter GraphQL API - Cookie extraction from Chrome (macOS) - Env var and CLI argument authentication - 19 passing tests with vitest - Biome for linting --- .gitignore | 5 + README.md | 86 +++ biome.json | 32 + package.json | 33 + pnpm-lock.yaml | 1271 ++++++++++++++++++++++++++++++++++ src/index.ts | 167 +++++ src/lib/cookies.ts | 249 +++++++ src/lib/twitter-client.ts | 219 ++++++ tests/cli.test.ts | 40 ++ tests/cookies.test.ts | 106 +++ tests/twitter-client.test.ts | 162 +++++ tsconfig.json | 20 + twitter-cli | 6 + vitest.config.ts | 15 + 14 files changed, 2411 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 biome.json create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 src/index.ts create mode 100644 src/lib/cookies.ts create mode 100644 src/lib/twitter-client.ts create mode 100644 tests/cli.test.ts create mode 100644 tests/cookies.test.ts create mode 100644 tests/twitter-client.test.ts create mode 100644 tsconfig.json create mode 100755 twitter-cli create mode 100644 vitest.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fe8a10e --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +*.log +.DS_Store +twitter-cli.bin diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f11830 --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# twitter-cli + +A command-line tool for posting tweets and replies via Twitter's GraphQL API. + +## Installation + +```bash +cd ~/Projects/twitter-cli +pnpm install +``` + +## Usage + +### Post a tweet + +```bash +./twitter-cli tweet "Hello from twitter-cli!" +``` + +### Reply to a tweet + +```bash +# Using tweet URL +./twitter-cli reply "https://x.com/user/status/1234567890" "This is my reply" + +# Using tweet ID directly +./twitter-cli reply 1234567890 "This is my reply" +``` + +### Check credentials + +```bash +./twitter-cli check +``` + +## Authentication + +The tool resolves credentials in the following order of priority: + +1. **CLI arguments** (highest priority) + ```bash + ./twitter-cli --auth-token "xxx" --ct0 "yyy" tweet "Hello" + ``` + +2. **Environment variables** + ```bash + export AUTH_TOKEN="xxx" + export CT0="yyy" + ./twitter-cli tweet "Hello" + ``` + + Alternative env var names: `TWITTER_AUTH_TOKEN`, `TWITTER_CT0` + +3. **Chrome cookies** (fallback - macOS only) + - Automatically extracts from Chrome's cookie database + - Requires Chrome to be logged into x.com + - May prompt for keychain access on first run + +### Getting Your Cookies + +1. Open Chrome and log into x.com +2. Open DevTools (Cmd+Option+I) +3. Go to Application > Cookies > x.com +4. Copy the values for `auth_token` and `ct0` + +## Development + +```bash +# Run in development mode +pnpm run dev tweet "Test" + +# Run tests +pnpm test + +# Run linter +pnpm run lint + +# Fix lint issues +pnpm run lint:fix +``` + +## Notes + +- Chrome cookie extraction requires macOS (uses `sqlite3` and `security` CLI tools) +- The keychain access may block when running over SSH - use env vars instead +- Twitter may rotate GraphQL query IDs - update `src/lib/twitter-client.ts` if needed diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..723f358 --- /dev/null +++ b/biome.json @@ -0,0 +1,32 @@ +{ + "$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"] + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..69b72a6 --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "twitter-cli", + "version": "0.1.0", + "description": "CLI tool for tweeting and replying via Twitter GraphQL API", + "type": "module", + "main": "dist/index.js", + "bin": { + "twitter-cli": "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 twitter-cli" + }, + "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/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..9017466 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1271 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + commander: + specifier: ^12.1.0 + version: 12.1.0 + devDependencies: + '@biomejs/biome': + specifier: ^1.9.4 + version: 1.9.4 + '@types/node': + specifier: ^22.10.0 + version: 22.19.1 + tsx: + specifier: ^4.19.2 + version: 4.20.6 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^2.1.6 + version: 2.1.9(@types/node@22.19.1) + +packages: + + '@biomejs/biome@1.9.4': + resolution: {integrity: sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@1.9.4': + resolution: {integrity: sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@1.9.4': + resolution: {integrity: sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@1.9.4': + resolution: {integrity: sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-arm64@1.9.4': + resolution: {integrity: sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-x64-musl@1.9.4': + resolution: {integrity: sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-linux-x64@1.9.4': + resolution: {integrity: sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-win32-arm64@1.9.4': + resolution: {integrity: sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@1.9.4': + resolution: {integrity: sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@rollup/rollup-android-arm-eabi@4.53.3': + resolution: {integrity: sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.53.3': + resolution: {integrity: sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.53.3': + resolution: {integrity: sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.53.3': + resolution: {integrity: sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.53.3': + resolution: {integrity: sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.53.3': + resolution: {integrity: sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.53.3': + resolution: {integrity: sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.53.3': + resolution: {integrity: sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.53.3': + resolution: {integrity: sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.53.3': + resolution: {integrity: sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.53.3': + resolution: {integrity: sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.53.3': + resolution: {integrity: sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.53.3': + resolution: {integrity: sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.53.3': + resolution: {integrity: sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.53.3': + resolution: {integrity: sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.53.3': + resolution: {integrity: sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.53.3': + resolution: {integrity: sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openharmony-arm64@4.53.3': + resolution: {integrity: sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.53.3': + resolution: {integrity: sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.53.3': + resolution: {integrity: sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.53.3': + resolution: {integrity: sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.53.3': + resolution: {integrity: sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==} + cpu: [x64] + os: [win32] + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/node@22.19.1': + resolution: {integrity: sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==} + + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.1: + resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} + engines: {node: '>= 16'} + + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.2.2: + resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} + engines: {node: '>=12.0.0'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-tsconfig@4.13.0: + resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + rollup@4.53.3: + resolution: {integrity: sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + tsx@4.20.6: + resolution: {integrity: sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + +snapshots: + + '@biomejs/biome@1.9.4': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 1.9.4 + '@biomejs/cli-darwin-x64': 1.9.4 + '@biomejs/cli-linux-arm64': 1.9.4 + '@biomejs/cli-linux-arm64-musl': 1.9.4 + '@biomejs/cli-linux-x64': 1.9.4 + '@biomejs/cli-linux-x64-musl': 1.9.4 + '@biomejs/cli-win32-arm64': 1.9.4 + '@biomejs/cli-win32-x64': 1.9.4 + + '@biomejs/cli-darwin-arm64@1.9.4': + optional: true + + '@biomejs/cli-darwin-x64@1.9.4': + optional: true + + '@biomejs/cli-linux-arm64-musl@1.9.4': + optional: true + + '@biomejs/cli-linux-arm64@1.9.4': + optional: true + + '@biomejs/cli-linux-x64-musl@1.9.4': + optional: true + + '@biomejs/cli-linux-x64@1.9.4': + optional: true + + '@biomejs/cli-win32-arm64@1.9.4': + optional: true + + '@biomejs/cli-win32-x64@1.9.4': + optional: true + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@rollup/rollup-android-arm-eabi@4.53.3': + optional: true + + '@rollup/rollup-android-arm64@4.53.3': + optional: true + + '@rollup/rollup-darwin-arm64@4.53.3': + optional: true + + '@rollup/rollup-darwin-x64@4.53.3': + optional: true + + '@rollup/rollup-freebsd-arm64@4.53.3': + optional: true + + '@rollup/rollup-freebsd-x64@4.53.3': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.53.3': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.53.3': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.53.3': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.53.3': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.53.3': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.53.3': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.53.3': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.53.3': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.53.3': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.53.3': + optional: true + + '@rollup/rollup-linux-x64-musl@4.53.3': + optional: true + + '@rollup/rollup-openharmony-arm64@4.53.3': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.53.3': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.53.3': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.53.3': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.53.3': + optional: true + + '@types/estree@1.0.8': {} + + '@types/node@22.19.1': + dependencies: + undici-types: 6.21.0 + + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.19.1))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@22.19.1) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + + assertion-error@2.0.1: {} + + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.1 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.1: {} + + commander@12.1.0: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.2.2: {} + + fsevents@2.3.3: + optional: true + + get-tsconfig@4.13.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + pathe@1.1.2: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + resolve-pkg-maps@1.0.0: {} + + rollup@4.53.3: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.53.3 + '@rollup/rollup-android-arm64': 4.53.3 + '@rollup/rollup-darwin-arm64': 4.53.3 + '@rollup/rollup-darwin-x64': 4.53.3 + '@rollup/rollup-freebsd-arm64': 4.53.3 + '@rollup/rollup-freebsd-x64': 4.53.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.53.3 + '@rollup/rollup-linux-arm-musleabihf': 4.53.3 + '@rollup/rollup-linux-arm64-gnu': 4.53.3 + '@rollup/rollup-linux-arm64-musl': 4.53.3 + '@rollup/rollup-linux-loong64-gnu': 4.53.3 + '@rollup/rollup-linux-ppc64-gnu': 4.53.3 + '@rollup/rollup-linux-riscv64-gnu': 4.53.3 + '@rollup/rollup-linux-riscv64-musl': 4.53.3 + '@rollup/rollup-linux-s390x-gnu': 4.53.3 + '@rollup/rollup-linux-x64-gnu': 4.53.3 + '@rollup/rollup-linux-x64-musl': 4.53.3 + '@rollup/rollup-openharmony-arm64': 4.53.3 + '@rollup/rollup-win32-arm64-msvc': 4.53.3 + '@rollup/rollup-win32-ia32-msvc': 4.53.3 + '@rollup/rollup-win32-x64-gnu': 4.53.3 + '@rollup/rollup-win32-x64-msvc': 4.53.3 + fsevents: 2.3.3 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + + tsx@4.20.6: + dependencies: + esbuild: 0.25.12 + get-tsconfig: 4.13.0 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + vite-node@2.1.9(@types/node@22.19.1): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@22.19.1) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@22.19.1): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.53.3 + optionalDependencies: + '@types/node': 22.19.1 + fsevents: 2.3.3 + + vitest@2.1.9(@types/node@22.19.1): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.1)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.2.2 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@22.19.1) + vite-node: 2.1.9(@types/node@22.19.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.1 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..fe23bb6 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,167 @@ +#!/usr/bin/env node +/** + * twitter-cli - CLI tool for posting tweets and replies + * + * Usage: + * twitter-cli tweet "Hello world!" + * twitter-cli reply "This is a reply" + * twitter-cli reply "This is a reply" + */ + +import { Command } from 'commander'; +import { resolveCredentials } from './lib/cookies.js'; +import { TwitterClient } from './lib/twitter-client.js'; + +const program = new Command(); + +program.name('twitter-cli').description('Post tweets and replies via Twitter GraphQL API').version('0.1.0'); + +// 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'); + +/** + * Extract tweet ID from URL or return as-is if already an ID + */ +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; +} + +// 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, + }); + + for (const warning of warnings) { + console.error(`āš ļø ${warning}`); + } + + if (!cookies.authToken || !cookies.ct0) { + console.error('āŒ Missing required credentials'); + process.exit(1); + } + + if (cookies.source) { + console.error(`šŸ“ Using credentials from: ${cookies.source}`); + } + + 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); + } + }); + +// 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, + }); + + for (const warning of warnings) { + console.error(`āš ļø ${warning}`); + } + + if (!cookies.authToken || !cookies.ct0) { + console.error('āŒ Missing required credentials'); + process.exit(1); + } + + if (cookies.source) { + console.error(`šŸ“ Using credentials from: ${cookies.source}`); + } + + const tweetId = extractTweetId(tweetIdOrUrl); + console.error(`šŸ“ Replying to tweet: ${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); + } + }); + +// 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, + }); + + 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.ct0) { + console.log(`āœ… ct0: ${cookies.ct0.slice(0, 10)}...`); + } else { + console.log('āŒ ct0: not found'); + } + + 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 (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 new file mode 100644 index 0000000..9ffb8ce --- /dev/null +++ b/src/lib/cookies.ts @@ -0,0 +1,249 @@ +/** + * Chrome cookie extraction for Twitter authentication + * Uses macOS sqlite3 CLI and keychain for decryption - no native dependencies! + */ + +import { execSync } from 'node:child_process'; +import { createDecipheriv, pbkdf2Sync } from 'node:crypto'; +import { copyFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +export interface TwitterCookies { + authToken: string | null; + ct0: string | null; + source: string | null; +} + +export interface CookieExtractionResult { + 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; +} + +function getChromeCookiesPath(profile?: string): string { + const home = process.env.HOME || ''; + const profileDir = profile || 'Default'; + return join(home, 'Library', 'Application Support', 'Google', 'Chrome', profileDir, 'Cookies'); +} + +/** + * Decrypt Chrome cookie value using macOS keychain + * Chrome encrypts cookies with a key stored in the keychain + */ +function decryptCookieValue(encryptedHex: string): string | null { + try { + // Convert hex to buffer + const encryptedValue = Buffer.from(encryptedHex, 'hex'); + + if (encryptedValue.length < 4) { + return null; + } + + const version = encryptedValue.subarray(0, 3).toString('utf8'); + if (version !== 'v10' && version !== 'v11') { + // Not encrypted, just return as string + return encryptedValue.toString('utf8'); + } + + // Get encryption key from keychain + const keyOutput = execSync('security find-generic-password -s "Chrome Safe Storage" -w 2>/dev/null || echo ""', { + encoding: 'utf8', + }).trim(); + + if (!keyOutput) { + return null; + } + + // Derive the key using PBKDF2 + const salt = 'saltysalt'; + const iterations = 1003; + const keyLength = 16; + + const derivedKey = pbkdf2Sync(keyOutput, salt, iterations, keyLength, 'sha1'); + + // Decrypt using AES-128-CBC with empty IV (16 bytes of 0x20/space) + const iv = Buffer.alloc(16, 0x20); + const encryptedData = encryptedValue.subarray(3); // Skip "v10" or "v11" prefix + + const decipher = createDecipheriv('aes-128-cbc', derivedKey, iv); + decipher.setAutoPadding(true); + + let decrypted = decipher.update(encryptedData); + decrypted = Buffer.concat([decrypted, decipher.final()]); + + return decrypted.toString('utf8'); + } catch { + return null; + } +} + +/** + * Extract Twitter cookies from Chrome browser using sqlite3 CLI + * @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 cookiesPath = getChromeCookiesPath(profile); + + 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; + + 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`); + } + + // 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(); + + 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; + } + } + } + } + + 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.'); + } + + return { cookies, warnings }; +} + +/** + * Resolve Twitter credentials from multiple sources + * Priority: CLI args > environment variables > Chrome cookies + */ +export async function resolveCredentials(options: { + authToken?: string; + ct0?: string; + chromeProfile?: string; +}): Promise { + 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'; + } + + // 2. Environment variables + const envAuthKeys = ['AUTH_TOKEN', 'TWITTER_AUTH_TOKEN']; + const envCt0Keys = ['CT0', 'TWITTER_CT0']; + + if (!cookies.authToken) { + for (const key of envAuthKeys) { + const value = normalizeValue(process.env[key]); + if (value) { + cookies.authToken = value; + cookies.source = `env ${key}`; + break; + } + } + } + + if (!cookies.ct0) { + 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); + + 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'); + } + + return { cookies, warnings }; +} diff --git a/src/lib/twitter-client.ts b/src/lib/twitter-client.ts new file mode 100644 index 0000000..c9ded09 --- /dev/null +++ b/src/lib/twitter-client.ts @@ -0,0 +1,219 @@ +/** + * Twitter GraphQL API client for posting tweets and replies + */ + +import type { TwitterCookies } from './cookies.js'; + +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', +}; + +export interface TweetResult { + success: boolean; + tweetId?: string; + error?: string; +} + +export interface TwitterClientOptions { + 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 }>; +} + +export class TwitterClient { + 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'; + } + + 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/', + }; + } + + /** + * 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, + }; + + 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: [], + }; + + 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); + } + + 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, + }); + + 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)}`, + }; + } + + 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(', '), + }; + } + + 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), + }; + } + } +} diff --git a/tests/cli.test.ts b/tests/cli.test.ts new file mode 100644 index 0000000..d855687 --- /dev/null +++ b/tests/cli.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +describe('CLI utilities', () => { + describe('extractTweetId', () => { + // Testing the URL extraction logic inline since it's a simple function + function extractTweetId(input: string): string { + const urlMatch = input.match(/(?:twitter\.com|x\.com)\/\w+\/status\/(\d+)/); + if (urlMatch) { + return urlMatch[1]; + } + return input; + } + + 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 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 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 new file mode 100644 index 0000000..bab4ec5 --- /dev/null +++ b/tests/cookies.test.ts @@ -0,0 +1,106 @@ +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(() => ''), +})); + +// 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(), +})); + +describe('cookies', () => { + 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; + }); + + 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'; + + 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'); + }); + + 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({}); + + 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'; + + 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'); + }); + + 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({}); + + 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 = ''; + + 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); + }); + + 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'); + }); + }); +}); diff --git a/tests/twitter-client.test.ts b/tests/twitter-client.test.ts new file mode 100644 index 0000000..4cf1869 --- /dev/null +++ b/tests/twitter-client.test.ts @@ -0,0 +1,162 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { TwitterClient } from '../src/lib/twitter-client.js'; + +describe('TwitterClient', () => { + const validCookies = { + authToken: 'test_auth_token', + ct0: 'test_ct0_token', + source: 'test', + }; + + 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 create client with valid cookies', () => { + const client = new TwitterClient({ cookies: validCookies }); + expect(client).toBeInstanceOf(TwitterClient); + }); + }); + + describe('tweet', () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = vi.fn(); + global.fetch = mockFetch; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + 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!'); + + 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 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 }], + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.tweet('Test'); + + 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', + }); + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.tweet('Test'); + + expect(result.success).toBe(false); + expect(result.error).toContain('HTTP 403'); + }); + + it('should handle network errors', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.tweet('Test'); + + expect(result.success).toBe(false); + expect(result.error).toBe('Network error'); + }); + }); + + describe('reply', () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = vi.fn(); + global.fetch = mockFetch; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + 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'); + + 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'); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d23c6f4 --- /dev/null +++ b/tsconfig.json @@ -0,0 +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"] +} diff --git a/twitter-cli b/twitter-cli new file mode 100755 index 0000000..130832f --- /dev/null +++ b/twitter-cli @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# twitter-cli - CLI tool for posting tweets and replies +# Requires Node.js 20+ and pnpm + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" && exec pnpm exec tsx src/index.ts "$@" diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..b3b8226 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +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'], + }, + }, +});