A simple tool which lets you scrape twitter accounts and crosspost them to bluesky accounts! Comes with a CLI and a webapp for managing profiles! Works with images/videos/link embeds/threads.
11
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 935 lines 30 kB view raw
1import { spawn } from 'node:child_process'; 2import fs from 'node:fs'; 3import path from 'node:path'; 4import { fileURLToPath } from 'node:url'; 5import { Command } from 'commander'; 6import inquirer from 'inquirer'; 7import { deleteAllPosts } from './bsky.js'; 8import { 9 type AccountMapping, 10 type AppConfig, 11 addMapping, 12 getConfig, 13 removeMapping, 14 saveConfig, 15 updateTwitterConfig, 16} from './config-manager.js'; 17import { dbService } from './db.js'; 18import { 19 applyProfileMirrorSyncState, 20 ensureBlueskyBotSelfLabel, 21 fetchTwitterMirrorProfile, 22 syncBlueskyProfileFromTwitter, 23 validateBlueskyCredentials, 24} from './profile-mirror.js'; 25 26const __filename = fileURLToPath(import.meta.url); 27const __dirname = path.dirname(__filename); 28const ROOT_DIR = path.join(__dirname, '..'); 29 30const normalizeHandle = (value: string) => value.trim().replace(/^@/, '').toLowerCase(); 31 32const parsePositiveInt = (value: string, defaultValue: number): number => { 33 const parsed = Number.parseInt(value, 10); 34 if (!Number.isFinite(parsed) || parsed <= 0) { 35 return defaultValue; 36 } 37 return parsed; 38}; 39 40const findMappingByRef = (config: AppConfig, ref: string): AccountMapping | undefined => { 41 const needle = normalizeHandle(ref); 42 return config.mappings.find( 43 (mapping) => 44 mapping.id === ref || 45 normalizeHandle(mapping.bskyIdentifier) === needle || 46 mapping.twitterUsernames.some((username) => normalizeHandle(username) === needle), 47 ); 48}; 49 50const selectMapping = async (message: string): Promise<AccountMapping | null> => { 51 const config = getConfig(); 52 if (config.mappings.length === 0) { 53 console.log('No mappings found.'); 54 return null; 55 } 56 57 const { id } = await inquirer.prompt([ 58 { 59 type: 'list', 60 name: 'id', 61 message, 62 choices: config.mappings.map((mapping) => ({ 63 name: `${mapping.owner || 'System'}: ${mapping.twitterUsernames.join(', ')} -> ${mapping.bskyIdentifier}`, 64 value: mapping.id, 65 })), 66 }, 67 ]); 68 69 return config.mappings.find((mapping) => mapping.id === id) ?? null; 70}; 71 72const spawnAndWait = async (command: string, args: string[], cwd: string): Promise<void> => 73 new Promise((resolve, reject) => { 74 const child = spawn(command, args, { 75 cwd, 76 stdio: 'inherit', 77 env: process.env, 78 }); 79 80 child.on('error', reject); 81 child.on('exit', (code) => { 82 if (code === 0) { 83 resolve(); 84 return; 85 } 86 reject(new Error(`Process exited with code ${code}`)); 87 }); 88 }); 89 90const runCoreCommand = async (args: string[]): Promise<void> => { 91 const distEntry = path.join(ROOT_DIR, 'dist', 'index.js'); 92 if (fs.existsSync(distEntry)) { 93 await spawnAndWait(process.execPath, [distEntry, ...args], ROOT_DIR); 94 return; 95 } 96 97 const sourceEntry = path.join(ROOT_DIR, 'src', 'index.ts'); 98 if (fs.existsSync(sourceEntry)) { 99 await spawnAndWait(process.execPath, [sourceEntry, ...args], ROOT_DIR); 100 return; 101 } 102 103 throw new Error('Could not find dist/index.js or source runtime entry. Run bun run build first.'); 104}; 105 106const ensureMapping = async (mappingRef?: string): Promise<AccountMapping | null> => { 107 const config = getConfig(); 108 if (config.mappings.length === 0) { 109 console.log('No mappings found.'); 110 return null; 111 } 112 113 if (mappingRef) { 114 const mapping = findMappingByRef(config, mappingRef); 115 if (!mapping) { 116 console.log(`No mapping found for '${mappingRef}'.`); 117 return null; 118 } 119 return mapping; 120 } 121 122 return selectMapping('Select a mapping:'); 123}; 124 125const exportConfig = (outputFile: string) => { 126 const config = getConfig(); 127 const { users, ...cleanConfig } = config; 128 const outputPath = path.resolve(outputFile); 129 fs.writeFileSync(outputPath, JSON.stringify(cleanConfig, null, 2)); 130 console.log(`Exported config to ${outputPath}.`); 131}; 132 133const importConfig = (inputFile: string) => { 134 const inputPath = path.resolve(inputFile); 135 if (!fs.existsSync(inputPath)) { 136 throw new Error(`File not found: ${inputPath}`); 137 } 138 139 const parsed = JSON.parse(fs.readFileSync(inputPath, 'utf8')); 140 if (!parsed.mappings || !Array.isArray(parsed.mappings)) { 141 throw new Error('Invalid config format: missing mappings array.'); 142 } 143 144 const currentConfig = getConfig(); 145 const nextConfig: AppConfig = { 146 ...currentConfig, 147 mappings: parsed.mappings, 148 groups: Array.isArray(parsed.groups) ? parsed.groups : currentConfig.groups, 149 twitter: parsed.twitter || currentConfig.twitter, 150 ai: parsed.ai || currentConfig.ai, 151 checkIntervalMinutes: parsed.checkIntervalMinutes || currentConfig.checkIntervalMinutes, 152 }; 153 154 saveConfig(nextConfig); 155 console.log('Config imported successfully. Existing users were preserved.'); 156}; 157 158const program = new Command(); 159 160program.name('tweets-2-bsky-cli').description('CLI for full Tweets -> Bluesky dashboard workflows').version('2.1.0'); 161 162program 163 .command('setup-ai') 164 .description('Configure AI settings for alt text generation') 165 .action(async () => { 166 const config = getConfig(); 167 const currentAi = config.ai || { provider: 'gemini' }; 168 169 if (!config.ai && config.geminiApiKey) { 170 currentAi.apiKey = config.geminiApiKey; 171 } 172 173 const answers = await inquirer.prompt([ 174 { 175 type: 'list', 176 name: 'provider', 177 message: 'Select AI Provider:', 178 choices: [ 179 { name: 'Google Gemini (Default)', value: 'gemini' }, 180 { name: 'OpenAI / OpenRouter', value: 'openai' }, 181 { name: 'Anthropic (Claude)', value: 'anthropic' }, 182 { name: 'Custom (OpenAI Compatible)', value: 'custom' }, 183 ], 184 default: currentAi.provider, 185 }, 186 { 187 type: 'input', 188 name: 'apiKey', 189 message: 'Enter API Key (optional for some custom providers):', 190 default: currentAi.apiKey, 191 }, 192 { 193 type: 'input', 194 name: 'model', 195 message: 'Enter Model ID (optional, leave empty for default):', 196 default: currentAi.model, 197 }, 198 { 199 type: 'input', 200 name: 'baseUrl', 201 message: 'Enter Base URL (optional):', 202 default: currentAi.baseUrl, 203 when: (answers) => ['openai', 'anthropic', 'custom'].includes(answers.provider), 204 }, 205 ]); 206 207 config.ai = { 208 provider: answers.provider, 209 apiKey: answers.apiKey, 210 model: answers.model || undefined, 211 baseUrl: answers.baseUrl || undefined, 212 }; 213 214 delete config.geminiApiKey; 215 saveConfig(config); 216 console.log('AI configuration updated.'); 217 }); 218 219program 220 .command('setup-twitter') 221 .description('Setup Twitter auth cookies (primary + backup)') 222 .action(async () => { 223 const config = getConfig(); 224 const answers = await inquirer.prompt([ 225 { 226 type: 'input', 227 name: 'authToken', 228 message: 'Primary Twitter auth_token:', 229 default: config.twitter.authToken, 230 }, 231 { 232 type: 'input', 233 name: 'ct0', 234 message: 'Primary Twitter ct0:', 235 default: config.twitter.ct0, 236 }, 237 { 238 type: 'input', 239 name: 'backupAuthToken', 240 message: 'Backup Twitter auth_token (optional):', 241 default: config.twitter.backupAuthToken, 242 }, 243 { 244 type: 'input', 245 name: 'backupCt0', 246 message: 'Backup Twitter ct0 (optional):', 247 default: config.twitter.backupCt0, 248 }, 249 ]); 250 251 updateTwitterConfig(answers); 252 console.log('Twitter credentials updated.'); 253 }); 254 255program 256 .command('add-mapping') 257 .description('Add a new Twitter -> Bluesky mapping with guided onboarding') 258 .action(async () => { 259 const config = getConfig(); 260 const ownerDefault = 261 config.users.find((user) => user.role === 'admin')?.username || config.users[0]?.username || ''; 262 263 const answers = await inquirer.prompt([ 264 { 265 type: 'input', 266 name: 'twitterUsernames', 267 message: 'Twitter username(s) to watch (comma separated, without @):', 268 }, 269 ]); 270 271 const usernames = String(answers.twitterUsernames || '') 272 .split(',') 273 .map((username: string) => normalizeHandle(username)) 274 .filter((username: string) => username.length > 0); 275 276 if (usernames.length === 0) { 277 console.log('Please provide at least one Twitter username.'); 278 return; 279 } 280 281 const accountFlow = await inquirer.prompt([ 282 { 283 type: 'list', 284 name: 'accountState', 285 message: 'Bluesky account setup:', 286 choices: [ 287 { name: 'Open bsky.app and create a new account', value: 'create' }, 288 { name: 'I already have a Bluesky account', value: 'existing' }, 289 ], 290 }, 291 ]); 292 293 if (accountFlow.accountState === 'create') { 294 console.log('Open https://bsky.app to create the account, then generate an app password.'); 295 const continueAnswer = await inquirer.prompt([ 296 { 297 type: 'confirm', 298 name: 'continueAfterCreate', 299 message: 'Continue once your Bluesky account exists?', 300 default: true, 301 }, 302 ]); 303 304 if (!continueAnswer.continueAfterCreate) { 305 console.log('Cancelled.'); 306 return; 307 } 308 } 309 310 const bskyAnswers = await inquirer.prompt([ 311 { 312 type: 'input', 313 name: 'bskyIdentifier', 314 message: 'Bluesky identifier (handle or email):', 315 }, 316 { 317 type: 'password', 318 name: 'bskyPassword', 319 message: 'Bluesky app password:', 320 }, 321 { 322 type: 'input', 323 name: 'bskyServiceUrl', 324 message: 'Bluesky service URL:', 325 default: 'https://bsky.social', 326 }, 327 ]); 328 329 let validation: Awaited<ReturnType<typeof validateBlueskyCredentials>>; 330 try { 331 validation = await validateBlueskyCredentials({ 332 bskyIdentifier: bskyAnswers.bskyIdentifier, 333 bskyPassword: bskyAnswers.bskyPassword, 334 bskyServiceUrl: bskyAnswers.bskyServiceUrl, 335 }); 336 console.log(`Authenticated as @${validation.handle} on ${validation.serviceUrl}.`); 337 if (validation.emailConfirmed) { 338 console.log('Email status: confirmed ✅'); 339 } else { 340 console.log('Email status: not confirmed yet ⚠️ (media upload features may be limited until verified).'); 341 } 342 console.log(`Verify email from: ${validation.settingsUrl}`); 343 } catch (error) { 344 console.log(`Bluesky credential validation failed: ${error instanceof Error ? error.message : String(error)}`); 345 return; 346 } 347 348 const continueAfterVerify = await inquirer.prompt([ 349 { 350 type: 'confirm', 351 name: 'continueAfterVerify', 352 message: 'Continue with mapping creation and profile mirror sync now?', 353 default: validation.emailConfirmed, 354 }, 355 ]); 356 357 if (!continueAfterVerify.continueAfterVerify) { 358 console.log('Cancelled.'); 359 return; 360 } 361 362 try { 363 const preview = await fetchTwitterMirrorProfile(usernames[0] || ''); 364 console.log(`Twitter mirror preview from @${preview.username}:`); 365 console.log(` Display name -> ${preview.mirroredDisplayName}`); 366 console.log(` Bio preview -> ${JSON.stringify(preview.mirroredDescription)}`); 367 } catch (error) { 368 console.log(`Twitter metadata preview failed: ${error instanceof Error ? error.message : String(error)}`); 369 console.log('Continuing. You can retry profile sync later with sync-profile.'); 370 } 371 372 const metadataAnswers = await inquirer.prompt([ 373 { 374 type: 'input', 375 name: 'owner', 376 message: 'Owner name (optional):', 377 default: ownerDefault, 378 }, 379 { 380 type: 'input', 381 name: 'groupName', 382 message: 'Group/folder name (optional):', 383 }, 384 { 385 type: 'input', 386 name: 'groupEmoji', 387 message: 'Group emoji icon (optional):', 388 }, 389 { 390 type: 'list', 391 name: 'mirrorSourceUsername', 392 message: 'Use which Twitter source for profile mirror metadata?', 393 choices: usernames.map((username) => ({ 394 name: `@${username}`, 395 value: username, 396 })), 397 default: usernames[0], 398 }, 399 ]); 400 401 addMapping({ 402 owner: metadataAnswers.owner, 403 twitterUsernames: usernames, 404 bskyIdentifier: bskyAnswers.bskyIdentifier, 405 bskyPassword: bskyAnswers.bskyPassword, 406 bskyServiceUrl: bskyAnswers.bskyServiceUrl, 407 groupName: metadataAnswers.groupName?.trim() || undefined, 408 groupEmoji: metadataAnswers.groupEmoji?.trim() || undefined, 409 profileSyncSourceUsername: normalizeHandle(metadataAnswers.mirrorSourceUsername || usernames[0] || ''), 410 }); 411 412 const latestConfig = getConfig(); 413 const createdMapping = [...latestConfig.mappings] 414 .reverse() 415 .find( 416 (mapping) => 417 normalizeHandle(mapping.bskyIdentifier) === normalizeHandle(bskyAnswers.bskyIdentifier) && 418 normalizeHandle(mapping.bskyServiceUrl || 'https://bsky.social') === 419 normalizeHandle(bskyAnswers.bskyServiceUrl || 'https://bsky.social') && 420 mapping.twitterUsernames.length === usernames.length && 421 mapping.twitterUsernames.every( 422 (username, index) => normalizeHandle(username) === normalizeHandle(usernames[index] || ''), 423 ), 424 ); 425 426 if (!createdMapping) { 427 console.log('Mapping added, but could not locate it for automatic profile sync.'); 428 return; 429 } 430 431 try { 432 const botLabelResult = await ensureBlueskyBotSelfLabel({ 433 bskyIdentifier: createdMapping.bskyIdentifier, 434 bskyPassword: createdMapping.bskyPassword, 435 bskyServiceUrl: createdMapping.bskyServiceUrl, 436 }); 437 const labeledConfig = getConfig(); 438 const labeledIndex = labeledConfig.mappings.findIndex((entry) => entry.id === createdMapping.id); 439 if (labeledIndex !== -1) { 440 const current = labeledConfig.mappings[labeledIndex]; 441 if (current && !current.hasBotLabel) { 442 current.hasBotLabel = true; 443 saveConfig(labeledConfig); 444 } 445 } 446 if (botLabelResult.updated) { 447 console.log('Applied Bluesky bot self-label.'); 448 } else { 449 console.log('Bluesky bot self-label already present.'); 450 } 451 } catch (error) { 452 console.log( 453 `Warning: failed to apply Bluesky bot self-label automatically: ${ 454 error instanceof Error ? error.message : String(error) 455 }`, 456 ); 457 } 458 459 try { 460 const syncResult = await syncBlueskyProfileFromTwitter({ 461 twitterUsername: metadataAnswers.mirrorSourceUsername, 462 bskyIdentifier: createdMapping.bskyIdentifier, 463 bskyPassword: createdMapping.bskyPassword, 464 bskyServiceUrl: createdMapping.bskyServiceUrl, 465 previousSync: { 466 sourceUsername: createdMapping.profileSyncSourceUsername, 467 mirroredDisplayName: createdMapping.lastMirroredDisplayName, 468 mirroredDescription: createdMapping.lastMirroredDescription, 469 avatarUrl: createdMapping.lastMirroredAvatarUrl, 470 bannerUrl: createdMapping.lastMirroredBannerUrl, 471 }, 472 }); 473 474 const syncedConfig = getConfig(); 475 const syncedIndex = syncedConfig.mappings.findIndex((entry) => entry.id === createdMapping.id); 476 if (syncedIndex !== -1) { 477 const current = syncedConfig.mappings[syncedIndex]; 478 if (current) { 479 syncedConfig.mappings[syncedIndex] = applyProfileMirrorSyncState( 480 current, 481 metadataAnswers.mirrorSourceUsername, 482 syncResult, 483 ); 484 saveConfig(syncedConfig); 485 } 486 } 487 488 console.log('Mapping added successfully. Bluesky profile mirror sync completed.'); 489 if (syncResult.skipped) { 490 console.log('No profile updates were required (Twitter profile is unchanged).'); 491 } 492 if (syncResult.warnings.length > 0) { 493 console.log('Profile sync warnings:'); 494 for (const warning of syncResult.warnings) { 495 console.log(` - ${warning}`); 496 } 497 } 498 } catch (error) { 499 console.log('Mapping added successfully, but automatic profile sync failed.'); 500 console.log(`Reason: ${error instanceof Error ? error.message : String(error)}`); 501 console.log('Run `bun run cli -- sync-profile` later to retry.'); 502 } 503 }); 504 505program 506 .command('sync-profile [mapping]') 507 .description('Sync Bluesky profile from a mapped Twitter source') 508 .option('-s, --source <username>', 'Twitter source username to mirror from') 509 .action(async (mappingRef?: string, options?: { source?: string }) => { 510 const mapping = await ensureMapping(mappingRef); 511 if (!mapping) return; 512 513 const availableSources = mapping.twitterUsernames.map(normalizeHandle).filter((username) => username.length > 0); 514 const requestedSource = options?.source ? normalizeHandle(options.source) : ''; 515 if (requestedSource && !availableSources.includes(requestedSource)) { 516 console.log(`@${requestedSource} is not part of the selected mapping.`); 517 return; 518 } 519 520 const storedSource = normalizeHandle(mapping.profileSyncSourceUsername || ''); 521 const sourceTwitterUsername = 522 requestedSource || 523 (storedSource && availableSources.includes(storedSource) ? storedSource : '') || 524 availableSources[0] || 525 ''; 526 527 if (!sourceTwitterUsername) { 528 console.log('Mapping has no Twitter source usernames.'); 529 return; 530 } 531 532 try { 533 const result = await syncBlueskyProfileFromTwitter({ 534 twitterUsername: sourceTwitterUsername, 535 bskyIdentifier: mapping.bskyIdentifier, 536 bskyPassword: mapping.bskyPassword, 537 bskyServiceUrl: mapping.bskyServiceUrl, 538 previousSync: { 539 sourceUsername: mapping.profileSyncSourceUsername, 540 mirroredDisplayName: mapping.lastMirroredDisplayName, 541 mirroredDescription: mapping.lastMirroredDescription, 542 avatarUrl: mapping.lastMirroredAvatarUrl, 543 bannerUrl: mapping.lastMirroredBannerUrl, 544 }, 545 }); 546 547 const config = getConfig(); 548 const index = config.mappings.findIndex((entry) => entry.id === mapping.id); 549 if (index !== -1) { 550 const current = config.mappings[index]; 551 if (current) { 552 config.mappings[index] = applyProfileMirrorSyncState(current, sourceTwitterUsername, result); 553 saveConfig(config); 554 } 555 } 556 557 console.log(`Profile sync completed for ${mapping.bskyIdentifier} from @${result.twitterProfile.username}.`); 558 if (result.skipped) { 559 console.log('No profile updates needed (Twitter profile is unchanged).'); 560 } 561 if (result.warnings.length > 0) { 562 console.log('Warnings:'); 563 for (const warning of result.warnings) { 564 console.log(` - ${warning}`); 565 } 566 } 567 } catch (error) { 568 console.log(`Profile sync failed: ${error instanceof Error ? error.message : String(error)}`); 569 } 570 }); 571 572program 573 .command('edit-mapping [mapping]') 574 .description('Edit a mapping by id/handle/twitter username') 575 .action(async (mappingRef?: string) => { 576 const mapping = await ensureMapping(mappingRef); 577 if (!mapping) return; 578 579 const config = getConfig(); 580 const answers = await inquirer.prompt([ 581 { 582 type: 'input', 583 name: 'owner', 584 message: 'Owner:', 585 default: mapping.owner || '', 586 }, 587 { 588 type: 'input', 589 name: 'twitterUsernames', 590 message: 'Twitter username(s) (comma separated):', 591 default: mapping.twitterUsernames.join(', '), 592 }, 593 { 594 type: 'input', 595 name: 'bskyIdentifier', 596 message: 'Bluesky identifier:', 597 default: mapping.bskyIdentifier, 598 }, 599 { 600 type: 'password', 601 name: 'bskyPassword', 602 message: 'Bluesky app password (leave empty to keep current):', 603 }, 604 { 605 type: 'input', 606 name: 'bskyServiceUrl', 607 message: 'Bluesky service URL:', 608 default: mapping.bskyServiceUrl || 'https://bsky.social', 609 }, 610 { 611 type: 'input', 612 name: 'groupName', 613 message: 'Group/folder name (optional):', 614 default: mapping.groupName || '', 615 }, 616 { 617 type: 'input', 618 name: 'groupEmoji', 619 message: 'Group emoji icon (optional):', 620 default: mapping.groupEmoji || '', 621 }, 622 ]); 623 624 const usernames = answers.twitterUsernames 625 .split(',') 626 .map((username: string) => username.trim()) 627 .map((username: string) => normalizeHandle(username)) 628 .filter((username: string) => username.length > 0); 629 630 if (usernames.length === 0) { 631 console.log('Please provide at least one Twitter username.'); 632 return; 633 } 634 635 let profileSyncSourceUsername = normalizeHandle(mapping.profileSyncSourceUsername || ''); 636 if (usernames.length === 1) { 637 profileSyncSourceUsername = usernames[0] || ''; 638 } else { 639 const currentSource = usernames.includes(profileSyncSourceUsername) 640 ? profileSyncSourceUsername 641 : usernames[0] || ''; 642 const sourceAnswer = await inquirer.prompt([ 643 { 644 type: 'list', 645 name: 'profileSyncSourceUsername', 646 message: 'Use which Twitter source for profile syncing?', 647 choices: usernames.map((username: string) => ({ 648 name: `@${username}`, 649 value: username, 650 })), 651 default: currentSource, 652 }, 653 ]); 654 profileSyncSourceUsername = normalizeHandle(String(sourceAnswer.profileSyncSourceUsername || '')); 655 } 656 657 const index = config.mappings.findIndex((entry) => entry.id === mapping.id); 658 if (index === -1) return; 659 660 const existingMapping = config.mappings[index]; 661 if (!existingMapping) return; 662 663 const updatedMapping = { 664 ...existingMapping, 665 owner: answers.owner, 666 twitterUsernames: usernames, 667 bskyIdentifier: answers.bskyIdentifier, 668 bskyServiceUrl: answers.bskyServiceUrl, 669 groupName: answers.groupName?.trim() || undefined, 670 groupEmoji: answers.groupEmoji?.trim() || undefined, 671 profileSyncSourceUsername: profileSyncSourceUsername || undefined, 672 }; 673 674 if (answers.bskyPassword && answers.bskyPassword.trim().length > 0) { 675 updatedMapping.bskyPassword = answers.bskyPassword; 676 } 677 678 config.mappings[index] = updatedMapping; 679 saveConfig(config); 680 console.log('Mapping updated successfully.'); 681 }); 682 683program 684 .command('list') 685 .description('List all mappings') 686 .action(() => { 687 const config = getConfig(); 688 if (config.mappings.length === 0) { 689 console.log('No mappings found.'); 690 return; 691 } 692 693 console.table( 694 config.mappings.map((mapping) => ({ 695 id: mapping.id, 696 owner: mapping.owner || 'System', 697 twitter: mapping.twitterUsernames.join(', '), 698 profileSyncSource: mapping.profileSyncSourceUsername || '--', 699 bsky: mapping.bskyIdentifier, 700 group: `${mapping.groupEmoji || '📁'} ${mapping.groupName || 'Ungrouped'}`, 701 enabled: mapping.enabled, 702 })), 703 ); 704 }); 705 706program 707 .command('remove [mapping]') 708 .description('Remove a mapping by id/handle/twitter username') 709 .action(async (mappingRef?: string) => { 710 const mapping = await ensureMapping(mappingRef); 711 if (!mapping) return; 712 713 const { confirmed } = await inquirer.prompt([ 714 { 715 type: 'confirm', 716 name: 'confirmed', 717 message: `Remove mapping ${mapping.twitterUsernames.join(', ')} -> ${mapping.bskyIdentifier}?`, 718 default: false, 719 }, 720 ]); 721 722 if (!confirmed) { 723 console.log('Cancelled.'); 724 return; 725 } 726 727 removeMapping(mapping.id); 728 console.log('Mapping removed.'); 729 }); 730 731program 732 .command('import-history [mapping]') 733 .description('Import history immediately for one mapping') 734 .option('-l, --limit <number>', 'Tweet limit', '15') 735 .option('--dry-run', 'Do not post to Bluesky', false) 736 .option('--web', 'Keep web server enabled during import', false) 737 .action(async (mappingRef: string | undefined, options) => { 738 const mapping = await ensureMapping(mappingRef); 739 if (!mapping) return; 740 741 let username = mapping.twitterUsernames[0] ?? ''; 742 if (!username) { 743 console.log('Mapping has no Twitter usernames.'); 744 return; 745 } 746 747 if (mapping.twitterUsernames.length > 1) { 748 const answer = await inquirer.prompt([ 749 { 750 type: 'list', 751 name: 'username', 752 message: 'Select Twitter username to import:', 753 choices: mapping.twitterUsernames, 754 default: username, 755 }, 756 ]); 757 username = String(answer.username || '').trim(); 758 } 759 760 const args: string[] = [ 761 '--import-history', 762 '--username', 763 username, 764 '--limit', 765 String(parsePositiveInt(options.limit, 15)), 766 ]; 767 if (options.dryRun) args.push('--dry-run'); 768 if (!options.web) args.push('--no-web'); 769 770 await runCoreCommand(args); 771 }); 772 773program 774 .command('set-interval <minutes>') 775 .description('Set scheduler interval in minutes') 776 .action((minutes) => { 777 const parsed = parsePositiveInt(minutes, 5); 778 const config = getConfig(); 779 config.checkIntervalMinutes = parsed; 780 saveConfig(config); 781 console.log(`Interval set to ${parsed} minutes.`); 782 }); 783 784program 785 .command('run-now') 786 .description('Run one sync cycle now (ideal for cronjobs)') 787 .option('--dry-run', 'Fetch but do not post', false) 788 .option('--web', 'Keep web server enabled during this run', false) 789 .action(async (options) => { 790 const args = ['--run-once']; 791 if (options.dryRun) args.push('--dry-run'); 792 if (!options.web) args.push('--no-web'); 793 await runCoreCommand(args); 794 }); 795 796program 797 .command('backfill [mapping]') 798 .description('Run backfill now for one mapping (id/handle/twitter username)') 799 .option('-l, --limit <number>', 'Tweet limit', '15') 800 .option('--dry-run', 'Fetch but do not post', false) 801 .option('--web', 'Keep web server enabled during this run', false) 802 .action(async (mappingRef: string | undefined, options) => { 803 const mapping = await ensureMapping(mappingRef); 804 if (!mapping) return; 805 806 const args = [ 807 '--run-once', 808 '--backfill-mapping', 809 mapping.id, 810 '--backfill-limit', 811 String(parsePositiveInt(options.limit, 15)), 812 ]; 813 if (options.dryRun) args.push('--dry-run'); 814 if (!options.web) args.push('--no-web'); 815 816 await runCoreCommand(args); 817 }); 818 819program 820 .command('clear-cache [mapping]') 821 .description('Clear cached tweet history for a mapping') 822 .action(async (mappingRef?: string) => { 823 const mapping = await ensureMapping(mappingRef); 824 if (!mapping) return; 825 826 for (const username of mapping.twitterUsernames) { 827 dbService.deleteTweetsByUsername(username); 828 } 829 830 console.log(`Cache cleared for ${mapping.twitterUsernames.join(', ')}.`); 831 }); 832 833program 834 .command('delete-all-posts [mapping]') 835 .description('Delete all posts on mapped Bluesky account and clear local cache') 836 .action(async (mappingRef?: string) => { 837 const mapping = await ensureMapping(mappingRef); 838 if (!mapping) return; 839 840 const { confirmed } = await inquirer.prompt([ 841 { 842 type: 'confirm', 843 name: 'confirmed', 844 message: `Delete ALL posts for ${mapping.bskyIdentifier}? This cannot be undone.`, 845 default: false, 846 }, 847 ]); 848 849 if (!confirmed) { 850 console.log('Cancelled.'); 851 return; 852 } 853 854 const { typed } = await inquirer.prompt([ 855 { 856 type: 'input', 857 name: 'typed', 858 message: 'Type DELETE to confirm:', 859 }, 860 ]); 861 862 if (typed !== 'DELETE') { 863 console.log('Confirmation failed. Aborting.'); 864 return; 865 } 866 867 const deleted = await deleteAllPosts(mapping.id); 868 dbService.deleteTweetsByBskyIdentifier(mapping.bskyIdentifier); 869 console.log(`Deleted ${deleted} posts for ${mapping.bskyIdentifier} and cleared local cache.`); 870 }); 871 872program 873 .command('recent-activity') 874 .description('Show recent processed tweets') 875 .option('-l, --limit <number>', 'Number of rows', '20') 876 .action((options) => { 877 const limit = parsePositiveInt(options.limit, 20); 878 const rows = dbService.getRecentProcessedTweets(limit); 879 880 if (rows.length === 0) { 881 console.log('No recent activity found.'); 882 return; 883 } 884 885 console.table( 886 rows.map((row) => ({ 887 time: row.created_at, 888 twitter: row.twitter_username, 889 bsky: row.bsky_identifier, 890 status: row.status, 891 text: row.tweet_text ? row.tweet_text.slice(0, 80) : row.twitter_id, 892 })), 893 ); 894 }); 895 896program 897 .command('config-export [file]') 898 .description('Export dashboard config (without users/password hashes)') 899 .action((file = 'tweets-2-bsky-config.json') => { 900 exportConfig(file); 901 }); 902 903program 904 .command('config-import <file>') 905 .description('Import dashboard config (preserves existing users)') 906 .action((file) => { 907 importConfig(file); 908 }); 909 910program 911 .command('status') 912 .description('Show local CLI status summary') 913 .action(() => { 914 const config = getConfig(); 915 const recent = dbService.getRecentProcessedTweets(5); 916 917 console.log('Tweets-2-Bsky status'); 918 console.log('--------------------'); 919 console.log(`Mappings: ${config.mappings.length}`); 920 console.log(`Enabled mappings: ${config.mappings.filter((mapping) => mapping.enabled).length}`); 921 console.log(`Check interval: ${config.checkIntervalMinutes} minute(s)`); 922 console.log(`Twitter configured: ${Boolean(config.twitter.authToken && config.twitter.ct0)}`); 923 console.log(`AI provider: ${config.ai?.provider || 'gemini (default)'}`); 924 console.log(`Recent processed tweets: ${recent.length > 0 ? recent.length : 0}`); 925 926 if (recent.length > 0) { 927 const last = recent[0]; 928 console.log(`Latest activity: ${last?.created_at || 'unknown'} (${last?.status || 'unknown'})`); 929 } 930 }); 931 932program.parseAsync().catch((error) => { 933 console.error(error instanceof Error ? error.message : error); 934 process.exit(1); 935});