Compare commits

..

144 Commits

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:03:40 +01:00
Matiss Janis Aboltins
f85627dcf6 [AI] Disable bundle minification for readable error messages (#7538)
* [AI] Disable bundle minification for readable production error messages

The desktop-client had dead terserOptions (no `minify: 'terser'` was set, so
Vite's default esbuild minifier ran with name mangling). The loot-core and
plugins-service workers used Terser with mangle:false but still compressed.
Set `minify: false` across all three browser build configs so production
stack traces are human-readable.

https://claude.ai/code/session_01VEywxebiNYAgJia35fygQx

* [AI] Rename release note to match PR number

https://claude.ai/code/session_01VEywxebiNYAgJia35fygQx

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-19 20:35:26 +00:00
Matt Fiddaman
695fd0e7e0 fix bank sync account linking modal being disabled when relinking existing accounts (#7487)
* fix link account modal button disabled

* note
2026-04-18 22:25:28 +00:00
Matiss Janis Aboltins
9682f6d8c9 ci: disable fail-fast for Electron build workflows (#7547)
* [AI] Disable fail-fast for Electron build matrices

Prevents cancellation of in-progress platform builds when one fails, so
Windows/macOS/Linux results are all visible on a single run.

* Add release notes for PR #7547

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-18 21:32:21 +00:00
Matiss Janis Aboltins
4b940423ee [AI] Persist custom CSS override across theme changes (#7495)
* [AI] Persist custom CSS overrides as a standalone global pref

Moves the custom CSS override out of the InstalledTheme JSON blob into
a dedicated customCssOverride global pref so that overrides survive
switching themes, clearing installed themes, or toggling auto/light/dark
mode. Includes a one-time migration that lifts the legacy overrideCss
field out of installedCustomLightTheme / installedCustomDarkTheme JSON.

- Add customCssOverride global pref (loot-core types + server defaults)
- Inject the override as a trailing style layer in CustomThemeStyle so
  it layers on top of any installed custom theme
- Drop overrideCss from the InstalledTheme type; extractLegacyOverride
  + migrateLegacyOverride handle the one-time lift with whitespace trim
- Run the migration from CustomThemeStyle with an idempotent effect that
  re-runs safely once prefs hydrate
- Bind the ThemeInstaller textarea directly to the new pref
- Add a "Custom CSS is active" indicator button next to the theme
  selector that opens the installer for editing the override without
  flipping auto mode to light
- Pre-switch out of auto when the user picks "Custom theme" from the
  main selector, so the flag that used to distinguish entry points goes
  away and handleInstall collapses to a pure slot dispatch
- Tests: hermetic Themes settings tests, expanded customThemes unit
  tests covering extraction/migration/trim edge cases, updated
  ThemeInstaller tests for the new pref binding

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Address review feedback for custom CSS override installer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Defer auto-mode theme switch and guard stale installer callbacks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 15:29:32 +00:00
Matiss Janis Aboltins
9fb6876f3b [AI] sync-server: use workspace reference for @actual-app/crdt (#7541)
* [AI] sync-server: use workspace reference for @actual-app/crdt

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Update build script in sync-server package to use TypeScript's build mode

* Package electron

* [AI] crdt: add conditional exports so Node can load the built bundle

Before this change the root exports entry pointed at `./src/index.ts`, so
any pure-Node consumer (notably the sync server that Electron forks as a
utility process) failed to import `@actual-app/crdt` — Node can't execute
TypeScript source directly. Sync-server had been masking this by pulling
`@actual-app/crdt@npm:2.1.0` where `publishConfig.exports` resolves to
`./dist/index.js`; once sync-server switched to `workspace:*`, the
Functional Desktop App CI job timed out waiting for the sync server to
boot.

Switch to conditional exports in the same shape `@actual-app/api` already
uses:

- `types` → `./dist/index.d.ts` for TypeScript tooling
- `development` → `./src/index.ts` for Vite/Vitest (HMR, fast feedback)
- `default` → `./dist/index.js` for Node runtime

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:27:34 +00:00
kenkuo
210422f61a [AI] Add 'Last 30 days' date range option to custom reports (#7217)
* Add 'Last 30 days' date range option to custom reports

Add a rolling 30-day date range to the Live mode date filter in custom
reports. The option appears between 'Last month' and 'Last 3 months'
and is available for Daily, Weekly, and Monthly intervals.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* [autofix.ci] apply automated fixes

* Fix release note filename and author to match PR #7217

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: youngcw <calebyoung94@gmail.com>
2026-04-18 15:10:35 +00:00
Aadhith
6941cc9e01 Add Ilavenil theme to custom theme catalog (#7543)
* Add theme Ilavenil to custom catalog

* Add release notes
2026-04-18 14:38:08 +00:00
tempiz
13abe0cb00 Addition of scoped ErrorBoundarys per #7391 (#7497)
* Addition of scoped ErrorBoundarys per 7391

* Adjusted to use FeatureErrorfallback from #7437
2026-04-17 20:52:57 +00:00
Matt Fiddaman
a4e401bc8b fix build error with typescript v6 (#7524)
* fix tsconfig error

* note

* fix strict mode violation

* fix another type issue
2026-04-17 20:52:44 +00:00
Matiss Janis Aboltins
ff7f81ac06 [AI] Emit bundle stats from the crdt package (#7537)
* [AI] Emit bundle stats from the crdt package

The crdt package was the only published library without a stats.json
artifact. Migrate its build to Vite (mirroring the api/cli setup), wire
in rollup-plugin-visualizer to emit dist/stats.json, and upload it from
the CRDT CI job. Declarations are still produced by tsgo via
--emitDeclarationOnly.

https://claude.ai/code/session_01CDVAGLGu49q5YMHsRLkYLQ

* Add release notes for PR #7537

* [AI] crdt: drop redundant rm -rf dist from build script

Vite's build.emptyOutDir: true already clears the output directory
before writing, so the leading rm -rf dist is unnecessary.

https://claude.ai/code/session_01CDVAGLGu49q5YMHsRLkYLQ

* [AI] Include crdt in the size-compare bundle stats table

Wait for the crdt build check on both the base branch and the PR,
download the crdt-build-stats artifact for each, and pass it to
bundle-stats-comment.mjs so the summary table rendered on the PR
includes a row for the crdt package alongside desktop-client,
loot-core, api, and cli.

https://claude.ai/code/session_01CDVAGLGu49q5YMHsRLkYLQ

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-17 20:38:06 +00:00
Michael Clark
5bf160463c 🎨 Update storybook fonts and brand image (#7536)
* update storybook fonts and brand image

* release notes
2026-04-17 19:26:24 +00:00
Matiss Janis Aboltins
598bf81da1 [AI] crdt: typecheck test files and clean up lint issues (#7534)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 17:33:02 +00:00
Julian Dominguez-Schatz
995670476e Add GitHub Actions check step via zizmor (#7465)
* Add GitHub Actions check step via `zizmor`

* Add security-events permissions to check-gh-actions

Added permissions for security events in GitHub Actions.

* Add persist-credentials option to checkout action

* Add release notes for PR #7465

* Change category to Maintenance and update action step

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-17 11:55:51 +00:00
Matt Fiddaman
711939f71c replace uuid and fs-extra with builtins (#7529)
* fs-extra

* uuid

* note
2026-04-16 20:42:27 +00:00
Matt Fiddaman
8486dca33a clean up more unused dependencies (#7528)
* remove unused deps

* note

* remove babel

* fix lint
2026-04-16 20:23:13 +00:00
Matt Fiddaman
359c1fe9ce consolidate internal naming patterns used for budget types (#7527)
* consolidate budget naming pattern

* note

* coderabbit feedback
2026-04-16 17:37:21 +00:00
Matt Fiddaman
7a4b43e7a4 fix some more GitHub code quality issues (#7520)
* Apply suggested fix to packages/desktop-client/src/components/modals/EditFieldModal.tsx from Copilot Autofix

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>

* Apply suggested fix to packages/desktop-client/src/components/modals/EditFieldModal.tsx from Copilot Autofix

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>

* note

* fix typo in note

* remove useless conditions

---------

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-16 17:07:22 +00:00
sk10727-a11y
94ea408303 Fix filter operator switching behavior (#7304)
* [autofix.ci] apply automated fixes

* Fix render condition (code rabbit)

* [autofix.ci] apply automated fixes

* Fix filter operator switching behavior

updated to allow conversion from multi-ID to text if only one array value and to handle no-value operators for account filter

final fixes before upstream sync
Fix UUID leakage when switching filter operators
Fix UUID leakage when switching filter operators#

* fixed merge conflicts

* [autofix.ci] apply automated fixes

* Fix Account filter input not rendering due to incorrect conditional logic

* [autofix.ci] apply automated fixes

* Rename isPayeeIdOp to isIdOp for clarity

* [autofix.ci] apply automated fixes

* Trigger CI rerun

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-16 16:47:28 +00:00
Matt Fiddaman
dc8694cc3a fix potentially inconsistent state updates (#7523)
* fix potentially inconsistent state updates

* note
2026-04-16 09:41:00 +00:00
Matt Fiddaman
89b442bc74 fix runImport failing when ACTUAL_DATA_DIR environment variable is not set (#7522)
* Fix ACTUAL_DATA_DIR being used instead of fetching it from config

* note
2026-04-16 09:40:43 +00:00
Matiss Janis Aboltins
3ca0bcbc13 Remove inactive community repository links from docs (#7500)
* [AI] Remove outdated community project links from docs

* Add release notes for PR #7500

* [AI] Add back actualplaid link to community repos

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

* Delete 7500.md

---------

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-04-16 07:35:20 +00:00
Matt Fiddaman
0e5e641aae ⬆️ eslint v10 & migrate eslint plugins to oxc performant API (#7508)
* eslint (^9.39.3 -> ^10.2.0)

* migrate plugins to oxc alternative API

* note
2026-04-15 17:57:04 +00:00
Matt Fiddaman
8c47374b9d ⬆️ mid-month dependency bump (#7506)
* typescript (^5.9.3 -> ^6.0.2)

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

* @types/node (^22.19.15 → ^22.19.17)

* @typescript/native-preview (^7.0.0-dev.20260309.1 → ^7.0.0-dev.20260404.1)

* eslint (^9.39.3 → ^9.39.4)

* lage (^2.14.19 → ^2.15.5)

* lint-staged (^16.3.2 → ^16.4.0)

* minimatch (^10.2.4 → ^10.2.5)

* vitest (^4.1.0 → ^4.1.2)

* better-sqlite3 (^12.6.2 → ^12.8.0)

* commander (^13.0.0 → ^13.1.0)

* cosmiconfig (^9.0.0 → ^9.0.1)

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

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

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

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

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

* storybook (^10.2.16 → ^10.3.4)

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

* @react-aria/interactions (^3.27.0 → ^3.27.1)

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

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

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

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

* @vitejs/plugin-basic-ssl (^2.2.0 → ^2.3.0)

* i18next (^25.8.14 → ^25.10.10)

* lru-cache (^11.2.6 → ^11.2.7)

* react-grid-layout (^2.2.2 → ^2.2.3)

* react-i18next (^16.5.6 → ^16.6.6)

* rolldown (^1.0.0-rc.12 → ^1.0.0-rc.13)

* sass (^1.97.3 → ^1.99.0)

* adm-zip (^0.5.16 → ^0.5.17)

* csv-parse (^6.1.0 → ^6.2.1)

* csv-stringify (^6.6.0 → ^6.7.0)

* jest-diff (^30.2.0 → ^30.3.0)

* express-rate-limit (^8.3.0 → ^8.3.2)

* upgrade yarn to 4.13.0

* react-aria-components (^1.15.1 → ^1.16.0)

* @vitejs/plugin-react (^6.0.0 → ^6.0.1)

* @codemirror/state (^6.5.4 → ^6.6.0), @codemirror/view (^6.38.7 → ^6.41.0)

* react-aria (^3.46.0 → ^3.47.0)

* react-error-boundary (^6.0.3 → ^6.1.1)

* recharts (^3.7.0 → ^3.8.1)

* fast-check (4.5.3 → ^4.6.0)

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

* commander (^13.1.0 → ^14.0.3)

* note

* coderabbit feedback, and a test for good measure

* typescript (^5.9.3 -> ^6.0.2)

* @playwright/test (1.58.2 -> 1.59.1)

* yarn dedupe
2026-04-15 17:05:26 +00:00
Stephen Brown II
f8d5d38d0a Skip release notes generation for docs-only PRs (#6815) 2026-04-15 14:52:16 +00:00
Matt Fiddaman
023f34814c ⬆️ bump gh actions (#7507)
* upload-artifact

* codeql-action

* create-pr

* docker

* github-script

* sticky-pull-request-comment

* action-download-artifact

* note
2026-04-15 14:36:28 +00:00
JasmineLCY
2aa2e49df9 [AI] Fix regex checkbox label contrast in notes modal (#7515)
* [AI] Fix regex checkbox label contrast in notes modal (#7514)

Apply theme.menuAutoCompleteText color to the "Use Regular Expressions"
checkbox label in the find-and-replace tab so it contrasts with the
menuAutoCompleteBackground modal background.

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

* fix: rename release note file to match PR number

The CI expects the release note file to be named after the PR number
(7515), not the issue number (7514).

---------

Co-authored-by: liuren.lcy <liuren.lcy@antgroup.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 13:25:41 +00:00
Aadhith
c9f5f6deb2 Add Nord theme to custom theme catalog (#7513)
* Add nord theme to catalog

* Add release notes
2026-04-15 10:08:21 +00:00
Matiss Janis Aboltins
e109d652b4 [AI] Refactor IS_GENERIC_BROWSER env var to --mode=browser (#7466)
* [AI] Refactor IS_GENERIC_BROWSER env var to --mode=browser

Replace the IS_GENERIC_BROWSER environment variable with Vite's built-in
--mode=browser flag to distinguish browser builds from Electron builds.
This aligns with the existing --mode=desktop pattern used for Electron
production builds.

Also fix build-shims.js to derive NODE_ENV from import.meta.env.DEV
instead of import.meta.env.MODE, so custom modes don't leak into
process.env.NODE_ENV.

https://claude.ai/code/session_014HvkpR59Ke4eUoiUzsUruv

* Add release notes for PR #7466

* [AI] Fix COOP/COEP headers not set with --mode=browser

The server.headers config was gated on mode === 'development', which
excluded --mode=browser. Since server.headers only applies during
vite serve (not builds), always set the COOP/COEP headers. These are
required for SharedArrayBuffer support used by the SQLite backend.

https://claude.ai/code/session_014HvkpR59Ke4eUoiUzsUruv

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-15 07:39:31 +00:00
tempiz
4878c0f333 Add maketransfer to uncategorized transactions (#7496)
* Add maketransfer to uncategorized transactions

Add maketransfer to uncategorized transactions

* retrigger checks

* retrigger checks 2
2026-04-14 23:52:34 +00:00
dependabot[bot]
18886fe166 Bump tar from 7.5.1 to 7.5.13 (#7505)
Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.1 to 7.5.13.
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.5.1...v7.5.13)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 7.5.13
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-14 22:32:49 +00:00
dependabot[bot]
081b43cf99 Bump jws from 3.2.2 to 3.2.3 (#7504)
Bumps [jws](https://github.com/brianloveswords/node-jws) from 3.2.2 to 3.2.3.
- [Release notes](https://github.com/brianloveswords/node-jws/releases)
- [Changelog](https://github.com/auth0/node-jws/blob/master/CHANGELOG.md)
- [Commits](https://github.com/brianloveswords/node-jws/compare/v3.2.2...v3.2.3)

---
updated-dependencies:
- dependency-name: jws
  dependency-version: 3.2.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-04-14 22:17:18 +00:00
dependabot[bot]
29827b9be8 Bump electron from 39.8.4 to 39.8.5 (#7413)
Bumps [electron](https://github.com/electron/electron) from 39.8.4 to 39.8.5.
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v39.8.4...v39.8.5)

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 39.8.5
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-14 21:57:49 +00:00
dependabot[bot]
b5b422f4c8 Bump lodash from 4.17.21 to 4.18.1 (#7502)
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.21 to 4.18.1.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.18.1)

---
updated-dependencies:
- dependency-name: lodash
  dependency-version: 4.18.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-04-14 21:40:42 +00:00
dependabot[bot]
21408f1e66 Bump vite from 8.0.0 to 8.0.8 (#7501)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.0 to 8.0.8.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.8/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.8
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-14 21:27:47 +00:00
Trevin Chow
c77b4cc220 Add scoped ErrorBoundary to Rules page to contain rendering crashes (#7437)
* [AI] Add scoped ErrorBoundary to Rules page to contain rendering crashes

* [autofix.ci] apply automated fixes

* docs: add release notes for ErrorBoundary PR

* fix(rules): add resetKeys to ErrorBoundary for route navigation reset

In react-error-boundary v6, the boundary does not auto-reset when the
user navigates away and back. Adding resetKeys={[location.pathname]}
ensures the error state clears on route changes.

This contribution was developed with AI assistance (Claude Code).

* fix(rules): show error message in fallback UI and log to console

Addresses reviewer feedback on #7437: surface error.message in the
FeatureErrorFallback so users can copy-paste when reporting issues,
and log the error to the console via useEffect so the error isn't
swallowed by the boundary.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-14 21:06:32 +00:00
Matiss Janis Aboltins
eac26fa4ef Remove duplicate excludes.txt entry (#7499)
* Remove duplicate excludes.txt entry

Remove package-lock.json from spelling exclusions

* Add release notes for PR #7499

* Fix duplicate exclusion in spelling checks

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-14 20:48:32 +00:00
dependabot[bot]
d27db77164 Bump follow-redirects from 1.15.11 to 1.16.0 (#7498)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.11 to 1.16.0.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.11...v1.16.0)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-version: 1.16.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-14 20:35:48 +00:00
Matiss Janis Aboltins
c7876a58cb Revert "[AI] Add project-level hook to enforce PR creation rules" (#7491)
* Revert "[AI] Add project-level hook to enforce PR creation rules (#7471)"

This reverts commit d6253c86eb.

* Add release notes for PR #7491

* Delete upcoming-release-notes/7491.md

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-14 17:12:53 +00:00
Stephen Brown II
54a26ae199 [AI] Add BALANCE_OF() function to fetch balance of an arbitrary account (#7335)
* Add BALANCE_OF() function to fetch balance of an arbitrary account

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #7335

* Adjust docs

* [autofix.ci] apply automated fixes

* Update tests to verify sidebar balances

* Add comments to schedule-template.ts

* Update index.ts to recompute sidebar balances

* [autofix.ci] apply automated fixes

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #7335

* Address CodeRabbit comments

* [AI] Implement BALANCE_OF as HyperFormula custom function via context

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* Remove protective if statement when adding _balanceOfPrefetched

* [autofix.ci] apply automated fixes

---------

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-04-14 13:40:12 +00:00
Michael Clark
bf513ad11c :electron: Desktop app tests for exporting budget file (#7494)
* tests for exporting budget file

* release notes

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #7494

* ditch the vrt, it's done in the cdesktop-client

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-14 07:51:45 +00:00
Matiss Janis Aboltins
4bc8ec876a [AI] Add publishConfig.imports sync validator with pre-commit integration (#7469)
* [AI] Add publishConfig.imports sync validator with pre-commit integration

Add a TypeScript script that validates publishConfig.imports stays in sync
with imports in all packages/*/package.json files. Runs automatically in
the pre-commit hook via lint-staged with --fix mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Add release notes for #7469

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Guard main() with require.main and respect lint-staged file args

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Handle non-string imports targets in derivePublishImports

- Add type guard to check for non-string values in imports
- Throw descriptive error when conditional imports are encountered
- Update type signature to accept Record<string, string | object>
- Add test case for non-string imports error handling

This prevents TypeError when packages have conditional imports (e.g., #browser-preload in desktop-client)

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

* [AI] Limit resolvePackageJsonPaths scope to packages directory

- Add packagesRoot constant to restrict path resolution
- Update while loop condition to only traverse within packages directory
- Add additional check to ensure candidate paths are under packages/
- Prevents resolution to repo root package.json for missing/deleted files

This ensures the validator only processes package.json files under packages/* and avoids accidentally targeting the monorepo root manifest.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>
2026-04-14 07:40:36 +00:00
Matiss Janis Aboltins
c4f3fb0b93 [AI] Fix type errors for API consumers by shipping .d.ts declarations (#7468)
* [AI] Fix type errors for API consumers by shipping .d.ts declarations from loot-core

Downstream consumers of @actual-app/api with strict: true get type errors
because @actual-app/core exports raw .ts source files. Consumers' tsc
follows the import chain into core's source (compiled with strict: false),
and skipLibCheck doesn't help since it only skips .d.ts files.

Add "types" conditions to all imports/exports entries in loot-core's
package.json, pointing to the pre-built declarations in lib-dist/decl/.
Add .npmignore to include lib-dist/decl/ in the published package.

Fixes #7410

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Use prepack/postpack scripts instead of inflating package.json

Replace the inline "types" conditions in imports/exports with a prepack
script that adds them at pack/publish time. This keeps the checked-in
package.json clean while still shipping .d.ts declarations to npm
consumers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Convert prepack/postpack scripts to TypeScript

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Add release notes for #7468

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Fix recursive ExportValue type and remove redundant comment

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Rename scripts to .mts and inline types conditions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Make backup/restore scripts safer

- Check if backup exists before creating it in prepack
- Make restore idempotent by checking if backup exists in postpack
- Prevents overwriting existing backups from interrupted runs
- Addresses CodeRabbit review feedback

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

* [AI] Type api-handlers.ts fields to drop implicit any

The `fields` / export-args slots in the ApiHandlers contract were
untyped, surfacing as TS7008 errors in strict consumers. Replace them
with the `Partial<APIXxxEntity>` shapes the `@actual-app/api` wrappers
already pass, and annotate the matching call sites in `api.ts` with
`@ts-expect-error` where the legacy helpers still declare full-entity
parameters despite accepting partial updates at runtime.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Replace vite-plugin-dts with tsgo for api types

Drops vite-plugin-dts in favor of running tsgo --emitDeclarationOnly
after the vite bundle, eliminating a heavy dev dependency tree
(api-extractor, volar, vue language-core) from the api package build.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Add build script to loot-core to emit declarations via lage

`yarn build:cli` failed in CI with TS6305 because api's
`tsgo --emitDeclarationOnly` depends on loot-core's pre-built
`lib-dist/decl/*.d.ts`, but loot-core had no `build` script, so lage's
`^build` cascade silently skipped it. Add `"build": "tsgo -b"` so loot-core
slots into the dependency chain; its tsconfig already has
`emitDeclarationOnly: true`, so the output is declarations only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Simplify API build

* [AI] Document TypeScript moduleResolution requirement for @actual-app/api

The published declarations rely on package.json exports conditions, which
classic node / node10 resolvers don't honor. Document the supported modes
(bundler / nodenext / node16) in the package README and in the Getting
Started section of the API docs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Type-guard default value in add-types-conditions prepack

`value.default` is typed `ExportValue | undefined`, which allows nested
conditional objects. The previous truthy check fell through to
`shouldSkip(defaultValue)` and would crash on `.endsWith()` if that shape
ever appeared. Replace with a `typeof === 'string'` narrowing and drop a
now-redundant "Insert types as the first key" comment.

No runtime change on current package.json — no nested `default` values
exist today — but the script is not covered by loot-core's tsconfig
include, so the latent type issue was silent.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Allow "nodenext" in docs spellcheck expect list

Referenced in the new TypeScript moduleResolution note in the API docs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Move loot-core declarations to @types and whitelist publish with files

Relocate loot-core's composite TypeScript output from lib-dist/decl to the
top-level @types directory, matching the api package's convention. Replace
the old .npmignore blacklist with an explicit package.json files whitelist.

- tsconfig.json: outDir @types, exclude test/mock dirs from decl emission
- scripts/add-types-conditions.mts: rewrite paths to ./@types/src/...
- package.json: files whitelist shipping only src, @types, migrations,
  typings, default-db.sqlite; drop legacy typesVersions (docs now require
  moduleResolution bundler/nodenext/node16, so the classic-resolution
  fallback is unused)
- .gitignore: ignore the new @types build artifact
- lage.config.js: factor outputGlob into a shared BUILD_OUTPUT_GLOBS
  constant and add @types/** so lage caches loot-core's decl output
- root tsconfig.json: tighten exclude from packages/api/@types to
  packages/*/@types to cover both api and loot-core
- delete .npmignore entirely

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Build loot-core declarations inside prepack

yarn workspace @actual-app/core pack is the first non-setup step in the
publish workflow, running before any build. Without a build chained into
prepack the @types/ tree is empty at pack time, so the tarball shipped a
transformed package.json pointing at ./@types/src/... paths that didn't
exist. npm publish doesn't re-run hooks on a pre-packed tarball, so the
frozen snapshot must be self-contained; prepack now runs yarn build first
to populate @types/ before add-types-conditions rewrites the exports.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>
2026-04-14 07:40:17 +00:00
youngcw
69cd4fc13a Update sync error message (#7493)
* format

* note

* update test

* change message

* note

* fix
2026-04-13 19:07:22 +00:00
Juulz
35a48cbf15 🎨 You Need A - Theme Dark (#7447)
* Update customThemeCatalog.json

* Update customThemeCatalog.json

* [autofix.ci] apply automated fixes

* Update customThemeCatalog.json

* 🎨 You Need A Theme Dark, based on 2026 nYNAB dark

* YNA Theme Dark

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-13 15:17:53 +00:00
Emil Tveden Bjerglund
213185661f [AI] Add Sankey report toggle to view values as percentages (#7476)
* Add Options button. Add toggle for showing values as percentages.

* Add release note

* [autofix.ci] apply automated fixes

* Ensure 0's are also reported as percentages for consistency

* Shorten release note

* Remove reduncant type assertions

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-13 14:26:26 +00:00
Michael Clark
4fa658b91e :electron: Playwright test to verify sync server starts (#7484)
* test for opening sync server

* urelease note

* lint

* remove console logs from test

* wait on navigation

* works locally... Pipelines failing

* testing

* more

* rebuild electron before testing

* jeeezoo

* ipv4 issue?

* [autofix.ci] apply automated fixes

* add rebuild step to vrt

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #7484

---------

Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>
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-04-13 09:58:20 +00:00
Michael Clark
95b2925be6 :electron: Fix desktop app playwright vrt setup (#7490)
* fix desktop app playwright vrt tests

* release notes

* add bcrypt in as well

* install build tools for bcrypt....
2026-04-13 08:04:15 +00:00
Matt Fiddaman
db72948d7c change release process to use a branch and add automation (#7418)
* release automation

* note

* harden tokens for release notes workflow

* fix yarn install on release notes check

* amend release docs

* address coderabbit

* change back to auto version resolution

* more coderabbit

* clarify a poorly worded comment

* drop cherry pick workflows

* fix docker tag

* coderabbit

* drop `v` from release branch name

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

* simplify release note workflows

---------

Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>
2026-04-12 22:17:14 +00:00
Matt Fiddaman
2c4d64eaac fix docker tags in docs (#7486) 2026-04-12 20:06:08 +00:00
Matiss Janis Aboltins
78b1fc2713 [AI] Add no-extraneous-dependencies lint rule (#7480)
* [AI] Add no-extraneous-dependencies lint rule to prevent transitive dependency usage

Closes #7479. Adds a custom ESLint rule that flags imports of packages not
explicitly listed in the workspace's dependencies or devDependencies. Also
fixes all existing violations by adding missing deps and removes unused
deps (@reduxjs/toolkit, @rschedule/json-tools) from loot-core.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Fix builtin subpath detection and improve cache in no-extraneous-dependencies

Fix false positives for Node.js builtin subpaths (fs/promises, path/posix)
by checking the package name portion against builtins. Also cache all
visited directories during walk-up, not just the starting directory.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Release notes

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:11:26 +00:00
Juulz
d2475ebb02 [Docs] Update tags doc for clarity - fixes #7475 (#7485)
* Update tags.md

* [autofix.ci] apply automated fixes

* Update tags.md

* Update tags.md

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-12 13:46:47 +00:00
Michael Clark
78dad7c91b Docs - fonts (#7477)
* making the docs fonts match the app fonts

* quote the blink mac sys font
2026-04-12 08:43:14 +00:00
Matiss Janis Aboltins
410658009e [AI] Fix sync-server build not resolving subpath imports (#7478)
* [AI] Fix sync-server build not resolving subpath imports

The add-import-extensions build script only handled relative imports
(./  ../), leaving #-prefixed subpath imports unresolved in the build
output. At runtime Node.js resolved them via package.json's imports
map back to source files, which have extensionless imports that fail
in ESM.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Add release notes for sync-server subpath imports fix

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Simplify subpath import resolution using publishConfig.imports

Use publishConfig.imports which already has ./build/src/ paths with .js
extensions, eliminating manual src->build and .ts->.js conversions.
Also sort wildcard patterns by specificity and extract shared helper.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Use replaceAll for wildcard substitution to satisfy CodeQL

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 05:36:55 +00:00
Matiss Janis Aboltins
a69e33bd7b [AI] Upgrade oxlint, oxfmt, and oxlint-tsgolint to latest versions (#7463)
- oxlint ^1.51.0 → ^1.59.0
- oxfmt ^0.32.0 → ^0.44.0
- oxlint-tsgolint ^0.13.0 → ^0.20.0
- Remove rules dropped by oxlint: import/no-unresolved, import/no-unused-modules,
  import/no-useless-path-segments, react/no-unstable-nested-components
- Rename eslint/no-use-isnan → eslint/use-isnan
- Remove deprecated moduleResolution: "node10" from crdt and desktop-electron tsconfigs

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 05:36:29 +00:00
Juulz
0025010382 [Docs] Update text and image in restore doc to Switch file from Close file. (#7473)
* Update restore.md

* Add files via upload
2026-04-11 22:57:59 +00:00
Matiss Janis Aboltins
d6253c86eb [AI] Add project-level hook to enforce PR creation rules (#7471)
* [AI] Add project-level hook to enforce PR creation rules

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Fix hook JSON output format to use hookSpecificOutput

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Rename 7470.md to 7471.md

* Update upcoming-release-notes/7471.md

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-04-11 15:37:26 +00:00
Matiss Janis Aboltins
5220dff75b Prevent path traversal in file upload by using basename (#7428)
* [AI] Fix path traversal vulnerability in uploadFileWeb

Sanitize user-supplied filename with fs.basename() to strip directory
components (e.g. ../../) before writing to /uploads/, preventing
arbitrary file writes outside the intended directory.

https://claude.ai/code/session_01UgQANWBxqkqVT7xGyWNAXB

* [AI] Harden path traversal fix and correct broken basename in web fs

The browser fs.basename implementation was returning the directory part
instead of the filename, making the previous fs.basename() fix
ineffective on the web platform. Replace with inline sanitization that
works regardless of platform: split on path separators, strip null
bytes, reject . and .., and use fs.join for safe path construction.

Also fix the browser fs.basename to actually return the last path
segment, matching the behavior of Node's path.basename.

https://claude.ai/code/session_01UgQANWBxqkqVT7xGyWNAXB

* [AI] Revert browser fs.basename change per user request

The browser fs.basename implementation is restored to its original
behavior. The path traversal fix in uploadFileWeb does not depend on
fs.basename.

https://claude.ai/code/session_01UgQANWBxqkqVT7xGyWNAXB

* [AI] Add release notes for path traversal fix (#7428)

https://claude.ai/code/session_01UgQANWBxqkqVT7xGyWNAXB

* [AI] Suppress no-control-regex lint for null byte sanitization

The \0 regex is intentional to strip null bytes from filenames as part
of the path traversal fix.

https://claude.ai/code/session_01UgQANWBxqkqVT7xGyWNAXB

* [AI] Use replaceAll for null-byte stripping instead of regex

Replace /\0/g regex with replaceAll('\0', '') to avoid triggering
the no-control-regex ESLint rule, removing the need for the
eslint-disable comment.

https://claude.ai/code/session_01UgQANWBxqkqVT7xGyWNAXB

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-11 11:28:13 +00:00
Matiss Janis Aboltins
65b154151f [AI] Enable subpath imports across all packages (#7462)
* [AI] Enable subpath imports across all packages

Generalize the prefer-subpath-imports ESLint rule to work with any
package (not just loot-core) and enable it globally. Add subpath import
mappings to cli, component-library, and sync-server package.json files.
Auto-fix all backtracked relative imports to use #-prefixed subpath
imports.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Add release notes for #7462

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Fix mock specifiers in accounts.test.ts to use aliased imports

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

* [AI] Fix mock specifiers in query.test.ts to use aliased imports

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

* [AI] Fix ESLint rule to properly validate src/ directory paths

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

* [AI] Add publishConfig.imports for sync-server to remap aliases to build directory

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>
2026-04-11 08:56:49 +00:00
Michael Clark
4aa6ac76b4 Update docs logo and cleanup some unused files (#7444)
* update docs logo and cleanup some unused files

* remove icon.png

* keep old logo around in archived
2026-04-11 08:03:37 +00:00
Kennedy242
a87676bd84 Fix bug where total selected balance is not shown when it is zero (#7460)
* Write a test to catch this case

* Handle falsey values better in 'Selected balance' logic
2026-04-10 22:49:54 +00:00
Matiss Janis Aboltins
e322b0319c Update pre-commit hook configuration (#7461)
* [AI] Set .husky/pre-commit hook as executable

The pre-commit hook was being ignored by git because it lacked the
executable permission bit, producing a warning on every commit attempt.

https://claude.ai/code/session_016jLmTo6L5PxMKK8wJMptCP

* Add release notes for PR #7461

* Update 7461.md

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-10 22:46:51 +00:00
Matiss Janis Aboltins
4960363de6 [AI] Stop using .browser extension; removing "resolve.extensions" - prefer conditions via package.json (#7254)
* [AI] Consolidate loot-core connection: default web path, electron split, drop .browser

* [autofix.ci] apply automated fixes

* [AI] Replace browser-preload .browser extension with package.json subpath imports

Use the imports field in desktop-client/package.json with conditional
resolution (electron → empty stub, default → real implementation) to
eliminate the last .browser file extension from the codebase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Refactor connection imports to use @actual-app/core

* Implement connection mock for desktop-client tests and update import path

* [AI] Fix formatting and update imports after master merge

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Fix connection mock in TransactionsTable tests and use electron-renderer condition

Wire up the manual connection mock for TransactionsTable tests since the
__mocks__ directory was removed, and restore electron-renderer condition
in loot-core package.json exports.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [autofix.ci] apply automated fixes

* [AI] Remove redundant resolveExtensions from vite configs

These arrays were identical to Vite's built-in default and served no purpose.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Remove remaining resolveExtensions from vite/vitest configs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Fix build failures: update browser-preload import path and condition

- Change loot-core/shared/platform to @actual-app/core/shared/platform
- Use electron-renderer condition for #browser-preload to match vite resolve

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Remove redundant resolveExtensions from api and loot-core desktop configs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Remove '*.browser.ts' extension and alias resolutions

Removed the special '*.browser.ts' file extension and file resolutions via alias, preferring conditions.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 21:32:51 +00:00
Matiss Janis Aboltins
d76d7d3204 Security hardening: validate release notes and workflow inputs (#7448)
* [AI] Harden GitHub Actions workflows against low-severity security issues

- generate-release-pr.yml: replace `eval` with an associative array for
  per-package version tracking. The version input was already moved to an
  env var in #7433, so this removes the remaining defense-in-depth concern
  of `eval`ing subshell output.
- create-release-notes-file.js: validate the OpenAI-returned category
  against the known allow-list (Features, Bugfixes, Enhancements,
  Maintenance), validate the author against the GitHub username regex,
  and collapse the summary to a single line before embedding it in the
  markdown body. Prevents indirect prompt-injection via CodeRabbit
  comments from producing malformed YAML frontmatter.
- generate-summary.js: stop logging the full CodeRabbit comment body to
  CI logs.
- netlify-release.yml, i18n-string-extract-master.yml: pass secrets via
  `env:` blocks rather than as CLI arguments, so they do not appear in
  argv / process listings.

https://claude.ai/code/session_012pZSkUBbabmmuaxbwysW33

* Add release notes for PR #7448

* [AI] Address review feedback on security hardening

- create-release-notes-file.js: stop logging the full fileContent body.
  Only log the target filename plus the (already-validated) category and
  author metadata, so the model-generated release-note text doesn't end
  up in CI logs.
- create-release-notes-file.js: validate summaryData.prNumber as a
  positive integer before using it in the file path or commit message,
  and switch both usages to the validated numeric value.
- i18n-string-extract-master.yml: write the Weblate API key into
  ~/.config/weblate under a [keys] section in a new "Configure Weblate
  API credentials" step, then drop the per-step env blocks and the
  --key CLI flag from every wlc invocation so the secret is no longer
  visible in process listings at all.

https://claude.ai/code/session_012pZSkUBbabmmuaxbwysW33

* [AI] Remove debug console.log statements for category in release notes script

Remove the four "Debug - ..." console.log calls that printed the raw
category env var (value/type/JSON-stringified form) plus the cleanCategory
value. They were clutter in CI logs; the existing info-level
"Creating release notes file: ... (category: ..., author: ...)" log
already surfaces the sanitized category.

https://claude.ai/code/session_012pZSkUBbabmmuaxbwysW33

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-10 17:11:52 +00:00
Matiss Janis Aboltins
d8317c44b7 [AI] Migrate desktop-client to subpath imports (#7446)
* [AI] Migrate desktop-client to subpath imports

Replace the `@desktop-client/*` path alias with Node.js subpath
imports (`#*`) across packages/desktop-client:

- Declare the full `imports` map in packages/desktop-client/package.json
  (bare index entries, root-level files, and per-subdirectory wildcards
  with explicit extension overrides where `.ts` and `.tsx` mix).
- Update all source files to import from `#...` specifiers.
- Drop the `@desktop-client` group from .oxfmtrc.json.
- Enable `actual/prefer-subpath-imports` for desktop-client in
  .oxlintrc.json so future code keeps using the subpath form.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Drop legacy desktop-client aliases

Remove the `@desktop-client/*` and `loot-core/*` path aliases from
vite.config.ts and tsconfig.json now that every desktop-client source
file imports via subpath imports / `@actual-app/core`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Release notes

* [AI] Use electron-renderer condition for renderer-only exports

Desktop-client's Vite build used the `electron` resolve condition, which
overlapped with loot-core exports where `electron` means the Node/main
variant (e.g. `shared/platform.electron.ts` using `os`,
`platform/server/asyncStorage/index.electron.ts` using `fs`). Once the
`loot-core` Vite alias was removed, the renderer bundle started pulling
those Node variants and crashed at runtime with
`It.default.platform is not a function` inside `platform.electron.ts`.

Introduce a distinct `electron-renderer` condition used only by
desktop-client's Vite config, and rename the `electron` key to
`electron-renderer` on the sole loot-core export whose `electron` branch
is the Electron renderer variant (`#/./platform/client/connection`, the
IPC `global.Actual.ipcConnect` file). Every other `electron`-conditioned
export keeps its Node semantics and is still matched by loot-core's own
`vite.desktop.config.ts` (`conditions: ['electron']`).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Drop .electron.* extensions from loot-core desktop resolver

Now that every Node/main variant is selected via the `electron` subpath
import condition in `packages/loot-core/package.json`, Vite's
`resolveExtensions` list no longer needs the `.electron.js`,
`.electron.ts`, `.electron.tsx` entries. Remove them to keep resolution
explicit and avoid implicit extension picking.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Align desktop-client TS resolution with Vite

- Set `customConditions: ["electron-renderer"]` in
  `packages/desktop-client/tsconfig.json` so TypeScript resolves
  conditional imports (notably `@actual-app/core/platform/client/connection`)
  to the same file Vite picks at runtime. Today the surfaces happen to
  match because both variants import from a shared `index-types.ts`,
  but the alignment prevents a latent drift bug.
- Fix typo in the release note (`Standartise` -> `Standardise`).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:03:45 +00:00
Michael Clark
c2c6d49afa Upgrade docusaurus (#7445)
* upgrade docusaurus

* release notes
2026-04-10 08:06:31 +00:00
Emil Tveden Bjerglund
6a96231c1a Add more filtering options for transactions in the Sankey report (#7442)
* Allow more filters in Spent mode

* Add release note

* Add translation do displayed fields. Fix typo.
2026-04-09 18:59:17 +00:00
Juulz
085355b467 🎨 Add You Need A (YNA) Theme Light (#7441)
* Update customThemeCatalog.json

Add YNA Theme Light

* [autofix.ci] apply automated fixes

* 🎨 You Need A Theme Light

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-09 18:55:21 +00:00
Juulz
23313b3ac5 Change formatting of reconcile form for clarity and ease of use (#7423)
* Update Reconcile.tsx

* Update Reconcile.tsx

* Update Reconcile.tsx

* Update Reconcile.tsx

* Update Reconcile.tsx

* Update Reconcile.tsx

* Update Reconcile.tsx

* [autofix.ci] apply automated fixes

* Update Reconcile.tsx

* Update Reconcile.tsx

* [autofix.ci] apply automated fixes

* Change formatting of reconcile form for clarity and ease of use.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-09 14:09:58 +00:00
Matiss Janis Aboltins
f364d5a9d8 [AI] Add subpath import wildcard patterns to loot-core (#7429)
* [AI] Add subpath import wildcard patterns and explicit directory entries to loot-core

Extend the package.json imports field with prefix-specific wildcard patterns
(#server/*, #shared/*, #types/*, #mocks/*, #platform/*) and explicit entries
for directory imports (#server/db, #server/sync, etc.) to support the ongoing
migration from relative imports to subpath imports.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Add release notes for #7429

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>
2026-04-09 08:02:25 +00:00
Tamás Szelei
e6bd684812 [AI] Update Age of Money Report discussion link to issue #7006 (#7421) 2026-04-08 23:05:56 +00:00
Matiss Janis Aboltins
4d0f0f740d Update browserlist (caniuse-lite) (#7431)
* [AI] Update browserslist caniuse-lite database

Update caniuse-lite from 1.0.30001751 to 1.0.30001787 and
baseline-browser-mapping from 2.9.14 to 2.10.16.

https://claude.ai/code/session_01Q4x7bPh2tkKDKcbM4kfHNo

* [AI] Add release notes for browserslist update

https://claude.ai/code/session_01Q4x7bPh2tkKDKcbM4kfHNo

* Update 7431.md

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-08 22:35:21 +00:00
Matiss Janis Aboltins
20ba076a51 Add rate limiting to authentication endpoints (#7432)
* [AI] Add rate limiting to authentication endpoints

Add strict rate limiting (5 attempts per 15 minutes) to /account/login,
/account/bootstrap, and /account/change-password endpoints to prevent
brute-force password attacks. Uses express-rate-limit as route-level
middleware on auth-sensitive routes only.

https://claude.ai/code/session_017SHnNCn93RzxpvEEPJAZUZ

* [AI] Add release notes and remove rate limit from /change-password

Add upcoming release notes file for the auth rate limiting feature.
Remove rate limiting from /change-password since it already requires
a valid admin session token.

https://claude.ai/code/session_017SHnNCn93RzxpvEEPJAZUZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-08 22:21:04 +00:00
Matiss Janis Aboltins
4efa8bba04 Fix script injection patterns in GitHub Actions workflows (#7433)
* [AI] Fix script injection in vrt-update-apply.yml workflow

Use environment variables instead of direct expression interpolation
in the github-script step to prevent potential script injection via
artifact-sourced values (steps.apply.outputs.error and
steps.metadata.outputs.pr_number).

https://claude.ai/code/session_01V28NTQAXTvSfwyoDhWpWo9

* [AI] Fix script injection in generate-release-pr.yml workflow

Use environment variable instead of direct expression interpolation
for github.event.inputs.version in the shell script context to
prevent potential command injection.

https://claude.ai/code/session_01V28NTQAXTvSfwyoDhWpWo9

* [AI] Add release notes for #7433

https://claude.ai/code/session_01V28NTQAXTvSfwyoDhWpWo9

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-08 22:19:45 +00:00
Matiss Janis Aboltins
1f3b4e613d Pin check-spelling action versions to specific commits (#7430)
* [AI] Pin check-spelling actions to commit SHAs in docs-spelling.yml

Pin check-spelling/check-spelling@main and check-spelling/spell-check-this@prerelease
to specific commit SHAs to prevent supply chain attacks. The update job is especially
sensitive as it has contents:write, pull-requests:write permissions and access to the
CHECK_SPELLING SSH deploy key.

https://claude.ai/code/session_01FK9KT4VRxvm24bb18Q9rvM

* [AI] Fix spell-check-this prerelease pin to correct commit SHA

https://claude.ai/code/session_01FK9KT4VRxvm24bb18Q9rvM

* [AI] Add release notes for pinning check-spelling action versions

https://claude.ai/code/session_01FK9KT4VRxvm24bb18Q9rvM

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-08 21:38:07 +00:00
youngcw
926f7193f9 fix formula date variable in rules (#7373)
* format

* note

* update test
2026-04-08 15:47:19 +00:00
Eduardo Pio
092b85e075 Fix/5840 escape search wildcards (#7270)
* Escape LIKE wildcards; support ? in unicodeLike

* add release notes

* [autofix.ci] apply automated fixes

* Revert "[autofix.ci] apply automated fixes"

This reverts commit d8a2843a5b.

* [autofix.ci] apply automated fixes

* fix: address bot review feedback for escapes

* Support escaped backslash

* Add tests for escaped characters in unicodeLike

* [autofix.ci] apply automated fixes

* Remove underscore from escaped search regex

* Refactor regex escaping to use REGEX_SPECIAL constant

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-08 15:37:41 +00:00
gust0717
2bbcbaee73 Fixes query for tag starting in $ (#7324)
* Fixes query for tag starting in $

* Update packages/loot-core/src/server/transactions/transaction-rules.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Apply coderabbitai suggestion

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-08 14:45:00 +00:00
tabedzki
446fde6cd9 Add Category Group filtering in Budget Analysis Report (#7116)
* feat: add FilterInclude to match FilterExclude

* feat: enhance category filtering to support category groups in budget analysis

* add release notes

* fix: applied corabbit's suggestion

* applied coderabbit's fixes

* removed duped code

* applied more coderabbit comments

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes

* fix: include docs about include parameter

* fix: contains and matches now properly filter category names instead of UUID

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: youngcw <calebyoung94@gmail.com>
2026-04-08 14:34:26 +00:00
James Skinner
7ce44c2e56 Migrate add-attribute plugin to TypeScript (#7417)
* Migrate add-attribute plugin to TypeScript

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-08 14:02:45 +00:00
Matiss Janis Aboltins
e0772e24cd [AI] Add ErrorBoundary around dashboard widgets (#7382)
* [AI] Add ErrorBoundary around dashboard widgets (#7273)

Wraps each dashboard widget in an ErrorBoundary so a faulty widget
degrades to an error card instead of crashing the entire Reports page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add release notes for PR #7382

* [autofix.ci] apply automated fixes

---------

Co-authored-by: Claude Opus 4.6 (1M context) <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>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-08 06:41:31 +00:00
Emil Tveden Bjerglund
3d5881ea57 Fix Net Worth graph not showing correct number of intervals (#7296)
* Fix Net Worth graph showing N-1 intervals, resulting in inconsistent totalChange value.

* Fix starting date being wrong for 'daily' and 'weekly'

* Linting

* Add release note

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #7296

* Remove manipulation of startDate for 'yearly'

The result was not consistent with the reports previous behavior when yearly was selected (it went back too far).

* Remove empty datapoint at beginning when start equals earliest transaction

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-08 02:40:28 +00:00
Matiss Janis Aboltins
2295e6d464 [AI] Add electron conditions to loot-core platform/server exports (#7383)
* trim down some unused/unnecessary dependencies (#7350)

* fix github actions inconsistencies

* fix pinning of transitive deps in eslint-plugin

* drop use of node-fetch in api

* drop md5 dependency in favour of node:crypto

* drop slash

* drop unused top level packages

* add note about node-polyfills warning

* remove unused deps from desktop-client

* drop pegjs types

* note

* drop node-jq

* [Doc] More tour image (mostly) updates & a hotkey fix (#7328)

* Fix keyboard shortcut Mac key for undo operations

Updated keyboard shortcut instructions for Mac & make consistent.

* Add files via upload

* Fix undo shortcut from 'K' to 'Z'

Updated keyboard shortcut for undo operation in payees guide. COFFEE!

* Revise budget section for clarity and consistency

Updated category descriptions and improved Markdown support details.

* Add files via upload

* Fix grammatical error in budget.md

* Fix typo and clarify Markdown description in budget.md

Corrected a typo in the documentation regarding the chevrons and clarified the description of rendered Markdown.

* Fix spelling error in budget documentation

Corrected the spelling of 'cheverons' to 'chevrons'.

* Add files via upload

* Remove redundant text in budget.md

* Fix formatting issues in payees.md

* count points script should fetch the release note from the PR directly (#7309)

* get pr release note from PR, not top of master

* note

* [AI] Mobile: Post transaction today on global account lists (#7311) (#7322)

* [AI] Mobile: pass today for Post transaction today on global account lists (#7311)

All Accounts, On budget, and Off budget transaction lists now forward the
today flag to schedule/post-transaction, matching single-account mobile
and desktop behavior.

Made-with: Cursor

* [AI] Add release note for PR 7322 (#7311)

Made-with: Cursor

* [AI] Tighten release note wording for PR 7322 (imperative)

Made-with: Cursor

---------

Co-authored-by: Pranay Mac M1 <pranayseela@yahoo.com>

---------

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
Co-authored-by: Pranay S <pranayritvik@gmail.com>
Co-authored-by: Pranay Mac M1 <pranayseela@yahoo.com>
Co-authored-by: youngcw <calebyoung94@gmail.com>

* Implement Sankey report for spent and budgeted money (#7220)

* Implement Sankey graph report

* Add release notes

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6068

* Remove local debug settings

* [autofix.ci] apply automated fixes

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6068

* Improve graphs from comments

* Fix lints

* coderabit fixes

* Fix filtering and UI enhancements

* remove pngs

* Fix typecheck

* Another type issue

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6068

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6068

* Fix strict typing issues

* Update report page

Now better conforms with components from other reports, e.g. by reusing Header
Makes it possible to display a period longer than one month.

* Change view description order

* Formatting and cleanup

* Removed difference section, as it will be difficult to get a reliable view across months

* Introduce the Timeframe param, similar to Spending report, to allow saving a Live sliding window.

* Allow filtering just the last month

* Fix linting errors

* Remove all information about income

* Remove debugging statement

* Sort categories and subcategories by amount

* Move compact mode to spreadsheet to fix Card view more easily

* Update tests file

* Add release notes

* Rename release notes to match PR#

* Fix autofix.ci issues

* Update packages/desktop-client/e2e/sankey.test.ts

Enable experimental feature fall all tests, pr. coderabbit recommendation

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

* Add sankey-card to isWidgetType

* Gate Sankey routes to prevent direct URL bypass

* Fix typo

* Change node transformation to work by key instead of name, to remove risk of duplicate issues

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

* Prevent false-positive pass in month-change test.

* Translate mode to a proper label

* Fix message for empty data

* Enabled LoadingIndicator until data is ready

* Change card default mode

* More robust filtering

* Fixed issue with budgeted spreadsheet not using 'end' date

* Allow copying SankeyCard to dashboard

* Fix typing and linting issues

* Remove e2e tests

I cannot currently get them to pass, because I dont fully understand playwright and how they are supposed to work. I can see that they don't exist for other reports. We can add them later if required.

* Remove unecessary sankey reference

* Refactor spreadsheet

* Remove dead code from SankeyGraph

* Collect to Other if too many subcategories

* Edit wrong comment

* Linting and typechecking

* Show remaining amount to budget

* Hide description on narrow device

* Add visual clue if 'To budget' is larger than 'Budgeted' and would extend below the edge of the graph

* Add colors to the links

* Fix report card showing subcategories instead of main categories

* Add tooltip info to Other on SankeyCard

* Create globalOther flag and implement greedy category reduction algorithm

* Allow user to select between Global or Per category Other

* Allow user to choose number of subcategories to show

* Allow user to select how subcategories are sorted

* Fix budget filtering

* [autofix.ci] apply automated fixes

* Condense sorting and Other-grouping to one option

* Implement Sort as budget option

* Dynamically adjust topN based on SankeyCard height

* Remove old feature flags from previous PR

---------

Co-authored-by: andrewhumble <43395285+andrewhumble@users.noreply.github.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>
Co-authored-by: youngcw <calebyoung94@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Fix yarn generate:icons command (#7281)

* fix icon templates with `module.exports` to `export default`

* Add `@svgr/babel-plugin-add-jsx-attribute` to dependencies

* Run `yarn generate:icons`, and set prettier singleQuote to reduce changes

* Add release note

* Add temporary fix for `SvgChartArea`

* Add `ChartArea` svg from the existing tsx

* CI rerun

* Standardise ledger scrolling when using keyboard shortcuts (#7283)

* Standardise table keyboard navigation by preventing browser scroll with arrow keys

* Add release note

* Apply the preventDefault() in specific cases so that it is not applied to default

---------

Co-authored-by: youngcw <calebyoung94@gmail.com>

* Fix updateTransaction corrupting split parents with partial updates (#7242)

* [AI] Fix updateTransaction corrupting split parents with partial updates

When `api.updateTransaction(id, { notes: '...' })` is called on a split
parent, the `updateTransaction` helper replaces the parent with the
sparse update object (`{ id, notes }`) instead of merging it with
the existing transaction data.  This causes `recalculateSplit` to see
`amount` as `undefined` (→ 0), which doesn't match the children's
total and sets a `SplitTransactionError` on the parent.  `makeChild`
also inherits undefined `account`, `date`, and `cleared` values,
potentially creating broken child rows.

Fix: merge the incoming partial fields (`{ ...trans, ...transaction }`)
so all existing properties are preserved.

Add a test that performs a notes-only update on a split parent and
asserts no error is set and the amount stays intact.

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

* [AI] Add release notes for PR #7242

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

* Address review feedback: remove verbose comment and simplify release note

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

---------

Co-authored-by: L. Warren Thompson <lwarrenthompson@Warren-MBP.local>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* [AI] Add electron conditions to loot-core platform/server exports and fix imports

- Add "electron" condition to platform/server exports (asyncStorage,
  connection, fetch, fs, sqlite) so they resolve to .electron.ts files
  when using the electron export condition
- Remove broken ./client/platform export referencing non-existent files
- Convert deep relative imports in electron files to subpath imports
  (#types/prefs, #server/errors, #server/mutators)

https://claude.ai/code/session_01FPpKnozt42Mf79YHAT6ytM

* [AI] Convert remaining relative imports to subpath imports in electron files

- Convert ../fs, ../log, ../../exceptions to subpath imports
  (#platform/server/fs, #platform/server/log, #platform/exceptions)
- Add electron-conditional entries to the imports field in package.json
  for all 5 platform/server modules with electron variants
- Add resolve.conditions: ['electron'] to vite.desktop.config.ts so the
  electron condition is recognized during desktop builds

https://claude.ai/code/session_01FPpKnozt42Mf79YHAT6ytM

* Add release notes for PR #7383

* [AI] Fix API build and test failures from conditional exports

- Add "api" condition to all 5 platform/server exports and imports
  entries so the API build resolves to .api.ts variants correctly
- Add resolve.conditions and ssr.resolve.conditions: ['api'] to
  packages/api/vite.config.ts
- Add explicit #platform/server/log and #platform/exceptions entries
  to the imports field (array fallback in #* wildcard doesn't work for
  directory modules)
- Revert #platform/server/fs back to relative ../fs import in
  asyncStorage/index.electron.ts — subpath imports for platform modules
  with electron variants don't work in vitest because the test runner
  doesn't propagate resolve.conditions to Node's import resolution

https://claude.ai/code/session_01FPpKnozt42Mf79YHAT6ytM

* fix: apply CodeRabbit auto-fixes

Fixed 2 file(s) based on 2 unresolved review comments.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>

* [autofix.ci] apply automated fixes

* Enhance package.json and Vite configurations for Electron support; refactor imports to use new path aliases. Added new paths for server and shared modules, updated SSR settings, and improved test configurations for better module resolution.

* [AI] Merge electron condition with default Vite conditions in vitest config

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

* [AI] Move @ts-strict-ignore comment to first line in reset.ts

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

---------

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
Co-authored-by: Juulz <julesmcn@gmail.com>
Co-authored-by: Pranay S <pranayritvik@gmail.com>
Co-authored-by: Pranay Mac M1 <pranayseela@yahoo.com>
Co-authored-by: youngcw <calebyoung94@gmail.com>
Co-authored-by: Emil Tveden Bjerglund <emilbp@gmail.com>
Co-authored-by: andrewhumble <43395285+andrewhumble@users.noreply.github.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>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: James Skinner <56730344+JSkinnerUK@users.noreply.github.com>
Co-authored-by: L. Warren Thompson <warren.thompson@zuirail.com>
Co-authored-by: L. Warren Thompson <lwarrenthompson@Warren-MBP.local>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>
2026-04-07 22:17:43 +00:00
Matt Fiddaman
1e8ad9a89f fix loot-core version bump in generate script (#7414) 2026-04-07 22:17:18 +00:00
Matt Fiddaman
5009f01218 generate docs from release notes directly (#7408)
* move workflows to use the local actions

* generate docs from release notes directly

* note

* fix create PR workflow from SHA
2026-04-07 21:00:32 +00:00
Michael Clark
6decd9d0f6 Move view styles into view component (#7412)
* move view styles into view component

* release notes

* use viewstyles on autocomplete
2026-04-07 20:39:37 +00:00
JkBoyo
5809292579 Capture sync time for initial account linking Fixes #7282 (#7347)
* Capture sync time for initial account linking

* Fix id to point to correct id

* Add release notes

* fix indenting
2026-04-07 20:31:00 +00:00
Matiss Janis Aboltins
edc0242203 [AI] Fix CLI accounts list: compute balances, hide closed, sort by group (#7378)
* [AI] Fix accounts list: compute balances, hide closed, sort by budget group

- Replace empty `balance_current` (bank-sync field) with computed `balance`
  from transaction history via `getAccountBalance`
- Filter out closed accounts by default; add `--include-closed` flag
- Stable-sort on-budget accounts before off-budget
- Add `balance_current` to AMOUNT_FIELDS for table/csv formatting
- Update docs and tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Extract duplicate isRecord type guard to shared utils

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Add types condition to api package exports for tsc resolution

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Update packages/cli/src/commands/query.ts

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

* [AI] Add balance_available/balance_limit to AMOUNT_FIELDS, validate query result.data

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[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-04-07 19:28:46 +00:00
Matiss Janis Aboltins
4fe4421ab7 [AI] Fix crash viewing account ledger with expired recurring schedules (#7381)
* [AI] Fix crash viewing account ledger with expired recurring schedules

Guard against null return from getNextDate() when a recurring schedule
has an end date in the past and no future occurrences exist.

Fixes #7285

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add release notes for PR #7381

* [AI] Address code review feedback for PR #7381

Revert schedule-template.ts changes and fix test names/assertions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Simplify bugfix description for account ledger crash

Removed redundant information about null checks in the bugfix description.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-07 18:14:28 +00:00
Matiss Janis Aboltins
78739b926b [AI] Clarify that E2E encryption does not cover bank sync tokens (#7392)
* [AI] Clarify that E2E encryption does not cover bank sync tokens (#5550)

Update docs and in-app text to make clear that end-to-end encryption
only applies to budget data, not bank sync tokens stored on the server.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add release notes for PR #7392

---------

Co-authored-by: Claude Opus 4.6 (1M context) <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-04-07 17:16:19 +00:00
Matt Fiddaman
d42f6c7437 improve release notes actions (#7407)
* consolidate find-comment, post-comment into single action

* use release notes in current branch for both generation and deletion

* note
2026-04-07 17:16:14 +00:00
Matt Fiddaman
ba780514f6 prevent messages being dropped after closing last budget (#7411)
* prevent messages being dropped after closing last budget

* note
2026-04-07 17:14:28 +00:00
Trevin Chow
a84fb3dae1 Fix custom report editor retaining unsaved settings (#7356)
* Fix custom report editor retaining unsaved settings

The session storage clear condition only fired when navigating from
the /reports dashboard. Since the URL tracking runs inside the report
component, the stored URL always pointed to the last report path, so
revisiting the same report never triggered the clear.

Changed the condition to clear session storage whenever the stored URL
differs from the current path. This handles navigating from the
dashboard, from another report, or any other page.

Fixes #7332

* Add release notes for PR #7356
2026-04-07 16:37:43 +00:00
Tamás Szelei
e3dd3d1d5a Add Age of Money report (#2994) (#6685) 2026-04-07 15:05:45 +00:00
James Skinner
477b1873e2 Extend normalisation utility for non-latin diacritic character handling (#7284)
* Extend normalisation utility for non-latin diacritic character handling

* Add release note

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-07 14:54:32 +00:00
dependabot[bot]
59192c9b02 Bump lodash from 4.17.23 to 4.18.1 (#7380)
* trim down some unused/unnecessary dependencies (#7350)

* fix github actions inconsistencies

* fix pinning of transitive deps in eslint-plugin

* drop use of node-fetch in api

* drop md5 dependency in favour of node:crypto

* drop slash

* drop unused top level packages

* add note about node-polyfills warning

* remove unused deps from desktop-client

* drop pegjs types

* note

* drop node-jq

* [Doc] More tour image (mostly) updates & a hotkey fix (#7328)

* Fix keyboard shortcut Mac key for undo operations

Updated keyboard shortcut instructions for Mac & make consistent.

* Add files via upload

* Fix undo shortcut from 'K' to 'Z'

Updated keyboard shortcut for undo operation in payees guide. COFFEE!

* Revise budget section for clarity and consistency

Updated category descriptions and improved Markdown support details.

* Add files via upload

* Fix grammatical error in budget.md

* Fix typo and clarify Markdown description in budget.md

Corrected a typo in the documentation regarding the chevrons and clarified the description of rendered Markdown.

* Fix spelling error in budget documentation

Corrected the spelling of 'cheverons' to 'chevrons'.

* Add files via upload

* Remove redundant text in budget.md

* Fix formatting issues in payees.md

* count points script should fetch the release note from the PR directly (#7309)

* get pr release note from PR, not top of master

* note

* [AI] Mobile: Post transaction today on global account lists (#7311) (#7322)

* [AI] Mobile: pass today for Post transaction today on global account lists (#7311)

All Accounts, On budget, and Off budget transaction lists now forward the
today flag to schedule/post-transaction, matching single-account mobile
and desktop behavior.

Made-with: Cursor

* [AI] Add release note for PR 7322 (#7311)

Made-with: Cursor

* [AI] Tighten release note wording for PR 7322 (imperative)

Made-with: Cursor

---------

Co-authored-by: Pranay Mac M1 <pranayseela@yahoo.com>

---------

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
Co-authored-by: Pranay S <pranayritvik@gmail.com>
Co-authored-by: Pranay Mac M1 <pranayseela@yahoo.com>
Co-authored-by: youngcw <calebyoung94@gmail.com>

* Implement Sankey report for spent and budgeted money (#7220)

* Implement Sankey graph report

* Add release notes

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6068

* Remove local debug settings

* [autofix.ci] apply automated fixes

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6068

* Improve graphs from comments

* Fix lints

* coderabit fixes

* Fix filtering and UI enhancements

* remove pngs

* Fix typecheck

* Another type issue

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6068

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6068

* Fix strict typing issues

* Update report page

Now better conforms with components from other reports, e.g. by reusing Header
Makes it possible to display a period longer than one month.

* Change view description order

* Formatting and cleanup

* Removed difference section, as it will be difficult to get a reliable view across months

* Introduce the Timeframe param, similar to Spending report, to allow saving a Live sliding window.

* Allow filtering just the last month

* Fix linting errors

* Remove all information about income

* Remove debugging statement

* Sort categories and subcategories by amount

* Move compact mode to spreadsheet to fix Card view more easily

* Update tests file

* Add release notes

* Rename release notes to match PR#

* Fix autofix.ci issues

* Update packages/desktop-client/e2e/sankey.test.ts

Enable experimental feature fall all tests, pr. coderabbit recommendation

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

* Add sankey-card to isWidgetType

* Gate Sankey routes to prevent direct URL bypass

* Fix typo

* Change node transformation to work by key instead of name, to remove risk of duplicate issues

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

* Prevent false-positive pass in month-change test.

* Translate mode to a proper label

* Fix message for empty data

* Enabled LoadingIndicator until data is ready

* Change card default mode

* More robust filtering

* Fixed issue with budgeted spreadsheet not using 'end' date

* Allow copying SankeyCard to dashboard

* Fix typing and linting issues

* Remove e2e tests

I cannot currently get them to pass, because I dont fully understand playwright and how they are supposed to work. I can see that they don't exist for other reports. We can add them later if required.

* Remove unecessary sankey reference

* Refactor spreadsheet

* Remove dead code from SankeyGraph

* Collect to Other if too many subcategories

* Edit wrong comment

* Linting and typechecking

* Show remaining amount to budget

* Hide description on narrow device

* Add visual clue if 'To budget' is larger than 'Budgeted' and would extend below the edge of the graph

* Add colors to the links

* Fix report card showing subcategories instead of main categories

* Add tooltip info to Other on SankeyCard

* Create globalOther flag and implement greedy category reduction algorithm

* Allow user to select between Global or Per category Other

* Allow user to choose number of subcategories to show

* Allow user to select how subcategories are sorted

* Fix budget filtering

* [autofix.ci] apply automated fixes

* Condense sorting and Other-grouping to one option

* Implement Sort as budget option

* Dynamically adjust topN based on SankeyCard height

* Remove old feature flags from previous PR

---------

Co-authored-by: andrewhumble <43395285+andrewhumble@users.noreply.github.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>
Co-authored-by: youngcw <calebyoung94@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Fix yarn generate:icons command (#7281)

* fix icon templates with `module.exports` to `export default`

* Add `@svgr/babel-plugin-add-jsx-attribute` to dependencies

* Run `yarn generate:icons`, and set prettier singleQuote to reduce changes

* Add release note

* Add temporary fix for `SvgChartArea`

* Add `ChartArea` svg from the existing tsx

* CI rerun

* Standardise ledger scrolling when using keyboard shortcuts (#7283)

* Standardise table keyboard navigation by preventing browser scroll with arrow keys

* Add release note

* Apply the preventDefault() in specific cases so that it is not applied to default

---------

Co-authored-by: youngcw <calebyoung94@gmail.com>

* Bump lodash from 4.17.23 to 4.18.1

Bumps [lodash](https://github.com/lodash/lodash) from 4.17.23 to 4.18.1.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.23...4.18.1)

---
updated-dependencies:
- dependency-name: lodash
  dependency-version: 4.18.1
  dependency-type: direct:development
...

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

* note

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
Co-authored-by: Juulz <julesmcn@gmail.com>
Co-authored-by: Pranay S <pranayritvik@gmail.com>
Co-authored-by: Pranay Mac M1 <pranayseela@yahoo.com>
Co-authored-by: youngcw <calebyoung94@gmail.com>
Co-authored-by: Emil Tveden Bjerglund <emilbp@gmail.com>
Co-authored-by: andrewhumble <43395285+andrewhumble@users.noreply.github.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>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: James Skinner <56730344+JSkinnerUK@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>
2026-04-07 10:55:46 +00:00
Matt Fiddaman
8511687da4 replace nordigen-node with our own GoCardless implementation (#7352)
* implement our own GoCardless api class

* switch the service to use the new api

* drop deps

* note

* guard against request forgery

* strip empty params from the request body, add error logging

* coderabbit suggestions

* fix test, make institution nullable
2026-04-07 10:33:04 +00:00
Matt Fiddaman
a394aa1a57 remove useless assignments to local variables (#7354)
* fixes

* note

* enforce with lint

* identical operands

* unused state

* fix unused imports in new sankey report
2026-04-07 10:32:30 +00:00
dependabot[bot]
5eaf0be744 Bump vite from 8.0.0 to 8.0.5 (#7398)
* Bump vite from 8.0.0 to 8.0.5

Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.0 to 8.0.5.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.5/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.5
  dependency-type: direct:development
...

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-04-07 10:28:57 +00:00
Matt Fiddaman
bb7d7275a6 migrate actualbudget/actions to the main repo (#7406)
* migrate release note actions

* move workflows to use the local actions

* note

* fix failing cleanup in release notes action

* fix codeQL violation
2026-04-07 10:28:30 +00:00
Walter
4fe79e890b Issue 6856 crypt missing due to protocol isn't marked as secure (#7368)
* crypt missing due to protocol isn't marked as secure

* [autofix.ci] apply automated fixes

* release notes

* remove binary files

* Update upcoming-release-notes/7368.md

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

---------

Co-authored-by: Walter <walter@72:bf:48:9d:a9:b6.home>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2026-04-07 08:04:43 +00:00
Matiss Janis Aboltins
7af0910d4e [AI] Improve theme catalog responsive layout in ThemeInstaller (#7253)
* [AI] Improve theme catalog responsive layout in ThemeInstaller

* Refactor ColorPalette and ThemeInstaller components for improved layout and responsiveness

* Fix typo in Custom Reports description

* Correct category name from 'Reports' to 'Themes'

* [AI] Fix theme catalog scrollbar overlapping content

Reserve space for the scrollbar by adding right padding to catalog rows
so items don't get clipped when the list overflows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:41:48 +00:00
Matiss Janis Aboltins
093d869bba [AI] Add .claude/worktrees directory to .gitignore (#7344)
* [AI] Add .claude/ directory to .gitignore

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Modify .gitignore for Claude worktrees

Update .gitignore to include Claude worktrees and exclude settings.json

* Update .gitignore to include claude worktree folder

* Add .claude/settings.local.json to .gitignore

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:41:33 +00:00
Matiss Janis Aboltins
fc5f598098 docs: Add guide for self-signed SSL certificates in CLI (#7360)
* [AI] Add self-signed SSL certificate documentation to CLI docs

Add a section explaining how to use NODE_TLS_REJECT_UNAUTHORIZED=0
to allow the CLI to connect to servers with self-signed SSL certificates,
with a security caution about the implications.

Closes #7327

https://claude.ai/code/session_01Mwsuc9By67uzSiMLxvPsMq

* Add release notes for PR #7360

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-07 07:41:22 +00:00
Matiss Janis Aboltins
799db6c496 [AI] Improve post-checkout hook to handle git worktree creation (#7393)
* [AI] Add worktree creation hook to run yarn install

Add a WorktreeCreate hook in .claude/settings.json that runs a setup
script after creating a new git worktree. The script creates a detached
worktree and runs yarn install to ensure dependencies are available.

https://claude.ai/code/session_01MjuJLWcxNU6nbWQDrgGpPr

* [AI] Move worktree setup hook to husky post-checkout for universal support

Instead of a Claude Code-specific hook, detect new worktree creation in
the existing husky post-checkout hook by checking for missing node_modules.
This works with any tool that creates git worktrees (Claude Code, Cursor,
CLI, etc.) since git runs post-checkout after worktree creation.

https://claude.ai/code/session_01MjuJLWcxNU6nbWQDrgGpPr

* Add release notes for PR #7393

* Update 7393.md

* [AI] Fix post-checkout hook to propagate yarn install failures

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-04-07 07:40:47 +00:00
Kennedy242
477fed1607 Add documentation for self-signed cert healthchecks (#7397)
* Add documentation for self-signed cert healthchecks

Adds instruction to set NODE_EXTRA_CA_CERTS in docker-compose healthcheck commands so Node.js can trust the certificate.

* [autofix.ci] apply automated fixes

* fixup! Add documentation for self-signed cert healthchecks

* [autofix.ci] apply automated fixes

* Update Docker health checks with self-signed certs

If using self signed certs, comment the first test line and uncomment
the second test line.

* fixup! Update Docker health checks with self-signed certs

* fixup! Add documentation for self-signed cert healthchecks

* fixup! Update Docker health checks with self-signed certs

* [autofix.ci] apply automated fixes

* fixup! Add documentation for self-signed cert healthchecks

* fixup! Update Docker health checks with self-signed certs

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-07 00:09:35 +00:00
Juulz
64b2d9b31a [Docs] Tour: Update reports, payees, schedules and rules pages. (#7405)
* Improve clarity of reports documentation

Revised descriptions for built-in and custom reports for clarity and conciseness.

* Revise schedules documentation for clarity and conciseness

* Update payees management documentation for clarity

* Update wording and formatting in rules documentation

* Fix formatting issue in schedules.md

* Clarify Payees management overview

Revised the description to clarify the overview of Payees management.

* [autofix.ci] apply automated fixes

* Fix hyphenation in reports documentation

* [autofix.ci] apply automated fixes

* Update packages/docs/docs/tour/reports.md

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

* Apply suggestions from code review

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

* Update transaction rule example in documentation

* Apply suggestions from code review

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

* [autofix.ci] apply automated fixes

* Add 'PAYPAL' to spelling expectations

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-04-06 22:30:38 +00:00
Juulz
5511d508ba [Docs] Tour: Move the Tour to website top level and add a sidebar for the tour (#7399)
* Add tourSidebar with various tour documentation

Added a new sidebar for the tour section with multiple entries.

* Add 'Tour Actual' sidebar to Docusaurus config

* Remove 'A Tour of Actual' from docs sidebar

Removed 'A Tour of Actual' category and its items from the sidebar.

* Rename 'Tour Actual' to 'Tour' in sidebar
2026-04-06 22:25:53 +00:00
Victor
59839f83e3 Proportional distribute for split transaction (#7257)
* Add button to proportionally distribute remaining amount of split transaction among child transactions

* Added release note

* Increased min width for split error popover so all buttons are visible

* Updated release note

* Merge proportional distribution into even distribution button

* Added docs on split transactions

* [autofix.ci] apply automated fixes

* Fixed spelling

* [autofix.ci] apply automated fixes

* Change split transaction popover hack to use resize event

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2026-04-06 21:51:18 +00:00
Matt Fiddaman
3ec6eeabb1 add confirmation dialog when changing half-reconciled transfers (#7269)
* check paired transfer transactions for reconciliation status

* add confirm messages

* note

* bulk edit

* improve types

* add checks to individual transaction edits

* add to mobile

* wabbit
2026-04-06 21:15:23 +00:00
Lucas Alvarez
ceaf13f271 feat: Add CLP currency (#7346) 2026-04-06 21:14:54 +00:00
dependabot[bot]
8e1d4a8b27 Bump electron from 39.2.7 to 39.8.4 (#7367)
* Bump electron from 39.2.7 to 39.8.4

Bumps [electron](https://github.com/electron/electron) from 39.2.7 to 39.8.4.
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v39.2.7...v39.8.4)

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 39.8.4
  dependency-type: direct:development
...

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

* note

* tidy up electron dependencies

* 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-04-06 21:23:27 +00:00
Matt Fiddaman
3bbcb60fe6 pin minimatch versions to resolve vulnerability reports (#7355)
* pin minimatch versions

* note
2026-04-06 21:14:38 +00:00
Juulz
c2319cdcb5 [Docs] Tour: update tour budgeting page for clarity (#7403)
* Improve clarity and formatting in budget.md

* Fix grammatical error in budget documentation

* Update packages/docs/docs/tour/budget.md

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

* Update packages/docs/docs/tour/budget.md

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

---------

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2026-04-06 21:14:32 +00:00
Juulz
d3895042bb Update account documentation for clarity and accuracy (#7404) 2026-04-06 20:51:54 +00:00
Juulz
533cbed106 [Docs] Tour: Update user interface page for clarity (#7402)
* Refine user interface documentation for clarity

Updated text for clarity and corrected typos in the user interface documentation.

* Clarify server status and sync icon details

Updated descriptions for server status and sync icon interactions.

* Clarify server status and sidebar account display

Updated server status descriptions for clarity and improved wording in the sidebar section.
2026-04-06 20:49:23 +00:00
Juulz
221a57e218 [Docs] Tour: remove old files (#7400)
* Delete packages/docs/docs/tour/settings.md

NO longer in use.

* Delete packages/docs/docs/tour/sidebar.md

No longer in use.
2026-04-06 20:48:53 +00:00
Juulz
23bad279a0 [Docs] Tour: Update tour landing page for clarity and accuracy (#7401)
* Update tour documentation for clarity and accuracy

* Fix formatting and phrasing in tour documentation

* Fix link to documentation in tour index
2026-04-06 20:47:06 +00:00
L. Warren Thompson
d262f7d8b2 Fix updateTransaction corrupting split parents with partial updates (#7242)
* [AI] Fix updateTransaction corrupting split parents with partial updates

When `api.updateTransaction(id, { notes: '...' })` is called on a split
parent, the `updateTransaction` helper replaces the parent with the
sparse update object (`{ id, notes }`) instead of merging it with
the existing transaction data.  This causes `recalculateSplit` to see
`amount` as `undefined` (→ 0), which doesn't match the children's
total and sets a `SplitTransactionError` on the parent.  `makeChild`
also inherits undefined `account`, `date`, and `cleared` values,
potentially creating broken child rows.

Fix: merge the incoming partial fields (`{ ...trans, ...transaction }`)
so all existing properties are preserved.

Add a test that performs a notes-only update on a split parent and
asserts no error is set and the amount stays intact.

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

* [AI] Add release notes for PR #7242

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

* Address review feedback: remove verbose comment and simplify release note

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

---------

Co-authored-by: L. Warren Thompson <lwarrenthompson@Warren-MBP.local>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 18:12:51 +01:00
James Skinner
c75a94e8b0 Standardise ledger scrolling when using keyboard shortcuts (#7283)
* Standardise table keyboard navigation by preventing browser scroll with arrow keys

* Add release note

* Apply the preventDefault() in specific cases so that it is not applied to default

---------

Co-authored-by: youngcw <calebyoung94@gmail.com>
2026-04-05 18:12:51 +01:00
James Skinner
cb50930d0b Fix yarn generate:icons command (#7281)
* fix icon templates with `module.exports` to `export default`

* Add `@svgr/babel-plugin-add-jsx-attribute` to dependencies

* Run `yarn generate:icons`, and set prettier singleQuote to reduce changes

* Add release note

* Add temporary fix for `SvgChartArea`

* Add `ChartArea` svg from the existing tsx

* CI rerun
2026-04-05 18:12:51 +01:00
Emil Tveden Bjerglund
10b7385ad4 Implement Sankey report for spent and budgeted money (#7220)
* Implement Sankey graph report

* Add release notes

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6068

* Remove local debug settings

* [autofix.ci] apply automated fixes

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6068

* Improve graphs from comments

* Fix lints

* coderabit fixes

* Fix filtering and UI enhancements

* remove pngs

* Fix typecheck

* Another type issue

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6068

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6068

* Fix strict typing issues

* Update report page

Now better conforms with components from other reports, e.g. by reusing Header
Makes it possible to display a period longer than one month.

* Change view description order

* Formatting and cleanup

* Removed difference section, as it will be difficult to get a reliable view across months

* Introduce the Timeframe param, similar to Spending report, to allow saving a Live sliding window.

* Allow filtering just the last month

* Fix linting errors

* Remove all information about income

* Remove debugging statement

* Sort categories and subcategories by amount

* Move compact mode to spreadsheet to fix Card view more easily

* Update tests file

* Add release notes

* Rename release notes to match PR#

* Fix autofix.ci issues

* Update packages/desktop-client/e2e/sankey.test.ts

Enable experimental feature fall all tests, pr. coderabbit recommendation

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

* Add sankey-card to isWidgetType

* Gate Sankey routes to prevent direct URL bypass

* Fix typo

* Change node transformation to work by key instead of name, to remove risk of duplicate issues

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

* Prevent false-positive pass in month-change test.

* Translate mode to a proper label

* Fix message for empty data

* Enabled LoadingIndicator until data is ready

* Change card default mode

* More robust filtering

* Fixed issue with budgeted spreadsheet not using 'end' date

* Allow copying SankeyCard to dashboard

* Fix typing and linting issues

* Remove e2e tests

I cannot currently get them to pass, because I dont fully understand playwright and how they are supposed to work. I can see that they don't exist for other reports. We can add them later if required.

* Remove unecessary sankey reference

* Refactor spreadsheet

* Remove dead code from SankeyGraph

* Collect to Other if too many subcategories

* Edit wrong comment

* Linting and typechecking

* Show remaining amount to budget

* Hide description on narrow device

* Add visual clue if 'To budget' is larger than 'Budgeted' and would extend below the edge of the graph

* Add colors to the links

* Fix report card showing subcategories instead of main categories

* Add tooltip info to Other on SankeyCard

* Create globalOther flag and implement greedy category reduction algorithm

* Allow user to select between Global or Per category Other

* Allow user to choose number of subcategories to show

* Allow user to select how subcategories are sorted

* Fix budget filtering

* [autofix.ci] apply automated fixes

* Condense sorting and Other-grouping to one option

* Implement Sort as budget option

* Dynamically adjust topN based on SankeyCard height

* Remove old feature flags from previous PR

---------

Co-authored-by: andrewhumble <43395285+andrewhumble@users.noreply.github.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>
Co-authored-by: youngcw <calebyoung94@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-04-05 18:12:51 +01:00
Juulz
78ce7da1b4 [Doc] More tour image (mostly) updates & a hotkey fix (#7328)
* Fix keyboard shortcut Mac key for undo operations

Updated keyboard shortcut instructions for Mac & make consistent.

* Add files via upload

* Fix undo shortcut from 'K' to 'Z'

Updated keyboard shortcut for undo operation in payees guide. COFFEE!

* Revise budget section for clarity and consistency

Updated category descriptions and improved Markdown support details.

* Add files via upload

* Fix grammatical error in budget.md

* Fix typo and clarify Markdown description in budget.md

Corrected a typo in the documentation regarding the chevrons and clarified the description of rendered Markdown.

* Fix spelling error in budget documentation

Corrected the spelling of 'cheverons' to 'chevrons'.

* Add files via upload

* Remove redundant text in budget.md

* Fix formatting issues in payees.md

* count points script should fetch the release note from the PR directly (#7309)

* get pr release note from PR, not top of master

* note

* [AI] Mobile: Post transaction today on global account lists (#7311) (#7322)

* [AI] Mobile: pass today for Post transaction today on global account lists (#7311)

All Accounts, On budget, and Off budget transaction lists now forward the
today flag to schedule/post-transaction, matching single-account mobile
and desktop behavior.

Made-with: Cursor

* [AI] Add release note for PR 7322 (#7311)

Made-with: Cursor

* [AI] Tighten release note wording for PR 7322 (imperative)

Made-with: Cursor

---------

Co-authored-by: Pranay Mac M1 <pranayseela@yahoo.com>

---------

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
Co-authored-by: Pranay S <pranayritvik@gmail.com>
Co-authored-by: Pranay Mac M1 <pranayseela@yahoo.com>
Co-authored-by: youngcw <calebyoung94@gmail.com>
2026-04-05 18:12:51 +01:00
Matt Fiddaman
b03080b246 trim down some unused/unnecessary dependencies (#7350)
* fix github actions inconsistencies

* fix pinning of transitive deps in eslint-plugin

* drop use of node-fetch in api

* drop md5 dependency in favour of node:crypto

* drop slash

* drop unused top level packages

* add note about node-polyfills warning

* remove unused deps from desktop-client

* drop pegjs types

* note

* drop node-jq
2026-04-05 18:12:51 +01:00
Matt Fiddaman
a12b971670 🔖 (26.4.0) (#7389)
* bump versions

* Remove used release notes

* add docs pages

* Update check-spelling metadata

* bump cli

* change release date

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-05 18:12:51 +01:00
youngcw
475272adce Revert "[AI] fix: preserve explicit category on imported transactions" (#7388)
* Revert "[AI] fix: preserve explicit category on imported transactions (#7185)"

This reverts commit 91e839353f.

* Add release notes for PR #7388

* Delete upcoming-release-notes/7388.md

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-05 15:22:45 +01:00
youngcw
353e12a009 🐛 fix split popover (#7372)
* pull in change

* note
2026-04-04 12:47:42 +00:00
Matiss Janis Aboltins
9a30a14bf9 [AI] Fix unrecoverable UI error after login (#7341) (#7361)
* [AI] Fix unrecoverable UI error after login (#7341)

The crash was in CommandBar.tsx where item.name.toLowerCase() was called
on items with null/undefined names (from custom reports or dashboard
pages with null name columns in the database). Added optional chaining.

Also hardened React Query data hooks to always return safe defaults even
in error states, and guarded $oneof AQL operator against empty arrays
generating invalid SQL.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [autofix.ci] apply automated fixes

* [AI] Revert hook/compiler changes, keep CommandBar fix, add release notes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Default null names to empty string at data source

Instead of optional chaining at the consumer, ensure names are never
null by defaulting at the data source: reportModel.toJS for custom
reports and dashboardQueries for dashboard pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-03 20:41:32 +00:00
Matiss Janis Aboltins
c0c2d1630e [AI] Fix password login broken when OIDC is enabled (#7334)
* [AI] Fix password login broken when OIDC is enabled (#7331)

The security fix in PR #7155 added an `active = 1` check to
`getLoginMethod()`, which prevented password login when OIDC was the
active auth method. This caused the server to silently reroute password
login requests to the OpenID flow, which fails.

Remove the `active` requirement when the client explicitly requests a
login method — only require the method to exist in the auth table. The
`active` flag still governs the default method via `getActiveLoginMethod()`.

The `/change-password` endpoint security protections (admin role +
password auth_method checks) remain intact.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add release notes for PR #7334

* Update getLoginMethod to validate client-requested login methods against the auth database

Modified the getLoginMethod function to check if the requested login method exists in the auth table before returning it. Updated the corresponding test to reflect that a client-requested method not found in the database will be ignored, ensuring proper handling of login methods.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-02 18:34:36 +00:00
dependabot[bot]
556bea0953 Bump @xmldom/xmldom from 0.8.11 to 0.8.12 (#7348)
* Bump @xmldom/xmldom from 0.8.11 to 0.8.12

Bumps [@xmldom/xmldom](https://github.com/xmldom/xmldom) from 0.8.11 to 0.8.12.
- [Release notes](https://github.com/xmldom/xmldom/releases)
- [Changelog](https://github.com/xmldom/xmldom/blob/master/CHANGELOG.md)
- [Commits](https://github.com/xmldom/xmldom/compare/0.8.11...0.8.12)

---
updated-dependencies:
- dependency-name: "@xmldom/xmldom"
  dependency-version: 0.8.12
  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-04-01 19:41:21 +00:00
Matiss Janis Aboltins
4b5c0a79a7 [AI] Pin axios to 1.14.0 to avoid vulnerable 1.14.1 (#7343)
* [AI] Pin axios to 1.14.0 to avoid vulnerable 1.14.1

Add yarn resolution to prevent axios from being upgraded to 1.14.1,
which contains a critical vulnerability.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add release notes for PR #7343

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-31 22:04:54 +00:00
Pranay S
3b14fd08c3 [AI] Mobile: Post transaction today on global account lists (#7311) (#7322)
* [AI] Mobile: pass today for Post transaction today on global account lists (#7311)

All Accounts, On budget, and Off budget transaction lists now forward the
today flag to schedule/post-transaction, matching single-account mobile
and desktop behavior.

Made-with: Cursor

* [AI] Add release note for PR 7322 (#7311)

Made-with: Cursor

* [AI] Tighten release note wording for PR 7322 (imperative)

Made-with: Cursor

---------

Co-authored-by: Pranay Mac M1 <pranayseela@yahoo.com>
2026-03-30 15:46:59 +00:00
Matt Fiddaman
a729b9a4a0 count points script should fetch the release note from the PR directly (#7309)
* get pr release note from PR, not top of master

* note
2026-03-30 15:44:57 +00:00
Juulz
4820331be9 [Doc] A few new images in the tour to fix spelling errors (#7325)
* Add files via upload

* Add files via upload

* Add files via upload
2026-03-29 22:48:41 +01:00
L. Warren Thompson
f7e9ced9e3 Fix balance_current not syncing to API clients after bank sync (#7243)
* [AI] Fix balance_current not syncing to API clients after bank sync

updateAccountBalance() used db.runQuery() (raw SQL) to set
balance_current, which bypasses the CRDT sync layer.  This means
when SimpleFIN or GoCardless updates an account's bank balance on
the server, API clients calling api.sync() never receive the change —
balance_current stays null or stale in their local database.

Fix: use db.update() which goes through sendMessages/CRDT, ensuring
balance_current propagates to all synced clients just like other
account fields (name, closed, etc.).

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

* [AI] Add release notes for PR #7243

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

* Address review feedback: remove verbose comment and simplify release note

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

---------

Co-authored-by: L. Warren Thompson <lwarrenthompson@Warren-MBP.local>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 22:19:19 +00:00
dependabot[bot]
4dfba02cba Bump axios from 1.12.2 to 1.14.0 (#7308)
* Bump axios from 1.12.2 to 1.14.0

Bumps [axios](https://github.com/axios/axios) from 1.12.2 to 1.14.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.12.2...v1.14.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.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-27 21:38:19 +00:00
dependabot[bot]
29f55a18ce Bump brace-expansion from 1.1.12 to 1.1.13 (#7306)
* Bump brace-expansion from 1.1.12 to 1.1.13

Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 1.1.12 to 1.1.13.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.12...v1.1.13)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.13
  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-27 20:44:21 +00:00
dependabot[bot]
1a5dfc4692 Bump node-forge from 1.3.2 to 1.4.0 (#7307)
* Bump node-forge from 1.3.2 to 1.4.0

Bumps [node-forge](https://github.com/digitalbazaar/forge) from 1.3.2 to 1.4.0.
- [Changelog](https://github.com/digitalbazaar/forge/blob/main/CHANGELOG.md)
- [Commits](https://github.com/digitalbazaar/forge/compare/v1.3.2...v1.4.0)

---
updated-dependencies:
- dependency-name: node-forge
  dependency-version: 1.4.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-27 20:47:28 +00:00
dependabot[bot]
756320ecb7 Bump yaml from 1.10.2 to 1.10.3 (#7286)
* Bump yaml from 1.10.2 to 1.10.3

Bumps [yaml](https://github.com/eemeli/yaml) from 1.10.2 to 1.10.3.
- [Release notes](https://github.com/eemeli/yaml/releases)
- [Commits](https://github.com/eemeli/yaml/compare/v1.10.2...v1.10.3)

---
updated-dependencies:
- dependency-name: yaml
  dependency-version: 1.10.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-27 20:20:04 +00:00
dependabot[bot]
216fc747d1 Bump picomatch from 2.3.1 to 2.3.2 (#7287)
* Bump picomatch from 2.3.1 to 2.3.2

Bumps [picomatch](https://github.com/micromatch/picomatch) from 2.3.1 to 2.3.2.
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 2.3.2
  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-27 20:08:02 +00:00
dependabot[bot]
82509b053f Bump handlebars from 4.7.8 to 4.7.9 (#7298)
* Bump handlebars from 4.7.8 to 4.7.9

Bumps [handlebars](https://github.com/handlebars-lang/handlebars.js) from 4.7.8 to 4.7.9.
- [Release notes](https://github.com/handlebars-lang/handlebars.js/releases)
- [Changelog](https://github.com/handlebars-lang/handlebars.js/blob/v4.7.9/release-notes.md)
- [Commits](https://github.com/handlebars-lang/handlebars.js/compare/v4.7.8...v4.7.9)

---
updated-dependencies:
- dependency-name: handlebars
  dependency-version: 4.7.9
  dependency-type: direct:production
...

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-27 19:57:42 +00:00
dependabot[bot]
24382e0e14 Bump convict from 6.2.4 to 6.2.5 (#7293)
* Bump convict from 6.2.4 to 6.2.5

Bumps [convict](https://github.com/mozilla/node-convict) from 6.2.4 to 6.2.5.
- [Changelog](https://github.com/mozilla/node-convict/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mozilla/node-convict/commits)

---
updated-dependencies:
- dependency-name: convict
  dependency-version: 6.2.5
  dependency-type: direct:production
...

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-27 19:55:04 +00:00
Matt Fiddaman
798fcc9eee amend maintainer points counting script docs file filter (#7305)
* fix

* note
2026-03-27 19:31:29 +00:00
1544 changed files with 23650 additions and 15465 deletions

View File

@@ -16,14 +16,19 @@ if (!token || !repo || !issueNumber || !summaryDataJson || !category) {
const [owner, repoName] = repo.split('/');
const octokit = new Octokit({ auth: token });
const VALID_CATEGORIES = [
'Features',
'Bugfixes',
'Enhancements',
'Maintenance',
];
const GITHUB_USERNAME_RE =
/^[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38}$/;
async function createReleaseNotesFile() {
try {
const summaryData = JSON.parse(summaryDataJson);
console.log('Debug - Category value:', category);
console.log('Debug - Category type:', typeof category);
console.log('Debug - Category JSON stringified:', JSON.stringify(category));
if (!summaryData) {
console.log('No summary data available, cannot create file');
return;
@@ -34,26 +39,62 @@ async function createReleaseNotesFile() {
return;
}
// Create file content - ensure category is not quoted
// Normalize category - strip surrounding quotes and validate against allow-list
const cleanCategory =
typeof category === 'string'
? category.replace(/^["']|["']$/g, '')
: category;
console.log('Debug - Clean category:', cleanCategory);
if (!VALID_CATEGORIES.includes(cleanCategory)) {
console.log(
`Invalid category "${cleanCategory}". Must be one of: ${VALID_CATEGORIES.join(', ')}`,
);
return;
}
// Validate author is a plausible GitHub username
const author = String(summaryData.author || '');
if (!GITHUB_USERNAME_RE.test(author)) {
console.log(
`Invalid author "${author}", aborting release notes creation`,
);
return;
}
// Normalize summary: collapse whitespace to a single line so it cannot
// introduce extra YAML frontmatter or break the markdown structure.
const cleanSummary = String(summaryData.summary || '')
.replace(/\s+/g, ' ')
.trim();
if (!cleanSummary) {
console.log('Empty summary, aborting release notes creation');
return;
}
// Validate PR number - must be a positive integer. The value comes from
// the GitHub API, but we harden it because it's used to build a file path
// and a commit message.
const validatedPrNumber = Number(summaryData.prNumber);
if (!Number.isInteger(validatedPrNumber) || validatedPrNumber <= 0) {
console.log(
`Invalid PR number "${summaryData.prNumber}", aborting release notes creation`,
);
return;
}
const fileContent = `---
category: ${cleanCategory}
authors: [${summaryData.author}]
authors: [${author}]
---
${summaryData.summary}
${cleanSummary}
`;
const fileName = `upcoming-release-notes/${summaryData.prNumber}.md`;
const fileName = `upcoming-release-notes/${validatedPrNumber}.md`;
console.log(`Creating release notes file: ${fileName}`);
console.log('File content:');
console.log(fileContent);
console.log(
`Creating release notes file: ${fileName} (category: ${cleanCategory}, author: ${author})`,
);
// Get PR info
const { data: pr } = await octokit.rest.pulls.get({
@@ -75,7 +116,7 @@ ${summaryData.summary}
owner: headOwner,
repo: headRepo,
path: fileName,
message: `Add release notes for PR #${summaryData.prNumber}`,
message: `Add release notes for PR #${validatedPrNumber}`,
content: Buffer.from(fileContent).toString('base64'),
branch: prBranch,
committer: {

View File

@@ -25,8 +25,6 @@ try {
process.exit(0);
}
console.log('CodeRabbit comment body:', commentBody);
const data = JSON.stringify({
model: 'gpt-4o-mini',
messages: [

View File

@@ -39,6 +39,22 @@ async function getPRDetails() {
console.log('- Base Branch:', pr.base.ref);
console.log('- Head Branch:', pr.head.ref);
// Fetch all changed files to detect docs-only PRs
const files = await octokit.paginate(octokit.rest.pulls.listFiles, {
owner,
repo: repoName,
pull_number: issueNumber,
per_page: 100,
});
const changedFiles = files.map(f => f.filename);
const isDocsOnly =
changedFiles.length > 0 &&
changedFiles.every(file => file.startsWith('packages/docs/'));
console.log('- Changed Files:', changedFiles.length);
console.log('- Is Docs Only:', isDocsOnly);
const result = {
number: pr.number,
author: pr.user.login,
@@ -47,11 +63,31 @@ async function getPRDetails() {
headBranch: pr.head.ref,
};
let eligible = true;
if (pr.base.ref !== 'master') {
console.log(
'PR does not target master branch, skipping release notes generation',
);
eligible = false;
} else if (pr.head.ref.startsWith('release/')) {
console.log(
'PR head branch is a release branch, skipping release notes generation',
);
eligible = false;
} else if (isDocsOnly) {
console.log(
'PR only changes documentation, skipping release notes generation',
);
eligible = false;
}
setOutput('result', JSON.stringify(result));
setOutput('eligible', JSON.stringify(eligible));
} catch (error) {
console.log('Error getting PR details:', error.message);
console.log('Stack:', error.stack);
setOutput('result', 'null');
setOutput('eligible', 'false');
process.exit(1);
}
}
@@ -60,5 +96,6 @@ getPRDetails().catch(error => {
console.log('Unhandled error:', error.message);
console.log('Stack:', error.stack);
setOutput('result', 'null');
setOutput('eligible', 'false');
process.exit(1);
});

View File

@@ -68,7 +68,6 @@ ignore$
^\Qsrc/\E$
^\Qstatic/\E$
^\Q.github/\E$
(?:^|/)package(?:-lock|)\.json$
(?:^|/)yarn\.lock$
(?:^|/)(?i)docusaurus.config.js
(?:^|/)(?i)README.md

View File

@@ -4,6 +4,7 @@ ABNANL
Activo
actualrc
AESUDEF
ajv
ALZEY
Anglais
ANZ
@@ -125,6 +126,7 @@ Moldovan
murmurhash
NETWORKDAYS
nginx
nodenext
OIDC
Okabe
overbudgeted
@@ -132,6 +134,8 @@ overbudgeting
oxc
Paribas
passwordless
PAYPAL
picomatch
pluggyai
Poste
PPABPLPK
@@ -175,6 +179,8 @@ THB
TIMEFRAME
touchscreen
triaging
tsgo
TWD
UAH
ubuntu
undici

View File

@@ -0,0 +1,17 @@
name: Check release notes
description: Validate that a PR includes a properly formatted release note file
runs:
using: composite
steps:
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
- name: Install dependencies
shell: bash
run: yarn workspaces focus @actual-app/ci-actions
- name: Check release notes
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
shell: bash
run: node packages/ci-actions/bin/release-notes-check.mjs

View File

@@ -0,0 +1,17 @@
name: Generate release notes
description: Generate release documentation from release note files
runs:
using: composite
steps:
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
- name: Install dependencies
shell: bash
run: yarn workspaces focus @actual-app/ci-actions
- name: Generate release notes
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
run: node packages/ci-actions/bin/release-notes-generate.mjs

View File

@@ -35,7 +35,11 @@ const CONFIG = {
'release-notes/**/*',
'upcoming-release-notes/**/*',
],
DOCS_FILES_PATTERN: 'packages/docs/**/*',
DOCS_FILES_PATTERNS: [
'packages/docs/**/*',
'!packages/docs/package.json',
'.github/actions/docs-spelling/*',
],
};
/**
@@ -57,78 +61,29 @@ function parseReleaseNotesCategory(content) {
return categoryMatch[1].trim();
}
/**
* Get the last commit SHA on or before a given date.
* @param {Octokit} octokit - The Octokit instance.
* @param {string} owner - Repository owner.
* @param {string} repo - Repository name.
* @param {Date} beforeDate - The date to find the last commit before.
* @returns {Promise<string|null>} The commit SHA or null if not found.
*/
async function getLastCommitBeforeDate(octokit, owner, repo, beforeDate) {
try {
// Get the default branch from the repository
const { data: repoData } = await octokit.repos.get({ owner, repo });
const defaultBranch = repoData.default_branch;
const { data: commits } = await octokit.repos.listCommits({
owner,
repo,
sha: defaultBranch,
until: beforeDate.toISOString(),
per_page: 1,
});
if (commits.length > 0) {
return commits[0].sha;
}
} catch {
// If error occurs, return null to fall back to default branch
}
return null;
}
/**
* Get the category and points for a PR by reading its release notes file.
* @param {Octokit} octokit - The Octokit instance.
* @param {string} owner - Repository owner.
* @param {string} repo - Repository name.
* @param {number} prNumber - PR number.
* @param {Date} monthEnd - The end date of the month to use as base revision.
* @returns {Promise<Object>} Object with category and points, or null if error.
* @param {string|null} releaseNoteBlobSha - The blob SHA of the release notes file, or null if not found.
* @returns {Promise<Object>} Object with category and points.
*/
async function getPRCategoryAndPoints(
octokit,
owner,
repo,
prNumber,
monthEnd,
releaseNoteBlobSha,
) {
const releaseNotesPath = `upcoming-release-notes/${prNumber}.md`;
try {
// Get the last commit of the month to use as base revision
const commitSha = await getLastCommitBeforeDate(
octokit,
owner,
repo,
monthEnd,
);
if (releaseNoteBlobSha) {
const { data: blob } = await octokit.git.getBlob({
owner,
repo,
file_sha: releaseNoteBlobSha,
});
// Try to read the release notes file from the last commit of the month
const { data: fileContent } = await octokit.repos.getContent({
owner,
repo,
path: releaseNotesPath,
ref: commitSha || undefined, // Use commit SHA if available, otherwise default branch
});
if (fileContent.content) {
// Decode base64 content
const content = Buffer.from(fileContent.content, 'base64').toString(
'utf-8',
);
const content = Buffer.from(blob.content, 'base64').toString('utf-8');
const category = parseReleaseNotesCategory(content);
const tier = CONFIG.PR_CONTRIBUTION_POINTS.find(e =>
e.categories.includes(category),
@@ -276,13 +231,25 @@ async function countContributorPoints() {
),
);
const docsFiles = filteredFiles.filter(file =>
minimatch(file.filename, CONFIG.DOCS_FILES_PATTERN, { dot: true }),
);
const codeFiles = filteredFiles.filter(
file =>
!minimatch(file.filename, CONFIG.DOCS_FILES_PATTERN, { dot: true }),
);
const isDocsFile = file => {
const positivePatterns = CONFIG.DOCS_FILES_PATTERNS.filter(
p => !p.startsWith('!'),
);
const negativePatterns = CONFIG.DOCS_FILES_PATTERNS.filter(p =>
p.startsWith('!'),
);
return (
positivePatterns.some(p =>
minimatch(file.filename, p, { dot: true }),
) &&
negativePatterns.every(p =>
minimatch(file.filename, p, { dot: true }),
)
);
};
const docsFiles = filteredFiles.filter(isDocsFile);
const codeFiles = filteredFiles.filter(file => !isDocsFile(file));
const docsChanges = docsFiles.reduce(
(sum, file) => sum + file.additions + file.deletions,
@@ -329,12 +296,15 @@ async function countContributorPoints() {
// Award points to PR author if they are a core maintainer
const prAuthor = pr.user?.login;
if (prAuthor && orgMemberLogins.has(prAuthor)) {
const releaseNoteFile = modifiedFiles.find(
file =>
file.filename === `upcoming-release-notes/${pr.number}.md`,
);
const categoryAndPoints = await getPRCategoryAndPoints(
octokit,
owner,
repo,
pr.number,
until,
releaseNoteFile?.sha ?? null,
);
if (categoryAndPoints) {

View File

@@ -42,11 +42,7 @@ jobs:
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
- name: Check if release notes file already exists
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/')
if: steps.pr-details.outputs.eligible == 'true'
id: check-release-notes-exists
run: node .github/actions/ai-generated-release-notes/check-release-notes-exists.js
env:
@@ -56,7 +52,7 @@ jobs:
PR_DETAILS: ${{ steps.pr-details.outputs.result }}
- name: Generate summary with OpenAI
if: steps.check-first-comment.outputs.result == 'true' && steps.check-release-notes-exists.outputs.result == 'false'
if: steps.check-release-notes-exists.outputs.result == 'false'
id: generate-summary
run: node .github/actions/ai-generated-release-notes/generate-summary.js
env:
@@ -65,7 +61,7 @@ jobs:
PR_DETAILS: ${{ steps.pr-details.outputs.result }}
- name: Determine category with OpenAI
if: steps.check-first-comment.outputs.result == 'true' && steps.check-release-notes-exists.outputs.result == 'false' && steps.generate-summary.outputs.result != 'null'
if: steps.generate-summary.outputs.result != 'null' && steps.generate-summary.outputs.result != ''
id: determine-category
run: node .github/actions/ai-generated-release-notes/determine-category.js
env:
@@ -75,7 +71,7 @@ jobs:
SUMMARY_DATA: ${{ steps.generate-summary.outputs.result }}
- name: Create and commit release notes file via GitHub API
if: steps.check-first-comment.outputs.result == 'true' && steps.check-release-notes-exists.outputs.result == 'false' && steps.generate-summary.outputs.result != 'null' && steps.determine-category.outputs.result != 'null' && steps.determine-category.outputs.result != ''
if: steps.determine-category.outputs.result != 'null' && steps.determine-category.outputs.result != ''
run: node .github/actions/ai-generated-release-notes/create-release-notes-file.js
env:
GITHUB_TOKEN: ${{ secrets.ACTIONS_UPDATE_TOKEN }}
@@ -85,7 +81,7 @@ jobs:
CATEGORY: ${{ steps.determine-category.outputs.result }}
- name: Comment on PR
if: steps.check-first-comment.outputs.result == 'true' && steps.check-release-notes-exists.outputs.result == 'false' && steps.generate-summary.outputs.result != 'null' && steps.determine-category.outputs.result != 'null' && steps.determine-category.outputs.result != ''
if: steps.determine-category.outputs.result != 'null' && steps.determine-category.outputs.result != ''
run: node .github/actions/ai-generated-release-notes/comment-on-pr.js
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -28,18 +28,18 @@ jobs:
with:
download-translations: 'false'
- name: Build API
run: cd packages/api && yarn build
run: yarn build:api
- name: Create package tgz
run: cd packages/api && yarn pack && mv package.tgz actual-api.tgz
- name: Prepare bundle stats artifact
run: cp packages/api/app/stats.json api-stats.json
- name: Upload Build
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: actual-api
path: packages/api/actual-api.tgz
- name: Upload API bundle stats
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: api-build-stats
path: api-stats.json
@@ -56,11 +56,18 @@ jobs:
run: cd packages/crdt && yarn build
- name: Create package tgz
run: cd packages/crdt && yarn pack && mv package.tgz actual-crdt.tgz
- name: Prepare bundle stats artifact
run: cp packages/crdt/dist/stats.json crdt-stats.json
- name: Upload Build
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: actual-crdt
path: packages/crdt/actual-crdt.tgz
- name: Upload CRDT bundle stats
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: crdt-build-stats
path: crdt-stats.json
web:
runs-on: ubuntu-latest
@@ -71,12 +78,12 @@ jobs:
- name: Build Web
run: yarn build:browser
- name: Upload Build
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: actual-web
path: packages/desktop-client/build
- name: Upload Build Stats
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: build-stats
path: packages/desktop-client/build-stats
@@ -84,7 +91,7 @@ jobs:
cli:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -96,12 +103,12 @@ jobs:
- 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
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: actual-cli
path: packages/cli/actual-cli.tgz
- name: Upload CLI bundle stats
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cli-build-stats
path: cli-stats.json
@@ -117,7 +124,7 @@ jobs:
- name: Build Server
run: yarn workspace @actual-app/sync-server build
- name: Upload Build
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: sync-server
path: packages/sync-server/build

View File

@@ -64,6 +64,15 @@ jobs:
download-translations: 'false'
- name: Test
run: yarn test
check-gh-actions:
runs-on: ubuntu-latest
permissions:
security-events: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
migrations:
if: github.event_name == 'pull_request'

View File

@@ -25,11 +25,11 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Initialize CodeQL
uses: github/codeql-action/init@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
with:
languages: javascript
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
with:
category: '/language:javascript'

View File

@@ -1,6 +1,9 @@
name: Generate release PR
name: Cut release branch
on:
schedule:
# 17:00 UTC on the 25th of each month
- cron: '0 17 25 * *'
workflow_dispatch:
inputs:
ref:
@@ -11,37 +14,52 @@ on:
description: 'Version number for the release (optional)'
required: false
default: ''
release-date:
description: 'Expected release date, YYYY-MM-DD (optional)'
required: false
default: ''
permissions:
contents: write
pull-requests: write
jobs:
generate-release-pr:
cut-release-branch:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.inputs.ref }}
ref: ${{ github.event.inputs.ref || 'master' }}
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Bump package versions
id: bump_package_versions
shell: bash
env:
INPUT_VERSION: ${{ github.event.inputs.version }}
run: |
declare -A packages=(
[web]="desktop-client"
[electron]="desktop-electron"
[sync]="sync-server"
[api]="api"
[cli]="cli"
[core]="loot-core"
)
declare -A new_versions
for key in "${!packages[@]}"; do
pkg="${packages[$key]}"
if [[ -n "${{ github.event.inputs.version }}" ]]; then
if [[ -n "$INPUT_VERSION" ]]; then
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 }}" \
--version "$INPUT_VERSION" \
--update)
else
version=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts \
@@ -50,15 +68,33 @@ jobs:
--update)
fi
eval "NEW_${key^^}_VERSION=\"$version\""
new_versions[$key]="$version"
done
echo "version=$NEW_WEB_VERSION" >> "$GITHUB_OUTPUT"
- name: Create PR
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
echo "version=${new_versions[web]}" >> "$GITHUB_OUTPUT"
- name: Compute release date
id: release_date
shell: bash
env:
INPUT_DATE: ${{ github.event.inputs['release-date'] }}
run: |
if [[ -n "$INPUT_DATE" ]]; then
echo "date=$INPUT_DATE" >> "$GITHUB_OUTPUT"
else
# default to the 1st of next month
echo "date=$(date -d '+1 month' '+%Y-%m-01')" >> "$GITHUB_OUTPUT"
fi
- name: Create release branch and PR
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
token: ${{ secrets.ACTIONS_UPDATE_TOKEN }}
commit-message: '🔖 (${{ steps.bump_package_versions.outputs.version }})'
title: '🔖 (${{ steps.bump_package_versions.outputs.version }})'
body: 'Generated by [generate-release-pr.yml](../tree/master/.github/workflows/generate-release-pr.yml)'
branch: 'release/v${{ steps.bump_package_versions.outputs.version }}'
body: |
Generated by [cut-release-branch.yml](../tree/master/.github/workflows/cut-release-branch.yml)
<!-- release-date:${{ steps.release_date.outputs.date }} -->
branch: 'release/${{ steps.bump_package_versions.outputs.version }}'
base: master

View File

@@ -54,14 +54,14 @@ jobs:
tags: ${{ env.TAGS }}
- name: Login to Docker Hub
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
if: github.event_name != 'pull_request' && !github.event.repository.fork
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
if: github.event_name != 'pull_request'
with:
registry: ghcr.io
@@ -76,7 +76,7 @@ jobs:
run: yarn build:server
- name: Build image for testing
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
push: false
@@ -93,7 +93,7 @@ jobs:
# This will use the cache from the earlier build step and not rebuild the image
# https://docs.docker.com/build/ci/github-actions/test-before-push/
- name: Build and push images
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
push: ${{ github.event_name != 'pull_request' }}

View File

@@ -58,13 +58,13 @@ jobs:
tags: ${{ env.TAGS }}
- name: Login to Docker Hub
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -78,7 +78,7 @@ jobs:
run: yarn build:server
- name: Build and push ubuntu image
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
push: true
@@ -87,7 +87,7 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
- name: Build and push alpine image
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
push: true

View File

@@ -79,12 +79,12 @@ jobs:
steps:
- name: check-spelling
id: spelling
uses: check-spelling/check-spelling@main
uses: check-spelling/check-spelling@cfb6f7e75bbfc89c71eaa30366d0c166f1bd9c8c # main
with:
suppress_push_for_open_pull_request: 1
checkout: true
check_file_names: 1
spell_check_this: check-spelling/spell-check-this@prerelease
spell_check_this: check-spelling/spell-check-this@92453f59cac8985482dfc301a8ece4c6d7de8a0c # prerelease
post_comment: 0
use_magic_file: 1
experimental_apply_changes_via_bot: 1
@@ -114,10 +114,10 @@ jobs:
if: (success() || failure()) && needs.spelling.outputs.followup && github.event_name == 'push'
steps:
- name: comment
uses: check-spelling/check-spelling@main
uses: check-spelling/check-spelling@cfb6f7e75bbfc89c71eaa30366d0c166f1bd9c8c # main
with:
checkout: true
spell_check_this: check-spelling/spell-check-this@prerelease
spell_check_this: check-spelling/spell-check-this@92453f59cac8985482dfc301a8ece4c6d7de8a0c # prerelease
task: ${{ needs.spelling.outputs.followup }}
config: .github/actions/docs-spelling
@@ -131,10 +131,10 @@ jobs:
if: (success() || failure()) && needs.spelling.outputs.followup && contains(github.event_name, 'pull_request')
steps:
- name: comment
uses: check-spelling/check-spelling@main
uses: check-spelling/check-spelling@cfb6f7e75bbfc89c71eaa30366d0c166f1bd9c8c # main
with:
checkout: true
spell_check_this: check-spelling/spell-check-this@prerelease
spell_check_this: check-spelling/spell-check-this@92453f59cac8985482dfc301a8ece4c6d7de8a0c # prerelease
task: ${{ needs.spelling.outputs.followup }}
experimental_apply_changes_via_bot: 1
config: .github/actions/docs-spelling
@@ -156,7 +156,7 @@ jobs:
cancel-in-progress: false
steps:
- name: apply spelling updates
uses: check-spelling/check-spelling@main
uses: check-spelling/check-spelling@cfb6f7e75bbfc89c71eaa30366d0c166f1bd9c8c # main
with:
experimental_apply_changes_via_bot: 1
checkout: true

View File

@@ -30,7 +30,7 @@ jobs:
matrix:
shard: [1, 2, 3, 4, 5]
container:
image: mcr.microsoft.com/playwright:v1.58.2-jammy
image: mcr.microsoft.com/playwright:v1.59.1-jammy
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up environment
@@ -41,7 +41,7 @@ jobs:
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Run E2E Tests
run: yarn e2e --shard=${{ matrix.shard }}/5
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: desktop-client-test-results-shard-${{ matrix.shard }}
@@ -53,7 +53,7 @@ jobs:
name: Functional Desktop App
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.58.2-jammy
image: mcr.microsoft.com/playwright:v1.59.1-jammy
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up environment
@@ -62,10 +62,16 @@ jobs:
download-translations: 'false'
- name: Trust the repository directory
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
# Build tools are needed to rebuild native modules like better-sqlite3 used by the Desktop app, which is required to run E2E tests on the Desktop app.
- name: Install build tools
run: apt-get update && apt-get install -y build-essential python3
- name: Run Desktop app E2E Tests
run: |
yarn rebuild-electron
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" -- yarn e2e:desktop
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: desktop-app-test-results
@@ -81,7 +87,7 @@ jobs:
matrix:
shard: [1, 2, 3, 4, 5]
container:
image: mcr.microsoft.com/playwright:v1.58.2-jammy
image: mcr.microsoft.com/playwright:v1.59.1-jammy
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up environment
@@ -90,7 +96,7 @@ jobs:
download-translations: 'false'
- name: Run VRT Tests
run: yarn vrt --shard=${{ matrix.shard }}/5
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: vrt-blob-report-${{ matrix.shard }}
@@ -104,7 +110,7 @@ jobs:
runs-on: ubuntu-latest
if: ${{ !cancelled() }}
container:
image: mcr.microsoft.com/playwright:v1.58.2-jammy
image: mcr.microsoft.com/playwright:v1.59.1-jammy
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up environment
@@ -118,7 +124,7 @@ jobs:
- name: Merge reports
id: merge-reports
run: yarn workspace @actual-app/web run playwright merge-reports --reporter html ./all-blob-reports
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
id: playwright-report-vrt
with:
name: html-report--attempt-${{ github.run_attempt }}
@@ -134,7 +140,7 @@ jobs:
echo "${{ steps.playwright-report-vrt.outputs.artifact-url }}" > vrt-metadata/artifact-url.txt
- name: Upload VRT metadata
if: github.event_name == 'pull_request'
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vrt-comment-metadata
path: vrt-metadata/

View File

@@ -53,7 +53,7 @@ jobs:
- name: Comment on PR with VRT report link
if: steps.metadata.outputs.should_comment == 'true'
uses: marocchino/sticky-pull-request-comment@70d2764d1a7d5d9560b100cbea0077fc8f633987 # v3.0.2
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
with:
number: ${{ steps.metadata.outputs.pr_number }}
header: vrt-comment

View File

@@ -22,6 +22,7 @@ jobs:
permissions:
contents: write
strategy:
fail-fast: false
matrix:
os:
- ubuntu-22.04
@@ -74,7 +75,7 @@ jobs:
if: ${{ ! startsWith(matrix.os, 'macos') }}
run: ./bin/package-electron
- name: Upload Build
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: actual-electron-${{ matrix.os }}
path: |
@@ -85,7 +86,7 @@ jobs:
packages/desktop-electron/dist/*.flatpak
- name: Upload Windows Store Build
if: ${{ startsWith(matrix.os, 'windows') }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: actual-electron-${{ matrix.os }}-appx
path: |

View File

@@ -26,6 +26,7 @@ concurrency:
jobs:
build:
strategy:
fail-fast: false
matrix:
os:
- ubuntu-22.04
@@ -65,56 +66,56 @@ jobs:
run: ./bin/package-electron
- name: Upload Linux x64 AppImage
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Actual-linux-x86_64.AppImage
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-linux-x86_64.AppImage
- name: Upload Linux arm64 AppImage
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Actual-linux-arm64.AppImage
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-linux-arm64.AppImage
- name: Upload Linux x64 flatpak
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Actual-linux-x86_64.flatpak
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-linux-x86_64.flatpak
- name: Upload Windows x32 exe
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Actual-windows-ia32.exe
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-windows-ia32.exe
- name: Upload Windows x64 exe
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Actual-windows-x64.exe
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-windows-x64.exe
- name: Upload Windows arm64 exe
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Actual-windows-arm64.exe
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-windows-arm64.exe
- name: Upload Mac x64 dmg
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Actual-mac-x64.dmg
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-mac-x64.dmg
- name: Upload Mac arm64 dmg
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Actual-mac-arm64.dmg
if-no-files-found: ignore
@@ -122,7 +123,7 @@ jobs:
- name: Upload Windows Store Build
if: ${{ startsWith(matrix.os, 'windows') }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: actual-electron-${{ matrix.os }}-appx
path: |

View File

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

View File

@@ -27,12 +27,23 @@ jobs:
- name: Configure i18n client
run: |
pip install wlc
- name: Configure Weblate API credentials
env:
WEBLATE_API_KEY: ${{ secrets.WEBLATE_API_KEY_CI_STRINGS }}
run: |
# Write the API key to wlc's config file instead of passing it on
# the command line, so the secret doesn't appear in process listings.
mkdir -p "$HOME/.config"
umask 077
cat > "$HOME/.config/weblate" <<EOF
[keys]
https://hosted.weblate.org/api/ = ${WEBLATE_API_KEY}
EOF
- name: Lock translations
run: |
wlc \
--url https://hosted.weblate.org/api/ \
--key "${{ secrets.WEBLATE_API_KEY_CI_STRINGS }}" \
lock \
actualbudget/actual
@@ -40,7 +51,6 @@ jobs:
run: |
wlc \
--url https://hosted.weblate.org/api/ \
--key "${{ secrets.WEBLATE_API_KEY_CI_STRINGS }}" \
push \
actualbudget/actual
- name: Check out updated translations
@@ -73,7 +83,6 @@ jobs:
run: |
wlc \
--url https://hosted.weblate.org/api/ \
--key "${{ secrets.WEBLATE_API_KEY_CI_STRINGS }}" \
pull \
actualbudget/actual
@@ -82,6 +91,5 @@ jobs:
run: |
wlc \
--url https://hosted.weblate.org/api/ \
--key "${{ secrets.WEBLATE_API_KEY_CI_STRINGS }}" \
unlock \
actualbudget/actual

View File

@@ -34,10 +34,11 @@ jobs:
- name: Deploy to Netlify
id: netlify_deploy
env:
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_API_TOKEN }}
run: |
netlify deploy \
--dir packages/desktop-client/build \
--site ${{ secrets.NETLIFY_SITE_ID }} \
--auth ${{ secrets.NETLIFY_API_TOKEN }} \
--filter @actual-app/web \
--prod

View File

@@ -113,7 +113,7 @@ jobs:
cat com.actualbudget.actual.yml
- name: Create PR in Flathub repo
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
commit-message: 'Update Actual flatpak to version ${{ steps.resolve_version.outputs.version }}'

View File

@@ -20,6 +20,7 @@ concurrency:
jobs:
build:
strategy:
fail-fast: false
matrix:
os:
- ubuntu-22.04
@@ -83,49 +84,49 @@ jobs:
run: ./bin/package-electron
- name: Upload Linux x64 AppImage
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Actual-linux-x86_64.AppImage
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-linux-x86_64.AppImage
- name: Upload Linux arm64 AppImage
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Actual-linux-arm64.AppImage
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-linux-arm64.AppImage
- name: Upload Windows x32 exe
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Actual-windows-ia32.exe
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-windows-ia32.exe
- name: Upload Windows x64 exe
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Actual-windows-x64.exe
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-windows-x64.exe
- name: Upload Windows arm64 exe
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Actual-windows-arm64.exe
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-windows-arm64.exe
- name: Upload Mac x64 dmg
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Actual-mac-x64.dmg
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-mac-x64.dmg
- name: Upload Mac arm64 dmg
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Actual-mac-arm64.dmg
if-no-files-found: ignore
@@ -133,7 +134,7 @@ jobs:
- name: Upload Windows Store Build
if: ${{ startsWith(matrix.os, 'windows') }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: actual-electron-${{ matrix.os }}-appx
path: |

View File

@@ -64,7 +64,7 @@ jobs:
yarn workspace @actual-app/cli pack --filename @actual-app/cli.tgz
- name: Upload package artifacts
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: npm-packages
path: |

View File

@@ -43,7 +43,7 @@ jobs:
yarn workspace @actual-app/cli pack --filename @actual-app/cli.tgz
- name: Upload package artifacts
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: npm-packages
path: |

View File

@@ -3,6 +3,10 @@ name: Release notes
on:
pull_request:
permissions:
contents: write
pull-requests: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
@@ -11,11 +15,31 @@ jobs:
release-notes:
runs-on: ubuntu-latest
steps:
- name: Check if triggered by bot
id: bot-check
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { data: commit } = await github.rest.git.getCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: context.payload.pull_request.head.sha,
});
const skip = commit.author.name === 'github-actions[bot]'
&& commit.message.startsWith('Generate release notes');
console.log(`Head commit by "${commit.author.name}": ${commit.message.split('\n')[0]}`);
console.log(`Skip: ${skip}`);
core.setOutput('skip', String(skip));
- name: Checkout
if: steps.bot-check.outputs.skip != 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
token: ${{ secrets.ACTIONS_UPDATE_TOKEN || github.token }}
- name: Get changed files
if: steps.bot-check.outputs.skip != 'true'
id: changed-files
run: |
git fetch origin ${{ github.base_ref }}
@@ -28,9 +52,17 @@ jobs:
else
echo "only_docs=false" >> $GITHUB_OUTPUT
fi
- name: Check release notes
if: startsWith(github.head_ref, 'release/') == false && steps.changed-files.outputs.only_docs != 'true'
uses: actualbudget/actions/release-notes/check@main
if: >-
steps.bot-check.outputs.skip != 'true'
&& startsWith(github.head_ref, 'release/') == false
&& steps.changed-files.outputs.only_docs != 'true'
uses: ./.github/actions/release-notes/check
- name: Generate release notes
if: startsWith(github.head_ref, 'release/') == true
uses: actualbudget/actions/release-notes/generate@main
if: >-
steps.bot-check.outputs.skip != 'true'
&& startsWith(github.head_ref, 'release/') == true
&& github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name
uses: ./.github/actions/release-notes/generate

View File

@@ -64,6 +64,13 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: cli
ref: ${{github.base_ref}}
- name: Wait for ${{github.base_ref}} CRDT build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
id: master-crdt-build
with:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: crdt
ref: ${{github.base_ref}}
- name: Wait for PR build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
@@ -86,15 +93,22 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: cli
ref: ${{github.event.pull_request.head.sha}}
- name: Wait for CRDT PR build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
id: wait-for-crdt-build
with:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: crdt
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' || steps.wait-for-cli-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' || steps.wait-for-crdt-build.outputs.conclusion == 'failure'
run: |
echo "Build failed on PR branch or ${{github.base_ref}}"
exit 1
- name: Download web build artifact from ${{github.base_ref}}
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
id: pr-web-build
with:
branch: ${{github.base_ref}}
@@ -103,7 +117,7 @@ jobs:
name: build-stats
path: base
- name: Download API build artifact from ${{github.base_ref}}
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
id: pr-api-build
with:
branch: ${{github.base_ref}}
@@ -112,7 +126,7 @@ jobs:
name: api-build-stats
path: base
- name: Download build stats from PR
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
with:
pr: ${{github.event.pull_request.number}}
workflow: build.yml
@@ -121,7 +135,7 @@ jobs:
path: head
allow_forks: true
- name: Download API stats from PR
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
with:
pr: ${{github.event.pull_request.number}}
workflow: build.yml
@@ -130,7 +144,7 @@ jobs:
path: head
allow_forks: true
- name: Download CLI build artifact from ${{github.base_ref}}
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
with:
branch: ${{github.base_ref}}
workflow: build.yml
@@ -138,7 +152,7 @@ jobs:
name: cli-build-stats
path: base
- name: Download CLI stats from PR
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
with:
pr: ${{github.event.pull_request.number}}
workflow: build.yml
@@ -146,6 +160,23 @@ jobs:
name: cli-build-stats
path: head
allow_forks: true
- name: Download CRDT build artifact from ${{github.base_ref}}
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
with:
branch: ${{github.base_ref}}
workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: crdt-build-stats
path: base
- name: Download CRDT stats from PR
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
with:
pr: ${{github.event.pull_request.number}}
workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: crdt-build-stats
path: head
allow_forks: true
- name: Strip content hashes from stats files
run: |
if [ -f ./head/web-stats.json ]; then
@@ -168,10 +199,12 @@ jobs:
--base loot-core=./base/loot-core-stats.json \
--base api=./base/api-stats.json \
--base cli=./base/cli-stats.json \
--base crdt=./base/crdt-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 \
--head crdt=./head/crdt-stats.json \
--identifier combined \
--format pr-body > bundle-stats-comment.md
- name: Post combined bundle stats comment

View File

@@ -133,12 +133,15 @@ jobs:
- name: Comment on PR - Failure
if: failure() && steps.metadata.outputs.pr_number != ''
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
APPLY_ERROR: ${{ steps.apply.outputs.error }}
PR_NUMBER: ${{ steps.metadata.outputs.pr_number }}
with:
script: |
const error = `${{ steps.apply.outputs.error }}` || 'Unknown error occurred';
const error = process.env.APPLY_ERROR || 'Unknown error occurred';
await github.rest.issues.createComment({
issue_number: ${{ steps.metadata.outputs.pr_number }},
issue_number: parseInt(process.env.PR_NUMBER, 10),
owner: context.repo.owner,
repo: context.repo.repo,
body: `❌ Failed to apply VRT updates: ${error}\n\nPlease check the workflow logs for details.`

View File

@@ -26,7 +26,7 @@ jobs:
pull-requests: write
steps:
- name: Add 👀 reaction to comment
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
await github.rest.reactions.createForIssueComment({
@@ -44,11 +44,11 @@ jobs:
github.event.issue.pull_request &&
startsWith(github.event.comment.body, '/update-vrt')
container:
image: mcr.microsoft.com/playwright:v1.58.2-jammy
image: mcr.microsoft.com/playwright:v1.59.1-jammy
steps:
- name: Get PR details
id: pr
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { data: pr } = await github.rest.pulls.get({
@@ -69,9 +69,14 @@ jobs:
with:
download-translations: 'false'
# Build tools are needed to rebuild native modules like better-sqlite3 used by the Desktop app, which is required to run VRT tests on the Desktop app and generate updated snapshots.
- name: Install build tools
run: apt-get update && apt-get install -y build-essential python3
- name: Run VRT Tests on Desktop app
continue-on-error: true
run: |
yarn rebuild-electron
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" -- yarn e2e:desktop --update-snapshots
- name: Run VRT Tests
@@ -113,7 +118,7 @@ jobs:
- name: Upload patch artifact
if: steps.create-patch.outputs.has_changes == 'true'
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vrt-patch-${{ github.event.issue.number }}
path: vrt-update.patch
@@ -129,7 +134,7 @@ jobs:
- name: Upload PR metadata
if: steps.create-patch.outputs.has_changes == 'true'
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vrt-metadata-${{ github.event.issue.number }}
path: pr-metadata/

4
.gitignore vendored
View File

@@ -58,6 +58,10 @@ bundle.mobile.js.map
# IntelliJ IDEA
.idea
# Claude Code
.claude/worktrees/*
.claude/settings.local.json
# Misc
.#*

View File

@@ -1,11 +1,19 @@
#!/bin/sh
# Run yarn install when switching branches (if yarn.lock changed)
# or when creating a new worktree (node_modules won't exist yet)
# $3 is 1 for branch checkout, 0 for file checkout
if [ "$3" != "1" ]; then
exit 0
fi
# Worktree creation: node_modules doesn't exist yet, always install
if [ ! -d "node_modules" ]; then
echo "New worktree detected — running yarn install..."
yarn install || exit 1
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..."

0
.husky/pre-commit Normal file → Executable file
View File

View File

@@ -9,24 +9,14 @@
"react",
"builtin",
"external",
"loot-core",
["parent", "subpath"],
"sibling",
"index",
"desktop-client"
"index"
],
"customGroups": [
{
"groupName": "react",
"elementNamePattern": ["react", "react-dom/*", "react-*"]
},
{
"groupName": "loot-core",
"elementNamePattern": ["loot-core/**", "@actual-app/core/**"]
},
{
"groupName": "desktop-client",
"elementNamePattern": ["@desktop-client/**"]
}
],
"newlinesBetween": true

View File

@@ -36,6 +36,8 @@
"actual/prefer-const": "error",
"actual/no-anchor-tag": "error",
"actual/no-react-default-import": "error",
"actual/prefer-subpath-imports": "error",
"actual/no-extraneous-dependencies": "error",
// JSX A11y rules
"jsx-a11y/no-autofocus": [
@@ -120,9 +122,6 @@
"import/no-amd": "error",
"import/no-default-export": "error",
"import/no-webpack-loader-syntax": "error",
"import/no-useless-path-segments": "error",
"import/no-unresolved": "error",
"import/no-unused-modules": "error",
"import/no-duplicates": [
"error",
{
@@ -160,7 +159,6 @@
"react/no-danger-with-children": "error",
"react/no-direct-mutation-state": "error",
"react/no-is-mounted": "error",
"react/no-unstable-nested-components": "error",
"react/require-render-return": "error",
"react/rules-of-hooks": "error",
"react/self-closing-comp": "error",
@@ -234,7 +232,7 @@
"eslint/require-yield": "error",
"eslint/getter-return": "error",
"eslint/unicode-bom": ["error", "never"],
"eslint/no-use-isnan": "error",
"eslint/use-isnan": "error",
"eslint/valid-typeof": "error",
"eslint/no-useless-rename": [
"error",
@@ -335,14 +333,9 @@
],
"patterns": [
{
"group": ["**/*.api", "**/*.web", "**/*.electron"],
"group": ["**/*.api", "**/*.electron"],
"message": "Don't directly reference imports from other platforms"
},
{
"group": ["uuid"],
"importNames": ["*"],
"message": "Use `import { v4 as uuidv4 } from 'uuid'` instead"
},
{
"group": ["**/style", "**/colors"],
"importNames": ["colors"],
@@ -361,7 +354,9 @@
],
"eslint/no-useless-constructor": "error",
"eslint/no-undef": "error",
"eslint/no-unused-expressions": "error"
"eslint/no-unused-expressions": "error",
"eslint/no-return-assign": "error",
"eslint/no-unused-vars": "error"
},
"overrides": [
{
@@ -421,6 +416,16 @@
"rules": {
"eslint/no-empty-function": "off"
}
},
// crdt enforces the repo's "TODO: enable this" typescript rules as errors
{
"files": ["packages/crdt/**/*"],
"rules": {
"typescript/no-misused-spread": "error",
"typescript/no-base-to-string": "error",
"typescript/no-unsafe-unary-minus": "error",
"typescript/no-unsafe-type-assertion": "error"
}
}
]
}

File diff suppressed because one or more lines are too long

940
.yarn/releases/yarn-4.13.0.cjs vendored Executable file

File diff suppressed because one or more lines are too long

View File

@@ -6,4 +6,4 @@ enableTransparentWorkspaces: false
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.10.3.cjs
yarnPath: .yarn/releases/yarn-4.13.0.cjs

View File

@@ -331,7 +331,7 @@ Always maintain newlines between import groups.
### Platform-Specific Code
- Don't directly reference platform-specific imports (`.api`, `.web`, `.electron`)
- Don't directly reference platform-specific imports (`.api`, `.electron`)
- Use conditional exports in `loot-core` for platform-specific code
- Platform resolution happens at build time via package.json exports
@@ -501,7 +501,7 @@ Icons in `packages/component-library/src/icons/` are auto-generated. Don't manua
1. Check `tsconfig.json` for path mappings
2. Check package.json `exports` field (especially for loot-core)
3. Verify platform-specific imports (`.web`, `.electron`, `.api`)
3. Verify platform-specific imports (`.electron`, `.api`)
4. Use absolute imports in `desktop-client` (enforced by ESLint)
### Build Failures

View File

@@ -0,0 +1,218 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
derivePublishImports,
validatePackage,
} from '../validate-publish-imports.js';
describe('derivePublishImports', () => {
it('prepends ./build/ to .js paths', () => {
const imports = {
'#account-db': './src/account-db.js',
};
expect(derivePublishImports(imports)).toEqual({
'#account-db': './build/src/account-db.js',
});
});
it('converts .ts extension to .js and prepends ./build/', () => {
const imports = {
'#migrations': './src/migrations.ts',
};
expect(derivePublishImports(imports)).toEqual({
'#migrations': './build/src/migrations.js',
});
});
it('converts .tsx extension to .js and prepends ./build/', () => {
const imports = {
'#component': './src/component.tsx',
};
expect(derivePublishImports(imports)).toEqual({
'#component': './build/src/component.js',
});
});
it('preserves wildcard patterns', () => {
const imports = {
'#accounts/*': './src/accounts/*.js',
'#services/*': './src/app-gocardless/services/*.ts',
};
expect(derivePublishImports(imports)).toEqual({
'#accounts/*': './build/src/accounts/*.js',
'#services/*': './build/src/app-gocardless/services/*.js',
});
});
it('handles multiple entries with mixed extensions', () => {
const imports = {
'#account-db': './src/account-db.js',
'#migrations': './src/migrations.ts',
'#app-gocardless/errors': './src/app-gocardless/errors.ts',
'#util/*': './src/util/*.ts',
'#scripts/*': './src/scripts/*.js',
};
expect(derivePublishImports(imports)).toEqual({
'#account-db': './build/src/account-db.js',
'#migrations': './build/src/migrations.js',
'#app-gocardless/errors': './build/src/app-gocardless/errors.js',
'#util/*': './build/src/util/*.js',
'#scripts/*': './build/src/scripts/*.js',
});
});
it('returns empty object for empty imports', () => {
expect(derivePublishImports({})).toEqual({});
});
it('throws error for non-string imports values', () => {
const imports = {
'#foo': './src/foo.js',
'#conditional': {
browser: './src/browser.js',
node: './src/node.js',
},
};
expect(() => derivePublishImports(imports)).toThrow(
'Unsupported imports target for "#conditional". Expected a string path.',
);
});
it('handles paths with /index.js suffix', () => {
const imports = {
'#util/title': './src/util/title/index.js',
};
expect(derivePublishImports(imports)).toEqual({
'#util/title': './build/src/util/title/index.js',
});
});
});
describe('validatePackage', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'validate-imports-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function writePackageJson(content: Record<string, unknown>) {
const filePath = path.join(tmpDir, 'package.json');
fs.writeFileSync(filePath, JSON.stringify(content, null, 2) + '\n');
return filePath;
}
it('skips packages with no publishConfig', () => {
const filePath = writePackageJson({
name: 'test-pkg',
imports: { '#foo': './src/foo.js' },
});
const { result, warnings } = validatePackage(filePath);
expect(result).toBeNull();
expect(warnings).toEqual([]);
});
it('skips packages with publishConfig but no publishConfig.imports', () => {
const filePath = writePackageJson({
name: 'test-pkg',
imports: { '#foo': './src/foo.js' },
publishConfig: { access: 'public' },
});
const { result, warnings } = validatePackage(filePath);
expect(result).toBeNull();
expect(warnings).toEqual([]);
});
it('warns when publishConfig.imports exists but imports does not', () => {
const filePath = writePackageJson({
name: 'test-pkg',
publishConfig: {
imports: { '#foo': './build/src/foo.js' },
},
});
const { result, warnings } = validatePackage(filePath);
expect(result).toBeNull();
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain('orphaned');
});
it('returns no errors when publishConfig.imports matches', () => {
const filePath = writePackageJson({
name: 'test-pkg',
imports: {
'#foo': './src/foo.js',
'#bar': './src/bar.ts',
},
publishConfig: {
imports: {
'#foo': './build/src/foo.js',
'#bar': './build/src/bar.js',
},
},
});
const { result } = validatePackage(filePath);
expect(result).not.toBeNull();
expect(result!.missingKeys).toEqual([]);
expect(result!.extraKeys).toEqual([]);
expect(result!.wrongValues).toEqual([]);
});
it('detects missing keys in publishConfig.imports', () => {
const filePath = writePackageJson({
name: 'test-pkg',
imports: {
'#foo': './src/foo.js',
'#bar': './src/bar.ts',
},
publishConfig: {
imports: {
'#foo': './build/src/foo.js',
},
},
});
const { result } = validatePackage(filePath);
expect(result!.missingKeys).toEqual(['#bar']);
});
it('detects extra keys in publishConfig.imports', () => {
const filePath = writePackageJson({
name: 'test-pkg',
imports: {
'#foo': './src/foo.js',
},
publishConfig: {
imports: {
'#foo': './build/src/foo.js',
'#orphan': './build/src/orphan.js',
},
},
});
const { result } = validatePackage(filePath);
expect(result!.extraKeys).toEqual(['#orphan']);
});
it('detects wrong values in publishConfig.imports', () => {
const filePath = writePackageJson({
name: 'test-pkg',
imports: {
'#foo': './src/foo.ts',
},
publishConfig: {
imports: {
'#foo': './src/foo.ts',
},
},
});
const { result } = validatePackage(filePath);
expect(result!.wrongValues).toEqual([
{ key: '#foo', expected: './build/src/foo.js', actual: './src/foo.ts' },
]);
});
});

View File

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

View File

@@ -43,6 +43,7 @@ if [ $SKIP_TRANSLATIONS == false ]; then
pushd packages/desktop-client/locale > /dev/null
git checkout .
git pull
popd > /dev/null
packages/desktop-client/bin/remove-untranslated-languages
@@ -50,6 +51,7 @@ fi
export NODE_OPTIONS="--max-old-space-size=4096"
yarn workspace @actual-app/crdt build
yarn workspace plugins-service build
yarn workspace @actual-app/core build:node
yarn workspace @actual-app/web build --mode=desktop # electron specific build

View File

@@ -28,5 +28,5 @@ echo "Running VRT tests with the following parameters:"
echo "E2E_START_URL: $E2E_START_URL"
echo "VRT_ARGS: $VRT_ARGS"
MSYS_NO_PATHCONV=1 docker run --rm --network host -v "$(pwd)":/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.58.2-jammy /bin/bash \
MSYS_NO_PATHCONV=1 docker run --rm --network host -v "$(pwd)":/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.59.1-jammy /bin/bash \
-c "E2E_START_URL=$E2E_START_URL yarn vrt $VRT_ARGS"

View File

@@ -0,0 +1,216 @@
import fs from 'node:fs';
import path from 'node:path';
/**
* Derives publishConfig.imports from imports by:
* 1. Prepending ./build/ to each value path
* 2. Replacing .ts/.tsx extensions with .js
*/
export function derivePublishImports(
imports: Record<string, string | object>,
): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(imports)) {
if (typeof value !== 'string') {
throw new Error(
`Unsupported imports target for "${key}". Expected a string path.`,
);
}
const withBuildPrefix = value.replace(/^\.\//, './build/');
const withJsExtension = withBuildPrefix.replace(/\.tsx?$/, '.js');
result[key] = withJsExtension;
}
return result;
}
export type ValidationResult = {
packagePath: string;
packageName: string;
missingKeys: string[];
extraKeys: string[];
wrongValues: Array<{ key: string; expected: string; actual: string }>;
};
/**
* Validates publishConfig.imports against imports for a single package.json.
* Returns null if the package should be skipped (no publishConfig.imports).
* Returns a ValidationResult if the package has both fields.
*/
export function validatePackage(packageJsonPath: string): {
result: ValidationResult | null;
warnings: string[];
} {
const warnings: string[] = [];
const content = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
const packageName: string = content.name ?? packageJsonPath;
const imports: Record<string, string | object> | undefined = content.imports;
const publishImports: Record<string, string> | undefined =
content.publishConfig?.imports;
// No publishConfig.imports → skip
if (!publishImports) {
return { result: null, warnings };
}
// Has publishConfig.imports but no imports → warn
if (!imports) {
warnings.push(
`${packageName}: orphaned publishConfig.imports (no imports field)`,
);
return { result: null, warnings };
}
const expected = derivePublishImports(imports);
const expectedKeys = new Set(Object.keys(expected));
const actualKeys = new Set(Object.keys(publishImports));
const missingKeys = [...expectedKeys].filter(k => !actualKeys.has(k));
const extraKeys = [...actualKeys].filter(k => !expectedKeys.has(k));
const wrongValues: ValidationResult['wrongValues'] = [];
for (const key of expectedKeys) {
if (actualKeys.has(key) && publishImports[key] !== expected[key]) {
wrongValues.push({
key,
expected: expected[key],
actual: publishImports[key],
});
}
}
return {
result: {
packagePath: packageJsonPath,
packageName,
missingKeys,
extraKeys,
wrongValues,
},
warnings,
};
}
export function fixPackage(packageJsonPath: string): boolean {
const raw = fs.readFileSync(packageJsonPath, 'utf-8');
const content = JSON.parse(raw);
if (!content.imports || !content.publishConfig?.imports) {
return false;
}
const expected = derivePublishImports(content.imports);
// Check if already correct
if (
JSON.stringify(content.publishConfig.imports) === JSON.stringify(expected)
) {
return false;
}
content.publishConfig.imports = expected;
fs.writeFileSync(packageJsonPath, JSON.stringify(content, null, 2) + '\n');
return true;
}
function findPackageJsonFiles(): string[] {
const packagesDir = path.resolve(__dirname, '..', 'packages');
const entries = fs.readdirSync(packagesDir, { withFileTypes: true });
const results: string[] = [];
for (const entry of entries) {
if (entry.isDirectory()) {
const pkgPath = path.join(packagesDir, entry.name, 'package.json');
if (fs.existsSync(pkgPath)) {
results.push(pkgPath);
}
}
}
return results;
}
function resolvePackageJsonPaths(filePaths: string[]): string[] {
const packagesRoot = path.resolve(__dirname, '..', 'packages');
const seen = new Set<string>();
for (const filePath of filePaths) {
const resolvedPath = path.resolve(filePath);
let dir = path.dirname(resolvedPath);
while (dir.startsWith(packagesRoot + path.sep)) {
const candidate = path.join(dir, 'package.json');
if (
fs.existsSync(candidate) &&
candidate.startsWith(packagesRoot + path.sep)
) {
seen.add(candidate);
break;
}
dir = path.dirname(dir);
}
}
return [...seen];
}
function main() {
const args = process.argv.slice(2);
const fixMode = args.includes('--fix');
const filePaths = args.filter(arg => !arg.startsWith('--'));
const packageJsonFiles =
filePaths.length > 0
? resolvePackageJsonPaths(filePaths)
: findPackageJsonFiles();
let hasErrors = false;
const allWarnings: string[] = [];
for (const pkgPath of packageJsonFiles) {
if (fixMode) {
const fixed = fixPackage(pkgPath);
if (fixed) {
const name = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).name;
console.log(`Fixed publishConfig.imports in ${name}`);
}
} else {
const { result, warnings } = validatePackage(pkgPath);
allWarnings.push(...warnings);
if (result) {
const hasIssues =
result.missingKeys.length > 0 ||
result.extraKeys.length > 0 ||
result.wrongValues.length > 0;
if (hasIssues) {
hasErrors = true;
console.error(`\n${result.packageName}:`);
for (const key of result.missingKeys) {
console.error(` Missing key: ${key}`);
}
for (const key of result.extraKeys) {
console.error(` Extra key: ${key}`);
}
for (const { key, expected, actual } of result.wrongValues) {
console.error(` Wrong value for ${key}:`);
console.error(` expected: ${expected}`);
console.error(` actual: ${actual}`);
}
}
}
}
}
for (const warning of allWarnings) {
console.warn(`Warning: ${warning}`);
}
if (hasErrors) {
console.error(
'\npublishConfig.imports is out of sync. Run with --fix to auto-fix.',
);
process.exit(1);
}
}
if (require.main === module) {
main();
}

9
bin/vitest.config.mts Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
include: ['__tests__/**/*.test.ts'],
environment: 'node',
},
});

View File

@@ -1,3 +1,5 @@
const BUILD_OUTPUT_GLOBS = ['lib-dist/**', 'dist/**', 'build/**', '@types/**'];
/** @type {import('lage').ConfigOptions} */
module.exports = {
pipeline: {
@@ -20,14 +22,14 @@ module.exports = {
dependsOn: ['^build'],
cache: true,
options: {
outputGlob: ['lib-dist/**', 'dist/**', 'build/**'],
outputGlob: BUILD_OUTPUT_GLOBS,
},
},
},
cacheOptions: {
cacheStorageConfig: {
provider: 'local',
outputGlob: ['lib-dist/**', 'dist/**', 'build/**'],
outputGlob: BUILD_OUTPUT_GLOBS,
},
},
npmClient: 'yarn',

View File

@@ -40,7 +40,7 @@
"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:api": "yarn build --scope=@actual-app/api",
"build:cli": "yarn build --scope=@actual-app/cli",
"build:docs": "yarn workspace docs build",
"build:storybook": "yarn workspace @actual-app/components build:storybook",
@@ -54,49 +54,53 @@
"playwright": "yarn workspace @actual-app/web run playwright",
"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-electron": "./node_modules/.bin/electron-rebuild -m ./packages/loot-core && ./node_modules/.bin/electron-rebuild -m ./packages/desktop-electron -o better-sqlite3,bcrypt",
"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",
"constraints": "yarn constraints",
"typecheck": "tsgo -p tsconfig.root.json --noEmit && lage typecheck",
"jq": "./node_modules/node-jq/bin/jq",
"prepare": "husky"
},
"devDependencies": {
"@octokit/rest": "^22.0.1",
"@types/node": "^22.19.15",
"@types/node": "^22.19.17",
"@types/prompts": "^2.4.9",
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
"@typescript/native-preview": "^7.0.0-dev.20260404.1",
"@yarnpkg/types": "^4.0.1",
"baseline-browser-mapping": "^2.10.0",
"cross-env": "^10.1.0",
"eslint": "^9.39.3",
"eslint-plugin-perfectionist": "^5.6.0",
"eslint": "^10.2.0",
"eslint-plugin-perfectionist": "^5.8.0",
"eslint-plugin-typescript-paths": "^0.0.33",
"html-to-image": "^1.11.13",
"husky": "^9.1.7",
"lage": "^2.14.19",
"lint-staged": "^16.3.2",
"minimatch": "^10.2.4",
"node-jq": "^6.3.1",
"lage": "^2.15.5",
"lint-staged": "^16.4.0",
"minimatch": "^10.2.5",
"npm-run-all": "^4.1.5",
"oxfmt": "^0.32.0",
"oxlint": "^1.51.0",
"oxlint-tsgolint": "^0.13.0",
"oxfmt": "^0.44.0",
"oxlint": "^1.59.0",
"oxlint-tsgolint": "^0.20.0",
"p-limit": "^7.3.0",
"prompts": "^2.4.2",
"source-map-support": "^0.5.21",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
"typescript": "^6.0.2",
"vitest": "^4.1.2"
},
"resolutions": {
"adm-zip": "patch:adm-zip@npm%3A0.5.16#~/.yarn/patches/adm-zip-npm-0.5.16-4556fea098.patch",
"minimatch@10.2.1": "10.2.5",
"minimatch@3.1.2": "3.1.5",
"minimatch@>=10.0.0 <11.0.0": "10.2.5",
"minimatch@>=3.0.0 <4.0.0": "3.1.5",
"minimatch@>=5.0.0 <6.0.0": "5.1.9",
"minimatch@>=9.0.0 <10.0.0": "9.0.9",
"rollup": "4.40.1",
"socks": ">=2.8.3"
},
"lint-staged": {
"packages/*/package.json": [
"ts-node ./bin/validate-publish-imports.ts --fix"
],
"*.{js,mjs,jsx,ts,tsx,md,json,yml,yaml}": [
"oxfmt --no-error-on-unmatched-pattern"
],
@@ -112,5 +116,5 @@
"node": ">=22",
"yarn": "^4.9.1"
},
"packageManager": "yarn@4.10.3"
"packageManager": "yarn@4.13.0"
}

View File

@@ -3,3 +3,7 @@ npm install @actual-app/api
```
View docs here: https://actualbudget.org/docs/api/
## TypeScript
`@actual-app/api` publishes TypeScript declarations. Consumers using TypeScript must set `moduleResolution` to `"bundler"`, `"nodenext"`, or `"node16"` in their `tsconfig.json`. Legacy `"node"` / `"node10"` / `"classic"` resolution is not supported in strict mode — the published declarations rely on package.json `exports` conditions that older resolvers don't honor.

View File

@@ -1,5 +1,5 @@
class Query {
/** @type {import('loot-core/shared/query').QueryState} */
/** @type {import('@actual-app/core/shared/query').QueryState} */
state;
constructor(state) {

View File

@@ -1,8 +1,3 @@
import type {
RequestInfo as FetchInfo,
RequestInit as FetchInit,
} from 'node-fetch';
import { init as initLootCore } from '@actual-app/core/server/main';
import type { InitConfig, lib } from '@actual-app/core/server/main';
@@ -17,14 +12,6 @@ export let internal: typeof lib | null = null;
export async function init(config: InitConfig = {}) {
validateNodeVersion();
if (!globalThis.fetch) {
globalThis.fetch = (url: URL | RequestInfo, init?: RequestInit) => {
return import('node-fetch').then(({ default: fetch }) =>
fetch(url as unknown as FetchInfo, init as unknown as FetchInit),
) as unknown as Promise<Response>;
};
}
internal = await initLootCore(config);
return internal;
}

View File

@@ -1,9 +1,8 @@
import * as fs from 'fs/promises';
import * as path from 'path';
import { vi } from 'vitest';
import type { RuleEntity } from '@actual-app/core/types/models';
import { vi } from 'vitest';
import * as api from './index';

View File

@@ -1,6 +1,6 @@
{
"name": "@actual-app/api",
"version": "v26.4.0-pre",
"version": "26.4.0",
"description": "An API for Actual",
"license": "MIT",
"files": [
@@ -11,6 +11,7 @@
"types": "@types/index.d.ts",
"exports": {
".": {
"types": "./@types/index.d.ts",
"development": "./index.ts",
"default": "./dist/index.js"
}
@@ -24,26 +25,23 @@
}
},
"scripts": {
"build": "vite build",
"build": "vite build && tsgo --emitDeclarationOnly",
"test": "vitest --run",
"typecheck": "tsgo -b && tsc-strict"
},
"dependencies": {
"@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"
"better-sqlite3": "^12.8.0",
"compare-versions": "^6.1.1"
},
"devDependencies": {
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
"rollup-plugin-visualizer": "^6.0.11",
"@typescript/native-preview": "^7.0.0-dev.20260404.1",
"rollup-plugin-visualizer": "^7.0.1",
"typescript-strict-plugin": "^2.4.4",
"vite": "^8.0.0",
"vite-plugin-dts": "^4.5.4",
"vite": "^8.0.5",
"vite-plugin-peggy-loader": "^2.0.1",
"vitest": "^4.1.0"
"vitest": "^4.1.2"
},
"engines": {
"node": ">=20"

View File

@@ -7,6 +7,7 @@
"target": "ES2021",
"module": "es2022",
"moduleResolution": "bundler",
"customConditions": ["api"],
"noEmit": false,
"declaration": true,
"declarationMap": true,
@@ -18,5 +19,12 @@
},
"references": [{ "path": "../crdt" }, { "path": "../loot-core" }],
"include": ["."],
"exclude": ["**/node_modules/*", "dist", "@types", "*.test.ts", "*.config.ts"]
"exclude": [
"**/node_modules/*",
"dist",
"@types",
"*.test.ts",
"*.config.ts",
"*.config.mts"
]
}

View File

@@ -3,7 +3,6 @@ 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');
@@ -55,7 +54,11 @@ function copyMigrationsAndDefaultDb() {
}
export default defineConfig({
ssr: { noExternal: true, external: ['better-sqlite3'] },
ssr: {
noExternal: true,
external: ['better-sqlite3'],
resolve: { conditions: ['api'] },
},
build: {
ssr: true,
target: 'node20',
@@ -71,16 +74,11 @@ export default defineConfig({
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'],
conditions: ['api'],
},
test: {
globals: true,

View File

@@ -0,0 +1,68 @@
import * as fs from 'node:fs';
import matter from 'gray-matter';
import {
categoryAutocorrections,
categoryOrder,
} from '../src/release-notes/util.mjs';
console.log('Looking in ' + fs.realpathSync('upcoming-release-notes'));
const expectedPath = `upcoming-release-notes/${process.env.PR_NUMBER}.md`;
function reportError(message) {
console.log(`::error::${message}`);
process.stdout.write('::notice::');
fs.createReadStream('upcoming-release-notes/README.md').pipe(process.stdout);
fs.createReadStream('upcoming-release-notes/README.md')
.pipe(fs.createWriteStream(process.env.GITHUB_STEP_SUMMARY))
.on('close', () => {
process.exit(1);
});
}
(() => {
if (!fs.existsSync(expectedPath)) {
reportError(`Release note file ${expectedPath} not found`);
return;
}
const { data, content } = matter(fs.readFileSync(expectedPath, 'utf-8'));
if (!data.category) {
reportError(`Release note is missing a category.`);
return;
}
if (categoryAutocorrections[data.category]) {
data.category = categoryAutocorrections[data.category];
}
if (!categoryOrder.includes(data.category)) {
reportError(
`Release note category "${data.category}" is not one of ${categoryOrder
.map(JSON.stringify)
.join(', ')}`,
);
return;
}
if (!data.authors) {
reportError(`Release note is missing authors.`);
return;
}
if (!Array.isArray(data.authors)) {
reportError(`Release note authors should be a list.`);
return;
}
if (content.trim().split('\n').length !== 1) {
reportError(
`Release note file ${expectedPath} body should contain exactly one line`,
);
return;
}
console.log('Everything looks good! \u{1f389}');
})();

View File

@@ -0,0 +1,229 @@
import * as childProcess from 'node:child_process';
import * as fs from 'node:fs/promises';
import { join } from 'node:path';
import { inspect, promisify } from 'node:util';
import matter from 'gray-matter';
import listify from 'listify';
import {
categoryAutocorrections,
categoryOrder,
} from '../src/release-notes/util.mjs';
const exec = promisify(childProcess.exec);
const [owner, repo] = process.env.GITHUB_REPOSITORY.split('/');
const apiResult = await fetch('https://api.github.com/graphql', {
method: 'POST',
headers: {
Authorization: `bearer ${process.env.GITHUB_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: /* GraphQL */ `
query GetPRMetadata(
$name: String!
$owner: String!
$headRefName: String!
) {
repository(name: $name, owner: $owner) {
pullRequests(headRefName: $headRefName, first: 1) {
edges {
node {
number
headRefName
body
}
}
}
}
}
`,
variables: {
name: repo,
owner,
headRefName: process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME,
},
}),
}).then(res => res.json());
await collapsedLog('API Response', apiResult);
const prData = apiResult.data.repository.pullRequests.edges[0].node;
const version = prData.headRefName.split('/')[1].replace(/^v/, '');
const slug = version.replace(/\./g, '-');
const author = process.env.GITHUB_ACTOR || 'TODO';
const commitMessage = `Generate release notes for v${version}`;
const releaseDateMatch = (prData.body || '').match(
/<!-- release-date:(\d{4}-\d{2}-\d{2}) -->/,
);
const releaseDate = releaseDateMatch ? releaseDateMatch[1] : 'TODO';
const botName = 'github-actions[bot]';
const botEmail = '41898282+github-actions[bot]@users.noreply.github.com';
await exec(`git config user.name '${botName}'`);
await exec(`git config user.email '${botEmail}'`);
await group('Prepare branch', async () => {
if (process.env.GITHUB_HEAD_REF) {
await exec(`git fetch origin ${process.env.GITHUB_HEAD_REF}`, {
stdio: 'inherit',
});
await exec(`git checkout ${process.env.GITHUB_HEAD_REF}`, {
stdio: 'inherit',
});
}
// the previous generation commit deletes source files from
// upcoming-release-notes, rebase it out so we can regenerate from all of them
const { stdout: commitHash } = await exec(
`git log --grep='${commitMessage}' --format=%H -1`,
);
const hash = commitHash.trim();
if (hash) {
console.log(`Dropping previous release notes commit ${hash}`);
await exec(`git rebase --onto ${hash}~1 ${hash}`, {
stdio: 'inherit',
});
}
});
const { notesByCategory, files } = await parseReleaseNotes(
'upcoming-release-notes',
);
const categorizedNotes = formatNotes(notesByCategory);
await collapsedLog('Release Notes', categorizedNotes);
if (files.length === 0) {
console.log('No release notes found, nothing to generate');
process.exit(0);
}
const highlights = '- TODO: Add release highlights';
await group('Generate blog post', async () => {
const blogPath = join(
'packages/docs/blog',
`${releaseDate}-release-${slug}.md`,
);
const blogContent = `---
title: Release ${version}
description: New release of Actual.
date: ${releaseDate}T10:00
slug: release-${version}
tags: [announcement, release]
hide_table_of_contents: false
authors: ${author}
---
${highlights}
<!--truncate-->
**Docker Tag: ${version}**
${categorizedNotes}
`;
await fs.writeFile(blogPath, blogContent);
console.log(`Wrote ${blogPath}`);
});
await group('Update releases.md', async () => {
const releasesPath = 'packages/docs/docs/releases.md';
const existing = await fs.readFile(releasesPath, 'utf-8');
const newSection = `## ${version}
Release date: ${releaseDate}
${highlights}
**Docker Tag: ${version}**
${categorizedNotes}`;
const updated = existing.replace(
'# Release Notes\n',
`# Release Notes\n\n${newSection}\n`,
);
await fs.writeFile(releasesPath, updated);
console.log(`Updated ${releasesPath}`);
});
await group('Remove used release notes', async () => {
await Promise.all(
files.map(f => fs.unlink(join('upcoming-release-notes', f))),
);
});
await group('Commit and push', async () => {
await exec(
'git add upcoming-release-notes packages/docs/blog packages/docs/docs/releases.md',
{ stdio: 'inherit' },
);
await exec(`git commit -m '${commitMessage}'`);
await exec('git push --force-with-lease origin', { stdio: 'inherit' });
});
async function parseReleaseNotes(dir) {
const files = (await fs.readdir(dir)).filter(f => f.match(/^\d+\.md$/));
const notes = files.map(async name => {
const content = await fs.readFile(join(dir, name), 'utf-8');
const { data, content: body } = matter(content);
const number = name.replace('.md', '');
const authors = listify(
data.authors.map(a => `@${a}`),
{ finalWord: '&' },
);
return {
category: categoryAutocorrections[data.category] ?? data.category,
value: `- [#${number}](https://github.com/actualbudget/${repo}/pull/${number}) ${body.trim()} — thanks ${authors}`,
};
});
const notesByCategory = (await Promise.all(notes)).reduce(
(acc, note) => {
if (!acc[note.category]) {
console.log(`WARNING: Unrecognized category "${note.category}"`);
acc[note.category] = [];
}
acc[note.category].push(note.value);
return acc;
},
Object.fromEntries(categoryOrder.map(c => [c, []])),
);
return { notesByCategory, files };
}
function formatNotes(notes) {
return Object.entries(notes)
.filter(([_, values]) => values.length > 0)
.map(([category, values]) => `#### ${category}\n\n${values.join('\n')}`)
.join('\n\n');
}
async function collapsedLog(name, value) {
await group(name, () => {
if (typeof value === 'string') {
console.log(value);
} else {
console.log(inspect(value, { depth: null }));
}
});
}
async function group(name, cb) {
console.log(`::group::${name}`);
await cb();
console.log('::endgroup::');
}

View File

@@ -8,9 +8,12 @@
"typecheck": "tsgo -b"
},
"devDependencies": {
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
"@octokit/rest": "^22.0.1",
"@typescript/native-preview": "^7.0.0-dev.20260404.1",
"extensionless": "^2.0.6",
"vitest": "^4.1.0"
"gray-matter": "^4.0.3",
"listify": "^1.0.3",
"vitest": "^4.1.2"
},
"extensionless": {
"lookFor": [

View File

@@ -0,0 +1,12 @@
export const categoryAutocorrections = {
Feature: 'Features',
Enhancement: 'Enhancements',
Bugfix: 'Bugfixes',
};
export const categoryOrder = [
'Features',
'Enhancements',
'Bugfixes',
'Maintenance',
];

View File

@@ -60,7 +60,7 @@ function resolveType(
currentDate.getFullYear() === 2000 + versionYear &&
currentDate.getMonth() + 1 === versionMonth;
if (inPatchMonth && currentDate.getDate() <= 25) {
if (inPatchMonth && currentDate.getDate() < 25) {
return 'hotfix';
}

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@actual-app/cli",
"version": "26.3.0",
"version": "26.4.0",
"description": "CLI for Actual Budget",
"license": "MIT",
"bin": {
@@ -11,6 +11,16 @@
"dist"
],
"type": "module",
"imports": {
"#cache": "./src/cache.ts",
"#commands/*": "./src/commands/*.ts",
"#config": "./src/config.ts",
"#connection": "./src/connection.ts",
"#input": "./src/input.ts",
"#lock": "./src/lock.ts",
"#output": "./src/output.ts",
"#utils": "./src/utils.ts"
},
"scripts": {
"build": "vite build",
"test": "vitest --run",
@@ -19,15 +29,17 @@
"dependencies": {
"@actual-app/api": "workspace:*",
"cli-table3": "^0.6.5",
"commander": "^13.0.0",
"cosmiconfig": "^9.0.0"
"commander": "^14.0.3",
"cosmiconfig": "^9.0.1",
"proper-lockfile": "^4.1.2"
},
"devDependencies": {
"@types/node": "^22.19.15",
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
"rollup-plugin-visualizer": "^6.0.11",
"vite": "^8.0.0",
"vitest": "^4.1.0"
"@types/node": "^22.19.17",
"@types/proper-lockfile": "^4",
"@typescript/native-preview": "^7.0.0-dev.20260404.1",
"rollup-plugin-visualizer": "^7.0.1",
"vite": "^8.0.5",
"vitest": "^4.1.2"
},
"engines": {
"node": ">=22"

View File

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

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

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

View File

@@ -1,7 +1,7 @@
import * as api from '@actual-app/api';
import { Command } from 'commander';
import { printOutput } from '../output';
import { printOutput } from '#output';
import { registerAccountsCommand } from './accounts';
@@ -15,11 +15,11 @@ vi.mock('@actual-app/api', () => ({
getAccountBalance: vi.fn().mockResolvedValue(10000),
}));
vi.mock('../connection', () => ({
vi.mock('#connection', () => ({
withConnection: vi.fn((_opts, fn) => fn()),
}));
vi.mock('../output', () => ({
vi.mock('#output', () => ({
printOutput: vi.fn(),
}));
@@ -62,14 +62,28 @@ describe('accounts commands', () => {
});
describe('list', () => {
it('calls api.getAccounts and prints result', async () => {
const accounts = [{ id: '1', name: 'Checking' }];
it('calls api.getAccounts and prints result with computed balance', async () => {
const accounts = [
{ id: '1', name: 'Checking', offbudget: false, closed: false },
];
vi.mocked(api.getAccounts).mockResolvedValue(accounts);
await run(['accounts', 'list']);
expect(api.getAccounts).toHaveBeenCalled();
expect(printOutput).toHaveBeenCalledWith(accounts, undefined);
expect(api.getAccountBalance).toHaveBeenCalledWith('1');
expect(printOutput).toHaveBeenCalledWith(
[
{
id: '1',
name: 'Checking',
offbudget: false,
closed: false,
balance: 10000,
},
],
undefined,
);
});
it('passes format option to printOutput', async () => {
@@ -79,6 +93,59 @@ describe('accounts commands', () => {
expect(printOutput).toHaveBeenCalledWith([], 'csv');
});
it('filters out closed accounts by default', async () => {
vi.mocked(api.getAccounts).mockResolvedValue([
{ id: '1', name: 'Open', offbudget: false, closed: false },
{ id: '2', name: 'Closed', offbudget: false, closed: true },
]);
await run(['accounts', 'list']);
expect(printOutput).toHaveBeenCalledWith(
[
{
id: '1',
name: 'Open',
offbudget: false,
closed: false,
balance: 10000,
},
],
undefined,
);
});
it('includes closed accounts when --include-closed is passed', async () => {
vi.mocked(api.getAccounts).mockResolvedValue([
{ id: '1', name: 'Open', offbudget: false, closed: false },
{ id: '2', name: 'Closed', offbudget: false, closed: true },
]);
await run(['accounts', 'list', '--include-closed']);
expect(printOutput).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ id: '2', closed: true }),
]),
undefined,
);
});
it('sorts on-budget accounts before off-budget', async () => {
vi.mocked(api.getAccounts).mockResolvedValue([
{ id: '1', name: 'OffBudget', offbudget: true, closed: false },
{ id: '2', name: 'OnBudget', offbudget: false, closed: false },
]);
await run(['accounts', 'list']);
const output = vi.mocked(printOutput).mock.calls[0][0] as Array<{
id: string;
}>;
expect(output[0].id).toBe('2'); // on-budget first
expect(output[1].id).toBe('1'); // off-budget second
});
});
describe('create', () => {

View File

@@ -1,9 +1,9 @@
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';
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');
@@ -11,12 +11,33 @@ export function registerAccountsCommand(program: Command) {
accounts
.command('list')
.description('List all accounts')
.action(async () => {
.option('--include-closed', 'Include closed accounts', false)
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const result = await api.getAccounts();
printOutput(result, opts.format);
});
await withConnection(
opts,
async () => {
const allAccounts = await api.getAccounts();
const accounts = allAccounts.filter(
a => cmdOpts.includeClosed || !a.closed,
);
// Stable sort: on-budget first, off-budget second
// (preserves API sort_order within each group)
accounts.sort((a, b) => Number(a.offbudget) - Number(b.offbudget));
const balances = await Promise.all(
accounts.map(a => api.getAccountBalance(a.id)),
);
const output = accounts.map((a, i) => ({
id: a.id,
name: a.name,
offbudget: a.offbudget,
closed: a.closed,
balance: balances[i],
}));
printOutput(output, opts.format);
},
{ mutates: false },
);
});
accounts
@@ -24,17 +45,25 @@ export function registerAccountsCommand(program: Command) {
.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')
.option(
'--balance <amount>',
'Initial balance in cents (e.g. 50000 = 500.00)',
'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);
});
await withConnection(
opts,
async () => {
const id = await api.createAccount(
{ name: cmdOpts.name, offbudget: cmdOpts.offbudget },
balance,
);
printOutput({ id }, opts.format);
},
{ mutates: true },
);
});
accounts
@@ -60,10 +89,14 @@ export function registerAccountsCommand(program: Command) {
'No update fields provided. Use --name or --offbudget.',
);
}
await withConnection(opts, async () => {
await api.updateAccount(id, fields);
printOutput({ success: true, id }, opts.format);
});
await withConnection(
opts,
async () => {
await api.updateAccount(id, fields);
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
});
accounts
@@ -79,14 +112,18 @@ export function registerAccountsCommand(program: Command) {
)
.action(async (id: string, cmdOpts) => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.closeAccount(
id,
cmdOpts.transferAccount,
cmdOpts.transferCategory,
);
printOutput({ success: true, id }, opts.format);
});
await withConnection(
opts,
async () => {
await api.closeAccount(
id,
cmdOpts.transferAccount,
cmdOpts.transferCategory,
);
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
});
accounts
@@ -94,10 +131,14 @@ export function registerAccountsCommand(program: Command) {
.description('Reopen a closed account')
.action(async (id: string) => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.reopenAccount(id);
printOutput({ success: true, id }, opts.format);
});
await withConnection(
opts,
async () => {
await api.reopenAccount(id);
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
});
accounts
@@ -105,10 +146,14 @@ export function registerAccountsCommand(program: Command) {
.description('Delete an account')
.action(async (id: string) => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.deleteAccount(id);
printOutput({ success: true, id }, opts.format);
});
await withConnection(
opts,
async () => {
await api.deleteAccount(id);
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
});
accounts
@@ -127,9 +172,13 @@ export function registerAccountsCommand(program: Command) {
cutoff = cutoffDate;
}
const opts = program.opts();
await withConnection(opts, async () => {
const balance = await api.getAccountBalance(id, cutoff);
printOutput({ id, balance }, opts.format);
});
await withConnection(
opts,
async () => {
const balance = await api.getAccountBalance(id, cutoff);
printOutput({ id, balance }, opts.format);
},
{ mutates: false },
);
});
}

View File

@@ -1,10 +1,9 @@
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';
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');
@@ -20,7 +19,7 @@ export function registerBudgetsCommand(program: Command) {
const result = await api.getBudgets();
printOutput(result, opts.format);
},
{ loadBudget: false },
{ mutates: false, skipBudget: true },
);
});
@@ -30,40 +29,33 @@ export function registerBudgetsCommand(program: Command) {
.option('--encryption-password <password>', 'Encryption password')
.action(async (syncId: string, cmdOpts) => {
const opts = program.opts();
const config = await resolveConfig(opts);
const password = config.encryptionPassword ?? cmdOpts.encryptionPassword;
await withConnection(
opts,
async () => {
async config => {
const password =
config.encryptionPassword ?? cmdOpts.encryptionPassword;
await api.downloadBudget(syncId, {
password,
});
printOutput({ success: true, syncId }, opts.format);
},
{ loadBudget: false },
{ mutates: false, skipBudget: true },
);
});
budgets
.command('sync')
.description('Sync the current budget')
.action(async () => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.sync();
printOutput({ success: true }, opts.format);
});
});
budgets
.command('months')
.description('List available budget months')
.action(async () => {
const opts = program.opts();
await withConnection(opts, async () => {
const result = await api.getBudgetMonths();
printOutput(result, opts.format);
});
await withConnection(
opts,
async () => {
const result = await api.getBudgetMonths();
printOutput(result, opts.format);
},
{ mutates: false },
);
});
budgets
@@ -71,10 +63,14 @@ export function registerBudgetsCommand(program: Command) {
.description('Get budget data for a specific month (YYYY-MM)')
.action(async (month: string) => {
const opts = program.opts();
await withConnection(opts, async () => {
const result = await api.getBudgetMonth(month);
printOutput(result, opts.format);
});
await withConnection(
opts,
async () => {
const result = await api.getBudgetMonth(month);
printOutput(result, opts.format);
},
{ mutates: false },
);
});
budgets
@@ -82,14 +78,21 @@ export function registerBudgetsCommand(program: Command) {
.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')
.requiredOption(
'--amount <amount>',
'Amount in cents (e.g. 50000 = 500.00)',
)
.action(async cmdOpts => {
const amount = parseIntFlag(cmdOpts.amount, '--amount');
const opts = program.opts();
await withConnection(opts, async () => {
await api.setBudgetAmount(cmdOpts.month, cmdOpts.category, amount);
printOutput({ success: true }, opts.format);
});
await withConnection(
opts,
async () => {
await api.setBudgetAmount(cmdOpts.month, cmdOpts.category, amount);
printOutput({ success: true }, opts.format);
},
{ mutates: true },
);
});
budgets
@@ -101,24 +104,35 @@ export function registerBudgetsCommand(program: Command) {
.action(async cmdOpts => {
const flag = parseBoolFlag(cmdOpts.flag, '--flag');
const opts = program.opts();
await withConnection(opts, async () => {
await api.setBudgetCarryover(cmdOpts.month, cmdOpts.category, flag);
printOutput({ success: true }, opts.format);
});
await withConnection(
opts,
async () => {
await api.setBudgetCarryover(cmdOpts.month, cmdOpts.category, flag);
printOutput({ success: true }, opts.format);
},
{ mutates: true },
);
});
budgets
.command('hold-next-month')
.description('Hold budget amount for next month')
.requiredOption('--month <month>', 'Budget month (YYYY-MM)')
.requiredOption('--amount <amount>', 'Amount in cents')
.requiredOption(
'--amount <amount>',
'Amount in cents (e.g. 50000 = 500.00)',
)
.action(async cmdOpts => {
const parsedAmount = parseIntFlag(cmdOpts.amount, '--amount');
const opts = program.opts();
await withConnection(opts, async () => {
await api.holdBudgetForNextMonth(cmdOpts.month, parsedAmount);
printOutput({ success: true }, opts.format);
});
await withConnection(
opts,
async () => {
await api.holdBudgetForNextMonth(cmdOpts.month, parsedAmount);
printOutput({ success: true }, opts.format);
},
{ mutates: true },
);
});
budgets
@@ -127,9 +141,13 @@ export function registerBudgetsCommand(program: Command) {
.requiredOption('--month <month>', 'Budget month (YYYY-MM)')
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
await api.resetBudgetHold(cmdOpts.month);
printOutput({ success: true }, opts.format);
});
await withConnection(
opts,
async () => {
await api.resetBudgetHold(cmdOpts.month);
printOutput({ success: true }, opts.format);
},
{ mutates: true },
);
});
}

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import * as api from '@actual-app/api';
import { Command } from 'commander';
import { printOutput } from '../output';
import { printOutput } from '#output';
import { parseOrderBy, registerQueryCommand } from './query';
@@ -21,11 +21,11 @@ vi.mock('@actual-app/api', () => {
};
});
vi.mock('../connection', () => ({
vi.mock('#connection', () => ({
withConnection: vi.fn((_opts, fn) => fn()),
}));
vi.mock('../output', () => ({
vi.mock('#output', () => ({
printOutput: vi.fn(),
}));
@@ -145,6 +145,25 @@ describe('query commands', () => {
]);
});
it('outputs unwrapped data array (not the full result envelope)', async () => {
const mockData = [{ id: '1', amount: -500 }];
vi.mocked(api.aqlQuery).mockResolvedValueOnce({
data: mockData,
dependencies: [],
});
await run([
'query',
'run',
'--table',
'transactions',
'--select',
'id,amount',
]);
expect(printOutput).toHaveBeenCalledWith(mockData, undefined);
});
it('passes --filter as JSON', async () => {
await run([
'query',

View File

@@ -1,14 +1,10 @@
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);
}
import { withConnection } from '#connection';
import { readJsonInput } from '#input';
import { printOutput } from '#output';
import { isRecord, parseIntFlag } from '#utils';
/**
* Parse order-by strings like "date:desc,amount:asc,id" into
@@ -253,7 +249,17 @@ Available tables: ${AVAILABLE_TABLES}
Use "actual query tables" and "actual query fields <table>" for schema info.
Common filter operators: $eq, $ne, $lt, $lte, $gt, $gte, $like, $and, $or
See ActualQL docs for full reference: https://actualbudget.org/docs/api/actual-ql/`;
See ActualQL docs for full reference: https://actualbudget.org/docs/api/actual-ql/
Tips:
- Amounts are stored as integer cents (e.g. 166500 = 1665.00).
Table and CSV output auto-formats these as decimals; JSON keeps raw cents.
- Filter "is_parent": false to avoid double-counting split transactions.
- Fetch all data in a single query with a date range instead of running
one query per month — rapid sequential requests may cause auth failures.
- date.month, date.year etc. are not supported as fields in AQL.
To group by month, fetch raw transactions with a date range filter
and aggregate locally (e.g. in a script).`;
export function registerQueryCommand(program: Command) {
const query = program
@@ -295,23 +301,31 @@ export function registerQueryCommand(program: Command) {
.addHelpText('after', RUN_EXAMPLES)
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const parsed = cmdOpts.file ? readJsonInput(cmdOpts) : undefined;
if (parsed !== undefined && !isRecord(parsed)) {
throw new Error('Query file must contain a JSON object');
}
const queryObj = parsed
? buildQueryFromFile(parsed, cmdOpts.table)
: buildQueryFromFlags(cmdOpts);
await withConnection(
opts,
async () => {
const parsed = cmdOpts.file ? readJsonInput(cmdOpts) : undefined;
if (parsed !== undefined && !isRecord(parsed)) {
throw new Error('Query file must contain a JSON object');
}
const queryObj = parsed
? buildQueryFromFile(parsed, cmdOpts.table)
: buildQueryFromFlags(cmdOpts);
const result = await api.aqlQuery(queryObj);
const result = await api.aqlQuery(queryObj);
if (cmdOpts.count) {
printOutput({ count: result.data }, opts.format);
} else {
printOutput(result, opts.format);
}
});
if (!isRecord(result) || !('data' in result)) {
throw new Error('Query result missing data');
}
if (cmdOpts.count) {
printOutput({ count: result.data }, opts.format);
} else {
printOutput(result.data, opts.format);
}
},
{ mutates: false },
);
});
query

View File

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

View File

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

View File

@@ -2,8 +2,8 @@ import * as api from '@actual-app/api';
import { Option } from 'commander';
import type { Command } from 'commander';
import { withConnection } from '../connection';
import { printOutput } from '../output';
import { withConnection } from '#connection';
import { printOutput } from '#output';
export function registerServerCommand(program: Command) {
const server = program.command('server').description('Server utilities');
@@ -19,7 +19,7 @@ export function registerServerCommand(program: Command) {
const version = await api.getServerVersion();
printOutput({ version }, opts.format);
},
{ loadBudget: false },
{ mutates: false, skipBudget: true },
);
});
@@ -34,13 +34,17 @@ export function registerServerCommand(program: Command) {
.requiredOption('--name <name>', 'Entity name')
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const id = await api.getIDByName(cmdOpts.type, cmdOpts.name);
printOutput(
{ id, type: cmdOpts.type, name: cmdOpts.name },
opts.format,
);
});
await withConnection(
opts,
async () => {
const id = await api.getIDByName(cmdOpts.type, cmdOpts.name);
printOutput(
{ id, type: cmdOpts.type, name: cmdOpts.name },
opts.format,
);
},
{ mutates: false },
);
});
server
@@ -49,12 +53,16 @@ export function registerServerCommand(program: Command) {
.option('--account <id>', 'Specific account ID to sync')
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
const args = cmdOpts.account
? { accountId: cmdOpts.account }
: undefined;
await api.runBankSync(args);
printOutput({ success: true }, opts.format);
});
await withConnection(
opts,
async () => {
const args = cmdOpts.account
? { accountId: cmdOpts.account }
: undefined;
await api.runBankSync(args);
printOutput({ success: true }, opts.format);
},
{ mutates: true },
);
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -60,6 +60,33 @@ describe('formatOutput', () => {
expect(result).toContain('a');
expect(result).toContain('b');
});
it('formats amount fields as decimal values', () => {
const data = [{ name: 'Groceries', amount: -250000 }];
const result = formatOutput(data, 'table');
expect(result).toContain('-2500.00');
expect(result).not.toContain('-250000');
});
it('formats balance fields as decimal values', () => {
const data = [{ id: 'acc1', balance: 166500 }];
const result = formatOutput(data, 'table');
expect(result).toContain('1665.00');
});
it('formats budgeted and spent fields as decimal values', () => {
const data = [{ budgeted: 50000, spent: -32150 }];
const result = formatOutput(data, 'table');
expect(result).toContain('500.00');
expect(result).toContain('-321.50');
});
it('does not format non-amount numeric fields', () => {
const data = [{ id: 12345, sort_order: 100 }];
const result = formatOutput(data, 'table');
expect(result).toContain('12345');
expect(result).toContain('100');
});
});
describe('csv', () => {
@@ -112,6 +139,21 @@ describe('formatOutput', () => {
const lines = result.split('\n');
expect(lines[0]).toBe('a,b');
});
it('formats amount fields as decimal values', () => {
const data = [{ name: 'Coffee', amount: -2500 }];
const result = formatOutput(data, 'csv');
const lines = result.split('\n');
expect(lines[0]).toBe('name,amount');
expect(lines[1]).toBe('Coffee,-25.00');
});
it('does not format amount fields in json output', () => {
const data = [{ amount: 166500 }];
const result = formatOutput(data, 'json');
expect(result).toContain('166500');
expect(result).not.toContain('1665.00');
});
});
});

View File

@@ -2,6 +2,29 @@ import Table from 'cli-table3';
export type OutputFormat = 'json' | 'table' | 'csv';
// Fields containing integer-cent values, auto-formatted as decimals in table/csv output.
const AMOUNT_FIELDS = new Set([
'amount',
'balance',
'balance_available',
'balance_current',
'balance_limit',
'budgeted',
'spent',
'carryover',
]);
function isAmountValue(key: string, value: unknown): value is number {
return AMOUNT_FIELDS.has(key) && typeof value === 'number';
}
function formatCellValue(key: string, value: unknown): string {
if (isAmountValue(key, value)) {
return (value / 100).toFixed(2);
}
return String(value ?? '');
}
export function formatOutput(
data: unknown,
format: OutputFormat = 'json',
@@ -23,7 +46,7 @@ function formatTable(data: unknown): string {
if (data && typeof data === 'object') {
const table = new Table();
for (const [key, value] of Object.entries(data)) {
table.push({ [key]: String(value) });
table.push({ [key]: formatCellValue(key, value) });
}
return table.toString();
}
@@ -39,7 +62,7 @@ function formatTable(data: unknown): string {
for (const row of data) {
const r = row as Record<string, unknown>;
table.push(keys.map(k => String(r[k] ?? '')));
table.push(keys.map(k => formatCellValue(k, r[k])));
}
return table.toString();
@@ -50,7 +73,9 @@ function formatCsv(data: unknown): string {
if (data && typeof data === 'object') {
const entries = Object.entries(data);
const header = entries.map(([k]) => escapeCsv(k)).join(',');
const values = entries.map(([, v]) => escapeCsv(String(v))).join(',');
const values = entries
.map(([k, v]) => escapeCsv(formatCellValue(k, v)))
.join(',');
return header + '\n' + values;
}
return String(data);
@@ -64,7 +89,7 @@ function formatCsv(data: unknown): string {
const header = keys.map(k => escapeCsv(k)).join(',');
const rows = data.map(row => {
const r = row as Record<string, unknown>;
return keys.map(k => escapeCsv(String(r[k] ?? ''))).join(',');
return keys.map(k => escapeCsv(formatCellValue(k, r[k]))).join(',');
});
return [header, ...rows].join('\n');

View File

@@ -1,3 +1,7 @@
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export function parseBoolFlag(value: string, flagName: string): boolean {
if (value !== 'true' && value !== 'false') {
throw new Error(
@@ -14,3 +18,23 @@ export function parseIntFlag(value: string, flagName: string): number {
}
return parsed;
}
export function parseNonNegativeIntFlag(
value: string,
flagName: string,
): number {
const parsed = parseIntFlag(value, flagName);
if (parsed < 0) {
throw new Error(
`Invalid ${flagName}: "${value}". Expected a non-negative integer.`,
);
}
return parsed;
}
export function parseBoolEnv(raw: string | undefined): boolean | undefined {
if (raw === undefined) return undefined;
if (raw === '1' || raw.toLowerCase() === 'true') return true;
if (raw === '0' || raw.toLowerCase() === 'false') return false;
return undefined;
}

View File

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

View File

@@ -45,6 +45,27 @@
-->
<style>
/* Show logo icon next to brand title text */
.sidebar-header a[href='https://actualbudget.org'] {
display: flex;
align-items: center;
gap: 8px;
text-decoration: none;
font-weight: 700;
font-size: 16px;
}
.sidebar-header a[href='https://actualbudget.org']::before {
content: '';
display: inline-block;
width: 32px;
height: 32px;
min-width: 32px;
background-image: url('https://actualbudget.org/img/logo.webp');
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}
#storybook-explorer-searchfield {
font-weight: 400 !important;
font-size: 14px !important;

View File

@@ -16,7 +16,6 @@ const theme = create({
base: 'light',
brandTitle: 'Actual Budget',
brandUrl: 'https://actualbudget.org',
brandImage: 'https://actualbudget.org/img/actual.webp',
brandTarget: '_blank',
// UI colors
@@ -32,7 +31,7 @@ const theme = create({
// Fonts
fontBase:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
'"Inter Variable", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", Helvetica, Arial, sans-serif',
fontCode: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace',
// Text colors

View File

@@ -2,6 +2,9 @@
"name": "@actual-app/components",
"version": "0.0.1",
"license": "MIT",
"imports": {
"#tokens": "./src/tokens.ts"
},
"exports": {
"./hooks/*": "./src/hooks/*.ts",
"./icons/logo": "./src/icons/logo/index.ts",
@@ -44,24 +47,25 @@
},
"dependencies": {
"@emotion/css": "^11.13.5",
"react-aria-components": "^1.15.1",
"react-aria-components": "^1.16.0",
"usehooks-ts": "^3.1.1"
},
"devDependencies": {
"@chromatic-com/storybook": "^5.0.1",
"@storybook/addon-a11y": "^10.2.16",
"@storybook/addon-docs": "^10.2.16",
"@storybook/react-vite": "^10.2.16",
"@chromatic-com/storybook": "^5.1.1",
"@storybook/addon-a11y": "^10.3.4",
"@storybook/addon-docs": "^10.3.4",
"@storybook/react-vite": "^10.3.4",
"@svgr/babel-plugin-add-jsx-attribute": "^8.0.0",
"@svgr/cli": "^8.1.0",
"@types/react": "^19.2.14",
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
"@vitejs/plugin-react": "^6.0.0",
"eslint-plugin-storybook": "^10.2.16",
"@typescript/native-preview": "^7.0.0-dev.20260404.1",
"@vitejs/plugin-react": "^6.0.1",
"eslint-plugin-storybook": "^10.3.4",
"react": "19.2.4",
"react-dom": "19.2.4",
"storybook": "^10.2.16",
"vite": "^8.0.0",
"vitest": "^4.1.0"
"storybook": "^10.3.4",
"vite": "^8.0.5",
"vitest": "^4.1.2"
},
"peerDependencies": {
"react": ">=19.2",

View File

@@ -5,6 +5,21 @@ import { css, cx } from '@emotion/css';
import type { CSSProperties } from './styles';
export const viewStyles = css({
alignItems: 'stretch',
borderWidth: 0,
borderStyle: 'solid',
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column',
margin: 0,
padding: 0,
position: 'relative',
/* fix flexbox bugs */
minHeight: 0,
minWidth: 0,
});
type ViewProps = Omit<HTMLProps<HTMLDivElement>, 'style'> & {
className?: string;
style?: CSSProperties;
@@ -13,19 +28,15 @@ type ViewProps = Omit<HTMLProps<HTMLDivElement>, 'style'> & {
};
export const View = forwardRef<HTMLDivElement, ViewProps>((props, ref) => {
// The default styles are special-cased and pulled out into static
// styles, and hardcode the class name here. View is used almost
// everywhere and we can avoid any perf penalty that glamor would
// incur.
const { className = '', style, nativeStyle, innerRef, ...restProps } = props;
return (
<div
{...restProps}
ref={innerRef ?? ref}
style={nativeStyle}
className={cx(
'view',
viewStyles,
className,
style && Object.keys(style).length > 0 ? css(style) : undefined,
)}

View File

@@ -1,6 +1,6 @@
import { useWindowSize } from 'usehooks-ts';
import { breakpoints } from '../tokens';
import { breakpoints } from '#tokens';
export function useResponsive() {
const { height, width } = useWindowSize({

View File

@@ -1,4 +1,8 @@
module.exports = {
prettier: true,
prettierConfig: {
singleQuote: true,
},
svgoConfig: {
plugins: [
{
@@ -14,7 +18,7 @@ module.exports = {
babelConfig: {
plugins: [
[
'./add-attribute',
'./add-attribute.ts',
{
elements: ['path', 'Path', 'rect', 'Rect'],
attributes: [

View File

@@ -1,10 +1,26 @@
import type BabelTemplate from '@babel/template';
import type { NodePath } from '@babel/traverse';
import type * as BabelTypes from '@babel/types';
import type { Attribute, Options } from '@svgr/babel-plugin-add-jsx-attribute';
type PluginAPI = {
types: typeof BabelTypes;
template: typeof BabelTemplate;
};
const positionMethod = {
start: 'unshiftContainer',
end: 'pushContainer',
};
} as const;
const addJSXAttribute = ({ types: t, template }, opts) => {
function getAttributeValue({ literal, value }) {
const addJSXAttribute = ({ types: t, template }: PluginAPI, opts: Options) => {
function getAttributeValue({
literal,
value,
}: Pick<Attribute, 'literal' | 'value'>):
| BabelTypes.JSXExpressionContainer
| BabelTypes.StringLiteral
| null {
if (typeof value === 'boolean') {
return t.jsxExpressionContainer(t.booleanLiteral(value));
}
@@ -14,7 +30,7 @@ const addJSXAttribute = ({ types: t, template }, opts) => {
}
if (typeof value === 'string' && literal) {
return t.jsxExpressionContainer(template.ast(value).expression);
return t.jsxExpressionContainer(template.expression.ast(value));
}
if (typeof value === 'string') {
@@ -24,7 +40,7 @@ const addJSXAttribute = ({ types: t, template }, opts) => {
return null;
}
function getAttribute({ spread, name, value, literal }) {
function getAttribute({ spread, name, value, literal }: Attribute) {
if (spread) {
return t.jsxSpreadAttribute(t.identifier(name));
}
@@ -37,7 +53,8 @@ const addJSXAttribute = ({ types: t, template }, opts) => {
return {
visitor: {
JSXOpeningElement(path) {
JSXOpeningElement(path: NodePath<BabelTypes.JSXOpeningElement>) {
if (!t.isJSXIdentifier(path.node.name)) return;
if (!opts.elements.includes(path.node.name.name)) return;
opts.attributes.forEach(
@@ -52,7 +69,11 @@ const addJSXAttribute = ({ types: t, template }, opts) => {
const newAttribute = getAttribute({ spread, name, value, literal });
const attributes = path.get('attributes');
const isEqualAttribute = attribute => {
const isEqualAttribute = (
attribute: NodePath<
BabelTypes.JSXAttribute | BabelTypes.JSXSpreadAttribute
>,
) => {
if (spread) {
return attribute.get('argument').isIdentifier({ name });
}
@@ -67,7 +88,11 @@ const addJSXAttribute = ({ types: t, template }, opts) => {
// Only add the color if it doesn't explicitly say no
// color
if (attribute.get('value').node.value !== 'none') {
const attrValue = attribute.get('value');
if (
!attrValue.isStringLiteral() ||
attrValue.node.value !== 'none'
) {
attribute.replaceWith(newAttribute);
}
@@ -84,4 +109,4 @@ const addJSXAttribute = ({ types: t, template }, opts) => {
};
};
module.exports = addJSXAttribute;
export default addJSXAttribute;

View File

@@ -9,4 +9,4 @@ function indexTemplate(filePaths: { path: string }[]) {
return exportEntries.join('\n');
}
module.exports = indexTemplate;
export default indexTemplate;

View File

@@ -13,11 +13,11 @@ export const SvgLogo = (props: SVGProps<SVGSVGElement>) => (
>
<path
fill="currentColor"
d="m1.138 30.423 13.8-29.309a.32.32 0 0 1 .289-.184h.605a.32.32 0 0 1 .287.18l8.903 18.29 2.791-1.074a.32.32 0 0 1 .414.184l.742 1.932a.32.32 0 0 1-.183.413l-2.574.99 3.175 6.524a.32.32 0 0 1-.147.428l-1.861.905a.32.32 0 0 1-.428-.147l-3.277-6.733-21.98 8.453a.32.32 0 0 1-.415-.189l-.152-.418a.32.32 0 0 1 .01-.245ZM15.56 6.152 5.85 26.774l16.634-6.398L15.56 6.152Z"
d="m1.138 30.423 13.8-29.309a.32.32 0 0 1 .289-.184h.605a.32.32 0 0 1 .287.18l8.903 18.29 2.791-1.074a.32.32 0 0 1 .414.184l.742 1.932a.32.32 0 0 1-.183.413l-2.574.99 3.175 6.524a.32.32 0 0 1-.147.428l-1.861.905a.32.32 0 0 1-.428-.147l-3.277-6.733-21.98 8.453a.32.32 0 0 1-.415-.189l-.152-.418a.32.32 0 0 1 .01-.245M15.56 6.152 5.85 26.774l16.634-6.398z"
/>
<path
fill="currentColor"
d="m21.777 14.568.932 2.544-21.203 7.775a.32.32 0 0 1-.41-.19l-.713-1.944a.32.32 0 0 1 .19-.41l21.204-7.775Z"
d="m21.777 14.568.932 2.544-21.203 7.775a.32.32 0 0 1-.41-.19l-.713-1.944a.32.32 0 0 1 .19-.41z"
/>
</svg>
);

View File

@@ -15,4 +15,4 @@ export const ${componentName} = (${props}) => (
`;
};
module.exports = tmpl;
export default tmpl;

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