Compare commits

...

2 Commits

Author SHA1 Message Date
github-actions[bot]
88a8729071 [AI] Simplify CLI cache/lock internals
Use a discriminated SyncDecision union so connection.ts no longer needs
non-null assertions on the cached state. Thread the resolved CliConfig
through withConnection's callback to drop duplicate resolveConfig calls
in the sync and budgets commands. Extract an errorCode helper and
replace the existsSync+readdirSync TOCTOU pattern in the reader-wait
polling loop with a single readdir that tolerates ENOENT.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:13:50 +01:00
github-actions[bot]
85d601a707 [AI] Cache the CLI's local budget between invocations
Every `actual <cmd>` call currently delta-syncs the budget with the
sync server via `api.downloadBudget`, which hits the server's
500-req/min rate limit on scripted workflows. Actual is local-first:
once the budget is on disk, most read commands do not need fresh
server data.

Introduce a CLI-only cache layer inside `withConnection` that
decides per invocation whether to skip, sync, or re-download:

- Cache state lives at `{dataDir}/.actual-cli/{syncId}/state.json`,
  keyed by `syncId` to avoid the chicken-and-egg of not knowing the
  on-disk `budgetId` before the first download. The on-disk id is
  resolved via `api.getBudgets()` and persisted after first download.
- Read commands (list, balance, query run, …) skip the `/sync`
  call while `now - lastSyncedAt < cacheTtl`. Write commands
  (create, update, delete, set-*, etc.) sync before and after the
  operation to keep server state consistent.
- Encrypted budgets force a sync per call since `api/load-budget`
  does not re-verify the password.
- New `proper-lockfile`-backed shared/exclusive lock serializes
  writes while allowing parallel reads. Reader markers live in
  `{meta}/readers/`; writers sweep stale markers by PID.

New `actual sync` command with three modes: default (sync now),
`--status` (print cache age, TTL, stale flag), `--clear` (delete
cache, holding the exclusive lock to avoid racing writers).

New config surface, following the existing flag → env → config file
→ default precedence chain:

- `--cache-ttl <s>` / `ACTUAL_CACHE_TTL` / `cacheTtl` (default 60)
- `--refresh` / `--no-cache`
- `--lock-timeout <s>` / `ACTUAL_LOCK_TIMEOUT` / `lockTimeout` (10)
- `--no-lock` / `ACTUAL_NO_LOCK` / `noLock`

Every `withConnection` call site now passes an explicit
`{ mutates: boolean, skipBudget?: boolean }` so read/write intent is
visible at the edge.

The old `budgets sync` subcommand is removed — it silently diverged
from the new top-level `actual sync`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:03:40 +01:00
28 changed files with 1918 additions and 410 deletions

View File

