Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 2 additions & 112 deletions bun.lock

Large diffs are not rendered by default.

10 changes: 3 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@stephendolan/ynab-cli",
"version": "2.0.0",
"version": "2.1.0",
"description": "A command-line interface for You Need a Budget (YNAB)",
"type": "module",
"main": "./dist/cli.js",
Expand Down Expand Up @@ -46,18 +46,14 @@
],
"dependencies": {
"@napi-rs/keyring": "^1.2.0",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"conf": "^12.0.0",
"date-fns": "^3.0.0",
"dayjs": "^1.11.19",
"dotenv": "^16.4.0",
"inquirer": "^9.2.0",
"ynab": "^2.10.0",
"zod": "^3.22.0"
"ynab": "^2.10.0"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@types/inquirer": "^9.0.0",
"@types/node": "^20.0.0",
"oxlint": "^0.16.0",
"tsup": "^8.0.0",
Expand Down
5 changes: 3 additions & 2 deletions src/commands/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Command } from 'commander';
import { client } from '../lib/api-client.js';
import { outputJson } from '../lib/output.js';
import { withErrorHandling } from '../lib/command-utils.js';
import { parseDate } from '../lib/dates.js';
import type { CommandOptions } from '../types/index.js';

export function createAccountsCommand(): Command {
Expand Down Expand Up @@ -35,7 +36,7 @@ export function createAccountsCommand(): Command {
.description('List transactions for account')
.argument('<id>', 'Account ID')
.option('-b, --budget <id>', 'Budget ID')
.option('--since <date>', 'Filter transactions since date (YYYY-MM-DD)')
.option('--since <date>', 'Filter transactions since date')
.option('--type <type>', 'Filter by transaction type')
.action(
withErrorHandling(
Expand All @@ -49,7 +50,7 @@ export function createAccountsCommand(): Command {
) => {
const result = await client.getTransactionsByAccount(id, {
budgetId: options.budget,
sinceDate: options.since,
sinceDate: options.since ? parseDate(options.since) : undefined,
type: options.type,
});
outputJson(result?.transactions);
Expand Down
7 changes: 4 additions & 3 deletions src/commands/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { client } from '../lib/api-client.js';
import { outputJson } from '../lib/output.js';
import { YnabCliError } from '../lib/errors.js';
import { withErrorHandling } from '../lib/command-utils.js';
import { validateJson, ApiDataSchema } from '../lib/schemas.js';
import { validateApiData } from '../lib/schemas.js';
import type { CommandOptions } from '../types/index.js';

const VALID_HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
Expand Down Expand Up @@ -35,12 +35,13 @@ export function createApiCommand(): Command {

let data: Record<string, unknown> | undefined;
if (options.data) {
let parsedData: unknown;
try {
const parsedData = JSON.parse(options.data);
data = validateJson(parsedData, ApiDataSchema, 'API data');
parsedData = JSON.parse(options.data);
} catch {
throw new YnabCliError('Invalid JSON in --data parameter', 400);
}
data = validateApiData(parsedData);
}

const result = await client.rawApiCall(upperMethod, path, data, options.budget);
Expand Down
10 changes: 7 additions & 3 deletions src/commands/auth.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
import { Command } from 'commander';
import { auth } from '../lib/auth.js';
import { promptForAccessToken } from '../lib/prompts.js';
import { outputJson } from '../lib/output.js';
import { client } from '../lib/api-client.js';
import { withErrorHandling } from '../lib/command-utils.js';
import { YnabCliError } from '../lib/errors.js';

export function createAuthCommand(): Command {
const cmd = new Command('auth').description('Authentication management');

cmd
.command('login')
.description('Configure access token')
.requiredOption('-t, --token <token>', 'YNAB Personal Access Token')
.action(
withErrorHandling(async () => {
const token = await promptForAccessToken();
withErrorHandling(async (options: { token: string }) => {
const token = options.token.trim();
if (!token) {
throw new YnabCliError('Access token cannot be empty', 400);
}
await auth.setAccessToken(token);
client.clearApi();

Expand Down
9 changes: 5 additions & 4 deletions src/commands/categories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { outputJson } from '../lib/output.js';
import { YnabCliError } from '../lib/errors.js';
import { amountToMilliunits } from '../lib/utils.js';
import { withErrorHandling } from '../lib/command-utils.js';
import { parseDate } from '../lib/dates.js';
import type { CommandOptions } from '../types/index.js';

export function createCategoriesCommand(): Command {
Expand Down Expand Up @@ -39,7 +40,7 @@ export function createCategoriesCommand(): Command {
.command('budget')
.description('Set category budgeted amount for a month (overrides existing amount)')
.argument('<id>', 'Category ID')
.requiredOption('--month <month>', 'Month in YYYY-MM-DD format (e.g., 2025-07-01)')
.requiredOption('--month <month>', 'Budget month (e.g., 2025-07-01)')
.requiredOption('--amount <amount>', 'Total budgeted amount to set (e.g., 100.50)', parseFloat)
.option('-b, --budget <id>', 'Budget ID')
.action(
Expand All @@ -58,7 +59,7 @@ export function createCategoriesCommand(): Command {

const milliunits = amountToMilliunits(options.amount);
const category = await client.updateMonthCategory(
options.month,
parseDate(options.month),
id,
{ category: { budgeted: milliunits } },
options.budget
Expand All @@ -73,7 +74,7 @@ export function createCategoriesCommand(): Command {
.description('List transactions for category')
.argument('<id>', 'Category ID')
.option('-b, --budget <id>', 'Budget ID')
.option('--since <date>', 'Filter transactions since date (YYYY-MM-DD)')
.option('--since <date>', 'Filter transactions since date')
.option('--type <type>', 'Filter by transaction type')
.option('--last-knowledge <number>', 'Last knowledge of server', parseInt)
.action(
Expand All @@ -89,7 +90,7 @@ export function createCategoriesCommand(): Command {
) => {
const result = await client.getTransactionsByCategory(id, {
budgetId: options.budget,
sinceDate: options.since,
sinceDate: options.since ? parseDate(options.since) : undefined,
type: options.type,
lastKnowledgeOfServer: options.lastKnowledge,
});
Expand Down
5 changes: 3 additions & 2 deletions src/commands/months.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Command } from 'commander';
import { client } from '../lib/api-client.js';
import { outputJson } from '../lib/output.js';
import { withErrorHandling } from '../lib/command-utils.js';
import { parseDate } from '../lib/dates.js';
import type { CommandOptions } from '../types/index.js';

export function createMonthsCommand(): Command {
Expand All @@ -24,11 +25,11 @@ export function createMonthsCommand(): Command {
cmd
.command('view')
.description('View specific month details')
.argument('<month>', 'Month in YYYY-MM-DD format (e.g., 2025-07-01)')
.argument('<month>', 'Budget month (e.g., 2025-07-01)')
.option('-b, --budget <id>', 'Budget ID')
.action(
withErrorHandling(async (month: string, options: CommandOptions) => {
const monthData = await client.getBudgetMonth(month, options.budget);
const monthData = await client.getBudgetMonth(parseDate(month), options.budget);
outputJson(monthData);
})
);
Expand Down
5 changes: 3 additions & 2 deletions src/commands/payees.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { client } from '../lib/api-client.js';
import { outputJson } from '../lib/output.js';
import { YnabCliError } from '../lib/errors.js';
import { withErrorHandling } from '../lib/command-utils.js';
import { parseDate } from '../lib/dates.js';
import type { CommandOptions } from '../types/index.js';

export function createPayeesCommand(): Command {
Expand Down Expand Up @@ -74,7 +75,7 @@ export function createPayeesCommand(): Command {
.description('List transactions for payee')
.argument('<id>', 'Payee ID')
.option('-b, --budget <id>', 'Budget ID')
.option('--since <date>', 'Filter transactions since date (YYYY-MM-DD)')
.option('--since <date>', 'Filter transactions since date')
.option('--type <type>', 'Filter by transaction type')
.option('--last-knowledge <number>', 'Last knowledge of server', parseInt)
.action(
Expand All @@ -90,7 +91,7 @@ export function createPayeesCommand(): Command {
) => {
const result = await client.getTransactionsByPayee(id, {
budgetId: options.budget,
sinceDate: options.since,
sinceDate: options.since ? parseDate(options.since) : undefined,
type: options.type,
lastKnowledgeOfServer: options.lastKnowledge,
});
Expand Down
7 changes: 2 additions & 5 deletions src/commands/scheduled.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Command } from 'commander';
import { client } from '../lib/api-client.js';
import { outputJson } from '../lib/output.js';
import { withErrorHandling, confirmDelete } from '../lib/command-utils.js';
import { withErrorHandling, requireConfirmation } from '../lib/command-utils.js';
import type { CommandOptions } from '../types/index.js';

export function createScheduledCommand(): Command {
Expand Down Expand Up @@ -45,10 +45,7 @@ export function createScheduledCommand(): Command {
.action(
withErrorHandling(
async (id: string, options: { budget?: string; yes?: boolean } & CommandOptions) => {
if (!(await confirmDelete('scheduled transaction', options.yes))) {
return;
}

requireConfirmation('scheduled transaction', options.yes);
const scheduledTransaction = await client.deleteScheduledTransaction(id, options.budget);
outputJson({
message: 'Scheduled transaction deleted',
Expand Down
48 changes: 17 additions & 31 deletions src/commands/transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,15 @@ import { Command } from 'commander';
import { client } from '../lib/api-client.js';
import { outputJson } from '../lib/output.js';
import { YnabCliError } from '../lib/errors.js';
import { promptForTransaction } from '../lib/prompts.js';
import {
isInteractive,
amountToMilliunits,
applyTransactionFilters,
applyFieldSelection,
type TransactionLike,
} from '../lib/utils.js';
import { withErrorHandling, confirmDelete, buildUpdateObject } from '../lib/command-utils.js';
import { validateJson, TransactionSplitSchema } from '../lib/schemas.js';
import { withErrorHandling, requireConfirmation, buildUpdateObject } from '../lib/command-utils.js';
import { validateTransactionSplits } from '../lib/schemas.js';
import { parseDate, todayDate } from '../lib/dates.js';
import type { CommandOptions } from '../types/index.js';

interface TransactionOptions {
Expand All @@ -36,7 +35,7 @@ function buildTransactionData(options: TransactionOptions): Record<string, unkno

return {
account_id: options.account,
date: options.date || new Date().toISOString().split('T')[0],
date: options.date ? parseDate(options.date) : todayDate(),
amount: amountToMilliunits(options.amount),
payee_name: options.payeeName,
payee_id: options.payeeId,
Expand All @@ -57,8 +56,8 @@ export function createTransactionsCommand(): Command {
.option('--account <id>', 'Filter by account ID')
.option('--category <id>', 'Filter by category ID')
.option('--payee <id>', 'Filter by payee ID')
.option('--since <date>', 'Filter transactions since date (YYYY-MM-DD)')
.option('--until <date>', 'Filter transactions until date (YYYY-MM-DD)')
.option('--since <date>', 'Filter transactions since date')
.option('--until <date>', 'Filter transactions until date')
.option('--type <type>', 'Filter by transaction type')
.option('--approved <value>', 'Filter by approval status: true or false')
.option(
Expand Down Expand Up @@ -91,7 +90,7 @@ export function createTransactionsCommand(): Command {
) => {
const params = {
budgetId: options.budget,
sinceDate: options.since,
sinceDate: options.since ? parseDate(options.since) : undefined,
type: options.type,
};

Expand All @@ -106,7 +105,7 @@ export function createTransactionsCommand(): Command {
const transactions = result?.transactions || [];

const filtered = applyTransactionFilters(transactions as TransactionLike[], {
until: options.until,
until: options.until ? parseDate(options.until) : undefined,
approved: options.approved,
status: options.status,
minAmount: options.minAmount,
Expand Down Expand Up @@ -137,7 +136,7 @@ export function createTransactionsCommand(): Command {
.description('Create transaction')
.option('-b, --budget <id>', 'Budget ID')
.option('--account <id>', 'Account ID')
.option('--date <date>', 'Date (YYYY-MM-DD)')
.option('--date <date>', 'Transaction date')
.option('--amount <amount>', 'Amount in currency units (e.g., 10.50)', parseFloat)
.option('--payee-name <name>', 'Payee name')
.option('--payee-id <id>', 'Payee ID')
Expand All @@ -161,17 +160,7 @@ export function createTransactionsCommand(): Command {
approved?: boolean;
} & CommandOptions
) => {
const shouldPrompt = isInteractive() && !options.amount;
if (shouldPrompt && !options.account) {
throw new YnabCliError(
'--account is required. Interactive mode cannot auto-select an account.',
400
);
}
const transactionData = shouldPrompt
? { ...(await promptForTransaction()), account_id: options.account }
: buildTransactionData(options);

const transactionData = buildTransactionData(options);
const transaction = await client.createTransaction(
{ transaction: transactionData },
options.budget
Expand All @@ -187,7 +176,7 @@ export function createTransactionsCommand(): Command {
.argument('<id>', 'Transaction ID')
.option('-b, --budget <id>', 'Budget ID')
.option('--account <id>', 'Account ID')
.option('--date <date>', 'Date (YYYY-MM-DD)')
.option('--date <date>', 'Transaction date')
.option('--amount <amount>', 'Amount in currency units', parseFloat)
.option('--payee-name <name>', 'Payee name')
.option('--payee-id <id>', 'Payee ID')
Expand Down Expand Up @@ -232,10 +221,7 @@ export function createTransactionsCommand(): Command {
.action(
withErrorHandling(
async (id: string, options: { budget?: string; yes?: boolean } & CommandOptions) => {
if (!(await confirmDelete('transaction', options.yes))) {
return;
}

requireConfirmation('transaction', options.yes);
const transaction = await client.deleteTransaction(id, options.budget);
outputJson({ message: 'Transaction deleted', transaction });
}
Expand Down Expand Up @@ -278,7 +264,7 @@ export function createTransactionsCommand(): Command {
throw new YnabCliError('Invalid JSON in --splits parameter', 400);
}

const splits = validateJson(parsedSplits, TransactionSplitSchema, 'transaction splits');
const splits = validateTransactionSplits(parsedSplits);

const splitsInMilliunits = splits.map((split) => ({
...split,
Expand Down Expand Up @@ -343,8 +329,8 @@ export function createTransactionsCommand(): Command {
.option('--memo <text>', 'Search in memo field')
.option('--payee-name <name>', 'Search in payee name')
.option('--amount <amount>', 'Search for exact amount in currency units', parseFloat)
.option('--since <date>', 'Search transactions since date (YYYY-MM-DD)')
.option('--until <date>', 'Search transactions until date (YYYY-MM-DD)')
.option('--since <date>', 'Search transactions since date')
.option('--until <date>', 'Search transactions until date')
.option('--approved <value>', 'Filter by approval status: true or false')
.option(
'--status <statuses>',
Expand Down Expand Up @@ -375,7 +361,7 @@ export function createTransactionsCommand(): Command {

const params = {
budgetId: options.budget,
sinceDate: options.since,
sinceDate: options.since ? parseDate(options.since) : undefined,
};

const result = await client.getTransactions(params);
Expand All @@ -399,7 +385,7 @@ export function createTransactionsCommand(): Command {
}

transactions = applyTransactionFilters(transactions, {
until: options.until,
until: options.until ? parseDate(options.until) : undefined,
approved: options.approved,
status: options.status,
});
Expand Down
Loading