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.
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});