UNPKG

ledgerline

Version:

A modern interactive CLI to track expenses, stored locally in JSON.

189 lines (187 loc) 6.91 kB
#!/usr/bin/env node import { Command } from "commander"; import inquirer from "inquirer"; import chalk from "chalk"; import { nanoid } from "nanoid"; import { loadExpenses, saveExpenses, getDataFilePath } from "./storage.js"; import { createRequire } from "node:module"; const require = createRequire(import.meta.url); const pkg = require("../package.json"); const program = new Command(); program .name("ledgerline") .description("Track expenses in a local JSON store") .version(pkg.version) .showHelpAfterError() .addHelpText("afterAll", ` List filters (for 'list' command): -c, --category <category> Filter by category --since <YYYY-MM-DD> Start date inclusive --until <YYYY-MM-DD> End date inclusive -l, --limit <n> Limit number of results --json Output raw JSON Examples: ledgerline add ledgerline add --amount 12.5 --category Food --date 2025-08-07 --note "Lunch" ledgerline list --category Food --since 2025-08-01 --until 2025-08-31 `); program .command("add") .description("Add a new expense") .option("-a, --amount <number>", "Amount", (v) => parseFloat(v)) .option("-c, --category <category>", "Category") .option("-d, --date <YYYY-MM-DD>", "Date in YYYY-MM-DD") .option("-n, --note <note>", "Note") .option("--currency <currency>", "Currency (ISO code)", "EUR") .option("-i, --interactive", "Prompt for values", false) .action(async (opts) => { const shouldPrompt = opts.interactive || opts.amount == null || !opts.category || !opts.date; let amount = opts.amount; let category = opts.category; let date = opts.date; let note = opts.note; let currency = opts.currency; if (shouldPrompt) { const answers = await inquirer.prompt([ { type: "input", name: "amount", message: "Amount", when: () => amount == null, validate: (v) => isNaN(parseFloat(v)) ? "Enter a number" : true, filter: (v) => parseFloat(v) }, { type: "list", name: "category", message: "Category", when: () => !category, choices: [ "Food", "Transport", "Groceries", "Utilities", "Rent", "Entertainment", "Health", "Shopping", "Travel", "Other" ], default: "Other" }, { type: "input", name: "date", message: "Date (YYYY-MM-DD)", when: () => !date, default: new Date().toISOString().slice(0, 10), validate: (v) => /^\d{4}-\d{2}-\d{2}$/.test(v) ? true : "Use YYYY-MM-DD" }, { type: "input", name: "note", message: "Note (optional)", when: () => !note }, { type: "input", name: "currency", message: "Currency (ISO)", when: () => !currency, default: "EUR" } ]); amount = amount ?? answers.amount; category = category ?? answers.category; date = date ?? answers.date; note = note ?? answers.note; currency = currency ?? answers.currency; } if (typeof amount !== "number" || isNaN(amount)) { console.error(chalk.red("Amount is required. Use --amount or run with --interactive.")); process.exit(1); } if (!category) { console.error(chalk.red("Category is required. Use --category or run with --interactive.")); process.exit(1); } if (!date) { date = new Date().toISOString().slice(0, 10); } else if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { console.error(chalk.red("Date must be in YYYY-MM-DD")); process.exit(1); } const expense = { id: nanoid(10), amount, currency: currency || "EUR", category, date, note: note || undefined, createdAt: new Date().toISOString() }; const expenses = await loadExpenses(); expenses.push(expense); await saveExpenses(expenses); console.log(chalk.green("Saved expense:"), chalk.bold(expense.currency), expense.amount.toFixed(2), chalk.gray(`(${expense.category} on ${expense.date})`), expense.note ? `- ${expense.note}` : ""); console.log(chalk.gray(`Data file: ${getDataFilePath()}`)); }); program .command("list") .description("List expenses") .option("-c, --category <category>", "Filter by category") .option("--since <YYYY-MM-DD>", "Start date inclusive") .option("--until <YYYY-MM-DD>", "End date inclusive") .option("-l, --limit <n>", "Limit number", (v) => parseInt(v, 10)) .option("--json", "Output raw JSON", false) .action(async (opts) => { let expenses = await loadExpenses(); if (opts.category) { const categoryLower = String(opts.category).toLowerCase(); expenses = expenses.filter((e) => e.category.toLowerCase() === categoryLower); } const validDate = (s) => /^\d{4}-\d{2}-\d{2}$/.test(s); if (opts.since) { if (!validDate(opts.since)) { console.error(chalk.red("since must be YYYY-MM-DD")); process.exit(1); } expenses = expenses.filter((e) => e.date >= opts.since); } if (opts.until) { if (!validDate(opts.until)) { console.error(chalk.red("until must be YYYY-MM-DD")); process.exit(1); } expenses = expenses.filter((e) => e.date <= opts.until); } expenses.sort((a, b) => a.date.localeCompare(b.date) || a.createdAt.localeCompare(b.createdAt)); if (opts.limit && Number.isFinite(opts.limit)) { expenses = expenses.slice(-opts.limit); } if (opts.json) { console.log(JSON.stringify(expenses, null, 2)); return; } if (expenses.length === 0) { console.log(chalk.yellow("No expenses found.")); return; } const rows = expenses.map((e) => { return `${chalk.gray(e.date)} ${chalk.bold(e.currency)} ${e.amount .toFixed(2) .padStart(8)} ${chalk.cyan(e.category.padEnd(14))} ${e.note ? e.note : ""}`; }); console.log(rows.join("\n")); const total = expenses.reduce((sum, e) => sum + e.amount, 0); console.log(chalk.gray(`\nTotal: ${total.toFixed(2)} (mixed currencies may be summed as-is)`)); }); program .command("path") .description("Show the data file path") .action(() => { console.log(getDataFilePath()); }); program.parseAsync(process.argv);