@@ -43,13 +43,16 @@ Configuration is resolved in this order (highest priority first):
### Environment Variables
| Variable | Description |
| ---------------------- | --------------------------------------------- |
| `ACTUAL_SERVER_URL` | URL of the Actual sync server (required) |
| `ACTUAL_PASSWORD` | Server password (required unless using token) |
| `ACTUAL_SESSION_TOKEN` | Session token (alternative to password) |
| `ACTUAL_SYNC_ID` | Budget Sync ID (required for most commands) |
| `ACTUAL_DATA_DIR` | Local directory for cached budget data |
| Variable | Description |
| ---------------------- | ----------------------------------------------------- |
| `ACTUAL_SERVER_URL` | URL of the Actual sync server (required) |
| `ACTUAL_PASSWORD` | Server password (required unless using token) |
| `ACTUAL_SESSION_TOKEN` | Session token (alternative to password) |
| `ACTUAL_SYNC_ID` | Budget Sync ID (required for most commands) |
| `ACTUAL_DATA_DIR` | Local directory for cached budget data |
| `ACTUAL_CACHE_TTL` | Cache TTL in seconds (default: 60) |
| `ACTUAL_LOCK_TIMEOUT` | Budget-dir lock wait timeout in seconds (default: 10) |
| `ACTUAL_NO_LOCK` | Set to `1` to disable budget-dir locking |
### Config File
@@ -59,7 +62,10 @@ Create an `.actualrc.json` (or `.actualrc`, `.actualrc.yaml`, `actual.config.js`
{
"serverUrl": "http://localhost:5006",
"password": "your-password",
"syncId": "1cfdbb80-6274-49bf-b0c2-737235a4c81f"
"syncId": "1cfdbb80-6274-49bf-b0c2-737235a4c81f",
"cacheTtl": 60,
"lockTimeout": 10,
"noLock": false
}
```
@@ -74,6 +80,11 @@ Create an `.actualrc.json` (or `.actualrc`, `.actualrc.yaml`, `actual.config.js`
| `--session-token <token>` | Session token |
| `--sync-id <id>` | Budget Sync ID |
| `--data-dir <path>` | Data directory |
| `--cache-ttl <seconds>` | Cache TTL; `0` disables caching (default: 60) |
| `--refresh` | Force a sync on this call, ignoring the cache |
| `--no-cache` | Alias for `--refresh` |
| `--lock-timeout <secs>` | Lock wait timeout (default: 10) |
| `--no-lock` | Disable budget-dir locking (use with care) |
| `--format <format>` | Output format: `json` (default), `table`, `csv` |
| `--verbose` | Show informational messages |
@@ -92,6 +103,7 @@ Create an `.actualrc.json` (or `.actualrc`, `.actualrc.yaml`, `actual.config.js`
| `schedules` | Manage scheduled transactions |
| `query` | Run an ActualQL query |
| `server` | Server utilities and lookups |
| `sync` | Refresh or inspect local cache |
Run `actual <command> --help` for subcommands and options.
@@ -135,22 +147,32 @@ All monetary amounts are **integer cents** when passed as input (flags, JSON):
- **Split transactions:** When summing or counting transactions, filter `"is_parent": false` to avoid double-counting. A split parent holds the total amount, and its children hold the individual parts — including both would count the total twice.
- **Avoid rapid sequential requests:** Each CLI invocation opens a new server connection. Running queries in a tight loop (e.g. one per month) may trigger rate limiting or authentication failures. Instead, fetch all data in a single query with a date range filter and process locally:
- **Rapid sequential requests:** The CLI caches the budget locally (see [Caching](#caching)), so read-heavy scripts no longer need a single-query workaround by default. For very chatty scripts, run `actual sync` once and then use a long `--cache-ttl` for reads:
```bash
# Good: single query for the full year
actual query run --table transactions \
--filter '{"$and":[{"date":{"$gte":"2025-01-01"}},{"date":{"$lte":"2025-12-31"}}]}' \
--limit 5000
# Bad: one query per month in a loop (may fail with auth errors)
for month in 01 02 03 ...; do actual query run ...; done
actual sync
actual --cache-ttl 3600 query run ...
actual --cache-ttl 3600 accounts list
```
- **Uncategorized transactions:** `category.name` is `null` for transactions without a category. Account for this when filtering or grouping by category.
- **No date sub-fields in AQL:** `date.month`, `date.year`, etc. are not supported as query fields. To group by month, fetch raw transactions with a date range filter and aggregate locally in a script.
## Caching
The CLI keeps a local copy of your budget so repeated commands don't hit the sync server on every call. Within the TTL (default `60` seconds), read commands (`list`, `balance`, `query run`, …) reuse the cached budget without a network round-trip. Write commands (`add`, `update`, `set-amount`, …) always sync with the server before and after the write.
- `actual sync` — refresh the cache now.
- `actual sync --status` — show how stale the local cache is.
- `actual sync --clear` — delete the local cache; the next command re-downloads.
- `--refresh` (or `--no-cache`) — force a sync on a single call.
- `--cache-ttl <seconds>` — override the TTL for a single call (use `0` to disable caching).
### Concurrency
The CLI takes a shared lock for reads and an exclusive lock for writes on the per-budget cache directory. Many parallel reads are safe; writes serialize. If another CLI process is holding the lock, subsequent invocations wait up to `--lock-timeout` seconds (default `10`) before failing with an error. Pass `--no-lock` to opt out in trusted single-process setups.
## Running Locally (Development)
If you're working on the CLI within the monorepo:

View File

@@ -12,10 +12,12 @@
],
"type": "module",
"imports": {
"#cache": "./src/cache.ts",
"#commands/*": "./src/commands/*.ts",
"#config": "./src/config.ts",
"#connection": "./src/connection.ts",
"#input": "./src/input.ts",
"#lock": "./src/lock.ts",
"#output": "./src/output.ts",
"#utils": "./src/utils.ts"
},
@@ -28,10 +30,12 @@
"@actual-app/api": "workspace:*",
"cli-table3": "^0.6.5",
"commander": "^14.0.3",
"cosmiconfig": "^9.0.1"
"cosmiconfig": "^9.0.1",
"proper-lockfile": "^4.1.2"
},
"devDependencies": {
"@types/node": "^22.19.17",
"@types/proper-lockfile": "^4",
"@typescript/native-preview": "^7.0.0-dev.20260404.1",
"rollup-plugin-visualizer": "^7.0.1",
"vite": "^8.0.5",

View File

@@ -0,0 +1,206 @@
import {
existsSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
CACHE_FILE_NAME,
decideSyncAction,
readCacheState,
writeCacheState,
} from './cache';
describe('readCacheState', () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'actual-cli-cache-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it('returns null when the file does not exist', () => {
expect(readCacheState(dir)).toBeNull();
});
it('returns null when the file is corrupt', () => {
writeFileSync(join(dir, CACHE_FILE_NAME), 'not json');
expect(readCacheState(dir)).toBeNull();
});
it('returns null when the file has the wrong version', () => {
writeFileSync(
join(dir, CACHE_FILE_NAME),
JSON.stringify({
version: 999,
syncId: 'a',
budgetId: 'b',
serverUrl: 'c',
lastSyncedAt: 1,
lastDownloadedAt: 1,
}),
);
expect(readCacheState(dir)).toBeNull();
});
it('returns the parsed state when the file is valid', () => {
writeFileSync(
join(dir, CACHE_FILE_NAME),
JSON.stringify({
version: 1,
syncId: 'a',
budgetId: 'b',
serverUrl: 'c',
lastSyncedAt: 1234,
lastDownloadedAt: 5678,
}),
);
expect(readCacheState(dir)).toEqual({
version: 1,
syncId: 'a',
budgetId: 'b',
serverUrl: 'c',
lastSyncedAt: 1234,
lastDownloadedAt: 5678,
});
});
});
describe('writeCacheState', () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'actual-cli-cache-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it('writes the state to the cache file', () => {
writeCacheState(dir, {
version: 1,
syncId: 'a',
budgetId: 'b',
serverUrl: 'c',
lastSyncedAt: 1,
lastDownloadedAt: 1,
});
const raw = readFileSync(join(dir, CACHE_FILE_NAME), 'utf-8');
expect(JSON.parse(raw).syncId).toBe('a');
});
it('is atomic: removes the tmp file after rename', () => {
writeCacheState(dir, {
version: 1,
syncId: 'a',
budgetId: 'b',
serverUrl: 'c',
lastSyncedAt: 1,
lastDownloadedAt: 1,
});
expect(existsSync(join(dir, `${CACHE_FILE_NAME}.tmp`))).toBe(false);
});
it('does not throw when the filesystem refuses the write', () => {
// Force ENOTDIR by pointing writeCacheState at a path whose parent is a
// regular file — no OS-specific pseudo-filesystem semantics needed.
const file = join(dir, 'not-a-dir');
writeFileSync(file, '');
expect(() =>
writeCacheState(join(file, 'nested'), {
version: 1,
syncId: 'a',
budgetId: 'b',
serverUrl: 'c',
lastSyncedAt: 1,
lastDownloadedAt: 1,
}),
).not.toThrow();
});
});
describe('decideSyncAction', () => {
const base = {
state: {
version: 1 as const,
syncId: 'sync-1',
budgetId: 'bud-1',
serverUrl: 'http://s',
lastSyncedAt: 1_000_000,
lastDownloadedAt: 1_000_000,
},
config: { syncId: 'sync-1', serverUrl: 'http://s' },
now: 1_000_000,
ttlMs: 60_000,
mutates: false,
refresh: false,
encrypted: false,
};
it('returns "download" when state is null', () => {
expect(decideSyncAction({ ...base, state: null }).action).toBe('download');
});
it('returns "download" when syncId changed', () => {
expect(
decideSyncAction({
...base,
config: { ...base.config, syncId: 'other' },
}).action,
).toBe('download');
});
it('returns "download" when serverUrl changed', () => {
expect(
decideSyncAction({
...base,
config: { ...base.config, serverUrl: 'http://other' },
}).action,
).toBe('download');
});
it('returns "skip" for a read within the TTL', () => {
expect(decideSyncAction({ ...base, now: 1_000_000 + 30_000 }).action).toBe(
'skip',
);
});
it('returns "sync" for a read past the TTL', () => {
expect(decideSyncAction({ ...base, now: 1_000_000 + 61_000 }).action).toBe(
'sync',
);
});
it('returns "sync" for a write even when fresh', () => {
expect(decideSyncAction({ ...base, mutates: true }).action).toBe('sync');
});
it('returns "sync" when refresh is true', () => {
expect(decideSyncAction({ ...base, refresh: true }).action).toBe('sync');
});
it('returns "sync" when ttlMs is 0', () => {
expect(decideSyncAction({ ...base, ttlMs: 0 }).action).toBe('sync');
});
it('returns "sync" for encrypted budgets within the TTL', () => {
expect(decideSyncAction({ ...base, encrypted: true }).action).toBe('sync');
});
it('treats clock skew (negative age) as stale', () => {
expect(decideSyncAction({ ...base, now: 999_999 }).action).toBe('sync');
});
it('carries cached state on non-download actions', () => {
const decision = decideSyncAction({ ...base, mutates: true });
expect(decision).toEqual({ action: 'sync', state: base.state });
});
});

102
packages/cli/src/cache.ts Normal file
View File

@@ -0,0 +1,102 @@
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { isRecord } from './utils';
export const CACHE_FILE_NAME = 'state.json';
export const CACHE_VERSION = 1;
export const META_ROOT_DIR = '.actual-cli';
export type CacheState = {
version: typeof CACHE_VERSION;
syncId: string;
budgetId: string;
serverUrl: string;
lastSyncedAt: number;
lastDownloadedAt: number;
};
export function getMetaDir(dataDir: string, syncId: string): string {
return join(dataDir, META_ROOT_DIR, syncId);
}
function cachePath(metaDir: string): string {
return join(metaDir, CACHE_FILE_NAME);
}
function isCacheState(value: unknown): value is CacheState {
if (!isRecord(value)) return false;
return (
value.version === CACHE_VERSION &&
typeof value.syncId === 'string' &&
typeof value.budgetId === 'string' &&
typeof value.serverUrl === 'string' &&
typeof value.lastSyncedAt === 'number' &&
typeof value.lastDownloadedAt === 'number'
);
}
export function readCacheState(metaDir: string): CacheState | null {
let raw: string;
try {
raw = readFileSync(cachePath(metaDir), 'utf-8');
} catch {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
return isCacheState(parsed) ? parsed : null;
}
export function writeCacheState(metaDir: string, state: CacheState): void {
try {
mkdirSync(metaDir, { recursive: true });
const target = cachePath(metaDir);
const tmp = `${target}.tmp`;
writeFileSync(tmp, JSON.stringify(state));
renameSync(tmp, target);
} catch {
// Cache persistence is best-effort. A read-only or unreachable dir must
// not crash the CLI; the next invocation simply won't find a cache.
}
}
export type SyncDecision =
| { action: 'download' }
| { action: 'skip'; state: CacheState }
| { action: 'sync'; state: CacheState };
export type DecideSyncArgs = {
state: CacheState | null;
config: { syncId: string; serverUrl: string };
now: number;
ttlMs: number;
mutates: boolean;
refresh: boolean;
encrypted: boolean;
};
export function decideSyncAction({
state,
config,
now,
ttlMs,
mutates,
refresh,
encrypted,
}: DecideSyncArgs): SyncDecision {
if (state === null) return { action: 'download' };
if (state.syncId !== config.syncId) return { action: 'download' };
if (state.serverUrl !== config.serverUrl) return { action: 'download' };
if (mutates || refresh || ttlMs === 0 || encrypted) {
return { action: 'sync', state };
}
const age = now - state.lastSyncedAt;
if (age < 0) return { action: 'sync', state };
if (age < ttlMs) return { action: 'skip', state };
return { action: 'sync', state };
}

View File

@@ -14,26 +14,30 @@ export function registerAccountsCommand(program: Command) {
.option('--include-closed', 'Include closed accounts', false)
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const allAccounts = await api.getAccounts();
const accounts = allAccounts.filter(
a => cmdOpts.includeClosed || !a.closed,
);
// Stable sort: on-budget first, off-budget second
// (preserves API sort_order within each group)
accounts.sort((a, b) => Number(a.offbudget) - Number(b.offbudget));
const balances = await Promise.all(
accounts.map(a => api.getAccountBalance(a.id)),
);
const output = accounts.map((a, i) => ({
id: a.id,
name: a.name,
offbudget: a.offbudget,
closed: a.closed,
balance: balances[i],
}));
printOutput(output, opts.format);
});
await withConnection(
opts,
async () => {
const allAccounts = await api.getAccounts();
const accounts = allAccounts.filter(
a => cmdOpts.includeClosed || !a.closed,
);
// Stable sort: on-budget first, off-budget second
// (preserves API sort_order within each group)
accounts.sort((a, b) => Number(a.offbudget) - Number(b.offbudget));
const balances = await Promise.all(
accounts.map(a => api.getAccountBalance(a.id)),
);
const output = accounts.map((a, i) => ({
id: a.id,
name: a.name,
offbudget: a.offbudget,
closed: a.closed,
balance: balances[i],
}));
printOutput(output, opts.format);
},
{ mutates: false },
);
});
accounts
@@ -49,13 +53,17 @@ export function registerAccountsCommand(program: Command) {
.action(async cmdOpts => {
const balance = parseIntFlag(cmdOpts.balance, '--balance');
const opts = program.opts();
await withConnection(opts, async () => {
const id = await api.createAccount(
{ name: cmdOpts.name, offbudget: cmdOpts.offbudget },
balance,
);
printOutput({ id }, opts.format);
});
await withConnection(
opts,
async () => {
const id = await api.createAccount(
{ name: cmdOpts.name, offbudget: cmdOpts.offbudget },
balance,
);
printOutput({ id }, opts.format);
},
{ mutates: true },
);
});
accounts
@@ -81,10 +89,14 @@ export function registerAccountsCommand(program: Command) {
'No update fields provided. Use --name or --offbudget.',
);
}
await withConnection(opts, async () => {
await api.updateAccount(id, fields);
printOutput({ success: true, id }, opts.format);
});
await withConnection(
opts,
async () => {
await api.updateAccount(id, fields);
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
});
accounts
@@ -100,14 +112,18 @@ export function registerAccountsCommand(program: Command) {
)
.action(async (id: string, cmdOpts) => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.closeAccount(
id,
cmdOpts.transferAccount,
cmdOpts.transferCategory,
);
printOutput({ success: true, id }, opts.format);
});
await withConnection(
opts,
async () => {
await api.closeAccount(
id,
cmdOpts.transferAccount,
cmdOpts.transferCategory,
);
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
});
accounts
@@ -115,10 +131,14 @@ export function registerAccountsCommand(program: Command) {
.description('Reopen a closed account')
.action(async (id: string) => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.reopenAccount(id);
printOutput({ success: true, id }, opts.format);
});
await withConnection(
opts,
async () => {
await api.reopenAccount(id);
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
});
accounts
@@ -126,10 +146,14 @@ export function registerAccountsCommand(program: Command) {
.description('Delete an account')
.action(async (id: string) => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.deleteAccount(id);
printOutput({ success: true, id }, opts.format);
});
await withConnection(
opts,
async () => {
await api.deleteAccount(id);
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
});
accounts
@@ -148,9 +172,13 @@ export function registerAccountsCommand(program: Command) {
cutoff = cutoffDate;
}
const opts = program.opts();
await withConnection(opts, async () => {
const balance = await api.getAccountBalance(id, cutoff);
printOutput({ id, balance }, opts.format);
});
await withConnection(
opts,
async () => {
const balance = await api.getAccountBalance(id, cutoff);
printOutput({ id, balance }, opts.format);
},
{ mutates: false },
);
});
}

View File

@@ -1,7 +1,6 @@
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 { parseBoolFlag, parseIntFlag } from '#utils';
@@ -20,7 +19,7 @@ export function registerBudgetsCommand(program: Command) {
const result = await api.getBudgets();
printOutput(result, opts.format);
},
{ loadBudget: false },
{ mutates: false, skipBudget: true },
);
});
@@ -30,40 +29,33 @@ 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 () => {
async config => {
const password =
config.encryptionPassword ?? cmdOpts.encryptionPassword;
await api.downloadBudget(syncId, {
password,
});
printOutput({ success: true, syncId }, opts.format);
},
{ loadBudget: false },
{ mutates: false, skipBudget: true },
);
});
budgets
.command('sync')
.description('Sync the current budget')
.action(async () => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.sync();
printOutput({ success: true }, opts.format);
});
});
budgets
.command('months')
.description('List available budget months')
.action(async () => {
const opts = program.opts();
await withConnection(opts, async () => {
const result = await api.getBudgetMonths();
printOutput(result, opts.format);
});
await withConnection(
opts,
async () => {
const result = await api.getBudgetMonths();
printOutput(result, opts.format);
},
{ mutates: false },
);
});
budgets
@@ -71,10 +63,14 @@ export function registerBudgetsCommand(program: Command) {
.description('Get budget data for a specific month (YYYY-MM)')
.action(async (month: string) => {
const opts = program.opts();
await withConnection(opts, async () => {
const result = await api.getBudgetMonth(month);
printOutput(result, opts.format);
});
await withConnection(
opts,
async () => {
const result = await api.getBudgetMonth(month);
printOutput(result, opts.format);
},
{ mutates: false },
);
});
budgets
@@ -89,10 +85,14 @@ export function registerBudgetsCommand(program: Command) {
.action(async cmdOpts => {
const amount = parseIntFlag(cmdOpts.amount, '--amount');
const opts = program.opts();
await withConnection(opts, async () => {
await api.setBudgetAmount(cmdOpts.month, cmdOpts.category, amount);
printOutput({ success: true }, opts.format);
});
await withConnection(
opts,
async () => {
await api.setBudgetAmount(cmdOpts.month, cmdOpts.category, amount);
printOutput({ success: true }, opts.format);
},
{ mutates: true },
);
});
budgets
@@ -104,10 +104,14 @@ export function registerBudgetsCommand(program: Command) {
.action(async cmdOpts => {
const flag = parseBoolFlag(cmdOpts.flag, '--flag');
const opts = program.opts();
await withConnection(opts, async () => {
await api.setBudgetCarryover(cmdOpts.month, cmdOpts.category, flag);
printOutput({ success: true }, opts.format);
});
await withConnection(
opts,
async () => {
await api.setBudgetCarryover(cmdOpts.month, cmdOpts.category, flag);
printOutput({ success: true }, opts.format);
},
{ mutates: true },
);
});
budgets
@@ -121,10 +125,14 @@ export function registerBudgetsCommand(program: Command) {
.action(async cmdOpts => {
const parsedAmount = parseIntFlag(cmdOpts.amount, '--amount');
const opts = program.opts();
await withConnection(opts, async () => {
await api.holdBudgetForNextMonth(cmdOpts.month, parsedAmount);
printOutput({ success: true }, opts.format);
});
await withConnection(
opts,
async () => {
await api.holdBudgetForNextMonth(cmdOpts.month, parsedAmount);
printOutput({ success: true }, opts.format);
},
{ mutates: true },
);
});
budgets
@@ -133,9 +141,13 @@ export function registerBudgetsCommand(program: Command) {
.requiredOption('--month <month>', 'Budget month (YYYY-MM)')
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.resetBudgetHold(cmdOpts.month);
printOutput({ success: true }, opts.format);
});
await withConnection(
opts,
async () => {
await api.resetBudgetHold(cmdOpts.month);
printOutput({ success: true }, opts.format);
},
{ mutates: true },
);
});
}

View File

@@ -15,10 +15,14 @@ export function registerCategoriesCommand(program: Command) {
.description('List all categories')
.action(async () => {
const opts = program.opts();
await withConnection(opts, async () => {
const result = await api.getCategories();
printOutput(result, opts.format);
});
await withConnection(
opts,
async () => {
const result = await api.getCategories();
printOutput(result, opts.format);
},
{ mutates: false },
);
});
categories
@@ -29,15 +33,19 @@ export function registerCategoriesCommand(program: Command) {
.option('--is-income', 'Mark as income category', false)
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const id = await api.createCategory({
name: cmdOpts.name,
group_id: cmdOpts.groupId,
is_income: cmdOpts.isIncome,
hidden: false,
});
printOutput({ id }, opts.format);
});
await withConnection(
opts,
async () => {
const id = await api.createCategory({
name: cmdOpts.name,
group_id: cmdOpts.groupId,
is_income: cmdOpts.isIncome,
hidden: false,
});
printOutput({ id }, opts.format);
},
{ mutates: true },
);
});
categories
@@ -55,10 +63,14 @@ export function registerCategoriesCommand(program: Command) {
throw new Error('No update fields provided. Use --name or --hidden.');
}
const opts = program.opts();
await withConnection(opts, async () => {
await api.updateCategory(id, fields);
printOutput({ success: true, id }, opts.format);
});
await withConnection(
opts,
async () => {
await api.updateCategory(id, fields);
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
});
categories
@@ -67,9 +79,13 @@ export function registerCategoriesCommand(program: Command) {
.option('--transfer-to <id>', 'Transfer transactions to this category')
.action(async (id: string, cmdOpts) => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.deleteCategory(id, cmdOpts.transferTo);
printOutput({ success: true, id }, opts.format);
});
await withConnection(
opts,
async () => {
await api.deleteCategory(id, cmdOpts.transferTo);
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
});
}

View File

@@ -15,10 +15,14 @@ export function registerCategoryGroupsCommand(program: Command) {
.description('List all category groups')
.action(async () => {
const opts = program.opts();
await withConnection(opts, async () => {
const result = await api.getCategoryGroups();
printOutput(result, opts.format);
});
await withConnection(
opts,
async () => {
const result = await api.getCategoryGroups();
printOutput(result, opts.format);
},
{ mutates: false },
);
});
groups
@@ -28,14 +32,18 @@ export function registerCategoryGroupsCommand(program: Command) {
.option('--is-income', 'Mark as income group', false)
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const id = await api.createCategoryGroup({
name: cmdOpts.name,
is_income: cmdOpts.isIncome,
hidden: false,
});
printOutput({ id }, opts.format);
});
await withConnection(
opts,
async () => {
const id = await api.createCategoryGroup({
name: cmdOpts.name,
is_income: cmdOpts.isIncome,
hidden: false,
});
printOutput({ id }, opts.format);
},
{ mutates: true },
);
});
groups
@@ -53,10 +61,14 @@ export function registerCategoryGroupsCommand(program: Command) {
throw new Error('No update fields provided. Use --name or --hidden.');
}
const opts = program.opts();
await withConnection(opts, async () => {
await api.updateCategoryGroup(id, fields);
printOutput({ success: true, id }, opts.format);
});
await withConnection(
opts,
async () => {
await api.updateCategoryGroup(id, fields);
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
});
groups
@@ -65,9 +77,13 @@ export function registerCategoryGroupsCommand(program: Command) {
.option('--transfer-to <id>', 'Transfer transactions to this category ID')
.action(async (id: string, cmdOpts) => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.deleteCategoryGroup(id, cmdOpts.transferTo);
printOutput({ success: true, id }, opts.format);
});
await withConnection(
opts,
async () => {
await api.deleteCategoryGroup(id, cmdOpts.transferTo);
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
});
}

View File

@@ -12,10 +12,14 @@ export function registerPayeesCommand(program: Command) {
.description('List all payees')
.action(async () => {
const opts = program.opts();
await withConnection(opts, async () => {
const result = await api.getPayees();
printOutput(result, opts.format);
});
await withConnection(
opts,
async () => {
const result = await api.getPayees();
printOutput(result, opts.format);
},
{ mutates: false },
);
});
payees
@@ -23,10 +27,14 @@ export function registerPayeesCommand(program: Command) {
.description('List frequently used payees')
.action(async () => {
const opts = program.opts();
await withConnection(opts, async () => {
const result = await api.getCommonPayees();
printOutput(result, opts.format);
});
await withConnection(
opts,
async () => {
const result = await api.getCommonPayees();
printOutput(result, opts.format);
},
{ mutates: false },
);
});
payees
@@ -35,10 +43,14 @@ export function registerPayeesCommand(program: Command) {
.requiredOption('--name <name>', 'Payee name')
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const id = await api.createPayee({ name: cmdOpts.name });
printOutput({ id }, opts.format);
});
await withConnection(
opts,
async () => {
const id = await api.createPayee({ name: cmdOpts.name });
printOutput({ id }, opts.format);
},
{ mutates: true },
);
});
payees
@@ -54,10 +66,14 @@ export function registerPayeesCommand(program: Command) {
);
}
const opts = program.opts();
await withConnection(opts, async () => {
await api.updatePayee(id, fields);
printOutput({ success: true, id }, opts.format);
});
await withConnection(
opts,
async () => {
await api.updatePayee(id, fields);
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
});
payees
@@ -65,10 +81,14 @@ export function registerPayeesCommand(program: Command) {
.description('Delete a payee')
.action(async (id: string) => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.deletePayee(id);
printOutput({ success: true, id }, opts.format);
});
await withConnection(
opts,
async () => {
await api.deletePayee(id);
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
});
payees
@@ -87,9 +107,13 @@ export function registerPayeesCommand(program: Command) {
);
}
const opts = program.opts();
await withConnection(opts, async () => {
await api.mergePayees(cmdOpts.target, mergeIds);
printOutput({ success: true }, opts.format);
});
await withConnection(
opts,
async () => {
await api.mergePayees(cmdOpts.target, mergeIds);
printOutput({ success: true }, opts.format);
},
{ mutates: true },
);
});
}

View File

@@ -301,27 +301,31 @@ export function registerQueryCommand(program: Command) {
.addHelpText('after', RUN_EXAMPLES)
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const parsed = cmdOpts.file ? readJsonInput(cmdOpts) : undefined;
if (parsed !== undefined && !isRecord(parsed)) {
throw new Error('Query file must contain a JSON object');
}
const queryObj = parsed
? buildQueryFromFile(parsed, cmdOpts.table)
: buildQueryFromFlags(cmdOpts);
await withConnection(
opts,
async () => {
const parsed = cmdOpts.file ? readJsonInput(cmdOpts) : undefined;
if (parsed !== undefined && !isRecord(parsed)) {
throw new Error('Query file must contain a JSON object');
}
const queryObj = parsed
? buildQueryFromFile(parsed, cmdOpts.table)
: buildQueryFromFlags(cmdOpts);
const result = await api.aqlQuery(queryObj);
const result = await api.aqlQuery(queryObj);
if (!isRecord(result) || !('data' in result)) {
throw new Error('Query result missing data');
}
if (!isRecord(result) || !('data' in result)) {
throw new Error('Query result missing data');
}
if (cmdOpts.count) {
printOutput({ count: result.data }, opts.format);
} else {
printOutput(result.data, opts.format);
}
});
if (cmdOpts.count) {
printOutput({ count: result.data }, opts.format);
} else {
printOutput(result.data, opts.format);
}
},
{ mutates: false },
);
});
query

View File

@@ -15,10 +15,14 @@ export function registerRulesCommand(program: Command) {
.description('List all rules')
.action(async () => {
const opts = program.opts();
await withConnection(opts, async () => {
const result = await api.getRules();
printOutput(result, opts.format);
});
await withConnection(
opts,
async () => {
const result = await api.getRules();
printOutput(result, opts.format);
},
{ mutates: false },
);
});
rules
@@ -26,10 +30,14 @@ export function registerRulesCommand(program: Command) {
.description('List rules for a specific payee')
.action(async (payeeId: string) => {
const opts = program.opts();
await withConnection(opts, async () => {
const result = await api.getPayeeRules(payeeId);
printOutput(result, opts.format);
});
await withConnection(
opts,
async () => {
const result = await api.getPayeeRules(payeeId);
printOutput(result, opts.format);
},
{ mutates: false },
);
});
rules
@@ -39,13 +47,17 @@ export function registerRulesCommand(program: Command) {
.option('--file <path>', 'Read rule from JSON file (use - for stdin)')
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const rule = readJsonInput(cmdOpts) as Parameters<
typeof api.createRule
>[0];
const id = await api.createRule(rule);
printOutput({ id }, opts.format);
});
await withConnection(
opts,
async () => {
const rule = readJsonInput(cmdOpts) as Parameters<
typeof api.createRule
>[0];
const id = await api.createRule(rule);
printOutput({ id }, opts.format);
},
{ mutates: true },
);
});
rules
@@ -55,13 +67,17 @@ export function registerRulesCommand(program: Command) {
.option('--file <path>', 'Read rule from JSON file (use - for stdin)')
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const rule = readJsonInput(cmdOpts) as Parameters<
typeof api.updateRule
>[0];
await api.updateRule(rule);
printOutput({ success: true }, opts.format);
});
await withConnection(
opts,
async () => {
const rule = readJsonInput(cmdOpts) as Parameters<
typeof api.updateRule
>[0];
await api.updateRule(rule);
printOutput({ success: true }, opts.format);
},
{ mutates: true },
);
});
rules
@@ -69,9 +85,13 @@ export function registerRulesCommand(program: Command) {
.description('Delete a rule')
.action(async (id: string) => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.deleteRule(id);
printOutput({ success: true, id }, opts.format);
});
await withConnection(
opts,
async () => {
await api.deleteRule(id);
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
});
}

View File

@@ -15,10 +15,14 @@ export function registerSchedulesCommand(program: Command) {
.description('List all schedules')
.action(async () => {
const opts = program.opts();
await withConnection(opts, async () => {
const result = await api.getSchedules();
printOutput(result, opts.format);
});
await withConnection(
opts,
async () => {
const result = await api.getSchedules();
printOutput(result, opts.format);
},
{ mutates: false },
);
});
schedules
@@ -28,13 +32,17 @@ export function registerSchedulesCommand(program: Command) {
.option('--file <path>', 'Read schedule from JSON file (use - for stdin)')
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const schedule = readJsonInput(cmdOpts) as Parameters<
typeof api.createSchedule
>[0];
const id = await api.createSchedule(schedule);
printOutput({ id }, opts.format);
});
await withConnection(
opts,
async () => {
const schedule = readJsonInput(cmdOpts) as Parameters<
typeof api.createSchedule
>[0];
const id = await api.createSchedule(schedule);
printOutput({ id }, opts.format);
},
{ mutates: true },
);
});
schedules
@@ -45,13 +53,17 @@ export function registerSchedulesCommand(program: Command) {
.option('--reset-next-date', 'Reset next occurrence date', false)
.action(async (id: string, cmdOpts) => {
const opts = program.opts();
await withConnection(opts, async () => {
const fields = readJsonInput(cmdOpts) as Parameters<
typeof api.updateSchedule
>[1];
await api.updateSchedule(id, fields, cmdOpts.resetNextDate);
printOutput({ success: true, id }, opts.format);
});
await withConnection(
opts,
async () => {
const fields = readJsonInput(cmdOpts) as Parameters<
typeof api.updateSchedule
>[1];
await api.updateSchedule(id, fields, cmdOpts.resetNextDate);
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
});
schedules
@@ -59,9 +71,13 @@ export function registerSchedulesCommand(program: Command) {
.description('Delete a schedule')
.action(async (id: string) => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.deleteSchedule(id);
printOutput({ success: true, id }, opts.format);
});
await withConnection(
opts,
async () => {
await api.deleteSchedule(id);
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
});
}

View File

@@ -19,7 +19,7 @@ export function registerServerCommand(program: Command) {
const version = await api.getServerVersion();
printOutput({ version }, opts.format);
},
{ loadBudget: false },
{ mutates: false, skipBudget: true },
);
});
@@ -34,13 +34,17 @@ export function registerServerCommand(program: Command) {
.requiredOption('--name <name>', 'Entity name')
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const id = await api.getIDByName(cmdOpts.type, cmdOpts.name);
printOutput(
{ id, type: cmdOpts.type, name: cmdOpts.name },
opts.format,
);
});
await withConnection(
opts,
async () => {
const id = await api.getIDByName(cmdOpts.type, cmdOpts.name);
printOutput(
{ id, type: cmdOpts.type, name: cmdOpts.name },
opts.format,
);
},
{ mutates: false },
);
});
server
@@ -49,12 +53,16 @@ export function registerServerCommand(program: Command) {
.option('--account <id>', 'Specific account ID to sync')
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const args = cmdOpts.account
? { accountId: cmdOpts.account }
: undefined;
await api.runBankSync(args);
printOutput({ success: true }, opts.format);
});
await withConnection(
opts,
async () => {
const args = cmdOpts.account
? { accountId: cmdOpts.account }
: undefined;
await api.runBankSync(args);
printOutput({ success: true }, opts.format);
},
{ mutates: true },
);
});
}

View File

@@ -0,0 +1,124 @@
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Command } from 'commander';
import { CACHE_FILE_NAME, getMetaDir, writeCacheState } from '#cache';
import { resolveConfig } from '#config';
import { registerSyncCommand } from './sync';
vi.mock('@actual-app/api', () => ({
init: vi.fn().mockResolvedValue(undefined),
downloadBudget: vi.fn().mockResolvedValue(undefined),
loadBudget: vi.fn().mockResolvedValue(undefined),
sync: vi.fn().mockResolvedValue(undefined),
shutdown: vi.fn().mockResolvedValue(undefined),
getBudgets: vi
.fn()
.mockResolvedValue([{ id: 'bud-disk-1', groupId: 'sync-1' }]),
}));
vi.mock('#config', () => ({
resolveConfig: vi.fn(),
}));
let dataDir: string;
function metaDirFor(syncId: string) {
return getMetaDir(dataDir, syncId);
}
function program() {
const p = new Command();
p.exitOverride();
p.option('--sync-id <id>');
p.option('--data-dir <path>');
p.option('--format <fmt>');
p.option('--verbose');
registerSyncCommand(p);
return p;
}
describe('actual sync', () => {
let stdoutSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
dataDir = mkdtempSync(join(tmpdir(), 'actual-cli-sync-'));
vi.mocked(resolveConfig).mockResolvedValue({
serverUrl: 'http://test',
password: 'pw',
dataDir,
syncId: 'sync-1',
cacheTtl: 60,
lockTimeout: 10,
refresh: false,
noLock: true,
});
stdoutSpy = vi
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);
});
afterEach(() => {
stdoutSpy.mockRestore();
rmSync(dataDir, { recursive: true, force: true });
});
it('runs a sync and prints the syncId', async () => {
writeCacheState(metaDirFor('sync-1'), {
version: 1,
syncId: 'sync-1',
budgetId: 'bud-disk-1',
serverUrl: 'http://test',
lastSyncedAt: 0,
lastDownloadedAt: 0,
});
await program().parseAsync(['node', 'actual', 'sync']);
const out = stdoutSpy.mock.calls
.map((c: unknown[]) => String(c[0]))
.join('');
expect(out).toMatch(/"syncId":\s*"sync-1"/);
});
it('--status prints cache info without syncing', async () => {
writeCacheState(metaDirFor('sync-1'), {
version: 1,
syncId: 'sync-1',
budgetId: 'bud-disk-1',
serverUrl: 'http://test',
lastSyncedAt: Date.now() - 5000,
lastDownloadedAt: Date.now() - 5000,
});
await program().parseAsync(['node', 'actual', 'sync', '--status']);
const out = stdoutSpy.mock.calls
.map((c: unknown[]) => String(c[0]))
.join('');
expect(out).toMatch(/"stale":\s*(true|false)/);
expect(out).toMatch(/"ageSeconds":\s*\d+/);
});
it('--status on no prior sync reports "never synced" and exits 0', async () => {
await program().parseAsync(['node', 'actual', 'sync', '--status']);
const out = stdoutSpy.mock.calls
.map((c: unknown[]) => String(c[0]))
.join('');
expect(out).toMatch(/"neverSynced":\s*true/);
});
it('--clear removes the cache file', async () => {
writeCacheState(metaDirFor('sync-1'), {
version: 1,
syncId: 'sync-1',
budgetId: 'bud-disk-1',
serverUrl: 'http://test',
lastSyncedAt: Date.now(),
lastDownloadedAt: Date.now(),
});
expect(existsSync(join(metaDirFor('sync-1'), CACHE_FILE_NAME))).toBe(true);
await program().parseAsync(['node', 'actual', 'sync', '--clear']);
expect(existsSync(join(metaDirFor('sync-1'), CACHE_FILE_NAME))).toBe(false);
});
});

View File

@@ -0,0 +1,118 @@
import { rmSync } from 'node:fs';
import { join } from 'node:path';
import type { Command } from 'commander';
import { CACHE_FILE_NAME, getMetaDir, readCacheState } from '#cache';
import type { CliConfig } from '#config';
import { resolveConfig } from '#config';
import { withConnection } from '#connection';
import { acquireExclusive } from '#lock';
import { printOutput } from '#output';
type SyncCmdOpts = {
status?: boolean;
clear?: boolean;
};
async function requireSyncIdAndMeta(
opts: Record<string, unknown>,
flag: string,
): Promise<{ config: CliConfig; meta: string }> {
const config = await resolveConfig(opts);
if (!config.syncId) {
throw new Error(
`Sync ID is required for sync ${flag}. Set --sync-id or ACTUAL_SYNC_ID.`,
);
}
return { config, meta: getMetaDir(config.dataDir, config.syncId) };
}
export function registerSyncCommand(program: Command) {
program
.command('sync')
.description(
'Sync the local cached budget with the server, print cache status, or clear the cache',
)
.option('--status', 'Print cache status without syncing', false)
.option(
'--clear',
'Delete the local cache; next command re-downloads',
false,
)
.action(async (cmdOpts: SyncCmdOpts) => {
const opts = program.opts();
if (cmdOpts.status) {
const { config, meta } = await requireSyncIdAndMeta(opts, '--status');
const state = readCacheState(meta);
if (state === null) {
printOutput(
{
neverSynced: true,
syncId: config.syncId,
ttlSeconds: config.cacheTtl,
},
opts.format,
);
return;
}
const ageSeconds = Math.max(
0,
Math.round((Date.now() - state.lastSyncedAt) / 1000),
);
printOutput(
{
neverSynced: false,
syncId: state.syncId,
budgetId: state.budgetId,
syncedAt: new Date(state.lastSyncedAt).toISOString(),
lastDownloadedAt: new Date(state.lastDownloadedAt).toISOString(),
ageSeconds,
ttlSeconds: config.cacheTtl,
stale: ageSeconds > config.cacheTtl,
},
opts.format,
);
return;
}
if (cmdOpts.clear) {
const { config, meta } = await requireSyncIdAndMeta(opts, '--clear');
// Serialize with concurrent writers so we don't rm a half-written
// state.json that's about to be renamed into place.
const release = config.noLock
? null
: await acquireExclusive(meta, {
timeoutMs: config.lockTimeout * 1000,
});
try {
rmSync(join(meta, CACHE_FILE_NAME), { force: true });
} finally {
await release?.();
}
printOutput({ cleared: true, syncId: config.syncId }, opts.format);
return;
}
await withConnection(
opts,
async config => {
const state = config.syncId
? readCacheState(getMetaDir(config.dataDir, config.syncId))
: null;
printOutput(
{
syncedAt: new Date(
state?.lastSyncedAt ?? Date.now(),
).toISOString(),
syncId: config.syncId,
budgetId: state?.budgetId ?? config.syncId,
},
opts.format,
);
},
{ mutates: true },
);
});
}

View File

@@ -12,10 +12,14 @@ export function registerTagsCommand(program: Command) {
.description('List all tags')
.action(async () => {
const opts = program.opts();
await withConnection(opts, async () => {
const result = await api.getTags();
printOutput(result, opts.format);
});
await withConnection(
opts,
async () => {
const result = await api.getTags();
printOutput(result, opts.format);
},
{ mutates: false },
);
});
tags
@@ -26,14 +30,18 @@ export function registerTagsCommand(program: Command) {
.option('--description <description>', 'Tag description')
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const id = await api.createTag({
tag: cmdOpts.tag,
color: cmdOpts.color,
description: cmdOpts.description,
});
printOutput({ id }, opts.format);
});
await withConnection(
opts,
async () => {
const id = await api.createTag({
tag: cmdOpts.tag,
color: cmdOpts.color,
description: cmdOpts.description,
});
printOutput({ id }, opts.format);
},
{ mutates: true },
);
});
tags
@@ -55,10 +63,14 @@ export function registerTagsCommand(program: Command) {
);
}
const opts = program.opts();
await withConnection(opts, async () => {
await api.updateTag(id, fields);
printOutput({ success: true, id }, opts.format);
});
await withConnection(
opts,
async () => {
await api.updateTag(id, fields);
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
});
tags
@@ -66,9 +78,13 @@ export function registerTagsCommand(program: Command) {
.description('Delete a tag')
.action(async (id: string) => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.deleteTag(id);
printOutput({ success: true, id }, opts.format);
});
await withConnection(
opts,
async () => {
await api.deleteTag(id);
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
});
}

View File

@@ -18,14 +18,18 @@ export function registerTransactionsCommand(program: Command) {
.requiredOption('--end <date>', 'End date (YYYY-MM-DD)')
.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);
});
await withConnection(
opts,
async () => {
const result = await api.getTransactions(
cmdOpts.account,
cmdOpts.start,
cmdOpts.end,
);
printOutput(result, opts.format);
},
{ mutates: false },
);
});
transactions
@@ -41,20 +45,24 @@ export function registerTransactionsCommand(program: Command) {
.option('--run-transfers', 'Process transfers', false)
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const transactions = readJsonInput(cmdOpts) as Parameters<
typeof api.addTransactions
>[1];
const result = await api.addTransactions(
cmdOpts.account,
transactions,
{
learnCategories: cmdOpts.learnCategories,
runTransfers: cmdOpts.runTransfers,
},
);
printOutput(result, opts.format);
});
await withConnection(
opts,
async () => {
const transactions = readJsonInput(cmdOpts) as Parameters<
typeof api.addTransactions
>[1];
const result = await api.addTransactions(
cmdOpts.account,
transactions,
{
learnCategories: cmdOpts.learnCategories,
runTransfers: cmdOpts.runTransfers,
},
);
printOutput(result, opts.format);
},
{ mutates: true },
);
});
transactions
@@ -69,20 +77,24 @@ export function registerTransactionsCommand(program: Command) {
.option('--dry-run', 'Preview without importing', false)
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const transactions = readJsonInput(cmdOpts) as Parameters<
typeof api.importTransactions
>[1];
const result = await api.importTransactions(
cmdOpts.account,
transactions,
{
defaultCleared: true,
dryRun: cmdOpts.dryRun,
},
);
printOutput(result, opts.format);
});
await withConnection(
opts,
async () => {
const transactions = readJsonInput(cmdOpts) as Parameters<
typeof api.importTransactions
>[1];
const result = await api.importTransactions(
cmdOpts.account,
transactions,
{
defaultCleared: true,
dryRun: cmdOpts.dryRun,
},
);
printOutput(result, opts.format);
},
{ mutates: true },
);
});
transactions
@@ -92,13 +104,17 @@ export function registerTransactionsCommand(program: Command) {
.option('--file <path>', 'Read fields from JSON file (use - for stdin)')
.action(async (id: string, cmdOpts) => {
const opts = program.opts();
await withConnection(opts, async () => {
const fields = readJsonInput(cmdOpts) as Parameters<
typeof api.updateTransaction
>[1];
await api.updateTransaction(id, fields);
printOutput({ success: true, id }, opts.format);
});
await withConnection(
opts,
async () => {
const fields = readJsonInput(cmdOpts) as Parameters<
typeof api.updateTransaction
>[1];
await api.updateTransaction(id, fields);
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
});
transactions
@@ -106,9 +122,13 @@ export function registerTransactionsCommand(program: Command) {
.description('Delete a transaction')
.action(async (id: string) => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.deleteTransaction(id);
printOutput({ success: true, id }, opts.format);
});
await withConnection(
opts,
async () => {
await api.deleteTransaction(id);
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
});
}

View File

@@ -28,6 +28,9 @@ describe('resolveConfig', () => {
'ACTUAL_SYNC_ID',
'ACTUAL_DATA_DIR',
'ACTUAL_ENCRYPTION_PASSWORD',
'ACTUAL_CACHE_TTL',
'ACTUAL_LOCK_TIMEOUT',
'ACTUAL_NO_LOCK',
];
beforeEach(() => {
@@ -159,6 +162,105 @@ describe('resolveConfig', () => {
});
});
describe('cache options', () => {
beforeEach(() => {
process.env.ACTUAL_SERVER_URL = 'http://test';
process.env.ACTUAL_PASSWORD = 'pw';
});
it('defaults cacheTtl to 60 seconds', async () => {
const config = await resolveConfig({});
expect(config.cacheTtl).toBe(60);
});
it('reads cacheTtl from env', async () => {
process.env.ACTUAL_CACHE_TTL = '300';
const config = await resolveConfig({});
expect(config.cacheTtl).toBe(300);
});
it('prefers cacheTtl from CLI flag', async () => {
process.env.ACTUAL_CACHE_TTL = '300';
const config = await resolveConfig({ cacheTtl: 10 });
expect(config.cacheTtl).toBe(10);
});
it('rejects negative cacheTtl', async () => {
await expect(resolveConfig({ cacheTtl: -1 })).rejects.toThrow(/cacheTtl/);
});
it('rejects non-integer cacheTtl from env', async () => {
process.env.ACTUAL_CACHE_TTL = 'banana';
await expect(resolveConfig({})).rejects.toThrow(/ACTUAL_CACHE_TTL/);
});
it('defaults lockTimeout to 10 seconds', async () => {
const config = await resolveConfig({});
expect(config.lockTimeout).toBe(10);
});
it('reads lockTimeout from env', async () => {
process.env.ACTUAL_LOCK_TIMEOUT = '30';
const config = await resolveConfig({});
expect(config.lockTimeout).toBe(30);
});
it('defaults refresh to false', async () => {
const config = await resolveConfig({});
expect(config.refresh).toBe(false);
});
it('sets refresh when provided on CLI opts', async () => {
const config = await resolveConfig({ refresh: true });
expect(config.refresh).toBe(true);
});
it('sets refresh when noCache is true', async () => {
const config = await resolveConfig({ noCache: true });
expect(config.refresh).toBe(true);
});
it('defaults noLock to false', async () => {
const config = await resolveConfig({});
expect(config.noLock).toBe(false);
});
it('parses ACTUAL_NO_LOCK=1 as true', async () => {
process.env.ACTUAL_NO_LOCK = '1';
const config = await resolveConfig({});
expect(config.noLock).toBe(true);
});
it('parses ACTUAL_NO_LOCK=true as true', async () => {
process.env.ACTUAL_NO_LOCK = 'true';
const config = await resolveConfig({});
expect(config.noLock).toBe(true);
});
it('reads cacheTtl/lockTimeout/noLock from config file', async () => {
mockConfigFile({
serverUrl: 'http://file',
password: 'pw',
cacheTtl: 120,
lockTimeout: 5,
noLock: true,
});
const config = await resolveConfig({});
expect(config.cacheTtl).toBe(120);
expect(config.lockTimeout).toBe(5);
expect(config.noLock).toBe(true);
});
it('rejects non-number cacheTtl in config file', async () => {
mockConfigFile({
serverUrl: 'http://file',
password: 'pw',
cacheTtl: 'soon',
});
await expect(resolveConfig({})).rejects.toThrow(/cacheTtl/);
});
});
describe('cosmiconfig handling', () => {
it('handles null result (no config file found)', async () => {
mockConfigFile(null);

View File

@@ -3,7 +3,7 @@ import { join } from 'path';
import { cosmiconfig } from 'cosmiconfig';
import { isRecord } from './utils';
import { isRecord, parseBoolEnv, parseNonNegativeIntFlag } from './utils';
export type CliConfig = {
serverUrl: string;
@@ -12,6 +12,10 @@ export type CliConfig = {
syncId?: string;
dataDir: string;
encryptionPassword?: string;
cacheTtl: number;
lockTimeout: number;
refresh: boolean;
noLock: boolean;
};
export type CliGlobalOpts = {
@@ -21,10 +25,27 @@ export type CliGlobalOpts = {
syncId?: string;
dataDir?: string;
encryptionPassword?: string;
cacheTtl?: number;
lockTimeout?: number;
refresh?: boolean;
noCache?: boolean;
noLock?: boolean;
format?: 'json' | 'table' | 'csv';
verbose?: boolean;
};
const stringKeys = [
'serverUrl',
'password',
'sessionToken',
'syncId',
'dataDir',
'encryptionPassword',
] as const;
const numberKeys = ['cacheTtl', 'lockTimeout'] as const;
const booleanKeys = ['noLock'] as const;
type ConfigFileContent = {
serverUrl?: string;
password?: string;
@@ -32,15 +53,15 @@ type ConfigFileContent = {
syncId?: string;
dataDir?: string;
encryptionPassword?: string;
cacheTtl?: number;
lockTimeout?: number;
noLock?: boolean;
};
const configFileKeys: readonly string[] = [
'serverUrl',
'password',
'sessionToken',
'syncId',
'dataDir',
'encryptionPassword',
...stringKeys,
...numberKeys,
...booleanKeys,
];
function validateConfigFileContent(value: unknown): ConfigFileContent {
@@ -54,9 +75,30 @@ function validateConfigFileContent(value: unknown): ConfigFileContent {
if (!configFileKeys.includes(key)) {
throw new Error(`Invalid config file: unknown key "${key}"`);
}
if (value[key] !== undefined && typeof value[key] !== 'string') {
const v = value[key];
if (v === undefined) continue;
if (
(stringKeys as readonly string[]).includes(key) &&
typeof v !== 'string'
) {
throw new Error(
`Invalid config file: key "${key}" must be a string, got ${typeof value[key]}`,
`Invalid config file: key "${key}" must be a string, got ${typeof v}`,
);
}
if (
(numberKeys as readonly string[]).includes(key) &&
(typeof v !== 'number' || !Number.isInteger(v) || v < 0)
) {
throw new Error(
`Invalid config file: key "${key}" must be a non-negative integer`,
);
}
if (
(booleanKeys as readonly string[]).includes(key) &&
typeof v !== 'boolean'
) {
throw new Error(
`Invalid config file: key "${key}" must be a boolean, got ${typeof v}`,
);
}
}
@@ -83,6 +125,22 @@ async function loadConfigFile(): Promise<ConfigFileContent> {
return {};
}
function parseNonNegativeIntEnv(
raw: string | undefined,
source: string,
): number | undefined {
return raw === undefined ? undefined : parseNonNegativeIntFlag(raw, source);
}
function validateNonNegativeInt(value: number, name: string): number {
if (!Number.isInteger(value) || value < 0) {
throw new Error(
`Invalid ${name}: expected a non-negative integer, got ${value}`,
);
}
return value;
}
export async function resolveConfig(
cliOpts: CliGlobalOpts,
): Promise<CliConfig> {
@@ -128,6 +186,36 @@ export async function resolveConfig(
);
}
const cacheTtl = validateNonNegativeInt(
cliOpts.cacheTtl ??
parseNonNegativeIntEnv(
process.env.ACTUAL_CACHE_TTL,
'ACTUAL_CACHE_TTL',
) ??
fileConfig.cacheTtl ??
60,
'cacheTtl',
);
const lockTimeout = validateNonNegativeInt(
cliOpts.lockTimeout ??
parseNonNegativeIntEnv(
process.env.ACTUAL_LOCK_TIMEOUT,
'ACTUAL_LOCK_TIMEOUT',
) ??
fileConfig.lockTimeout ??
10,
'lockTimeout',
);
const refresh = cliOpts.refresh ?? cliOpts.noCache ?? false;
const noLock =
cliOpts.noLock ??
parseBoolEnv(process.env.ACTUAL_NO_LOCK) ??
fileConfig.noLock ??
false;
return {
serverUrl,
password,
@@ -135,5 +223,9 @@ export async function resolveConfig(
syncId,
dataDir,
encryptionPassword,
cacheTtl,
lockTimeout,
refresh,
noLock,
};
}

View File

@@ -1,24 +1,44 @@
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import * as api from '@actual-app/api';
import { getMetaDir, writeCacheState } from './cache';
import { resolveConfig } from './config';
import { withConnection } from './connection';
vi.mock('@actual-app/api', () => ({
init: vi.fn().mockResolvedValue(undefined),
downloadBudget: vi.fn().mockResolvedValue(undefined),
loadBudget: vi.fn().mockResolvedValue(undefined),
sync: vi.fn().mockResolvedValue(undefined),
shutdown: vi.fn().mockResolvedValue(undefined),
getBudgets: vi
.fn()
.mockResolvedValue([{ id: 'bud-disk-1', groupId: 'sync-1' }]),
}));
vi.mock('./config', () => ({
resolveConfig: vi.fn(),
}));
let dataDir: string;
function metaDirFor(syncId: string) {
return getMetaDir(dataDir, syncId);
}
function setConfig(overrides: Record<string, unknown> = {}) {
vi.mocked(resolveConfig).mockResolvedValue({
serverUrl: 'http://test',
password: 'pw',
dataDir: '/tmp/data',
syncId: 'budget-1',
dataDir,
syncId: 'sync-1',
cacheTtl: 60,
lockTimeout: 10,
refresh: false,
noLock: true,
...overrides,
});
}
@@ -31,104 +51,182 @@ describe('withConnection', () => {
stderrSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
dataDir = mkdtempSync(join(tmpdir(), 'actual-cli-conn-'));
setConfig();
});
afterEach(() => {
stderrSpy.mockRestore();
rmSync(dataDir, { recursive: true, force: true });
});
it('calls api.init with password when no sessionToken', async () => {
setConfig({ password: 'pw', sessionToken: undefined });
await withConnection({}, async () => 'ok');
await withConnection({}, async () => 'ok', { mutates: false });
expect(api.init).toHaveBeenCalledWith({
serverURL: 'http://test',
password: 'pw',
dataDir: '/tmp/data',
dataDir,
verbose: undefined,
});
});
it('calls api.init with sessionToken when present', async () => {
setConfig({ sessionToken: 'tok', password: undefined });
await withConnection({}, async () => 'ok');
await withConnection({}, async () => 'ok', { mutates: false });
expect(api.init).toHaveBeenCalledWith({
serverURL: 'http://test',
sessionToken: 'tok',
dataDir: '/tmp/data',
dataDir,
verbose: undefined,
});
});
it('calls api.downloadBudget when syncId is set', async () => {
setConfig({ syncId: 'budget-1' });
await withConnection({}, async () => 'ok');
expect(api.downloadBudget).toHaveBeenCalledWith('budget-1', {
it('first run: calls downloadBudget and writes cache state', async () => {
await withConnection({}, async () => 'ok', { mutates: false });
expect(api.downloadBudget).toHaveBeenCalledWith('sync-1', {
password: undefined,
});
expect(api.sync).not.toHaveBeenCalled();
});
it('throws when loadBudget is true but syncId is not set', async () => {
setConfig({ syncId: undefined });
await expect(withConnection({}, async () => 'ok')).rejects.toThrow(
'Sync ID is required',
);
});
it('skips budget download when loadBudget is false and syncId is not set', async () => {
setConfig({ syncId: undefined });
await withConnection({}, async () => 'ok', { loadBudget: false });
it('skips sync on a read inside the TTL', async () => {
writeCacheState(metaDirFor('sync-1'), {
version: 1,
syncId: 'sync-1',
budgetId: 'bud-disk-1',
serverUrl: 'http://test',
lastSyncedAt: Date.now(),
lastDownloadedAt: Date.now(),
});
await withConnection({}, async () => 'ok', { mutates: false });
expect(api.loadBudget).toHaveBeenCalledWith('bud-disk-1');
expect(api.sync).not.toHaveBeenCalled();
expect(api.downloadBudget).not.toHaveBeenCalled();
});
it('does not call api.downloadBudget when loadBudget is false', async () => {
setConfig({ syncId: 'budget-1' });
await withConnection({}, async () => 'ok', { loadBudget: false });
expect(api.downloadBudget).not.toHaveBeenCalled();
it('syncs on a read past the TTL', async () => {
writeCacheState(metaDirFor('sync-1'), {
version: 1,
syncId: 'sync-1',
budgetId: 'bud-disk-1',
serverUrl: 'http://test',
lastSyncedAt: Date.now() - 10 * 60_000,
lastDownloadedAt: Date.now() - 10 * 60_000,
});
await withConnection({}, async () => 'ok', { mutates: false });
expect(api.loadBudget).toHaveBeenCalled();
expect(api.sync).toHaveBeenCalledTimes(1);
});
it('returns callback result', async () => {
const result = await withConnection({}, async () => 42);
it('write command syncs before and after the callback, even when fresh', async () => {
writeCacheState(metaDirFor('sync-1'), {
version: 1,
syncId: 'sync-1',
budgetId: 'bud-disk-1',
serverUrl: 'http://test',
lastSyncedAt: Date.now(),
lastDownloadedAt: Date.now(),
});
await withConnection({}, async () => 'ok', { mutates: true });
expect(api.loadBudget).toHaveBeenCalled();
expect(api.sync).toHaveBeenCalledTimes(2);
});
it('--refresh forces a sync on a read inside the TTL', async () => {
setConfig({ refresh: true });
writeCacheState(metaDirFor('sync-1'), {
version: 1,
syncId: 'sync-1',
budgetId: 'bud-disk-1',
serverUrl: 'http://test',
lastSyncedAt: Date.now(),
lastDownloadedAt: Date.now(),
});
await withConnection({}, async () => 'ok', { mutates: false });
expect(api.sync).toHaveBeenCalledTimes(1);
});
it('encrypted budget forces a sync on a read inside the TTL', async () => {
setConfig({ encryptionPassword: 'secret' });
writeCacheState(metaDirFor('sync-1'), {
version: 1,
syncId: 'sync-1',
budgetId: 'bud-disk-1',
serverUrl: 'http://test',
lastSyncedAt: Date.now(),
lastDownloadedAt: Date.now(),
});
await withConnection({}, async () => 'ok', { mutates: false });
expect(api.sync).toHaveBeenCalledTimes(1);
});
it('invalidates cache when syncId changes', async () => {
writeCacheState(metaDirFor('sync-1'), {
version: 1,
syncId: 'OTHER',
budgetId: 'bud-disk-1',
serverUrl: 'http://test',
lastSyncedAt: Date.now(),
lastDownloadedAt: Date.now(),
});
await withConnection({}, async () => 'ok', { mutates: false });
expect(api.downloadBudget).toHaveBeenCalled();
});
it('skips budget work when skipBudget is true', async () => {
await withConnection({}, async () => 'ok', {
mutates: false,
skipBudget: true,
});
expect(api.downloadBudget).not.toHaveBeenCalled();
expect(api.loadBudget).not.toHaveBeenCalled();
expect(api.sync).not.toHaveBeenCalled();
});
it('throws when syncId is missing and skipBudget is false', async () => {
setConfig({ syncId: undefined });
await expect(
withConnection({}, async () => 'ok', { mutates: false }),
).rejects.toThrow('Sync ID is required');
});
it('returns the callback result', async () => {
const result = await withConnection({}, async () => 42, {
mutates: false,
});
expect(result).toBe(42);
});
it('calls api.shutdown in finally block on success', async () => {
await withConnection({}, async () => 'ok');
it('calls api.shutdown on success', async () => {
await withConnection({}, async () => 'ok', { mutates: false });
expect(api.shutdown).toHaveBeenCalled();
});
it('calls api.shutdown in finally block on error', async () => {
it('calls api.shutdown on error', async () => {
await expect(
withConnection({}, async () => {
throw new Error('boom');
}),
withConnection(
{},
async () => {
throw new Error('boom');
},
{ mutates: false },
),
).rejects.toThrow('boom');
expect(api.shutdown).toHaveBeenCalled();
});
it('does not write to stderr by default', async () => {
await withConnection({}, async () => 'ok');
expect(stderrSpy).not.toHaveBeenCalled();
});
it('writes info to stderr when verbose', async () => {
await withConnection({ verbose: true }, async () => 'ok');
expect(stderrSpy).toHaveBeenCalledWith(
expect.stringContaining('Connecting to'),
);
it('propagates sync errors on a stale read', async () => {
writeCacheState(metaDirFor('sync-1'), {
version: 1,
syncId: 'sync-1',
budgetId: 'bud-disk-1',
serverUrl: 'http://test',
lastSyncedAt: Date.now() - 10 * 60_000,
lastDownloadedAt: Date.now() - 10 * 60_000,
});
vi.mocked(api.sync).mockRejectedValueOnce(new Error('network'));
await expect(
withConnection({}, async () => 'ok', { mutates: false }),
).rejects.toThrow('network');
});
});

View File

@@ -1,30 +1,52 @@
import { mkdirSync } from 'fs';
import * as api from '@actual-app/api';
import type { CacheState } from './cache';
import {
CACHE_VERSION,
decideSyncAction,
getMetaDir,
readCacheState,
writeCacheState,
} from './cache';
import type { CliConfig, CliGlobalOpts } from './config';
import { resolveConfig } from './config';
import type { CliGlobalOpts } from './config';
function info(message: string, verbose?: boolean) {
if (verbose) {
process.stderr.write(message + '\n');
}
}
import { acquireExclusive, acquireShared } from './lock';
import type { Release } from './lock';
type ConnectionOptions = {
loadBudget?: boolean;
mutates: boolean;
skipBudget?: boolean;
};
function info(message: string, verbose?: boolean) {
if (verbose) process.stderr.write(message + '\n');
}
async function resolveBudgetIdForSyncId(syncId: string): Promise<string> {
const budgets = (await api.getBudgets()) as Array<{
id?: string;
groupId?: string;
cloudFileId?: string;
}>;
const match = budgets.find(
b =>
b.id !== undefined && (b.groupId === syncId || b.cloudFileId === syncId),
);
if (!match?.id) {
throw new Error(
`Could not resolve on-disk budget id for syncId ${syncId} after download.`,
);
}
return match.id;
}
export async function withConnection<T>(
globalOpts: CliGlobalOpts,
fn: () => Promise<T>,
options: ConnectionOptions = {},
fn: (config: CliConfig) => Promise<T>,
{ mutates, skipBudget = false }: ConnectionOptions,
): Promise<T> {
const { loadBudget = true } = options;
const config = await resolveConfig(globalOpts);
mkdirSync(config.dataDir, { recursive: true });
info(`Connecting to ${config.serverUrl}...`, globalOpts.verbose);
if (config.sessionToken) {
@@ -48,17 +70,87 @@ export async function withConnection<T>(
}
try {
if (loadBudget && config.syncId) {
info(`Downloading budget ${config.syncId}...`, globalOpts.verbose);
await api.downloadBudget(config.syncId, {
password: config.encryptionPassword,
});
} else if (loadBudget && !config.syncId) {
if (skipBudget) return await fn(config);
if (!config.syncId) {
throw new Error(
'Sync ID is required for this command. Set --sync-id or ACTUAL_SYNC_ID.',
);
}
return await fn();
const meta = getMetaDir(config.dataDir, config.syncId);
let release: Release | null = null;
if (!config.noLock) {
release = mutates
? await acquireExclusive(meta, {
timeoutMs: config.lockTimeout * 1000,
})
: await acquireShared(meta, {
timeoutMs: config.lockTimeout * 1000,
});
}
try {
const cachedState = readCacheState(meta);
const decision = decideSyncAction({
state: cachedState,
config: { syncId: config.syncId, serverUrl: config.serverUrl },
now: Date.now(),
ttlMs: config.cacheTtl * 1000,
mutates,
refresh: config.refresh,
encrypted: Boolean(config.encryptionPassword),
});
let state: CacheState;
if (decision.action === 'download') {
info(
cachedState === null
? `Downloading budget ${config.syncId} for the first time...`
: `Re-downloading budget ${config.syncId} (cache invalidated)...`,
globalOpts.verbose,
);
await api.downloadBudget(config.syncId, {
password: config.encryptionPassword,
});
const budgetId = await resolveBudgetIdForSyncId(config.syncId);
const now = Date.now();
state = {
version: CACHE_VERSION,
syncId: config.syncId,
budgetId,
serverUrl: config.serverUrl,
lastSyncedAt: now,
lastDownloadedAt: now,
};
writeCacheState(meta, state);
} else if (decision.action === 'skip') {
const age = Math.round(
(Date.now() - decision.state.lastSyncedAt) / 1000,
);
info(`Using cached budget (synced ${age}s ago)...`, globalOpts.verbose);
await api.loadBudget(decision.state.budgetId);
state = decision.state;
} else {
info(`Syncing budget ${config.syncId}...`, globalOpts.verbose);
await api.loadBudget(decision.state.budgetId);
await api.sync();
state = { ...decision.state, lastSyncedAt: Date.now() };
writeCacheState(meta, state);
}
const result = await fn(config);
if (mutates) {
info(`Pushing changes for ${config.syncId}...`, globalOpts.verbose);
await api.sync();
state = { ...state, lastSyncedAt: Date.now() };
writeCacheState(meta, state);
}
return result;
} finally {
if (release) await release();
}
} finally {
await api.shutdown();
}

View File

@@ -9,8 +9,10 @@ import { registerQueryCommand } from './commands/query';
import { registerRulesCommand } from './commands/rules';
import { registerSchedulesCommand } from './commands/schedules';
import { registerServerCommand } from './commands/server';
import { registerSyncCommand } from './commands/sync';
import { registerTagsCommand } from './commands/tags';
import { registerTransactionsCommand } from './commands/transactions';
import { parseNonNegativeIntFlag } from './utils';
declare const __CLI_VERSION__: string;
@@ -32,6 +34,23 @@ program
'--encryption-password <password>',
'E2E encryption password (env: ACTUAL_ENCRYPTION_PASSWORD)',
)
.option(
'--cache-ttl <seconds>',
'Cache TTL in seconds (env: ACTUAL_CACHE_TTL; default: 60)',
value => parseNonNegativeIntFlag(value, '--cache-ttl'),
)
.option('--refresh', 'Force a sync on this call, ignoring the cache', false)
.option('--no-cache', 'Alias for --refresh', false)
.option(
'--lock-timeout <seconds>',
'How long to wait for another CLI process to release the lock (env: ACTUAL_LOCK_TIMEOUT; default: 10)',
value => parseNonNegativeIntFlag(value, '--lock-timeout'),
)
.option(
'--no-lock',
'Disable the budget directory lock (use with care, env: ACTUAL_NO_LOCK)',
false,
)
.addOption(
new Option('--format <format>', 'Output format: json, table, csv')
.choices(['json', 'table', 'csv'] as const)
@@ -50,6 +69,7 @@ registerRulesCommand(program);
registerSchedulesCommand(program);
registerQueryCommand(program);
registerServerCommand(program);
registerSyncCommand(program);
function normalizeThrownMessage(err: unknown): string {
if (err instanceof Error) return err.message;

View File

@@ -0,0 +1,159 @@
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { acquireExclusive, acquireShared } from './lock';
// In-memory stand-in for proper-lockfile. The real library spins up a
// setTimeout loop to refresh lockfile mtimes; on some CI filesystems that
// timer keeps Node's event loop alive even after tests complete, wedging the
// test run. The mock behaves identically from our wrapper's perspective
// (acquire, detect contention with ELOCKED, release) without touching the
// filesystem or scheduling timers.
const mockHeld = new Set<string>();
vi.mock('proper-lockfile', () => ({
default: {
lock: vi.fn(
async (
file: string,
opts?: { lockfilePath?: string },
): Promise<() => Promise<void>> => {
const key = opts?.lockfilePath ?? file;
if (mockHeld.has(key)) {
const err = new Error('Lock is already held') as Error & {
code?: string;
};
err.code = 'ELOCKED';
throw err;
}
mockHeld.add(key);
return async () => {
mockHeld.delete(key);
};
},
),
},
}));
describe('acquireExclusive', () => {
let dir: string;
beforeEach(() => {
mockHeld.clear();
dir = mkdtempSync(join(tmpdir(), 'actual-cli-lock-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it('creates the directory if it does not exist', async () => {
const target = join(dir, 'nested', 'budget');
const release = await acquireExclusive(target, { timeoutMs: 1000 });
expect(existsSync(target)).toBe(true);
await release();
});
it('returns a release function that frees the lock', async () => {
const release1 = await acquireExclusive(dir, { timeoutMs: 1000 });
await release1();
const release2 = await acquireExclusive(dir, { timeoutMs: 1000 });
await release2();
});
it('rejects with a user-friendly error when another holder has the lock', async () => {
const release = await acquireExclusive(dir, { timeoutMs: 1000 });
await expect(acquireExclusive(dir, { timeoutMs: 100 })).rejects.toThrow(
/holding the budget/,
);
await release();
});
});
describe('acquireShared', () => {
let dir: string;
beforeEach(() => {
mockHeld.clear();
dir = mkdtempSync(join(tmpdir(), 'actual-cli-lock-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it('allows multiple concurrent shared holders', async () => {
const r1 = await acquireShared(dir, { timeoutMs: 1000 });
const r2 = await acquireShared(dir, { timeoutMs: 1000 });
const readers = readdirSync(join(dir, 'readers'));
expect(readers).toHaveLength(2);
await r1();
await r2();
});
it('removes the reader marker on release', async () => {
const release = await acquireShared(dir, { timeoutMs: 1000 });
await release();
const readers = readdirSync(join(dir, 'readers'));
expect(readers).toHaveLength(0);
});
it('rejects when an exclusive lock is held', async () => {
const releaseExclusive = await acquireExclusive(dir, { timeoutMs: 1000 });
await expect(acquireShared(dir, { timeoutMs: 100 })).rejects.toThrow(
/holding the budget/,
);
await releaseExclusive();
});
it('sweeps stale reader markers whose PIDs no longer exist', async () => {
const readersDir = join(dir, 'readers');
mkdirSync(readersDir, { recursive: true });
writeFileSync(join(readersDir, '-1-abc'), '');
const release = await acquireExclusive(dir, { timeoutMs: 1000 });
expect(readdirSync(readersDir)).toHaveLength(0);
await release();
});
});
describe('writer-reader interaction', () => {
let dir: string;
beforeEach(() => {
mockHeld.clear();
dir = mkdtempSync(join(tmpdir(), 'actual-cli-lock-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it('exclusive waits for active shared holders to release', async () => {
const readerRelease = await acquireShared(dir, { timeoutMs: 500 });
let writerAcquired = false;
const writerPromise = acquireExclusive(dir, { timeoutMs: 1000 }).then(
release => {
writerAcquired = true;
return release;
},
);
await new Promise(resolve => setTimeout(resolve, 150));
expect(writerAcquired).toBe(false);
await readerRelease();
const writerRelease = await writerPromise;
expect(writerAcquired).toBe(true);
await writerRelease();
});
});

149
packages/cli/src/lock.ts Normal file
View File

@@ -0,0 +1,149 @@
import { randomBytes } from 'node:crypto';
import { mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import lockfile from 'proper-lockfile';
export type Release = () => Promise<void>;
export type AcquireOptions = {
timeoutMs: number;
};
const LOCKFILE_NAME = 'lock';
const READERS_DIR_NAME = 'readers';
const READER_POLL_INTERVAL_MS = 100;
function lockfilePath(dir: string): string {
return join(dir, LOCKFILE_NAME);
}
function readersDir(dir: string): string {
return join(dir, READERS_DIR_NAME);
}
function ensureDir(dir: string) {
mkdirSync(dir, { recursive: true });
}
function retriesForTimeout(timeoutMs: number) {
return {
retries: Math.max(1, Math.floor(timeoutMs / 200)),
minTimeout: 100,
maxTimeout: 500,
factor: 1.5,
};
}
function errorCode(err: unknown): string | undefined {
if (err instanceof Error && 'code' in err) {
const { code } = err as { code?: unknown };
if (typeof code === 'string') return code;
}
return undefined;
}
function isLockedError(err: unknown): boolean {
return errorCode(err) === 'ELOCKED';
}
function lockedMessage(timeoutMs: number): string {
return `Another CLI process is holding the budget (waited ${Math.round(
timeoutMs / 1000,
)}s). Retry, or use a different --data-dir.`;
}
function pidIsAlive(pid: number): boolean {
if (pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (err) {
return errorCode(err) === 'EPERM';
}
}
function readReaderNames(readers: string): string[] {
try {
return readdirSync(readers);
} catch (err) {
if (errorCode(err) === 'ENOENT') return [];
throw err;
}
}
function sweepStaleReaders(dir: string) {
const readers = readersDir(dir);
for (const name of readReaderNames(readers)) {
const pid = Number(name.split('-')[0]);
if (!Number.isFinite(pid) || !pidIsAlive(pid)) {
rmSync(join(readers, name), { force: true });
}
}
}
async function waitForReadersEmpty(dir: string, timeoutMs: number) {
const readers = readersDir(dir);
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
sweepStaleReaders(dir);
if (readReaderNames(readers).length === 0) return;
await new Promise(resolve => setTimeout(resolve, READER_POLL_INTERVAL_MS));
}
throw new Error(lockedMessage(timeoutMs));
}
async function acquireGate(
dir: string,
timeoutMs: number,
): Promise<() => Promise<void>> {
ensureDir(dir);
try {
return await lockfile.lock(dir, {
lockfilePath: lockfilePath(dir),
retries: retriesForTimeout(timeoutMs),
stale: 30_000,
});
} catch (err) {
if (isLockedError(err)) throw new Error(lockedMessage(timeoutMs));
throw err;
}
}
export async function acquireExclusive(
dir: string,
{ timeoutMs }: AcquireOptions,
): Promise<Release> {
const start = Date.now();
const release = await acquireGate(dir, timeoutMs);
try {
const remaining = Math.max(0, timeoutMs - (Date.now() - start));
await waitForReadersEmpty(dir, remaining);
} catch (err) {
await release();
throw err;
}
return () => release();
}
export async function acquireShared(
dir: string,
{ timeoutMs }: AcquireOptions,
): Promise<Release> {
const gate = await acquireGate(dir, timeoutMs);
let markerPath: string;
try {
const readers = readersDir(dir);
ensureDir(readers);
const markerName = `${process.pid}-${randomBytes(6).toString('hex')}`;
markerPath = join(readers, markerName);
writeFileSync(markerPath, '');
} catch (err) {
await gate();
throw err;
}
await gate();
return async () => {
rmSync(markerPath, { force: true });
};
}

View File

@@ -18,3 +18,23 @@ export function parseIntFlag(value: string, flagName: string): number {
}
return parsed;
}
export function parseNonNegativeIntFlag(
value: string,
flagName: string,
): number {
const parsed = parseIntFlag(value, flagName);
if (parsed < 0) {
throw new Error(
`Invalid ${flagName}: "${value}". Expected a non-negative integer.`,
);
}
return parsed;
}
export function parseBoolEnv(raw: string | undefined): boolean | undefined {
if (raw === undefined) return undefined;
if (raw === '1' || raw.toLowerCase() === 'true') return true;
if (raw === '0' || raw.toLowerCase() === 'false') return false;
return undefined;
}

View File

@@ -32,5 +32,8 @@ export default defineConfig({
plugins: [visualizer({ template: 'raw-data', filename: 'dist/stats.json' })],
test: {
globals: true,
include: ['src/**/*.test.ts'],
exclude: ['**/node_modules/**', '**/dist/**'],
testTimeout: 10_000,
},
});

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [MatissJanis]
---
CLI: cache the downloaded budget between invocations so read commands within a 60-second TTL skip the server sync, reducing sync-server rate-limit pressure on scripted workflows. New `actual sync` command (with `--status` and `--clear` modes) plus `--cache-ttl`, `--refresh`, `--lock-timeout`, and `--no-lock` flags. Write commands still sync before and after every operation.

View File

@@ -55,10 +55,12 @@ __metadata:
dependencies:
"@actual-app/api": "workspace:*"
"@types/node": "npm:^22.19.17"
"@types/proper-lockfile": "npm:^4"
"@typescript/native-preview": "npm:^7.0.0-dev.20260404.1"
cli-table3: "npm:^0.6.5"
commander: "npm:^14.0.3"
cosmiconfig: "npm:^9.0.1"
proper-lockfile: "npm:^4.1.2"
rollup-plugin-visualizer: "npm:^7.0.1"
vite: "npm:^8.0.5"
vitest: "npm:^4.1.2"
@@ -9960,6 +9962,15 @@ __metadata:
languageName: node
linkType: hard
"@types/proper-lockfile@npm:^4":
version: 4.1.4
resolution: "@types/proper-lockfile@npm:4.1.4"
dependencies:
"@types/retry": "npm:*"
checksum: 10/b0d1b8e84a563b2c5f869f7ff7542b1d83dec03d1c9d980847cbb189865f44b4a854673cdde59767e41bcb8c31932e613ac43822d358a6f8eede6b79ccfceb1d
languageName: node
linkType: hard
"@types/qs@npm:*":
version: 6.14.0
resolution: "@types/qs@npm:6.14.0"