Compare commits

...

1 Commits

Author SHA1 Message Date
Claude
6dce328b00 [AI] Improve CLI transactions and query commands for better usability
- Flatten nested objects in table/csv output instead of showing [object Object]
- Make --start/--end optional on transactions list (defaults to last 30 days)
- Resolve payee, category, and account names in transactions list output via AQL
- Add --exclude-transfers flag to transactions list and query run
- Add query describe command for full schema output in one call
- Add select shorthand aliases (payee → payee.name, category → category.name)
- Add field descriptions to query fields/describe output (e.g. amount is in cents)
- Add CliError class with suggestion messages for better error guidance

https://claude.ai/code/session_01Tzo7tE4YutV9hqgHNYonXB
2026-03-21 21:56:05 +00:00
9 changed files with 607 additions and 69 deletions

View File

@@ -3,7 +3,11 @@ import { Command } from 'commander';
import { printOutput } from '../output';
import { parseOrderBy, registerQueryCommand } from './query';
import {
expandSelectAliases,
parseOrderBy,
registerQueryCommand,
} from './query';
vi.mock('@actual-app/api', () => {
const queryObj = {
@@ -342,5 +346,87 @@ describe('query commands', () => {
'Unknown table "unknown"',
);
});
it('includes descriptions in field output', async () => {
await run(['query', 'fields', 'transactions']);
const output = vi.mocked(printOutput).mock.calls[0][0] as Array<{
name: string;
type: string;
description?: string;
}>;
const amountField = output.find(f => f.name === 'amount');
expect(amountField?.description).toContain('cents');
});
});
describe('describe subcommand', () => {
it('outputs schema for all tables', async () => {
await run(['query', 'describe']);
const output = vi.mocked(printOutput).mock.calls[0][0] as Record<
string,
unknown[]
>;
expect(output).toHaveProperty('transactions');
expect(output).toHaveProperty('accounts');
expect(output).toHaveProperty('categories');
expect(output).toHaveProperty('payees');
expect(output).toHaveProperty('rules');
expect(output).toHaveProperty('schedules');
});
});
describe('--exclude-transfers flag', () => {
it('adds transfer_id null filter for transactions', async () => {
await run([
'query',
'run',
'--table',
'transactions',
'--exclude-transfers',
]);
const qObj = getQueryObj();
expect(qObj.filter).toHaveBeenCalledWith({ transfer_id: { $eq: null } });
});
it('errors when used with non-transactions table', async () => {
await expect(
run(['query', 'run', '--table', 'accounts', '--exclude-transfers']),
).rejects.toThrow(
'--exclude-transfers can only be used with --table transactions',
);
});
});
});
describe('expandSelectAliases', () => {
it('expands transaction aliases', () => {
expect(
expandSelectAliases('transactions', [
'date',
'payee',
'category',
'amount',
]),
).toEqual(['date', 'payee.name', 'category.name', 'amount']);
});
it('expands account alias', () => {
expect(expandSelectAliases('transactions', ['account'])).toEqual([
'account.name',
]);
});
it('passes through unknown fields unchanged', () => {
expect(expandSelectAliases('transactions', ['notes'])).toEqual(['notes']);
});
it('returns fields unchanged for tables without aliases', () => {
expect(expandSelectAliases('rules', ['id', 'stage'])).toEqual([
'id',
'stage',
]);
});
});

View File

