Enhance CLI functionality: Update configuration loading to support additional search places for config files. Refactor error handling in command options to improve validation and user feedback. Introduce new utility functions for parsing boolean flags and update related commands to utilize these functions. Add comprehensive tests for new utility functions to ensure reliability.

This commit is contained in:
Matiss Janis Aboltins
2026-03-15 20:54:27 +00:00
parent 843274e00e
commit 3ddb403bfe
14 changed files with 241 additions and 54 deletions

View File

@@ -32,7 +32,7 @@ function createProgram(): Command {
program.option('--session-token <token>');
program.option('--budget-id <id>');
program.option('--data-dir <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', () => {

View File

@@ -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 <bool>', 'Set off-budget status')
.action(async (id: string, cmdOpts) => {
const opts = program.opts();
await withConnection(opts, async () => {
const fields: Record<string, unknown> = {};
if (cmdOpts.name !== undefined) fields.name = cmdOpts.name;
if (cmdOpts.offbudget !== undefined) {
fields.offbudget = cmdOpts.offbudget === 'true';
const fields: Record<string, unknown> = {};
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);
});

View File

@@ -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 <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 <id>', 'Category ID')
.requiredOption('--flag <bool>', '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);
});
});

View File

@@ -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 <name>', 'New category name')
.option('--hidden <bool>', 'Set hidden status')
.action(async (id: string, cmdOpts) => {
const fields: Record<string, unknown> = {};
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<string, unknown> = {};
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);
});

View File

@@ -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 <name>', 'New group name')
.option('--hidden <bool>', 'Set hidden status')
.action(async (id: string, cmdOpts) => {
const fields: Record<string, unknown> = {};
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<string, unknown> = {};
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 <id>')
.description('Delete a category group')
.option('--transfer-to <id>', 'Transfer categories to this category group')
.option(
'--transfer-to <id>',
'Transfer category groups to this category group',
)
.action(async (id: string, cmdOpts) => {
const opts = program.opts();
await withConnection(opts, async () => {

View File

@@ -46,15 +46,15 @@ export function registerPayeesCommand(program: Command) {
.description('Update a payee')
.option('--name <name>', 'New payee name')
.action(async (id: string, cmdOpts) => {
const fields: Record<string, unknown> = {};
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<string, unknown> = {};
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 <id>', 'Target payee ID')
.requiredOption('--ids <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);
});

View File

@@ -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)) {

View File

@@ -43,14 +43,19 @@ export function registerTagsCommand(program: Command) {
.option('--color <color>', 'New tag color')
.option('--description <description>', 'New tag description')
.action(async (id: string, cmdOpts) => {
const fields: Record<string, unknown> = {};
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<string, unknown> = {};
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);
});

View File

@@ -33,7 +33,18 @@ type ConfigFileContent = {
};
async function loadConfigFile(): Promise<ConfigFileContent> {
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;

View File

@@ -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 '<non-serializable error>';
}
}
return String(err);
}

View File

@@ -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);
}

View File

@@ -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');
});
});

View File

@@ -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;

View File

@@ -53,15 +53,15 @@ Global flags override environment variables:
| `--budget-id <id>` | Budget ID |
| `--data-dir <path>` | Local data directory for cached budget data |
| `--format <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 <id> --quiet
actual accounts balance <id>
```
**Export transactions to CSV:**
@@ -353,4 +353,4 @@ actual transactions add --account <id> --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