import { spawn } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { Command } from 'commander'; import inquirer from 'inquirer'; import { deleteAllPosts } from './bsky.js'; import { addMapping, getConfig, removeMapping, saveConfig, updateTwitterConfig, type AccountMapping, type AppConfig, } from './config-manager.js'; import { dbService } from './db.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const ROOT_DIR = path.join(__dirname, '..'); const normalizeHandle = (value: string) => value.trim().replace(/^@/, '').toLowerCase(); const parsePositiveInt = (value: string, defaultValue: number): number => { const parsed = Number.parseInt(value, 10); if (!Number.isFinite(parsed) || parsed <= 0) { return defaultValue; } return parsed; }; const findMappingByRef = (config: AppConfig, ref: string): AccountMapping | undefined => { const needle = normalizeHandle(ref); return config.mappings.find( (mapping) => mapping.id === ref || normalizeHandle(mapping.bskyIdentifier) === needle || mapping.twitterUsernames.some((username) => normalizeHandle(username) === needle), ); }; const selectMapping = async (message: string): Promise => { const config = getConfig(); if (config.mappings.length === 0) { console.log('No mappings found.'); return null; } const { id } = await inquirer.prompt([ { type: 'list', name: 'id', message, choices: config.mappings.map((mapping) => ({ name: `${mapping.owner || 'System'}: ${mapping.twitterUsernames.join(', ')} -> ${mapping.bskyIdentifier}`, value: mapping.id, })), }, ]); return config.mappings.find((mapping) => mapping.id === id) ?? null; }; const spawnAndWait = async (command: string, args: string[], cwd: string): Promise => new Promise((resolve, reject) => { const child = spawn(command, args, { cwd, stdio: 'inherit', env: process.env, }); child.on('error', reject); child.on('exit', (code) => { if (code === 0) { resolve(); return; } reject(new Error(`Process exited with code ${code}`)); }); }); const runCoreCommand = async (args: string[]): Promise => { const distEntry = path.join(ROOT_DIR, 'dist', 'index.js'); if (fs.existsSync(distEntry)) { await spawnAndWait(process.execPath, [distEntry, ...args], ROOT_DIR); return; } const tsxBin = process.platform === 'win32' ? path.join(ROOT_DIR, 'node_modules', '.bin', 'tsx.cmd') : path.join(ROOT_DIR, 'node_modules', '.bin', 'tsx'); const sourceEntry = path.join(ROOT_DIR, 'src', 'index.ts'); if (fs.existsSync(tsxBin) && fs.existsSync(sourceEntry)) { await spawnAndWait(tsxBin, [sourceEntry, ...args], ROOT_DIR); return; } throw new Error('Could not find dist/index.js or tsx runtime. Run npm run build first.'); }; const ensureMapping = async (mappingRef?: string): Promise => { const config = getConfig(); if (config.mappings.length === 0) { console.log('No mappings found.'); return null; } if (mappingRef) { const mapping = findMappingByRef(config, mappingRef); if (!mapping) { console.log(`No mapping found for '${mappingRef}'.`); return null; } return mapping; } return selectMapping('Select a mapping:'); }; const exportConfig = (outputFile: string) => { const config = getConfig(); const { users, ...cleanConfig } = config; const outputPath = path.resolve(outputFile); fs.writeFileSync(outputPath, JSON.stringify(cleanConfig, null, 2)); console.log(`Exported config to ${outputPath}.`); }; const importConfig = (inputFile: string) => { const inputPath = path.resolve(inputFile); if (!fs.existsSync(inputPath)) { throw new Error(`File not found: ${inputPath}`); } const parsed = JSON.parse(fs.readFileSync(inputPath, 'utf8')); if (!parsed.mappings || !Array.isArray(parsed.mappings)) { throw new Error('Invalid config format: missing mappings array.'); } const currentConfig = getConfig(); const nextConfig: AppConfig = { ...currentConfig, mappings: parsed.mappings, groups: Array.isArray(parsed.groups) ? parsed.groups : currentConfig.groups, twitter: parsed.twitter || currentConfig.twitter, ai: parsed.ai || currentConfig.ai, checkIntervalMinutes: parsed.checkIntervalMinutes || currentConfig.checkIntervalMinutes, }; saveConfig(nextConfig); console.log('Config imported successfully. Existing users were preserved.'); }; const program = new Command(); program .name('tweets-2-bsky-cli') .description('CLI for full Tweets -> Bluesky dashboard workflows') .version('2.1.0'); program .command('setup-ai') .description('Configure AI settings for alt text generation') .action(async () => { const config = getConfig(); const currentAi = config.ai || { provider: 'gemini' }; if (!config.ai && config.geminiApiKey) { currentAi.apiKey = config.geminiApiKey; } const answers = await inquirer.prompt([ { type: 'list', name: 'provider', message: 'Select AI Provider:', choices: [ { name: 'Google Gemini (Default)', value: 'gemini' }, { name: 'OpenAI / OpenRouter', value: 'openai' }, { name: 'Anthropic (Claude)', value: 'anthropic' }, { name: 'Custom (OpenAI Compatible)', value: 'custom' }, ], default: currentAi.provider, }, { type: 'input', name: 'apiKey', message: 'Enter API Key (optional for some custom providers):', default: currentAi.apiKey, }, { type: 'input', name: 'model', message: 'Enter Model ID (optional, leave empty for default):', default: currentAi.model, }, { type: 'input', name: 'baseUrl', message: 'Enter Base URL (optional):', default: currentAi.baseUrl, when: (answers) => ['openai', 'anthropic', 'custom'].includes(answers.provider), }, ]); config.ai = { provider: answers.provider, apiKey: answers.apiKey, model: answers.model || undefined, baseUrl: answers.baseUrl || undefined, }; delete config.geminiApiKey; saveConfig(config); console.log('AI configuration updated.'); }); program .command('setup-twitter') .description('Setup Twitter auth cookies (primary + backup)') .action(async () => { const config = getConfig(); const answers = await inquirer.prompt([ { type: 'input', name: 'authToken', message: 'Primary Twitter auth_token:', default: config.twitter.authToken, }, { type: 'input', name: 'ct0', message: 'Primary Twitter ct0:', default: config.twitter.ct0, }, { type: 'input', name: 'backupAuthToken', message: 'Backup Twitter auth_token (optional):', default: config.twitter.backupAuthToken, }, { type: 'input', name: 'backupCt0', message: 'Backup Twitter ct0 (optional):', default: config.twitter.backupCt0, }, ]); updateTwitterConfig(answers); console.log('Twitter credentials updated.'); }); program .command('add-mapping') .description('Add a new Twitter -> Bluesky mapping') .action(async () => { const answers = await inquirer.prompt([ { type: 'input', name: 'owner', message: 'Owner name:', }, { type: 'input', name: 'twitterUsernames', message: 'Twitter username(s) to watch (comma separated, without @):', }, { type: 'input', name: 'bskyIdentifier', message: 'Bluesky identifier (handle or email):', }, { type: 'password', name: 'bskyPassword', message: 'Bluesky app password:', }, { type: 'input', name: 'bskyServiceUrl', message: 'Bluesky service URL:', default: 'https://bsky.social', }, { type: 'input', name: 'groupName', message: 'Group/folder name (optional):', }, { type: 'input', name: 'groupEmoji', message: 'Group emoji icon (optional):', }, ]); const usernames = answers.twitterUsernames .split(',') .map((username: string) => username.trim()) .filter((username: string) => username.length > 0); addMapping({ owner: answers.owner, twitterUsernames: usernames, bskyIdentifier: answers.bskyIdentifier, bskyPassword: answers.bskyPassword, bskyServiceUrl: answers.bskyServiceUrl, groupName: answers.groupName?.trim() || undefined, groupEmoji: answers.groupEmoji?.trim() || undefined, }); console.log('Mapping added successfully.'); }); program .command('edit-mapping [mapping]') .description('Edit a mapping by id/handle/twitter username') .action(async (mappingRef?: string) => { const mapping = await ensureMapping(mappingRef); if (!mapping) return; const config = getConfig(); const answers = await inquirer.prompt([ { type: 'input', name: 'owner', message: 'Owner:', default: mapping.owner || '', }, { type: 'input', name: 'twitterUsernames', message: 'Twitter username(s) (comma separated):', default: mapping.twitterUsernames.join(', '), }, { type: 'input', name: 'bskyIdentifier', message: 'Bluesky identifier:', default: mapping.bskyIdentifier, }, { type: 'password', name: 'bskyPassword', message: 'Bluesky app password (leave empty to keep current):', }, { type: 'input', name: 'bskyServiceUrl', message: 'Bluesky service URL:', default: mapping.bskyServiceUrl || 'https://bsky.social', }, { type: 'input', name: 'groupName', message: 'Group/folder name (optional):', default: mapping.groupName || '', }, { type: 'input', name: 'groupEmoji', message: 'Group emoji icon (optional):', default: mapping.groupEmoji || '', }, ]); const usernames = answers.twitterUsernames .split(',') .map((username: string) => username.trim()) .filter((username: string) => username.length > 0); const index = config.mappings.findIndex((entry) => entry.id === mapping.id); if (index === -1) return; const existingMapping = config.mappings[index]; if (!existingMapping) return; const updatedMapping = { ...existingMapping, owner: answers.owner, twitterUsernames: usernames, bskyIdentifier: answers.bskyIdentifier, bskyServiceUrl: answers.bskyServiceUrl, groupName: answers.groupName?.trim() || undefined, groupEmoji: answers.groupEmoji?.trim() || undefined, }; if (answers.bskyPassword && answers.bskyPassword.trim().length > 0) { updatedMapping.bskyPassword = answers.bskyPassword; } config.mappings[index] = updatedMapping; saveConfig(config); console.log('Mapping updated successfully.'); }); program .command('list') .description('List all mappings') .action(() => { const config = getConfig(); if (config.mappings.length === 0) { console.log('No mappings found.'); return; } console.table( config.mappings.map((mapping) => ({ id: mapping.id, owner: mapping.owner || 'System', twitter: mapping.twitterUsernames.join(', '), bsky: mapping.bskyIdentifier, group: `${mapping.groupEmoji || '📁'} ${mapping.groupName || 'Ungrouped'}`, enabled: mapping.enabled, })), ); }); program .command('remove [mapping]') .description('Remove a mapping by id/handle/twitter username') .action(async (mappingRef?: string) => { const mapping = await ensureMapping(mappingRef); if (!mapping) return; const { confirmed } = await inquirer.prompt([ { type: 'confirm', name: 'confirmed', message: `Remove mapping ${mapping.twitterUsernames.join(', ')} -> ${mapping.bskyIdentifier}?`, default: false, }, ]); if (!confirmed) { console.log('Cancelled.'); return; } removeMapping(mapping.id); console.log('Mapping removed.'); }); program .command('import-history [mapping]') .description('Import history immediately for one mapping') .option('-l, --limit ', 'Tweet limit', '15') .option('--dry-run', 'Do not post to Bluesky', false) .option('--web', 'Keep web server enabled during import', false) .action(async (mappingRef: string | undefined, options) => { const mapping = await ensureMapping(mappingRef); if (!mapping) return; let username = mapping.twitterUsernames[0] ?? ''; if (!username) { console.log('Mapping has no Twitter usernames.'); return; } if (mapping.twitterUsernames.length > 1) { const answer = await inquirer.prompt([ { type: 'list', name: 'username', message: 'Select Twitter username to import:', choices: mapping.twitterUsernames, default: username, }, ]); username = String(answer.username || '').trim(); } const args: string[] = [ '--import-history', '--username', username, '--limit', String(parsePositiveInt(options.limit, 15)), ]; if (options.dryRun) args.push('--dry-run'); if (!options.web) args.push('--no-web'); await runCoreCommand(args); }); program .command('set-interval ') .description('Set scheduler interval in minutes') .action((minutes) => { const parsed = parsePositiveInt(minutes, 5); const config = getConfig(); config.checkIntervalMinutes = parsed; saveConfig(config); console.log(`Interval set to ${parsed} minutes.`); }); program .command('run-now') .description('Run one sync cycle now (ideal for cronjobs)') .option('--dry-run', 'Fetch but do not post', false) .option('--web', 'Keep web server enabled during this run', false) .action(async (options) => { const args = ['--run-once']; if (options.dryRun) args.push('--dry-run'); if (!options.web) args.push('--no-web'); await runCoreCommand(args); }); program .command('backfill [mapping]') .description('Run backfill now for one mapping (id/handle/twitter username)') .option('-l, --limit ', 'Tweet limit', '15') .option('--dry-run', 'Fetch but do not post', false) .option('--web', 'Keep web server enabled during this run', false) .action(async (mappingRef: string | undefined, options) => { const mapping = await ensureMapping(mappingRef); if (!mapping) return; const args = ['--run-once', '--backfill-mapping', mapping.id, '--backfill-limit', String(parsePositiveInt(options.limit, 15))]; if (options.dryRun) args.push('--dry-run'); if (!options.web) args.push('--no-web'); await runCoreCommand(args); }); program .command('clear-cache [mapping]') .description('Clear cached tweet history for a mapping') .action(async (mappingRef?: string) => { const mapping = await ensureMapping(mappingRef); if (!mapping) return; for (const username of mapping.twitterUsernames) { dbService.deleteTweetsByUsername(username); } console.log(`Cache cleared for ${mapping.twitterUsernames.join(', ')}.`); }); program .command('delete-all-posts [mapping]') .description('Delete all posts on mapped Bluesky account and clear local cache') .action(async (mappingRef?: string) => { const mapping = await ensureMapping(mappingRef); if (!mapping) return; const { confirmed } = await inquirer.prompt([ { type: 'confirm', name: 'confirmed', message: `Delete ALL posts for ${mapping.bskyIdentifier}? This cannot be undone.`, default: false, }, ]); if (!confirmed) { console.log('Cancelled.'); return; } const { typed } = await inquirer.prompt([ { type: 'input', name: 'typed', message: 'Type DELETE to confirm:', }, ]); if (typed !== 'DELETE') { console.log('Confirmation failed. Aborting.'); return; } const deleted = await deleteAllPosts(mapping.id); dbService.deleteTweetsByBskyIdentifier(mapping.bskyIdentifier); console.log(`Deleted ${deleted} posts for ${mapping.bskyIdentifier} and cleared local cache.`); }); program .command('recent-activity') .description('Show recent processed tweets') .option('-l, --limit ', 'Number of rows', '20') .action((options) => { const limit = parsePositiveInt(options.limit, 20); const rows = dbService.getRecentProcessedTweets(limit); if (rows.length === 0) { console.log('No recent activity found.'); return; } console.table( rows.map((row) => ({ time: row.created_at, twitter: row.twitter_username, bsky: row.bsky_identifier, status: row.status, text: row.tweet_text ? row.tweet_text.slice(0, 80) : row.twitter_id, })), ); }); program .command('config-export [file]') .description('Export dashboard config (without users/password hashes)') .action((file = 'tweets-2-bsky-config.json') => { exportConfig(file); }); program .command('config-import ') .description('Import dashboard config (preserves existing users)') .action((file) => { importConfig(file); }); program .command('status') .description('Show local CLI status summary') .action(() => { const config = getConfig(); const recent = dbService.getRecentProcessedTweets(5); console.log('Tweets-2-Bsky status'); console.log('--------------------'); console.log(`Mappings: ${config.mappings.length}`); console.log(`Enabled mappings: ${config.mappings.filter((mapping) => mapping.enabled).length}`); console.log(`Check interval: ${config.checkIntervalMinutes} minute(s)`); console.log(`Twitter configured: ${Boolean(config.twitter.authToken && config.twitter.ct0)}`); console.log(`AI provider: ${config.ai?.provider || 'gemini (default)'}`); console.log(`Recent processed tweets: ${recent.length > 0 ? recent.length : 0}`); if (recent.length > 0) { const last = recent[0]; console.log(`Latest activity: ${last?.created_at || 'unknown'} (${last?.status || 'unknown'})`); } }); program.parseAsync().catch((error) => { console.error(error instanceof Error ? error.message : error); process.exit(1); });