@@ -4,7 +4,7 @@ import type { Command } from 'commander';
import { withConnection } from '../connection';
import { readJsonInput } from '../input';
import { printOutput } from '../output';
import { parseIntFlag } from '../utils';
import { CliError, parseIntFlag } from '../utils';
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -43,71 +43,166 @@ export function parseOrderBy(
}
// TODO: Import schema from API once it exposes table/field metadata
const TABLE_SCHEMA: Record<
string,
Record<string, { type: string; ref?: string }>
> = {
type FieldInfo = { type: string; ref?: string; description?: string };
const TABLE_SCHEMA: Record<string, Record<string, FieldInfo>> = {
transactions: {
id: { type: 'id' },
account: { type: 'id', ref: 'accounts' },
date: { type: 'date' },
amount: { type: 'integer' },
payee: { type: 'id', ref: 'payees' },
category: { type: 'id', ref: 'categories' },
notes: { type: 'string' },
imported_id: { type: 'string' },
transfer_id: { type: 'id' },
cleared: { type: 'boolean' },
reconciled: { type: 'boolean' },
starting_balance_flag: { type: 'boolean' },
imported_payee: { type: 'string' },
is_parent: { type: 'boolean' },
is_child: { type: 'boolean' },
parent_id: { type: 'id' },
sort_order: { type: 'float' },
schedule: { type: 'id', ref: 'schedules' },
'account.name': { type: 'string', ref: 'accounts' },
'payee.name': { type: 'string', ref: 'payees' },
'category.name': { type: 'string', ref: 'categories' },
'category.group.name': { type: 'string', ref: 'category_groups' },
id: { type: 'id', description: 'Unique transaction identifier' },
account: { type: 'id', ref: 'accounts', description: 'Account ID' },
date: { type: 'date', description: 'Transaction date (YYYY-MM-DD)' },
amount: {
type: 'integer',
description:
'Amount in cents (e.g. 1000 = $10.00). Negative = expense, positive = income',
},
payee: { type: 'id', ref: 'payees', description: 'Payee ID' },
category: { type: 'id', ref: 'categories', description: 'Category ID' },
notes: { type: 'string', description: 'Transaction notes/memo' },
imported_id: {
type: 'string',
description: 'External ID from bank import',
},
transfer_id: {
type: 'id',
description:
'Linked transaction ID for transfers. Non-null means this is a transfer between own accounts',
},
cleared: { type: 'boolean', description: 'Whether transaction is cleared' },
reconciled: {
type: 'boolean',
description: 'Whether transaction is reconciled',
},
starting_balance_flag: {
type: 'boolean',
description: 'True for the starting balance transaction',
},
imported_payee: {
type: 'string',
description: 'Original payee name from bank import',
},
is_parent: {
type: 'boolean',
description: 'True if this is a split parent transaction',
},
is_child: {
type: 'boolean',
description: 'True if this is a split child transaction',
},
parent_id: {
type: 'id',
description: 'Parent transaction ID for split children',
},
sort_order: { type: 'float', description: 'Sort order within a day' },
schedule: {
type: 'id',
ref: 'schedules',
description: 'Linked schedule ID',
},
'account.name': {
type: 'string',
ref: 'accounts',
description: 'Resolved account name',
},
'payee.name': {
type: 'string',
ref: 'payees',
description: 'Resolved payee name',
},
'category.name': {
type: 'string',
ref: 'categories',
description: 'Resolved category name',
},
'category.group.name': {
type: 'string',
ref: 'category_groups',
description: 'Resolved category group name',
},
},
accounts: {
id: { type: 'id' },
name: { type: 'string' },
offbudget: { type: 'boolean' },
closed: { type: 'boolean' },
sort_order: { type: 'float' },
id: { type: 'id', description: 'Unique account identifier' },
name: { type: 'string', description: 'Account name' },
offbudget: {
type: 'boolean',
description: 'True if account is off-budget (tracking)',
},
closed: { type: 'boolean', description: 'True if account is closed' },
sort_order: { type: 'float', description: 'Display sort order' },
},
categories: {
id: { type: 'id' },
name: { type: 'string' },
is_income: { type: 'boolean' },
group_id: { type: 'id', ref: 'category_groups' },
sort_order: { type: 'float' },
hidden: { type: 'boolean' },
'group.name': { type: 'string', ref: 'category_groups' },
id: { type: 'id', description: 'Unique category identifier' },
name: { type: 'string', description: 'Category name' },
is_income: { type: 'boolean', description: 'True for income categories' },
group_id: {
type: 'id',
ref: 'category_groups',
description: 'Category group ID',
},
sort_order: { type: 'float', description: 'Display sort order' },
hidden: { type: 'boolean', description: 'True if category is hidden' },
'group.name': {
type: 'string',
ref: 'category_groups',
description: 'Resolved category group name',
},
},
payees: {
id: { type: 'id' },
name: { type: 'string' },
transfer_acct: { type: 'id', ref: 'accounts' },
id: { type: 'id', description: 'Unique payee identifier' },
name: { type: 'string', description: 'Payee name' },
transfer_acct: {
type: 'id',
ref: 'accounts',
description:
'Linked account ID for transfer payees. Non-null means this payee represents a transfer to/from this account',
},
},
rules: {
id: { type: 'id' },
stage: { type: 'string' },
conditions_op: { type: 'string' },
conditions: { type: 'json' },
actions: { type: 'json' },
id: { type: 'id', description: 'Unique rule identifier' },
stage: { type: 'string', description: 'Rule stage (pre, post, null)' },
conditions_op: {
type: 'string',
description: 'How conditions combine: "and" or "or"',
},
conditions: { type: 'json', description: 'Rule conditions as JSON array' },
actions: { type: 'json', description: 'Rule actions as JSON array' },
},
schedules: {
id: { type: 'id' },
name: { type: 'string' },
rule: { type: 'id', ref: 'rules' },
next_date: { type: 'date' },
completed: { type: 'boolean' },
id: { type: 'id', description: 'Unique schedule identifier' },
name: { type: 'string', description: 'Schedule name' },
rule: {
type: 'id',
ref: 'rules',
description: 'Associated rule ID',
},
next_date: {
type: 'date',
description: 'Next occurrence date (YYYY-MM-DD)',
},
completed: {
type: 'boolean',
description: 'True if schedule is completed',
},
},
};
const FIELD_ALIASES: Record<string, Record<string, string>> = {
transactions: {
payee: 'payee.name',
category: 'category.name',
account: 'account.name',
group: 'category.group.name',
},
categories: {
group: 'group.name',
},
};
export function expandSelectAliases(table: string, fields: string[]): string[] {
const aliases = FIELD_ALIASES[table];
if (!aliases) return fields;
return fields.map(f => aliases[f.trim()] ?? f);
}
const AVAILABLE_TABLES = Object.keys(TABLE_SCHEMA).join(', ');
const LAST_DEFAULT_SELECT = [
@@ -163,7 +258,10 @@ function buildQueryFromFlags(cmdOpts: Record<string, string | undefined>) {
const table =
cmdOpts.table ?? (last !== undefined ? 'transactions' : undefined);
if (!table) {
throw new Error('--table is required (or use --file or --last)');
throw new CliError(
'--table is required (or use --file or --last)',
'Run "actual query tables" to see available tables, or use --last <n> for recent transactions.',
);
}
if (!(table in TABLE_SCHEMA)) {
@@ -180,19 +278,38 @@ function buildQueryFromFlags(cmdOpts: Record<string, string | undefined>) {
throw new Error('--count and --select are mutually exclusive');
}
if (cmdOpts.excludeTransfers && table !== 'transactions') {
throw new Error(
'--exclude-transfers can only be used with --table transactions',
);
}
let queryObj = api.q(table);
if (cmdOpts.count) {
queryObj = queryObj.calculate({ $count: '*' });
} else if (cmdOpts.select) {
queryObj = queryObj.select(cmdOpts.select.split(','));
queryObj = queryObj.select(
expandSelectAliases(table, cmdOpts.select.split(',')),
);
} else if (last !== undefined) {
queryObj = queryObj.select(LAST_DEFAULT_SELECT);
}
const filterStr = cmdOpts.filter ?? cmdOpts.where;
if (filterStr) {
queryObj = queryObj.filter(JSON.parse(filterStr));
try {
queryObj = queryObj.filter(JSON.parse(filterStr));
} catch {
throw new CliError(
'Invalid JSON in --filter.',
`Ensure valid JSON. Example: --filter '{"amount":{"$lt":0}}'`,
);
}
}
if (cmdOpts.excludeTransfers) {
queryObj = queryObj.filter({ transfer_id: { $eq: null } });
}
const orderByStr =
@@ -249,8 +366,15 @@ Examples:
# Pipe query from stdin
echo '{"table":"transactions","limit":5}' | actual query run --file -
# Exclude transfers from results
actual query run --table transactions --exclude-transfers --last 10
# Use shorthand aliases (payee = payee.name, category = category.name)
actual query run --table transactions --select "date,payee,category,amount" --last 10
Available tables: ${AVAILABLE_TABLES}
Use "actual query tables" and "actual query fields <table>" for schema info.
Use "actual query describe" for full schema with all tables, fields, and descriptions.
Common filter operators: $eq, $ne, $lt, $lte, $gt, $gte, $like, $and, $or
See ActualQL docs for full reference: https://actualbudget.org/docs/api/actual-ql/`;
@@ -292,6 +416,11 @@ export function registerQueryCommand(program: Command) {
'--file <path>',
'Read full query object from JSON file (use - for stdin)',
)
.option(
'--exclude-transfers',
'Exclude transfer transactions (only for --table transactions)',
false,
)
.addHelpText('after', RUN_EXAMPLES)
.action(async cmdOpts => {
const opts = program.opts();
@@ -338,7 +467,27 @@ export function registerQueryCommand(program: Command) {
name,
type: info.type,
...(info.ref ? { ref: info.ref } : {}),
...(info.description ? { description: info.description } : {}),
}));
printOutput(fields, opts.format);
});
query
.command('describe')
.description(
'Output full schema for all tables (fields, types, relationships, descriptions)',
)
.action(() => {
const opts = program.opts();
const schema: Record<string, unknown[]> = {};
for (const [table, fields] of Object.entries(TABLE_SCHEMA)) {
schema[table] = Object.entries(fields).map(([name, info]) => ({
name,
type: info.type,
...(info.ref ? { ref: info.ref } : {}),
...(info.description ? { description: info.description } : {}),
}));
}
printOutput(schema, opts.format);
});
}

View File

@@ -0,0 +1,170 @@
import * as api from '@actual-app/api';
import { Command } from 'commander';
import { printOutput } from '../output';
import { registerTransactionsCommand } from './transactions';
vi.mock('@actual-app/api', () => {
const queryObj = {
select: vi.fn().mockReturnThis(),
filter: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
offset: vi.fn().mockReturnThis(),
groupBy: vi.fn().mockReturnThis(),
calculate: vi.fn().mockReturnThis(),
};
return {
q: vi.fn().mockReturnValue(queryObj),
aqlQuery: vi.fn().mockResolvedValue({ data: [] }),
addTransactions: vi.fn().mockResolvedValue([]),
importTransactions: vi.fn().mockResolvedValue({ added: [], updated: [] }),
updateTransaction: vi.fn().mockResolvedValue(undefined),
deleteTransaction: vi.fn().mockResolvedValue(undefined),
};
});
vi.mock('../connection', () => ({
withConnection: vi.fn((_opts, fn) => fn()),
}));
vi.mock('../output', () => ({
printOutput: vi.fn(),
}));
function createProgram(): Command {
const program = new Command();
program.option('--format <format>');
program.option('--server-url <url>');
program.option('--password <pw>');
program.option('--session-token <token>');
program.option('--sync-id <id>');
program.option('--data-dir <dir>');
program.option('--verbose');
program.exitOverride();
registerTransactionsCommand(program);
return program;
}
async function run(args: string[]) {
const program = createProgram();
await program.parseAsync(['node', 'test', ...args]);
}
function getQueryObj() {
return vi.mocked(api.q).mock.results[0]?.value;
}
describe('transactions list', () => {
let stderrSpy: ReturnType<typeof vi.spyOn>;
let stdoutSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
stderrSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
stdoutSpy = vi
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);
});
afterEach(() => {
stderrSpy.mockRestore();
stdoutSpy.mockRestore();
});
it('uses AQL query with resolved field names', async () => {
await run([
'transactions',
'list',
'--account',
'acc-1',
'--start',
'2025-01-01',
'--end',
'2025-01-31',
]);
expect(api.q).toHaveBeenCalledWith('transactions');
const qObj = getQueryObj();
expect(qObj.select).toHaveBeenCalledWith([
'*',
'account.name',
'payee.name',
'category.name',
]);
expect(qObj.filter).toHaveBeenCalledWith({
account: 'acc-1',
date: { $gte: '2025-01-01', $lte: '2025-01-31' },
});
expect(qObj.orderBy).toHaveBeenCalledWith([{ date: 'desc' }]);
});
it('defaults --start to 30 days before --end', async () => {
await run([
'transactions',
'list',
'--account',
'acc-1',
'--end',
'2025-02-28',
]);
const qObj = getQueryObj();
expect(qObj.filter).toHaveBeenCalledWith({
account: 'acc-1',
date: { $gte: '2025-01-29', $lte: '2025-02-28' },
});
});
it('defaults both --start and --end when omitted', async () => {
await run(['transactions', 'list', '--account', 'acc-1']);
const qObj = getQueryObj();
const filterCall = qObj.filter.mock.calls[0][0];
expect(filterCall.account).toBe('acc-1');
expect(filterCall.date.$gte).toBeDefined();
expect(filterCall.date.$lte).toBeDefined();
});
it('excludes transfers when --exclude-transfers is set', async () => {
await run([
'transactions',
'list',
'--account',
'acc-1',
'--start',
'2025-01-01',
'--end',
'2025-01-31',
'--exclude-transfers',
]);
const qObj = getQueryObj();
expect(qObj.filter).toHaveBeenCalledWith({
account: 'acc-1',
date: { $gte: '2025-01-01', $lte: '2025-01-31' },
transfer_id: { $eq: null },
});
});
it('outputs result.data from AQL query', async () => {
const mockData = [{ id: 't1', amount: -500 }];
vi.mocked(api.aqlQuery).mockResolvedValueOnce({ data: mockData });
await run([
'transactions',
'list',
'--account',
'acc-1',
'--start',
'2025-01-01',
'--end',
'2025-01-31',
]);
expect(printOutput).toHaveBeenCalledWith(mockData, undefined);
});
});

