diff --git a/packages/cli/src/commands/accounts.test.ts b/packages/cli/src/commands/accounts.test.ts index 2e9290c111..869d0fd30a 100644 --- a/packages/cli/src/commands/accounts.test.ts +++ b/packages/cli/src/commands/accounts.test.ts @@ -32,7 +32,7 @@ function createProgram(): Command { program.option('--session-token '); program.option('--budget-id '); program.option('--data-dir '); - program.option('--quiet'); + program.option('--verbose'); program.exitOverride(); registerAccountsCommand(program); return program; @@ -123,6 +123,60 @@ describe('accounts commands', () => { undefined, ); }); + + it('passes offbudget true', async () => { + await run([ + 'accounts', + 'update', + 'acct-1', + '--name', + 'X', + '--offbudget', + 'true', + ]); + + expect(api.updateAccount).toHaveBeenCalledWith('acct-1', { + name: 'X', + offbudget: true, + }); + }); + + it('passes offbudget false', async () => { + await run([ + 'accounts', + 'update', + 'acct-1', + '--name', + 'X', + '--offbudget', + 'false', + ]); + + expect(api.updateAccount).toHaveBeenCalledWith('acct-1', { + name: 'X', + offbudget: false, + }); + }); + + it('rejects invalid offbudget value', async () => { + await expect( + run(['accounts', 'update', 'acct-1', '--offbudget', 'yes']), + ).rejects.toThrow( + 'Invalid --offbudget: "yes". Expected "true" or "false".', + ); + }); + + it('rejects empty name', async () => { + await expect( + run(['accounts', 'update', 'acct-1', '--name', ' ']), + ).rejects.toThrow('Invalid --name: must be a non-empty string.'); + }); + + it('rejects update with no fields', async () => { + await expect(run(['accounts', 'update', 'acct-1'])).rejects.toThrow( + 'No update fields provided. Use --name or --offbudget.', + ); + }); }); describe('close', () => { diff --git a/packages/cli/src/commands/accounts.ts b/packages/cli/src/commands/accounts.ts index 260fcfc71a..4b46d75186 100644 --- a/packages/cli/src/commands/accounts.ts +++ b/packages/cli/src/commands/accounts.ts @@ -3,7 +3,7 @@ import type { Command } from 'commander'; import { withConnection } from '../connection'; import { printOutput } from '../output'; -import { parseIntFlag } from '../utils'; +import { parseBoolFlag, parseIntFlag } from '../utils'; export function registerAccountsCommand(program: Command) { const accounts = program.command('accounts').description('Manage accounts'); @@ -44,12 +44,23 @@ export function registerAccountsCommand(program: Command) { .option('--offbudget ', 'Set off-budget status') .action(async (id: string, cmdOpts) => { const opts = program.opts(); - await withConnection(opts, async () => { - const fields: Record = {}; - if (cmdOpts.name !== undefined) fields.name = cmdOpts.name; - if (cmdOpts.offbudget !== undefined) { - fields.offbudget = cmdOpts.offbudget === 'true'; + const fields: Record = {}; + if (cmdOpts.name !== undefined) { + const trimmed = cmdOpts.name.trim(); + if (trimmed === '') { + throw new Error('Invalid --name: must be a non-empty string.'); } + fields.name = trimmed; + } + if (cmdOpts.offbudget !== undefined) { + fields.offbudget = parseBoolFlag(cmdOpts.offbudget, '--offbudget'); + } + if (Object.keys(fields).length === 0) { + throw new Error( + 'No update fields provided. Use --name or --offbudget.', + ); + } + await withConnection(opts, async () => { await api.updateAccount(id, fields); printOutput({ success: true, id }, opts.format); }); diff --git a/packages/cli/src/commands/budgets.ts b/packages/cli/src/commands/budgets.ts index ff5dd78ec4..6809f30d5f 100644 --- a/packages/cli/src/commands/budgets.ts +++ b/packages/cli/src/commands/budgets.ts @@ -1,9 +1,10 @@ import * as api from '@actual-app/api'; import type { Command } from 'commander'; +import { resolveConfig } from '../config'; import { withConnection } from '../connection'; import { printOutput } from '../output'; -import { parseIntFlag } from '../utils'; +import { parseBoolFlag, parseIntFlag } from '../utils'; export function registerBudgetsCommand(program: Command) { const budgets = program.command('budgets').description('Manage budgets'); @@ -29,11 +30,13 @@ export function registerBudgetsCommand(program: Command) { .option('--encryption-password ', 'Encryption password') .action(async (syncId: string, cmdOpts) => { const opts = program.opts(); + const config = await resolveConfig(opts); + const password = config.encryptionPassword ?? cmdOpts.encryptionPassword; await withConnection( opts, async () => { await api.downloadBudget(syncId, { - password: cmdOpts.encryptionPassword, + password, }); printOutput({ success: true, syncId }, opts.format); }, @@ -96,13 +99,10 @@ export function registerBudgetsCommand(program: Command) { .requiredOption('--category ', 'Category ID') .requiredOption('--flag ', 'Enable (true) or disable (false)') .action(async cmdOpts => { + const flag = parseBoolFlag(cmdOpts.flag, '--flag'); const opts = program.opts(); await withConnection(opts, async () => { - await api.setBudgetCarryover( - cmdOpts.month, - cmdOpts.category, - cmdOpts.flag === 'true', - ); + await api.setBudgetCarryover(cmdOpts.month, cmdOpts.category, flag); printOutput({ success: true }, opts.format); }); }); diff --git a/packages/cli/src/commands/categories.ts b/packages/cli/src/commands/categories.ts index f3aa71570c..d18a3dea89 100644 --- a/packages/cli/src/commands/categories.ts +++ b/packages/cli/src/commands/categories.ts @@ -3,6 +3,7 @@ import type { Command } from 'commander'; import { withConnection } from '../connection'; import { printOutput } from '../output'; +import { parseBoolFlag } from '../utils'; export function registerCategoriesCommand(program: Command) { const categories = program @@ -45,13 +46,16 @@ export function registerCategoriesCommand(program: Command) { .option('--name ', 'New category name') .option('--hidden ', 'Set hidden status') .action(async (id: string, cmdOpts) => { + const fields: Record = {}; + if (cmdOpts.name !== undefined) fields.name = cmdOpts.name; + if (cmdOpts.hidden !== undefined) { + fields.hidden = parseBoolFlag(cmdOpts.hidden, '--hidden'); + } + if (Object.keys(fields).length === 0) { + throw new Error('No update fields provided. Use --name or --hidden.'); + } const opts = program.opts(); await withConnection(opts, async () => { - const fields: Record = {}; - if (cmdOpts.name !== undefined) fields.name = cmdOpts.name; - if (cmdOpts.hidden !== undefined) { - fields.hidden = cmdOpts.hidden === 'true'; - } await api.updateCategory(id, fields); printOutput({ success: true, id }, opts.format); }); diff --git a/packages/cli/src/commands/category-groups.ts b/packages/cli/src/commands/category-groups.ts index 0b2a579025..a210b80484 100644 --- a/packages/cli/src/commands/category-groups.ts +++ b/packages/cli/src/commands/category-groups.ts @@ -3,6 +3,7 @@ import type { Command } from 'commander'; import { withConnection } from '../connection'; import { printOutput } from '../output'; +import { parseBoolFlag } from '../utils'; export function registerCategoryGroupsCommand(program: Command) { const groups = program @@ -43,13 +44,16 @@ export function registerCategoryGroupsCommand(program: Command) { .option('--name ', 'New group name') .option('--hidden ', 'Set hidden status') .action(async (id: string, cmdOpts) => { + const fields: Record = {}; + if (cmdOpts.name !== undefined) fields.name = cmdOpts.name; + if (cmdOpts.hidden !== undefined) { + fields.hidden = parseBoolFlag(cmdOpts.hidden, '--hidden'); + } + if (Object.keys(fields).length === 0) { + throw new Error('No update fields provided. Use --name or --hidden.'); + } const opts = program.opts(); await withConnection(opts, async () => { - const fields: Record = {}; - if (cmdOpts.name !== undefined) fields.name = cmdOpts.name; - if (cmdOpts.hidden !== undefined) { - fields.hidden = cmdOpts.hidden === 'true'; - } await api.updateCategoryGroup(id, fields); printOutput({ success: true, id }, opts.format); }); @@ -58,7 +62,10 @@ export function registerCategoryGroupsCommand(program: Command) { groups .command('delete ') .description('Delete a category group') - .option('--transfer-to ', 'Transfer categories to this category group') + .option( + '--transfer-to ', + 'Transfer category groups to this category group', + ) .action(async (id: string, cmdOpts) => { const opts = program.opts(); await withConnection(opts, async () => { diff --git a/packages/cli/src/commands/payees.ts b/packages/cli/src/commands/payees.ts index a0df7174d6..f569f983ec 100644 --- a/packages/cli/src/commands/payees.ts +++ b/packages/cli/src/commands/payees.ts @@ -46,15 +46,15 @@ export function registerPayeesCommand(program: Command) { .description('Update a payee') .option('--name ', 'New payee name') .action(async (id: string, cmdOpts) => { + const fields: Record = {}; + if (cmdOpts.name) fields.name = cmdOpts.name; + if (Object.keys(fields).length === 0) { + throw new Error( + 'No fields to update. Use --name to specify a new name.', + ); + } const opts = program.opts(); await withConnection(opts, async () => { - const fields: Record = {}; - if (cmdOpts.name) fields.name = cmdOpts.name; - if (Object.keys(fields).length === 0) { - throw new Error( - 'No fields to update. Use --name to specify a new name.', - ); - } await api.updatePayee(id, fields); printOutput({ success: true, id }, opts.format); }); @@ -77,12 +77,17 @@ export function registerPayeesCommand(program: Command) { .requiredOption('--target ', 'Target payee ID') .requiredOption('--ids ', 'Comma-separated payee IDs to merge') .action(async (cmdOpts: { target: string; ids: string }) => { + const mergeIds = cmdOpts.ids + .split(',') + .map(id => id.trim()) + .filter(id => id.length > 0); + if (mergeIds.length === 0) { + throw new Error( + 'No valid payee IDs provided in --ids. Provide comma-separated IDs.', + ); + } const opts = program.opts(); await withConnection(opts, async () => { - const mergeIds = cmdOpts.ids - .split(',') - .map(id => id.trim()) - .filter(id => id.length > 0); await api.mergePayees(cmdOpts.target, mergeIds); printOutput({ success: true }, opts.format); }); diff --git a/packages/cli/src/commands/query.ts b/packages/cli/src/commands/query.ts index 06cfe36736..eaca33729e 100644 --- a/packages/cli/src/commands/query.ts +++ b/packages/cli/src/commands/query.ts @@ -15,7 +15,12 @@ function buildQueryFromFile( fallbackTable: string | undefined, ) { const table = typeof parsed.table === 'string' ? parsed.table : fallbackTable; - let queryObj = api.q(table || 'transactions'); + if (!table) { + throw new Error( + '--table is required when the input file lacks a "table" field', + ); + } + let queryObj = api.q(table); if (Array.isArray(parsed.select)) queryObj = queryObj.select(parsed.select); if (isRecord(parsed.filter)) queryObj = queryObj.filter(parsed.filter); if (Array.isArray(parsed.orderBy)) { diff --git a/packages/cli/src/commands/tags.ts b/packages/cli/src/commands/tags.ts index ec1d291d07..b730fde798 100644 --- a/packages/cli/src/commands/tags.ts +++ b/packages/cli/src/commands/tags.ts @@ -43,14 +43,19 @@ export function registerTagsCommand(program: Command) { .option('--color ', 'New tag color') .option('--description ', 'New tag description') .action(async (id: string, cmdOpts) => { + const fields: Record = {}; + if (cmdOpts.tag !== undefined) fields.tag = cmdOpts.tag; + if (cmdOpts.color !== undefined) fields.color = cmdOpts.color; + if (cmdOpts.description !== undefined) { + fields.description = cmdOpts.description; + } + if (Object.keys(fields).length === 0) { + throw new Error( + 'At least one of --tag, --color, or --description is required', + ); + } const opts = program.opts(); await withConnection(opts, async () => { - const fields: Record = {}; - if (cmdOpts.tag !== undefined) fields.tag = cmdOpts.tag; - if (cmdOpts.color !== undefined) fields.color = cmdOpts.color; - if (cmdOpts.description !== undefined) { - fields.description = cmdOpts.description; - } await api.updateTag(id, fields); printOutput({ success: true, id }, opts.format); }); diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index a34492f876..c061a6b602 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -33,7 +33,18 @@ type ConfigFileContent = { }; async function loadConfigFile(): Promise { - const explorer = cosmiconfig('actual'); + const explorer = cosmiconfig('actual', { + searchPlaces: [ + 'package.json', + '.actualrc', + '.actualrc.json', + '.actualrc.yaml', + '.actualrc.yml', + 'actual.config.json', + 'actual.config.yaml', + 'actual.config.yml', + ], + }); const result = await explorer.search(); if (result && !result.isEmpty) { return result.config as ConfigFileContent; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 9663fb7190..833a64262a 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -53,7 +53,13 @@ registerServerCommand(program); function normalizeThrownMessage(err: unknown): string { if (err instanceof Error) return err.message; - if (typeof err === 'object' && err !== null) return JSON.stringify(err); + if (typeof err === 'object' && err !== null) { + try { + return JSON.stringify(err); + } catch { + return ''; + } + } return String(err); } diff --git a/packages/cli/src/input.ts b/packages/cli/src/input.ts index 083373427d..1072b39088 100644 --- a/packages/cli/src/input.ts +++ b/packages/cli/src/input.ts @@ -4,6 +4,9 @@ export function readJsonInput(cmdOpts: { data?: string; file?: string; }): unknown { + if (cmdOpts.data && cmdOpts.file) { + throw new Error('Cannot use both --data and --file'); + } if (cmdOpts.data) { return JSON.parse(cmdOpts.data); } diff --git a/packages/cli/src/utils.test.ts b/packages/cli/src/utils.test.ts new file mode 100644 index 0000000000..2937f8d8e9 --- /dev/null +++ b/packages/cli/src/utils.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; + +import { parseBoolFlag, parseIntFlag } from './utils'; + +describe('parseBoolFlag', () => { + it('parses "true"', () => { + expect(parseBoolFlag('true', '--flag')).toBe(true); + }); + + it('parses "false"', () => { + expect(parseBoolFlag('false', '--flag')).toBe(false); + }); + + it('rejects other strings', () => { + expect(() => parseBoolFlag('yes', '--flag')).toThrow( + 'Invalid --flag: "yes". Expected "true" or "false".', + ); + }); + + it('includes the flag name in the error message', () => { + expect(() => parseBoolFlag('1', '--offbudget')).toThrow( + 'Invalid --offbudget', + ); + }); +}); + +describe('parseIntFlag', () => { + it('parses a valid integer string', () => { + expect(parseIntFlag('42', '--balance')).toBe(42); + }); + + it('parses zero', () => { + expect(parseIntFlag('0', '--balance')).toBe(0); + }); + + it('parses negative integers', () => { + expect(parseIntFlag('-10', '--balance')).toBe(-10); + }); + + it('rejects decimal values', () => { + expect(() => parseIntFlag('3.5', '--balance')).toThrow( + 'Invalid --balance: "3.5". Expected an integer.', + ); + }); + + it('rejects non-numeric strings', () => { + expect(() => parseIntFlag('abc', '--balance')).toThrow( + 'Invalid --balance: "abc". Expected an integer.', + ); + }); + + it('rejects partially numeric strings', () => { + expect(() => parseIntFlag('3abc', '--balance')).toThrow( + 'Invalid --balance: "3abc". Expected an integer.', + ); + }); + + it('rejects empty string', () => { + expect(() => parseIntFlag('', '--balance')).toThrow( + 'Invalid --balance: "". Expected an integer.', + ); + }); + + it('includes the flag name in the error message', () => { + expect(() => parseIntFlag('x', '--amount')).toThrow('Invalid --amount'); + }); +}); diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index 45f86f1e93..90c305bbb2 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -1,6 +1,15 @@ +export function parseBoolFlag(value: string, flagName: string): boolean { + if (value !== 'true' && value !== 'false') { + throw new Error( + `Invalid ${flagName}: "${value}". Expected "true" or "false".`, + ); + } + return value === 'true'; +} + export function parseIntFlag(value: string, flagName: string): number { - const parsed = parseInt(value, 10); - if (Number.isNaN(parsed)) { + const parsed = value.trim() === '' ? NaN : Number(value); + if (!Number.isInteger(parsed)) { throw new Error(`Invalid ${flagName}: "${value}". Expected an integer.`); } return parsed; diff --git a/packages/docs/docs/api/cli.md b/packages/docs/docs/api/cli.md index 8b2ff6a9cb..42e71a95d1 100644 --- a/packages/docs/docs/api/cli.md +++ b/packages/docs/docs/api/cli.md @@ -53,15 +53,15 @@ Global flags override environment variables: | `--budget-id ` | Budget ID | | `--data-dir ` | Local data directory for cached budget data | | `--format ` | Output format: `json` (default), `table`, `csv` | -| `--quiet` | Suppress informational messages on stderr | +| `--verbose` | Show informational messages on stderr | ### Config File The CLI uses [cosmiconfig](https://github.com/cosmiconfig/cosmiconfig) for configuration. You can create a config file in any of these formats: - `.actualrc` (JSON or YAML) -- `.actualrc.json`, `.actualrc.yaml`, `.actualrc.yml`, `.actualrc.js`, `.actualrc.cjs` -- `actual.config.js`, `actual.config.cjs` +- `.actualrc.json`, `.actualrc.yaml`, `.actualrc.yml` +- `actual.config.json`, `actual.config.yaml`, `actual.config.yml` - An `"actual"` key in your `package.json` Example `.actualrc.json`: @@ -318,7 +318,7 @@ The `--format` flag controls how results are displayed: - **`table`** — Human-readable table format - **`csv`** — Comma-separated values for spreadsheet import -Use `--quiet` to suppress informational messages on stderr when piping output to other tools. +Use `--verbose` to enable informational messages on stderr for debugging or visibility into what the CLI is doing. ## Common Workflows @@ -332,9 +332,9 @@ actual budgets month 2026-03 --format table ```bash # Find the account ID -actual server get-id --type accounts --name "Checking" --quiet +actual server get-id --type accounts --name "Checking" # Get the balance -actual accounts balance --quiet +actual accounts balance ``` **Export transactions to CSV:** @@ -353,4 +353,4 @@ actual transactions add --account --data '[{"date":"2026-03-14","amount":-2 - Non-zero exit codes indicate an error - Error output is JSON on stdout: `{"error": "message"}` -- Use `--quiet` to suppress informational stderr messages without affecting error output +- Use `--verbose` to enable informational stderr messages for debugging