cli.ts
Rama main · réplica de lectura del repositorio original.
#!/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<string> =>
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<string> =>
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('<file>', '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();