Compare commits

..

6 Commits

Author SHA1 Message Date
erwannc
0793eb5927 Add Notes to Monthly Budget Cell (#6620)
* Add Notes to Monthly Budget Cell
Changed Modal menus layout to follow month menu on mobile

* Fixed rebase errors

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6620

* Addressed youngcw's comments (notes id format, notesButton defaultColor and modal layout)

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6620

* Updated mobile budget menu modal page model

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: youngcw <calebyoung94@gmail.com>
2026-03-19 00:42:30 +00:00
Matiss Janis Aboltins
a43b6f5c47 [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 <noreply@anthropic.com>
2026-03-18 18:22:38 +00:00
Matt Fiddaman
1f821d2849 ⬆️ bump github actions (#7234)
* actions/setup-node

* actions/cache

* actions/checkout

* docker/*

* actions/*-artifact

* actions/stale

* others

* note
2026-03-18 08:53:03 +00:00
Matt Fiddaman
beee16bc8c ⬆️ march dependency updates (#7222)
* @types/node (^22.19.10 → ^22.19.15)

* baseline-browser-mapping (^2.9.19 → ^2.10.0)

* eslint (^9.39.2 → ^9.39.3)

* lage (^2.14.17 → ^2.14.19)

* lint-staged (^16.2.7 → ^16.3.2)

* minimatch (^10.1.2 → ^10.2.4)

* oxlint (^1.47.0 → ^1.51.0)

* rollup-plugin-visualizer (^6.0.5 → ^6.0.11)

* @chromatic-com/storybook (^5.0.0 → ^5.0.1)

* @storybook/addon-a11y (^10.2.7 → ^10.2.16)

* @storybook/addon-docs (^10.2.7 → ^10.2.16)

* @storybook/react-vite (^10.2.7 → ^10.2.16)

* eslint-plugin-storybook (^10.2.7 → ^10.2.16)

* storybook (^10.2.7 → ^10.2.16)

* @codemirror/autocomplete (^6.20.0 → ^6.20.1)

* @codemirror/lang-javascript (^6.2.4 → ^6.2.5)

* @codemirror/language (^6.12.1 → ^6.12.2)

* @rolldown/plugin-babel (~0.1.7 → ~0.1.8)

* @swc/core (^1.15.11 → ^1.15.18)

* @swc/helpers (^0.5.18 → ^0.5.19)

* @tanstack/react-query (^5.90.20 → ^5.90.21)

* @uiw/react-codemirror (^4.25.4 → ^4.25.7)

* hyperformula (^3.1.1 → ^3.2.0)

* i18next (^25.8.4 → ^25.8.14)

* i18next-parser (^9.3.0 → ^9.4.0)

* react-i18next (^16.5.4 → ^16.5.6)

* react-virtualized-auto-sizer (^2.0.2 → ^2.0.3)

* fs-extra (^11.3.3 → ^11.3.4)

* @r74tech/docusaurus-plugin-panzoom (^2.4.0 → ^2.4.2)

* lru-cache (^11.2.5 → ^11.2.6)

* nodemon (^3.1.11 → ^3.1.14)

* eslint-plugin-perfectionist (^4.15.1 → ^5.6.0)

* downshift (9.0.10 → 9.3.2)

* react-router (7.13.0 → 7.13.1)

* @easyops-cn/docusaurus-search-local (^0.52.3 → ^0.55.1)

* peggy (5.0.6 → 5.1.0)

* @types/supertest (^6.0.3 → ^7.2.0)

* note
2026-03-18 08:37:04 +00:00
Karim Kodera
4cdb26f9a7 Adding Concentric Donut Pie Chart type to custom report charts (#7038)
* Initial commit for concentric donut chart implementation

* [autofix.ci] apply automated fixes

* Update upcoming-release-notes/7038.md

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Fix coderabbit comments (Recalculated total to avoid hidden cats, remove tooltip, add proper types)

* Update packages/desktop-client/src/components/reports/graphs/DonutGraph.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Fix zero total group

* lint issues fix

* Fix lint issues

* Empty commit to retriger the process

* [autofix.ci] apply automated fixes

* Removed line betweeen arc and label. I beleive the view is cleaner this way.

* Fixed line for outer donut

* split active shape for concentric circles to avoid impacting original chart

* [autofix.ci] apply automated fixes

* Fixing mid point to align with mid point on inner circle

* 1- make line always start inside the core circle
2- fix bug where inner circle label was showing below the outer circle

* - Fixed Dashboard issue when height too low.
- Rewrite of the activeShape part for simplicity.
- Centralize radius calculation.
- Provide differnt dimensions for compact vs standard rendering
- fix mid line point to auto fix at 70% of the inner radius
- More readable code

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #7038

* Fixed distance issue for arc on single ring.

* [autofix.ci] apply automated fixes

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #7038

* Update packages/desktop-client/src/components/reports/graphs/DonutGraph.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Fixing Code Rabbit Comments

* rerunning tests

* Added Group click through passing all categories iwth a workaorund on showActivity

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-17 20:10:30 +00:00
Matiss Janis Aboltins
15358b6b54 [AI] Remove development theme (#7232)
* [AI] Remove development theme

Delete the development theme that was only available in non-production
environments. Removes the theme file, its registration in the theme
registry, type definitions, preference validation, ThemeSelector icon
mapping, and Storybook configuration.

https://claude.ai/code/session_01A1aEippeWppuwoRSCBPwby

* Add release notes for PR #7232

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-17 19:18:47 +00:00
116 changed files with 4680 additions and 1417 deletions

View File

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

View File

@@ -15,7 +15,7 @@ runs:
using: composite
steps:
- name: Install node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
- name: Install yarn
@@ -27,7 +27,7 @@ runs:
run: echo "version=$(node -v)" >> "$GITHUB_OUTPUT"
shell: bash
- name: Cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
id: cache
with:
path: ${{ format('{0}/**/node_modules', inputs.working-directory) }}
@@ -36,7 +36,7 @@ runs:
run: mkdir -p ${{ format('{0}/.lage', inputs.working-directory) }}
shell: bash
- name: Cache Lage
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ${{ format('{0}/.lage', inputs.working-directory) }}
key: lage-${{ runner.os }}-${{ github.sha }}
@@ -48,7 +48,7 @@ runs:
shell: bash
if: steps.cache.outputs.cache-hit != 'true'
- name: Download translations
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: actualbudget/translations
path: ${{ inputs.working-directory }}/packages/desktop-client/locale

View File

@@ -17,7 +17,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up environment
uses: ./.github/actions/setup
with:

View File

@@ -15,11 +15,11 @@ jobs:
autofix:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Format code
run: yarn lint:fix
- uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27
- uses: autofix-ci/action@7a166d7532b277f34e16238930461bf77f9d7ed8 # v1.3.3

View File

@@ -22,7 +22,7 @@ jobs:
api:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -34,12 +34,12 @@ jobs:
- name: Prepare bundle stats artifact
run: cp packages/api/app/stats.json api-stats.json
- name: Upload Build
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: actual-api
path: packages/api/actual-api.tgz
- name: Upload API bundle stats
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: api-build-stats
path: api-stats.json
@@ -47,7 +47,7 @@ jobs:
crdt:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -57,7 +57,7 @@ jobs:
- name: Create package tgz
run: cd packages/crdt && yarn pack && mv package.tgz actual-crdt.tgz
- name: Upload Build
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: actual-crdt
path: packages/crdt/actual-crdt.tgz
@@ -65,23 +65,23 @@ jobs:
web:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up environment
uses: ./.github/actions/setup
- name: Build Web
run: yarn build:browser
- name: Upload Build
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: actual-web
path: packages/desktop-client/build
- name: Upload Build Stats
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: build-stats
path: packages/desktop-client/build-stats
server:
cli:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
@@ -89,10 +89,35 @@ jobs:
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Build Server
run: yarn workspace @actual-app/sync-server build
- 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:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Build Server
run: yarn workspace @actual-app/sync-server build
- name: Upload Build
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: sync-server
path: packages/sync-server/build

View File

@@ -15,7 +15,7 @@ jobs:
constraints:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -25,7 +25,7 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -35,7 +35,7 @@ jobs:
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -45,7 +45,7 @@ jobs:
validate-cli:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -57,7 +57,7 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -69,7 +69,7 @@ jobs:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up environment
uses: ./.github/actions/setup
with:

View File

@@ -22,14 +22,14 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
uses: github/codeql-action/init@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
with:
languages: javascript
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
uses: github/codeql-action/analyze@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
with:
category: '/language:javascript'

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up environment
uses: ./.github/actions/setup
with:

View File

@@ -36,17 +36,17 @@ jobs:
matrix:
os: [ubuntu, alpine]
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Docker meta
id: meta
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
# Push to both Docker Hub and Github Container Registry
images: ${{ env.IMAGES }}
@@ -54,14 +54,14 @@ jobs:
tags: ${{ env.TAGS }}
- name: Login to Docker Hub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
if: github.event_name != 'pull_request' && !github.event.repository.fork
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
if: github.event_name != 'pull_request'
with:
registry: ghcr.io
@@ -76,7 +76,7 @@ jobs:
run: yarn build:server
- name: Build image for testing
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with:
context: .
push: false
@@ -93,7 +93,7 @@ jobs:
# This will use the cache from the earlier build step and not rebuild the image
# https://docs.docker.com/build/ci/github-actions/test-before-push/
- name: Build and push images
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with:
context: .
push: ${{ github.event_name != 'pull_request' }}

View File

@@ -28,17 +28,17 @@ jobs:
name: Build Docker image
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Docker meta
id: meta
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
# Push to both Docker Hub and Github Container Registry
images: ${{ env.IMAGES }}
@@ -48,7 +48,7 @@ jobs:
- name: Docker meta for Alpine image
id: alpine-meta
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ${{ env.IMAGES }}
# Automatically update :latest
@@ -58,13 +58,13 @@ jobs:
tags: ${{ env.TAGS }}
- name: Login to Docker Hub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -78,7 +78,7 @@ jobs:
run: yarn build:server
- name: Build and push ubuntu image
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with:
context: .
push: true
@@ -87,7 +87,7 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
- name: Build and push alpine image
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with:
context: .
push: true

View File

@@ -32,7 +32,7 @@ jobs:
container:
image: mcr.microsoft.com/playwright:v1.58.2-jammy
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -41,7 +41,7 @@ jobs:
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Run E2E Tests
run: yarn e2e --shard=${{ matrix.shard }}/5
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: always()
with:
name: desktop-client-test-results-shard-${{ matrix.shard }}
@@ -55,7 +55,7 @@ jobs:
container:
image: mcr.microsoft.com/playwright:v1.58.2-jammy
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -65,7 +65,7 @@ jobs:
- name: Run Desktop app E2E Tests
run: |
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" -- yarn e2e:desktop
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: always()
with:
name: desktop-app-test-results
@@ -83,14 +83,14 @@ jobs:
container:
image: mcr.microsoft.com/playwright:v1.58.2-jammy
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Run VRT Tests
run: yarn vrt --shard=${{ matrix.shard }}/5
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: always()
with:
name: vrt-blob-report-${{ matrix.shard }}
@@ -106,11 +106,11 @@ jobs:
container:
image: mcr.microsoft.com/playwright:v1.58.2-jammy
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up environment
uses: ./.github/actions/setup
- name: Download all blob reports
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: packages/desktop-client/all-blob-reports
pattern: vrt-blob-report-*
@@ -118,7 +118,7 @@ jobs:
- name: Merge reports
id: merge-reports
run: yarn workspace @actual-app/web run playwright merge-reports --reporter html ./all-blob-reports
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
id: playwright-report-vrt
with:
name: html-report--attempt-${{ github.run_attempt }}
@@ -134,7 +134,7 @@ jobs:
echo "${{ steps.playwright-report-vrt.outputs.artifact-url }}" > vrt-metadata/artifact-url.txt
- name: Upload VRT metadata
if: github.event_name == 'pull_request'
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: vrt-comment-metadata
path: vrt-metadata/

View File

@@ -18,7 +18,7 @@ jobs:
if: github.event.workflow_run.event == 'pull_request'
steps:
- name: Download VRT metadata
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
@@ -53,7 +53,7 @@ jobs:
- name: Comment on PR with VRT report link
if: steps.metadata.outputs.should_comment == 'true'
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
uses: marocchino/sticky-pull-request-comment@70d2764d1a7d5d9560b100cbea0077fc8f633987 # v3.0.2
with:
number: ${{ steps.metadata.outputs.pr_number }}
header: vrt-comment

View File

@@ -29,7 +29,7 @@ jobs:
- macos-latest
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: ${{ startsWith(matrix.os, 'windows') }}
run: pip.exe install setuptools
- if: ${{ ! startsWith(matrix.os, 'windows') }}
@@ -74,7 +74,7 @@ jobs:
if: ${{ ! startsWith(matrix.os, 'macos') }}
run: ./bin/package-electron
- name: Upload Build
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: actual-electron-${{ matrix.os }}
path: |
@@ -85,13 +85,13 @@ jobs:
packages/desktop-electron/dist/*.flatpak
- name: Upload Windows Store Build
if: ${{ startsWith(matrix.os, 'windows') }}
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: actual-electron-${{ matrix.os }}-appx
path: |
packages/desktop-electron/dist/*.appx
- name: Add to new release
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
with:
draft: true
body: |
@@ -126,7 +126,7 @@ jobs:
Install-Module -Name StoreBroker -AcceptLicense -Force -Scope CurrentUser -Verbose
- name: Download Microsoft Store artifacts
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: actual-electron-windows-latest-appx

View File

@@ -33,7 +33,7 @@ jobs:
- macos-latest
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: ${{ startsWith(matrix.os, 'windows') }}
run: pip.exe install setuptools
- if: ${{ ! startsWith(matrix.os, 'windows') }}
@@ -65,56 +65,56 @@ jobs:
run: ./bin/package-electron
- name: Upload Linux x64 AppImage
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: Actual-linux-x86_64.AppImage
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-linux-x86_64.AppImage
- name: Upload Linux arm64 AppImage
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: Actual-linux-arm64.AppImage
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-linux-arm64.AppImage
- name: Upload Linux x64 flatpak
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: Actual-linux-x86_64.flatpak
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-linux-x86_64.flatpak
- name: Upload Windows x32 exe
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: Actual-windows-ia32.exe
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-windows-ia32.exe
- name: Upload Windows x64 exe
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: Actual-windows-x64.exe
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-windows-x64.exe
- name: Upload Windows arm64 exe
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: Actual-windows-arm64.exe
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-windows-arm64.exe
- name: Upload Mac x64 dmg
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: Actual-mac-x64.dmg
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-mac-x64.dmg
- name: Upload Mac arm64 dmg
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: Actual-mac-arm64.dmg
if-no-files-found: ignore
@@ -122,7 +122,7 @@ jobs:
- name: Upload Windows Store Build
if: ${{ startsWith(matrix.os, 'windows') }}
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: actual-electron-${{ matrix.os }}-appx
path: |

View File

@@ -25,7 +25,7 @@ jobs:
if: github.event.pull_request.head.repo.full_name != github.repository
steps:
- name: Post welcome comment
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
uses: marocchino/sticky-pull-request-comment@70d2764d1a7d5d9560b100cbea0077fc8f633987 # v3.0.2
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
number: ${{ github.event.pull_request.number }}

View File

@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.inputs.ref }}
- name: Set up environment
@@ -55,7 +55,7 @@ jobs:
echo "version=$NEW_WEB_VERSION" >> "$GITHUB_OUTPUT"
- name: Create PR
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ secrets.ACTIONS_UPDATE_TOKEN }}
commit-message: '🔖 (${{ steps.bump_package_versions.outputs.version }})'

View File

@@ -12,7 +12,7 @@ jobs:
if: github.repository == 'actualbudget/actual'
steps:
- name: Check out main repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
path: actual
- name: Set up environment
@@ -44,7 +44,7 @@ jobs:
push \
actualbudget/actual
- name: Check out updated translations
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ssh-key: ${{ secrets.STRING_IMPORT_DEPLOY_KEY }}
repository: actualbudget/translations

View File

@@ -24,8 +24,8 @@ jobs:
runs-on: ubuntu-latest
steps:
# This is not a security concern because we have approved & merged the PR
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
- name: Handle feature requests

View File

@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Repository Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up environment
uses: ./.github/actions/setup

View File

@@ -92,7 +92,7 @@ jobs:
echo "APPIMAGE_ARM64_SHA256=$APPIMAGE_ARM64_SHA256" >> "$GITHUB_ENV"
- name: Checkout Flathub repo
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: flathub/com.actualbudget.actual
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
@@ -113,7 +113,7 @@ jobs:
cat com.actualbudget.actual.yml
- name: Create PR in Flathub repo
uses: peter-evans/create-pull-request@v7
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
commit-message: 'Update Actual flatpak to version ${{ steps.resolve_version.outputs.version }}'

View File

@@ -28,7 +28,7 @@ jobs:
runs-on: ${{ matrix.os }}
if: github.event.repository.fork == false
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: ${{ startsWith(matrix.os, 'windows') }}
run: pip.exe install setuptools
@@ -83,49 +83,49 @@ jobs:
run: ./bin/package-electron
- name: Upload Linux x64 AppImage
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: Actual-linux-x86_64.AppImage
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-linux-x86_64.AppImage
- name: Upload Linux arm64 AppImage
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: Actual-linux-arm64.AppImage
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-linux-arm64.AppImage
- name: Upload Windows x32 exe
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: Actual-windows-ia32.exe
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-windows-ia32.exe
- name: Upload Windows x64 exe
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: Actual-windows-x64.exe
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-windows-x64.exe
- name: Upload Windows arm64 exe
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: Actual-windows-arm64.exe
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-windows-arm64.exe
- name: Upload Mac x64 dmg
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: Actual-mac-x64.dmg
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-mac-x64.dmg
- name: Upload Mac arm64 dmg
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: Actual-mac-arm64.dmg
if-no-files-found: ignore
@@ -133,7 +133,7 @@ jobs:
- name: Upload Windows Store Build
if: ${{ startsWith(matrix.os, 'windows') }}
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: actual-electron-${{ matrix.os }}-appx
path: |

View File

@@ -12,7 +12,7 @@ jobs:
name: Build and pack npm packages
if: github.event.repository.fork == false
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up environment
uses: ./.github/actions/setup
@@ -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,8 +56,15 @@ 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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: npm-packages
path: |
@@ -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
@@ -73,12 +83,12 @@ jobs:
packages: write
steps:
- name: Download the artifacts
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: npm-packages
- name: Setup node and npm registry
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
registry-url: 'https://registry.npmjs.org'
@@ -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 }}

View File

@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
name: Build and pack npm packages
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up environment
uses: ./.github/actions/setup
@@ -35,8 +35,15 @@ 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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: npm-packages
path: |
@@ -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
@@ -54,12 +62,12 @@ jobs:
packages: write
steps:
- name: Download the artifacts
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: npm-packages
- name: Setup node and npm registry
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
registry-url: 'https://registry.npmjs.org'
@@ -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 }}

View File

@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Get changed files

View File

@@ -35,7 +35,7 @@ jobs:
contents: read
steps:
- name: Checkout base branch
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.base_ref }}
- name: Set up environment
@@ -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,15 +79,22 @@ 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
- name: Download web build artifact from ${{github.base_ref}}
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
id: pr-web-build
with:
branch: ${{github.base_ref}}
@@ -89,7 +103,7 @@ jobs:
name: build-stats
path: base
- name: Download API build artifact from ${{github.base_ref}}
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
id: pr-api-build
with:
branch: ${{github.base_ref}}
@@ -98,7 +112,7 @@ jobs:
name: api-build-stats
path: base
- name: Download build stats from PR
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
with:
pr: ${{github.event.pull_request.number}}
workflow: build.yml
@@ -107,7 +121,7 @@ jobs:
path: head
allow_forks: true
- name: Download API stats from PR
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
with:
pr: ${{github.event.pull_request.number}}
workflow: build.yml
@@ -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

View File

@@ -8,7 +8,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with:
stale-pr-message: 'This PR is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.'
close-pr-message: 'This PR was closed because it has been stalled for 5 days with no activity.'
@@ -18,7 +18,7 @@ jobs:
stale-wip:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with:
stale-pr-message: ':wave: Hi! It looks like this PR has not had any changes for a week now. Would you like someone to review this PR? If so - please remove the "[WIP]" prefix from the PR title. That will let the community know that this PR is open for a review.'
days-before-stale: 7
@@ -29,7 +29,7 @@ jobs:
stale-needs-info:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with:
stale-issue-label: 'needs info'
days-before-stale: -1

View File

@@ -19,7 +19,7 @@ jobs:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- name: Download patch artifact
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
@@ -27,7 +27,7 @@ jobs:
path: /tmp/artifacts
- name: Download metadata artifact
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
@@ -54,7 +54,7 @@ jobs:
- name: Checkout fork branch
if: steps.metadata.outputs.pr_number != ''
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: ${{ steps.metadata.outputs.head_repo }}
ref: ${{ steps.metadata.outputs.head_ref }}

View File

@@ -60,7 +60,7 @@ jobs:
core.setOutput('head_ref', pr.head.ref);
core.setOutput('head_repo', pr.head.repo.full_name);
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ steps.pr.outputs.head_sha }}
@@ -113,7 +113,7 @@ jobs:
- name: Upload patch artifact
if: steps.create-patch.outputs.has_changes == 'true'
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: vrt-patch-${{ github.event.issue.number }}
path: vrt-update.patch
@@ -129,7 +129,7 @@ jobs:
- name: Upload PR metadata
if: steps.create-patch.outputs.has_changes == 'true'
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: vrt-metadata-${{ github.event.issue.number }}
path: pr-metadata/

7
.gitignore vendored
View File

@@ -81,3 +81,10 @@ build/
*storybook.log
storybook-static
# cli config when testing locally
.actualrc.json
.actualrc
.actualrc.yaml
.actualrc.yml
actual.config.js

View File

@@ -17,6 +17,7 @@ module.exports = {
},
build: {
type: 'npmScript',
dependsOn: ['^build'],
cache: true,
options: {
outputGlob: ['lib-dist/**', 'dist/**', 'build/**'],

View File

@@ -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",
@@ -64,24 +66,24 @@
},
"devDependencies": {
"@octokit/rest": "^22.0.1",
"@types/node": "^22.19.10",
"@types/node": "^22.19.15",
"@types/prompts": "^2.4.9",
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
"@yarnpkg/types": "^4.0.1",
"baseline-browser-mapping": "^2.9.19",
"baseline-browser-mapping": "^2.10.0",
"cross-env": "^10.1.0",
"eslint": "^9.39.2",
"eslint-plugin-perfectionist": "^4.15.1",
"eslint": "^9.39.3",
"eslint-plugin-perfectionist": "^5.6.0",
"eslint-plugin-typescript-paths": "^0.0.33",
"html-to-image": "^1.11.13",
"husky": "^9.1.7",
"lage": "^2.14.17",
"lint-staged": "^16.2.7",
"minimatch": "^10.1.2",
"lage": "^2.14.19",
"lint-staged": "^16.3.2",
"minimatch": "^10.2.4",
"node-jq": "^6.3.1",
"npm-run-all": "^4.1.5",
"oxfmt": "^0.32.0",
"oxlint": "^1.47.0",
"oxlint": "^1.51.0",
"oxlint-tsgolint": "^0.13.0",
"p-limit": "^7.3.0",
"prompts": "^2.4.2",

View File

@@ -1,4 +1,7 @@
class Query {
/** @type {import('loot-core/shared/query').QueryState} */
state;
constructor(state) {
this.state = {
filterExpressions: state.filterExpressions || [],

View File

@@ -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",
@@ -24,7 +38,7 @@
},
"devDependencies": {
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
"rollup-plugin-visualizer": "^6.0.5",
"rollup-plugin-visualizer": "^6.0.11",
"typescript-strict-plugin": "^2.4.4",
"vite": "^8.0.0",
"vite-plugin-dts": "^4.5.4",

7
packages/cli/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
dist
coverage
.actualrc.json
.actualrc
.actualrc.yaml
.actualrc.yml
actual.config.js

155
packages/cli/README.md Normal file
View File

@@ -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 <account-id>
# 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 <url>` | Server URL |
| `--password <pw>` | Server password |
| `--session-token <token>` | Session token |
| `--sync-id <id>` | Budget Sync ID |
| `--data-dir <path>` | Data directory |
| `--format <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 <command> --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 <id> \
--data '[{"date":"2026-03-14","amount":-2500,"payee_name":"Coffee Shop"}]'
# Export transactions to CSV
actual transactions list --account <id> \
--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 <id> --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
```

35
packages/cli/package.json Normal file
View File

@@ -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"
}
}

View File

@@ -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 <format>');
program.option('--server-url <url>');
program.option('--password <pw>');
program.option('--session-token <token>');
program.option('--sync-id <id>');
program.option('--data-dir <dir>');
program.option('--verbose');
program.exitOverride();
registerAccountsCommand(program);
return program;
}
async function run(args: string[]) {
const program = createProgram();
await program.parseAsync(['node', 'test', ...args]);
}
describe('accounts commands', () => {
let stderrSpy: ReturnType<typeof vi.spyOn>;
let stdoutSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
stderrSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
stdoutSpy = vi
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);
});
afterEach(() => {
stderrSpy.mockRestore();
stdoutSpy.mockRestore();
});
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'),
);
});
});
});

