mirror of
https://github.com/actualbudget/actual.git
synced 2026-04-28 01:58:40 -05:00
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:
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
67
packages/cli/src/utils.test.ts
Normal file
67
packages/cli/src/utils.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user