From a43b6f5c4714fb08b3fe3e5ce560213b229648c1 Mon Sep 17 00:00:00 2001 From: Matiss Janis Aboltins Date: Wed, 18 Mar 2026 18:22:38 +0000 Subject: [PATCH] [AI] Experimental CLI tool for Actual (#7208) * [AI] Add @actual-app/cli package New CLI tool wrapping the full @actual-app/api surface for interacting with Actual Budget from the command line. Connects to a sync server and supports all CRUD operations across accounts, budgets, categories, transactions, payees, tags, rules, schedules, and AQL queries. * Refactor CLI options: replace `--quiet` with `--verbose` for improved message control. Update related configurations and tests to reflect this change. Adjust build command in workflow for consistency. * Refactor tests: streamline imports in connection and accounts test files for improved clarity and consistency. Remove dynamic imports in favor of static imports. * Enhance package.json: Add exports configuration for module resolution and publish settings. This includes specifying types and default files for better compatibility and clarity in package usage. * Update package.json exports configuration to support environment-specific module resolution. Added 'development' and 'default' entries for improved clarity in file usage. * Enhance CLI functionality: Update configuration loading to support additional search places for config files. Refactor error handling in command options to improve validation and user feedback. Introduce new utility functions for parsing boolean flags and update related commands to utilize these functions. Add comprehensive tests for new utility functions to ensure reliability. * Update CLI TypeScript configuration to include Vitest globals and streamline test imports across multiple test files for improved clarity and consistency. * Update CLI dependencies and build workflow - Upgrade Vite to version 8.0.0 and Vitest to version 4.1.0 in package.json. - Add rollup-plugin-visualizer for bundle analysis. - Modify build workflow to prepare and upload CLI bundle stats. - Update size comparison workflow to include CLI stats. - Remove obsolete vitest.config.ts file as its configuration is now integrated into vite.config.ts. * Enhance size comparison workflow to include CLI build checks and artifact downloads - Added steps to wait for CLI build success in both base and PR workflows. - Included downloading of CLI build artifacts for comparison between base and PR branches. - Updated failure reporting to account for CLI build status. * Update documentation to replace "CLI tool" with "Server CLI" for consistency across multiple files. This change clarifies the distinction between the command-line interface for the Actual Budget application and the sync-server CLI tool. * Refactor configuration to replace "budgetId" with "syncId" across CLI and documentation * Enhance configuration validation by adding support for 'ACTUAL_ENCRYPTION_PASSWORD' and implementing a new validation function for config file content. Update documentation to clarify error output format for the CLI tool. * Enhance configuration tests to include 'encryptionPassword' checks for CLI options and environment variables, ensuring proper priority handling in the configuration resolution process. * Update nightly versioning script to use yarn * Align versions --------- Co-authored-by: Claude --- .github/actions/docs-spelling/expect.txt | 3 +- .github/workflows/build.yml | 25 ++ .../publish-nightly-npm-packages.yml | 16 + .github/workflows/publish-npm-packages.yml | 14 + .github/workflows/size-compare.yml | 35 +- .gitignore | 7 + lage.config.js | 1 + package.json | 2 + packages/api/app/query.js | 3 + packages/api/package.json | 14 + packages/cli/.gitignore | 7 + packages/cli/README.md | 155 ++++++++ packages/cli/package.json | 35 ++ packages/cli/src/commands/accounts.test.ts | 259 +++++++++++++ packages/cli/src/commands/accounts.ts | 135 +++++++ packages/cli/src/commands/budgets.ts | 135 +++++++ packages/cli/src/commands/categories.ts | 75 ++++ packages/cli/src/commands/category-groups.ts | 73 ++++ packages/cli/src/commands/payees.ts | 95 +++++ packages/cli/src/commands/query.ts | 93 +++++ packages/cli/src/commands/rules.ts | 77 ++++ packages/cli/src/commands/schedules.ts | 67 ++++ packages/cli/src/commands/server.ts | 60 +++ packages/cli/src/commands/tags.ts | 74 ++++ packages/cli/src/commands/transactions.ts | 114 ++++++ packages/cli/src/config.test.ts | 185 +++++++++ packages/cli/src/config.ts | 141 +++++++ packages/cli/src/connection.test.ts | 134 +++++++ packages/cli/src/connection.ts | 65 ++++ packages/cli/src/index.ts | 70 ++++ packages/cli/src/input.ts | 21 ++ packages/cli/src/output.test.ts | 152 ++++++++ packages/cli/src/output.ts | 82 ++++ packages/cli/src/utils.test.ts | 65 ++++ packages/cli/src/utils.ts | 16 + packages/cli/tsconfig.json | 15 + packages/cli/vite.config.ts | 36 ++ packages/docs/docs-sidebar.js | 1 + packages/docs/docs/api/cli.md | 356 ++++++++++++++++++ .../docs/contributing/development-setup.md | 6 + packages/docs/docs/contributing/releasing.md | 2 +- .../docs/docs/install/build-from-source.md | 2 +- packages/docs/docs/install/cli-tool.md | 12 +- packages/docs/docs/install/index.md | 2 +- packages/docs/src/pages/download.md | 2 +- tsconfig.json | 3 +- upcoming-release-notes/7208.md | 6 + yarn.lock | 49 ++- 48 files changed, 2981 insertions(+), 16 deletions(-) create mode 100644 packages/cli/.gitignore create mode 100644 packages/cli/README.md create mode 100644 packages/cli/package.json create mode 100644 packages/cli/src/commands/accounts.test.ts create mode 100644 packages/cli/src/commands/accounts.ts create mode 100644 packages/cli/src/commands/budgets.ts create mode 100644 packages/cli/src/commands/categories.ts create mode 100644 packages/cli/src/commands/category-groups.ts create mode 100644 packages/cli/src/commands/payees.ts create mode 100644 packages/cli/src/commands/query.ts create mode 100644 packages/cli/src/commands/rules.ts create mode 100644 packages/cli/src/commands/schedules.ts create mode 100644 packages/cli/src/commands/server.ts create mode 100644 packages/cli/src/commands/tags.ts create mode 100644 packages/cli/src/commands/transactions.ts create mode 100644 packages/cli/src/config.test.ts create mode 100644 packages/cli/src/config.ts create mode 100644 packages/cli/src/connection.test.ts create mode 100644 packages/cli/src/connection.ts create mode 100644 packages/cli/src/index.ts create mode 100644 packages/cli/src/input.ts create mode 100644 packages/cli/src/output.test.ts create mode 100644 packages/cli/src/output.ts create mode 100644 packages/cli/src/utils.test.ts create mode 100644 packages/cli/src/utils.ts create mode 100644 packages/cli/tsconfig.json create mode 100644 packages/cli/vite.config.ts create mode 100644 packages/docs/docs/api/cli.md create mode 100644 upcoming-release-notes/7208.md diff --git a/.github/actions/docs-spelling/expect.txt b/.github/actions/docs-spelling/expect.txt index a1d4de6180..0e0478c0ee 100644 --- a/.github/actions/docs-spelling/expect.txt +++ b/.github/actions/docs-spelling/expect.txt @@ -2,6 +2,7 @@ Abanca ABNAMRO ABNANL Activo +actualrc AESUDEF ALZEY Anglais @@ -110,8 +111,8 @@ KBCBE Keycloak Khurozov KORT -KRW Kreditbank +KRW lage LHV LHVBEE diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c70fa55bcd..90f727d6d9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -81,6 +81,31 @@ jobs: name: build-stats path: packages/desktop-client/build-stats + cli: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - name: Set up environment + uses: ./.github/actions/setup + with: + download-translations: 'false' + - name: Build CLI + run: yarn build:cli + - name: Create package tgz + run: cd packages/cli && yarn pack && mv package.tgz actual-cli.tgz + - name: Prepare bundle stats artifact + run: cp packages/cli/dist/stats.json cli-stats.json + - name: Upload Build + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: actual-cli + path: packages/cli/actual-cli.tgz + - name: Upload CLI bundle stats + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: cli-build-stats + path: cli-stats.json + server: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/publish-nightly-npm-packages.yml b/.github/workflows/publish-nightly-npm-packages.yml index 2ced314d31..3f6215e6ff 100644 --- a/.github/workflows/publish-nightly-npm-packages.yml +++ b/.github/workflows/publish-nightly-npm-packages.yml @@ -24,12 +24,14 @@ jobs: NEW_WEB_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/desktop-client/package.json --type nightly) NEW_SYNC_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/sync-server/package.json --type nightly) NEW_API_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/api/package.json --type nightly) + NEW_CLI_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/cli/package.json --type nightly) # Set package versions npm version $NEW_CORE_VERSION --no-git-tag-version --workspace=@actual-app/core --no-workspaces-update npm version $NEW_WEB_VERSION --no-git-tag-version --workspace=@actual-app/web --no-workspaces-update npm version $NEW_SYNC_VERSION --no-git-tag-version --workspace=@actual-app/sync-server --no-workspaces-update npm version $NEW_API_VERSION --no-git-tag-version --workspace=@actual-app/api --no-workspaces-update + npm version $NEW_CLI_VERSION --no-git-tag-version --workspace=@actual-app/cli --no-workspaces-update - name: Yarn install run: | @@ -54,6 +56,13 @@ jobs: run: | yarn workspace @actual-app/api pack --filename @actual-app/api.tgz + - name: Build CLI + run: yarn workspace @actual-app/cli build + + - name: Pack the cli package + run: | + yarn workspace @actual-app/cli pack --filename @actual-app/cli.tgz + - name: Upload package artifacts uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: @@ -63,6 +72,7 @@ jobs: packages/desktop-client/@actual-app/web.tgz packages/sync-server/@actual-app/sync-server.tgz packages/api/@actual-app/api.tgz + packages/cli/@actual-app/cli.tgz publish: runs-on: ubuntu-latest @@ -106,3 +116,9 @@ jobs: npm publish api/@actual-app/api.tgz --access public --tag nightly env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Publish CLI + run: | + npm publish cli/@actual-app/cli.tgz --access public --tag nightly + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/publish-npm-packages.yml b/.github/workflows/publish-npm-packages.yml index 133d955ec2..159efa999f 100644 --- a/.github/workflows/publish-npm-packages.yml +++ b/.github/workflows/publish-npm-packages.yml @@ -35,6 +35,13 @@ jobs: run: | yarn workspace @actual-app/api pack --filename @actual-app/api.tgz + - name: Build CLI + run: yarn workspace @actual-app/cli build + + - name: Pack the cli package + run: | + yarn workspace @actual-app/cli pack --filename @actual-app/cli.tgz + - name: Upload package artifacts uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: @@ -44,6 +51,7 @@ jobs: packages/desktop-client/@actual-app/web.tgz packages/sync-server/@actual-app/sync-server.tgz packages/api/@actual-app/api.tgz + packages/cli/@actual-app/cli.tgz publish: runs-on: ubuntu-latest @@ -87,3 +95,9 @@ jobs: npm publish api/@actual-app/api.tgz --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Publish CLI + run: | + npm publish cli/@actual-app/cli.tgz --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/size-compare.yml b/.github/workflows/size-compare.yml index a67952afa3..9ad247b04e 100644 --- a/.github/workflows/size-compare.yml +++ b/.github/workflows/size-compare.yml @@ -57,6 +57,13 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} checkName: api ref: ${{github.base_ref}} + - name: Wait for ${{github.base_ref}} CLI build to succeed + uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0 + id: master-cli-build + with: + token: ${{ secrets.GITHUB_TOKEN }} + checkName: cli + ref: ${{github.base_ref}} - name: Wait for PR build to succeed uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0 @@ -72,9 +79,16 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} checkName: api ref: ${{github.event.pull_request.head.sha}} + - name: Wait for CLI PR build to succeed + uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0 + id: wait-for-cli-build + with: + token: ${{ secrets.GITHUB_TOKEN }} + checkName: cli + ref: ${{github.event.pull_request.head.sha}} - name: Report build failure - if: steps.wait-for-web-build.outputs.conclusion == 'failure' || steps.wait-for-api-build.outputs.conclusion == 'failure' + if: steps.wait-for-web-build.outputs.conclusion == 'failure' || steps.wait-for-api-build.outputs.conclusion == 'failure' || steps.wait-for-cli-build.outputs.conclusion == 'failure' run: | echo "Build failed on PR branch or ${{github.base_ref}}" exit 1 @@ -115,6 +129,23 @@ jobs: name: api-build-stats path: head allow_forks: true + - name: Download CLI build artifact from ${{github.base_ref}} + uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11 + with: + branch: ${{github.base_ref}} + workflow: build.yml + workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it + name: cli-build-stats + path: base + - name: Download CLI stats from PR + uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11 + with: + pr: ${{github.event.pull_request.number}} + workflow: build.yml + workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it + name: cli-build-stats + path: head + allow_forks: true - name: Strip content hashes from stats files run: | if [ -f ./head/web-stats.json ]; then @@ -136,9 +167,11 @@ jobs: --base desktop-client=./base/web-stats.json \ --base loot-core=./base/loot-core-stats.json \ --base api=./base/api-stats.json \ + --base cli=./base/cli-stats.json \ --head desktop-client=./head/web-stats.json \ --head loot-core=./head/loot-core-stats.json \ --head api=./head/api-stats.json \ + --head cli=./head/cli-stats.json \ --identifier combined \ --format pr-body > bundle-stats-comment.md - name: Post combined bundle stats comment diff --git a/.gitignore b/.gitignore index 3734a994ae..4900b75f91 100644 --- a/.gitignore +++ b/.gitignore @@ -81,3 +81,10 @@ build/ *storybook.log storybook-static + +# cli config when testing locally +.actualrc.json +.actualrc +.actualrc.yaml +.actualrc.yml +actual.config.js diff --git a/lage.config.js b/lage.config.js index 109ab8cb3f..e697745592 100644 --- a/lage.config.js +++ b/lage.config.js @@ -17,6 +17,7 @@ module.exports = { }, build: { type: 'npmScript', + dependsOn: ['^build'], cache: true, options: { outputGlob: ['lib-dist/**', 'dist/**', 'build/**'], diff --git a/package.json b/package.json index 10b159d744..6e66b230cd 100644 --- a/package.json +++ b/package.json @@ -34,12 +34,14 @@ "start:browser-backend": "yarn workspace @actual-app/core watch:browser", "start:browser-frontend": "yarn workspace @actual-app/web start:browser", "start:storybook": "yarn workspace @actual-app/components start:storybook", + "build": "lage build", "build:browser-backend": "yarn workspace @actual-app/core build:browser", "build:server": "yarn build:browser && yarn workspace @actual-app/sync-server build", "build:browser": "./bin/package-browser", "build:desktop": "./bin/package-electron", "build:plugins-service": "yarn workspace plugins-service build", "build:api": "yarn workspace @actual-app/api build", + "build:cli": "yarn build --scope=@actual-app/cli", "build:docs": "yarn workspace docs build", "build:storybook": "yarn workspace @actual-app/components build:storybook", "deploy:docs": "yarn workspace docs deploy", diff --git a/packages/api/app/query.js b/packages/api/app/query.js index 2416fc97f9..b97ef72b61 100644 --- a/packages/api/app/query.js +++ b/packages/api/app/query.js @@ -1,4 +1,7 @@ class Query { + /** @type {import('loot-core/shared/query').QueryState} */ + state; + constructor(state) { this.state = { filterExpressions: state.filterExpressions || [], diff --git a/packages/api/package.json b/packages/api/package.json index 14c40ed125..b6e026f1c3 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -9,6 +9,20 @@ ], "main": "dist/index.js", "types": "@types/index.d.ts", + "exports": { + ".": { + "development": "./index.ts", + "default": "./dist/index.js" + } + }, + "publishConfig": { + "exports": { + ".": { + "types": "./@types/index.d.ts", + "default": "./dist/index.js" + } + } + }, "scripts": { "build": "vite build", "test": "vitest --run", diff --git a/packages/cli/.gitignore b/packages/cli/.gitignore new file mode 100644 index 0000000000..e342701cd8 --- /dev/null +++ b/packages/cli/.gitignore @@ -0,0 +1,7 @@ +dist +coverage +.actualrc.json +.actualrc +.actualrc.yaml +.actualrc.yml +actual.config.js diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 0000000000..a368d9a8f6 --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,155 @@ +# @actual-app/cli + +> **WARNING:** This CLI is experimental. + +Command-line interface for [Actual Budget](https://actualbudget.org). Query and modify your budget data from the terminal — accounts, transactions, categories, payees, rules, schedules, and more. + +> **Note:** This CLI connects to a running [Actual sync server](https://actualbudget.org/docs/install/). It does not operate on local budget files directly. + +## Installation + +```bash +npm install -g @actual-app/cli +``` + +Requires Node.js >= 22. + +## Quick Start + +```bash +# Set connection details +export ACTUAL_SERVER_URL=http://localhost:5006 +export ACTUAL_PASSWORD=your-password +export ACTUAL_SYNC_ID=your-sync-id # Found in Settings → Advanced → Sync ID + +# List your accounts +actual accounts list + +# Check a balance +actual accounts balance + +# View this month's budget +actual budgets month 2026-03 +``` + +## Configuration + +Configuration is resolved in this order (highest priority first): + +1. **CLI flags** (`--server-url`, `--password`, etc.) +2. **Environment variables** +3. **Config file** (via [cosmiconfig](https://github.com/cosmiconfig/cosmiconfig)) +4. **Defaults** (`dataDir` defaults to `~/.actual-cli/data`) + +### 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 | + +### Config File + +Create an `.actualrc.json` (or `.actualrc`, `.actualrc.yaml`, `actual.config.js`): + +```json +{ + "serverUrl": "http://localhost:5006", + "password": "your-password", + "syncId": "1cfdbb80-6274-49bf-b0c2-737235a4c81f" +} +``` + +**Security:** Do not store plaintext passwords in config files (e.g. `.actualrc.json`, `.actualrc`, `.actualrc.yaml`, `actual.config.js`). Add these files to `.gitignore` if they contain secrets. Prefer the `ACTUAL_SESSION_TOKEN` environment variable instead of the `password` field. See [Environment Variables](#environment-variables) for using a session token. + +### Global Flags + +| Flag | Description | +| ------------------------- | ----------------------------------------------- | +| `--server-url ` | Server URL | +| `--password ` | Server password | +| `--session-token ` | Session token | +| `--sync-id ` | Budget Sync ID | +| `--data-dir ` | Data directory | +| `--format ` | Output format: `json` (default), `table`, `csv` | +| `--verbose` | Show informational messages | + +## Commands + +| Command | Description | +| ----------------- | ------------------------------ | +| `accounts` | Manage accounts | +| `budgets` | Manage budgets and allocations | +| `categories` | Manage categories | +| `category-groups` | Manage category groups | +| `transactions` | Manage transactions | +| `payees` | Manage payees | +| `tags` | Manage tags | +| `rules` | Manage transaction rules | +| `schedules` | Manage scheduled transactions | +| `query` | Run an ActualQL query | +| `server` | Server utilities and lookups | + +Run `actual --help` for subcommands and options. + +### Examples + +```bash +# List all accounts (as a table) +actual accounts list --format table + +# Find an entity ID by name +actual server get-id --type accounts --name "Checking" + +# Add a transaction (amount in integer cents: -2500 = -$25.00) +actual transactions add --account \ + --data '[{"date":"2026-03-14","amount":-2500,"payee_name":"Coffee Shop"}]' + +# Export transactions to CSV +actual transactions list --account \ + --start 2026-01-01 --end 2026-12-31 --format csv > transactions.csv + +# Set budget amount ($500 = 50000 cents) +actual budgets set-amount --month 2026-03 --category --amount 50000 + +# Run an ActualQL query +actual query run --table transactions \ + --select "date,amount,payee" --filter '{"amount":{"$lt":0}}' --limit 10 +``` + +### Amount Convention + +All monetary amounts are **integer cents**: + +| CLI Value | Dollar Amount | +| --------- | ------------- | +| `5000` | $50.00 | +| `-12350` | -$123.50 | + +## Running Locally (Development) + +If you're working on the CLI within the monorepo: + +```bash +# 1. Build the CLI +yarn build:cli + +# 2. Start a local sync server (in a separate terminal) +yarn start:server-dev + +# 3. Open http://localhost:5006 in your browser, create a budget, +# then find the Sync ID in Settings → Advanced → Sync ID + +# 4. Run the CLI directly from the build output +ACTUAL_SERVER_URL=http://localhost:5006 \ +ACTUAL_PASSWORD=your-password \ +ACTUAL_SYNC_ID=your-sync-id \ +node packages/cli/dist/cli.js accounts list + +# Or use a shorthand alias for convenience +alias actual-dev="node $(pwd)/packages/cli/dist/cli.js" +actual-dev budgets list +``` diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000000..f4e9d24b74 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,35 @@ +{ + "name": "@actual-app/cli", + "version": "26.3.0", + "description": "CLI for Actual Budget", + "license": "MIT", + "bin": { + "actual": "./dist/cli.js", + "actual-cli": "./dist/cli.js" + }, + "files": [ + "dist" + ], + "type": "module", + "scripts": { + "build": "vite build", + "test": "vitest --run", + "typecheck": "tsgo -b" + }, + "dependencies": { + "@actual-app/api": "workspace:*", + "cli-table3": "^0.6.5", + "commander": "^13.0.0", + "cosmiconfig": "^9.0.0" + }, + "devDependencies": { + "@types/node": "^22.19.15", + "@typescript/native-preview": "^7.0.0-dev.20260309.1", + "rollup-plugin-visualizer": "^6.0.11", + "vite": "^8.0.0", + "vitest": "^4.1.0" + }, + "engines": { + "node": ">=22" + } +} diff --git a/packages/cli/src/commands/accounts.test.ts b/packages/cli/src/commands/accounts.test.ts new file mode 100644 index 0000000000..10a622d20d --- /dev/null +++ b/packages/cli/src/commands/accounts.test.ts @@ -0,0 +1,259 @@ +import * as api from '@actual-app/api'; +import { Command } from 'commander'; + +import { printOutput } from '../output'; + +import { registerAccountsCommand } from './accounts'; + +vi.mock('@actual-app/api', () => ({ + getAccounts: vi.fn().mockResolvedValue([]), + createAccount: vi.fn().mockResolvedValue('new-id'), + updateAccount: vi.fn().mockResolvedValue(undefined), + closeAccount: vi.fn().mockResolvedValue(undefined), + reopenAccount: vi.fn().mockResolvedValue(undefined), + deleteAccount: vi.fn().mockResolvedValue(undefined), + getAccountBalance: vi.fn().mockResolvedValue(10000), +})); + +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 '); + program.option('--server-url '); + program.option('--password '); + program.option('--session-token '); + program.option('--sync-id '); + program.option('--data-dir '); + program.option('--verbose'); + program.exitOverride(); + registerAccountsCommand(program); + return program; +} + +async function run(args: string[]) { + const program = createProgram(); + await program.parseAsync(['node', 'test', ...args]); +} + +describe('accounts commands', () => { + let stderrSpy: ReturnType; + let stdoutSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + stderrSpy = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + stdoutSpy = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + }); + + afterEach(() => { + stderrSpy.mockRestore(); + stdoutSpy.mockRestore(); + }); + + describe('list', () => { + it('calls api.getAccounts and prints result', async () => { + const accounts = [{ id: '1', name: 'Checking' }]; + vi.mocked(api.getAccounts).mockResolvedValue(accounts); + + await run(['accounts', 'list']); + + expect(api.getAccounts).toHaveBeenCalled(); + expect(printOutput).toHaveBeenCalledWith(accounts, undefined); + }); + + it('passes format option to printOutput', async () => { + vi.mocked(api.getAccounts).mockResolvedValue([]); + + await run(['--format', 'csv', 'accounts', 'list']); + + expect(printOutput).toHaveBeenCalledWith([], 'csv'); + }); + }); + + describe('create', () => { + it('passes name and defaults to api.createAccount', async () => { + await run(['accounts', 'create', '--name', 'Savings']); + + expect(api.createAccount).toHaveBeenCalledWith( + { name: 'Savings', offbudget: false }, + 0, + ); + expect(printOutput).toHaveBeenCalledWith({ id: 'new-id' }, undefined); + }); + + it('passes offbudget and balance options', async () => { + await run([ + 'accounts', + 'create', + '--name', + 'Investments', + '--offbudget', + '--balance', + '50000', + ]); + + expect(api.createAccount).toHaveBeenCalledWith( + { name: 'Investments', offbudget: true }, + 50000, + ); + }); + }); + + describe('update', () => { + it('passes fields to api.updateAccount', async () => { + await run(['accounts', 'update', 'acct-1', '--name', 'NewName']); + + expect(api.updateAccount).toHaveBeenCalledWith('acct-1', { + name: 'NewName', + }); + expect(printOutput).toHaveBeenCalledWith( + { success: true, id: 'acct-1' }, + undefined, + ); + }); + + it('passes offbudget true', async () => { + await run([ + 'accounts', + 'update', + 'acct-1', + '--name', + 'X', + '--offbudget', + 'true', + ]); + + expect(api.updateAccount).toHaveBeenCalledWith('acct-1', { + name: 'X', + offbudget: true, + }); + }); + + it('passes offbudget false', async () => { + await run([ + 'accounts', + 'update', + 'acct-1', + '--name', + 'X', + '--offbudget', + 'false', + ]); + + expect(api.updateAccount).toHaveBeenCalledWith('acct-1', { + name: 'X', + offbudget: false, + }); + }); + + it('rejects invalid offbudget value', async () => { + await expect( + run(['accounts', 'update', 'acct-1', '--offbudget', 'yes']), + ).rejects.toThrow( + 'Invalid --offbudget: "yes". Expected "true" or "false".', + ); + }); + + it('rejects empty name', async () => { + await expect( + run(['accounts', 'update', 'acct-1', '--name', ' ']), + ).rejects.toThrow('Invalid --name: must be a non-empty string.'); + }); + + it('rejects update with no fields', async () => { + await expect(run(['accounts', 'update', 'acct-1'])).rejects.toThrow( + 'No update fields provided. Use --name or --offbudget.', + ); + }); + }); + + describe('close', () => { + it('passes transfer options to api.closeAccount', async () => { + await run([ + 'accounts', + 'close', + 'acct-1', + '--transfer-account', + 'acct-2', + ]); + + expect(api.closeAccount).toHaveBeenCalledWith( + 'acct-1', + 'acct-2', + undefined, + ); + }); + + it('passes transfer category', async () => { + await run([ + 'accounts', + 'close', + 'acct-1', + '--transfer-category', + 'cat-1', + ]); + + expect(api.closeAccount).toHaveBeenCalledWith( + 'acct-1', + undefined, + 'cat-1', + ); + }); + }); + + describe('reopen', () => { + it('calls api.reopenAccount', async () => { + await run(['accounts', 'reopen', 'acct-1']); + + expect(api.reopenAccount).toHaveBeenCalledWith('acct-1'); + expect(printOutput).toHaveBeenCalledWith( + { success: true, id: 'acct-1' }, + undefined, + ); + }); + }); + + describe('delete', () => { + it('calls api.deleteAccount', async () => { + await run(['accounts', 'delete', 'acct-1']); + + expect(api.deleteAccount).toHaveBeenCalledWith('acct-1'); + expect(printOutput).toHaveBeenCalledWith( + { success: true, id: 'acct-1' }, + undefined, + ); + }); + }); + + describe('balance', () => { + it('calls api.getAccountBalance without cutoff', async () => { + await run(['accounts', 'balance', 'acct-1']); + + expect(api.getAccountBalance).toHaveBeenCalledWith('acct-1', undefined); + expect(printOutput).toHaveBeenCalledWith( + { id: 'acct-1', balance: 10000 }, + undefined, + ); + }); + + it('calls api.getAccountBalance with cutoff date', async () => { + await run(['accounts', 'balance', 'acct-1', '--cutoff', '2025-01-15']); + + expect(api.getAccountBalance).toHaveBeenCalledWith( + 'acct-1', + new Date('2025-01-15'), + ); + }); + }); +}); diff --git a/packages/cli/src/commands/accounts.ts b/packages/cli/src/commands/accounts.ts new file mode 100644 index 0000000000..4b46d75186 --- /dev/null +++ b/packages/cli/src/commands/accounts.ts @@ -0,0 +1,135 @@ +import * as api from '@actual-app/api'; +import type { Command } from 'commander'; + +import { withConnection } from '../connection'; +import { printOutput } from '../output'; +import { parseBoolFlag, parseIntFlag } from '../utils'; + +export function registerAccountsCommand(program: Command) { + const accounts = program.command('accounts').description('Manage accounts'); + + accounts + .command('list') + .description('List all accounts') + .action(async () => { + const opts = program.opts(); + await withConnection(opts, async () => { + const result = await api.getAccounts(); + printOutput(result, opts.format); + }); + }); + + accounts + .command('create') + .description('Create a new account') + .requiredOption('--name ', 'Account name') + .option('--offbudget', 'Create as off-budget account', false) + .option('--balance ', 'Initial balance in cents', '0') + .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); + }); + }); + + accounts + .command('update ') + .description('Update an account') + .option('--name ', 'New account name') + .option('--offbudget ', 'Set off-budget status') + .action(async (id: string, cmdOpts) => { + const opts = program.opts(); + const fields: Record = {}; + if (cmdOpts.name !== undefined) { + const trimmed = cmdOpts.name.trim(); + if (trimmed === '') { + throw new Error('Invalid --name: must be a non-empty string.'); + } + fields.name = trimmed; + } + if (cmdOpts.offbudget !== undefined) { + fields.offbudget = parseBoolFlag(cmdOpts.offbudget, '--offbudget'); + } + if (Object.keys(fields).length === 0) { + throw new Error( + 'No update fields provided. Use --name or --offbudget.', + ); + } + await withConnection(opts, async () => { + await api.updateAccount(id, fields); + printOutput({ success: true, id }, opts.format); + }); + }); + + accounts + .command('close ') + .description('Close an account') + .option( + '--transfer-account ', + 'Transfer remaining balance to this account', + ) + .option( + '--transfer-category ', + 'Transfer remaining balance to this category', + ) + .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); + }); + }); + + accounts + .command('reopen ') + .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); + }); + }); + + accounts + .command('delete ') + .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); + }); + }); + + accounts + .command('balance ') + .description('Get account balance') + .option('--cutoff ', 'Cutoff date (YYYY-MM-DD)') + .action(async (id: string, cmdOpts) => { + let cutoff: Date | undefined; + if (cmdOpts.cutoff) { + const cutoffDate = new Date(cmdOpts.cutoff); + if (Number.isNaN(cutoffDate.getTime())) { + throw new Error( + 'Invalid cutoff date: expected a valid date (e.g. YYYY-MM-DD).', + ); + } + cutoff = cutoffDate; + } + const opts = program.opts(); + await withConnection(opts, async () => { + const balance = await api.getAccountBalance(id, cutoff); + printOutput({ id, balance }, opts.format); + }); + }); +} diff --git a/packages/cli/src/commands/budgets.ts b/packages/cli/src/commands/budgets.ts new file mode 100644 index 0000000000..6809f30d5f --- /dev/null +++ b/packages/cli/src/commands/budgets.ts @@ -0,0 +1,135 @@ +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'; + +export function registerBudgetsCommand(program: Command) { + const budgets = program.command('budgets').description('Manage budgets'); + + budgets + .command('list') + .description('List all available budgets') + .action(async () => { + const opts = program.opts(); + await withConnection( + opts, + async () => { + const result = await api.getBudgets(); + printOutput(result, opts.format); + }, + { loadBudget: false }, + ); + }); + + budgets + .command('download ') + .description('Download a budget by sync ID') + .option('--encryption-password ', 'Encryption password') + .action(async (syncId: string, cmdOpts) => { + const opts = program.opts(); + const config = await resolveConfig(opts); + const password = config.encryptionPassword ?? cmdOpts.encryptionPassword; + await withConnection( + opts, + async () => { + await api.downloadBudget(syncId, { + password, + }); + printOutput({ success: true, syncId }, opts.format); + }, + { loadBudget: false }, + ); + }); + + 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); + }); + }); + + budgets + .command('month ') + .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); + }); + }); + + budgets + .command('set-amount') + .description('Set budget amount for a category in a month') + .requiredOption('--month ', 'Budget month (YYYY-MM)') + .requiredOption('--category ', 'Category ID') + .requiredOption('--amount ', 'Amount in cents') + .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); + }); + }); + + budgets + .command('set-carryover') + .description('Enable/disable carryover for a category') + .requiredOption('--month ', 'Budget month (YYYY-MM)') + .requiredOption('--category ', 'Category ID') + .requiredOption('--flag ', 'Enable (true) or disable (false)') + .action(async cmdOpts => { + const flag = parseBoolFlag(cmdOpts.flag, '--flag'); + const opts = program.opts(); + await withConnection(opts, async () => { + await api.setBudgetCarryover(cmdOpts.month, cmdOpts.category, flag); + printOutput({ success: true }, opts.format); + }); + }); + + budgets + .command('hold-next-month') + .description('Hold budget amount for next month') + .requiredOption('--month ', 'Budget month (YYYY-MM)') + .requiredOption('--amount ', 'Amount in cents') + .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); + }); + }); + + budgets + .command('reset-hold') + .description('Reset budget hold for a month') + .requiredOption('--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); + }); + }); +} diff --git a/packages/cli/src/commands/categories.ts b/packages/cli/src/commands/categories.ts new file mode 100644 index 0000000000..d18a3dea89 --- /dev/null +++ b/packages/cli/src/commands/categories.ts @@ -0,0 +1,75 @@ +import * as api from '@actual-app/api'; +import type { Command } from 'commander'; + +import { withConnection } from '../connection'; +import { printOutput } from '../output'; +import { parseBoolFlag } from '../utils'; + +export function registerCategoriesCommand(program: Command) { + const categories = program + .command('categories') + .description('Manage categories'); + + categories + .command('list') + .description('List all categories') + .action(async () => { + const opts = program.opts(); + await withConnection(opts, async () => { + const result = await api.getCategories(); + printOutput(result, opts.format); + }); + }); + + categories + .command('create') + .description('Create a new category') + .requiredOption('--name ', 'Category name') + .requiredOption('--group-id ', 'Category group ID') + .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); + }); + }); + + categories + .command('update ') + .description('Update a category') + .option('--name ', 'New category name') + .option('--hidden ', 'Set hidden status') + .action(async (id: string, cmdOpts) => { + const fields: Record = {}; + if (cmdOpts.name !== undefined) fields.name = cmdOpts.name; + if (cmdOpts.hidden !== undefined) { + fields.hidden = parseBoolFlag(cmdOpts.hidden, '--hidden'); + } + if (Object.keys(fields).length === 0) { + throw new Error('No update fields provided. Use --name or --hidden.'); + } + const opts = program.opts(); + await withConnection(opts, async () => { + await api.updateCategory(id, fields); + printOutput({ success: true, id }, opts.format); + }); + }); + + categories + .command('delete ') + .description('Delete a category') + .option('--transfer-to ', '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); + }); + }); +} diff --git a/packages/cli/src/commands/category-groups.ts b/packages/cli/src/commands/category-groups.ts new file mode 100644 index 0000000000..adc8e147f3 --- /dev/null +++ b/packages/cli/src/commands/category-groups.ts @@ -0,0 +1,73 @@ +import * as api from '@actual-app/api'; +import type { Command } from 'commander'; + +import { withConnection } from '../connection'; +import { printOutput } from '../output'; +import { parseBoolFlag } from '../utils'; + +export function registerCategoryGroupsCommand(program: Command) { + const groups = program + .command('category-groups') + .description('Manage category groups'); + + groups + .command('list') + .description('List all category groups') + .action(async () => { + const opts = program.opts(); + await withConnection(opts, async () => { + const result = await api.getCategoryGroups(); + printOutput(result, opts.format); + }); + }); + + groups + .command('create') + .description('Create a new category group') + .requiredOption('--name ', 'Group name') + .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); + }); + }); + + groups + .command('update ') + .description('Update a category group') + .option('--name ', 'New group name') + .option('--hidden ', 'Set hidden status') + .action(async (id: string, cmdOpts) => { + const fields: Record = {}; + if (cmdOpts.name !== undefined) fields.name = cmdOpts.name; + if (cmdOpts.hidden !== undefined) { + fields.hidden = parseBoolFlag(cmdOpts.hidden, '--hidden'); + } + if (Object.keys(fields).length === 0) { + throw new Error('No update fields provided. Use --name or --hidden.'); + } + const opts = program.opts(); + await withConnection(opts, async () => { + await api.updateCategoryGroup(id, fields); + printOutput({ success: true, id }, opts.format); + }); + }); + + groups + .command('delete ') + .description('Delete a category group') + .option('--transfer-to ', '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); + }); + }); +} diff --git a/packages/cli/src/commands/payees.ts b/packages/cli/src/commands/payees.ts new file mode 100644 index 0000000000..f569f983ec --- /dev/null +++ b/packages/cli/src/commands/payees.ts @@ -0,0 +1,95 @@ +import * as api from '@actual-app/api'; +import type { Command } from 'commander'; + +import { withConnection } from '../connection'; +import { printOutput } from '../output'; + +export function registerPayeesCommand(program: Command) { + const payees = program.command('payees').description('Manage payees'); + + payees + .command('list') + .description('List all payees') + .action(async () => { + const opts = program.opts(); + await withConnection(opts, async () => { + const result = await api.getPayees(); + printOutput(result, opts.format); + }); + }); + + payees + .command('common') + .description('List frequently used payees') + .action(async () => { + const opts = program.opts(); + await withConnection(opts, async () => { + const result = await api.getCommonPayees(); + printOutput(result, opts.format); + }); + }); + + payees + .command('create') + .description('Create a new payee') + .requiredOption('--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); + }); + }); + + payees + .command('update ') + .description('Update a payee') + .option('--name ', 'New payee name') + .action(async (id: string, cmdOpts) => { + const fields: Record = {}; + if (cmdOpts.name) fields.name = cmdOpts.name; + if (Object.keys(fields).length === 0) { + throw new Error( + 'No fields to update. Use --name to specify a new name.', + ); + } + const opts = program.opts(); + await withConnection(opts, async () => { + await api.updatePayee(id, fields); + printOutput({ success: true, id }, opts.format); + }); + }); + + payees + .command('delete ') + .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); + }); + }); + + payees + .command('merge') + .description('Merge payees into a target payee') + .requiredOption('--target ', 'Target payee ID') + .requiredOption('--ids ', 'Comma-separated payee IDs to merge') + .action(async (cmdOpts: { target: string; ids: string }) => { + const mergeIds = cmdOpts.ids + .split(',') + .map(id => id.trim()) + .filter(id => id.length > 0); + if (mergeIds.length === 0) { + throw new Error( + 'No valid payee IDs provided in --ids. Provide comma-separated IDs.', + ); + } + const opts = program.opts(); + await withConnection(opts, async () => { + await api.mergePayees(cmdOpts.target, mergeIds); + printOutput({ success: true }, opts.format); + }); + }); +} diff --git a/packages/cli/src/commands/query.ts b/packages/cli/src/commands/query.ts new file mode 100644 index 0000000000..eaca33729e --- /dev/null +++ b/packages/cli/src/commands/query.ts @@ -0,0 +1,93 @@ +import * as api from '@actual-app/api'; +import type { Command } from 'commander'; + +import { withConnection } from '../connection'; +import { readJsonInput } from '../input'; +import { printOutput } from '../output'; +import { parseIntFlag } from '../utils'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function buildQueryFromFile( + parsed: Record, + fallbackTable: string | undefined, +) { + const table = typeof parsed.table === 'string' ? parsed.table : fallbackTable; + if (!table) { + throw new Error( + '--table is required when the input file lacks a "table" field', + ); + } + let queryObj = api.q(table); + if (Array.isArray(parsed.select)) queryObj = queryObj.select(parsed.select); + if (isRecord(parsed.filter)) queryObj = queryObj.filter(parsed.filter); + if (Array.isArray(parsed.orderBy)) { + queryObj = queryObj.orderBy(parsed.orderBy); + } + if (typeof parsed.limit === 'number') queryObj = queryObj.limit(parsed.limit); + return queryObj; +} + +function buildQueryFromFlags(cmdOpts: Record) { + if (!cmdOpts.table) { + throw new Error('--table is required (or use --file)'); + } + let queryObj = api.q(cmdOpts.table); + + if (cmdOpts.select) { + queryObj = queryObj.select(cmdOpts.select.split(',')); + } + + if (cmdOpts.filter) { + queryObj = queryObj.filter(JSON.parse(cmdOpts.filter)); + } + + if (cmdOpts.orderBy) { + queryObj = queryObj.orderBy(cmdOpts.orderBy.split(',')); + } + + if (cmdOpts.limit) { + queryObj = queryObj.limit(parseIntFlag(cmdOpts.limit, '--limit')); + } + + return queryObj; +} + +export function registerQueryCommand(program: Command) { + const query = program + .command('query') + .description('Run AQL (Actual Query Language) queries'); + + query + .command('run') + .description('Execute an AQL query') + .option( + '--table ', + 'Table to query (transactions, accounts, categories, payees)', + ) + .option('--select ', 'Comma-separated fields to select') + .option('--filter ', 'Filter expression as JSON') + .option('--order-by ', 'Comma-separated fields to order by') + .option('--limit ', 'Limit number of results') + .option( + '--file ', + 'Read full query object from JSON file (use - for stdin)', + ) + .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); + + const result = await api.aqlQuery(queryObj); + printOutput(result, opts.format); + }); + }); +} diff --git a/packages/cli/src/commands/rules.ts b/packages/cli/src/commands/rules.ts new file mode 100644 index 0000000000..ef81cd7438 --- /dev/null +++ b/packages/cli/src/commands/rules.ts @@ -0,0 +1,77 @@ +import * as api from '@actual-app/api'; +import type { Command } from 'commander'; + +import { withConnection } from '../connection'; +import { readJsonInput } from '../input'; +import { printOutput } from '../output'; + +export function registerRulesCommand(program: Command) { + const rules = program + .command('rules') + .description('Manage transaction rules'); + + rules + .command('list') + .description('List all rules') + .action(async () => { + const opts = program.opts(); + await withConnection(opts, async () => { + const result = await api.getRules(); + printOutput(result, opts.format); + }); + }); + + rules + .command('payee-rules ') + .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); + }); + }); + + rules + .command('create') + .description('Create a new rule') + .option('--data ', 'Rule definition as JSON') + .option('--file ', '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); + }); + }); + + rules + .command('update') + .description('Update a rule') + .option('--data ', 'Rule data as JSON (must include id)') + .option('--file ', '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); + }); + }); + + rules + .command('delete ') + .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); + }); + }); +} diff --git a/packages/cli/src/commands/schedules.ts b/packages/cli/src/commands/schedules.ts new file mode 100644 index 0000000000..a7f834ddd8 --- /dev/null +++ b/packages/cli/src/commands/schedules.ts @@ -0,0 +1,67 @@ +import * as api from '@actual-app/api'; +import type { Command } from 'commander'; + +import { withConnection } from '../connection'; +import { readJsonInput } from '../input'; +import { printOutput } from '../output'; + +export function registerSchedulesCommand(program: Command) { + const schedules = program + .command('schedules') + .description('Manage scheduled transactions'); + + schedules + .command('list') + .description('List all schedules') + .action(async () => { + const opts = program.opts(); + await withConnection(opts, async () => { + const result = await api.getSchedules(); + printOutput(result, opts.format); + }); + }); + + schedules + .command('create') + .description('Create a new schedule') + .option('--data ', 'Schedule definition as JSON') + .option('--file ', '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); + }); + }); + + schedules + .command('update ') + .description('Update a schedule') + .option('--data ', 'Fields to update as JSON') + .option('--file ', 'Read fields from JSON file (use - for stdin)') + .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); + }); + }); + + schedules + .command('delete ') + .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); + }); + }); +} diff --git a/packages/cli/src/commands/server.ts b/packages/cli/src/commands/server.ts new file mode 100644 index 0000000000..f151ce46df --- /dev/null +++ b/packages/cli/src/commands/server.ts @@ -0,0 +1,60 @@ +import * as api from '@actual-app/api'; +import { Option } from 'commander'; +import type { Command } from 'commander'; + +import { withConnection } from '../connection'; +import { printOutput } from '../output'; + +export function registerServerCommand(program: Command) { + const server = program.command('server').description('Server utilities'); + + server + .command('version') + .description('Get server version') + .action(async () => { + const opts = program.opts(); + await withConnection( + opts, + async () => { + const version = await api.getServerVersion(); + printOutput({ version }, opts.format); + }, + { loadBudget: false }, + ); + }); + + server + .command('get-id') + .description('Get entity ID by name') + .addOption( + new Option('--type ', 'Entity type') + .choices(['accounts', 'categories', 'payees', 'schedules']) + .makeOptionMandatory(), + ) + .requiredOption('--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, + ); + }); + }); + + server + .command('bank-sync') + .description('Run bank synchronization') + .option('--account ', '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); + }); + }); +} diff --git a/packages/cli/src/commands/tags.ts b/packages/cli/src/commands/tags.ts new file mode 100644 index 0000000000..b730fde798 --- /dev/null +++ b/packages/cli/src/commands/tags.ts @@ -0,0 +1,74 @@ +import * as api from '@actual-app/api'; +import type { Command } from 'commander'; + +import { withConnection } from '../connection'; +import { printOutput } from '../output'; + +export function registerTagsCommand(program: Command) { + const tags = program.command('tags').description('Manage tags'); + + tags + .command('list') + .description('List all tags') + .action(async () => { + const opts = program.opts(); + await withConnection(opts, async () => { + const result = await api.getTags(); + printOutput(result, opts.format); + }); + }); + + tags + .command('create') + .description('Create a new tag') + .requiredOption('--tag ', 'Tag name') + .option('--color ', 'Tag color') + .option('--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); + }); + }); + + tags + .command('update ') + .description('Update a tag') + .option('--tag ', 'New tag name') + .option('--color ', 'New tag color') + .option('--description ', 'New tag description') + .action(async (id: string, cmdOpts) => { + const fields: Record = {}; + if (cmdOpts.tag !== undefined) fields.tag = cmdOpts.tag; + if (cmdOpts.color !== undefined) fields.color = cmdOpts.color; + if (cmdOpts.description !== undefined) { + fields.description = cmdOpts.description; + } + if (Object.keys(fields).length === 0) { + throw new Error( + 'At least one of --tag, --color, or --description is required', + ); + } + const opts = program.opts(); + await withConnection(opts, async () => { + await api.updateTag(id, fields); + printOutput({ success: true, id }, opts.format); + }); + }); + + tags + .command('delete ') + .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); + }); + }); +} diff --git a/packages/cli/src/commands/transactions.ts b/packages/cli/src/commands/transactions.ts new file mode 100644 index 0000000000..fbc9ca8d3f --- /dev/null +++ b/packages/cli/src/commands/transactions.ts @@ -0,0 +1,114 @@ +import * as api from '@actual-app/api'; +import type { Command } from 'commander'; + +import { withConnection } from '../connection'; +import { readJsonInput } from '../input'; +import { printOutput } from '../output'; + +export function registerTransactionsCommand(program: Command) { + const transactions = program + .command('transactions') + .description('Manage transactions'); + + transactions + .command('list') + .description('List transactions for an account') + .requiredOption('--account ', 'Account ID') + .requiredOption('--start ', 'Start date (YYYY-MM-DD)') + .requiredOption('--end ', '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); + }); + }); + + transactions + .command('add') + .description('Add transactions to an account') + .requiredOption('--account ', 'Account ID') + .option('--data ', 'Transaction data as JSON array') + .option( + '--file ', + 'Read transaction data from JSON file (use - for stdin)', + ) + .option('--learn-categories', 'Learn category assignments', false) + .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); + }); + }); + + transactions + .command('import') + .description('Import transactions to an account') + .requiredOption('--account ', 'Account ID') + .option('--data ', 'Transaction data as JSON array') + .option( + '--file ', + 'Read transaction data from JSON file (use - for stdin)', + ) + .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); + }); + }); + + transactions + .command('update ') + .description('Update a transaction') + .option('--data ', 'Fields to update as JSON') + .option('--file ', '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); + }); + }); + + transactions + .command('delete ') + .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); + }); + }); +} diff --git a/packages/cli/src/config.test.ts b/packages/cli/src/config.test.ts new file mode 100644 index 0000000000..9284d45654 --- /dev/null +++ b/packages/cli/src/config.test.ts @@ -0,0 +1,185 @@ +import { homedir } from 'os'; +import { join } from 'path'; + +import { resolveConfig } from './config'; + +const mockSearch = vi.fn().mockResolvedValue(null); + +vi.mock('cosmiconfig', () => ({ + cosmiconfig: () => ({ + search: (...args: unknown[]) => mockSearch(...args), + }), +})); + +function mockConfigFile(config: Record | null) { + if (config) { + mockSearch.mockResolvedValue({ config, isEmpty: false }); + } else { + mockSearch.mockResolvedValue(null); + } +} + +describe('resolveConfig', () => { + const savedEnv: Record = {}; + const envKeys = [ + 'ACTUAL_SERVER_URL', + 'ACTUAL_PASSWORD', + 'ACTUAL_SESSION_TOKEN', + 'ACTUAL_SYNC_ID', + 'ACTUAL_DATA_DIR', + 'ACTUAL_ENCRYPTION_PASSWORD', + ]; + + beforeEach(() => { + for (const key of envKeys) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + mockConfigFile(null); + }); + + afterEach(() => { + for (const key of envKeys) { + if (savedEnv[key] !== undefined) { + process.env[key] = savedEnv[key]; + } else { + delete process.env[key]; + } + } + }); + + describe('priority chain', () => { + it('CLI opts take highest priority', async () => { + process.env.ACTUAL_SERVER_URL = 'http://env'; + process.env.ACTUAL_PASSWORD = 'envpw'; + process.env.ACTUAL_ENCRYPTION_PASSWORD = 'env-enc'; + mockConfigFile({ + serverUrl: 'http://file', + password: 'filepw', + encryptionPassword: 'file-enc', + }); + + const config = await resolveConfig({ + serverUrl: 'http://cli', + password: 'clipw', + encryptionPassword: 'cli-enc', + }); + + expect(config.serverUrl).toBe('http://cli'); + expect(config.password).toBe('clipw'); + expect(config.encryptionPassword).toBe('cli-enc'); + }); + + it('env vars override file config', async () => { + process.env.ACTUAL_SERVER_URL = 'http://env'; + process.env.ACTUAL_PASSWORD = 'envpw'; + process.env.ACTUAL_ENCRYPTION_PASSWORD = 'env-enc'; + mockConfigFile({ + serverUrl: 'http://file', + password: 'filepw', + encryptionPassword: 'file-enc', + }); + + const config = await resolveConfig({}); + + expect(config.serverUrl).toBe('http://env'); + expect(config.password).toBe('envpw'); + expect(config.encryptionPassword).toBe('env-enc'); + }); + + it('file config is used when no CLI opts or env vars', async () => { + mockConfigFile({ + serverUrl: 'http://file', + password: 'filepw', + syncId: 'budget-1', + encryptionPassword: 'file-enc', + }); + + const config = await resolveConfig({}); + + expect(config.serverUrl).toBe('http://file'); + expect(config.password).toBe('filepw'); + expect(config.syncId).toBe('budget-1'); + expect(config.encryptionPassword).toBe('file-enc'); + }); + }); + + describe('defaults', () => { + it('dataDir defaults to ~/.actual-cli/data', async () => { + const config = await resolveConfig({ + serverUrl: 'http://test', + password: 'pw', + }); + + expect(config.dataDir).toBe(join(homedir(), '.actual-cli', 'data')); + }); + + it('CLI opt overrides default dataDir', async () => { + const config = await resolveConfig({ + serverUrl: 'http://test', + password: 'pw', + dataDir: '/custom/dir', + }); + + expect(config.dataDir).toBe('/custom/dir'); + }); + }); + + describe('validation', () => { + it('throws when serverUrl is missing', async () => { + await expect(resolveConfig({ password: 'pw' })).rejects.toThrow( + 'Server URL is required', + ); + }); + + it('throws when neither password nor sessionToken provided', async () => { + await expect(resolveConfig({ serverUrl: 'http://test' })).rejects.toThrow( + 'Authentication required', + ); + }); + + it('accepts sessionToken without password', async () => { + const config = await resolveConfig({ + serverUrl: 'http://test', + sessionToken: 'tok', + }); + + expect(config.sessionToken).toBe('tok'); + expect(config.password).toBeUndefined(); + }); + + it('accepts password without sessionToken', async () => { + const config = await resolveConfig({ + serverUrl: 'http://test', + password: 'pw', + }); + + expect(config.password).toBe('pw'); + expect(config.sessionToken).toBeUndefined(); + }); + }); + + describe('cosmiconfig handling', () => { + it('handles null result (no config file found)', async () => { + mockConfigFile(null); + + const config = await resolveConfig({ + serverUrl: 'http://test', + password: 'pw', + }); + + expect(config.serverUrl).toBe('http://test'); + }); + + it('handles isEmpty result', async () => { + mockSearch.mockResolvedValue({ config: {}, isEmpty: true }); + + const config = await resolveConfig({ + serverUrl: 'http://test', + password: 'pw', + }); + + expect(config.serverUrl).toBe('http://test'); + }); + }); +}); diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts new file mode 100644 index 0000000000..1a105b6f0a --- /dev/null +++ b/packages/cli/src/config.ts @@ -0,0 +1,141 @@ +import { homedir } from 'os'; +import { join } from 'path'; + +import { cosmiconfig } from 'cosmiconfig'; + +export type CliConfig = { + serverUrl: string; + password?: string; + sessionToken?: string; + syncId?: string; + dataDir: string; + encryptionPassword?: string; +}; + +export type CliGlobalOpts = { + serverUrl?: string; + password?: string; + sessionToken?: string; + syncId?: string; + dataDir?: string; + encryptionPassword?: string; + format?: 'json' | 'table' | 'csv'; + verbose?: boolean; +}; + +type ConfigFileContent = { + serverUrl?: string; + password?: string; + sessionToken?: string; + syncId?: string; + dataDir?: string; + encryptionPassword?: string; +}; + +const configFileKeys: readonly string[] = [ + 'serverUrl', + 'password', + 'sessionToken', + 'syncId', + 'dataDir', + 'encryptionPassword', +]; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function validateConfigFileContent(value: unknown): ConfigFileContent { + if (!isRecord(value)) { + throw new Error( + 'Invalid config file: expected an object with keys: ' + + configFileKeys.join(', '), + ); + } + for (const key of Object.keys(value)) { + if (!configFileKeys.includes(key)) { + throw new Error(`Invalid config file: unknown key "${key}"`); + } + if (value[key] !== undefined && typeof value[key] !== 'string') { + throw new Error( + `Invalid config file: key "${key}" must be a string, got ${typeof value[key]}`, + ); + } + } + return value as ConfigFileContent; +} + +async function loadConfigFile(): Promise { + const explorer = cosmiconfig('actual', { + searchPlaces: [ + 'package.json', + '.actualrc', + '.actualrc.json', + '.actualrc.yaml', + '.actualrc.yml', + 'actual.config.json', + 'actual.config.yaml', + 'actual.config.yml', + ], + }); + const result = await explorer.search(); + if (result && !result.isEmpty) { + return validateConfigFileContent(result.config); + } + return {}; +} + +export async function resolveConfig( + cliOpts: CliGlobalOpts, +): Promise { + const fileConfig = await loadConfigFile(); + + const serverUrl = + cliOpts.serverUrl ?? + process.env.ACTUAL_SERVER_URL ?? + fileConfig.serverUrl ?? + ''; + + const password = + cliOpts.password ?? process.env.ACTUAL_PASSWORD ?? fileConfig.password; + + const sessionToken = + cliOpts.sessionToken ?? + process.env.ACTUAL_SESSION_TOKEN ?? + fileConfig.sessionToken; + + const syncId = + cliOpts.syncId ?? process.env.ACTUAL_SYNC_ID ?? fileConfig.syncId; + + const dataDir = + cliOpts.dataDir ?? + process.env.ACTUAL_DATA_DIR ?? + fileConfig.dataDir ?? + join(homedir(), '.actual-cli', 'data'); + + const encryptionPassword = + cliOpts.encryptionPassword ?? + process.env.ACTUAL_ENCRYPTION_PASSWORD ?? + fileConfig.encryptionPassword; + + if (!serverUrl) { + throw new Error( + 'Server URL is required. Set --server-url, ACTUAL_SERVER_URL env var, or serverUrl in config file.', + ); + } + + if (!password && !sessionToken) { + throw new Error( + 'Authentication required. Set --password/--session-token, ACTUAL_PASSWORD/ACTUAL_SESSION_TOKEN env var, or password/sessionToken in config file.', + ); + } + + return { + serverUrl, + password, + sessionToken, + syncId, + dataDir, + encryptionPassword, + }; +} diff --git a/packages/cli/src/connection.test.ts b/packages/cli/src/connection.test.ts new file mode 100644 index 0000000000..9744f622b4 --- /dev/null +++ b/packages/cli/src/connection.test.ts @@ -0,0 +1,134 @@ +import * as api from '@actual-app/api'; + +import { resolveConfig } from './config'; +import { withConnection } from './connection'; + +vi.mock('@actual-app/api', () => ({ + init: vi.fn().mockResolvedValue(undefined), + downloadBudget: vi.fn().mockResolvedValue(undefined), + shutdown: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('./config', () => ({ + resolveConfig: vi.fn(), +})); + +function setConfig(overrides: Record = {}) { + vi.mocked(resolveConfig).mockResolvedValue({ + serverUrl: 'http://test', + password: 'pw', + dataDir: '/tmp/data', + syncId: 'budget-1', + ...overrides, + }); +} + +describe('withConnection', () => { + let stderrSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + stderrSpy = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + setConfig(); + }); + + afterEach(() => { + stderrSpy.mockRestore(); + }); + + it('calls api.init with password when no sessionToken', async () => { + setConfig({ password: 'pw', sessionToken: undefined }); + + await withConnection({}, async () => 'ok'); + + expect(api.init).toHaveBeenCalledWith({ + serverURL: 'http://test', + password: 'pw', + dataDir: '/tmp/data', + verbose: undefined, + }); + }); + + it('calls api.init with sessionToken when present', async () => { + setConfig({ sessionToken: 'tok', password: undefined }); + + await withConnection({}, async () => 'ok'); + + expect(api.init).toHaveBeenCalledWith({ + serverURL: 'http://test', + sessionToken: 'tok', + dataDir: '/tmp/data', + 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', { + password: undefined, + }); + }); + + 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 }); + + 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('returns callback result', async () => { + const result = await withConnection({}, async () => 42); + expect(result).toBe(42); + }); + + it('calls api.shutdown in finally block on success', async () => { + await withConnection({}, async () => 'ok'); + expect(api.shutdown).toHaveBeenCalled(); + }); + + it('calls api.shutdown in finally block on error', async () => { + await expect( + withConnection({}, async () => { + throw new Error('boom'); + }), + ).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'), + ); + }); +}); diff --git a/packages/cli/src/connection.ts b/packages/cli/src/connection.ts new file mode 100644 index 0000000000..7d87663d9a --- /dev/null +++ b/packages/cli/src/connection.ts @@ -0,0 +1,65 @@ +import { mkdirSync } from 'fs'; + +import * as api from '@actual-app/api'; + +import { resolveConfig } from './config'; +import type { CliGlobalOpts } from './config'; + +function info(message: string, verbose?: boolean) { + if (verbose) { + process.stderr.write(message + '\n'); + } +} + +type ConnectionOptions = { + loadBudget?: boolean; +}; + +export async function withConnection( + globalOpts: CliGlobalOpts, + fn: () => Promise, + options: ConnectionOptions = {}, +): Promise { + const { loadBudget = true } = options; + const config = await resolveConfig(globalOpts); + + mkdirSync(config.dataDir, { recursive: true }); + + info(`Connecting to ${config.serverUrl}...`, globalOpts.verbose); + + if (config.sessionToken) { + await api.init({ + serverURL: config.serverUrl, + dataDir: config.dataDir, + sessionToken: config.sessionToken, + verbose: globalOpts.verbose, + }); + } else if (config.password) { + await api.init({ + serverURL: config.serverUrl, + dataDir: config.dataDir, + password: config.password, + verbose: globalOpts.verbose, + }); + } else { + throw new Error( + 'Authentication required. Provide --password or --session-token, or set ACTUAL_PASSWORD / ACTUAL_SESSION_TOKEN.', + ); + } + + 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) { + throw new Error( + 'Sync ID is required for this command. Set --sync-id or ACTUAL_SYNC_ID.', + ); + } + return await fn(); + } finally { + await api.shutdown(); + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 0000000000..75594c3779 --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1,70 @@ +import { Command, Option } from 'commander'; + +import { registerAccountsCommand } from './commands/accounts'; +import { registerBudgetsCommand } from './commands/budgets'; +import { registerCategoriesCommand } from './commands/categories'; +import { registerCategoryGroupsCommand } from './commands/category-groups'; +import { registerPayeesCommand } from './commands/payees'; +import { registerQueryCommand } from './commands/query'; +import { registerRulesCommand } from './commands/rules'; +import { registerSchedulesCommand } from './commands/schedules'; +import { registerServerCommand } from './commands/server'; +import { registerTagsCommand } from './commands/tags'; +import { registerTransactionsCommand } from './commands/transactions'; + +declare const __CLI_VERSION__: string; + +const program = new Command(); + +program + .name('actual') + .description('CLI for Actual Budget') + .version(__CLI_VERSION__) + .option('--server-url ', 'Actual server URL (env: ACTUAL_SERVER_URL)') + .option('--password ', 'Server password (env: ACTUAL_PASSWORD)') + .option( + '--session-token ', + 'Session token (env: ACTUAL_SESSION_TOKEN)', + ) + .option('--sync-id ', 'Budget sync ID (env: ACTUAL_SYNC_ID)') + .option('--data-dir ', 'Data directory (env: ACTUAL_DATA_DIR)') + .option( + '--encryption-password ', + 'E2E encryption password (env: ACTUAL_ENCRYPTION_PASSWORD)', + ) + .addOption( + new Option('--format ', 'Output format: json, table, csv') + .choices(['json', 'table', 'csv'] as const) + .default('json'), + ) + .option('--verbose', 'Show informational messages', false); + +registerAccountsCommand(program); +registerBudgetsCommand(program); +registerCategoriesCommand(program); +registerCategoryGroupsCommand(program); +registerTransactionsCommand(program); +registerPayeesCommand(program); +registerTagsCommand(program); +registerRulesCommand(program); +registerSchedulesCommand(program); +registerQueryCommand(program); +registerServerCommand(program); + +function normalizeThrownMessage(err: unknown): string { + if (err instanceof Error) return err.message; + if (typeof err === 'object' && err !== null) { + try { + return JSON.stringify(err); + } catch { + return ''; + } + } + return String(err); +} + +program.parseAsync(process.argv).catch((err: unknown) => { + const message = normalizeThrownMessage(err); + process.stderr.write(`Error: ${message}\n`); + process.exitCode = 1; +}); diff --git a/packages/cli/src/input.ts b/packages/cli/src/input.ts new file mode 100644 index 0000000000..1072b39088 --- /dev/null +++ b/packages/cli/src/input.ts @@ -0,0 +1,21 @@ +import { readFileSync } from 'fs'; + +export function readJsonInput(cmdOpts: { + data?: string; + file?: string; +}): unknown { + if (cmdOpts.data && cmdOpts.file) { + throw new Error('Cannot use both --data and --file'); + } + if (cmdOpts.data) { + return JSON.parse(cmdOpts.data); + } + if (cmdOpts.file) { + const content = + cmdOpts.file === '-' + ? readFileSync(0, 'utf-8') + : readFileSync(cmdOpts.file, 'utf-8'); + return JSON.parse(content); + } + throw new Error('Either --data or --file is required'); +} diff --git a/packages/cli/src/output.test.ts b/packages/cli/src/output.test.ts new file mode 100644 index 0000000000..ed0a4e49dd --- /dev/null +++ b/packages/cli/src/output.test.ts @@ -0,0 +1,152 @@ +import { formatOutput, printOutput } from './output'; + +describe('formatOutput', () => { + describe('json (default)', () => { + it('pretty-prints with 2-space indent', () => { + const data = { a: 1, b: 'two' }; + expect(formatOutput(data)).toBe(JSON.stringify(data, null, 2)); + }); + + it('is the default format', () => { + expect(formatOutput({ x: 1 })).toBe(formatOutput({ x: 1 }, 'json')); + }); + + it('handles arrays', () => { + const data = [1, 2, 3]; + expect(formatOutput(data, 'json')).toBe('[\n 1,\n 2,\n 3\n]'); + }); + + it('handles null', () => { + expect(formatOutput(null, 'json')).toBe('null'); + }); + }); + + describe('table', () => { + it('renders an object as key-value table', () => { + const result = formatOutput({ name: 'Alice', age: 30 }, 'table'); + expect(result).toContain('name'); + expect(result).toContain('Alice'); + expect(result).toContain('age'); + expect(result).toContain('30'); + }); + + it('renders an array of objects as columnar table', () => { + const data = [ + { id: 1, name: 'a' }, + { id: 2, name: 'b' }, + ]; + const result = formatOutput(data, 'table'); + expect(result).toContain('id'); + expect(result).toContain('name'); + expect(result).toContain('1'); + expect(result).toContain('a'); + expect(result).toContain('2'); + expect(result).toContain('b'); + }); + + it('returns "(no results)" for empty array', () => { + expect(formatOutput([], 'table')).toBe('(no results)'); + }); + + it('returns String(data) for scalar values', () => { + expect(formatOutput(42, 'table')).toBe('42'); + expect(formatOutput('hello', 'table')).toBe('hello'); + expect(formatOutput(true, 'table')).toBe('true'); + }); + + it('handles null/undefined values in objects', () => { + const data = [{ a: null, b: undefined }]; + const result = formatOutput(data, 'table'); + expect(result).toContain('a'); + expect(result).toContain('b'); + }); + }); + + describe('csv', () => { + it('renders array of objects as header + data rows', () => { + const data = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ]; + const result = formatOutput(data, 'csv'); + const lines = result.split('\n'); + expect(lines[0]).toBe('id,name'); + expect(lines[1]).toBe('1,Alice'); + expect(lines[2]).toBe('2,Bob'); + }); + + it('renders single object as header + single row', () => { + const result = formatOutput({ x: 10, y: 20 }, 'csv'); + const lines = result.split('\n'); + expect(lines[0]).toBe('x,y'); + expect(lines[1]).toBe('10,20'); + }); + + it('returns empty string for empty array', () => { + expect(formatOutput([], 'csv')).toBe(''); + }); + + it('returns String(data) for scalar values', () => { + expect(formatOutput(42, 'csv')).toBe('42'); + expect(formatOutput('hello', 'csv')).toBe('hello'); + }); + + it('escapes commas by quoting', () => { + const data = [{ val: 'a,b' }]; + expect(formatOutput(data, 'csv')).toBe('val\n"a,b"'); + }); + + it('escapes double quotes by doubling them', () => { + const data = [{ val: 'say "hi"' }]; + expect(formatOutput(data, 'csv')).toBe('val\n"say ""hi"""'); + }); + + it('escapes newlines by quoting', () => { + const data = [{ val: 'line1\nline2' }]; + expect(formatOutput(data, 'csv')).toBe('val\n"line1\nline2"'); + }); + + it('handles null/undefined values', () => { + const data = [{ a: null, b: undefined }]; + const result = formatOutput(data, 'csv'); + const lines = result.split('\n'); + expect(lines[0]).toBe('a,b'); + }); + }); +}); + +describe('printOutput', () => { + let writeSpy: ReturnType; + + beforeEach(() => { + writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + writeSpy.mockRestore(); + }); + + it('writes formatted output followed by newline', () => { + printOutput({ a: 1 }, 'json'); + expect(writeSpy).toHaveBeenCalledWith( + JSON.stringify({ a: 1 }, null, 2) + '\n', + ); + }); + + it('defaults to json format', () => { + printOutput([1, 2]); + expect(writeSpy).toHaveBeenCalledWith( + JSON.stringify([1, 2], null, 2) + '\n', + ); + }); + + it('supports table format', () => { + printOutput([], 'table'); + expect(writeSpy).toHaveBeenCalledWith('(no results)\n'); + }); + + it('supports csv format', () => { + printOutput([], 'csv'); + expect(writeSpy).toHaveBeenCalledWith('\n'); + }); +}); diff --git a/packages/cli/src/output.ts b/packages/cli/src/output.ts new file mode 100644 index 0000000000..1693885af0 --- /dev/null +++ b/packages/cli/src/output.ts @@ -0,0 +1,82 @@ +import Table from 'cli-table3'; + +export type OutputFormat = 'json' | 'table' | 'csv'; + +export function formatOutput( + data: unknown, + format: OutputFormat = 'json', +): string { + switch (format) { + case 'json': + return JSON.stringify(data, null, 2); + case 'table': + return formatTable(data); + case 'csv': + return formatCsv(data); + default: + return JSON.stringify(data, null, 2); + } +} + +function formatTable(data: unknown): string { + if (!Array.isArray(data)) { + if (data && typeof data === 'object') { + const table = new Table(); + for (const [key, value] of Object.entries(data)) { + table.push({ [key]: String(value) }); + } + return table.toString(); + } + return String(data); + } + + if (data.length === 0) { + return '(no results)'; + } + + const keys = Object.keys(data[0] as Record); + const table = new Table({ head: keys }); + + for (const row of data) { + const r = row as Record; + table.push(keys.map(k => String(r[k] ?? ''))); + } + + return table.toString(); +} + +function formatCsv(data: unknown): string { + if (!Array.isArray(data)) { + 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(','); + return header + '\n' + values; + } + return String(data); + } + + if (data.length === 0) { + return ''; + } + + const keys = Object.keys(data[0] as Record); + const header = keys.map(k => escapeCsv(k)).join(','); + const rows = data.map(row => { + const r = row as Record; + return keys.map(k => escapeCsv(String(r[k] ?? ''))).join(','); + }); + + return [header, ...rows].join('\n'); +} + +function escapeCsv(value: string): string { + if (value.includes(',') || value.includes('"') || value.includes('\n')) { + return '"' + value.replace(/"/g, '""') + '"'; + } + return value; +} + +export function printOutput(data: unknown, format: OutputFormat = 'json') { + process.stdout.write(formatOutput(data, format) + '\n'); +} diff --git a/packages/cli/src/utils.test.ts b/packages/cli/src/utils.test.ts new file mode 100644 index 0000000000..e843442f33 --- /dev/null +++ b/packages/cli/src/utils.test.ts @@ -0,0 +1,65 @@ +import { parseBoolFlag, parseIntFlag } from './utils'; + +describe('parseBoolFlag', () => { + it('parses "true"', () => { + expect(parseBoolFlag('true', '--flag')).toBe(true); + }); + + it('parses "false"', () => { + expect(parseBoolFlag('false', '--flag')).toBe(false); + }); + + it('rejects other strings', () => { + expect(() => parseBoolFlag('yes', '--flag')).toThrow( + 'Invalid --flag: "yes". Expected "true" or "false".', + ); + }); + + it('includes the flag name in the error message', () => { + expect(() => parseBoolFlag('1', '--offbudget')).toThrow( + 'Invalid --offbudget', + ); + }); +}); + +describe('parseIntFlag', () => { + it('parses a valid integer string', () => { + expect(parseIntFlag('42', '--balance')).toBe(42); + }); + + it('parses zero', () => { + expect(parseIntFlag('0', '--balance')).toBe(0); + }); + + it('parses negative integers', () => { + expect(parseIntFlag('-10', '--balance')).toBe(-10); + }); + + it('rejects decimal values', () => { + expect(() => parseIntFlag('3.5', '--balance')).toThrow( + 'Invalid --balance: "3.5". Expected an integer.', + ); + }); + + it('rejects non-numeric strings', () => { + expect(() => parseIntFlag('abc', '--balance')).toThrow( + 'Invalid --balance: "abc". Expected an integer.', + ); + }); + + it('rejects partially numeric strings', () => { + expect(() => parseIntFlag('3abc', '--balance')).toThrow( + 'Invalid --balance: "3abc". Expected an integer.', + ); + }); + + it('rejects empty string', () => { + expect(() => parseIntFlag('', '--balance')).toThrow( + 'Invalid --balance: "". Expected an integer.', + ); + }); + + it('includes the flag name in the error message', () => { + expect(() => parseIntFlag('x', '--amount')).toThrow('Invalid --amount'); + }); +}); diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts new file mode 100644 index 0000000000..90c305bbb2 --- /dev/null +++ b/packages/cli/src/utils.ts @@ -0,0 +1,16 @@ +export function parseBoolFlag(value: string, flagName: string): boolean { + if (value !== 'true' && value !== 'false') { + throw new Error( + `Invalid ${flagName}: "${value}". Expected "true" or "false".`, + ); + } + return value === 'true'; +} + +export function parseIntFlag(value: string, flagName: string): number { + const parsed = value.trim() === '' ? NaN : Number(value); + if (!Number.isInteger(parsed)) { + throw new Error(`Invalid ${flagName}: "${value}". Expected an integer.`); + } + return parsed; +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000000..5db01c1f7a --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "lib": ["ES2021"], + "types": ["vitest/globals", "node"], + "noEmit": false, + "strict": true, + "outDir": "dist", + "tsBuildInfoFile": "dist/.tsbuildinfo" + }, + "references": [{ "path": "../api" }], + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "coverage"] +} diff --git a/packages/cli/vite.config.ts b/packages/cli/vite.config.ts new file mode 100644 index 0000000000..491c38a048 --- /dev/null +++ b/packages/cli/vite.config.ts @@ -0,0 +1,36 @@ +import fs from 'fs'; +import path from 'path'; + +import { visualizer } from 'rollup-plugin-visualizer'; +import { defineConfig } from 'vite'; + +const pkg = JSON.parse( + fs.readFileSync(path.resolve(__dirname, 'package.json'), 'utf-8'), +); + +export default defineConfig({ + define: { + __CLI_VERSION__: JSON.stringify(pkg.version), + }, + ssr: { noExternal: true, external: ['@actual-app/api'] }, + build: { + ssr: true, + target: 'node22', + outDir: path.resolve(__dirname, 'dist'), + emptyOutDir: true, + lib: { + entry: path.resolve(__dirname, 'src/index.ts'), + formats: ['es'], + }, + rollupOptions: { + output: { + entryFileNames: 'cli.js', + banner: chunk => (chunk.isEntry ? '#!/usr/bin/env node' : ''), + }, + }, + }, + plugins: [visualizer({ template: 'raw-data', filename: 'dist/stats.json' })], + test: { + globals: true, + }, +}); diff --git a/packages/docs/docs-sidebar.js b/packages/docs/docs-sidebar.js index fa3ebf464e..f6a5243efb 100644 --- a/packages/docs/docs-sidebar.js +++ b/packages/docs/docs-sidebar.js @@ -230,6 +230,7 @@ const sidebars = { link: { type: 'doc', id: 'api/index' }, items: [ 'api/reference', + 'api/cli', { type: 'category', label: 'ActualQL', diff --git a/packages/docs/docs/api/cli.md b/packages/docs/docs/api/cli.md new file mode 100644 index 0000000000..aeee9c82e8 --- /dev/null +++ b/packages/docs/docs/api/cli.md @@ -0,0 +1,356 @@ +--- +title: 'CLI' +--- + +# CLI Tool + +:::danger Experimental — API may change +The CLI is **experimental** and its commands, options, and behavior are **likely to change** in future releases. Use it for scripting and automation with the understanding that updates may require changes to your workflows. +::: + +The `@actual-app/cli` package provides a command-line interface for interacting with your Actual Budget data. It connects to your sync server and lets you query and modify budgets, accounts, transactions, categories, payees, rules, schedules, and more — all from the terminal. + +:::note +This is different from the [Server CLI](../install/cli-tool.md) (`@actual-app/sync-server`), which is used to host and manage the Actual server itself. +::: + +## Installation + +Node.js v22 or higher is required. + +```bash +npm install --save @actual-app/cli +``` + +Or install globally: + +```bash +npm install --location=global @actual-app/cli +``` + +## Configuration + +The CLI requires a connection to a running Actual sync server. Configuration can be provided via environment variables, CLI flags, or a config file. + +### Environment Variables + +| Variable | Description | +| ---------------------- | --------------------------------------------------- | +| `ACTUAL_SERVER_URL` | URL of the Actual sync server (required) | +| `ACTUAL_SYNC_ID` | Budget Sync ID (required for most commands) | +| `ACTUAL_PASSWORD` | Server password (one of password or token required) | +| `ACTUAL_SESSION_TOKEN` | Session token (alternative to password) | + +### CLI Flags + +Global flags override environment variables: + +| Flag | Description | +| ------------------------- | ----------------------------------------------- | +| `--server-url ` | Server URL | +| `--password ` | Server password | +| `--session-token ` | Session token | +| `--sync-id ` | Budget Sync ID | +| `--data-dir ` | Local data directory for cached budget data | +| `--format ` | Output format: `json` (default), `table`, `csv` | +| `--verbose` | Show informational messages on stderr | + +### Config File + +The CLI uses [cosmiconfig](https://github.com/cosmiconfig/cosmiconfig) for configuration. You can create a config file in any of these formats: + +- `.actualrc` (JSON or YAML) +- `.actualrc.json`, `.actualrc.yaml`, `.actualrc.yml` +- `actual.config.json`, `actual.config.yaml`, `actual.config.yml` +- An `"actual"` key in your `package.json` + +Example `.actualrc.json`: + +```json +{ + "serverUrl": "http://localhost:5006", + "password": "your-password", + "syncId": "1cfdbb80-6274-49bf-b0c2-737235a4c81f" +} +``` + +:::caution Security +Avoid storing plaintext passwords in config files (including the `password` key above). Prefer environment variables such as `ACTUAL_PASSWORD` or `ACTUAL_SESSION_TOKEN`, or use a session token in config instead of a password. +::: + +## Usage + +```bash +actual [options] +``` + +## Commands + +### Accounts + +```bash +# List all accounts +actual accounts list + +# Create an account +actual accounts create --name "Checking" [--offbudget] [--balance 50000] + +# Update an account +actual accounts update [--name "New Name"] [--offbudget true] + +# Close an account (with optional transfer) +actual accounts close [--transfer-account ] [--transfer-category ] + +# Reopen a closed account +actual accounts reopen + +# Delete an account +actual accounts delete + +# Get account balance +actual accounts balance [--cutoff 2026-01-31] +``` + +### Budgets + +```bash +# List available budgets on the server +actual budgets list + +# Download a budget by sync ID +actual budgets download [--encryption-password ] + +# Sync the current budget +actual budgets sync + +# List budget months +actual budgets months + +# View a specific month +actual budgets month 2026-03 + +# Set a budget amount (in integer cents) +actual budgets set-amount --month 2026-03 --category --amount 50000 + +# Set carryover flag +actual budgets set-carryover --month 2026-03 --category --flag true + +# Hold funds for next month +actual budgets hold-next-month --month 2026-03 --amount 10000 + +# Reset held funds +actual budgets reset-hold --month 2026-03 +``` + +### Categories + +```bash +# List all categories +actual categories list + +# Create a category +actual categories create --name "Groceries" --group-id [--is-income] + +# Update a category +actual categories update [--name "Food"] [--hidden true] + +# Delete a category (with optional transfer) +actual categories delete [--transfer-to ] +``` + +### Category Groups + +```bash +# List all category groups +actual category-groups list + +# Create a category group +actual category-groups create --name "Essentials" [--is-income] + +# Update a category group +actual category-groups update [--name "New Name"] [--hidden true] + +# Delete a category group (with optional transfer) +actual category-groups delete [--transfer-to ] +``` + +### Transactions + +```bash +# List transactions for an account within a date range +actual transactions list --account --start 2026-01-01 --end 2026-03-31 + +# Add transactions (inline JSON) +actual transactions add --account --data '[{"date":"2026-03-13","amount":-5000,"payee_name":"Store"}]' + +# Add transactions (from file) +actual transactions add --account --file transactions.json + +# Import transactions with reconciliation (deduplication) +actual transactions import --account --data '[...]' [--dry-run] + +# Update a transaction +actual transactions update --data '{"notes":"Updated note"}' + +# Delete a transaction +actual transactions delete +``` + +### Payees + +```bash +# List all payees +actual payees list + +# List common payees +actual payees common + +# Create a payee +actual payees create --name "Grocery Store" + +# Update a payee +actual payees update --name "New Name" + +# Delete a payee +actual payees delete + +# Merge multiple payees into one +actual payees merge --target --ids id1,id2,id3 +``` + +### Tags + +```bash +# List all tags +actual tags list + +# Create a tag +actual tags create --tag "vacation" [--color "#ff0000"] [--description "Vacation expenses"] + +# Update a tag +actual tags update [--tag "trip"] [--color "#00ff00"] + +# Delete a tag +actual tags delete +``` + +### Rules + +```bash +# List all rules +actual rules list + +# List rules for a specific payee +actual rules payee-rules + +# Create a rule (inline JSON) +actual rules create --data '{"stage":"pre","conditionsOp":"and","conditions":[...],"actions":[...]}' + +# Create a rule (from file) +actual rules create --file rule.json + +# Update a rule +actual rules update --data '{"id":"...","stage":"pre",...}' + +# Delete a rule +actual rules delete +``` + +### Schedules + +```bash +# List all schedules +actual schedules list + +# Create a schedule +actual schedules create --data '{"name":"Rent","date":"1st","amount":-150000,"amountOp":"is","account":"...","payee":"..."}' + +# Update a schedule +actual schedules update --data '{"name":"Updated Rent"}' [--reset-next-date] + +# Delete a schedule +actual schedules delete +``` + +### Query (ActualQL) + +Run queries using [ActualQL](./actual-ql/index.md): + +```bash +# Run a query (inline) +actual query run --table transactions --select "date,amount,payee" --filter '{"amount":{"$lt":0}}' --limit 10 + +# Run a query (from file) +actual query run --file query.json +``` + +### Server + +```bash +# Get the server version +actual server version + +# Look up an entity ID by name +actual server get-id --type accounts --name "Checking" +actual server get-id --type categories --name "Groceries" + +# Trigger bank sync +actual server bank-sync [--account ] +``` + +## Amount Convention + +All monetary amounts are represented as **integer cents**: + +| CLI Value | Dollar Amount | +| --------- | ------------- | +| `5000` | $50.00 | +| `-12350` | -$123.50 | +| `100` | $1.00 | + +When providing amounts, always use integer cents. For example, to budget $50, pass `5000`. + +## Output Formats + +The `--format` flag controls how results are displayed: + +- **`json`** (default) — Machine-readable JSON output, ideal for scripting +- **`table`** — Human-readable table format +- **`csv`** — Comma-separated values for spreadsheet import + +Use `--verbose` to enable informational messages on stderr for debugging or visibility into what the CLI is doing. + +## Common Workflows + +**View your budget for the current month:** + +```bash +actual budgets month 2026-03 --format table +``` + +**Check an account balance:** + +```bash +# Find the account ID +actual server get-id --type accounts --name "Checking" +# Get the balance +actual accounts balance +``` + +**Export transactions to CSV:** + +```bash +actual transactions list --account --start 2026-01-01 --end 2026-12-31 --format csv > transactions.csv +``` + +**Add a transaction:** + +```bash +actual transactions add --account --data '[{"date":"2026-03-14","amount":-2500,"payee_name":"Coffee Shop"}]' +``` + +## Error Handling + +- Non-zero exit codes indicate an error +- Errors are written as plain text to stderr (e.g., `Error: message`) +- Use `--verbose` to enable informational stderr messages for debugging diff --git a/packages/docs/docs/contributing/development-setup.md b/packages/docs/docs/contributing/development-setup.md index 431dd66ff1..e43b383f95 100644 --- a/packages/docs/docs/contributing/development-setup.md +++ b/packages/docs/docs/contributing/development-setup.md @@ -99,6 +99,9 @@ yarn build:desktop # Build API package yarn build:api +# Build CLI package +yarn build:cli + # Build sync server yarn build:server ``` @@ -160,6 +163,9 @@ yarn build:desktop # API build yarn build:api +# CLI build +yarn build:cli + # Sync server build yarn build:server ``` diff --git a/packages/docs/docs/contributing/releasing.md b/packages/docs/docs/contributing/releasing.md index d4596f630b..5c002b14b9 100644 --- a/packages/docs/docs/contributing/releasing.md +++ b/packages/docs/docs/contributing/releasing.md @@ -4,7 +4,7 @@ In the open-source version of Actual, there are 3 NPM packages: - [@actual-app/api](https://www.npmjs.com/package/@actual-app/api): The API for the underlying functionality. This includes the entire backend of Actual, meant to be used with Node. - [@actual-app/web](https://www.npmjs.com/package/@actual-app/web): A web build that will serve the app with a web frontend. This includes both the frontend and backend of Actual. It includes the backend as well because it's built to be used as a Web Worker. -- [@actual-app/sync-server](https://www.npmjs.com/package/@actual-app/sync-server): The entire sync-server and underlying web client in one package. This includes a CLI tool, meant to be used with Node. +- [@actual-app/sync-server](https://www.npmjs.com/package/@actual-app/sync-server): The entire sync-server and underlying web client in one package. This includes the Server CLI, meant to be used with Node. All packages and the main Actual release are versioned together. That makes it clear which version of the package should be used with the version of Actual. diff --git a/packages/docs/docs/install/build-from-source.md b/packages/docs/docs/install/build-from-source.md index a36efbfde1..3db4b19d3b 100644 --- a/packages/docs/docs/install/build-from-source.md +++ b/packages/docs/docs/install/build-from-source.md @@ -8,7 +8,7 @@ For most cases, we suggest opting for one of the simpler alternatives: - [Pikapods](/docs/install/pikapods) - [Desktop Client](/download) -- [CLI tool](/docs/install/cli-tool) +- [Server CLI](/docs/install/cli-tool) - [Docker](/docs/install/docker) ::: diff --git a/packages/docs/docs/install/cli-tool.md b/packages/docs/docs/install/cli-tool.md index 7b6418336f..240838d4fb 100644 --- a/packages/docs/docs/install/cli-tool.md +++ b/packages/docs/docs/install/cli-tool.md @@ -1,12 +1,12 @@ --- -title: 'CLI tool' +title: 'Server CLI' --- -## Hosting Actual with the CLI tool +## Hosting Actual with the Server CLI The Actual sync-server is available as an NPM package. The package is designed to make running the sync-server as easy as possible and is published to the official NPM registry under [@@actual-app/sync-server](https://www.npmjs.com/package/@actual-app/sync-server). -### Installing the CLI tool +### Installing the Server CLI Node.js v22 or higher is required for the `@actual-app/sync-server` npm package @@ -22,7 +22,7 @@ Once installed, you can execute commands directly from your terminal using `actu > Before running the tool, navigate to the directory that you wish your files to be located. -Run the CLI tool with the following syntax: +Run the Server CLI with the following syntax: ```bash actual-server [options] @@ -67,7 +67,7 @@ Reset your password actual-server --reset-password ``` -### Updating the CLI tool +### Updating the Server CLI The sync server can be updated with a simple command. @@ -75,7 +75,7 @@ The sync server can be updated with a simple command. npm update -g @actual-app/sync-server ``` -### Uninstalling the CLI tool +### Uninstalling the Server CLI The sync server can be uninstalled with a simple command. diff --git a/packages/docs/docs/install/index.md b/packages/docs/docs/install/index.md index 765f6f6379..22fdf1f2df 100644 --- a/packages/docs/docs/install/index.md +++ b/packages/docs/docs/install/index.md @@ -47,7 +47,7 @@ While running a server can be a complicated endeavor, we've tried to make it fai - If you're not comfortable with the command line and are willing to pay a small amount of money to have your version of Actual hosted on the cloud for you, we recommend [PikaPods](pikapods.md).[^2] - If you're willing to run a few commands in the terminal: - - You can run the server with a simple command using the [CLI tool](cli-tool.md) + - You can run the server with a simple command using the [Server CLI](cli-tool.md) - [Fly.io](fly.md) also offers cloud hosting for a similar amount of money. - If you want to use Docker, we have instructions for [using our provided Docker containers](docker.md). - You could [build Actual from source](build-from-source.md) on macOS, Windows, or Linux if you don't want to use a tool like Docker. (This method is the best option if you want to contribute to Actual's development!) diff --git a/packages/docs/src/pages/download.md b/packages/docs/src/pages/download.md index 81e06054ee..5e6bde0fe0 100644 --- a/packages/docs/src/pages/download.md +++ b/packages/docs/src/pages/download.md @@ -78,7 +78,7 @@ Actual has two parts, the client and a sync server. The primary task of the sync - [PikaPods](/docs/install/pikapods) - [Fly.io](/docs/install/fly) -- [CLI Tool](/docs/install/cli-tool) +- [Server CLI](/docs/install/cli-tool) - [Docker Install](/docs/install/docker) - [Build from source](/docs/install/build-from-source) diff --git a/tsconfig.json b/tsconfig.json index 99c1793e6a..da64d4f83d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,8 @@ { "path": "./packages/api" }, { "path": "./packages/desktop-client" }, { "path": "./packages/sync-server" }, - { "path": "./packages/desktop-electron" } + { "path": "./packages/desktop-electron" }, + { "path": "./packages/cli" } ], "compilerOptions": { "target": "ES2022", diff --git a/upcoming-release-notes/7208.md b/upcoming-release-notes/7208.md new file mode 100644 index 0000000000..a3f98537aa --- /dev/null +++ b/upcoming-release-notes/7208.md @@ -0,0 +1,6 @@ +--- +category: Features +authors: [MatissJanis] +--- + +Actual-cli: tool for accessing your budget via the command line. Useful for AI agents diff --git a/yarn.lock b/yarn.lock index d444218ef5..8da3153f0b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19,7 +19,7 @@ __metadata: languageName: node linkType: hard -"@actual-app/api@workspace:packages/api": +"@actual-app/api@workspace:*, @actual-app/api@workspace:packages/api": version: 0.0.0-use.local resolution: "@actual-app/api@workspace:packages/api" dependencies: @@ -49,6 +49,25 @@ __metadata: languageName: unknown linkType: soft +"@actual-app/cli@workspace:packages/cli": + version: 0.0.0-use.local + resolution: "@actual-app/cli@workspace:packages/cli" + dependencies: + "@actual-app/api": "workspace:*" + "@types/node": "npm:^22.19.15" + "@typescript/native-preview": "npm:^7.0.0-dev.20260309.1" + cli-table3: "npm:^0.6.5" + commander: "npm:^13.0.0" + cosmiconfig: "npm:^9.0.0" + rollup-plugin-visualizer: "npm:^6.0.11" + vite: "npm:^8.0.0" + vitest: "npm:^4.1.0" + bin: + actual: ./dist/cli.js + actual-cli: ./dist/cli.js + languageName: unknown + linkType: soft + "@actual-app/components@workspace:*, @actual-app/components@workspace:packages/component-library": version: 0.0.0-use.local resolution: "@actual-app/components@workspace:packages/component-library" @@ -13194,7 +13213,7 @@ __metadata: languageName: node linkType: hard -"cli-table3@npm:^0.6.3": +"cli-table3@npm:^0.6.3, cli-table3@npm:^0.6.5": version: 0.6.5 resolution: "cli-table3@npm:0.6.5" dependencies: @@ -13504,6 +13523,13 @@ __metadata: languageName: node linkType: hard +"commander@npm:^13.0.0": + version: 13.1.0 + resolution: "commander@npm:13.1.0" + checksum: 10/d3b4b79e6be8471ddadacbb8cd441fe82154d7da7393b50e76165a9e29ccdb74fa911a186437b9a211d0fc071db6051915c94fb8ef16d77511d898e9dbabc6af + languageName: node + linkType: hard + "commander@npm:^14.0.3": version: 14.0.3 resolution: "commander@npm:14.0.3" @@ -13904,6 +13930,23 @@ __metadata: languageName: node linkType: hard +"cosmiconfig@npm:^9.0.0": + version: 9.0.1 + resolution: "cosmiconfig@npm:9.0.1" + dependencies: + env-paths: "npm:^2.2.1" + import-fresh: "npm:^3.3.0" + js-yaml: "npm:^4.1.0" + parse-json: "npm:^5.2.0" + peerDependencies: + typescript: ">=4.9.5" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10/89fcac84d062f0710091bb2d6a6175bcde22f5448877db9c43429694408191d3d4e215193b3ac4d54f7f89ef188d55cd481c7a2295b0dc572e65b528bf6fec01 + languageName: node + linkType: hard + "crc@npm:^3.8.0": version: 3.8.0 resolution: "crc@npm:3.8.0" @@ -15697,7 +15740,7 @@ __metadata: languageName: node linkType: hard -"env-paths@npm:^2.2.0": +"env-paths@npm:^2.2.0, env-paths@npm:^2.2.1": version: 2.2.1 resolution: "env-paths@npm:2.2.1" checksum: 10/65b5df55a8bab92229ab2b40dad3b387fad24613263d103a97f91c9fe43ceb21965cd3392b1ccb5d77088021e525c4e0481adb309625d0cb94ade1d1fb8dc17e