View File

@@ -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 <name>', 'Account name')
.option('--offbudget', 'Create as off-budget account', false)
.option('--balance <amount>', '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 <id>')
.description('Update an account')
.option('--name <name>', 'New account name')
.option('--offbudget <bool>', 'Set off-budget status')
.action(async (id: string, cmdOpts) => {
const opts = program.opts();
const fields: Record<string, unknown> = {};
if (cmdOpts.name !== undefined) {
const trimmed = cmdOpts.name.trim();
if (trimmed === '') {
throw new Error('Invalid --name: must be a non-empty string.');
}
fields.name = trimmed;
}
if (cmdOpts.offbudget !== undefined) {
fields.offbudget = parseBoolFlag(cmdOpts.offbudget, '--offbudget');
}
if (Object.keys(fields).length === 0) {
throw new Error(
'No update fields provided. Use --name or --offbudget.',
);
}
await withConnection(opts, async () => {
await api.updateAccount(id, fields);
printOutput({ success: true, id }, opts.format);
});
});
accounts
.command('close <id>')
.description('Close an account')
.option(
'--transfer-account <id>',
'Transfer remaining balance to this account',
)
.option(
'--transfer-category <id>',
'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 <id>')
.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 <id>')
.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 <id>')
.description('Get account balance')
.option('--cutoff <date>', '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);
});
});
}

View File

@@ -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 <syncId>')
.description('Download a budget by sync ID')
.option('--encryption-password <password>', 'Encryption password')
.action(async (syncId: string, cmdOpts) => {
const opts = program.opts();
const config = await resolveConfig(opts);
const password = config.encryptionPassword ?? cmdOpts.encryptionPassword;
await withConnection(
opts,
async () => {
await api.downloadBudget(syncId, {
password,
});
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 <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 <month>', 'Budget month (YYYY-MM)')
.requiredOption('--category <id>', 'Category ID')
.requiredOption('--amount <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 <month>', 'Budget month (YYYY-MM)')
.requiredOption('--category <id>', 'Category ID')
.requiredOption('--flag <bool>', 'Enable (true) or disable (false)')
.action(async cmdOpts => {
const flag = parseBoolFlag(cmdOpts.flag, '--flag');
const opts = program.opts();
await withConnection(opts, async () => {
await api.setBudgetCarryover(cmdOpts.month, cmdOpts.category, flag);
printOutput({ success: true }, opts.format);
});
});
budgets
.command('hold-next-month')
.description('Hold budget amount for next month')
.requiredOption('--month <month>', 'Budget month (YYYY-MM)')
.requiredOption('--amount <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 <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);
});
});
}

View File

@@ -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 <name>', 'Category name')
.requiredOption('--group-id <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 <id>')
.description('Update a category')
.option('--name <name>', 'New category name')
.option('--hidden <bool>', 'Set hidden status')
.action(async (id: string, cmdOpts) => {
const fields: Record<string, unknown> = {};
if (cmdOpts.name !== undefined) fields.name = cmdOpts.name;
if (cmdOpts.hidden !== undefined) {
fields.hidden = parseBoolFlag(cmdOpts.hidden, '--hidden');
}
if (Object.keys(fields).length === 0) {
throw new Error('No update fields provided. Use --name or --hidden.');
}
const opts = program.opts();
await withConnection(opts, async () => {
await api.updateCategory(id, fields);
printOutput({ success: true, id }, opts.format);
});
});
categories
.command('delete <id>')
.description('Delete a category')
.option('--transfer-to <id>', 'Transfer transactions to this category')
.action(async (id: string, cmdOpts) => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.deleteCategory(id, cmdOpts.transferTo);
printOutput({ success: true, id }, opts.format);
});
});
}

View File

@@ -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 <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 <id>')
.description('Update a category group')
.option('--name <name>', 'New group name')
.option('--hidden <bool>', 'Set hidden status')
.action(async (id: string, cmdOpts) => {
const fields: Record<string, unknown> = {};
if (cmdOpts.name !== undefined) fields.name = cmdOpts.name;
if (cmdOpts.hidden !== undefined) {
fields.hidden = parseBoolFlag(cmdOpts.hidden, '--hidden');
}
if (Object.keys(fields).length === 0) {
throw new Error('No update fields provided. Use --name or --hidden.');
}
const opts = program.opts();
await withConnection(opts, async () => {
await api.updateCategoryGroup(id, fields);
printOutput({ success: true, id }, opts.format);
});
});
groups
.command('delete <id>')
.description('Delete a category group')
.option('--transfer-to <id>', 'Transfer transactions to this category ID')
.action(async (id: string, cmdOpts) => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.deleteCategoryGroup(id, cmdOpts.transferTo);
printOutput({ success: true, id }, opts.format);
});
});
}

View File

@@ -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 <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 <id>')
.description('Update a payee')
.option('--name <name>', 'New payee name')
.action(async (id: string, cmdOpts) => {
const fields: Record<string, unknown> = {};
if (cmdOpts.name) fields.name = cmdOpts.name;
if (Object.keys(fields).length === 0) {
throw new Error(
'No fields to update. Use --name to specify a new name.',
);
}
const opts = program.opts();
await withConnection(opts, async () => {
await api.updatePayee(id, fields);
printOutput({ success: true, id }, opts.format);
});
});
payees
.command('delete <id>')
.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 <id>', 'Target payee ID')
.requiredOption('--ids <ids>', 'Comma-separated payee IDs to merge')
.action(async (cmdOpts: { target: string; ids: string }) => {
const mergeIds = cmdOpts.ids
.split(',')
.map(id => id.trim())
.filter(id => id.length > 0);
if (mergeIds.length === 0) {
throw new Error(
'No valid payee IDs provided in --ids. Provide comma-separated IDs.',
);
}
const opts = program.opts();
await withConnection(opts, async () => {
await api.mergePayees(cmdOpts.target, mergeIds);
printOutput({ success: true }, opts.format);
});
});
}