View File

@@ -4,6 +4,7 @@ import type { Command } from 'commander';
import { withConnection } from '../connection';
import { readJsonInput } from '../input';
import { printOutput } from '../output';
import { defaultDateRange } from '../utils';
export function registerTransactionsCommand(program: Command) {
const transactions = program
@@ -14,17 +15,32 @@ export function registerTransactionsCommand(program: Command) {
.command('list')
.description('List transactions for an account')
.requiredOption('--account <id>', 'Account ID')
.requiredOption('--start <date>', 'Start date (YYYY-MM-DD)')
.requiredOption('--end <date>', 'End date (YYYY-MM-DD)')
.option(
'--start <date>',
'Start date (YYYY-MM-DD, defaults to 30 days ago)',
)
.option('--end <date>', 'End date (YYYY-MM-DD, defaults to today)')
.option('--exclude-transfers', 'Exclude transfer transactions', false)
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const result = await api.getTransactions(
cmdOpts.account,
cmdOpts.start,
cmdOpts.end,
);
printOutput(result, opts.format);
const { start, end } = defaultDateRange(cmdOpts.start, cmdOpts.end);
const filter: Record<string, unknown> = {
account: cmdOpts.account,
date: { $gte: start, $lte: end },
};
if (cmdOpts.excludeTransfers) {
filter.transfer_id = { $eq: null };
}
const queryObj = api
.q('transactions')
.select(['*', 'account.name', 'payee.name', 'category.name'])
.filter(filter)
.orderBy([{ date: 'desc' }]);
const result = await api.aqlQuery(queryObj);
printOutput(result.data, opts.format);
});
});

