Compare commits

..

93 Commits

Author SHA1 Message Date
Matiss Janis Aboltins
4bea2bd070 Update nightly versioning script to use yarn 2026-03-17 16:26:19 +00:00
Matiss Janis Aboltins
45a7c029ba Merge branch 'master' into claude/actualbudget-api-plugin-0KVGL 2026-03-17 16:14:51 +00:00
Matiss Janis Aboltins
c4ee71409e [AI] Add Yarn constraints to enforce consistent dependency versions (#7229)
* [AI] Add yarn constraints to enforce consistent dependency versions

Adds a `yarn.config.cjs` that uses Yarn 4's built-in constraints feature
to detect when the same dependency is declared with different version
ranges across workspaces. Workspace protocol references and
peerDependencies are excluded from the check.

Also adds a `yarn constraints` convenience script and the `@yarnpkg/types`
dev dependency for type-checked constraint authoring.

https://claude.ai/code/session_01B1xRjZXn6b18anZjo8cbqb

* Add release notes for PR #7229

* Add constraints job to GitHub Actions workflow

* Fix constraints

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-17 16:00:34 +00:00
Matt Fiddaman
dfd6e468a6 ⬆️ react-spring 10.0.3 (#7224)
* react-spring (10.0.0 -> ^10.0.3)

* note

* fix VRT

* fix more animations

* fix budget month colouring
2026-03-17 16:00:27 +00:00
Matiss Janis Aboltins
5b227f5fa1 feat: Add post-checkout hook to run yarn install if yarn.lock changes (#7230) 2026-03-17 15:58:02 +00:00
Julian Dominguez-Schatz
e1606b31ab Migrate get-next-package-version.js to TypeScript (#7227)
* Migrate `get-next-package-version.js` to TypeScript

* Add release notes

* Stronger type check

* Fix step ordering

* Fix typo

* Fix missed ordering
2026-03-17 13:56:47 +00:00
Matiss Janis Aboltins
1c21476ad3 Enhance configuration tests to include 'encryptionPassword' checks for CLI options and environment variables, ensuring proper priority handling in the configuration resolution process. 2026-03-17 12:28:21 +00:00
Matiss Janis Aboltins
b03a507d33 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. 2026-03-17 12:04:31 +00:00
Matiss Janis Aboltins
b8a255abc8 Refactor configuration to replace "budgetId" with "syncId" across CLI and documentation 2026-03-17 11:17:20 +00:00
Michael Clark
4f7c3c51a5 🐛 Using a shared worker to coordinate multiple tabs (#7172)
* attempt to enable sync when multiple tabs are open

* allow multiple tabs to work

* release notes

* rehome the host if the tab closes

* ensure new tabs always receive failure  messages by broadcasting them on interval

* reject after retries are exhausted

* forwarding the logs from the worker to the main browser

* [autofix.ci] apply automated fixes

* add preflight fetch from main thread to server endpoint to trigger permission prompt if required

* remove the log prefix for cleaner logs

* adding heardbeat to detect closed tabs so they can be removed from the list

* store failure payload and broadcast for new tabs after timeout is cleared

* if a tab closes a budget, force other tabs to go to the budget list screen

* fix safari by detecting crossoriginisolated as a dependency for shared worker

* all ios to fallback to non-shared-worker implemenation

* coordinator and all backend work going through a leader tab to enable ios

* electing new leader tab when oone tab closes or is refreshed

* logic for standalone tabs to rejoin shared workers when on same budget

* remove the preflight request, shouldnt be needed now the code runs on the main process

* handling brand new tabs going to open budgets that are current standalone with no leader

* allowing budgets to be closed  without kickother others by transfering leadership to remaining oopened tabs

* remove unnedd comments

* change approach slightly - no more standalone, now every budget gets leader promotion automatically)

* adding tests and fixed minor bug to do with deleting budget with multiple tabs open

* fix worker not loading

* trouble with ts - moving to js

* reintroduce ts for the worker

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-17 09:30:34 +00:00
Matt Fiddaman
0e1fc07bf3 ⬆️ @types/react (#7223)
* bump @types/react

* note
2026-03-17 08:17:48 +00:00
Matiss Janis Aboltins
53cdc6fa48 [AI] Further hardening of "/change-password" endpoint (#7207)
* [AI] Fix OIDC privilege escalation in /change-password endpoint

Add admin role check and password auth_method session check to prevent
non-admin or OIDC-authenticated users from changing the server password.
Previously, any authenticated user could overwrite the password hash and
then login via password method to obtain an ADMIN session.

https://claude.ai/code/session_01Wne9FY2QnKp6JF7g61B1Sn

* Add release notes for PR #7207

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-17 08:16:46 +00:00
okxint
1d0281025d fix: preserve schedule link when merging transactions (#7177)
* [AI] fix: preserve schedule link when merging transactions

When merging two transactions where one is linked to a schedule,
the schedule field was not included in the merge update, causing
the schedule association to be silently dropped. This resulted in
duplicate transactions and incorrect "Due" status for scheduled
transactions.

Add `schedule: keep.schedule || drop.schedule` to both the normal
merge path and the subtransaction merge path, matching the existing
fallback pattern used for payee, category, notes, etc.

Add three test cases covering:
- Schedule preserved from dropped transaction when kept has none
- Kept transaction's schedule takes priority when both have one
- Schedule preserved when merging manual scheduled with banksynced

Fixes #6997

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add release notes for PR #7177

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 05:14:16 +00:00
Matiss Janis Aboltins
231bd96092 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. 2026-03-16 21:37:19 +00:00
Matiss Janis Aboltins
a3eb298f09 Merge branch 'master' into claude/actualbudget-api-plugin-0KVGL 2026-03-16 21:33:14 +00:00
Matiss Janis Aboltins
f73c5e9210 [AI] Fix adm-zip dependency resolution in loot-core (#7219) 2026-03-16 21:11:51 +00:00
Matiss Janis Aboltins
722ff6eb85 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.
2026-03-16 19:01:15 +00:00
Matiss Janis Aboltins
6f1bac2977 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.
2026-03-16 18:39:06 +00:00
Matiss Janis Aboltins
c64b7ce754 Merge branch 'master' into claude/actualbudget-api-plugin-0KVGL 2026-03-16 18:35:11 +00:00
Julian Dominguez-Schatz
a4eb17eff2 Upgrade to Vite 8 (#7184)
* Upgrade to Vite 8

* Add release notes

* PR feedback

* [autofix.ci] apply automated fixes

* PR feedback

* fix: inject process.env

* Restore deleted release note

* Clean up and typecheck

* Fix dev server

* Fix type error

* Fix tests

* PR feedback

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-15 23:16:39 +00:00
Matiss Janis Aboltins
0d7e3d2007 Update CLI TypeScript configuration to include Vitest globals and streamline test imports across multiple test files for improved clarity and consistency. 2026-03-15 21:52:09 +00:00
Matiss Janis Aboltins
3ddb403bfe 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. 2026-03-15 20:54:27 +00:00
Matiss Janis Aboltins
8a3db77cff [AI] api: simplify bundling by removing loot-core type inlining (#7209) 2026-03-15 20:07:36 +00:00
Matiss Janis Aboltins
843274e00e Update package.json exports configuration to support environment-specific module resolution. Added 'development' and 'default' entries for improved clarity in file usage. 2026-03-15 18:52:39 +00:00
Matiss Janis Aboltins
fd916a925d 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. 2026-03-15 18:39:35 +00:00
Matiss Janis Aboltins
8e2a996671 Refactor tests: streamline imports in connection and accounts test files for improved clarity and consistency. Remove dynamic imports in favor of static imports. 2026-03-15 18:27:26 +00:00
Matiss Janis Aboltins
70b802da39 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. 2026-03-15 18:20:49 +00:00
Matiss Janis Aboltins
b16e5d685c Merge branch 'master' into claude/actualbudget-api-plugin-0KVGL 2026-03-15 18:11:30 +00:00
Claude
c4de834f98 [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.
2026-03-15 18:09:43 +00:00
Matiss Janis Aboltins
f5a62627f0 [AI] Remove @actual-app/crdt Vite aliases and redundant config (#7195)
* [AI] Remove @actual-app/crdt Vite aliases and redundant config

* Release notes

* Enhance CRDT package configuration and clean up Vite settings

* Added `publishConfig` to `crdt/package.json` to specify exports for types and default files.
* Removed unused `crdtDir` references from `vite.config.ts` and `vite.desktop.config.ts` to streamline configuration.
2026-03-15 17:41:27 +00:00
Matiss Janis Aboltins
6c150cf28a [AI] Publish loot-core (@actual-app/core) nightly first in workflow (#7200)
* [AI] Publish loot-core (@actual-app/core) nightly first in workflow

* [autofix.ci] apply automated fixes

* Refactor imports and update configuration

- Updated .oxfmtrc.json to change "parent" to ["parent", "subpath"].
- Removed unnecessary blank lines in various TypeScript files to improve code readability.
- Adjusted import order in reports and rules files for consistency.

* Add workflow steps to pack and publish the core package nightly

* Remove nightly tag from npm publish command in workflow for core package

* Update post-build script comment to reflect correct workspace command for loot-core declarations

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-15 17:35:01 +00:00
Asger Mogensen
e069312ac3 Feature: Add a confirmation modal when users merge payees in /payees (#7188)
* Add a confirmation model when merging payees in /payee

* Added a confirmation modal when users merge payees in /payees

* Address coderabbit comments

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-15 15:59:57 +00:00
Pranay S
f266b761c2 [AI] Fix Spending Analysis budget table for tracking budgeting (#7191)
* [AI] Fix Spending Analysis budget table for tracking budgeting

Made-with: Cursor

* [autofix.ci] apply automated fixes

* [AI] Add release note for Spending Analysis tracking budgeting fix

Made-with: Cursor

* [autofix.ci] apply automated fixes

* [AI] Address CodeRabbit nitpicks: use typed narrowing instead of assertion for budgetType

Made-with: Cursor

---------

Co-authored-by: Pranay Mac M1 <pranayseela@yahoo.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-15 15:58:22 +00:00
Matiss Janis Aboltins
328b36f124 [AI] Fix navigator is not defined error in @actual-app/api for Node.js environments (#7202)
* [AI] Fix navigator is not defined error in @actual-app/api for Node.js environments

Add platform.api.ts to provide Node.js-safe defaults for platform detection,
which the API's Vite config resolves before the browser-only platform.ts.
Also guard navigator access in environment.ts isElectron() function.

Fixes #7201

https://claude.ai/code/session_015Xz2nHC12pNkADGjGZnSXd

* Add release notes for PR #7202

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-15 08:13:55 +00:00
Derek Gaffney
8934df0cb5 fix(docs/accounts): remove duplicated content (#7199)
* fix(docs): remove duplicated content

* release note file
2026-03-14 21:39:33 +00:00
dependabot[bot]
ab269fa4ea Bump undici from 7.18.2 to 7.24.1 (#7197)
Bumps [undici](https://github.com/nodejs/undici) from 7.18.2 to 7.24.1.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.18.2...v7.24.1)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 7.24.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-14 21:19:47 +00:00
Matiss Janis Aboltins
9c61cfc145 [AI] Switch typecheck from tsc to tsgo and fix Menu type narrowing (#7183)
* [AI] Switch typecheck from tsc to tsgo and fix Menu type narrowing

* [autofix.ci] apply automated fixes

* Add .gitignore for dist directory, update typecheck script in package.json to use -b flag, and remove noEmit option from tsconfig.json files in ci-actions and desktop-electron packages. Introduce typesVersions in loot-core package.json for improved type handling.

* Refactor SelectedTransactionsButton to improve type safety and readability. Updated items prop to use spread operator for conditional rendering of menu items, ensuring proper type annotations with MenuItem. This change enhances the clarity of the component's structure and maintains TypeScript compliance.

* Update tsconfig.json in desktop-electron package to maintain consistent formatting for plugins section. No functional changes made.

* [autofix.ci] apply automated fixes

* Update package.json and yarn.lock to add TypeScript 5.8.0 dependency. Adjust typesVersions in loot-core package.json for improved type handling. Enhance tsconfig.json in sync-server package to enable strictFunctionTypes for better type safety.

* Enhance tsconfig.json in ci-actions package by adding composite option for improved project references and build performance.

* [AI] Revert typescript to 5.9.3 for ts-node compatibility

Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>

* [AI] Update yarn.lock after TypeScript version change

Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>

* Refactor Menu component for improved type safety and readability. Updated type assertions for Menu.line and Menu.label, simplified type checks in filtering and selection logic, and enhanced conditional rendering of menu items. This change ensures better TypeScript compliance and maintains clarity in the component's structure.

* Refactor Select and OpenIdForm components to improve type safety and simplify logic. Updated item mapping to handle Menu.line more effectively, enhancing clarity in selection processes. Adjusted SelectedTransactionsButton to streamline item creation and improve readability.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>
2026-03-14 21:03:10 +00:00
Matiss Janis Aboltins
d86c9cf735 Add theme mode filtering to custom theme catalog (#7194)
* [AI] Add mode field to custom theme catalog for dark/light filtering

Each catalog theme now has a `mode: 'dark' | 'light'` field. When
installing a custom theme in auto mode, the ThemeInstaller filters the
catalog to only show themes matching the selected mode (light or dark).

https://claude.ai/code/session_01PtSEMRv3SpAEtdGzvYxzpa

* Add release notes for PR #7194

* Change category from Features to Enhancements

* [AI] Rename filter parameter to avoid shadowing useTranslation t()

Rename `t` to `catalogTheme` in the catalogItems filter to avoid
shadowing the `t` translation function from useTranslation().

https://claude.ai/code/session_01PtSEMRv3SpAEtdGzvYxzpa

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-14 14:57:47 +00:00
Stephen Brown II
f95cfbf82c Add drag&drop reordering for transactions within the same day (#6653)
* feat(desktop-client): add transaction drag-and-drop reordering

Add the ability to manually reorder transactions within a date group using
drag-and-drop in the TransactionsTable. This is useful for correcting the
order of transactions that occurred on the same day.

Prevent transaction reordering from falling back to the end of the day list
when dropping before a split parent. Parent-level moves now target parent
rows only, so regular transactions can be inserted between split parents on
the same date.

Key changes:

Frontend:
- Migrate drag-and-drop from react-dnd to react-aria hooks
- Add transaction row drag handles and drop indicators
- Implement onReorder handler in TransactionList
- Restrict reordering to same-date transactions only
- Disable drag-drop on aggregate views (categories, etc.)

Backend:
- Add transaction-move handler with validation
- Implement moveTransaction in db layer with midpoint/shove algorithm
- Add TRANSACTION_SORT_INCREMENT constant for consistent spacing
- Handle split transaction subtransactions automatically
- Inherit parent sort_order in child transactions during import

Refactoring:
- Remove useDragRef hook (replaced by react-aria)
- Remove DndProvider wrapper from App.tsx
- Update budget and sidebar components for new drag-drop API
- Fix sort.tsx @ts-strict-ignore by adding proper types

configure allowReorder for TransactionTable consumers

- Account.tsx: Enable reordering for single-account views, add sort_order
  as tiebreaker for stable ordering when sorted by other columns
- Calendar.tsx: Disable reordering in calendar report view (read-only context)

* void promises
2026-03-14 13:35:45 +00:00
Matiss Janis Aboltins
767f77fea3 [AI] Enable more lint rules as warn for gradual fix (7196) (#7196) 2026-03-14 01:47:17 +00:00
Matiss Janis Aboltins
d6dcc30e44 [AI] Promote typescript/no-for-in-array lint rule from warn to error (#7193)
* [AI] Promote typescript/no-for-in-array lint rule from warn to error

Convert the oxlint rule from "warn" to "error" as noted by the existing
TODO comment, and fix the three violations by replacing for-in loops
with for-of using .entries().

https://claude.ai/code/session_01N6F8DMzUVDxNJC56jMGknf

* Add release notes for PR #7193

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-13 21:06:06 +00:00
Matiss Janis Aboltins
541df52441 Enable restrict-template-expressions linting rule (#7181)
* [AI] Promote typescript/restrict-template-expressions to error and fix violations

Convert the oxlint rule from "warn" to "error" and fix all 42 violations
by wrapping non-string template expressions with String(). This ensures
type safety in template literals across the codebase.

https://claude.ai/code/session_01Uk8SwFbD6HuUuo3SSMwU9z

* Add release notes for PR #7181

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-13 18:36:58 +00:00
Julian Dominguez-Schatz
85e3166495 [AI] Remove deep-equal package (#7187)
* Remove `deep-equal` package

* Add release notes

* Add a few more tests

* Add release notes
2026-03-13 16:36:52 +00:00
Mats Nilsson
5d4bbc9ebb fix: mobile autocomplete modals (#6741)
When filtering for accounts in e.g. the net worth graph the modal closes
the filter the tooltip so it's impossible to add e.g. accounts to the
filter.
2026-03-13 15:03:38 +00:00
dependabot[bot]
031aac9799 bump ajv from 6.12.6 to 6.14.0 (#7044)
* Bump ajv from 6.12.6 to 6.14.0

Bumps [ajv](https://github.com/ajv-validator/ajv) from 6.12.6 to 6.14.0.
- [Release notes](https://github.com/ajv-validator/ajv/releases)
- [Commits](https://github.com/ajv-validator/ajv/compare/v6.12.6...v6.14.0)

---
updated-dependencies:
- dependency-name: ajv
  dependency-version: 6.14.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* note

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2026-03-13 01:53:32 +00:00
Stephen Brown II
c53c5c2f36 [AI] Normalize apostrophe-dot thousandsSeparator for consistency (#7179)
* Normalize apostrophe-dot thousandsSeparator for consistency

* Also normalize keyboard apostrophes on the input path
2026-03-13 01:47:53 +00:00
Matiss Janis Aboltins
e968213977 Use TypeScript project references for incremental builds (#7180)
* [AI] Fix duplicate typechecking by consolidating into lage

Previously `yarn typecheck` ran:
1. `tsc -b` (type-checks all packages via project references)
2. `tsc -p tsconfig.root.json --noEmit` (checks root bin/*.ts)
3. `lage typecheck` (runs `tsc --noEmit` per package - duplicate!)

Now it runs:
1. `tsc -p tsconfig.root.json --noEmit` (checks root bin/*.ts)
2. `lage typecheck` (handles everything via dependency ordering)

Changes:
- Remove `tsc -b` from root typecheck script
- Add `dependsOn: ["^typecheck"]` to lage config for correct ordering
- Change per-package typecheck from `tsc --noEmit` to `tsc -b` so
  declarations are emitted for dependent packages

https://claude.ai/code/session_01P7mtAHphD6f1FsnQRwWBaW

* Add release notes for PR #7180

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-12 21:54:29 +00:00
Matiss Janis Aboltins
6b996c11d8 [AI] Add --quiet flag to oxlint commands (#7055)
* [AI] Separate lint and format into distinct commands

Co-authored-by: Cursor <cursoragent@cursor.com>

* Update lint-staged configuration to use 'oxfmt' for formatting instead of 'yarn format:fix'

* [AI] Add format checks to CI workflows

Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>

* Refactor linting and formatting commands in package.json and GitHub workflows to streamline processes and add quiet mode for linting

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>
2026-03-12 18:01:10 +00:00
Matiss Janis Aboltins
282a99db2f lint: promote no-floating-promises and require-array-sort-compare to error and fix violations (#7168)
* lint: promote no-floating-promises and require-array-sort-compare to error and fix violations

- Set typescript/no-floating-promises and typescript/require-array-sort-compare to error in .oxlintrc.json
- Add explicit compare functions to all .sort() calls: migrations (localeCompare), crdt/merkle (localeCompare), FiltersMenu (tuple key), useScheduleEdit (two-arg comparator), exec.test (localeCompare), goal-template and category-template-context (numeric), main.test and transactions.test (localeCompare / amount+id)
- Fix invalid single-arg sort in useScheduleEdit to proper two-arg comparator

Made-with: Cursor

* refactor: update sorting functions to use two-argument comparator

* Update index.ts

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>
2026-03-12 17:50:37 +00:00
Matiss Janis Aboltins
3a22f1a153 refactor(api): fix cyclic dependencies (#6809)
* refactor(api): defineConfig vitest, api-helpers, drop vite.api build

- Wrap api vitest.config with defineConfig for typing/IDE
- Add loot-core api-helpers, use in YNAB4/YNAB5 importers
- Remove vite.api.config, build-api, injected.js; simplify api package

* refactor(api): update package structure and build scripts

- Change main entry point and types definition paths in package.json to reflect new structure.
- Simplify build script by removing migration and default database copy commands.
- Adjust tsconfig.dist.json to maintain declaration directory.
- Add typings for external modules in a new typings.ts file.
- Update comments in schedules.ts to improve clarity and maintainability.

* chore(api): update dependencies and build configuration

- Replace tsc-alias with rollup-plugin-visualizer in package.json.
- Update build script to use vite for building the API package.
- Add vite configuration file for improved build process and visualization.
- Adjust tsconfig.dist.json to exclude additional configuration files from the build.

* fix(api): update visualizer output path in vite configuration

- Change the output filename for the visualizer plugin from 'dist/stats.json' to 'app/stats.json' to align with the new directory structure.

* refactor(api): streamline Vite configuration and remove vitest.config.ts

- Remove vitest.config.ts as its configuration is now integrated into vite.config.ts.
- Update vite.config.ts to include sourcemap generation and adjust CRDT path resolution.
- Modify vitest.setup.ts to correct the import path for the CRDT proto file.

* feat(api): enhance build scripts and add file system utilities

- Update build scripts in package.json to include separate commands for building node, migrations, and default database.
- Introduce a new file system utility module in loot-core to handle file operations such as reading, writing, and directory management.
- Implement error handling and logging for file operations to improve robustness.

* Refactor typecheck script in api package and enhance api-helpers with new schedule and rule update functions. The typecheck command was simplified by removing the strict check, and new API methods for creating schedules and updating rules were added to improve functionality.

* Refactor API integration in loot-core by removing api-helpers and directly invoking handlers. Update typecheck script in api package to include strict checks, and refine TypeScript configurations across multiple packages for improved type safety and build processes.

* Refactor imports and enhance code readability across multiple files in loot-core. Simplified import statements in the API and adjusted formatting in YNAB importers for consistency. Updated type annotations to improve type safety and maintainability.

* Refactor handler invocation in YNAB importers to use the new send function from main-app. This change improves code consistency and readability by standardizing the method of invoking handlers across different modules.

* Refactor schedule configuration in loot-core to enhance type safety by introducing a new ScheduleRuleOptions type. This change improves the clarity of the recurring schedule configuration and ensures better type checking for frequency and interval properties.

* Update TypeScript configuration in api package to include path mapping for loot-core. This change enhances module resolution and improves type safety by allowing direct imports from the loot-core source directory.

* Update TypeScript configuration in api package to reposition the typescript-strict-plugin entry. This change improves the organization of the tsconfig.json file while maintaining the existing path mapping for loot-core, ensuring consistent type checking across the project.

* Update TypeScript configurations across multiple packages to enable noEmit option. This change enhances build processes by preventing unnecessary output files during compilation. Additionally, remove the obsolete tsconfig.api.json file from loot-core to streamline project structure.

* Update TypeScript configuration in sync-server package to enable noEmit option. This change allows for the generation of output files during compilation, facilitating the build process.

* Update api package configuration to streamline build process and enhance type safety. Removed unnecessary build scripts, integrated vite-plugin-dts for type declaration generation, and added migration and default database copying functionality. Adjusted vitest setup to comment out CRDT proto file import for improved test isolation.

* Update TypeScript configurations in desktop-client and desktop-electron packages to enable noEmit option, allowing for output file generation during compilation. Additionally, add ts-strict-ignore comments in YNAB importers to suppress strict type checking, improving compatibility with embedded API usage.

* Refactor api package configuration to update type declaration paths and enhance build process. Changed type definitions reference in package.json, streamlined tsconfig.json exclusions, and added functionality to copy inlined types during the build. Removed obsolete vitest setup file for improved test isolation.

* Revert to solution without types

* Update TypeScript configuration in API package to use ES2022 module and bundler resolution. This change enhances compatibility with modern JavaScript features and improves the build process.

* Update yarn.lock and API package to enhance TypeScript build process and add new dependencies

* Refactor inline-loot-core-types script to streamline TypeScript declaration handling and improve output organization. Remove legacy code and directly copy loot-core declaration tree, updating index.d.ts to reference local imports.

* Add internal export to API and enhance Vite configuration for migration handling

* Update Vite configuration in API package to target Node 18, enhancing compatibility with the latest Node features.

* Enhance inline-loot-core-types script to improve TypeScript declaration handling by separating source and typings directories. Update the copy process to include emitted typings, ensuring no declarations are dropped and maintaining better organization of loot-core types.

* Enhance migration handling by allowing both .sql and .js files to be copied during the migration process. Refactor file system operations in loot-core to improve error handling and streamline file management, including new methods for reading, writing, and removing files and directories.

* Refactor rootPath determination in Electron file system module by removing legacy case for 'bundle.api.js'. This simplifies the path management for the Electron app.

* Update API tests to mock file system paths for migration handling and change Vite configuration to target Node 20 for improved compatibility.

* Add promise-retry dependency to loot-core package and update yarn.lock

* Fix lint

* Refactor build script order in package.json for improved execution flow

* Feedback: API changes for "internal"
2026-03-12 17:43:39 +00:00
Tifan Dwi Avianto
d30162672c [AI] Correct API docs to match actual implementation (#7096)
* [AI] docs(api): fix API reference discrepancies in reference.md

Amp-Thread-ID: https://ampcode.com/threads/T-019ca316-33e2-75db-a333-baf62bb55f6c

* [autofix.ci] apply automated fixes

* docs: update API reference for init and updateRule methods

* docs(7096.md): add release notes for API reference documentation fix

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-11 19:04:19 +00:00
Matiss Janis Aboltins
db03d77e81 Handle missing accounts in SimpleFin batch sync (#7152)
* [AI] Fix SimpleFin batch sync crash when accounts are missing from response

When SimpleFin doesn't return data for all requested accounts during
batch sync, the code crashed with a TypeError accessing properties on
undefined, resulting in a generic "internal error" message for users.

This fix:
- Adds a guard in simpleFinBatchSync for missing account data, returning
  an ACCOUNT_MISSING error instead of crashing
- Propagates error entries from the SimpleFin response's errors map for
  accounts that have no data entry
- Adds a user-friendly ACCOUNT_MISSING error message in the UI suggesting
  to unlink and relink the account
- Adds test cases covering both scenarios

https://claude.ai/code/session_01XbHgxxrXYR3UTyW6VmYj47

* Add release notes for PR #7152

* [AI] Fix SimpleFIN batch sync error_code TypeError

Fix "Cannot read properties of undefined (reading 'error_code')" that
occurs during SimpleFIN batch sync by:

1. Adding null check for downloadSimpleFinTransactions result in
   simpleFinBatchSync (sync.ts) - the function can return undefined
   when user token is missing

2. Adding .catch() handler on individual processBankSyncDownload
   promises so a single account failure doesn't crash the entire
   batch via Promise.all rejection

3. Using optional chaining on syncResponse.res?.error_code in app.ts
   and handling the case where res is undefined with proper error
   reporting

https://claude.ai/code/session_01XbHgxxrXYR3UTyW6VmYj47

* [AI] Fix SimpleFin batch sync to emit ACCOUNT_MISSING for empty payloads

In the batch sync path, if a per-account download payload is an empty
object or is missing the transactions array, processBankSyncDownload
would crash and the error would be caught as INTERNAL_ERROR. Now we
check for these cases explicitly and emit ACCOUNT_MISSING instead,
while still allowing entries with error_code to propagate their
specific error.

https://claude.ai/code/session_01XbHgxxrXYR3UTyW6VmYj47

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-11 18:07:42 +00:00
Matiss Janis Aboltins
8a8fb2da51 [AI] Custom Themes - ability to define separate light/dark theme (#7145)
* [AI] Consolidate custom theme prefs and improve auto-mode UX

- Merge `installedCustomTheme` into `installedCustomLightTheme` so only
  two prefs exist (light + dark). The legacy asyncStorage key
  `installed-custom-theme` is preserved for backwards compatibility.
- In auto (System default) mode, the main Theme dropdown no longer
  surfaces the installed custom-light theme as an option; custom themes
  for light/dark are managed exclusively via the sub-selectors.
- Selecting "System default" resets both light and dark custom themes.
- Installing a custom theme from the main dropdown while in auto mode
  switches the base theme to "Light" so it applies directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Add release notes for PR #7145

* Change category from Features to Enhancement

Custom Themes: separate light and dark theme options when selecting 'system default' theme.

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #7145

* Enhance ThemeSettings and UI components by adding maxWidth styling for better responsiveness. This change ensures that buttons and columns adapt to the full width of their containers.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-11 18:07:06 +00:00
Juulz
a65ab2b4ce Change Titlebar.tsx 'Sync' to Syncing icon only (#7005)
* Update Titlebar.tsx sync name to Server Sync

* Update Titlebar.tsx

* Create 7005.md

Change title Bar 'Sync' to 'Server Sync'.

* Update packages/desktop-client/src/components/Titlebar.tsx

Co-authored-by: Joel Jeremy Marquez <joeljeremy.marquez@gmail.com>

* Update packages/desktop-client/src/components/Titlebar.tsx

Co-authored-by: Joel Jeremy Marquez <joeljeremy.marquez@gmail.com>

* Update Titlebar.tsx

* Update Titlebar.tsx

* Update Titlebar.tsx

* [autofix.ci] apply automated fixes

* Update 7005.md

* Disable Server Sync button when offline in Titlebar component

* fix lint

* Add aria-disabled attribute to Server Sync button in Titlebar component

* Update titlebar sync icon and improve accessibility

Add disabled state and aria disabled label for offline mode.

---------

Co-authored-by: Joel Jeremy Marquez <joeljeremy.marquez@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: youngcw <calebyoung94@gmail.com>
2026-03-11 13:43:56 +00:00
Sylvercode
f29c031735 Add an option to swap payee/memo when importing transaction form file (#7101)
* Add swap payee-memo to ofx import

* Add mock files to test payee-memo swap

* Add swap payee-memo to qif import

* Add swap payee-memo to camt import

* Minor code cleanup for swap payee-memo on import

* Add release note

* lint fixing

* Fixe Payee and Memo capitalization

* change swapPayeeAndMemo to ofxSwapPayeeAndMemo

* correct  release note typo

* Add getSwapOption base on file type

* Support qfx

* Add CheckboxToggle to simplify ImportTransactionsModal options

* Fix split reac import

* Fix react import lint
2026-03-11 13:40:26 +00:00
Asherah Connor
c06f96f015 Add "only import transactions since" (#7139)
* Add "only import transactions since"

If specified, we filter out transactions before the given date.

* Address 🐰 comments

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #7139

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-11 10:52:21 +00:00
Alieksieiev0
0b21b572fe fix item selection requiring double tap on mobile (#7166)
* fix item selection requiring double tap on mobile

* Update upcoming-release-notes/7166.md

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

* extract getItemProps wrapper

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-11 10:34:31 +00:00
Matiss Janis Aboltins
bf1d220ced [AI] Lint: fix typescript/unbound-method problems (#7163)
* [AI] Refactor modal components and server modules

Made-with: Cursor

* Release notes
2026-03-10 18:06:50 +00:00
Vicente Cruz
94dd8f73c0 Add "Notion Dark Mode" custom theme (#7151)
* Update customThemeCatalog.json

Added custom theme inspired by Notion's Dark Mode

* [autofix.ci] apply automated fixes

* Create 7151.md release notes file

* Add Notion Dark Mode custom theme

Pushed a no-op change to get the tests to re-run

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-10 17:00:12 +00:00
Matiss Janis Aboltins
85e08b2e9e [AI] Fix privilege escalation in sync-server /change-password and getLoginMethod (#7155)
* [AI] Fix privilege escalation in sync-server /change-password and getLoginMethod

Made-with: Cursor

* Update upcoming-release-notes/7155.md

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

* Fix privilege escalation issue in change-password endpoint

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-09 20:33:20 +00:00
Dustin Brewer
60e2665fcc MVP for Payee Locations (#6157)
* Phase 1: Add payee locations database schema/types

 * Add migration to create payee_locations table with proper indexes
 * Add PayeeLocationEntity type definition
 * Update database schema to include payee_locations table
 * Export PayeeLocationEntity from models index

* Phase 2: Add payee location API/services

  * Add constants for default location behavior
  * Implement location service with geolocation adapters
  * Add new API handlers for payee location operations

* Phase 3: Add location-aware UI components/hooks

 * Update mobile transaction editing with location integration
 * Enhance PayeeAutocomplete with nearby payee suggestions and forget
   functionality
 * Implement location permission and placeholder unit of measurement hooks

* Phase 4: Add YNAB5 payee location import support

 * Extend YNAB5 types to include location data from payees
 * Implement location import logic in YNAB5 importer

* Phase 5: Add unit of measurement support

 * Add unit of measurement preference setting in Format.tsx
 * Implement distance formatting utilities for imperial/metric units
 * Add useUnitOfMeasurementFormat hook for accessing preferences

* Required release note about the PR

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6157

* Actually get syncing working

This was not obvious to me, esp. with 13 years of data, but the
locations I've been inserting were local only.

Everything appeared to work.

What I failed to notice is that the locations did not sync
across devices. Of course all the location data that was imported worked
fine, but nothing new unless it was created on device.

This changes the schema and uses the proper insert/delete methods such
that syncing works.

* Remove unit of measurement preference

Display feet and meters automatically, and don't bother to format based on miles/kilometers.

* Add payeeLocations feature flag

Place the location permissions check and thus user-facing functionality behind the feature flag

* Missed adding tombstone to payee location query

* Adjust migration name to pass CI

Adjust the indexes as well

* Unify location.ts

If CodeRabbit complains again, reply that we are actively choosing a unified file

* Add bounds testing

The validation is straightforward range-checking — if it's wrong, it'll be obvious quickly. Unless there's a plan to start adding broader test coverage for that file, I'd leave it untested for now

* Prefer camelCase for the method params

* Fix the nested interactive containers

* Fix the majority of CodeRabbit nits

The remainder seem to not be related to my code change (just a lint), outdated (sql migration comment), or infeasible (sql haversine query)

* More CodeRabbit nits

* Revert unnecessary YNAB5 zip import

Turns out the payee_locations were inside the exported budget all along!

* Additional guards and other CR fixes

* Match the pattern used elsewhere in file

* YNAB5.Budget -> Budget

Missed in the merge conflict

* ci: trigger rerun

* Change import from fetch to connection module

* Correct invalid border property

Ah. I never noticed this property wasn't working. I guess the button
looked OK to me!

* Only hide the button on success

* Update packages/loot-core/src/shared/location-utils.ts

Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>

* Update packages/loot-core/src/server/payees/app.ts

Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>

* Fully fix typo

Guess I shouldn't commit a suggestion after waking up

* Attempting to address feedback

Manual select nearby payee and save payee location buttons to make the UX more obvi

* Remove stale file that was moved

* Additional cleanup of remnant change

Removed the references to location from a few existing entities

* Additional cleanup of remnant change

* Show the Nearby payees button even when the field is disabled

If there are nearby payees, there's not a payee already selected, and the save button isn't needed

* runQuery is not async

* Add mockNearbyPayeesResult to test

Trying to utilize the real type in the mock

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>
2026-03-09 20:26:48 +00:00
Matiss Janis Aboltins
102be1c54d Fix shared error array in SimpleFin batch sync error handling (#7125)
* Add failing tests for SimpleFin batch sync shared error array bug

Tests prove two bugs in simpleFinBatchSync() catch block (app.ts:1100-1115):
1. All accounts share the same errors array reference
2. Errors accumulate across accounts instead of being isolated

Related: #6623, #6651, #7114

https://claude.ai/code/session_011ebiiXRMmbiKxYMohVXL6o

* Fix shared error array in SimpleFin batch sync catch block

When simpleFinBatchSync() threw an error, all accounts received the
same errors array by reference and errors accumulated across accounts.
Each account now gets its own isolated errors array with a single error
specific to that account, matching the pattern used by accountsBankSync().

Fixes #6623

https://claude.ai/code/session_011ebiiXRMmbiKxYMohVXL6o

* Remove @ts-strict-ignore from bank sync tests

Use proper non-null assertions instead of disabling strict mode.

https://claude.ai/code/session_011ebiiXRMmbiKxYMohVXL6o

* Add release notes for PR #7125

* [AI] Replace test() with it() to follow repo convention

Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>
2026-03-09 19:50:18 +00:00
Julian Dominguez-Schatz
448da13cf5 Move migrations script to typescript (#7075)
* Move migrations script to typescript

* Add release notes

* Setup

* Fix

* PR feedback

* Make imports work as expected

* Rabbit
2026-03-09 07:58:03 +00:00
LeviBorodenko
41679235be [Mobile] Fix preview running balances not displaying on toggle (#7041)
* refactor(usePreviewTransactions): Move running balances to useMemo

* docs(relnotes): Add note for mobile running balance fix

* refactor(hooks): Remove unnecessary options ref
2026-03-08 22:04:52 +00:00
Matiss Janis Aboltins
73fa068fe9 [AI] Establish AI agent commit and PR guidelines (#7153)
* [AI] Extract PR/commit rules into shared agent skill

Deduplicate PR and commit instructions from AGENTS.md into a standalone
skill file at .github/agents/pr-and-commit-rules.md. This single source
of truth is consumed by both Claude Code (via CLAUDE.md @-import) and
Cursor (via .cursor/rules/pr-and-commit.mdc with alwaysApply: true).

AGENTS.md now references the shared file instead of repeating the rules
in three separate sections.

https://claude.ai/code/session_01KkHg7MYXrTyDkTw6u98Vam

* Add release notes for PR #7153

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-08 18:16:46 +00:00
Juulz
1fe588c143 🐞 Fix mobile transactions colors - fixes #7042 (#7047)
* Update amount styling with theme colors

* Clean up imports in TransactionListItem.tsx

Removed unused import of makeBalanceAmountStyle.

* Add release notes for bugfix in color variables

Fix color variables for mobile transaction list items.

* Change positiveColor in amount to use theme.tableText

* Change negative color style for running balance

* Fix negative color style in TransactionListItem

* Update color styles for transaction amount display

* Update upcoming-release-notes/7047.md

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #7047

---------

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-08 15:04:30 +00:00
mibragimov
edce092ae8 fix(csv-import): trim whitespace from amount strings before parsing (#7149)
* fix(csv-import): trim whitespace from amount strings before parsing

looselyParseAmount relies on a regex anchored at $ to detect decimal
markers. Trailing whitespace (e.g. from Excel-saved CSVs) shifts the
pattern match so a thousands separator is misidentified as a decimal
point, producing wildly wrong values.

Adding trim() at the top of the function eliminates trailing/leading
whitespace before any regex logic runs.

Fixes actualbudget/actual#7121

* chore: add release notes for #7149
2026-03-07 20:41:47 +00:00
dependabot[bot]
77411394f6 Bump dompurify from 3.3.0 to 3.3.2 (#7143)
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.3.0 to 3.3.2.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.3.0...3.3.2)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>
2026-03-07 20:24:41 +00:00
dependabot[bot]
235d94478f Bump express-rate-limit from 8.2.1 to 8.2.2 (#7140)
* Bump express-rate-limit from 8.2.1 to 8.2.2

Bumps [express-rate-limit](https://github.com/express-rate-limit/express-rate-limit) from 8.2.1 to 8.2.2.
- [Release notes](https://github.com/express-rate-limit/express-rate-limit/releases)
- [Commits](https://github.com/express-rate-limit/express-rate-limit/compare/v8.2.1...v8.2.2)

---
updated-dependencies:
- dependency-name: express-rate-limit
  dependency-version: 8.2.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* [AI] Update express-rate-limit to 8.3.0 to fix GHSA-46wh-pxpv-q5gq vulnerability

Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>

* Add release notes for PR #7140

* [AI] Update release notes to reflect version 8.3.0

Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-07 19:48:56 +00:00
Matiss Janis Aboltins
7e0edd43ec Sort theme catalog items alphabetically by name (#7144)
* [AI] Sort custom theme catalog options alphabetically in the UI

Sort catalog themes by name using localeCompare before rendering,
without modifying the underlying JSON data file.

https://claude.ai/code/session_01Y5SGaVYqsVWVsvXV8ZFXj3

* Add release notes for PR #7144

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-06 23:01:07 +00:00
Matiss Janis Aboltins
fdf5c8d0a9 [AI] Move window typings import to globals.ts (#7142)
* [AI] Remove parent path import of window.ts from desktop-client tsconfig

Replace the `../../packages/loot-core/typings/window.ts` include in
desktop-client's tsconfig.json with a proper package import. This adds
a `./typings/*` export to loot-core's package.json and creates a
globals.ts file in desktop-client that imports the window types via
the package name.

https://claude.ai/code/session_01GrgAzjWd3XvqwBTfXLerxc

* [autofix.ci] apply automated fixes

* Add release notes for PR #7142

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-06 23:00:50 +00:00
Matt Fiddaman
a8ec84ceac stop font size fluctuations showing in summary cards (#7092)
* stop summary cards from showing until font size settled

* note
2026-03-06 23:00:41 +00:00
Michael Clark
b727124603 Fix docker images (#7146)
* fix docker images

* release notes
2026-03-06 22:55:21 +00:00
dependabot[bot]
8bb7f207f2 Bump svgo from 3.3.2 to 3.3.3 (#7130)
Bumps [svgo](https://github.com/svg/svgo) from 3.3.2 to 3.3.3.
- [Release notes](https://github.com/svg/svgo/releases)
- [Commits](https://github.com/svg/svgo/compare/v3.3.2...v3.3.3)

---
updated-dependencies:
- dependency-name: svgo
  dependency-version: 3.3.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-06 21:56:01 +00:00
dependabot[bot]
6e0c15eb12 Bump immutable from 5.1.3 to 5.1.5 (#7129)
Bumps [immutable](https://github.com/immutable-js/immutable-js) from 5.1.3 to 5.1.5.
- [Release notes](https://github.com/immutable-js/immutable-js/releases)
- [Changelog](https://github.com/immutable-js/immutable-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/immutable-js/immutable-js/compare/v5.1.3...v5.1.5)

---
updated-dependencies:
- dependency-name: immutable
  dependency-version: 5.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>
2026-03-06 21:51:14 +00:00
Michael Clark
4e2cec2c7a 🧪 Improving docker image test resiliency (#7141)
* improving test resiliency

* release notes
2026-03-06 21:02:31 +00:00
Matiss Janis Aboltins
078603cadf [AI] Implement sync recovery (#7111)
* [AI] Fix iOS/Safari sync recovery (fixes #7026): useOnVisible hook, re-fetch server version on visible, improved network-failure message

Made-with: Cursor

* Feedback: coderabbitai

* Refactor useOnVisible test: remove unnecessary resolve check and simplify callback definition
2026-03-04 23:27:15 +00:00
Joel Jeremy Marquez
b3a86b5392 Update remaining accounts hooks to return react query states (#7071)
* Update remaining accounts hooks to return react query states

* Add release notes for PR #7071

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-04 23:13:55 +00:00
Mats Nilsson
295a565e55 Fix budget analysis report padding (#7118)
* Fix budget analysis report padding

The padding on the report is too small when the value is large.

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-04 22:08:30 +00:00
Matiss Janis Aboltins
387c8fce16 [AI] Enable TypeScript composite project references across monorepo (#7062)
* [AI] Enable TypeScript composite project references across monorepo

- Add composite and declaration emit to all package tsconfigs
- Wire root and per-package project references in dependency order
- Replace cross-package include-based typing with referenced outputs
- Fix api TS5055 by emitting declarations to decl-output
- Add desktop-client alias for tests; fix oxlint import order in vite.config
- Add UsersState.data null type and openDatabase return type for strict emit

Co-authored-by: Cursor <cursoragent@cursor.com>

* Remove obsolete TypeScript configuration for API and update build script to emit declarations directly to the output directory. This streamlines the build process and ensures compatibility with the new project structure.

* Refactor TypeScript configuration in API package to remove obsolete decl-output directory and update build scripts. The changes streamline the build process by directing declaration outputs to the @types directory, ensuring better organization and compatibility with the new project structure.

* Add TypeScript declaration emission for loot-core in desktop-electron build process

* Refactor TypeScript configuration in API package to utilize composite references and streamline build scripts. Update include and exclude patterns for improved file management, ensuring better organization of declaration outputs and migration SQL files.

* Refactor TypeScript configuration in loot-core and desktop-client packages to streamline path management and remove obsolete dependencies. Update paths in tsconfig.json files for better organization and compatibility, and adjust yarn.lock to reflect changes in workspace dependencies.

* Update desktop-electron package to utilize loot-core as a workspace dependency. Adjust TypeScript import paths and tsconfig references for improved organization and compatibility across packages.

* Enhance Vite configuration for desktop-client to support Electron-specific conditions and update loot-core package.json to include Electron as a platform for client connection. This improves compatibility for Electron builds.

* Refactor TypeScript configuration across multiple packages to streamline path management. Update tsconfig.json files in root, api, and loot-core packages to improve import paths and maintain compatibility with internal typings.

* Update package dependencies and Vite configuration across component-library and desktop-client. Add vite-tsconfig-paths to component-library and remove it from desktop-client. Refactor Storybook preview file to include a TODO for future refactoring.

* Remove Node-specific path from loot-core package.json for client connection, streamlining platform configuration for Electron.

* Remove loot-core as a workspace dependency from desktop-electron package.json

* Update tsconfig.json to remove reference to desktop-client

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-03-04 20:57:06 +00:00
YHC
c7ebfd8ad4 Add TWD Currency (#7095)
* Add New Taiwan Dollar to currency list

* Add New Taiwan Dollar to currency list

* Fix decimalPlaces for New Taiwan Dollar

Updated decimalPlaces for New Taiwan Dollar from 0 to 2, as suggested by coderabbitai, "Line 62 introduces TWD as zero-decimal, but this codebase currently has an unresolved zero-decimal conversion/storage issue. This risks incorrect persisted amounts for TWD transactions."

* Add upcoming-releass-notes

* Add New Taiwan Dollar (NT$) to available currency

* Add New Taiwan Dollar (NT$) to available currency
2026-03-04 18:46:20 +00:00
Matiss Janis Aboltins
e1f834371b [AI] Refactor YNAB importers to use server-side send() and handler API (#7050)
* [AI] Refactor YNAB importers to use server-side send() and handler API

Co-authored-by: Cursor <cursoragent@cursor.com>

* Rename 7049.md to 7050.md

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-03-04 18:38:33 +00:00
Matiss Janis Aboltins
4caee99955 [AI] Strengthen schedule rule types in loot-core (remove ts-expect-error) (#7051)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-03-04 18:38:20 +00:00
Matiss Janis Aboltins
286d05d187 [AI] Move ImportTransactionsOpts from api package to loot-core (#7053)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-03-04 18:38:09 +00:00
Matiss Janis Aboltins
cf05a7ea01 [AI] Typescript: low hanging fruit (#7091)
* [AI] Use loot-core workspace in desktop-electron and fix related types

- Add loot-core as workspace dependency in desktop-electron
- Import GlobalPrefsJson from loot-core package in desktop-electron
- Allow null in usersSlice data type (UsersState)
- Add explicit SQL.Database return type to openDatabase in sqlite electron

Made-with: Cursor

* Re-add loot-core as a workspace dependency in desktop-electron
2026-03-04 18:32:01 +00:00
Michael Clark
b373b612a4 :electron: Electron backups converted to zip for easy importing (#7069)
* e:electron: electron backups converted to zip for easy importing

* release notes

* fix lint

* suggestion from rabbit

* Change category from Maintenance to Enhancements
2026-03-04 18:00:07 +00:00
Copilot
3797cff716 [AI] Skip AI-generated release notes for release/ branch PRs (#7107)
* Initial plan

* [AI] Make AI-generated release notes not run on release/ branches

Co-authored-by: jfdoming <9922514+jfdoming@users.noreply.github.com>

* [AI] Use explicit startsWith(github.head_ref) if conditions instead of JS changes

Co-authored-by: jfdoming <9922514+jfdoming@users.noreply.github.com>

* Delete extra condition

* Add release notes for PR #7107

* [autofix.ci] apply automated fixes

* FIx note

* Add head branch information to PR details

* Refactor conditions for release notes generation

* Sec

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jfdoming <9922514+jfdoming@users.noreply.github.com>
Co-authored-by: Julian Dominguez-Schatz <julian.dominguezschatz@gmail.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-04 17:40:32 +00:00
Michael Clark
9e2793d413 :electron: Flathub pr on publish of release assets (#7117)
* flathub pr on publish of release

* docs

* release notes

* rabbit feedback
2026-03-04 17:05:47 +00:00
Julian Dominguez-Schatz
3201819df9 Display/save limit/refill templates (#6693)
* Handle refill templates in automations modal

* Add release notes

* Fix typecheck

* Fix icon style

* Style fixes

* Fix migration

* Fix typecheck

* Rabbit

* Add tests
2026-03-04 16:41:07 +00:00
Josh Woodward
eca50f28b0 Fix skipping schedules that move before weekend (#7057)
* Pushing before weekend to Monday

* release notes

* lint

* Guard against null

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

* fixing AI commit and addressing second comment

* addressing nitpicks

* first attempt at a test

* [autofix.ci] apply automated fixes

* refactor to use condition and fix tests

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-04 16:40:41 +00:00
dependabot[bot]
c82ee91b12 bump bn.js from 4.12.2 to 4.12.3 (#7073)
* Bump bn.js from 4.12.2 to 4.12.3

Bumps [bn.js](https://github.com/indutny/bn.js) from 4.12.2 to 4.12.3.
- [Release notes](https://github.com/indutny/bn.js/releases)
- [Changelog](https://github.com/indutny/bn.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/indutny/bn.js/compare/v4.12.2...v4.12.3)

---
updated-dependencies:
- dependency-name: bn.js
  dependency-version: 4.12.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* note

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2026-03-04 16:40:25 +00:00
Julian Dominguez-Schatz
cb8ff337dc Migrate sync server general utils to typescript (#7074)
* Migrate sync server general utils to typescript

* Add release notes

* Rabbit

* Fix
2026-03-04 16:40:24 +00:00
Matiss Janis Aboltins
c37a5a02aa [AI] Add CLAUDE.md with reference to AGENTS.md (#7105)
* [AI] Add CLAUDE.md with reference to AGENTS.md

Made-with: Cursor

* Add release notes for PR #7105

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-04 16:40:14 +00:00
534 changed files with 15502 additions and 3338 deletions

View File

@@ -0,0 +1,74 @@
---
description: Rules for AI-generated commits and pull requests
globs:
alwaysApply: true
---
# PR and Commit Rules for AI Agents
Canonical source: `.github/agents/pr-and-commit-rules.md`
## Commit Rules
### [AI] Prefix Requirement
**ALL commit messages MUST be prefixed with `[AI]`.** This is a mandatory requirement with no exceptions.
**Examples:**
- `[AI] Fix type error in account validation`
- `[AI] Add support for new transaction categories`
- `Fix type error in account validation` (MISSING PREFIX - NOT ALLOWED)
- `Add support for new transaction categories` (MISSING PREFIX - NOT ALLOWED)
### Git Safety Rules
- **Never** update git config
- **Never** run destructive git operations (force push, hard reset) unless the user explicitly requests it
- **Never** skip hooks (`--no-verify`, `--no-gpg-sign`)
- **Never** force push to `main`/`master`
- **Never** commit unless explicitly asked by the user
## Pre-Commit Quality Checklist
Before committing, ensure all of the following:
- [ ] Commit message is prefixed with `[AI]`
- [ ] `yarn typecheck` passes
- [ ] `yarn lint:fix` has been run
- [ ] Relevant tests pass
- [ ] User-facing strings are translated
- [ ] Code style conventions followed (see `AGENTS.md` for full style guide)
## Pull Request Rules
### [AI] Prefix Requirement
**ALL pull request titles MUST be prefixed with `[AI]`.** This is a mandatory requirement with no exceptions.
**Examples:**
- `[AI] Fix type error in account validation`
- `[AI] Add support for new transaction categories`
- `Fix type error in account validation` (MISSING PREFIX - NOT ALLOWED)
### Labels
Add the **"AI generated"** label to all AI-created pull requests.
### PR Template: Do Not Fill In
- **NEVER fill in the PR template** (`.github/PULL_REQUEST_TEMPLATE.md`). Leave all blank spaces and placeholder comments as-is.
- **Exception**: If a human **explicitly asks** you to fill out the PR template, then fill it out **in Chinese** (简体中文).
## Quick-Reference Workflow
1. Make your changes
2. Run `yarn typecheck` — fix any errors
3. Run `yarn lint:fix` — fix any remaining lint errors
4. Run relevant tests (`yarn test` for all, or workspace-specific)
5. Stage files and commit with `[AI]` prefix — do not skip hooks
6. When creating a PR:
- Use `[AI]` prefix in the title
- Add the `"AI generated"` label
- Leave the PR template blank (do not fill it in)

View File

@@ -37,12 +37,14 @@ async function getPRDetails() {
console.log('- PR Author:', pr.user.login);
console.log('- PR Title:', pr.title);
console.log('- Base Branch:', pr.base.ref);
console.log('- Head Branch:', pr.head.ref);
const result = {
number: pr.number,
author: pr.user.login,
title: pr.title,
baseBranch: pr.base.ref,
headBranch: pr.head.ref,
};
setOutput('result', JSON.stringify(result));

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

70
.github/agents/pr-and-commit-rules.md vendored Normal file
View File

@@ -0,0 +1,70 @@
# PR and Commit Rules for AI Agents
This is the single source of truth for all commit and pull request rules that AI agents must follow when working with Actual Budget.
## Commit Rules
### [AI] Prefix Requirement
**ALL commit messages MUST be prefixed with `[AI]`.** This is a mandatory requirement with no exceptions.
**Examples:**
- `[AI] Fix type error in account validation`
- `[AI] Add support for new transaction categories`
- `Fix type error in account validation` (MISSING PREFIX - NOT ALLOWED)
- `Add support for new transaction categories` (MISSING PREFIX - NOT ALLOWED)
### Git Safety Rules
- **Never** update git config
- **Never** run destructive git operations (force push, hard reset) unless the user explicitly requests it
- **Never** skip hooks (`--no-verify`, `--no-gpg-sign`)
- **Never** force push to `main`/`master`
- **Never** commit unless explicitly asked by the user
## Pre-Commit Quality Checklist
Before committing, ensure all of the following:
- [ ] Commit message is prefixed with `[AI]`
- [ ] `yarn typecheck` passes
- [ ] `yarn lint:fix` has been run
- [ ] Relevant tests pass
- [ ] User-facing strings are translated
- [ ] Code style conventions followed (see `AGENTS.md` for full style guide)
## Pull Request Rules
### [AI] Prefix Requirement
**ALL pull request titles MUST be prefixed with `[AI]`.** This is a mandatory requirement with no exceptions.
**Examples:**
- `[AI] Fix type error in account validation`
- `[AI] Add support for new transaction categories`
- `Fix type error in account validation` (MISSING PREFIX - NOT ALLOWED)
### Labels
Add the **"AI generated"** label to all AI-created pull requests. This helps maintainers understand the nature of the contribution.
### PR Template: Do Not Fill In
- **NEVER fill in the PR template** (`.github/PULL_REQUEST_TEMPLATE.md`). Leave all blank spaces and placeholder comments as-is. Humans are expected to fill in the Description, Related issue(s), Testing, and Checklist sections.
- **Exception**: If a human **explicitly asks** you to fill out the PR template, then fill it out **in Chinese**, using Chinese characters (简体中文) for all content you add.
## Quick-Reference Workflow
Follow these steps when committing and creating PRs:
1. Make your changes
2. Run `yarn typecheck` — fix any errors
3. Run `yarn lint:fix` — fix any remaining lint errors
4. Run relevant tests (`yarn test` for all, or workspace-specific)
5. Stage files and commit with `[AI]` prefix — do not skip hooks
6. When creating a PR:
- Use `[AI]` prefix in the title
- Add the `"AI generated"` label
- Leave the PR template blank (do not fill it in)

View File

@@ -41,21 +41,12 @@ jobs:
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
- name: Check if PR targets master branch
if: steps.check-first-comment.outputs.result == 'true' && steps.pr-details.outputs.result != 'null'
id: check-base-branch
run: |
BASE_BRANCH=$(echo '${{ steps.pr-details.outputs.result }}' | jq -r '.baseBranch')
echo "Base branch: $BASE_BRANCH"
if [ "$BASE_BRANCH" = "master" ]; then
echo "targets_master=true" >> $GITHUB_OUTPUT
else
echo "targets_master=false" >> $GITHUB_OUTPUT
echo "PR does not target master branch, skipping release notes generation"
fi
- name: Check if release notes file already exists
if: steps.check-first-comment.outputs.result == 'true' && steps.pr-details.outputs.result != 'null' && steps.check-base-branch.outputs.targets_master == 'true'
if: >-
steps.check-first-comment.outputs.result == 'true' &&
steps.pr-details.outputs.result != 'null' &&
fromJSON(steps.pr-details.outputs.result).baseBranch == 'master' &&
!startsWith(fromJSON(steps.pr-details.outputs.result).headBranch, 'release/')
id: check-release-notes-exists
run: node .github/actions/ai-generated-release-notes/check-release-notes-exists.js
env:

View File

@@ -81,6 +81,31 @@ jobs:
name: build-stats
path: packages/desktop-client/build-stats
cli:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Build CLI
run: yarn build:cli
- name: Create package tgz
run: cd packages/cli && yarn pack && mv package.tgz actual-cli.tgz
- name: Prepare bundle stats artifact
run: cp packages/cli/dist/stats.json cli-stats.json
- name: Upload Build
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: actual-cli
path: packages/cli/actual-cli.tgz
- name: Upload CLI bundle stats
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: cli-build-stats
path: cli-stats.json
server:
runs-on: ubuntu-latest
steps:

View File

@@ -12,6 +12,16 @@ concurrency:
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
jobs:
constraints:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Check dependency version consistency
run: yarn constraints
lint:
runs-on: ubuntu-latest
steps:
@@ -60,8 +70,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
- name: Set up environment
uses: ./.github/actions/setup
with:
node-version: 22
download-translations: 'false'
- name: Check migrations
run: node ./.github/actions/check-migrations.js
run: yarn workspace @actual-app/ci-actions tsx bin/check-migrations.ts

View File

@@ -87,8 +87,8 @@ jobs:
- name: Test that the docker image boots
run: |
docker run --detach --network=host actualbudget/actual-server-testing
sleep 5
curl --fail -sS -LI -w '%{http_code}\n' --retry 10 --retry-delay 1 --retry-connrefused localhost:5006
sleep 10
curl --fail -sS -LI -w '%{http_code}\n' --retry 20 --retry-delay 1 --retry-connrefused localhost:5006
# 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/

View File

@@ -156,53 +156,3 @@ jobs:
-NoStatus `
-AutoCommit `
-Force
publish-flathub:
needs: build
runs-on: ubuntu-22.04
if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
steps:
- name: Download Linux artifacts
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: actual-electron-ubuntu-22.04
- name: Calculate AppImage SHA256
id: appimage_sha256
run: |
APPIMAGE_X64_SHA256=$(sha256sum Actual-linux-x86_64.AppImage | awk '{ print $1 }')
APPIMAGE_ARM64_SHA256=$(sha256sum Actual-linux-arm64.AppImage | awk '{ print $1 }')
echo "APPIMAGE_X64_SHA256=$APPIMAGE_X64_SHA256" >> "$GITHUB_ENV"
echo "APPIMAGE_ARM64_SHA256=$APPIMAGE_ARM64_SHA256" >> "$GITHUB_ENV"
- name: Checkout Flathub repo
uses: actions/checkout@v6
with:
repository: flathub/com.actualbudget.actual
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
- name: Update manifest with new SHA256
run: |
# Replace x86_64 entry
sed -i "/x86_64.AppImage/{n;s|sha256:.*|sha256: ${{ env.APPIMAGE_X64_SHA256 }}|}" com.actualbudget.actual.yml
sed -i "/x86_64.AppImage/s|url:.*|url: https://github.com/actualbudget/actual/releases/download/v${{ needs.build.outputs.version }}/Actual-linux-x86_64.AppImage|" com.actualbudget.actual.yml
# Replace arm64 entry
sed -i "/arm64.AppImage/{n;s|sha256:.*|sha256: ${{ env.APPIMAGE_ARM64_SHA256 }}|}" com.actualbudget.actual.yml
sed -i "/arm64.AppImage/s|url:.*|url: https://github.com/actualbudget/actual/releases/download/v${{ needs.build.outputs.version }}/Actual-linux-arm64.AppImage|" com.actualbudget.actual.yml
cat com.actualbudget.actual.yml
- name: Create PR in Flathub repo
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
commit-message: 'Update Actual flatpak to version ${{ needs.build.outputs.version }}'
branch: 'release/${{ needs.build.outputs.version }}'
draft: true
title: 'Update Actual flatpak to version ${{ needs.build.outputs.version }}'
body: |
This PR updates the Actual desktop flatpak to version ${{ needs.build.outputs.version }}.
:link: [View release notes](https://actualbudget.org/blog/release-${{ needs.build.outputs.version }})
reviewers: 'jfdoming,MatissJanis,youngcw' # The core team that have accepted the collaborator access to the Flathub repo

View File

@@ -42,6 +42,8 @@ jobs:
python3 -m venv .venv
source .venv/bin/activate
python3 -m pip install setuptools
- name: Set up environment
uses: ./.github/actions/setup
- if: ${{ startsWith(matrix.os, 'ubuntu') }}
name: Setup Flatpak dependencies
run: |
@@ -56,11 +58,9 @@ jobs:
METAINFO_FILE="packages/desktop-electron/extra-resources/linux/com.actualbudget.actual.metainfo.xml"
TODAY=$(date +%Y-%m-%d)
VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-electron/package.json --type nightly)
VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/desktop-electron/package.json --type nightly)
sed -i "s/%RELEASE_VERSION%/$VERSION/g; s/%RELEASE_DATE%/$TODAY/g" "$METAINFO_FILE"
flatpak run --command=flatpak-builder-lint org.flatpak.Builder appstream "$METAINFO_FILE"
- name: Set up environment
uses: ./.github/actions/setup
- name: Build Electron
run: ./bin/package-electron

View File

@@ -20,6 +20,10 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.event.inputs.ref }}
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Bump package versions
id: bump_package_versions
shell: bash
@@ -35,12 +39,12 @@ jobs:
pkg="${packages[$key]}"
if [[ -n "${{ github.event.inputs.version }}" ]]; then
version=$(node ./packages/ci-actions/bin/get-next-package-version.js \
version=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts \
--package-json "./packages/$pkg/package.json" \
--version "${{ github.event.inputs.version }}" \
--update)
else
version=$(node ./packages/ci-actions/bin/get-next-package-version.js \
version=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts \
--package-json "./packages/$pkg/package.json" \
--type auto \
--update)

126
.github/workflows/publish-flathub.yml vendored Normal file
View File

@@ -0,0 +1,126 @@
name: Publish Flathub
defaults:
run:
shell: bash
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: 'Release tag (e.g. v25.3.0)'
required: true
type: string
concurrency:
group: publish-flathub
cancel-in-progress: false
jobs:
publish-flathub:
runs-on: ubuntu-22.04
steps:
- name: Resolve version
id: resolve_version
env:
EVENT_NAME: ${{ github.event_name }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
INPUT_TAG: ${{ inputs.tag }}
run: |
if [[ "$EVENT_NAME" == "release" ]]; then
TAG="$RELEASE_TAG"
else
TAG="$INPUT_TAG"
fi
if [[ -z "$TAG" ]]; then
echo "::error::No tag provided"
exit 1
fi
# Validate tag format (v-prefixed semver, e.g. v25.3.0 or v1.2.3-beta.1)
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
echo "::error::Invalid tag format: $TAG (expected v-prefixed semver, e.g. v25.3.0)"
exit 1
fi
VERSION="${TAG#v}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Resolved tag=$TAG version=$VERSION"
- name: Verify release assets exist
env:
GH_TOKEN: ${{ github.token }}
run: |
TAG="${{ steps.resolve_version.outputs.tag }}"
echo "Checking release assets for tag $TAG..."
ASSETS=$(gh api "repos/${{ github.repository }}/releases/tags/$TAG" --jq '.assets[].name')
echo "Found assets:"
echo "$ASSETS"
if ! echo "$ASSETS" | grep -qx "Actual-linux-x86_64.AppImage"; then
echo "::error::Missing asset: Actual-linux-x86_64.AppImage"
exit 1
fi
if ! echo "$ASSETS" | grep -qx "Actual-linux-arm64.AppImage"; then
echo "::error::Missing asset: Actual-linux-arm64.AppImage"
exit 1
fi
echo "All required AppImage assets found."
- name: Calculate AppImage SHA256 (streamed)
run: |
VERSION="${{ steps.resolve_version.outputs.version }}"
BASE_URL="https://github.com/${{ github.repository }}/releases/download/v${VERSION}"
echo "Streaming x86_64 AppImage to compute SHA256..."
APPIMAGE_X64_SHA256=$(curl -fSL "${BASE_URL}/Actual-linux-x86_64.AppImage" | sha256sum | awk '{ print $1 }')
echo "x86_64 SHA256: $APPIMAGE_X64_SHA256"
echo "Streaming arm64 AppImage to compute SHA256..."
APPIMAGE_ARM64_SHA256=$(curl -fSL "${BASE_URL}/Actual-linux-arm64.AppImage" | sha256sum | awk '{ print $1 }')
echo "arm64 SHA256: $APPIMAGE_ARM64_SHA256"
echo "APPIMAGE_X64_SHA256=$APPIMAGE_X64_SHA256" >> "$GITHUB_ENV"
echo "APPIMAGE_ARM64_SHA256=$APPIMAGE_ARM64_SHA256" >> "$GITHUB_ENV"
- name: Checkout Flathub repo
uses: actions/checkout@v6
with:
repository: flathub/com.actualbudget.actual
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
- name: Update manifest with new version
run: |
VERSION="${{ steps.resolve_version.outputs.version }}"
# Replace x86_64 entry
sed -i "/x86_64.AppImage/{n;s|sha256:.*|sha256: ${{ env.APPIMAGE_X64_SHA256 }}|}" com.actualbudget.actual.yml
sed -i "/x86_64.AppImage/s|url:.*|url: https://github.com/actualbudget/actual/releases/download/v${VERSION}/Actual-linux-x86_64.AppImage|" com.actualbudget.actual.yml
# Replace arm64 entry
sed -i "/arm64.AppImage/{n;s|sha256:.*|sha256: ${{ env.APPIMAGE_ARM64_SHA256 }}|}" com.actualbudget.actual.yml
sed -i "/arm64.AppImage/s|url:.*|url: https://github.com/actualbudget/actual/releases/download/v${VERSION}/Actual-linux-arm64.AppImage|" com.actualbudget.actual.yml
echo "Updated manifest:"
cat com.actualbudget.actual.yml
- name: Create PR in Flathub repo
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
commit-message: 'Update Actual flatpak to version ${{ steps.resolve_version.outputs.version }}'
branch: 'release/${{ steps.resolve_version.outputs.version }}'
title: 'Update Actual flatpak to version ${{ steps.resolve_version.outputs.version }}'
body: |
This PR updates the Actual desktop flatpak to version ${{ steps.resolve_version.outputs.version }}.
:link: [View release notes](https://actualbudget.org/blog/release-${{ steps.resolve_version.outputs.version }})
reviewers: 'jfdoming,MatissJanis,youngcw'

View File

@@ -39,6 +39,9 @@ jobs:
source .venv/bin/activate
python3 -m pip install setuptools
- name: Set up environment
uses: ./.github/actions/setup
- if: ${{ startsWith(matrix.os, 'ubuntu') }}
name: Setup Flatpak dependencies
run: |
@@ -53,16 +56,14 @@ jobs:
METAINFO_FILE="packages/desktop-electron/extra-resources/linux/com.actualbudget.actual.metainfo.xml"
TODAY=$(date +%Y-%m-%d)
VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-electron/package.json --type nightly)
VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/desktop-electron/package.json --type nightly)
sed -i "s/%RELEASE_VERSION%/$VERSION/g; s/%RELEASE_DATE%/$TODAY/g" "$METAINFO_FILE"
flatpak run --command=flatpak-builder-lint org.flatpak.Builder appstream "$METAINFO_FILE"
- name: Set up environment
uses: ./.github/actions/setup
- name: Update package versions
run: |
# Get new nightly version
NEW_DESKTOP_APP_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-electron/package.json --type nightly)
NEW_DESKTOP_APP_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/desktop-electron/package.json --type nightly)
# Set package version
npm version $NEW_DESKTOP_APP_VERSION --no-git-tag-version --workspace=desktop-electron --no-workspaces-update

View File

@@ -20,19 +20,27 @@ jobs:
- name: Update package versions
run: |
# Get new nightly versions
NEW_WEB_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-client/package.json --type nightly)
NEW_SYNC_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/sync-server/package.json --type nightly)
NEW_API_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/api/package.json --type nightly)
NEW_CORE_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/loot-core/package.json --type nightly)
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: |
yarn install
- name: Pack the core package
run: |
yarn workspace @actual-app/core pack --filename @actual-app/core.tgz
- name: Build Server & Web
run: yarn build:server
@@ -48,14 +56,23 @@ 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
with:
name: npm-packages
path: |
packages/loot-core/@actual-app/core.tgz
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
@@ -76,6 +93,12 @@ jobs:
node-version: 22
registry-url: 'https://registry.npmjs.org'
- name: Publish Core
run: |
npm publish loot-core/@actual-app/core.tgz --access public --tag nightly
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish Web
run: |
npm publish desktop-client/@actual-app/web.tgz --access public --tag nightly
@@ -93,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

@@ -16,6 +16,10 @@ jobs:
- name: Set up environment
uses: ./.github/actions/setup
- name: Pack the core package
run: |
yarn workspace @actual-app/core pack --filename @actual-app/core.tgz
- name: Build Web
run: yarn build:server
@@ -31,14 +35,23 @@ 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
with:
name: npm-packages
path: |
packages/loot-core/@actual-app/core.tgz
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
@@ -59,6 +72,12 @@ jobs:
node-version: 22
registry-url: 'https://registry.npmjs.org'
- name: Publish Core
run: |
npm publish loot-core/@actual-app/core.tgz --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish Web
run: |
npm publish desktop-client/@actual-app/web.tgz --access public
@@ -76,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

@@ -57,6 +57,13 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: api
ref: ${{github.base_ref}}
- name: Wait for ${{github.base_ref}} CLI build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
id: master-cli-build
with:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: cli
ref: ${{github.base_ref}}
- name: Wait for PR build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
@@ -72,9 +79,16 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: api
ref: ${{github.event.pull_request.head.sha}}
- name: Wait for CLI PR build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
id: wait-for-cli-build
with:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: cli
ref: ${{github.event.pull_request.head.sha}}
- name: Report build failure
if: steps.wait-for-web-build.outputs.conclusion == 'failure' || steps.wait-for-api-build.outputs.conclusion == 'failure'
if: steps.wait-for-web-build.outputs.conclusion == 'failure' || steps.wait-for-api-build.outputs.conclusion == 'failure' || steps.wait-for-cli-build.outputs.conclusion == 'failure'
run: |
echo "Build failed on PR branch or ${{github.base_ref}}"
exit 1
@@ -115,6 +129,23 @@ jobs:
name: api-build-stats
path: head
allow_forks: true
- name: Download CLI build artifact from ${{github.base_ref}}
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
with:
branch: ${{github.base_ref}}
workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: cli-build-stats
path: base
- name: Download CLI stats from PR
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
with:
pr: ${{github.event.pull_request.number}}
workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: cli-build-stats
path: head
allow_forks: true
- name: Strip content hashes from stats files
run: |
if [ -f ./head/web-stats.json ]; then
@@ -136,9 +167,11 @@ jobs:
--base desktop-client=./base/web-stats.json \
--base loot-core=./base/loot-core-stats.json \
--base api=./base/api-stats.json \
--base cli=./base/cli-stats.json \
--head desktop-client=./head/web-stats.json \
--head loot-core=./head/loot-core-stats.json \
--head api=./head/api-stats.json \
--head cli=./head/cli-stats.json \
--identifier combined \
--format pr-body > bundle-stats-comment.md
- name: Post combined bundle stats comment

9
.gitignore vendored
View File

@@ -33,7 +33,9 @@ packages/desktop-electron/dist
packages/desktop-electron/loot-core
packages/desktop-client/service-worker
packages/plugins-service/dist
packages/component-library/dist
packages/loot-core/lib-dist
**/.tsbuildinfo
packages/sync-server/coverage
bundle.desktop.js
bundle.desktop.js.map
@@ -79,3 +81,10 @@ build/
*storybook.log
storybook-static
# cli config when testing locally
.actualrc.json
.actualrc
.actualrc.yaml
.actualrc.yml
actual.config.js

13
.husky/post-checkout Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/sh
# Run yarn install when switching branches (if yarn.lock changed)
# $3 is 1 for branch checkout, 0 for file checkout
if [ "$3" != "1" ]; then
exit 0
fi
# Check if yarn.lock changed between the old and new HEAD
if git diff --name-only "$1" "$2" | grep -q "^yarn.lock$"; then
echo "yarn.lock changed — running yarn install..."
yarn install
fi

View File

@@ -10,7 +10,7 @@
"builtin",
"external",
"loot-core",
"parent",
["parent", "subpath"],
"sibling",
"index",
"desktop-client"
@@ -22,7 +22,7 @@
},
{
"groupName": "loot-core",
"elementNamePattern": ["loot-core/**"]
"elementNamePattern": ["loot-core/**", "@actual-app/core/**"]
},
{
"groupName": "desktop-client",

View File

@@ -101,8 +101,18 @@
"typescript/no-var-requires": "error",
// we want to allow unions such as "{ name: DbAccount['name'] | DbPayee['name'] }"
"typescript/no-duplicate-type-constituents": "off",
// we want to allow unions such as "string | 'network' | 'file-key-mismatch'"
"typescript/no-redundant-type-constituents": "off",
"typescript/await-thenable": "error",
"typescript/no-floating-promises": "warn", // TODO: covert to error
"typescript/no-floating-promises": "error",
"typescript/require-array-sort-compare": "error",
"typescript/unbound-method": "error",
"typescript/no-for-in-array": "error",
"typescript/restrict-template-expressions": "error",
"typescript/no-misused-spread": "warn", // TODO: enable this
"typescript/no-base-to-string": "warn", // TODO: enable this
"typescript/no-unsafe-unary-minus": "warn", // TODO: enable this
"typescript/no-unsafe-type-assertion": "warn", // TODO: enable this
// Import rules
"import/consistent-type-specifier-style": "error",

View File

@@ -44,25 +44,9 @@ yarn start:desktop
### ⚠️ CRITICAL REQUIREMENT: AI-Generated Commit Messages and PR Titles
**THIS IS A MANDATORY REQUIREMENT THAT MUST BE FOLLOWED WITHOUT EXCEPTION:**
**ALL commit messages and PR titles MUST be prefixed with `[AI]`.** No exceptions.
- **ALL commit messages MUST be prefixed with `[AI]`**
- **ALL pull request titles MUST be prefixed with `[AI]`**
**Examples:**
-`[AI] Fix type error in account validation`
-`[AI] Add support for new transaction categories`
-`Fix type error in account validation` (MISSING PREFIX - NOT ALLOWED)
-`Add support for new transaction categories` (MISSING PREFIX - NOT ALLOWED)
**This requirement applies to:**
- Every single commit message created by AI agents
- Every single pull request title created by AI agents
- No exceptions are permitted
**This is a hard requirement that agents MUST follow. Failure to include the `[AI]` prefix is a violation of these instructions.**
See [PR and Commit Rules](.github/agents/pr-and-commit-rules.md) for the full specification, including git safety rules, pre-commit checklist, and PR workflow.
### Task Orchestration with Lage
@@ -100,7 +84,7 @@ The core application logic that runs on any platform.
```bash
# Run all loot-core tests
yarn workspace loot-core run test
yarn workspace @actual-app/core run test
# Or run tests across all packages using lage
yarn test
@@ -235,7 +219,7 @@ yarn test
yarn test:debug
# Run tests for a specific package
yarn workspace loot-core run test
yarn workspace @actual-app/core run test
```
**E2E Tests (Playwright)**
@@ -314,6 +298,7 @@ Always run `yarn typecheck` before committing.
**React Patterns:**
- The project uses **React Compiler** (`babel-plugin-react-compiler`) in the desktop-client. The compiler auto-memoizes component bodies, so you can omit manual `useCallback`, `useMemo`, and `React.memo` when adding or refactoring code; prefer inline callbacks and values unless a stable identity is required by a non-compiled dependency.
- Don't use `React.FunctionComponent` or `React.FC` - type props directly
- Don't use `React.*` patterns - use named imports instead
- Use `<Link>` instead of `<a>` tags
@@ -360,13 +345,7 @@ Always maintain newlines between import groups.
**Git Commands:**
- **MANDATORY: ALL commit messages MUST be prefixed with `[AI]`** - This is a hard requirement with no exceptions
- **MANDATORY: ALL pull request titles MUST be prefixed with `[AI]`** - This is a hard requirement with no exceptions
- Never update git config
- Never run destructive git operations (force push, hard reset) unless explicitly requested
- Never skip hooks (--no-verify, --no-gpg-sign)
- Never force push to main/master
- Never commit unless explicitly asked
See [PR and Commit Rules](.github/agents/pr-and-commit-rules.md) for complete git safety rules, commit message requirements, and PR workflow.
## File Structure Patterns
@@ -565,7 +544,7 @@ Icons in `packages/component-library/src/icons/` are auto-generated. Don't manua
Before committing changes, ensure:
- [ ] **MANDATORY: Commit message is prefixed with `[AI]`** - This is a hard requirement with no exceptions
- [ ] Commit and PR rules followed (see [PR and Commit Rules](.github/agents/pr-and-commit-rules.md))
- [ ] `yarn typecheck` passes
- [ ] `yarn lint:fix` has been run
- [ ] Relevant tests pass
@@ -578,17 +557,7 @@ Before committing changes, ensure:
## Pull Request Guidelines
When creating pull requests:
- **MANDATORY PREFIX REQUIREMENT**: **ALL pull request titles MUST be prefixed with `[AI]`** - This is a hard requirement that MUST be followed without exception
- ✅ Correct: `[AI] Fix type error in account validation`
- ❌ Incorrect: `Fix type error in account validation` (MISSING PREFIX - NOT ALLOWED)
- **AI-Generated PRs**: If you create a PR using AI assistance, add the **"AI generated"** label to the pull request. This helps maintainers understand the nature of the contribution.
### PR Template: Do Not Fill In
- **NEVER fill in the PR template** (`.github/PULL_REQUEST_TEMPLATE.md`). Leave all blank spaces and placeholder comments as-is. We expect **humans** to fill in the Description, Related issue(s), Testing, and Checklist sections.
- **Exception**: If a human **explicitly asks** you to fill out the PR template, then fill it out **in Chinese**, using Chinese characters (简体中文) for all content you add.
See [PR and Commit Rules](.github/agents/pr-and-commit-rules.md) for complete PR creation rules, including title prefix requirements, labeling, and PR template handling.
## Code Review Guidelines
@@ -656,7 +625,7 @@ Standard commands documented in `package.json` scripts and the Quick Start secti
- `yarn lint` / `yarn lint:fix` (uses oxlint + oxfmt)
- `yarn test` (lage across all workspaces)
- `yarn typecheck` (tsc + lage typecheck)
- `yarn typecheck` (tsgo + lage typecheck)
### Testing and previewing the app

2
CLAUDE.md Normal file
View File

@@ -0,0 +1,2 @@
@AGENTS.md
@.github/agents/pr-and-commit-rules.md

View File

@@ -17,7 +17,7 @@ packages/desktop-client/bin/remove-untranslated-languages
export NODE_OPTIONS="--max-old-space-size=4096"
yarn workspace plugins-service build
yarn workspace loot-core build:browser
yarn workspace @actual-app/core build:browser
yarn workspace @actual-app/web build:browser
echo "packages/desktop-client/build"

View File

@@ -51,14 +51,17 @@ fi
export NODE_OPTIONS="--max-old-space-size=4096"
yarn workspace plugins-service build
yarn workspace loot-core build:node
yarn workspace @actual-app/core build:node
yarn workspace @actual-app/web build --mode=desktop # electron specific build
# required for running the sync-server server
yarn workspace loot-core build:browser
yarn workspace @actual-app/core build:browser
yarn workspace @actual-app/web build:browser
yarn workspace @actual-app/sync-server build
# Emit @actual-app/core declarations so desktop-electron (which includes typings/window.ts) can build
yarn workspace @actual-app/core exec tsgo -p tsconfig.json
yarn workspace desktop-electron update-client
(

View File

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

View File

@@ -25,21 +25,23 @@
"start:desktop": "yarn desktop-dependencies && npm-run-all --parallel 'start:desktop-*'",
"start:docs": "yarn workspace docs start",
"desktop-dependencies": "npm-run-all --parallel rebuild-electron build:browser-backend build:plugins-service",
"start:desktop-node": "yarn workspace loot-core watch:node",
"start:desktop-node": "yarn workspace @actual-app/core watch:node",
"start:desktop-client": "yarn workspace @actual-app/web watch",
"start:desktop-server-client": "yarn workspace @actual-app/web build:browser",
"start:desktop-electron": "yarn workspace desktop-electron watch",
"start:browser": "yarn workspace plugins-service build-dev && npm-run-all --parallel 'start:browser-*'",
"start:service-plugins": "yarn workspace plugins-service watch",
"start:browser-backend": "yarn workspace loot-core watch:browser",
"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:browser-backend": "yarn workspace loot-core build:browser",
"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",
@@ -53,11 +55,12 @@
"vrt": "yarn workspace @actual-app/web run vrt",
"vrt:docker": "./bin/run-vrt",
"rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/loot-core",
"rebuild-node": "yarn workspace loot-core rebuild",
"lint": "oxfmt --check . && oxlint --type-aware",
"lint:fix": "oxfmt . && oxlint --fix --type-aware",
"rebuild-node": "yarn workspace @actual-app/core rebuild",
"lint": "oxfmt --check . && oxlint --type-aware --quiet",
"lint:fix": "oxfmt . && oxlint --fix --type-aware --quiet",
"install:server": "yarn workspaces focus @actual-app/sync-server --production",
"typecheck": "tsc -p tsconfig.root.json --noEmit && lage typecheck",
"constraints": "yarn constraints",
"typecheck": "tsgo -p tsconfig.root.json --noEmit && lage typecheck",
"jq": "./node_modules/node-jq/bin/jq",
"prepare": "husky"
},
@@ -65,6 +68,8 @@
"@octokit/rest": "^22.0.1",
"@types/node": "^22.19.10",
"@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",
"cross-env": "^10.1.0",
"eslint": "^9.39.2",
@@ -87,6 +92,7 @@
"typescript": "^5.9.3"
},
"resolutions": {
"adm-zip": "patch:adm-zip@npm%3A0.5.16#~/.yarn/patches/adm-zip-npm-0.5.16-4556fea098.patch",
"rollup": "4.40.1",
"socks": ">=2.8.3"
},
@@ -95,7 +101,7 @@
"oxfmt --no-error-on-unmatched-pattern"
],
"*.{js,mjs,jsx,ts,tsx}": [
"oxlint --fix --type-aware"
"oxlint --fix --type-aware --quiet"
]
},
"browserslist": [

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

@@ -3,26 +3,18 @@ import type {
RequestInit as FetchInit,
} from 'node-fetch';
// loot-core types
import type { InitConfig } from 'loot-core/server/main';
import { init as initLootCore } from '@actual-app/core/server/main';
import type { InitConfig, lib } from '@actual-app/core/server/main';
// oxlint-disable-next-line typescript/ban-ts-comment
// @ts-ignore: bundle not available until we build it
import * as bundle from './app/bundle.api.js';
import * as injected from './injected';
import { validateNodeVersion } from './validateNodeVersion';
let actualApp: null | typeof bundle.lib;
export const internal = bundle.lib;
export * from './methods';
export * as utils from './utils';
export async function init(config: InitConfig = {}) {
if (actualApp) {
return;
}
/** @deprecated Please use return value of `init` instead */
export let internal: typeof lib | null = null;
export async function init(config: InitConfig = {}) {
validateNodeVersion();
if (!globalThis.fetch) {
@@ -33,21 +25,19 @@ export async function init(config: InitConfig = {}) {
};
}
await bundle.init(config);
actualApp = bundle.lib;
injected.override(bundle.lib.send);
return bundle.lib;
internal = await initLootCore(config);
return internal;
}
export async function shutdown() {
if (actualApp) {
if (internal) {
try {
await actualApp.send('sync');
await internal.send('sync');
} catch {
// most likely that no budget is loaded, so the sync failed
}
await actualApp.send('close-budget');
actualApp = null;
await internal.send('close-budget');
internal = null;
}
}

View File

@@ -1,7 +0,0 @@
// TODO: comment on why it works this way
export let send;
export function override(sendImplementation) {
send = sendImplementation;
}

View File

@@ -1,10 +1,29 @@
import * as fs from 'fs/promises';
import * as path from 'path';
import type { RuleEntity } from 'loot-core/types/models';
import { vi } from 'vitest';
import type { RuleEntity } from '@actual-app/core/types/models';
import * as api from './index';
// In tests we run from source; loot-core's API fs uses __dirname (for the built dist/).
// Mock the fs so path constants point at loot-core package root where migrations live.
vi.mock(
'../loot-core/src/platform/server/fs/index.api',
async importOriginal => {
const actual = (await importOriginal()) as Record<string, unknown>;
const pathMod = await import('path');
const lootCoreRoot = pathMod.join(__dirname, '..', 'loot-core');
return {
...actual,
migrationsPath: pathMod.join(lootCoreRoot, 'migrations'),
bundledDatabasePath: pathMod.join(lootCoreRoot, 'default-db.sqlite'),
demoBudgetPath: pathMod.join(lootCoreRoot, 'demo-budget'),
};
},
);
const budgetName = 'test-budget';
global.IS_TESTING = true;

View File

@@ -6,16 +6,16 @@ import type {
APIPayeeEntity,
APIScheduleEntity,
APITagEntity,
} from 'loot-core/server/api-models';
import type { Query } from 'loot-core/shared/query';
import type { Handlers } from 'loot-core/types/handlers';
} from '@actual-app/core/server/api-models';
import { lib } from '@actual-app/core/server/main';
import type { Query } from '@actual-app/core/shared/query';
import type { ImportTransactionsOpts } from '@actual-app/core/types/api-handlers';
import type { Handlers } from '@actual-app/core/types/handlers';
import type {
ImportTransactionEntity,
RuleEntity,
TransactionEntity,
} from 'loot-core/types/models';
import * as injected from './injected';
} from '@actual-app/core/types/models';
export { q } from './app/query';
@@ -23,7 +23,7 @@ function send<K extends keyof Handlers, T extends Handlers[K]>(
name: K,
args?: Parameters<T>[0],
): Promise<Awaited<ReturnType<T>>> {
return injected.send(name, args);
return lib.send(name, args);
}
export async function runImport(
@@ -126,11 +126,6 @@ export function addTransactions(
});
}
export type ImportTransactionsOpts = {
defaultCleared?: boolean;
dryRun?: boolean;
};
export function importTransactions(
accountId: APIAccountEntity['id'],
transactions: ImportTransactionEntity[],

View File

@@ -9,29 +9,41 @@
],
"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:app": "yarn workspace loot-core build:api",
"build:crdt": "yarn workspace @actual-app/crdt build",
"build:node": "tsc && tsc-alias",
"build:migrations": "cp migrations/*.sql dist/migrations",
"build:default-db": "cp default-db.sqlite dist/",
"build": "yarn run clean && yarn run build:app && yarn run build:node && yarn run build:migrations && yarn run build:default-db",
"test": "yarn run clean && yarn run build:app && yarn run build:crdt && vitest --run",
"clean": "rm -rf dist @types",
"typecheck": "yarn build && tsc --noEmit && tsc-strict"
"build": "vite build",
"test": "vitest --run",
"typecheck": "tsgo -b && tsc-strict"
},
"dependencies": {
"@actual-app/crdt": "workspace:^",
"@actual-app/core": "workspace:*",
"@actual-app/crdt": "workspace:*",
"better-sqlite3": "^12.6.2",
"compare-versions": "^6.1.1",
"node-fetch": "^3.3.2",
"uuid": "^13.0.0"
},
"devDependencies": {
"tsc-alias": "^1.8.16",
"typescript": "^5.9.3",
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
"rollup-plugin-visualizer": "^6.0.5",
"typescript-strict-plugin": "^2.4.4",
"vitest": "^4.0.18"
"vite": "^8.0.0",
"vite-plugin-dts": "^4.5.4",
"vite-plugin-peggy-loader": "^2.0.1",
"vitest": "^4.1.0"
},
"engines": {
"node": ">=20"

View File

@@ -1,21 +1,22 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
// Using ES2021 because that's the newest version where
// the latest Node 16.x release supports all of the features
"target": "ES2021",
"module": "CommonJS",
"moduleResolution": "node10",
"module": "es2022",
"moduleResolution": "bundler",
"noEmit": false,
"declaration": true,
"declarationMap": true,
"outDir": "dist",
"rootDir": ".",
"declarationDir": "@types",
"paths": {
"loot-core/*": ["./@types/loot-core/src/*"]
},
"tsBuildInfoFile": "dist/.tsbuildinfo",
"plugins": [{ "name": "typescript-strict-plugin", "paths": ["."] }]
},
"references": [{ "path": "../crdt" }, { "path": "../loot-core" }],
"include": ["."],
"exclude": ["**/node_modules/*", "dist", "@types", "*.test.ts"]
"exclude": ["**/node_modules/*", "dist", "@types", "*.test.ts", "*.config.ts"]
}

2
packages/api/typings.ts Normal file
View File

@@ -0,0 +1,2 @@
declare module 'hyperformula/i18n/languages/enUS';
declare module '*.pegjs';

View File

@@ -1,6 +1,4 @@
// oxlint-disable-next-line typescript/ban-ts-comment
// @ts-ignore: bundle not available until we build it
import * as bundle from './app/bundle.api.js';
import { lib } from '@actual-app/core/server/main';
export const amountToInteger = bundle.lib.amountToInteger;
export const integerToAmount = bundle.lib.integerToAmount;
export const amountToInteger = lib.amountToInteger;
export const integerToAmount = lib.integerToAmount;

View File

@@ -0,0 +1,93 @@
import fs from 'fs';
import path from 'path';
import { visualizer } from 'rollup-plugin-visualizer';
import { defineConfig } from 'vite';
import dts from 'vite-plugin-dts';
import peggyLoader from 'vite-plugin-peggy-loader';
const lootCoreRoot = path.resolve(__dirname, '../loot-core');
const distDir = path.resolve(__dirname, 'dist');
const typesDir = path.resolve(__dirname, '@types');
function cleanOutputDirs() {
return {
name: 'clean-output-dirs',
buildStart() {
if (fs.existsSync(distDir)) fs.rmSync(distDir, { recursive: true });
if (fs.existsSync(typesDir)) fs.rmSync(typesDir, { recursive: true });
},
};
}
function copyMigrationsAndDefaultDb() {
return {
name: 'copy-migrations-and-default-db',
closeBundle() {
const migrationsSrc = path.join(lootCoreRoot, 'migrations');
const defaultDbPath = path.join(lootCoreRoot, 'default-db.sqlite');
if (!fs.existsSync(migrationsSrc)) {
throw new Error(`migrations directory not found at ${migrationsSrc}`);
}
const migrationsStat = fs.statSync(migrationsSrc);
if (!migrationsStat.isDirectory()) {
throw new Error(`migrations path is not a directory: ${migrationsSrc}`);
}
const migrationsDest = path.join(distDir, 'migrations');
fs.mkdirSync(migrationsDest, { recursive: true });
for (const name of fs.readdirSync(migrationsSrc)) {
if (name.endsWith('.sql') || name.endsWith('.js')) {
fs.copyFileSync(
path.join(migrationsSrc, name),
path.join(migrationsDest, name),
);
}
}
if (!fs.existsSync(defaultDbPath)) {
throw new Error(`default-db.sqlite not found at ${defaultDbPath}`);
}
fs.copyFileSync(defaultDbPath, path.join(distDir, 'default-db.sqlite'));
},
};
}
export default defineConfig({
ssr: { noExternal: true, external: ['better-sqlite3'] },
build: {
ssr: true,
target: 'node20',
outDir: distDir,
emptyOutDir: true,
sourcemap: true,
lib: {
entry: path.resolve(__dirname, 'index.ts'),
formats: ['cjs'],
fileName: () => 'index.js',
},
},
plugins: [
cleanOutputDirs(),
peggyLoader(),
dts({
tsconfigPath: path.resolve(__dirname, 'tsconfig.json'),
outDir: path.resolve(__dirname, '@types'),
rollupTypes: true,
}),
copyMigrationsAndDefaultDb(),
visualizer({ template: 'raw-data', filename: 'app/stats.json' }),
],
resolve: {
extensions: ['.api.ts', '.js', '.ts', '.tsx', '.json'],
},
test: {
globals: true,
onConsoleLog(log: string, type: 'stdout' | 'stderr'): boolean | void {
// print only console.error
return type === 'stderr';
},
maxWorkers: 2,
},
});

View File

@@ -1,10 +0,0 @@
export default {
test: {
globals: true,
onConsoleLog(log: string, type: 'stdout' | 'stderr'): boolean | void {
// print only console.error
return type === 'stderr';
},
maxWorkers: 2,
},
};

1
packages/ci-actions/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
dist/*

View File

@@ -1,14 +1,14 @@
#!/usr/bin/env node
// overview:
// 1. Identify the migrations in packages/loot-core/migrations/* on `master` and HEAD
// 2. Make sure that any new migrations on HEAD are dated after the latest migration on `master`.
const { spawnSync } = require('child_process');
const path = require('path');
import { spawnSync } from 'child_process';
import path from 'path';
import { fileURLToPath } from 'url';
const migrationsDir = path.join(
__dirname,
path.dirname(fileURLToPath(import.meta.url)),
'..',
'..',
'..',
'packages',
@@ -16,7 +16,7 @@ const migrationsDir = path.join(
'migrations',
);
function readMigrations(ref) {
function readMigrations(ref: string) {
const { stdout } = spawnSync('git', [
'ls-tree',
'--name-only',

View File

@@ -2,13 +2,13 @@
// This script is used in GitHub Actions to get the next version based on the current package.json version.
// It supports three types of versioning: nightly, hotfix, and monthly.
import fs from 'node:fs';
import { parseArgs } from 'node:util';
import { getNextVersion } from '../src/versions/get-next-package-version.js';
const args = process.argv;
import {
getNextVersion,
isValidVersionType,
} from '../src/versions/get-next-package-version';
const options = {
'package-json': {
@@ -28,40 +28,53 @@ const options = {
short: 'u',
default: false,
},
};
} as const;
function fail(message: string): never {
console.error(message);
process.exit(1);
}
const { values } = parseArgs({
args,
options,
allowPositionals: true,
});
if (!values['package-json']) {
console.error(
const packageJsonPath = values['package-json'];
if (!packageJsonPath) {
fail(
'Please specify the path to package.json using --package-json or -p option.',
);
process.exit(1);
}
try {
const packageJsonPath = values['package-json'];
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
if (!('version' in packageJson) || typeof packageJson.version !== 'string') {
fail('The specified package.json does not contain a valid version field.');
}
const currentVersion = packageJson.version;
const explicitVersion = values.version;
let newVersion;
if (explicitVersion) {
newVersion = explicitVersion;
} else {
const type = values.type;
if (!type || !isValidVersionType(type)) {
fail('Please specify the release type using --type or -t.');
}
try {
newVersion = getNextVersion({
currentVersion,
type: values.type,
type,
currentDate: new Date(),
});
} catch (e) {
console.error(e.message);
process.exit(1);
} catch (error) {
fail(error instanceof Error ? error.message : String(error));
}
}
@@ -76,6 +89,5 @@ try {
);
}
} catch (error) {
console.error('Error:', error.message);
process.exit(1);
fail(`Error: ${error instanceof Error ? error.message : String(error)}`);
}

8
packages/ci-actions/bin/tsx Executable file
View File

@@ -0,0 +1,8 @@
#!/bin/bash
set -euo pipefail
cd ../../
script="$1"
shift
exec node --import=extensionless/register --experimental-strip-types packages/ci-actions/"$script" "$@"

View File

@@ -3,9 +3,18 @@
"private": true,
"type": "module",
"scripts": {
"test": "vitest --run"
"tsx": "bin/tsx",
"test": "vitest --run",
"typecheck": "tsgo -b"
},
"devDependencies": {
"vitest": "^4.0.18"
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
"extensionless": "^2.0.6",
"vitest": "^4.1.0"
},
"extensionless": {
"lookFor": [
"ts"
]
}
}

View File

@@ -77,7 +77,7 @@ describe('getNextVersion (lib)', () => {
expect(() =>
getNextVersion({
currentVersion: '25.8.4',
type: 'unknown',
type: 'unknown' as never,
currentDate: new Date('2025-08-10'),
}),
).toThrow(/Invalid type/);

View File

@@ -1,35 +1,69 @@
function parseVersion(version) {
export const versionTypeArray = [
'auto',
'hotfix',
'monthly',
'nightly',
] as const;
export type VersionType = (typeof versionTypeArray)[number];
type ParsedVersion = {
versionYear: number;
versionMonth: number;
versionHotfix: number;
};
type GetNextVersionOptions = {
currentVersion: string;
type: VersionType;
currentDate?: Date;
};
function parseVersion(version: string): ParsedVersion {
const [y, m, p] = version.split('.');
return {
versionYear: parseInt(y, 10),
versionMonth: parseInt(m, 10),
versionHotfix: parseInt(p, 10),
versionYear: Number.parseInt(y, 10),
versionMonth: Number.parseInt(m, 10),
versionHotfix: Number.parseInt(p, 10),
};
}
function computeNextMonth(versionYear, versionMonth) {
// Create date and add 1 month
const versionDate = new Date(2000 + versionYear, versionMonth - 1, 1); // month is 0-indexed
function computeNextMonth(versionYear: number, versionMonth: number) {
const versionDate = new Date(2000 + versionYear, versionMonth - 1, 1);
const nextVersionMonthDate = new Date(
versionDate.getFullYear(),
versionDate.getMonth() + 1,
1,
);
// Format back to YY.M format
const fullYear = nextVersionMonthDate.getFullYear();
const nextVersionYear = fullYear.toString().slice(fullYear < 2100 ? -2 : -3);
const nextVersionMonth = nextVersionMonthDate.getMonth() + 1; // Convert back to 1-indexed
const nextVersionMonth = nextVersionMonthDate.getMonth() + 1;
return { nextVersionYear, nextVersionMonth };
}
// Determine logical type from 'auto' based on the current date and version
function resolveType(type, currentDate, versionYear, versionMonth) {
if (type !== 'auto') return type;
export function isValidVersionType(value: string): value is VersionType {
return versionTypeArray.includes(value as VersionType);
}
function resolveType(
type: VersionType,
currentDate: Date,
versionYear: number,
versionMonth: number,
) {
if (type !== 'auto') {
return type;
}
const inPatchMonth =
currentDate.getFullYear() === 2000 + versionYear &&
currentDate.getMonth() + 1 === versionMonth;
if (inPatchMonth && currentDate.getDate() <= 25) return 'hotfix';
if (inPatchMonth && currentDate.getDate() <= 25) {
return 'hotfix';
}
return 'monthly';
}
@@ -37,7 +71,7 @@ export function getNextVersion({
currentVersion,
type,
currentDate = new Date(),
}) {
}: GetNextVersionOptions) {
const { versionYear, versionMonth, versionHotfix } =
parseVersion(currentVersion);
const { nextVersionYear, nextVersionMonth } = computeNextMonth(
@@ -51,11 +85,10 @@ export function getNextVersion({
versionMonth,
);
// Format date stamp once for nightly
const currentDateString = currentDate
.toISOString()
.split('T')[0]
.replaceAll('-', '');
.replace(/-/g, '');
switch (resolvedType) {
case 'nightly':
@@ -66,7 +99,7 @@ export function getNextVersion({
return `${nextVersionYear}.${nextVersionMonth}.0`;
default:
throw new Error(
'Invalid type specified. Use "auto", "nightly", "hotfix", or "monthly".',
`Invalid type ${String(resolvedType satisfies never)} specified. Use "auto", "nightly", "hotfix", or "monthly".`,
);
}
}

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [],
"module": "es2022",
"moduleResolution": "bundler",
"skipLibCheck": true,
"strict": true,
"types": ["node"],
"outDir": "dist",
"rootDir": ".",
"composite": true
},
"include": ["src/**/*", "bin/**/*"],
"exclude": ["node_modules"]
}

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.10",
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
"rollup-plugin-visualizer": "^6.0.5",
"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

@@ -2,7 +2,7 @@ import { dirname } from 'path';
import { fileURLToPath } from 'url';
import type { StorybookConfig } from '@storybook/react-vite';
import viteTsconfigPaths from 'vite-tsconfig-paths';
import react from '@vitejs/plugin-react';
/**
* This function is used to resolve the absolute path of a package.
@@ -32,11 +32,9 @@ const config: StorybookConfig = {
const { mergeConfig } = await import('vite');
return mergeConfig(config, {
// Telling Vite how to resolve path aliases
plugins: [viteTsconfigPaths({ root: '../..' })],
esbuild: {
// Needed to handle JSX in .ts/.tsx files
jsx: 'automatic',
plugins: [react()],
resolve: {
tsconfigPaths: true,
},
});
},

View File

@@ -3,6 +3,7 @@ import { type ReactNode } from 'react';
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';

View File

@@ -40,7 +40,7 @@
"test:web": "ENV=web vitest --run -c vitest.web.config.ts",
"start:storybook": "storybook dev -p 6006",
"build:storybook": "storybook build",
"typecheck": "tsc --noEmit"
"typecheck": "tsgo -b"
},
"dependencies": {
"@emotion/css": "^11.13.5",
@@ -53,12 +53,15 @@
"@storybook/addon-docs": "^10.2.7",
"@storybook/react-vite": "^10.2.7",
"@svgr/cli": "^8.1.0",
"@types/react": "^19.2.5",
"@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",
"react": "19.2.4",
"react-dom": "19.2.4",
"storybook": "^10.2.7",
"vitest": "^4.0.18"
"vite": "^8.0.0",
"vitest": "^4.1.0"
},
"peerDependencies": {
"react": ">=19.2",

View File

@@ -4,7 +4,7 @@ import { css, cx } from '@emotion/css';
import type { CSSProperties } from './styles';
type BlockProps = HTMLProps<HTMLDivElement> & {
type BlockProps = Omit<HTMLProps<HTMLDivElement>, 'style'> & {
innerRef?: Ref<HTMLDivElement>;
style?: CSSProperties;
};

View File

@@ -16,8 +16,8 @@ import { View } from './View';
const MenuLine: unique symbol = Symbol('menu-line');
const MenuLabel: unique symbol = Symbol('menu-label');
Menu.line = MenuLine;
Menu.label = MenuLabel;
Menu.line = MenuLine as typeof MenuLine;
Menu.label = MenuLabel as typeof MenuLabel;
type KeybindingProps = {
keyName: ReactNode;

View File

@@ -4,7 +4,7 @@ import { css } from '@emotion/css';
import type { CSSProperties } from './styles';
type ParagraphProps = HTMLProps<HTMLDivElement> & {
type ParagraphProps = Omit<HTMLProps<HTMLDivElement>, 'style'> & {
style?: CSSProperties;
isLast?: boolean;
};

View File

@@ -5,7 +5,7 @@ import { css, cx } from '@emotion/css';
import type { CSSProperties } from './styles';
type TextProps = HTMLProps<HTMLSpanElement> & {
type TextProps = Omit<HTMLProps<HTMLSpanElement>, 'style'> & {
innerRef?: Ref<HTMLSpanElement>;
className?: string;
children?: ReactNode;

View File

@@ -5,7 +5,7 @@ import { css, cx } from '@emotion/css';
import type { CSSProperties } from './styles';
type ViewProps = HTMLProps<HTMLDivElement> & {
type ViewProps = Omit<HTMLProps<HTMLDivElement>, 'style'> & {
className?: string;
style?: CSSProperties;
nativeStyle?: CSSProperties;

View File

@@ -1,9 +1,14 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"composite": true,
"noEmit": false,
"declaration": true,
"declarationMap": true,
"rootDir": "src",
"strict": true
"strict": true,
"outDir": "dist",
"tsBuildInfoFile": "dist/.tsbuildinfo"
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules"]

View File

@@ -1,5 +1,6 @@
import path from 'path';
import react from '@vitejs/plugin-react';
import peggyLoader from 'vite-plugin-peggy-loader';
import { defineConfig } from 'vitest/config';
@@ -23,13 +24,7 @@ export default defineConfig({
maxWorkers: 2,
},
resolve: {
alias: [
{
find: /^@actual-app\/crdt(\/.*)?$/,
replacement: path.resolve('../../../crdt/src$1'),
},
],
extensions: resolveExtensions,
},
plugins: [peggyLoader()],
plugins: [react(), peggyLoader()],
});

View File

@@ -8,12 +8,23 @@
],
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": "./src/index.ts"
},
"publishConfig": {
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
}
},
"scripts": {
"build:node": "tsc",
"build:node": "tsgo",
"proto:generate": "./bin/generate-proto",
"build": "rm -rf dist && yarn run build:node",
"test": "vitest --run",
"typecheck": "tsc --noEmit"
"typecheck": "tsgo -b"
},
"dependencies": {
"google-protobuf": "^3.21.4",
@@ -22,9 +33,9 @@
},
"devDependencies": {
"@types/google-protobuf": "3.15.12",
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
"protoc-gen-js": "3.21.4-4",
"ts-protoc-gen": "0.15.0",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
"vitest": "^4.1.0"
}
}

View File

@@ -91,7 +91,7 @@ export function diff(trie1: TrieNode, trie2: TrieNode): number | null {
while (true) {
const keyset = new Set([...getKeys(node1), ...getKeys(node2)]);
const keys = [...keyset.values()];
keys.sort();
keys.sort((a, b) => a.localeCompare(b));
let diffkey: null | '0' | '1' | '2' = null;
@@ -145,7 +145,7 @@ export function prune(trie: TrieNode, n = 2): TrieNode {
}
const keys = getKeys(trie);
keys.sort();
keys.sort((a, b) => a.localeCompare(b));
const next: TrieNode = { hash: trie.hash };

View File

@@ -121,12 +121,12 @@ describe('Timestamp', function () {
it('should fail with counter overflow', function () {
now = 40;
for (let i = 0; i < 65536; i++) Timestamp.send();
expect(Timestamp.send).toThrow(Timestamp.OverflowError);
expect(() => Timestamp.send()).toThrow(Timestamp.OverflowError);
});
it('should fail with clock drift', function () {
now = -(5 * 60 * 1000 + 1);
expect(Timestamp.send).toThrow(Timestamp.ClockDriftError);
expect(() => Timestamp.send()).toThrow(Timestamp.ClockDriftError);
});
});

View File

@@ -1,6 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
// Using ES2021 because that's the newest version where
// the latest Node 16.x release supports all of the features
"target": "ES2021",
@@ -8,8 +9,10 @@
"moduleResolution": "node10",
"noEmit": false,
"declaration": true,
"declarationMap": true,
"strict": true,
"outDir": "dist"
"outDir": "dist",
"tsBuildInfoFile": "dist/.tsbuildinfo"
},
"include": ["."],
"exclude": ["dist", "**/*.test.ts", "**/*.spec.ts"]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 146 KiB

View File

@@ -61,7 +61,7 @@ export class ConfigurationPage {
break;
default:
throw new Error(`Unrecognized import type: ${type}`);
throw new Error(`Unrecognized import type: ${String(type)}`);
}
const fileChooser = await fileChooserPromise;

View File

@@ -39,7 +39,7 @@ export class CustomReportPage {
.click();
break;
default:
throw new Error(`Unrecognized mode: ${mode}`);
throw new Error(`Unrecognized mode: ${String(mode)}`);
}
}
}

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