View File

@@ -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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function buildQueryFromFile(
parsed: Record<string, unknown>,
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<string, string | undefined>) {
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>',
'Table to query (transactions, accounts, categories, payees)',
)
.option('--select <fields>', 'Comma-separated fields to select')
.option('--filter <json>', 'Filter expression as JSON')
.option('--order-by <fields>', 'Comma-separated fields to order by')
.option('--limit <n>', 'Limit number of results')
.option(
'--file <path>',
'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);
});
});
}

View File

@@ -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 <payeeId>')
.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 <json>', 'Rule definition as JSON')
.option('--file <path>', 'Read rule from JSON file (use - for stdin)')
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const rule = readJsonInput(cmdOpts) as Parameters<
typeof api.createRule
>[0];
const id = await api.createRule(rule);
printOutput({ id }, opts.format);
});
});
rules
.command('update')
.description('Update a rule')
.option('--data <json>', 'Rule data as JSON (must include id)')
.option('--file <path>', 'Read rule from JSON file (use - for stdin)')
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const rule = readJsonInput(cmdOpts) as Parameters<
typeof api.updateRule
>[0];
await api.updateRule(rule);
printOutput({ success: true }, opts.format);
});
});
rules
.command('delete <id>')
.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);
});
});
}

View File

@@ -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 <json>', 'Schedule definition as JSON')
.option('--file <path>', 'Read schedule from JSON file (use - for stdin)')
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const schedule = readJsonInput(cmdOpts) as Parameters<
typeof api.createSchedule
>[0];
const id = await api.createSchedule(schedule);
printOutput({ id }, opts.format);
});
});
schedules
.command('update <id>')
.description('Update a schedule')
.option('--data <json>', 'Fields to update as JSON')
.option('--file <path>', '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 <id>')
.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);
});
});
}

View File

@@ -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 <type>', 'Entity type')
.choices(['accounts', 'categories', 'payees', 'schedules'])
.makeOptionMandatory(),
)
.requiredOption('--name <name>', 'Entity name')
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const id = await api.getIDByName(cmdOpts.type, cmdOpts.name);
printOutput(
{ id, type: cmdOpts.type, name: cmdOpts.name },
opts.format,
);
});
});
server
.command('bank-sync')
.description('Run bank synchronization')
.option('--account <id>', 'Specific account ID to sync')
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const args = cmdOpts.account
? { accountId: cmdOpts.account }
: undefined;
await api.runBankSync(args);
printOutput({ success: true }, opts.format);
});
});
}

View File

@@ -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>', 'Tag name')
.option('--color <color>', 'Tag color')
.option('--description <description>', 'Tag description')
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const id = await api.createTag({
tag: cmdOpts.tag,
color: cmdOpts.color,
description: cmdOpts.description,
});
printOutput({ id }, opts.format);
});
});
tags
.command('update <id>')
.description('Update a tag')
.option('--tag <tag>', 'New tag name')
.option('--color <color>', 'New tag color')
.option('--description <description>', 'New tag description')
.action(async (id: string, cmdOpts) => {
const fields: Record<string, unknown> = {};
if (cmdOpts.tag !== undefined) fields.tag = cmdOpts.tag;
if (cmdOpts.color !== undefined) fields.color = cmdOpts.color;
if (cmdOpts.description !== undefined) {
fields.description = cmdOpts.description;
}
if (Object.keys(fields).length === 0) {
throw new Error(
'At least one of --tag, --color, or --description is required',
);
}
const opts = program.opts();
await withConnection(opts, async () => {
await api.updateTag(id, fields);
printOutput({ success: true, id }, opts.format);
});
});
tags
.command('delete <id>')
.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);
});
});
}

View File

@@ -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 <id>', 'Account ID')
.requiredOption('--start <date>', 'Start date (YYYY-MM-DD)')
.requiredOption('--end <date>', 'End date (YYYY-MM-DD)')
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const result = await api.getTransactions(
cmdOpts.account,
cmdOpts.start,
cmdOpts.end,
);
printOutput(result, opts.format);
});
});
transactions
.command('add')
.description('Add transactions to an account')
.requiredOption('--account <id>', 'Account ID')
.option('--data <json>', 'Transaction data as JSON array')
.option(
'--file <path>',
'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 <id>', 'Account ID')
.option('--data <json>', 'Transaction data as JSON array')
.option(
'--file <path>',
'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 <id>')
.description('Update a transaction')
.option('--data <json>', 'Fields to update as JSON')
.option('--file <path>', 'Read fields from JSON file (use - for stdin)')
.action(async (id: string, cmdOpts) => {
const opts = program.opts();
await withConnection(opts, async () => {
const fields = readJsonInput(cmdOpts) as Parameters<
typeof api.updateTransaction
>[1];
await api.updateTransaction(id, fields);
printOutput({ success: true, id }, opts.format);
});
});
transactions
.command('delete <id>')
.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);
});
});
}

View File

@@ -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<string, unknown> | null) {
if (config) {
mockSearch.mockResolvedValue({ config, isEmpty: false });
} else {
mockSearch.mockResolvedValue(null);
}
}
describe('resolveConfig', () => {
const savedEnv: Record<string, string | undefined> = {};
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');
});
});
});

141
packages/cli/src/config.ts Normal file
View File

@@ -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<string, unknown> {
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<ConfigFileContent> {
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<CliConfig> {
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,
};
}

View File

@@ -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<string, unknown> = {}) {
vi.mocked(resolveConfig).mockResolvedValue({
serverUrl: 'http://test',
password: 'pw',
dataDir: '/tmp/data',
syncId: 'budget-1',
...overrides,
});
}
describe('withConnection', () => {
let stderrSpy: ReturnType<typeof vi.spyOn>;
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'),
);
});
});

View File

@@ -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<T>(
globalOpts: CliGlobalOpts,
fn: () => Promise<T>,
options: ConnectionOptions = {},
): Promise<T> {
const { loadBudget = true } = options;
const config = await resolveConfig(globalOpts);
mkdirSync(config.dataDir, { recursive: true });
info(`Connecting to ${config.serverUrl}...`, globalOpts.verbose);
if (config.sessionToken) {
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();
}
}

70
packages/cli/src/index.ts Normal file
View File

@@ -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 <url>', 'Actual server URL (env: ACTUAL_SERVER_URL)')
.option('--password <password>', 'Server password (env: ACTUAL_PASSWORD)')
.option(
'--session-token <token>',
'Session token (env: ACTUAL_SESSION_TOKEN)',
)
.option('--sync-id <id>', 'Budget sync ID (env: ACTUAL_SYNC_ID)')
.option('--data-dir <path>', 'Data directory (env: ACTUAL_DATA_DIR)')
.option(
'--encryption-password <password>',
'E2E encryption password (env: ACTUAL_ENCRYPTION_PASSWORD)',
)
.addOption(
new Option('--format <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 '<non-serializable error>';
}
}
return String(err);
}
program.parseAsync(process.argv).catch((err: unknown) => {
const message = normalizeThrownMessage(err);
process.stderr.write(`Error: ${message}\n`);
process.exitCode = 1;
});

21
packages/cli/src/input.ts Normal file
View File

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

View File

@@ -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<typeof vi.spyOn>;
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');
});
});

View File

@@ -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<string, unknown>);
const table = new Table({ head: keys });
for (const row of data) {
const r = row as Record<string, unknown>;
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<string, unknown>);
const header = keys.map(k => escapeCsv(k)).join(',');
const rows = data.map(row => {
const r = row as Record<string, unknown>;
return keys.map(k => escapeCsv(String(r[k] ?? ''))).join(',');
});
return [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');
}

View File

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

16
packages/cli/src/utils.ts Normal file
View File

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

View File

@@ -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"]
}

View File

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

View File

@@ -5,7 +5,6 @@ import type { Preview } from '@storybook/react-vite';
// Not ideal to import from desktop-client, but we need a source of truth for theme variables
// TODO: this needs refactoring
import * as darkTheme from '../../desktop-client/src/style/themes/dark';
import * as developmentTheme from '../../desktop-client/src/style/themes/development';
import * as lightTheme from '../../desktop-client/src/style/themes/light';
import * as midnightTheme from '../../desktop-client/src/style/themes/midnight';
@@ -13,7 +12,6 @@ const THEMES = {
light: lightTheme,
dark: darkTheme,
midnight: midnightTheme,
development: developmentTheme,
} as const;
type ThemeName = keyof typeof THEMES;
@@ -64,7 +62,6 @@ const preview: Preview = {
{ value: 'light', title: 'Light' },
{ value: 'dark', title: 'Dark' },
{ value: 'midnight', title: 'Midnight' },
{ value: 'development', title: 'Development' },
],
},
},

View File