View File

@@ -11,6 +11,7 @@ import { registerSchedulesCommand } from './commands/schedules';
import { registerServerCommand } from './commands/server';
import { registerTagsCommand } from './commands/tags';
import { registerTransactionsCommand } from './commands/transactions';
import { CliError } from './utils';
declare const __CLI_VERSION__: string;
@@ -66,5 +67,8 @@ function normalizeThrownMessage(err: unknown): string {
program.parseAsync(process.argv).catch((err: unknown) => {
const message = normalizeThrownMessage(err);
process.stderr.write(`Error: ${message}\n`);
if (err instanceof CliError && err.suggestion) {
process.stderr.write(`Suggestion: ${err.suggestion}\n`);
}
process.exitCode = 1;
});

View File

@@ -60,6 +60,22 @@ describe('formatOutput', () => {
expect(result).toContain('a');
expect(result).toContain('b');
});
it('flattens nested objects instead of showing [object Object]', () => {
const data = [{ payee: { id: 'p1', name: 'Grocery' } }];
const result = formatOutput(data, 'table');
expect(result).toContain('Grocery');
expect(result).not.toContain('[object Object]');
});
it('flattens nested objects in key-value table', () => {
const result = formatOutput(
{ payee: { id: 'p1', name: 'Grocery' } },
'table',
);
expect(result).toContain('Grocery');
expect(result).not.toContain('[object Object]');
});
});
describe('csv', () => {
@@ -112,6 +128,24 @@ describe('formatOutput', () => {
const lines = result.split('\n');
expect(lines[0]).toBe('a,b');
});
it('flattens nested objects instead of showing [object Object]', () => {
const data = [{ payee: { id: 'p1', name: 'Grocery' } }];
const result = formatOutput(data, 'csv');
const lines = result.split('\n');
expect(lines[0]).toBe('payee');
expect(lines[1]).toContain('Grocery');
expect(lines[1]).not.toContain('[object Object]');
});
it('flattens nested objects in single-object csv', () => {
const result = formatOutput(
{ payee: { id: 'p1', name: 'Grocery' } },
'csv',
);
expect(result).toContain('Grocery');
expect(result).not.toContain('[object Object]');
});
});
});

