#!/usr/bin/env node import fs from 'node:fs'; import path from 'node:path'; import readline from 'node:readline'; import { fileURLToPath } from 'node:url'; import { Command } from 'commander'; import type { Config } from './lib/config.js'; import { getConfig, hasConfig, saveConfig } from './lib/config.js'; import { parseCSV } from './lib/converter.js'; import { handleCliError } from './lib/errors.js'; import { listAccounts, listBudgets, uploadTransactions } from './lib/uploader.js'; const program = new Command(); // package.json sits next to cli.ts in the repo and one level up from dist/cli.js // once built, so try both rather than hardcoding a version that drifts on release. function readVersion(): string { const dir = path.dirname(fileURLToPath(import.meta.url)); for (const candidate of [path.join(dir, 'package.json'), path.join(dir, '..', 'package.json')]) { if (fs.existsSync(candidate)) { const pkg = JSON.parse(fs.readFileSync(candidate, 'utf-8')) as { version?: string }; if (pkg.version) return pkg.version; } } return 'unknown'; } // Helper function to prompt for budget and account selection async function promptForBudgetAndAccount( config: Config ): Promise<{ budgetId: string; accountId: string }> { const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); const question = (prompt: string): Promise => new Promise((resolve) => rl.question(prompt, resolve)); try { let budgetId = config.budgetId; let accountId = config.accountId; // Get budgets if budget ID not set if (!budgetId) { const budgets = await listBudgets(config.accessToken); if (budgets.length === 0) { throw new Error('No budgets found in your YNAB account'); } if (budgets.length === 1) { budgetId = budgets[0]?.id ?? null; console.log(`Using budget: ${budgets[0]?.name}`); } else { console.log('\nAvailable budgets:'); budgets.forEach((budget, index) => { console.log(` ${index + 1}. ${budget.name}`); }); const selection = await question('\nSelect budget number: '); const selectedIndex = parseInt(selection, 10) - 1; if (selectedIndex < 0 || selectedIndex >= budgets.length) { throw new Error('Invalid budget selection'); } const selectedBudget = budgets[selectedIndex]; if (!selectedBudget) { throw new Error('Invalid budget selection'); } budgetId = selectedBudget.id; console.log(`Selected: ${selectedBudget.name}`); } } // Get accounts if account ID not set if (!accountId && budgetId) { const accounts = await listAccounts(config.accessToken, budgetId); if (accounts.length === 0) { throw new Error('No open accounts found in your budget'); } if (accounts.length === 1) { accountId = accounts[0]?.id ?? null; console.log(`Using account: ${accounts[0]?.name}`); } else { console.log('\nAvailable accounts:'); accounts.forEach((account, index) => { console.log(` ${index + 1}. ${account.name} (${account.type})`); }); const selection = await question('\nSelect account number: '); const selectedIndex = parseInt(selection, 10) - 1; if (selectedIndex < 0 || selectedIndex >= accounts.length) { throw new Error('Invalid account selection'); } const selectedAccount = accounts[selectedIndex]; if (!selectedAccount) { throw new Error('Invalid account selection'); } accountId = selectedAccount.id; console.log(`Selected: ${selectedAccount.name}`); } } rl.close(); if (!budgetId || !accountId) { throw new Error('Budget ID and Account ID are required'); } return { budgetId, accountId }; } catch (error) { rl.close(); throw error; } } program .name('ynab') .description('Quick and easy bank transaction imports to YNAB') .version(readVersion()); // Init command program .command('init') .description('Initialize YNAB CLI configuration') .action(async () => { try { const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); const question = (prompt: string): Promise => new Promise((resolve) => rl.question(prompt, resolve)); console.log('YNAB CLI Setup'); console.log('==============\n'); console.log('Get your Personal Access Token from: https://app.ynab.com/settings/developer\n'); const accessToken = await question('Enter your YNAB Access Token: '); if (!accessToken) { console.error('Error: Access token is required'); rl.close(); process.exit(1); } // Test the token by fetching budgets console.log('\nVerifying token...'); const budgets = await listBudgets(accessToken.trim()); console.log(`\n✓ Token verified! Found ${budgets.length} budget(s)`); budgets.forEach((budget) => { console.log(` - ${budget.name}`); }); // Save config with just the token const config = { YNAB_ACCESS_TOKEN: accessToken.trim(), }; saveConfig(config); console.log('\n✓ Configuration saved to ~/.quickynab/config'); console.log('\nFor CLI usage, you can optionally set YNAB_BUDGET_ID and YNAB_ACCOUNT_ID'); console.log('Edit ~/.quickynab/config or use .env in your project directory'); console.log('For web usage, just run: npm run web (budget/account selection in UI)'); rl.close(); } catch (error) { handleCliError(error); } }); // Import command program .command('import') .description('Import transactions from a YNAB-formatted CSV file') .argument('', 'Path to CSV file') .option('--dry-run', 'Preview transactions without uploading') .action(async (file: string, options: { dryRun?: boolean }) => { try { // Check if config exists if (!hasConfig()) { console.error('Error: QuickYNAB not configured. Run "ynab init" first.'); process.exit(1); } // Check if file exists if (!fs.existsSync(file)) { console.error(`Error: File not found: ${file}`); process.exit(1); } console.log(`Reading CSV file: ${file}`); // Parse CSV (auto-detects format; throws a clear error on garbage) const transactions = parseCSV(file); console.log(`Parsed ${transactions.length} transactions`); if (transactions.length === 0) { console.log('No transactions to import'); return; } // Show preview console.log('\nPreview of transactions:'); transactions.slice(0, 5).forEach((tx, index) => { console.log( ` ${index + 1}. ${tx.date} | ${tx.payee_name || 'No payee'} | ${tx.amount.toFixed(2)}` ); }); if (transactions.length > 5) { console.log(` ... and ${transactions.length - 5} more`); } // Dry run if (options.dryRun) { console.log('\n[DRY RUN] No transactions were uploaded'); return; } // Upload console.log('\nUploading transactions to YNAB...'); const config = getConfig(); // Prompt for budget and account if needed const { budgetId, accountId } = await promptForBudgetAndAccount(config); const result = await uploadTransactions(transactions, config, accountId, budgetId); console.log(`\n✓ Successfully imported ${result.imported} transactions`); if (result.duplicates > 0) { console.log(` (${result.duplicates} duplicates skipped)`); } } catch (error) { handleCliError(error); } }); // List budgets command program .command('budgets') .description('List all budgets') .action(async () => { try { if (!hasConfig()) { console.error('Error: QuickYNAB not configured. Run "ynab init" first.'); process.exit(1); } const config = getConfig(); const budgets = await listBudgets(config.accessToken); console.log('Available budgets:'); budgets.forEach((budget) => { console.log(` - ${budget.name} (${budget.id})`); }); } catch (error) { handleCliError(error); } }); // List accounts command program .command('accounts') .description('List all accounts in the configured budget') .action(async () => { try { if (!hasConfig()) { console.error('Error: QuickYNAB not configured. Run "ynab init" first.'); process.exit(1); } const config = getConfig(); if (!config.budgetId) { console.error('Error: YNAB_BUDGET_ID not set.'); console.error('Add it to ~/.quickynab/config or set in .env file'); process.exit(1); } const accounts = await listAccounts(config.accessToken, config.budgetId); console.log('Available accounts:'); accounts.forEach((account) => { console.log(` - ${account.name} (${account.id})`); }); } catch (error) { handleCliError(error); } }); program.parse();