@@ -48,18 +48,18 @@
"usehooks-ts": "^3.1.1"
},
"devDependencies": {
"@chromatic-com/storybook": "^5.0.0",
"@storybook/addon-a11y": "^10.2.7",
"@storybook/addon-docs": "^10.2.7",
"@storybook/react-vite": "^10.2.7",
"@chromatic-com/storybook": "^5.0.1",
"@storybook/addon-a11y": "^10.2.16",
"@storybook/addon-docs": "^10.2.16",
"@storybook/react-vite": "^10.2.16",
"@svgr/cli": "^8.1.0",
"@types/react": "^19.2.14",
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
"@vitejs/plugin-react": "^6.0.0",
"eslint-plugin-storybook": "^10.2.7",
"eslint-plugin-storybook": "^10.2.16",
"react": "19.2.4",
"react-dom": "19.2.4",
"storybook": "^10.2.7",
"storybook": "^10.2.16",
"vite": "^8.0.0",
"vitest": "^4.1.0"
},

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -5,6 +5,7 @@ export class BudgetMenuModal {
readonly locator: Locator;
readonly heading: Locator;
readonly budgetAmountInput: Locator;
readonly actionsButton: Locator;
readonly copyLastMonthBudgetButton: Locator;
readonly setTo3MonthAverageButton: Locator;
readonly setTo6MonthAverageButton: Locator;
@@ -17,6 +18,9 @@ export class BudgetMenuModal {
this.heading = locator.getByRole('heading');
this.budgetAmountInput = locator.getByTestId('amount-input');
this.actionsButton = locator.getByRole('button', {
name: 'Actions',
});
this.copyLastMonthBudgetButton = locator.getByRole('button', {
name: "Copy last month's budget",
});
@@ -38,6 +42,10 @@ export class BudgetMenuModal {
await this.heading.getByRole('button', { name: 'Close' }).click();
}
async showActions() {
await this.actionsButton.click();
}
async setBudgetAmount(newAmount: string) {
await this.budgetAmountInput.fill(newAmount);
await this.budgetAmountInput.blur();
@@ -45,22 +53,27 @@ export class BudgetMenuModal {
}
async copyLastMonthBudget() {
await this.showActions();
await this.copyLastMonthBudgetButton.click();
}
async setTo3MonthAverage() {
await this.showActions();
await this.setTo3MonthAverageButton.click();
}
async setTo6MonthAverage() {
await this.showActions();
await this.setTo6MonthAverageButton.click();
}
async setToYearlyAverage() {
await this.showActions();
await this.setToYearlyAverageButton.click();
}
async applyBudgetTemplate() {
await this.showActions();
await this.applyBudgetTemplateButton.click();
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 121 KiB

View File

@@ -22,9 +22,9 @@
"@actual-app/components": "workspace:*",
"@actual-app/core": "workspace:*",
"@babel/core": "^7.29.0",
"@codemirror/autocomplete": "^6.20.0",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/language": "^6.12.1",
"@codemirror/autocomplete": "^6.20.1",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/language": "^6.12.2",
"@codemirror/state": "^6.5.4",
"@codemirror/view": "^6.38.7",
"@emotion/css": "^11.13.5",
@@ -33,11 +33,11 @@
"@juggle/resize-observer": "^3.4.0",
"@lezer/highlight": "^1.2.3",
"@playwright/test": "1.58.2",
"@rolldown/plugin-babel": "~0.1.7",
"@rolldown/plugin-babel": "~0.1.8",
"@rollup/plugin-inject": "^5.0.5",
"@swc/core": "^1.15.11",
"@swc/helpers": "^0.5.18",
"@tanstack/react-query": "^5.90.20",
"@swc/core": "^1.15.18",
"@swc/helpers": "^0.5.19",
"@tanstack/react-query": "^5.90.21",
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "16.3.2",
@@ -49,7 +49,7 @@
"@types/react-dom": "^19.2.3",
"@types/react-modal": "^3.16.3",
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
"@uiw/react-codemirror": "^4.25.4",
"@uiw/react-codemirror": "^4.25.7",
"@use-gesture/react": "^10.3.1",
"@vitejs/plugin-basic-ssl": "^2.2.0",
"@vitejs/plugin-react": "^6.0.0",
@@ -58,10 +58,10 @@
"cmdk": "^1.1.1",
"cross-env": "^10.1.0",
"date-fns": "^4.1.0",
"downshift": "9.0.10",
"hyperformula": "^3.1.1",
"i18next": "^25.8.4",
"i18next-parser": "^9.3.0",
"downshift": "9.3.2",
"hyperformula": "^3.2.0",
"i18next": "^25.8.14",
"i18next-parser": "^9.4.0",
"i18next-resources-to-backend": "^1.2.1",
"jsdom": "^27.4.0",
"lodash": "^4.17.23",
@@ -80,19 +80,19 @@
"react-error-boundary": "^6.0.3",
"react-grid-layout": "^2.2.2",
"react-hotkeys-hook": "^5.2.4",
"react-i18next": "^16.5.4",
"react-i18next": "^16.5.6",
"react-markdown": "^10.1.0",
"react-modal": "3.16.3",
"react-redux": "^9.2.0",
"react-router": "7.13.0",
"react-router": "7.13.1",
"react-simple-pull-to-refresh": "^1.3.4",
"react-spring": "^10.0.3",
"react-swipeable": "^7.0.2",
"react-virtualized-auto-sizer": "^2.0.2",
"react-virtualized-auto-sizer": "^2.0.3",
"recharts": "^3.7.0",
"rehype-external-links": "^3.0.0",
"remark-gfm": "^4.0.1",
"rollup-plugin-visualizer": "^6.0.5",
"rollup-plugin-visualizer": "^6.0.11",
"sass": "^1.97.3",
"typescript-strict-plugin": "^2.4.4",
"usehooks-ts": "^3.1.1",

View File

@@ -33,7 +33,6 @@ export function ThemeSelector({ style }: ThemeSelectorProps) {
dark: SvgMoonStars,
auto: SvgSystem,
midnight: SvgMoonStars,
development: SvgMoonStars,
} as const;
type ThemeIconKey = keyof typeof themeIcons;

View File

@@ -25,6 +25,7 @@ import { IncomeMenu } from './IncomeMenu';
import { BalanceWithCarryover } from '@desktop-client/components/budget/BalanceWithCarryover';
import { makeAmountGrey } from '@desktop-client/components/budget/util';
import { NotesButton } from '@desktop-client/components/NotesButton';
import {
CellValue,
CellValueText,
@@ -281,85 +282,100 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
}}
>
{!editing && (
<View
className={`hover-expand ${budgetMenuOpen ? 'force-visible' : ''}`}
style={{
flexDirection: 'row',
flexShrink: 1,
paddingLeft: 3,
alignItems: 'center',
justifyContent: 'center',
borderTopWidth: 1,
borderBottomWidth: 1,
borderColor: theme.tableBorder,
}}
>
<Button
variant="bare"
onPress={() => {
resetBudgetPosition(2, -4);
setBudgetMenuOpen(true);
}}
<>
<View
style={{
color: theme.budgetNumberNeutral, //make sure button is visible when hovered
padding: 3,
paddingLeft: 3,
alignItems: 'center',
justifyContent: 'center',
borderTopWidth: 1,
borderBottomWidth: 1,
borderColor: theme.tableBorder,
}}
>
<SvgCheveronDown
width={14}
height={14}
className="hover-visible"
<NotesButton
id={`${category.id}-${month}`}
defaultColor={theme.pageTextLight}
/>
</Button>
<Popover
triggerRef={budgetMenuTriggerRef}
placement="bottom left"
isOpen={budgetMenuOpen}
onOpenChange={() => setBudgetMenuOpen(false)}
style={{ width: 200 }}
isNonModal
{...budgetPosition}
</View>
<View
className={`hover-expand ${budgetMenuOpen ? 'force-visible' : ''}`}
style={{
flexDirection: 'row',
flexShrink: 1,
paddingLeft: 3,
alignItems: 'center',
justifyContent: 'center',
borderTopWidth: 1,
borderBottomWidth: 1,
borderColor: theme.tableBorder,
}}
>
<BudgetMenu
onCopyLastMonthAverage={() => {
onMenuAction(month, 'copy-single-last', {
category: category.id,
});
showUndoNotification({
message: t(`Budget set to last month's budget.`),
});
<Button
variant="bare"
onPress={() => {
resetBudgetPosition(2, -4);
setBudgetMenuOpen(true);
}}
onSetMonthsAverage={numberOfMonths => {
if (
numberOfMonths !== 3 &&
numberOfMonths !== 6 &&
numberOfMonths !== 12
) {
return;
}
style={{
padding: 3,
}}
>
<SvgCheveronDown
width={14}
height={14}
className="hover-visible"
/>
</Button>
<Popover
triggerRef={budgetMenuTriggerRef}
placement="bottom left"
isOpen={budgetMenuOpen}
onOpenChange={() => setBudgetMenuOpen(false)}
style={{ width: 200 }}
isNonModal
{...budgetPosition}
>
<BudgetMenu
onCopyLastMonthAverage={() => {
onMenuAction(month, 'copy-single-last', {
category: category.id,
});
showUndoNotification({
message: t(`Budget set to last month's budget.`),
});
}}
onSetMonthsAverage={numberOfMonths => {
if (
numberOfMonths !== 3 &&
numberOfMonths !== 6 &&
numberOfMonths !== 12
) {
return;
}
onMenuAction(month, `set-single-${numberOfMonths}-avg`, {
category: category.id,
});
showUndoNotification({
message: t(
'Budget set to {{numberOfMonths}}-month average.',
{ numberOfMonths },
),
});
}}
onApplyBudgetTemplate={() => {
onMenuAction(month, 'apply-single-category-template', {
category: category.id,
});
showUndoNotification({
message: t(`Budget template applied.`),
});
}}
/>
</Popover>
</View>
onMenuAction(month, `set-single-${numberOfMonths}-avg`, {
category: category.id,
});
showUndoNotification({
message: t(
'Budget set to {{numberOfMonths}}-month average.',
{ numberOfMonths },
),
});
}}
onApplyBudgetTemplate={() => {
onMenuAction(month, 'apply-single-category-template', {
category: category.id,
});
showUndoNotification({
message: t(`Budget template applied.`),
});
}}
/>
</Popover>
</View>
</>
)}
<EnvelopeSheetCell
name="budget"

View File

@@ -15,6 +15,7 @@ import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { css } from '@emotion/css';
import { t } from 'i18next';
import * as monthUtils from 'loot-core/shared/months';
@@ -25,6 +26,7 @@ import { BudgetMenu } from './BudgetMenu';
import { BalanceWithCarryover } from '@desktop-client/components/budget/BalanceWithCarryover';
import { makeAmountGrey } from '@desktop-client/components/budget/util';
import { NotesButton } from '@desktop-client/components/NotesButton';
import {
CellValue,
CellValueText,
@@ -261,77 +263,96 @@ export const CategoryMonth = memo(function CategoryMonth({
}}
>
{!editing && (
<View
className={`hover-expand ${menuOpen ? 'force-visible' : ''}`}
style={{
flexDirection: 'row',
flexShrink: 0,
paddingLeft: 3,
alignItems: 'center',
justifyContent: 'center',
borderTopWidth: 1,
borderBottomWidth: 1,
borderColor: theme.tableBorder,
}}
>
<Button
ref={triggerRef}
variant="bare"
onPress={() => setMenuOpen(true)}
<>
<View
style={{
color: theme.budgetNumberNeutral, //make sure button is visible when hovered
padding: 3,
paddingLeft: 3,
alignItems: 'center',
justifyContent: 'center',
borderTopWidth: 1,
borderBottomWidth: 1,
borderColor: theme.tableBorder,
}}
>
<SvgCheveronDown
width={14}
height={14}
className="hover-visible"
<NotesButton
id={`${category.id}-${month}`}
defaultColor={theme.pageTextLight}
/>
</Button>
<Popover
triggerRef={triggerRef}
isOpen={menuOpen}
onOpenChange={() => setMenuOpen(false)}
placement="bottom start"
</View>
<View
className={`hover-expand ${menuOpen ? 'force-visible' : ''}`}
style={{
flexDirection: 'row',
flexShrink: 0,
paddingLeft: 3,
alignItems: 'center',
justifyContent: 'center',
borderTopWidth: 1,
borderBottomWidth: 1,
borderColor: theme.tableBorder,
}}
>
<BudgetMenu
onCopyLastMonthAverage={() => {
onMenuAction(month, 'copy-single-last', {
category: category.id,
});
showUndoNotification({
message: `Budget set to last month's budget.`,
});
<Button
ref={triggerRef}
variant="bare"
onPress={() => setMenuOpen(true)}
style={{
padding: 3,
}}
onSetMonthsAverage={numberOfMonths => {
if (
numberOfMonths !== 3 &&
numberOfMonths !== 6 &&
numberOfMonths !== 12
) {
return;
}
>
<SvgCheveronDown
width={14}
height={14}
className="hover-visible"
/>
</Button>
onMenuAction(month, `set-single-${numberOfMonths}-avg`, {
category: category.id,
});
showUndoNotification({
message: `Budget set to ${numberOfMonths}-month average.`,
});
}}
onApplyBudgetTemplate={() => {
onMenuAction(month, 'apply-single-category-template', {
category: category.id,
});
showUndoNotification({
message: `Budget template applied.`,
});
}}
/>
</Popover>
</View>
<Popover
triggerRef={triggerRef}
isOpen={menuOpen}
onOpenChange={() => setMenuOpen(false)}
placement="bottom start"
>
<BudgetMenu
onCopyLastMonthAverage={() => {
onMenuAction(month, 'copy-single-last', {
category: category.id,
});
showUndoNotification({
message: t(`Budget set to last month's budget.`),
});
}}
onSetMonthsAverage={numberOfMonths => {
if (
numberOfMonths !== 3 &&
numberOfMonths !== 6 &&
numberOfMonths !== 12
) {
return;
}
onMenuAction(month, `set-single-${numberOfMonths}-avg`, {
category: category.id,
});
showUndoNotification({
message: t(
'Budget set to {{numberOfMonths}}-month average.',
{ numberOfMonths },
),
});
}}
onApplyBudgetTemplate={() => {
onMenuAction(month, 'apply-single-category-template', {
category: category.id,
});
showUndoNotification({
message: t(`Budget template applied.`),
});
}}
/>
</Popover>
</View>
</>
)}
<TrackingSheetCell
name="budget"

View File

@@ -7,6 +7,8 @@ import { styles } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text';
import { AutoTextSize } from 'auto-text-size';
import { send } from 'loot-core/platform/client/connection';
import * as monthUtils from 'loot-core/shared/months';
import type { CategoryEntity } from 'loot-core/types/models';
import { getColumnWidth, PILL_STYLE } from './BudgetTable';
@@ -15,6 +17,7 @@ import { makeAmountGrey } from '@desktop-client/components/budget/util';
import { PrivacyFilter } from '@desktop-client/components/PrivacyFilter';
import { CellValue } from '@desktop-client/components/spreadsheet/CellValue';
import { useFormat } from '@desktop-client/hooks/useFormat';
import { useLocale } from '@desktop-client/hooks/useLocale';
import { useNotes } from '@desktop-client/hooks/useNotes';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
import { useUndo } from '@desktop-client/hooks/useUndo';
@@ -43,6 +46,7 @@ export function BudgetCell<
...props
}: BudgetCellProps<SheetFieldName>) {
const { t } = useTranslation();
const locale = useLocale();
const columnWidth = getColumnWidth();
const dispatch = useDispatch();
const format = useFormat();
@@ -50,6 +54,31 @@ export function BudgetCell<
const [budgetType = 'envelope'] = useSyncedPref('budgetType');
const categoryNotes = useNotes(category.id);
const onSaveNotes = useCallback(async (id: string, notes: string) => {
await send('notes-save', { id, note: notes });
}, []);
const onEditNotes = useCallback(
(id: string, month: string) => {
dispatch(
pushModal({
modal: {
name: 'notes',
options: {
id,
name:
category.name +
' - ' +
monthUtils.format(month, "MMMM ''yy", locale),
onSave: onSaveNotes,
},
},
}),
);
},
[category.name, locale, dispatch, onSaveNotes],
);
const onOpenCategoryBudgetMenu = useCallback(() => {
const modalBudgetType = budgetType === 'envelope' ? 'envelope' : 'tracking';
const categoryBudgetMenuModal = `${modalBudgetType}-budget-menu` as const;
@@ -60,6 +89,7 @@ export function BudgetCell<
options: {
categoryId: category.id,
month,
onEditNotes,
onUpdateBudget: amount => {
onBudgetAction(month, 'budget-amount', {
category: category.id,
@@ -114,6 +144,7 @@ export function BudgetCell<
month,
onBudgetAction,
showUndoNotification,
onEditNotes,
format,
]);

View File

@@ -2,10 +2,17 @@ import React, { useEffect, useState } from 'react';
import type { CSSProperties } from 'react';
import { Trans } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import {
SvgCheveronDown,
SvgCheveronUp,
} from '@actual-app/components/icons/v1';
import { SvgNotesPaper } from '@actual-app/components/icons/v2';
import { styles } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { t } from 'i18next';
import * as Platform from 'loot-core/shared/platform';
import { amountToInteger, integerToAmount } from 'loot-core/shared/util';
@@ -19,14 +26,16 @@ import {
ModalTitle,
} from '@desktop-client/components/common/Modal';
import { FocusableAmountInput } from '@desktop-client/components/mobile/transactions/FocusableAmountInput';
import { Notes } from '@desktop-client/components/Notes';
import { useCategory } from '@desktop-client/hooks/useCategory';
import { useNotes } from '@desktop-client/hooks/useNotes';
import type { Modal as ModalType } from '@desktop-client/modals/modalsSlice';
import { envelopeBudget } from '@desktop-client/spreadsheet/bindings';
type EnvelopeBudgetMenuModalProps = Omit<
Extract<ModalType, { name: 'envelope-budget-menu' }>['options'],
'month'
>;
type EnvelopeBudgetMenuModalProps = Extract<
ModalType,
{ name: 'envelope-budget-menu' }
>['options'];
export function EnvelopeBudgetMenuModal({
categoryId,
@@ -34,7 +43,17 @@ export function EnvelopeBudgetMenuModal({
onCopyLastMonthAverage,
onSetMonthsAverage,
onApplyBudgetTemplate,
onEditNotes,
month,
}: EnvelopeBudgetMenuModalProps) {
const buttonStyle: CSSProperties = {
...styles.mediumText,
height: styles.mobileMinHeight,
color: theme.formLabelText,
// Adjust based on desired number of buttons per row.
flexBasis: '100%',
};
const defaultMenuItemStyle: CSSProperties = {
...styles.mobileMenuItem,
color: theme.menuItemText,
@@ -48,10 +67,24 @@ export function EnvelopeBudgetMenuModal({
const { data: category } = useCategory(categoryId);
const [amountFocused, setAmountFocused] = useState(false);
const notesId = category ? `${category.id}-${month}` : '';
const originalNotes = useNotes(notesId) ?? '';
const _onUpdateBudget = (amount: number) => {
onUpdateBudget?.(amountToInteger(amount));
};
const [showMore, setShowMore] = useState(false);
const onShowMore = () => {
setShowMore(!showMore);
};
const _onEditNotes = () => {
if (category && month) {
onEditNotes?.(`${category.id}-${month}`, month);
}
};
useEffect(() => {
// iOS does not support automatically opening up the keyboard for the
// total amount field. Hence we should not focus on it on page render.
@@ -76,7 +109,6 @@ export function EnvelopeBudgetMenuModal({
style={{
justifyContent: 'center',
alignItems: 'center',
marginBottom: 20,
}}
>
<Text
@@ -106,12 +138,71 @@ export function EnvelopeBudgetMenuModal({
data-testid="budget-amount"
/>
</View>
<BudgetMenu
getItemStyle={() => defaultMenuItemStyle}
onCopyLastMonthAverage={onCopyLastMonthAverage}
onSetMonthsAverage={onSetMonthsAverage}
onApplyBudgetTemplate={onApplyBudgetTemplate}
/>
<View
style={{
display: showMore ? 'none' : undefined,
overflowY: 'auto',
flex: 1,
}}
>
<Notes
notes={originalNotes.length > 0 ? originalNotes : t('No notes')}
editable={false}
focused={false}
getStyle={() => ({
borderRadius: 6,
...(originalNotes.length === 0 && {
justifySelf: 'center',
alignSelf: 'center',
color: theme.pageTextSubdued,
}),
})}
/>
</View>
<View
style={{
display: showMore ? 'none' : undefined,
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
alignContent: 'space-between',
}}
>
<Button style={buttonStyle} onPress={_onEditNotes}>
<SvgNotesPaper
width={20}
height={20}
style={{ paddingRight: 5 }}
/>
<Trans>Edit notes</Trans>
</Button>
</View>
<View>
<Button variant="bare" style={buttonStyle} onPress={onShowMore}>
{!showMore ? (
<SvgCheveronUp
width={30}
height={30}
style={{ paddingRight: 5 }}
/>
) : (
<SvgCheveronDown
width={30}
height={30}
style={{ paddingRight: 5 }}
/>
)}
<Trans>Actions</Trans>
</Button>
</View>
{showMore && (
<BudgetMenu
getItemStyle={() => defaultMenuItemStyle}
onCopyLastMonthAverage={onCopyLastMonthAverage}
onSetMonthsAverage={onSetMonthsAverage}
onApplyBudgetTemplate={onApplyBudgetTemplate}
/>
)}
</>
)}
</Modal>

View File

@@ -2,10 +2,17 @@ import React, { useEffect, useState } from 'react';
import type { CSSProperties } from 'react';
import { Trans } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import {
SvgCheveronDown,
SvgCheveronUp,
} from '@actual-app/components/icons/v1';
import { SvgNotesPaper } from '@actual-app/components/icons/v2';
import { styles } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { t } from 'i18next';
import * as Platform from 'loot-core/shared/platform';
import { amountToInteger, integerToAmount } from 'loot-core/shared/util';
@@ -19,14 +26,16 @@ import {
ModalTitle,
} from '@desktop-client/components/common/Modal';
import { FocusableAmountInput } from '@desktop-client/components/mobile/transactions/FocusableAmountInput';
import { Notes } from '@desktop-client/components/Notes';
import { useCategory } from '@desktop-client/hooks/useCategory';
import { useNotes } from '@desktop-client/hooks/useNotes';
import type { Modal as ModalType } from '@desktop-client/modals/modalsSlice';
import { trackingBudget } from '@desktop-client/spreadsheet/bindings';
type TrackingBudgetMenuModalProps = Omit<
Extract<ModalType, { name: 'tracking-budget-menu' }>['options'],
'month'
>;
type TrackingBudgetMenuModalProps = Extract<
ModalType,
{ name: 'tracking-budget-menu' }
>['options'];
export function TrackingBudgetMenuModal({
categoryId,
@@ -34,6 +43,8 @@ export function TrackingBudgetMenuModal({
onCopyLastMonthAverage,
onSetMonthsAverage,
onApplyBudgetTemplate,
onEditNotes,
month,
}: TrackingBudgetMenuModalProps) {
const defaultMenuItemStyle: CSSProperties = {
...styles.mobileMenuItem,
@@ -42,16 +53,38 @@ export function TrackingBudgetMenuModal({
borderTop: `1px solid ${theme.pillBorder}`,
};
const buttonStyle: CSSProperties = {
...styles.mediumText,
height: styles.mobileMinHeight,
color: theme.formLabelText,
// Adjust based on desired number of buttons per row.
flexBasis: '100%',
};
const budgeted = useTrackingSheetValue(
trackingBudget.catBudgeted(categoryId),
);
const { data: category } = useCategory(categoryId);
const notesId = category ? `${category.id}-${month}` : '';
const originalNotes = useNotes(notesId) ?? '';
const [amountFocused, setAmountFocused] = useState(false);
const _onUpdateBudget = (amount: number) => {
onUpdateBudget?.(amountToInteger(amount));
};
const _onEditNotes = () => {
if (category && month) {
onEditNotes?.(`${category.id}-${month}`, month);
}
};
const [showMore, setShowMore] = useState(false);
const onShowMore = () => {
setShowMore(!showMore);
};
useEffect(() => {
// iOS does not support automatically opening up the keyboard for the
// total amount field. Hence we should not focus on it on page render.
@@ -76,7 +109,6 @@ export function TrackingBudgetMenuModal({
style={{
justifyContent: 'center',
alignItems: 'center',
marginBottom: 20,
}}
>
<Text
@@ -106,12 +138,71 @@ export function TrackingBudgetMenuModal({
data-testid="budget-amount"
/>
</View>
<BudgetMenu
getItemStyle={() => defaultMenuItemStyle}
onCopyLastMonthAverage={onCopyLastMonthAverage}
onSetMonthsAverage={onSetMonthsAverage}
onApplyBudgetTemplate={onApplyBudgetTemplate}
/>
<View
style={{
display: showMore ? 'none' : undefined,
overflowY: 'auto',
flex: 1,
}}
>
<Notes
notes={originalNotes.length > 0 ? originalNotes : t('No notes')}
editable={false}
focused={false}
getStyle={() => ({
borderRadius: 6,
...(originalNotes.length === 0 && {
justifySelf: 'center',
alignSelf: 'center',
color: theme.pageTextSubdued,
}),
})}
/>
</View>
<View
style={{
display: showMore ? 'none' : undefined,
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
alignContent: 'space-between',
}}
>
<Button style={buttonStyle} onPress={_onEditNotes}>
<SvgNotesPaper
width={20}
height={20}
style={{ paddingRight: 5 }}
/>
<Trans>Edit notes</Trans>
</Button>
</View>
<View>
<Button variant="bare" style={buttonStyle} onPress={onShowMore}>
{!showMore ? (
<SvgCheveronUp
width={30}
height={30}
style={{ paddingRight: 5 }}
/>
) : (
<SvgCheveronDown
width={30}
height={30}
style={{ paddingRight: 5 }}
/>
)}
<Trans>Actions</Trans>
</Button>
</View>
{showMore && (
<BudgetMenu
getItemStyle={() => defaultMenuItemStyle}
onCopyLastMonthAverage={onCopyLastMonthAverage}
onSetMonthsAverage={onSetMonthsAverage}
onApplyBudgetTemplate={onApplyBudgetTemplate}
/>
)}
</>
)}
</Modal>

View File

@@ -53,6 +53,7 @@ const balanceTypeOptions = [
const groupByOptions = [
{ description: t('Category'), key: 'Category' },
{ description: t('Group'), key: 'Group' },
{ description: t('Category+Group'), key: 'CategoryGroup' }, // new: two-ring donut support
{ description: t('Payee'), key: 'Payee' },
{ description: t('Account'), key: 'Account' },
{ description: t('Interval'), key: 'Interval' },
@@ -220,12 +221,10 @@ const intervalOptions: intervalOptionsProps[] = [
format: 'yy-MM-dd',
range: 'weekRangeInclusive',
},
//{ value: 3, description: 'Fortnightly', name: 3},
{
description: t('Monthly'),
key: 'Monthly',
name: 'Month',
format: "MMM ''yy",
range: 'rangeInclusive',
},
@@ -328,17 +327,11 @@ export const categoryLists = (categories: {
const categoriesToSort = [...categories.list];
const categoryList: UncategorizedEntity[] = [
...categoriesToSort.sort((a, b) => {
//The point of this sorting is to make the graphs match the "budget" page
const catGroupA = categories.grouped.find(f => f.id === a.group);
const catGroupB = categories.grouped.find(f => f.id === b.group);
//initial check that both a and b have a sort_order and category group
return a.sort_order && b.sort_order && catGroupA && catGroupB
? /*sorting by "is_income" because sort_order for this group is
separate from other groups*/
Number(catGroupA.is_income) - Number(catGroupB.is_income) ||
//Next, sorting by group sort_order
? Number(catGroupA.is_income) - Number(catGroupB.is_income) ||
(catGroupA.sort_order ?? 0) - (catGroupB.sort_order ?? 0) ||
//Finally, sorting by category within each group
a.sort_order - b.sort_order
: 0;
}),
@@ -382,6 +375,20 @@ export const groupBySelections = (
});
groupByLabel = 'categoryGroup';
break;
// CategoryGroup uses category-level data from createCustomSpreadsheet.
// The group-level data comes from groupedData (createGroupedSpreadsheet).
// This case just prevents the default throw so the spreadsheet doesn't error.
case 'CategoryGroup':
groupByList = categoryGroup.map(group => {
return {
...group,
id: group.id,
name: group.name,
hidden: group.hidden,
};
});
groupByLabel = 'categoryGroup';
break;
case 'Payee':
groupByList = payees.map(payee => {
return { id: payee.id, name: payee.name, hidden: false };

View File

@@ -63,10 +63,12 @@ type graphOptions = {
disableLabel?: boolean;
disableSort?: boolean;
};
const totalGraphOptions: graphOptions[] = [
{
description: 'TableGraph',
disabledSplit: [],
// CategoryGroup is only valid for DonutGraph
disabledSplit: ['CategoryGroup'],
defaultSplit: 'Category',
disabledType: [],
defaultType: 'Payment',
@@ -76,7 +78,8 @@ const totalGraphOptions: graphOptions[] = [
},
{
description: 'BarGraph',
disabledSplit: [],
// CategoryGroup is only valid for DonutGraph
disabledSplit: ['CategoryGroup'],
defaultSplit: 'Category',
disabledType: [],
defaultType: 'Payment',
@@ -84,7 +87,8 @@ const totalGraphOptions: graphOptions[] = [
},
{
description: 'AreaGraph',
disabledSplit: ['Category', 'Group', 'Payee', 'Account'],
// CategoryGroup is only valid for DonutGraph
disabledSplit: ['Category', 'Group', 'CategoryGroup', 'Payee', 'Account'],
defaultSplit: 'Interval',
disabledType: [],
defaultType: 'Payment',
@@ -94,6 +98,7 @@ const totalGraphOptions: graphOptions[] = [
},
{
description: 'DonutGraph',
// CategoryGroup is allowed here — it enables the two-ring concentric donut
disabledSplit: [],
defaultSplit: 'Category',
disabledType: ['Net'],
@@ -105,7 +110,8 @@ const totalGraphOptions: graphOptions[] = [
const timeGraphOptions: graphOptions[] = [
{
description: 'TableGraph',
disabledSplit: ['Interval'],
// CategoryGroup disabled in time mode (DonutGraph not available in time mode)
disabledSplit: ['Interval', 'CategoryGroup'],
defaultSplit: 'Category',
disabledType: ['Net Payment', 'Net Deposit'],
defaultType: 'Payment',
@@ -116,7 +122,8 @@ const timeGraphOptions: graphOptions[] = [
},
{
description: 'StackedBarGraph',
disabledSplit: ['Interval'],
// CategoryGroup disabled in time mode
disabledSplit: ['Interval', 'CategoryGroup'],
defaultSplit: 'Category',
disabledType: [],
defaultType: 'Payment',
@@ -125,7 +132,8 @@ const timeGraphOptions: graphOptions[] = [
},
{
description: 'LineGraph',
disabledSplit: ['Interval'],
// CategoryGroup disabled in time mode
disabledSplit: ['Interval', 'CategoryGroup'],
defaultSplit: 'Category',
disabledType: [],
defaultType: 'Payment',

View File

@@ -1,9 +1,9 @@
// @ts-strict-ignore
import React, { useState } from 'react';
import React, { useMemo, useState } from 'react';
import type { CSSProperties } from 'react';
import { theme } from '@actual-app/components/theme';
import { Pie, PieChart, Sector, Tooltip } from 'recharts';
import { Pie, PieChart, Sector } from 'recharts';
import type { PieSectorShapeProps } from 'recharts';
import type {
@@ -11,6 +11,7 @@ import type {
DataEntity,
GroupedEntity,
IntervalEntity,
LegendEntity,
RuleConditionEntity,
} from 'loot-core/types/models';
@@ -31,52 +32,162 @@ const RADIAN = Math.PI / 180;
const canDeviceHover = () => window.matchMedia('(hover: hover)').matches;
const ActiveShapeMobile = props => {
const {
cx,
cy,
innerRadius,
outerRadius,
startAngle,
endAngle,
fill,
payload,
percent,
value,
format,
} = props;
const yAxis = payload.name ?? payload.date;
// ---------------------------------------------------------------------------
// Dimension helpers
// ---------------------------------------------------------------------------
const sin = Math.sin(-RADIAN * 240);
const my = cy + outerRadius * sin;
const ey = my - 5;
type DonutDimensions = {
chartInnerRadius: number;
chartMidRadius: number;
chartOuterRadius: number;
compact: boolean;
};
const getDonutDimensions = (
width: number,
height: number,
twoRings: boolean,
): DonutDimensions => {
const minDim = Math.min(width, height);
const compact = height <= 300 || width <= 300;
return {
chartInnerRadius: minDim * (twoRings && compact ? 0.16 : 0.2),
chartMidRadius: minDim * (compact ? 0.27 : 0.31),
chartOuterRadius: minDim * (compact ? 0.36 : 0.42),
compact,
};
};
// ---------------------------------------------------------------------------
// Color helpers
// ---------------------------------------------------------------------------
const resolveCSSVariable = (color: string): string => {
if (!color.startsWith('var(')) return color;
const inner = color.slice(4, -1).trim();
const varName = inner.split(',')[0].trim();
return getComputedStyle(document.documentElement)
.getPropertyValue(varName)
.trim();
};
const hexToRgb = (hex: string) => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: { r: 0, g: 0, b: 0 };
};
const shadeColor = (resolvedHex: string, percent: number): string => {
const { r, g, b } = hexToRgb(resolvedHex);
const adjust = (c: number) =>
Math.min(255, Math.max(0, Math.round(c + (255 - c) * percent)));
return `rgb(${adjust(r)}, ${adjust(g)}, ${adjust(b)})`;
};
const buildColorMap = (
groupedData: GroupedEntity[],
legend: LegendEntity[],
): Map<string, string> => {
const legendById = new Map(
legend
.filter(l => l.id != null)
.map(l => [l.id, resolveCSSVariable(l.color)]),
);
return groupedData.reduce((acc, group) => {
if (!group.id) return acc;
const groupColor = legendById.get(group.id);
if (!groupColor) return acc;
acc.set(group.id, groupColor);
// Fix 1: capture cats once to avoid group.categories.length on undefined
const cats = group.categories ?? [];
cats.forEach((cat, i) => {
if (!cat.id) return;
const shade = 0.15 + (i / Math.max(cats.length, 1)) * 0.5;
acc.set(cat.id, shadeColor(groupColor, shade));
});
return acc;
}, new Map<string, string>());
};
// ---------------------------------------------------------------------------
// Active shapes
// ---------------------------------------------------------------------------
type ActiveShapeProps = {
cx: number;
cy: number;
midAngle: number;
innerRadius: number;
outerRadius: number;
startAngle: number;
endAngle: number;
fill: string;
payload: { name?: string; date?: string };
percent: number;
value: number;
expandInward: boolean;
chartInnerRadius: number;
chartMidRadius: number;
chartOuterRadius: number;
};
const ActiveShapeMobile = ({
cx,
cy,
innerRadius,
outerRadius,
startAngle,
endAngle,
fill,
payload,
percent,
value,
expandInward,
chartInnerRadius,
chartMidRadius,
chartOuterRadius,
}: ActiveShapeProps) => {
const format = useFormat();
// Fix 2: guard against undefined payload.name and payload.date
const yAxis = payload.name ?? payload.date ?? '';
const expansionInner = expandInward ? chartInnerRadius - 4 : outerRadius + 2;
const expansionOuter = expandInward ? chartInnerRadius - 2 : outerRadius + 4;
const ey = cy + chartOuterRadius * Math.sin(-RADIAN * 240) - 5;
return (
<g>
<text
x={cx}
y={cy + outerRadius * Math.sin(-RADIAN * 270) + 15}
dy={0}
y={cy + chartOuterRadius * Math.sin(-RADIAN * 270) + 17}
textAnchor="middle"
fill={fill}
>
{`${yAxis}`}
{yAxis}
</text>
<PrivacyFilter>
<FinancialText
as="text"
x={cx + outerRadius * Math.cos(-RADIAN * 240) - 30}
x={cx + chartOuterRadius * Math.cos(-RADIAN * 240) - 30}
y={ey}
dy={0}
textAnchor="end"
fill={fill}
>
{`${format(value, 'financial')}`}
{format(value, 'financial')}
</FinancialText>
<text
x={cx + outerRadius * Math.cos(-RADIAN * 330) + 10}
x={cx + chartOuterRadius * Math.cos(-RADIAN * 330) + 10}
y={ey}
dy={0}
textAnchor="start"
fill="#999"
>
@@ -97,43 +208,52 @@ const ActiveShapeMobile = props => {
cy={cy}
startAngle={startAngle}
endAngle={endAngle}
innerRadius={innerRadius - 8}
outerRadius={innerRadius - 6}
innerRadius={expansionInner}
outerRadius={expansionOuter}
fill={fill}
/>
</g>
);
};
const ActiveShapeMobileWithFormat = props => (
<ActiveShapeMobile {...props} format={props.format} />
);
const ActiveShape = props => {
const {
cx,
cy,
midAngle,
innerRadius,
outerRadius,
startAngle,
endAngle,
fill,
payload,
percent,
value,
format,
} = props;
const yAxis = payload.name ?? payload.date;
const ActiveShapeDesktop = ({
cx,
cy,
midAngle,
innerRadius,
outerRadius,
startAngle,
endAngle,
fill,
payload,
percent,
value,
expandInward,
chartInnerRadius,
chartMidRadius,
chartOuterRadius,
}: ActiveShapeProps) => {
const format = useFormat();
// Fix 2: guard against undefined payload.name and payload.date
const yAxis = payload.name ?? payload.date ?? '';
const sin = Math.sin(-RADIAN * midAngle);
const cos = Math.cos(-RADIAN * midAngle);
const sx = cx + (innerRadius - 10) * cos;
const sy = cy + (innerRadius - 10) * sin;
const mx = cx + (innerRadius - 30) * cos;
const my = cy + (innerRadius - 30) * sin;
const expansionInner = expandInward ? chartInnerRadius - 10 : outerRadius + 6;
const expansionOuter = expandInward ? chartInnerRadius - 6 : outerRadius + 10;
const lineStart = expandInward
? chartInnerRadius - 20
: chartInnerRadius - 10;
const lineMid = chartInnerRadius * 0.7;
const sx = cx + lineStart * cos;
const sy = cy + lineStart * sin;
const mx = cx + lineMid * cos;
const my = cy + lineMid * sin;
const ex = cx + (cos >= 0 ? 1 : -1) * yAxis.length * 4;
const ey = cy + 8;
const textAnchor = cos <= 0 ? 'start' : 'end';
const labelX = ex + (cos <= 0 ? 1 : -1) * 16;
return (
<g>
@@ -151,8 +271,8 @@ const ActiveShape = props => {
cy={cy}
startAngle={startAngle}
endAngle={endAngle}
innerRadius={outerRadius + 6}
outerRadius={outerRadius + 10}
innerRadius={expansionInner}
outerRadius={expansionOuter}
fill={fill}
/>
<path
@@ -161,30 +281,21 @@ const ActiveShape = props => {
fill="none"
/>
<circle cx={ex} cy={ey} r={3} fill={fill} stroke="none" />
<text
x={ex + (cos <= 0 ? 1 : -1) * 16}
y={ey}
textAnchor={textAnchor}
fill={fill}
>{`${yAxis}`}</text>
<text x={labelX} y={ey} textAnchor={textAnchor} fill={fill}>
{yAxis}
</text>
<PrivacyFilter>
<FinancialText
as="text"
x={ex + (cos <= 0 ? 1 : -1) * 16}
x={labelX}
y={ey}
dy={18}
textAnchor={textAnchor}
fill={fill}
>
{`${format(value, 'financial')}`}
{format(value, 'financial')}
</FinancialText>
<text
x={ex + (cos <= 0 ? 1 : -1) * 16}
y={ey}
dy={36}
textAnchor={textAnchor}
fill="#999"
>
<text x={labelX} y={ey} dy={36} textAnchor={textAnchor} fill="#999">
{`(${(percent * 100).toFixed(2)}%)`}
</text>
</PrivacyFilter>
@@ -192,15 +303,14 @@ const ActiveShape = props => {
);
};
const ActiveShapeWithFormat = props => (
<ActiveShape {...props} format={props.format} />
);
// ---------------------------------------------------------------------------
// Custom label
// ---------------------------------------------------------------------------
const customLabel = props => {
const radius =
props.innerRadius + (props.outerRadius - props.innerRadius) * 0.5;
const size = props.cx > props.cy ? props.cy : props.cx;
const calcX = props.cx + radius * Math.cos(-props.midAngle * RADIAN);
const calcY = props.cy + radius * Math.sin(-props.midAngle * RADIAN);
const textAnchor = calcX > props.cx ? 'start' : 'end';
@@ -209,7 +319,6 @@ const customLabel = props => {
const showLabel = props.percent;
const showLabelThreshold = 0.05;
const fill = theme.reportsInnerLabel;
return renderCustomLabel(
calcX,
calcY,
@@ -222,6 +331,10 @@ const customLabel = props => {
);
};
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
type DonutGraphProps = {
style?: CSSProperties;
data: DataEntity;
@@ -245,7 +358,6 @@ export function DonutGraph({
showOffBudget,
showTooltip = true,
}: DonutGraphProps) {
const format = useFormat();
const animationProps = useRechartsAnimation({ isAnimationActive: false });
const yAxis = groupBy === 'Interval' ? 'date' : 'name';
@@ -259,18 +371,248 @@ export function DonutGraph({
const getVal = (obj: GroupedEntity | IntervalEntity) => {
if (['totalDebts', 'netDebts'].includes(balanceTypeOp)) {
return -1 * obj[balanceTypeOp];
} else {
return obj[balanceTypeOp];
}
return obj[balanceTypeOp];
};
const [activeIndex, setActiveIndex] = useState(0);
const [activeGroupIndex, setActiveGroupIndex] = useState(0);
const [activeCategoryIndex, setActiveCategoryIndex] = useState(0);
const [activeRing, setActiveRing] = useState<'group' | 'category'>(
'category',
);
const isCategoryGroup =
groupBy === 'CategoryGroup' && !!data.groupedData?.length;
const { adjustedGroupData, flatCategories } = useMemo(() => {
if (!isCategoryGroup || !data.groupedData) {
return { adjustedGroupData: [], flatCategories: [] };
}
const adjustedGroups = data.groupedData
.map(group => {
const visibleCats = group.categories ?? [];
return {
...group,
totalAssets: visibleCats.reduce((sum, c) => sum + c.totalAssets, 0),
totalDebts: visibleCats.reduce((sum, c) => sum + c.totalDebts, 0),
totalTotals: visibleCats.reduce((sum, c) => sum + c.totalTotals, 0),
netAssets: visibleCats.reduce((sum, c) => sum + c.netAssets, 0),
netDebts: visibleCats.reduce((sum, c) => sum + c.netDebts, 0),
};
})
.filter(group =>
['totalDebts', 'netDebts'].includes(balanceTypeOp)
? -1 * group[balanceTypeOp] !== 0
: group[balanceTypeOp] !== 0,
);
return {
adjustedGroupData: adjustedGroups,
flatCategories: data.groupedData.flatMap(g => g.categories ?? []),
};
}, [isCategoryGroup, data.groupedData, balanceTypeOp]);
const colorMap = useMemo(
() =>
isCategoryGroup
? buildColorMap(data.groupedData ?? [], data.legend ?? [])
: new Map<string, string>(),
[isCategoryGroup, data.groupedData, data.legend],
);
return (
<Container style={style}>
{(width, height) => {
const compact = height <= 300 || width <= 300;
const { chartInnerRadius, chartMidRadius, chartOuterRadius, compact } =
getDonutDimensions(width, height, isCategoryGroup);
const showActiveShape = width >= 220 && height >= 130;
// ---------------------------------------------------------------
// Two-ring concentric donut (CategoryGroup mode)
// ---------------------------------------------------------------
if (isCategoryGroup) {
return (
data.groupedData && (
<div>
{!compact && <div style={{ marginTop: '15px' }} />}
<PieChart
responsive
width={width}
height={height}
style={{ cursor: pointer }}
>
{/* Inner ring — Category Groups */}
<Pie
dataKey={val => getVal(val)}
nameKey="name"
{...animationProps}
data={adjustedGroupData}
innerRadius={chartInnerRadius}
outerRadius={chartMidRadius}
startAngle={90}
endAngle={-270}
shape={(props: PieSectorShapeProps, index: number) => {
const item = adjustedGroupData[index];
const fill =
colorMap.get(item?.id ?? item?.name ?? '') ??
props.fill;
const isActive =
activeRing === 'group' && index === activeGroupIndex;
if (isActive && showActiveShape) {
return compact ? (
<ActiveShapeMobile
{...(props as unknown as ActiveShapeProps)}
fill={fill}
expandInward
chartInnerRadius={chartInnerRadius}
chartMidRadius={chartMidRadius}
chartOuterRadius={chartOuterRadius}
/>
) : (
<ActiveShapeDesktop
{...(props as unknown as ActiveShapeProps)}
fill={fill}
expandInward
chartInnerRadius={chartInnerRadius}
chartMidRadius={chartMidRadius}
chartOuterRadius={chartOuterRadius}
/>
);
}
return <Sector {...props} fill={fill} />;
}}
onMouseLeave={() => setPointer('')}
onMouseEnter={(_, index) => {
if (canDeviceHover()) {
setActiveGroupIndex(index);
setActiveRing('group');
}
}}
onClick={(item, index) => {
if (!canDeviceHover()) {
setActiveGroupIndex(index);
setActiveRing('group');
}
if (
(canDeviceHover() || activeGroupIndex === index) &&
((compact && showTooltip) || !compact)
) {
const groupCategoryIds = (
data.groupedData?.find(g => g.id === item.id)
?.categories ?? []
)
.map(c => c.id)
.filter((c): c is string => c != null);
showActivity({
navigate,
categories,
accounts,
balanceTypeOp,
filters,
showHiddenCategories,
showOffBudget,
type: 'totals',
startDate: data.startDate,
endDate: data.endDate,
field: 'category',
id: groupCategoryIds,
});
}
}}
/>
{/* Outer ring — Categories */}
<Pie
dataKey={val => getVal(val)}
nameKey="name"
{...animationProps}
data={flatCategories}
innerRadius={chartMidRadius}
outerRadius={chartOuterRadius}
startAngle={90}
endAngle={-270}
labelLine={false}
label={e =>
viewLabels && !compact ? customLabel(e) : null
}
shape={(props: PieSectorShapeProps, index: number) => {
const item = flatCategories[index];
const fill =
colorMap.get(item?.id ?? item?.name ?? '') ??
props.fill;
const isActive =
activeRing === 'category' &&
index === activeCategoryIndex;
if (isActive && showActiveShape) {
return compact ? (
<ActiveShapeMobile
{...(props as unknown as ActiveShapeProps)}
fill={fill}
expandInward={false}
chartInnerRadius={chartInnerRadius}
chartMidRadius={chartMidRadius}
chartOuterRadius={chartOuterRadius}
/>
) : (
<ActiveShapeDesktop
{...(props as unknown as ActiveShapeProps)}
fill={fill}
expandInward={false}
chartInnerRadius={chartInnerRadius}
chartMidRadius={chartMidRadius}
chartOuterRadius={chartOuterRadius}
/>
);
}
return <Sector {...props} fill={fill} />;
}}
onMouseLeave={() => setPointer('')}
onMouseEnter={(_, index) => {
if (canDeviceHover()) {
setActiveCategoryIndex(index);
setActiveRing('category');
setPointer('pointer');
}
}}
onClick={(item, index) => {
if (!canDeviceHover()) {
setActiveCategoryIndex(index);
setActiveRing('category');
}
if (
(canDeviceHover() || activeCategoryIndex === index) &&
((compact && showTooltip) || !compact)
) {
showActivity({
navigate,
categories,
accounts,
balanceTypeOp,
filters,
showHiddenCategories,
showOffBudget,
type: 'totals',
startDate: data.startDate,
endDate: data.endDate,
field: 'category',
id: item.id,
});
}
}}
/>
</PieChart>
</div>
)
);
}
// ---------------------------------------------------------------
// Single-ring donut (all other groupBy modes)
// ---------------------------------------------------------------
return (
data[splitData] && (
<div>
@@ -285,13 +627,8 @@ export function DonutGraph({
dataKey={val => getVal(val)}
nameKey={yAxis}
{...animationProps}
data={
data[splitData]?.map(item => ({
...item,
})) ?? []
}
innerRadius={Math.min(width, height) * 0.2}
fill="#8884d8"
data={data[splitData]?.map(item => ({ ...item })) ?? []}
innerRadius={chartInnerRadius}
labelLine={false}
label={e =>
viewLabels && !compact ? customLabel(e) : <div />
@@ -299,15 +636,28 @@ export function DonutGraph({
startAngle={90}
endAngle={-270}
shape={(props: PieSectorShapeProps, index: number) => {
const fill = data.legend[index]?.color ?? props.fill;
const showActiveShape = width >= 220 && height >= 130;
const isActive = props.isActive || index === activeIndex;
// Fix 3: optional chain data.legend to guard against undefined
const fill = data.legend?.[index]?.color ?? props.fill;
const isActive = index === activeIndex;
if (isActive && showActiveShape) {
const shapeProps = { ...props, fill, format };
return compact ? (
<ActiveShapeMobileWithFormat {...shapeProps} />
<ActiveShapeMobile
{...(props as unknown as ActiveShapeProps)}
fill={fill}
expandInward
chartInnerRadius={chartInnerRadius}
chartMidRadius={chartMidRadius}
chartOuterRadius={chartOuterRadius}
/>
) : (
<ActiveShapeWithFormat {...shapeProps} />
<ActiveShapeDesktop
{...(props as unknown as ActiveShapeProps)}
fill={fill}
expandInward={false}
chartInnerRadius={chartInnerRadius}
chartMidRadius={chartMidRadius}
chartOuterRadius={chartOuterRadius}
/>
);
}
return <Sector {...props} fill={fill} />;
@@ -325,12 +675,21 @@ export function DonutGraph({
if (!canDeviceHover()) {
setActiveIndex(index);
}
if (
!['Group', 'Interval'].includes(groupBy) &&
!['Interval'].includes(groupBy) &&
(canDeviceHover() || activeIndex === index) &&
((compact && showTooltip) || !compact)
) {
const groupCategoryIds =
groupBy === 'Group'
? (
categories.grouped.find(g => g.id === item.id)
?.categories ?? []
)
.map(c => c.id)
.filter((c): c is string => c != null)
: undefined;
showActivity({
navigate,
categories,
@@ -342,17 +701,15 @@ export function DonutGraph({
type: 'totals',
startDate: data.startDate,
endDate: data.endDate,
field: groupBy.toLowerCase(),
id: item.id,
field:
groupBy === 'Group'
? 'category'
: groupBy.toLowerCase(),
id: groupBy === 'Group' ? groupCategoryIds : item.id,
});
}
}}
/>
<Tooltip
content={() => null}
defaultIndex={activeIndex}
active
/>
</PieChart>
</div>
)

View File

@@ -23,7 +23,7 @@ type showActivityProps = {
startDate: string;
endDate?: string;
field?: string;
id?: string;
id?: string | string[]; // changed: supports array for oneOf
interval?: string;
};
@@ -55,7 +55,13 @@ export function showActivity({
const filterConditions = [
...filters,
id && { field, op: 'is', value: id, type: 'id' },
id && {
// changed: use oneOf when id is an array, is when it's a string
field,
op: Array.isArray(id) ? 'oneOf' : 'is',
value: id,
type: 'id',
},
{
field: 'date',
op: isDateOp ? 'gte' : 'is',
@@ -97,6 +103,7 @@ export function showActivity({
type: 'id',
},
].filter(f => f);
void navigate('/accounts', {
state: {
goBack: true,

View File

@@ -157,7 +157,7 @@ describe('ThemeInstaller', () => {
});
});
it('preserves pasted CSS when a catalog theme is selected', async () => {
it('clears pasted CSS when a catalog theme is selected', async () => {
const user = userEvent.setup();
render(
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
@@ -173,7 +173,7 @@ describe('ThemeInstaller', () => {
expect(textArea).toHaveValue(cssText);
await user.click(screen.getByRole('button', { name: 'Demo Theme' }));
expect(textArea).toHaveValue(cssText);
expect(textArea).toHaveValue('');
});
it('clears error when a catalog theme is selected', async () => {
@@ -347,7 +347,7 @@ describe('ThemeInstaller', () => {
expect.objectContaining({
name: 'Custom Theme',
repo: '',
overrideCss: mockValidCss,
cssContent: mockValidCss,
}),
);
});
@@ -372,28 +372,21 @@ describe('ThemeInstaller', () => {
expect(validateThemeCss).toHaveBeenCalledWith(cssWithWhitespace.trim());
});
it('calls onInstall with empty cssContent when Apply is clicked with empty CSS', async () => {
it('does not call onInstall when Apply is clicked with empty CSS', async () => {
const user = userEvent.setup();
render(
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
);
const applyButton = screen.getByText('Apply');
expect(applyButton).not.toBeDisabled();
expect(applyButton).toBeDisabled();
await user.click(applyButton);
await waitFor(() => {
expect(mockOnInstall).toHaveBeenCalledTimes(1);
expect(mockOnInstall).toHaveBeenCalledWith(
expect.objectContaining({
cssContent: '',
}),
);
});
expect(mockOnInstall).not.toHaveBeenCalled();
});
it('calls onInstall with empty cssContent when Apply is clicked with whitespace-only CSS', async () => {
it('does not call onInstall when Apply is clicked with whitespace-only CSS', async () => {
const user = userEvent.setup();
render(
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
@@ -406,18 +399,9 @@ describe('ThemeInstaller', () => {
await user.paste(' ');
const applyButton = screen.getByText('Apply');
expect(applyButton).not.toBeDisabled();
expect(applyButton).toBeDisabled();
await user.click(applyButton);
await waitFor(() => {
expect(mockOnInstall).toHaveBeenCalledTimes(1);
expect(mockOnInstall).toHaveBeenCalledWith(
expect.objectContaining({
cssContent: '',
}),
);
});
});
it('populates text box with installed custom theme CSS when reopening', () => {

View File

@@ -52,7 +52,6 @@ export function ThemeInstaller({
useState<CatalogTheme | null>(null);
const [erroringTheme, setErroringTheme] = useState<CatalogTheme | null>(null);
const [pastedCss, setPastedCss] = useState('');
const [cachedCatalogCss, setCachedCatalogCss] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -63,17 +62,10 @@ export function ThemeInstaller({
error: catalogError,
} = useThemeCatalog();
// Initialize state from installed theme
// Initialize pastedCss with installed custom theme CSS if it exists
useEffect(() => {
if (!installedTheme) return;
if (installedTheme.repo) {
// Catalog theme installed — restore overrideCss into text area if present
if (installedTheme.overrideCss) {
setPastedCss(installedTheme.overrideCss);
}
} else {
// Custom pasted CSS — restore into text area
// If there's an installed theme with empty repo (custom pasted CSS), restore it
if (installedTheme && !installedTheme.repo) {
setPastedCss(installedTheme.cssContent);
}
}, [installedTheme]);
@@ -113,8 +105,6 @@ export function ThemeInstaller({
id: string;
errorMessage: string;
catalogTheme?: CatalogTheme | null;
baseTheme?: 'light' | 'dark' | 'midnight';
overrideCss?: string;
}) => {
setError(null);
setErroringTheme(null);
@@ -123,26 +113,15 @@ export function ThemeInstaller({
try {
const css =
typeof options.css === 'string' ? options.css : await options.css;
const validatedCss = css ? validateThemeCss(css) : '';
const validatedCss = validateThemeCss(css);
const newTheme: InstalledTheme = {
const installedTheme: InstalledTheme = {
id: options.id,
name: options.name,
repo: options.repo,
cssContent: validatedCss,
baseTheme: options.catalogTheme
? options.catalogTheme.mode === 'dark'
? 'dark'
: 'light'
: options.baseTheme,
};
if (options.overrideCss) {
newTheme.overrideCss = validateThemeCss(options.overrideCss);
}
if (options.catalogTheme) {
setCachedCatalogCss(validatedCss);
}
onInstall(newTheme);
onInstall(installedTheme);
// Only set selectedCatalogTheme on success if it's a catalog theme
if (options.catalogTheme) {
setSelectedCatalogTheme(options.catalogTheme);
@@ -163,6 +142,7 @@ export function ThemeInstaller({
const handleCatalogThemeClick = useCallback(
async (theme: CatalogTheme) => {
setPastedCss('');
setSelectedCatalogTheme(theme);
const normalizedRepo = normalizeGitHubRepo(theme.repo);
@@ -173,50 +153,29 @@ export function ThemeInstaller({
id: generateThemeId(normalizedRepo),
errorMessage: t('Failed to load theme'),
catalogTheme: theme,
overrideCss: pastedCss.trim() || undefined,
});
},
[installTheme, pastedCss, t],
[installTheme, t],
);
const handlePastedCssChange = useCallback((value: string) => {
setPastedCss(value);
setSelectedCatalogTheme(null);
setErroringTheme(null);
setError(null);
}, []);
const handleInstallPastedCss = useCallback(() => {
// Determine the base catalog CSS: prefer the in-session selection,
// fall back to the previously installed catalog theme
const hasCatalog = selectedCatalogTheme || installedTheme?.repo;
const baseCss = selectedCatalogTheme
? cachedCatalogCss
: (installedTheme?.cssContent ?? '');
const repo = selectedCatalogTheme
? normalizeGitHubRepo(selectedCatalogTheme.repo)
: (installedTheme?.repo ?? '');
if (!pastedCss.trim()) return;
void installTheme({
css: hasCatalog ? baseCss : '',
name:
selectedCatalogTheme?.name ?? installedTheme?.name ?? t('Custom Theme'),
repo,
id: repo
? generateThemeId(repo)
: generateThemeId(`pasted-${Date.now()}`),
css: pastedCss.trim(),
name: t('Custom Theme'),
repo: '',
id: generateThemeId(`pasted-${Date.now()}`),
errorMessage: t('Failed to validate theme CSS'),
catalogTheme: selectedCatalogTheme,
baseTheme: installedTheme?.baseTheme,
overrideCss: pastedCss.trim() || undefined,
});
}, [
pastedCss,
selectedCatalogTheme,
cachedCatalogCss,
installedTheme,
installTheme,
t,
]);
}, [pastedCss, installTheme, t]);
return (
<View
@@ -443,7 +402,7 @@ export function ThemeInstaller({
}}
>
<Text style={{ marginBottom: 8, color: themeStyle.pageTextSubdued }}>
<Trans>Additional CSS overrides:</Trans>
<Trans>or paste CSS directly:</Trans>
</Text>
<TextArea
value={pastedCss}
@@ -466,7 +425,7 @@ export function ThemeInstaller({
<Button
variant="normal"
onPress={handleInstallPastedCss}
isDisabled={isLoading}
isDisabled={!pastedCss.trim() || isLoading}
>
<Trans>Apply</Trans>
</Button>

View File

@@ -334,6 +334,7 @@ export type Modal =
onCopyLastMonthAverage: () => void;
onSetMonthsAverage: (numberOfMonths: number) => void;
onApplyBudgetTemplate: () => void;
onEditNotes: (id: NoteEntity['id'], month: string) => void;
};
}
| {
@@ -345,6 +346,7 @@ export type Modal =
onCopyLastMonthAverage: () => void;
onSetMonthsAverage: (numberOfMonths: number) => void;
onApplyBudgetTemplate: () => void;
onEditNotes: (id: NoteEntity['id'], month: string) => void;
};
}
| {

View File

@@ -2,9 +2,6 @@
* Custom theme utilities: fetch, validation, and storage helpers.
*/
export const BASE_THEME_OPTIONS = ['light', 'dark', 'midnight'] as const;
export type BaseTheme = (typeof BASE_THEME_OPTIONS)[number];
export type CatalogTheme = {
name: string;
repo: string;
@@ -17,8 +14,6 @@ export type InstalledTheme = {
name: string;
repo: string;
cssContent: string; // CSS content stored when theme is installed (required)
baseTheme?: BaseTheme; // Which built-in theme to use as base (defaults to contextual theme)
overrideCss?: string; // Additional free-text CSS overrides on top of cssContent
};
/**
@@ -276,21 +271,6 @@ export function validateThemeCss(css: string): string {
return css.trim();
}
/**
* Validate and concatenate cssContent and overrideCss into a single CSS string.
* Returns empty string if neither is present.
*/
export function validateAndCombineThemeCss(
cssContent?: string,
overrideCss?: string,
): string {
const parts = [
cssContent && validateThemeCss(cssContent),
overrideCss && validateThemeCss(overrideCss),
].filter(Boolean);
return parts.join('\n');
}
/**
* Generate a unique ID for a theme based on its repo URL or direct CSS URL.
*/
@@ -323,24 +303,12 @@ export function parseInstalledTheme(
typeof parsed.repo === 'string' &&
typeof parsed.cssContent === 'string'
) {
const result: InstalledTheme = {
return {
id: parsed.id,
name: parsed.name,
repo: parsed.repo,
cssContent: parsed.cssContent,
};
if (
typeof parsed.baseTheme === 'string' &&
BASE_THEME_OPTIONS.includes(
parsed.baseTheme as (typeof BASE_THEME_OPTIONS)[number],
)
) {
result.baseTheme = parsed.baseTheme as BaseTheme;
}
if (typeof parsed.overrideCss === 'string' && parsed.overrideCss) {
result.overrideCss = parsed.overrideCss;
}
return result;
} satisfies InstalledTheme;
}
return null;
} catch {

View File

@@ -1,15 +1,9 @@
import { useEffect, useMemo, useState } from 'react';
import { isNonProductionEnvironment } from 'loot-core/shared/environment';
import type { DarkTheme, Theme } from 'loot-core/types/prefs';
import {
parseInstalledTheme,
validateAndCombineThemeCss,
} from './customThemes';
import type { BaseTheme } from './customThemes';
import { parseInstalledTheme, validateThemeCss } from './customThemes';
import * as darkTheme from './themes/dark';
import * as developmentTheme from './themes/development';
import * as lightTheme from './themes/light';
import * as midnightTheme from './themes/midnight';
@@ -21,9 +15,6 @@ const themes = {
dark: { name: 'Dark', colors: darkTheme },
midnight: { name: 'Midnight', colors: midnightTheme },
auto: { name: 'System default', colors: darkTheme },
...(isNonProductionEnvironment() && {
development: { name: 'Development', colors: developmentTheme },
}),
} as const;
type ThemeKey = keyof typeof themes;
@@ -48,51 +39,22 @@ export function usePreferredDarkTheme() {
return [darkTheme, setDarkTheme] as const;
}
function getBaseThemeColors(baseTheme: BaseTheme) {
return themes[baseTheme]?.colors;
}
export function ThemeStyle() {
const [activeTheme] = useTheme();
const [darkThemePreference] = usePreferredDarkTheme();
const customThemesEnabled = useFeatureFlag('customThemes');
const [installedCustomLightThemeJson] = useGlobalPref(
'installedCustomLightTheme',
);
const [installedCustomDarkThemeJson] = useGlobalPref(
'installedCustomDarkTheme',
);
const [themeColors, setThemeColors] = useState<
| typeof lightTheme
| typeof darkTheme
| typeof midnightTheme
| typeof developmentTheme
| undefined
typeof lightTheme | typeof darkTheme | typeof midnightTheme | undefined
>(undefined);
useEffect(() => {
if (activeTheme === 'auto') {
const installedLight = customThemesEnabled
? parseInstalledTheme(installedCustomLightThemeJson)
: null;
const installedDark = customThemesEnabled
? parseInstalledTheme(installedCustomDarkThemeJson)
: null;
const lightColors =
(installedLight?.baseTheme &&
getBaseThemeColors(installedLight.baseTheme)) ||
themes['light'].colors;
const darkColors =
(installedDark?.baseTheme &&
getBaseThemeColors(installedDark.baseTheme)) ||
themes[darkThemePreference].colors;
const darkTheme = themes[darkThemePreference];
function darkThemeMediaQueryListener(event: MediaQueryListEvent) {
if (event.matches) {
setThemeColors(darkColors);
setThemeColors(darkTheme.colors);
} else {
setThemeColors(lightColors);
setThemeColors(themes['light'].colors);
}
}
const darkThemeMediaQuery = window.matchMedia(
@@ -105,9 +67,9 @@ export function ThemeStyle() {
);
if (darkThemeMediaQuery.matches) {
setThemeColors(darkColors);
setThemeColors(darkTheme.colors);
} else {
setThemeColors(lightColors);
setThemeColors(themes['light'].colors);
}
return () => {
@@ -117,25 +79,9 @@ export function ThemeStyle() {
);
};
} else {
const installedTheme = customThemesEnabled
? parseInstalledTheme(installedCustomLightThemeJson)
: null;
if (installedTheme?.baseTheme) {
setThemeColors(
getBaseThemeColors(installedTheme.baseTheme) ??
themes[activeTheme as ThemeKey]?.colors,
);
} else {
setThemeColors(themes[activeTheme as ThemeKey]?.colors);
}
setThemeColors(themes[activeTheme as ThemeKey]?.colors);
}
}, [
activeTheme,
darkThemePreference,
customThemesEnabled,
installedCustomLightThemeJson,
installedCustomDarkThemeJson,
]);
}, [activeTheme, darkThemePreference]);
if (!themeColors) return null;
@@ -171,44 +117,36 @@ export function CustomThemeStyle() {
let css = '';
try {
const lightCss = validateAndCombineThemeCss(
lightTheme?.cssContent,
lightTheme?.overrideCss,
);
if (lightCss) {
css += `@media (prefers-color-scheme: light) { ${lightCss} }\n`;
if (lightTheme?.cssContent) {
try {
const validated = validateThemeCss(lightTheme.cssContent);
css += `@media (prefers-color-scheme: light) { ${validated} }\n`;
} catch (error) {
console.error('Invalid custom light theme CSS', { error });
}
} catch (error) {
console.error('Invalid custom light theme CSS', { error });
}
try {
const darkCss = validateAndCombineThemeCss(
darkTheme?.cssContent,
darkTheme?.overrideCss,
);
if (darkCss) {
css += `@media (prefers-color-scheme: dark) { ${darkCss} }\n`;
if (darkTheme?.cssContent) {
try {
const validated = validateThemeCss(darkTheme.cssContent);
css += `@media (prefers-color-scheme: dark) { ${validated} }\n`;
} catch (error) {
console.error('Invalid custom dark theme CSS', { error });
}
} catch (error) {
console.error('Invalid custom dark theme CSS', { error });
}
return css || null;
}
const installedTheme = parseInstalledTheme(installedCustomLightThemeJson);
const { cssContent } = installedTheme ?? {};
if (!cssContent) return null;
try {
return (
validateAndCombineThemeCss(
installedTheme?.cssContent,
installedTheme?.overrideCss,
) || null
);
return validateThemeCss(cssContent);
} catch (error) {
console.error('Invalid custom theme CSS', { error });
console.error('Invalid custom theme CSS', { error, cssContent });
return null;
}
}, [

View File

@@ -1,249 +0,0 @@
import * as colorPalette from '@desktop-client/style/palette';
export const pageBackground = colorPalette.navy100;
export const pageBackgroundModalActive = colorPalette.navy200;
export const pageBackgroundTopLeft = colorPalette.navy100;
export const pageBackgroundBottomRight = colorPalette.blue150;
export const pageBackgroundLineTop = colorPalette.white;
export const pageBackgroundLineMid = colorPalette.navy100;
export const pageBackgroundLineBottom = colorPalette.blue150;
export const pageText = '#272630';
export const pageTextLight = colorPalette.navy500;
export const pageTextSubdued = colorPalette.navy300;
export const pageTextDark = colorPalette.navy800;
export const pageTextPositive = colorPalette.purple600;
export const pageTextLink = colorPalette.blue600;
export const pageTextLinkLight = colorPalette.blue300;
export const cardBackground = colorPalette.white;
export const cardBorder = colorPalette.purple700;
export const cardShadow = colorPalette.navy700;
export const tableBackground = colorPalette.white;
export const tableRowBackgroundHover = colorPalette.navy50;
export const tableText = pageText;
export const tableTextLight = colorPalette.navy400;
export const tableTextSubdued = colorPalette.navy100;
export const tableTextSelected = colorPalette.navy700;
export const tableTextHover = colorPalette.navy900;
export const tableTextInactive = colorPalette.navy500;
export const tableHeaderText = colorPalette.navy600;
export const tableHeaderBackground = colorPalette.white;
export const tableBorder = colorPalette.navy100;
export const tableBorderSelected = colorPalette.purple500;
export const tableBorderHover = colorPalette.purple400;
export const tableBorderSeparator = colorPalette.navy400;
export const tableRowBackgroundHighlight = colorPalette.blue150;
export const tableRowBackgroundHighlightText = colorPalette.navy700;
export const tableRowHeaderBackground = colorPalette.navy50;
export const tableRowHeaderText = colorPalette.navy800;
export const numberPositive = colorPalette.green700;
export const numberNegative = colorPalette.red500;
export const numberNeutral = colorPalette.navy100;
export const budgetNumberNegative = numberNegative;
export const budgetNumberZero = tableTextSubdued;
export const budgetNumberNeutral = tableText;
export const budgetNumberPositive = budgetNumberNeutral;
export const templateNumberFunded = numberPositive;
export const templateNumberUnderFunded = colorPalette.orange700;
export const toBudgetPositive = numberPositive;
export const toBudgetZero = numberPositive;
export const toBudgetNegative = budgetNumberNegative;
export const sidebarBackground = colorPalette.navy900;
export const sidebarItemBackgroundPending = colorPalette.orange200;
export const sidebarItemBackgroundPositive = colorPalette.green500;
export const sidebarItemBackgroundFailed = colorPalette.red300;
export const sidebarItemBackgroundHover = colorPalette.navy800;
export const sidebarItemAccentSelected = colorPalette.purple200;
export const sidebarItemText = colorPalette.navy150;
export const sidebarItemTextSelected = colorPalette.purple200;
export const sidebarBudgetName = colorPalette.navy150;
export const menuBackground = colorPalette.white;
export const menuItemBackground = colorPalette.navy50;
export const menuItemBackgroundHover = colorPalette.navy100;
export const menuItemText = colorPalette.navy900;
export const menuItemTextHover = menuItemText;
export const menuItemTextSelected = colorPalette.purple300;
export const menuItemTextHeader = colorPalette.navy400;
export const menuBorder = colorPalette.navy100;
export const menuBorderHover = colorPalette.purple100;
export const menuKeybindingText = colorPalette.navy400;
export const menuAutoCompleteBackground = colorPalette.navy900;
export const menuAutoCompleteBackgroundHover = colorPalette.navy600;
export const menuAutoCompleteText = colorPalette.white;
export const menuAutoCompleteTextHeader = colorPalette.orange150;
export const menuAutoCompleteItemText = menuAutoCompleteText;
export const modalBackground = colorPalette.white;
export const modalBorder = colorPalette.white;
export const mobileHeaderBackground = colorPalette.purple400;
export const mobileHeaderText = colorPalette.navy50;
export const mobileHeaderTextSubdued = colorPalette.gray200;
export const mobileHeaderTextHover = 'rgba(200, 200, 200, .15)';
export const mobilePageBackground = colorPalette.navy50;
export const mobileNavBackground = colorPalette.white;
export const mobileNavItem = colorPalette.gray300;
export const mobileNavItemSelected = colorPalette.purple500;
export const mobileAccountShadow = colorPalette.navy300;
export const mobileAccountText = colorPalette.blue800;
export const mobileTransactionSelected = colorPalette.purple500;
// Mobile view themes (for the top bar)
export const mobileViewTheme = mobileHeaderBackground;
export const mobileConfigServerViewTheme = colorPalette.purple500;
export const markdownNormal = colorPalette.purple150;
export const markdownDark = colorPalette.purple400;
export const markdownLight = colorPalette.purple100;
// Button
export const buttonMenuText = colorPalette.navy100;
export const buttonMenuTextHover = colorPalette.navy50;
export const buttonMenuBackground = 'transparent';
export const buttonMenuBackgroundHover = 'rgba(200, 200, 200, .25)';
export const buttonMenuBorder = colorPalette.navy500;
export const buttonMenuSelectedText = colorPalette.green800;
export const buttonMenuSelectedTextHover = colorPalette.orange800;
export const buttonMenuSelectedBackground = colorPalette.orange200;
export const buttonMenuSelectedBackgroundHover = colorPalette.orange300;
export const buttonMenuSelectedBorder = buttonMenuSelectedBackground;
export const buttonPrimaryText = colorPalette.white;
export const buttonPrimaryTextHover = buttonPrimaryText;
export const buttonPrimaryBackground = colorPalette.purple500;
export const buttonPrimaryBackgroundHover = buttonPrimaryBackground;
export const buttonPrimaryBorder = buttonPrimaryBackground;
export const buttonPrimaryShadow = 'rgba(0, 0, 0, 0.3)';
export const buttonPrimaryDisabledText = colorPalette.white;
export const buttonPrimaryDisabledBackground = colorPalette.navy300;
export const buttonPrimaryDisabledBorder = buttonPrimaryDisabledBackground;
export const buttonNormalText = colorPalette.navy900;
export const buttonNormalTextHover = buttonNormalText;
export const buttonNormalBackground = colorPalette.white;
export const buttonNormalBackgroundHover = buttonNormalBackground;
export const buttonNormalBorder = colorPalette.navy150;
export const buttonNormalShadow = 'rgba(0, 0, 0, 0.2)';
export const buttonNormalSelectedText = colorPalette.white;
export const buttonNormalSelectedBackground = colorPalette.blue600;
export const buttonNormalDisabledText = colorPalette.navy300;
export const buttonNormalDisabledBackground = buttonNormalBackground;
export const buttonNormalDisabledBorder = buttonNormalBorder;
export const calendarText = colorPalette.navy50;
export const calendarBackground = colorPalette.navy900;
export const calendarItemText = colorPalette.navy150;
export const calendarItemBackground = colorPalette.navy800;
export const calendarSelectedBackground = colorPalette.navy500;
export const buttonBareText = buttonNormalText;
export const buttonBareTextHover = buttonNormalText;
export const buttonBareBackground = 'transparent';
export const buttonBareBackgroundHover = 'rgba(100, 100, 100, .15)';
export const buttonBareBackgroundActive = 'rgba(100, 100, 100, .25)';
export const buttonBareDisabledText = buttonNormalDisabledText;
export const buttonBareDisabledBackground = buttonBareBackground;
export const noticeBackground = colorPalette.green150;
export const noticeBackgroundLight = colorPalette.green100;
export const noticeBackgroundDark = colorPalette.green500;
export const noticeText = colorPalette.green700;
export const noticeTextLight = colorPalette.green500;
export const noticeTextDark = colorPalette.green900;
export const noticeTextMenu = colorPalette.green200;
export const noticeBorder = colorPalette.green500;
export const warningBackground = colorPalette.orange200;
export const warningText = colorPalette.orange700;
export const warningTextLight = colorPalette.orange500;
export const warningTextDark = colorPalette.orange900;
export const warningBorder = colorPalette.orange500;
export const errorBackground = colorPalette.red100;
export const errorText = colorPalette.red500;
export const errorTextDark = colorPalette.red700;
export const errorTextDarker = colorPalette.red900;
export const errorTextMenu = colorPalette.red200;
export const errorBorder = colorPalette.red500;
export const upcomingBackground = colorPalette.purple100;
export const upcomingText = colorPalette.purple700;
export const upcomingBorder = colorPalette.purple500;
export const formLabelText = colorPalette.blue600;
export const formLabelBackground = colorPalette.blue200;
export const formInputBackground = colorPalette.navy50;
export const formInputBackgroundSelected = colorPalette.white;
export const formInputBackgroundSelection = colorPalette.purple500;
export const formInputBorder = colorPalette.navy150;
export const formInputTextReadOnlySelection = colorPalette.navy50;
export const formInputBorderSelected = colorPalette.purple500;
export const formInputText = colorPalette.navy900;
export const formInputTextSelected = colorPalette.navy50;
export const formInputTextPlaceholder = colorPalette.navy300;
export const formInputTextPlaceholderSelected = colorPalette.navy200;
export const formInputTextSelection = colorPalette.navy100;
export const formInputShadowSelected = colorPalette.purple300;
export const formInputTextHighlight = colorPalette.purple200;
export const checkboxText = tableBackground;
export const checkboxBackgroundSelected = colorPalette.blue500;
export const checkboxBorderSelected = colorPalette.blue500;
export const checkboxShadowSelected = colorPalette.blue300;
export const checkboxToggleBackground = colorPalette.gray400;
export const checkboxToggleBackgroundSelected = colorPalette.purple600;
export const checkboxToggleDisabled = colorPalette.gray200;
export const pillBackground = colorPalette.navy150;
export const pillBackgroundLight = colorPalette.navy50;
export const pillText = colorPalette.navy800;
export const pillTextHighlighted = colorPalette.purple600;
export const pillBorder = colorPalette.navy150;
export const pillBorderDark = colorPalette.navy300;
export const pillBackgroundSelected = colorPalette.blue150;
export const pillTextSelected = colorPalette.blue900;
export const pillBorderSelected = colorPalette.purple500;
export const pillTextSubdued = colorPalette.navy200;
export const reportsRed = colorPalette.red300;
export const reportsBlue = colorPalette.blue400;
export const reportsGreen = colorPalette.green400;
export const reportsGray = colorPalette.gray400;
export const reportsLabel = colorPalette.navy900;
export const reportsInnerLabel = colorPalette.navy800;
export const reportsNumberPositive = numberPositive;
export const reportsNumberNegative = numberNegative;
export const reportsNumberNeutral = numberNeutral;
export const reportsChartFill = reportsNumberPositive;
export const noteTagBackground = colorPalette.purple125;
export const noteTagBackgroundHover = colorPalette.purple150;
export const noteTagDefault = colorPalette.purple125;
export const noteTagText = colorPalette.black;
export const budgetCurrentMonth = tableBackground;
export const budgetOtherMonth = colorPalette.gray50;
export const budgetHeaderCurrentMonth = budgetOtherMonth;
export const budgetHeaderOtherMonth = colorPalette.gray80;
export const floatingActionBarBackground = colorPalette.purple400;
export const floatingActionBarBorder = floatingActionBarBackground;
export const floatingActionBarText = colorPalette.navy50;
export const tooltipText = colorPalette.navy900;
export const tooltipBackground = colorPalette.navy50;
export const tooltipBorder = colorPalette.navy150;
export const calendarCellBackground = colorPalette.navy100;
export const overlayBackground = 'rgba(0, 0, 0, 0.3)';
// Chart colors - Qualitative scale (9 colors)
export const chartQual1 = colorPalette.chartQual1;
export const chartQual2 = colorPalette.chartQual2;
export const chartQual3 = colorPalette.chartQual3;
export const chartQual4 = colorPalette.chartQual4;
export const chartQual5 = colorPalette.chartQual5;
export const chartQual6 = colorPalette.chartQual6;
export const chartQual7 = colorPalette.chartQual7;
export const chartQual8 = colorPalette.chartQual8;
export const chartQual9 = colorPalette.chartQual9;

View File

@@ -17,7 +17,7 @@
"dependencies": {
"@actual-app/sync-server": "workspace:*",
"better-sqlite3": "^12.6.2",
"fs-extra": "^11.3.3",
"fs-extra": "^11.3.4",
"promise-retry": "^2.0.1"
},
"devDependencies": {

View File

@@ -230,6 +230,7 @@ const sidebars = {
link: { type: 'doc', id: 'api/index' },
items: [
'api/reference',
'api/cli',
{
type: 'category',
label: 'ActualQL',

View File

@@ -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 <url>` | Server URL |
| `--password <pw>` | Server password |
| `--session-token <token>` | Session token |
| `--sync-id <id>` | Budget Sync ID |
| `--data-dir <path>` | Local data directory for cached budget data |
| `--format <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 <command> <subcommand> [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 <id> [--name "New Name"] [--offbudget true]
# Close an account (with optional transfer)
actual accounts close <id> [--transfer-account <id>] [--transfer-category <id>]
# Reopen a closed account
actual accounts reopen <id>
# Delete an account
actual accounts delete <id>
# Get account balance
actual accounts balance <id> [--cutoff 2026-01-31]
```
### Budgets
```bash
# List available budgets on the server
actual budgets list
# Download a budget by sync ID
actual budgets download <syncId> [--encryption-password <pw>]
# 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 <id> --amount 50000
# Set carryover flag
actual budgets set-carryover --month 2026-03 --category <id> --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 <id> [--is-income]
# Update a category
actual categories update <id> [--name "Food"] [--hidden true]
# Delete a category (with optional transfer)
actual categories delete <id> [--transfer-to <id>]
```
### 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 <id> [--name "New Name"] [--hidden true]
# Delete a category group (with optional transfer)
actual category-groups delete <id> [--transfer-to <id>]
```
### Transactions
```bash
# List transactions for an account within a date range
actual transactions list --account <id> --start 2026-01-01 --end 2026-03-31
# Add transactions (inline JSON)
actual transactions add --account <id> --data '[{"date":"2026-03-13","amount":-5000,"payee_name":"Store"}]'
# Add transactions (from file)
actual transactions add --account <id> --file transactions.json
# Import transactions with reconciliation (deduplication)
actual transactions import --account <id> --data '[...]' [--dry-run]
# Update a transaction
actual transactions update <id> --data '{"notes":"Updated note"}'
# Delete a transaction
actual transactions delete <id>
```
### 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 <id> --name "New Name"
# Delete a payee
actual payees delete <id>
# Merge multiple payees into one
actual payees merge --target <id> --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 <id> [--tag "trip"] [--color "#00ff00"]
# Delete a tag
actual tags delete <id>
```
### Rules
```bash
# List all rules
actual rules list
# List rules for a specific payee
actual rules payee-rules <payeeId>
# 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 <id>
```
### 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 <id> --data '{"name":"Updated Rent"}' [--reset-next-date]
# Delete a schedule
actual schedules delete <id>
```
### 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 <id>]
```
## 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 <id>
```
**Export transactions to CSV:**
```bash
actual transactions list --account <id> --start 2026-01-01 --end 2026-12-31 --format csv > transactions.csv
```
**Add a transaction:**
```bash
actual transactions add --account <id> --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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More