View File

@@ -2,6 +2,12 @@ import Table from 'cli-table3';
export type OutputFormat = 'json' | 'table' | 'csv';
function flattenValue(value: unknown): string {
if (value === null || value === undefined) return '';
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
}
export function formatOutput(
data: unknown,
format: OutputFormat = 'json',
@@ -23,7 +29,7 @@ function formatTable(data: unknown): string {
if (data && typeof data === 'object') {
const table = new Table();
for (const [key, value] of Object.entries(data)) {
table.push({ [key]: String(value) });
table.push({ [key]: flattenValue(value) });
}
return table.toString();
}
@@ -39,7 +45,7 @@ function formatTable(data: unknown): string {
for (const row of data) {
const r = row as Record<string, unknown>;
table.push(keys.map(k => String(r[k] ?? '')));
table.push(keys.map(k => flattenValue(r[k])));
}
return table.toString();
@@ -50,7 +56,9 @@ function formatCsv(data: unknown): string {
if (data && typeof data === 'object') {
const entries = Object.entries(data);
const header = entries.map(([k]) => escapeCsv(k)).join(',');
const values = entries.map(([, v]) => escapeCsv(String(v))).join(',');
const values = entries
.map(([, v]) => escapeCsv(flattenValue(v)))
.join(',');
return header + '\n' + values;
}
return String(data);
@@ -64,7 +72,7 @@ function formatCsv(data: unknown): string {
const header = keys.map(k => escapeCsv(k)).join(',');
const rows = data.map(row => {
const r = row as Record<string, unknown>;
return keys.map(k => escapeCsv(String(r[k] ?? ''))).join(',');
return keys.map(k => escapeCsv(flattenValue(r[k]))).join(',');
});
return [header, ...rows].join('\n');

View File

@@ -1,4 +1,56 @@
import { parseBoolFlag, parseIntFlag } from './utils';
import {
CliError,
defaultDateRange,
parseBoolFlag,
parseIntFlag,
} from './utils';
describe('CliError', () => {
it('stores message and suggestion', () => {
const err = new CliError('something failed', 'try this instead');
expect(err.message).toBe('something failed');
expect(err.suggestion).toBe('try this instead');
expect(err).toBeInstanceOf(Error);
});
it('works without suggestion', () => {
const err = new CliError('something failed');
expect(err.message).toBe('something failed');
expect(err.suggestion).toBeUndefined();
});
});
describe('defaultDateRange', () => {
it('returns both dates when both provided', () => {
expect(defaultDateRange('2025-01-01', '2025-01-31')).toEqual({
start: '2025-01-01',
end: '2025-01-31',
});
});
it('defaults start to 30 days before end', () => {
expect(defaultDateRange(undefined, '2025-02-28')).toEqual({
start: '2025-01-29',
end: '2025-02-28',
});
});
it('defaults end to today when only start provided', () => {
const result = defaultDateRange('2025-01-01');
expect(result.start).toBe('2025-01-01');
expect(result.end).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
it('defaults both to last 30 days when neither provided', () => {
const result = defaultDateRange();
expect(result.start).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(result.end).toMatch(/^\d{4}-\d{2}-\d{2}$/);
const startDate = new Date(result.start);
const endDate = new Date(result.end);
const diffDays = (endDate.getTime() - startDate.getTime()) / 86400000;
expect(diffDays).toBe(30);
});
});
describe('parseBoolFlag', () => {
it('parses "true"', () => {

View File

@@ -1,3 +1,22 @@
export function defaultDateRange(
start?: string,
end?: string,
): { start: string; end: string } {
const endDate = end ?? new Date().toLocaleDateString('en-CA');
if (start) return { start, end: endDate };
const d = new Date(endDate + 'T00:00:00');
d.setDate(d.getDate() - 30);
return { start: d.toLocaleDateString('en-CA'), end: endDate };
}
export class CliError extends Error {
suggestion?: string;
constructor(message: string, suggestion?: string) {
super(message);
this.suggestion = suggestion;
}
}
export function parseBoolFlag(value: string, flagName: string): boolean {
if (value !== 'true' && value !== 'false') {
throw new Error(