Compare commits

..

20 Commits

Author SHA1 Message Date
github-actions[bot]
5b8382934a Bump version: 26.5.2 2026-05-08 20:03:38 +01:00
github-actions[bot]
5303ad7d93 Bump version 2026-05-08 19:59:44 +01:00
Matiss Janis Aboltins
1bec2bd2ee [AI] Revert crypto.randomUUID back to uuid library (#7734)
* [AI] Revert crypto.randomUUID back to uuid library

Partial revert of #7529. Restores the `uuid` package dependency in
api, crdt, desktop-client, loot-core, and sync-server, swapping every
`crypto.randomUUID()` call introduced by that PR back to `uuidv4()`
(and the `uuid()` alias in RuleEditor.tsx and ruleUtils.ts where it
was previously used). The lint rule, docs entry, and `vi.mock('uuid')`
test setup are restored as well. The `fs-extra` removals in
desktop-electron from the same PR are left in place.

https://claude.ai/code/session_01KTg1g416Jdjf5feGke8MQw

* Add release notes for PR #7734

* [check-spelling] Update metadata

Update for https://github.com/actualbudget/actual/actions/runs/25480733101/attempts/1
Accepted in https://github.com/actualbudget/actual/pull/7734#issuecomment-4394811498

Signed-off-by: check-spelling-bot <check-spelling-bot@users.noreply.github.com>
on-behalf-of: @check-spelling <check-spelling-bot@check-spelling.dev>

* [AI] Use the uuidv4 alias in RuleEditor and ruleUtils

The previous commit preserved the `v4 as uuid` alias in these two files
to match their pre-#7529 state, but the project convention (and lint
rule message) is `v4 as uuidv4`. CodeRabbit flagged the inconsistency,
so normalize the alias and call sites in both files.

https://claude.ai/code/session_01KTg1g416Jdjf5feGke8MQw

* [AI] Pin uuid to ^11.1.0 to fix Electron e2e

uuid v13 is ESM-only (no CJS entry, only an exports map with `node` and
`default` conditions both pointing to ESM files). The Electron backend is
bundled as CJS by `loot-core/vite.desktop.config.mts` and loaded via a
dynamic `await import(process.env.lootCoreScript!)` in the
desktop-electron utilityProcess; that pipeline appears to fall over on
the ESM-to-CJS transform of uuid v13 in Vite 8 / rolldown, which makes
the Functional Desktop App e2e job fail consistently while every other
check (web e2e, VRT, unit tests, lint, typecheck) passes.

uuid v11 still ships `dist/cjs/index.js`, so pinning each workspace's
uuid range to ^11.1.0 sidesteps the resolution path entirely. The API
is unchanged (`v4` is still the same export), so no source-code changes
are needed.

https://claude.ai/code/session_01KTg1g416Jdjf5feGke8MQw

* [AI] Capture Electron stdout/stderr in desktop e2e fixture (TEMP DEBUG)

Pipe the Electron main+utility process stdout/stderr into both the
playwright runner stderr and an electron.log file inside the test's
output directory. This makes the actual backend error visible in the
Functional Desktop App job output and in the desktop-app-test-results
artifact.

Will be reverted once the failure cause is identified.

https://claude.ai/code/session_01KTg1g416Jdjf5feGke8MQw

* Revert "[AI] Capture Electron stdout/stderr in desktop e2e fixture (TEMP DEBUG)"

This reverts commit 4cb5148859.

* [AI] Fix rebuild-electron and bump uuid back to ^13.0.0

The Functional Desktop App e2e job had been failing with
ERR_DLOPEN_FAILED on better-sqlite3 (NODE_MODULE_VERSION 127 vs 140),
which surfaced because this PR's yarn.lock change invalidated the
GitHub Actions node_modules cache. With a fresh install,
rebuild-electron is responsible for compiling better-sqlite3 and
bcrypt against Electron's ABI — but since #7712 the script has been
running from the repo root, where neither module is a direct
dependency, so electron-rebuild silently exits as a no-op.

Restore the `-m ./packages/desktop-electron` scoping that #7712
removed, so the rebuild actually finds and rebuilds the modules.
This fix is technically out of scope for this PR but it blocks any
PR that touches yarn.lock.

Also revert the `^11.1.0` pin from the previous commit back to
`^13.0.0`. The pin was based on a wrong hypothesis (that uuid v13's
ESM-only packaging was breaking the bundle); now that the real
cause is identified, there is no reason to deviate from the
pre-#7529 version.

https://claude.ai/code/session_01KTg1g416Jdjf5feGke8MQw

---------

Signed-off-by: check-spelling-bot <check-spelling-bot@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: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>
2026-05-07 21:28:35 +01:00
Dan Hopkins
1c4ff1d8f5 fix: only count failed attempts against auth rate limit (#7707)
* fix: only count failed attempts against auth rate limit

Add skipSuccessfulRequests: true to authRateLimiter so that successful
logins do not consume quota. This fixes breakage for API clients
(actual-cli, actual-mcp, custom scripts) that re-authenticate per
operation — they always provide the correct password, so they should
never be rate-limited.

Brute-force attackers generate repeated failures and still hit the wall.

Fixes #7706

* Update upcoming-release-notes/7706.md

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

* fix: rename release note to match PR number

---------

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2026-05-07 21:27:55 +01:00
Michael Clark
5b57f6e931 :electron: Fix desktop app server certs (#7713)
* fix desktop app server certs integration

* fix self signed certs - release notes

* security note

* release note spelling

* better message

* remove security notes cause its not a valid prop
2026-05-07 21:27:55 +01:00
Julian
4c62e2a75d Empty commit to bump CI 2026-05-03 03:39:27 +00:00
Julian Dominguez-Schatz
922f0b8a53 Update docs release date 2026-05-02 13:47:55 -04:00
Julian Dominguez-Schatz
cfd527b446 Fix shared worker resumption after tab suspend (#7656)
* [AI] Fix SharedWorker tab resume recovery

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

* [AI] Fix SharedWorker reload readiness

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

* Add release notes

* Update packages/desktop-client/src/shared-browser-server-core.ts

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>
2026-05-02 13:46:31 -04:00
Julian Dominguez-Schatz
56ea6cba68 Update author
Updated author information in the release notes.
2026-04-27 23:38:40 -04:00
github-actions[bot]
60c0796a92 Generate release notes for v26.5.0 2026-04-27 21:52:09 +00:00
Matt Fiddaman
7b0460d7e9 fix infinite loop when remainder is impossible to solve (#7623)
* fix infinite loop when remainder is impossible to solve

* note
2026-04-27 22:51:20 +01:00
Matt Fiddaman
46ba63f370 increase test coverage for budget templates (#7620)
* [AI] cover existing template engine logic with regression tests

Adds tests for goal template behavior that predates this PR so the
suite can be cherry-picked onto master to confirm no regressions. No
production code changes.

Covers:
- init() validation: schedule names, by/schedule priority match, past
  by-target with and without annual/repeat, percentage source not
  found, special source aliases, duplicate limit/spend/goal
  directives, weekly limit missing start date, invalid limit period,
  unrecognized periodic period
- runRemainder cap clamping and hideDecimal fraction removal
- Income-category branch in runTemplatesForPriority
- getLimitExcess against an aggregate weekly cap
- Past by-target rolling forward via the annual period
- runSchedule full=true (no sinking accumulation), percent and fixed
  adjustments, completed-schedule filtering, past-date error for
  non-repeating schedules, monthly/weekly/daily sinking contribution
  branches when interval exceeds the pay-month-of cap, surplus
  absorption when last-month balance exceeds the target, and
  tracking-budget mode forcing all schedules pay-month-of
- applyMultipleCategoryTemplates orchestration: per-category writes,
  cross-category priority clamping when funds run out, error
  notification path
- applyTemplate force=false skipping already-budgeted categories

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

* note

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 22:51:20 +01:00
github-actions[bot]
4926cd5d76 Generate release notes for v26.5.0 2026-04-27 21:48:23 +00:00
Emil Tveden Bjerglund
05efe9ceee Fix Sankey income bug, when payee it not set (#7632)
* Ensure income categories are shown correct, even if payee is not set

* Add release note
2026-04-27 22:47:35 +01:00
Matt Fiddaman
da522f3e3e add release note highlights 2026-04-27 22:35:34 +01:00
github-actions[bot]
8636591ddf Generate release notes for v26.5.0 2026-04-27 21:30:22 +00:00
Matt Fiddaman
b5d59c7428 fix lint (#7643) 2026-04-27 22:29:28 +01:00
Matt Fiddaman
c02e308739 fix cherrypicked commits not being respected and lint race in release note generation workflow (#7640)
* fix cherrypicked commits not being respected and lint race

* note

* coderabbit suggestions

* fix lint

* make double restore possibility safe
2026-04-27 21:59:57 +01:00
Matt Fiddaman
a5e80edd32 fix release note generation script (#7635)
* fix release note generation script

* note
2026-04-27 20:48:40 +01:00
github-merge-queue
9beeae54e0 🔖 (26.5.0) 2026-04-25 17:13:14 +00:00
458 changed files with 5478 additions and 17413 deletions

View File

@@ -1,6 +1,6 @@
issue_enrichment:
auto_enrich:
enabled: true
enabled: false
reviews:
request_changes_workflow: true
review_status: false

View File

@@ -1,7 +1,7 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-docker-compose
{
"name": "Actual Devcontainer",
"name": "Actual development",
"dockerComposeFile": ["../docker-compose.yml", "docker-compose.yml"],
// Alternatively:
// "image": "mcr.microsoft.com/devcontainers/typescript-node:0-16",

View File

@@ -3,6 +3,9 @@ contact_links:
- name: Bank-sync issues
url: https://discord.gg/pRYNYr4W5A
about: Is bank-sync not working? Returning too much or too few information? Reach out to the community on Discord.
- name: Support
url: https://discord.gg/pRYNYr4W5A
about: Need help with something? Having troubles setting up? Or perhaps issues using the API? Reach out to the community on Discord.
- name: Translations
url: https://hosted.weblate.org/projects/actualbudget/actual/
about: Found a string that needs a better translation? Add your suggestion or upvote an existing one in Weblate.

View File

@@ -1,17 +0,0 @@
name: Tech Support
description: Need help with something? Having troubles setting up? Or perhaps issues using the API?
title: '[Support]: '
labels: ['tech-support']
body:
- type: markdown
attributes:
value: |
> ⚠️ **Tech support tickets opened here are automatically closed.** GitHub Issues are reserved for bug reports and feature requests. The fastest way to get help is to ask the community on [Discord](https://discord.gg/pRYNYr4W5A) — that's where most of the community lives and can help you in real time.
- type: textarea
id: problem
attributes:
label: Describe your problem
description: Please describe, in as much detail as you can, what you need help with.
placeholder: I'm trying to [...] but [...]
validations:
required: true

View File

@@ -1,4 +1,4 @@
<!-- Thank you for submitting a pull request! Make sure to follow the instructions to write release notes for your PR — it should only take a minute or two: https://actualbudget.org/docs/contributing/#writing-good-release-notes. Try running yarn generate:release-notes *before* pushing your PR for an interactive experience. -->
<!-- Thank you for submitting a pull request! Make sure to follow the instructions to write release notes for your PR — it should only take a minute or two: https://github.com/actualbudget/docs#writing-good-release-notes. Try running yarn generate:release-notes *before* pushing your PR for an interactive experience. -->
## Description

View File

@@ -44,7 +44,6 @@ CLP
CMCIFRPAXXX
COBADEFF
CODEOWNERS
Codespaces
COEP
commerzbank
Copiar
@@ -147,6 +146,8 @@ pluggyai
Poste
PPABPLPK
prefs
Primoco
Priotecs
proactively
Qatari
QNTOFRP
@@ -178,6 +179,7 @@ SWEDBANK
SWEDNOKK
Synology
systemctl
tada
taskbar
templating
THB

View File

@@ -10,10 +10,6 @@ inputs:
description: 'Whether to download translations as part of setup, default true'
required: false
default: 'true'
cache:
description: 'Whether to restore and save dependency and Lage caches, default true'
required: false
default: 'true'
runs:
using: composite
@@ -22,7 +18,6 @@ runs:
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
package-manager-cache: ${{ inputs.cache }}
- name: Install yarn
run: npm install -g yarn
shell: bash
@@ -33,7 +28,6 @@ runs:
shell: bash
- name: Cache
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
if: ${{ inputs.cache == 'true' }}
id: cache
with:
path: ${{ format('{0}/**/node_modules', inputs.working-directory) }}
@@ -43,7 +37,6 @@ runs:
shell: bash
- name: Cache Lage
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
if: ${{ inputs.cache == 'true' }}
with:
path: ${{ format('{0}/.lage', inputs.working-directory) }}
key: lage-${{ runner.os }}-${{ github.sha }}

View File

@@ -1,27 +0,0 @@
name: Add 'AI generated' label to '[AI]' PRs
##########################################################################################
# This workflow uses the 'pull_request_target' event so it has a token that can add a #
# label to PRs from forks. It does NOT check out or execute any code from the PR, so it #
# is not vulnerable to the usual 'pull_request_target' code-injection concerns. Keep it #
# that way - do not add a checkout step or run any PR-provided scripts here. #
##########################################################################################
on:
# This workflow never checks out or runs PR code; it only reads the PR title and adds a label.
pull_request_target: # zizmor: ignore[dangerous-triggers]
types: [opened, reopened, edited]
permissions:
pull-requests: write
jobs:
add-ai-generated-label:
name: Add 'AI generated' label
runs-on: ubuntu-latest
if: startsWith(github.event.pull_request.title, '[AI]')
steps:
- uses: actions-ecosystem/action-add-labels@bd52874380e3909a1ac983768df6976535ece7f8 # v1.1.0
with:
labels: AI generated
github_token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -14,9 +14,6 @@ on:
pull_request:
merge_group:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}

View File

@@ -7,9 +7,6 @@ on:
pull_request:
merge_group:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}

View File

@@ -11,11 +11,6 @@ on:
required: true
type: string
permissions:
contents: read
issues: read
pull-requests: read
jobs:
count-points:
runs-on: ubuntu-latest

View File

@@ -1,48 +0,0 @@
name: CRDT version bump check
on:
pull_request:
paths:
- 'packages/crdt/**'
permissions:
contents: read
jobs:
check-version-bump:
runs-on: ubuntu-latest
name: Ensure @actual-app/crdt version is bumped
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- name: Verify version bump
env:
BASE_REF: ${{ github.base_ref }}
run: |
set -euo pipefail
if ! git cat-file -e "origin/${BASE_REF}:packages/crdt/package.json" 2>/dev/null; then
echo "packages/crdt/package.json does not exist on the base branch; skipping."
exit 0
fi
BASE_VERSION=$(git show "origin/${BASE_REF}:packages/crdt/package.json" | jq -r .version)
HEAD_VERSION=$(jq -r .version packages/crdt/package.json)
echo "Base version: $BASE_VERSION"
echo "Head version: $HEAD_VERSION"
if [ "$BASE_VERSION" = "$HEAD_VERSION" ]; then
echo "::error file=packages/crdt/package.json::Files in packages/crdt/ were modified but the @actual-app/crdt version was not bumped. Please update the \"version\" field in packages/crdt/package.json."
exit 1
fi
HIGHEST=$(printf '%s\n%s\n' "$BASE_VERSION" "$HEAD_VERSION" | sort -V | tail -n1)
if [ "$HIGHEST" != "$HEAD_VERSION" ]; then
echo "::error file=packages/crdt/package.json::The @actual-app/crdt version ($HEAD_VERSION) must be greater than the base version ($BASE_VERSION)."
exit 1
fi
echo "Version bumped from $BASE_VERSION to $HEAD_VERSION."

View File

@@ -26,7 +26,6 @@ permissions:
jobs:
cut-release-branch:
runs-on: ubuntu-latest
environment: release
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -37,8 +36,6 @@ jobs:
- name: Set up environment
uses: ./.github/actions/setup
with:
# Avoid restoring potentially poisoned caches in release jobs.
cache: 'false'
download-translations: 'false'
- name: Bump package versions

View File

@@ -32,7 +32,6 @@ jobs:
if: github.event_name == 'workflow_dispatch' || !github.event.repository.fork
name: Build Docker image
runs-on: ubuntu-latest
environment: release
strategy:
matrix:
os: [ubuntu, alpine]
@@ -75,9 +74,6 @@ jobs:
# This is faster and avoids yarn memory issues
- name: Set up environment
uses: ./.github/actions/setup
with:
# Avoid restoring potentially poisoned caches in release jobs.
cache: 'false'
- name: Build Web
run: yarn build:server
@@ -91,15 +87,10 @@ jobs:
tags: actualbudget/actual-server-testing
- name: Test that the docker image boots
timeout-minutes: 1
run: |
docker run --detach --network=host --name actual-server actualbudget/actual-server-testing
HEALTHCMD=$(yq -r '.services.actual_server.healthcheck.test[1]' packages/sync-server/docker-compose.yml)
until docker exec actual-server sh -c "$HEALTHCMD"; do sleep 1; done
- name: Dump container logs on failure
if: failure()
run: docker logs actual-server || true
docker run --detach --network=host actualbudget/actual-server-testing
sleep 10
curl --fail -sS -LI -w '%{http_code}\n' --retry 20 --retry-delay 1 --retry-connrefused localhost:5006
# This will use the cache from the earlier build step and not rebuild the image
# https://docs.docker.com/build/ci/github-actions/test-before-push/

View File

@@ -23,15 +23,10 @@ env:
TAGS: |
type=semver,pattern={{version}}
permissions:
contents: read
packages: write
jobs:
build:
name: Build Docker image
runs-on: ubuntu-latest
environment: release
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -81,29 +76,9 @@ jobs:
# This is faster and avoids yarn memory issues
- name: Set up environment
uses: ./.github/actions/setup
with:
# Avoid restoring potentially poisoned caches in release jobs.
cache: 'false'
- name: Build Web
run: yarn build:server
- name: Build ubuntu image for testing
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
push: false
load: true
file: packages/sync-server/docker/ubuntu.Dockerfile
tags: actualbudget/actual-server-testing
- name: Test that the ubuntu image boots
timeout-minutes: 1
run: |
docker rm -f actual-server 2>/dev/null || true
docker run --detach --network=host --name actual-server actualbudget/actual-server-testing
HEALTHCMD=$(yq -r '.services.actual_server.healthcheck.test[1]' packages/sync-server/docker-compose.yml)
until docker exec actual-server sh -c "$HEALTHCMD"; do sleep 1; done
- name: Build and push ubuntu image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
@@ -113,23 +88,6 @@ jobs:
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: ${{ steps.meta.outputs.tags }}
- name: Build alpine image for testing
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
push: false
load: true
file: packages/sync-server/docker/alpine.Dockerfile
tags: actualbudget/actual-server-testing
- name: Test that the alpine image boots
timeout-minutes: 1
run: |
docker rm -f actual-server 2>/dev/null || true
docker run --detach --network=host --name actual-server actualbudget/actual-server-testing
HEALTHCMD=$(yq -r '.services.actual_server.healthcheck.test[1]' packages/sync-server/docker-compose.yml)
until docker exec actual-server sh -c "$HEALTHCMD"; do sleep 1; done
- name: Build and push alpine image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
@@ -138,7 +96,3 @@ jobs:
file: packages/sync-server/docker/alpine.Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
tags: ${{ steps.alpine-meta.outputs.tags }}
- name: Dump container logs on failure
if: failure()
run: docker logs actual-server || true

View File

@@ -46,12 +46,13 @@ jobs:
# via ConfigurationPage.createTestFile()) is still rendered in a
# production build. Without it, e2e tests would time out waiting for
# a button that was tree-shaken out.
# --skip-translations keeps VRT screenshots deterministic by rendering
# source-code English instead of upstream Weblate en.json (which can
# drift between snapshot capture and test runs).
env:
REACT_APP_NETLIFY: 'true'
run: yarn build:browser --skip-translations
run: |
yarn workspace plugins-service build
yarn workspace @actual-app/crdt build
yarn workspace @actual-app/core build:browser
yarn workspace @actual-app/web build:browser
- name: Upload build artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
@@ -199,13 +200,11 @@ jobs:
if: github.event_name == 'pull_request'
run: |
mkdir -p vrt-metadata
echo "${PR_NUMBER}" > vrt-metadata/pr-number.txt
echo "${VRT_RESULT}" > vrt-metadata/vrt-result.txt
echo "${{ github.event.pull_request.number }}" > vrt-metadata/pr-number.txt
echo "${{ needs.vrt.result }}" > vrt-metadata/vrt-result.txt
echo "${STEPS_PLAYWRIGHT_REPORT_VRT_OUTPUTS_ARTIFACT_URL}" > vrt-metadata/artifact-url.txt
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
STEPS_PLAYWRIGHT_REPORT_VRT_OUTPUTS_ARTIFACT_URL: ${{ steps.playwright-report-vrt.outputs.artifact-url }}
VRT_RESULT: ${{ needs.vrt.result }}
- name: Upload VRT metadata
if: github.event_name == 'pull_request'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1

View File

@@ -21,7 +21,6 @@ jobs:
# this is so the assets can be added to the release
permissions:
contents: write
environment: release
strategy:
fail-fast: false
matrix:
@@ -67,9 +66,6 @@ jobs:
STEPS_PROCESS_VERSION_OUTPUTS_VERSION: ${{ steps.process_version.outputs.version }}
- name: Set up environment
uses: ./.github/actions/setup
with:
# Avoid restoring potentially poisoned caches in release jobs.
cache: 'false'
- name: Build Electron for Mac
if: ${{ startsWith(matrix.os, 'macos') }}
run: ./bin/package-electron
@@ -120,7 +116,48 @@ jobs:
!packages/desktop-electron/dist/Actual-windows.exe
packages/desktop-electron/dist/*.AppImage
packages/desktop-electron/dist/*.flatpak
packages/desktop-electron/dist/*.appx
outputs:
version: ${{ steps.process_version.outputs.version }}
publish-microsoft-store:
needs: build
runs-on: windows-latest
if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
steps:
- name: Install StoreBroker
shell: powershell
run: |
Install-Module -Name StoreBroker -AcceptLicense -Force -Scope CurrentUser -Verbose
- name: Download Microsoft Store artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: actual-electron-windows-latest-appx
- name: Submit to Microsoft Store
shell: powershell
run: |
# Disable telemetry
$global:SBDisableTelemetry = $true
# Authenticate against the store
$pass = ConvertTo-SecureString -String '${{ secrets.MICROSOFT_STORE_CLIENT_SECRET }}' -AsPlainText -Force
$cred = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList ${{ secrets.MICROSOFT_STORE_CLIENT_ID }},$pass
Set-StoreBrokerAuthentication -TenantId '${{ secrets.MICROSOFT_STORE_TENANT_ID }}' -Credential $cred
# Zip and create metadata files
$artifacts = Get-ChildItem -Path . -Filter *.appx | Select-Object -ExpandProperty FullName
New-StoreBrokerConfigFile -Path "$PWD/config.json" -AppId ${{ secrets.MICROSOFT_STORE_PRODUCT_ID }}
New-SubmissionPackage -ConfigPath "$PWD/config.json" -DisableAutoPackageNameFormatting -AppxPath $artifacts -OutPath "$PWD" -OutName submission
# Submit the app
# See https://github.com/microsoft/StoreBroker/blob/master/Documentation/USAGE.md#the-easy-way
Update-ApplicationSubmission `
-AppId ${{ secrets.MICROSOFT_STORE_PRODUCT_ID }} `
-SubmissionDataPath "submission.json" `
-PackagePath "submission.zip" `
-ReplacePackages `
-NoStatus `
-AutoCommit `
-Force

View File

@@ -19,9 +19,6 @@ on:
- '!packages/docs/**' # Docs changes don't affect Electron
- '!packages/eslint-plugin-actual/**' # Eslint plugin changes don't affect Electron
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true

View File

@@ -6,9 +6,6 @@ on:
- cron: '0 4 * * *'
workflow_dispatch:
permissions:
contents: read
jobs:
extract-and-upload-i18n-strings:
runs-on: ubuntu-latest

View File

@@ -4,9 +4,6 @@ on:
issues:
types: [labeled]
permissions:
issues: write
jobs:
needs-votes:
if: ${{ github.event.label.name == 'feature' }}

View File

@@ -1,26 +0,0 @@
name: Close tech support issues with automated message
on:
issues:
types: [labeled]
permissions:
issues: write
jobs:
tech-support:
if: ${{ github.event.label.name == 'tech-support' }}
runs-on: ubuntu-latest
steps:
- name: Create comment and close issue
run: |
gh issue comment "$ISSUE_URL" --body ":wave: Thanks for reaching out!
GitHub Issues are reserved for bug reports and feature requests, so tech support tickets are automatically closed. The fastest way to get help is to ask the community on [Discord](https://discord.gg/pRYNYr4W5A) — that's where most of the community lives and can help you in real time.
<!-- tech-support-auto-close-comment -->"
gh issue close "$ISSUE_URL"
env:
ISSUE_URL: https://github.com/actualbudget/actual/issues/${{ github.event.issue.number }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -4,9 +4,6 @@ on:
issues:
types: [closed]
permissions:
issues: write
jobs:
remove-help-wanted:
if: ${{ !contains(github.event.issue.labels.*.name, 'feature') && contains(github.event.issue.labels.*.name, 'help wanted') }}

View File

@@ -0,0 +1,37 @@
# When the "unfreeze" label is added to a PR, add that PR to Merge Freeze's unblocked list
# so it can be merged during a freeze. Uses pull_request_target so the workflow runs in
# the base repo and has access to MERGEFREEZE_ACCESS_TOKEN for fork PRs; it does not
# checkout or run any PR code. Requires MERGEFREEZE_ACCESS_TOKEN repo secret
# (project-specific token from Merge Freeze Web API panel for actualbudget/actual / master).
# See: https://docs.mergefreeze.com/web-api#post-freeze-status
name: Merge Freeze add PR to unblocked list
on:
pull_request_target:
types: [labeled]
jobs:
unfreeze:
if: ${{ github.event.label.name == 'unfreeze' }}
runs-on: ubuntu-latest
permissions: {}
concurrency:
group: merge-freeze-unfreeze-${{ github.ref }}-labels
cancel-in-progress: false
steps:
- name: POST to Merge Freeze add PR to unblocked list
env:
MERGEFREEZE_ACCESS_TOKEN: ${{ secrets.MERGEFREEZE_ACCESS_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
USER_NAME: ${{ github.actor }}
run: |
set -e
if [ -z "$MERGEFREEZE_ACCESS_TOKEN" ]; then
echo "::error::MERGEFREEZE_ACCESS_TOKEN secret is not set"
exit 1
fi
url="https://www.mergefreeze.com/api/branches/actualbudget/actual/master/?access_token=${MERGEFREEZE_ACCESS_TOKEN}"
payload=$(jq -n --arg user_name "$USER_NAME" --argjson pr "$PR_NUMBER" '{frozen: true, user_name: $user_name, unblocked_prs: [$pr]}')
curl -sf -X POST "$url" -H "Content-Type: application/json" -d "$payload"
echo "Merge Freeze updated: PR #$PR_NUMBER added to unblocked list."

View File

@@ -12,9 +12,6 @@ on:
tags:
- v**
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
@@ -22,7 +19,6 @@ concurrency:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
environment: release
steps:
- name: Repository Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -31,9 +27,6 @@ jobs:
- name: Set up environment
uses: ./.github/actions/setup
with:
# Avoid restoring potentially poisoned caches in release jobs.
cache: 'false'
- name: Install Netlify
run: npm install netlify-cli@17.10.1 -g

View File

@@ -1,86 +0,0 @@
name: Publish @actual-app/crdt
# Automatically publishes @actual-app/crdt when its package.json version
# changes on master (typically via a merged PR that bumped the version).
on:
push:
branches:
- master
paths:
- 'packages/crdt/package.json'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: publish-crdt
cancel-in-progress: false
jobs:
check-version:
runs-on: ubuntu-latest
name: Check if publish is needed
outputs:
should-publish: ${{ steps.check.outputs.should-publish }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Compare local version against npm registry
id: check
run: |
set -euo pipefail
LOCAL_VERSION=$(jq -r .version packages/crdt/package.json)
echo "Local version: $LOCAL_VERSION"
PUBLISHED_VERSION=$(npm view @actual-app/crdt version 2>/dev/null || echo "")
echo "Published version: ${PUBLISHED_VERSION:-<none>}"
if [ "$LOCAL_VERSION" = "$PUBLISHED_VERSION" ]; then
echo "Versions match - nothing to publish."
echo "should-publish=false" >> "$GITHUB_OUTPUT"
else
echo "Version changed - publish required."
echo "should-publish=true" >> "$GITHUB_OUTPUT"
fi
publish:
needs: check-version
if: needs.check-version.outputs.should-publish == 'true'
runs-on: ubuntu-latest
name: Publish @actual-app/crdt to npm
permissions:
contents: read
id-token: write # Required for npm OIDC provenance
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment
uses: ./.github/actions/setup
with:
# Avoid restoring potentially poisoned caches in release jobs.
cache: 'false'
download-translations: 'false'
- name: Build @actual-app/crdt
run: yarn workspace @actual-app/crdt build
- name: Pack @actual-app/crdt
run: yarn workspace @actual-app/crdt pack --filename @actual-app/crdt.tgz
- name: Setup node and npm registry
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 24
check-latest: true
# Avoid restoring potentially poisoned caches in release jobs.
package-manager-cache: false
registry-url: 'https://registry.npmjs.org'
- name: Publish to npm
run: npm publish packages/crdt/@actual-app/crdt.tgz --access public --provenance

View File

@@ -18,13 +18,9 @@ concurrency:
group: publish-flathub
cancel-in-progress: false
permissions:
contents: read
jobs:
publish-flathub:
runs-on: ubuntu-22.04
environment: release
steps:
- name: Resolve version
id: resolve_version

View File

@@ -1,116 +0,0 @@
name: Publish Microsoft Store
defaults:
run:
shell: bash
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: 'Release tag (e.g. v25.3.0)'
required: true
type: string
concurrency:
group: publish-microsoft-store
cancel-in-progress: false
permissions:
contents: read
jobs:
publish-microsoft-store:
runs-on: windows-latest
environment: release
steps:
- name: Resolve version
id: resolve_version
env:
EVENT_NAME: ${{ github.event_name }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
INPUT_TAG: ${{ inputs.tag }}
run: |
if [[ "$EVENT_NAME" == "release" ]]; then
TAG="$RELEASE_TAG"
else
TAG="$INPUT_TAG"
fi
if [[ -z "$TAG" ]]; then
echo "::error::No tag provided"
exit 1
fi
# Validate tag format (v-prefixed semver, e.g. v25.3.0 or v1.2.3-beta.1)
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
echo "::error::Invalid tag format: $TAG (expected v-prefixed semver, e.g. v25.3.0)"
exit 1
fi
VERSION="${TAG#v}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Resolved tag=$TAG version=$VERSION"
- name: Verify release assets exist
env:
GH_TOKEN: ${{ github.token }}
STEPS_RESOLVE_VERSION_OUTPUTS_TAG: ${{ steps.resolve_version.outputs.tag }}
run: |
TAG="${STEPS_RESOLVE_VERSION_OUTPUTS_TAG}"
echo "Checking release assets for tag $TAG..."
ASSETS=$(gh api "repos/${{ github.repository }}/releases/tags/$TAG" --jq '.assets[].name')
echo "Found assets:"
echo "$ASSETS"
if ! echo "$ASSETS" | grep -q "\.appx$"; then
echo "::error::No .appx assets found in release $TAG"
exit 1
fi
echo "Required .appx assets found."
- name: Download Microsoft Store artifacts
env:
GH_TOKEN: ${{ github.token }}
STEPS_RESOLVE_VERSION_OUTPUTS_TAG: ${{ steps.resolve_version.outputs.tag }}
run: |
TAG="${STEPS_RESOLVE_VERSION_OUTPUTS_TAG}"
gh release download "$TAG" --repo "${{ github.repository }}" --pattern "*.appx"
- name: Install StoreBroker
shell: powershell
run: |
Install-Module -Name StoreBroker -AcceptLicense -Force -Scope CurrentUser -Verbose
- name: Submit to Microsoft Store
shell: powershell
run: |
# Disable telemetry
$global:SBDisableTelemetry = $true
# Authenticate against the store
$pass = ConvertTo-SecureString -String '${{ secrets.MICROSOFT_STORE_CLIENT_SECRET }}' -AsPlainText -Force
$cred = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList ${{ secrets.MICROSOFT_STORE_CLIENT_ID }},$pass
Set-StoreBrokerAuthentication -TenantId '${{ secrets.MICROSOFT_STORE_TENANT_ID }}' -Credential $cred
# Zip and create metadata files
$artifacts = Get-ChildItem -Path . -Filter *.appx | Select-Object -ExpandProperty FullName
New-StoreBrokerConfigFile -Path "$PWD/config.json" -AppId ${{ secrets.MICROSOFT_STORE_PRODUCT_ID }}
New-SubmissionPackage -ConfigPath "$PWD/config.json" -DisableAutoPackageNameFormatting -AppxPath $artifacts -OutPath "$PWD" -OutName submission
# Submit the app
# See https://github.com/microsoft/StoreBroker/blob/master/Documentation/USAGE.md#the-easy-way
Update-ApplicationSubmission `
-AppId ${{ secrets.MICROSOFT_STORE_PRODUCT_ID }} `
-SubmissionDataPath "submission.json" `
-PackagePath "submission.zip" `
-ReplacePackages `
-NoStatus `
-AutoCommit `
-Force

View File

@@ -13,9 +13,6 @@ defaults:
env:
CI: true
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
@@ -30,7 +27,6 @@ jobs:
- windows-latest
- macos-latest
runs-on: ${{ matrix.os }}
environment: release
if: github.event.repository.fork == false
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -48,9 +44,6 @@ jobs:
- name: Set up environment
uses: ./.github/actions/setup
with:
# Avoid restoring potentially poisoned caches in release jobs.
cache: 'false'
- if: ${{ startsWith(matrix.os, 'ubuntu') }}
name: Setup Flatpak dependencies

View File

@@ -9,9 +9,6 @@ on:
- cron: '0 0 * * *'
workflow_dispatch:
permissions:
contents: read
jobs:
build-and-pack:
runs-on: ubuntu-latest
@@ -24,9 +21,6 @@ jobs:
- name: Set up environment
uses: ./.github/actions/setup
with:
# Avoid restoring potentially poisoned caches in release jobs.
cache: 'false'
- name: Update package versions
if: github.event_name != 'push'
@@ -93,7 +87,6 @@ jobs:
runs-on: ubuntu-latest
name: Publish npm packages
needs: build-and-pack
environment: release
permissions:
contents: read
packages: write
@@ -111,8 +104,6 @@ jobs:
with:
node-version: 24
check-latest: true
# Avoid restoring potentially poisoned caches in release jobs.
package-manager-cache: false
registry-url: 'https://registry.npmjs.org'
- name: Publish Core

View File

@@ -33,7 +33,6 @@ jobs:
permissions:
pull-requests: write
contents: read
actions: read
steps:
- name: Checkout base branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -45,120 +44,140 @@ jobs:
with:
download-translations: 'false'
# Resolve one successful `build.yml` run for each side (master and PR
# head) up front, then pin every download below to its `run_id`. This
# ensures artifact downloads are consistent and prevents race conditions.
- name: Resolve build runs
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
id: build-runs
env:
BASE_REF: ${{ github.base_ref }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
- name: Wait for ${{github.base_ref}} web build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
id: master-web-build
with:
script: |
const TIMEOUT_MS = 30 * 60 * 1000;
const SLEEP_MS = 15000;
token: ${{ secrets.GITHUB_TOKEN }}
checkName: web
ref: ${{github.base_ref}}
- name: Wait for ${{github.base_ref}} API build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
id: master-api-build
with:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: api
ref: ${{github.base_ref}}
- name: Wait for ${{github.base_ref}} CLI build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
id: master-cli-build
with:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: cli
ref: ${{github.base_ref}}
- name: Wait for ${{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}}
async function resolveRun({ label, filter, notFoundHint }) {
const deadline = Date.now() + TIMEOUT_MS;
while (true) {
const { data } = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'build.yml',
...filter,
status: 'success',
per_page: 1,
});
if (data.workflow_runs.length > 0) {
const run = data.workflow_runs[0];
core.info(`Found ${label} build run ${run.id} (${run.html_url})`);
return run.id;
}
if (Date.now() > deadline) {
throw new Error(
`No successful build.yml run found for ${label} within 30 min — ${notFoundHint}.`,
);
}
core.info(`No successful ${label} build run yet — sleeping 15s.`);
await new Promise(r => setTimeout(r, SLEEP_MS));
}
}
- name: Wait for PR build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
id: wait-for-web-build
with:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: web
ref: ${{github.event.pull_request.head.sha}}
- name: Wait for API PR build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
id: wait-for-api-build
with:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: api
ref: ${{github.event.pull_request.head.sha}}
- name: Wait for CLI PR build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
id: wait-for-cli-build
with:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: cli
ref: ${{github.event.pull_request.head.sha}}
- name: 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}}
const baseRef = process.env.BASE_REF;
const headSha = process.env.HEAD_SHA;
const [masterRunId, headRunId] = await Promise.all([
resolveRun({
label: baseRef,
filter: { branch: baseRef },
notFoundHint: `${baseRef} may be broken`,
}),
resolveRun({
label: `PR head ${headSha}`,
filter: { head_sha: headSha },
notFoundHint:
'build may still be running, have failed, or the branch may have been force-pushed',
}),
]);
core.setOutput('master_run_id', masterRunId);
core.setOutput('head_run_id', headRunId);
- 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' || 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@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
id: pr-web-build
with:
run_id: ${{ steps.build-runs.outputs.master_run_id }}
branch: ${{github.base_ref}}
workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: build-stats
path: base
- name: Download API build artifact from ${{github.base_ref}}
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
id: pr-api-build
with:
run_id: ${{ steps.build-runs.outputs.master_run_id }}
branch: ${{github.base_ref}}
workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: api-build-stats
path: base
- name: Download build stats from PR
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
with:
run_id: ${{ steps.build-runs.outputs.head_run_id }}
pr: ${{github.event.pull_request.number}}
workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: build-stats
path: head
allow_forks: true
- name: Download API stats from PR
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
with:
run_id: ${{ steps.build-runs.outputs.head_run_id }}
pr: ${{github.event.pull_request.number}}
workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: api-build-stats
path: head
allow_forks: true
- name: Download CLI build artifact from ${{github.base_ref}}
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
with:
run_id: ${{ steps.build-runs.outputs.master_run_id }}
branch: ${{github.base_ref}}
workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: cli-build-stats
path: base
- name: Download CLI stats from PR
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
with:
run_id: ${{ steps.build-runs.outputs.head_run_id }}
pr: ${{github.event.pull_request.number}}
workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: cli-build-stats
path: head
allow_forks: true
- name: Download CRDT build artifact from ${{github.base_ref}}
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
with:
run_id: ${{ steps.build-runs.outputs.master_run_id }}
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:
run_id: ${{ steps.build-runs.outputs.head_run_id }}
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

View File

@@ -75,12 +75,9 @@ jobs:
echo "Found patch file: $PATCH_FILE"
# Validate patch only contains PNG files. `git format-patch` emits a
# `GIT binary patch` block for PNGs (no +++/--- lines), so check
# `diff --git` headers — those are present for both text and binary.
# Validate patch only contains PNG files
echo "Validating patch contains only PNG files..."
if grep -E '^diff --git ' "$PATCH_FILE" \
| grep -vE '^diff --git a/[^[:space:]]+\.png b/[^[:space:]]+\.png$'; then
if grep -E '^(\+\+\+|---) [ab]/' "$PATCH_FILE" | grep -v '\.png$'; then
echo "ERROR: Patch contains non-PNG files! Rejecting for security."
echo "applied=false" >> "$GITHUB_OUTPUT"
echo "error=Patch validation failed: contains non-PNG files" >> "$GITHUB_OUTPUT"
@@ -88,7 +85,7 @@ jobs:
fi
# Extract file list for verification
FILES_CHANGED=$(grep -cE '^diff --git ' "$PATCH_FILE")
FILES_CHANGED=$(grep -E '^\+\+\+ b/' "$PATCH_FILE" | sed 's/^+++ b\///' | wc -l)
echo "Patch modifies $FILES_CHANGED PNG file(s)"
# Configure git

View File

@@ -36,16 +36,15 @@ jobs:
content: 'eyes'
});
get-pr:
name: Resolve PR details
generate-vrt-updates:
name: Generate VRT Updates
runs-on: ubuntu-latest
# Only run on PR comments containing /update-vrt
if: >
github.event.issue.pull_request &&
startsWith(github.event.comment.body, '/update-vrt')
outputs:
head_sha: ${{ steps.pr.outputs.head_sha }}
head_ref: ${{ steps.pr.outputs.head_ref }}
head_repo: ${{ steps.pr.outputs.head_repo }}
container:
image: mcr.microsoft.com/playwright:v1.59.1-jammy
steps:
- name: Get PR details
id: pr
@@ -61,132 +60,11 @@ jobs:
core.setOutput('head_ref', pr.head.ref);
core.setOutput('head_repo', pr.head.repo.full_name);
build-web:
name: Build web bundle
runs-on: ubuntu-latest
needs: get-pr
container:
image: mcr.microsoft.com/playwright:v1.59.1-jammy
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ needs.get-pr.outputs.head_sha }}
ref: ${{ steps.pr.outputs.head_sha }}
persist-credentials: false
- name: Trust workspace directory
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
shell: bash
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Build browser bundle
# REACT_APP_NETLIFY=true flips isNonProductionEnvironment() on in the
# bundle so the "Create test file" button (used by every e2e beforeEach
# via ConfigurationPage.createTestFile()) is still rendered in a
# production build. Without it, e2e tests would time out waiting for
# a button that was tree-shaken out.
# --skip-translations keeps VRT screenshots deterministic by rendering
# source-code English instead of upstream Weblate en.json (which can
# drift between snapshot capture and test runs).
env:
REACT_APP_NETLIFY: 'true'
run: yarn build:browser --skip-translations
- name: Upload build artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: desktop-client-build
path: packages/desktop-client/build/
retention-days: 1
overwrite: true
browser-vrt:
name: Browser VRT (shard ${{ matrix.shard }}/3)
runs-on: ubuntu-latest
needs: [get-pr, build-web]
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
container:
image: mcr.microsoft.com/playwright:v1.59.1-jammy
env:
E2E_USE_BUILD: '1'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ needs.get-pr.outputs.head_sha }}
persist-credentials: false
- name: Trust workspace directory
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
shell: bash
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Download web build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: desktop-client-build
path: packages/desktop-client/build/
- name: Run VRT Tests
continue-on-error: true
run: yarn vrt --update-snapshots --shard=${{ matrix.shard }}/3
- name: Create shard patch with PNG changes only
id: create-patch
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git add "**/*.png"
if git diff --staged --quiet; then
echo "has_changes=false" >> "$GITHUB_OUTPUT"
echo "No VRT changes in this shard"
exit 0
fi
echo "has_changes=true" >> "$GITHUB_OUTPUT"
git commit -m "Update VRT screenshots (browser shard ${{ matrix.shard }})"
git format-patch -1 HEAD --stdout > vrt-shard.patch
# Validate patch only contains PNG files. `git format-patch` emits a
# `GIT binary patch` block for PNGs (no +++/--- lines), so check
# `diff --git` headers — those are present for both text and binary.
if grep -E '^diff --git ' vrt-shard.patch \
| grep -vE '^diff --git a/[^[:space:]]+\.png b/[^[:space:]]+\.png$'; then
echo "ERROR: Shard patch contains non-PNG files!"
exit 1
fi
- name: Upload shard patch
if: steps.create-patch.outputs.has_changes == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vrt-shard-browser-${{ matrix.shard }}
path: vrt-shard.patch
retention-days: 1
overwrite: true
desktop-vrt:
name: Desktop VRT
runs-on: ubuntu-latest
needs: get-pr
container:
image: mcr.microsoft.com/playwright:v1.59.1-jammy
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ needs.get-pr.outputs.head_sha }}
persist-credentials: false
- name: Trust workspace directory
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
shell: bash
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -196,124 +74,48 @@ jobs:
- name: Install build tools
run: apt-get update && apt-get install -y build-essential python3
- name: Run Desktop VRT Tests
- 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: Create shard patch with PNG changes only
- name: Run VRT Tests
continue-on-error: true
run: yarn vrt --update-snapshots
- name: Create patch with PNG changes only
id: create-patch
run: |
# Trust the repository directory (required for container environments)
git config --global --add safe.directory "$GITHUB_WORKSPACE"
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
# Stage only PNG files
git add "**/*.png"
# Check if there are any changes
if git diff --staged --quiet; then
echo "has_changes=false" >> "$GITHUB_OUTPUT"
echo "No VRT changes in desktop shard"
exit 0
fi
echo "has_changes=true" >> "$GITHUB_OUTPUT"
git commit -m "Update VRT screenshots (desktop)"
git format-patch -1 HEAD --stdout > vrt-shard.patch
# See validation note in browser-vrt above.
if grep -E '^diff --git ' vrt-shard.patch \
| grep -vE '^diff --git a/[^[:space:]]+\.png b/[^[:space:]]+\.png$'; then
echo "ERROR: Desktop shard patch contains non-PNG files!"
exit 1
fi
- name: Upload shard patch
if: steps.create-patch.outputs.has_changes == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vrt-shard-desktop
path: vrt-shard.patch
retention-days: 1
overwrite: true
merge-patch:
name: Merge VRT Patches
runs-on: ubuntu-latest
needs: [get-pr, browser-vrt, desktop-vrt]
if: ${{ !cancelled() && needs.get-pr.result == 'success' }}
container:
image: mcr.microsoft.com/playwright:v1.59.1-jammy
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ needs.get-pr.outputs.head_sha }}
persist-credentials: false
- name: Download all shard patches
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: /tmp/shard-patches
pattern: vrt-shard-*
- name: Merge shard patches
id: create-patch
shell: bash
run: |
git config --global --add safe.directory "$GITHUB_WORKSPACE"
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
# actions/download-artifact puts a lone matched artifact directly in
# `path` but gives each of several its own `path/<name>/` subdir, so
# recurse instead of globbing `*/vrt-shard.patch` (which would miss
# the common single-shard case).
mapfile -t patches < <(find /tmp/shard-patches -type f -name 'vrt-shard.patch' | sort)
if [ ${#patches[@]} -eq 0 ]; then
echo "has_changes=false" >> "$GITHUB_OUTPUT"
echo "No shard patches to merge"
exit 0
fi
# Defense in depth: re-validate every shard patch before applying.
# See validation note in browser-vrt above for why we match
# `diff --git` headers instead of +++/--- lines.
for patch in "${patches[@]}"; do
echo "Validating $patch"
if grep -E '^diff --git ' "$patch" \
| grep -vE '^diff --git a/[^[:space:]]+\.png b/[^[:space:]]+\.png$'; then
echo "ERROR: $patch contains non-PNG files!"
exit 1
fi
done
# Apply each shard patch. Shards touch disjoint PNG files so
# order does not matter. --index stages the applied changes.
for patch in "${patches[@]}"; do
echo "Applying $patch"
git apply --index "$patch"
done
if git diff --staged --quiet; then
echo "has_changes=false" >> "$GITHUB_OUTPUT"
echo "No VRT changes after merge"
echo "No VRT changes to commit"
exit 0
fi
echo "has_changes=true" >> "$GITHUB_OUTPUT"
# Create commit and patch
git commit -m "Update VRT screenshots"
git format-patch -1 HEAD --stdout > vrt-update.patch
# Final guard on the combined patch.
if grep -E '^diff --git ' vrt-update.patch \
| grep -vE '^diff --git a/[^[:space:]]+\.png b/[^[:space:]]+\.png$'; then
echo "ERROR: Merged patch contains non-PNG files!"
# Validate patch only contains PNG files
if grep -E '^(\+\+\+|---) [ab]/' vrt-update.patch | grep -v '\.png$'; then
echo "ERROR: Patch contains non-PNG files!"
exit 1
fi
echo "Merged patch created successfully with PNG changes only"
echo "Patch created successfully with PNG changes only"
- name: Upload patch artifact
if: steps.create-patch.outputs.has_changes == 'true'
@@ -328,11 +130,11 @@ jobs:
run: |
mkdir -p pr-metadata
echo "${{ github.event.issue.number }}" > pr-metadata/pr-number.txt
echo "${NEEDS_GET_PR_OUTPUTS_HEAD_REF}" > pr-metadata/head-ref.txt
echo "${NEEDS_GET_PR_OUTPUTS_HEAD_REPO}" > pr-metadata/head-repo.txt
echo "${STEPS_PR_OUTPUTS_HEAD_REF}" > pr-metadata/head-ref.txt
echo "${STEPS_PR_OUTPUTS_HEAD_REPO}" > pr-metadata/head-repo.txt
env:
NEEDS_GET_PR_OUTPUTS_HEAD_REF: ${{ needs.get-pr.outputs.head_ref }}
NEEDS_GET_PR_OUTPUTS_HEAD_REPO: ${{ needs.get-pr.outputs.head_repo }}
STEPS_PR_OUTPUTS_HEAD_REF: ${{ steps.pr.outputs.head_ref }}
STEPS_PR_OUTPUTS_HEAD_REPO: ${{ steps.pr.outputs.head_repo }}
- name: Upload PR metadata
if: steps.create-patch.outputs.has_changes == 'true'

3
.gitignore vendored
View File

@@ -42,9 +42,6 @@ bundle.desktop.js.map
bundle.mobile.js
bundle.mobile.js.map
# Python virtualenv (Electron CI provisions one at the repo root for setuptools)
.venv/
# Yarn
.pnp.*
.yarn/*

View File

@@ -15,8 +15,7 @@
"vi": "readonly",
"backend": "readonly",
"importScripts": "readonly",
"FS": "readonly",
"__APP_VERSION__": "readonly"
"FS": "readonly"
},
"rules": {
// Import sorting
@@ -376,8 +375,7 @@
"files": ["**/*.test.{js,ts,jsx,tsx}", "packages/docs/**/*"],
"rules": {
"actual/no-untranslated-strings": "off",
"actual/prefer-logger-over-console": "off",
"typescript/unbound-method": "off"
"actual/prefer-logger-over-console": "off"
}
},
{

View File

@@ -7,7 +7,3 @@ enableTransparentWorkspaces: false
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.13.0.cjs
# Secure default: don't run postinstall scripts.
# If a new package requires them, add it to dependenciesMeta in package.json.
enableScripts: false

View File

@@ -4,30 +4,21 @@ ROOT=`dirname $0`
cd "$ROOT/.."
SKIP_TRANSLATIONS=false
while [[ $# -gt 0 ]]; do
case "$1" in
--skip-translations)
SKIP_TRANSLATIONS=true
shift
;;
*)
echo "Unknown argument: $1" >&2
exit 1
;;
esac
done
if [ "$SKIP_TRANSLATIONS" = false ]; then
echo "Updating translations..."
if ! [ -d packages/desktop-client/locale ]; then
git clone https://github.com/actualbudget/translations packages/desktop-client/locale
fi
pushd packages/desktop-client/locale > /dev/null
git checkout .
git pull
popd > /dev/null
packages/desktop-client/bin/remove-untranslated-languages
echo "Updating translations..."
if ! [ -d packages/desktop-client/locale ]; then
git clone https://github.com/actualbudget/translations packages/desktop-client/locale
fi
pushd packages/desktop-client/locale > /dev/null
git checkout .
git pull
popd > /dev/null
packages/desktop-client/bin/remove-untranslated-languages
lage build:browser --to=@actual-app/web
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
echo "packages/desktop-client/build"

View File

@@ -57,7 +57,8 @@ yarn workspace @actual-app/core build:node
yarn workspace @actual-app/web build --mode=desktop # electron specific build
# required for running the sync-server server
yarn build:browser
yarn workspace @actual-app/core build:browser
yarn workspace @actual-app/web build:browser
yarn workspace @actual-app/sync-server build
# Emit @actual-app/core declarations so desktop-electron (which includes typings/window.ts) can build

View File

@@ -25,14 +25,6 @@ module.exports = {
outputGlob: BUILD_OUTPUT_GLOBS,
},
},
// Not cached: the script stages files into public/ and build-stats/ that
// fall outside BUILD_OUTPUT_GLOBS, so a cache hit would skip the side
// effects.
'build:browser': {
type: 'npmScript',
dependsOn: ['^build'],
cache: false,
},
},
cacheOptions: {
cacheStorageConfig: {

View File

@@ -24,16 +24,18 @@
"start:server-dev": "NODE_ENV=development BROWSER_OPEN=localhost:5006 yarn npm-run-all --parallel 'start:server-monitor' 'start'",
"start:desktop": "yarn desktop-dependencies && npm-run-all --parallel 'start:desktop-*'",
"start:docs": "yarn workspace docs start",
"desktop-dependencies": "npm-run-all --parallel rebuild-electron build:plugins-service",
"desktop-dependencies": "npm-run-all --parallel rebuild-electron build:browser-backend build:plugins-service",
"start:desktop-node": "yarn workspace @actual-app/core watch:node",
"start:desktop-client": "yarn workspace @actual-app/web watch",
"start:desktop-server-client": "yarn workspace @actual-app/web build:browser",
"start:desktop-electron": "yarn workspace desktop-electron watch",
"start:browser": "npm-run-all --parallel 'start:browser-*' 'start:service-plugins'",
"start:browser": "yarn workspace plugins-service build-dev && npm-run-all --parallel 'start:browser-*'",
"start:service-plugins": "yarn workspace plugins-service watch",
"start:browser-backend": "yarn workspace @actual-app/core watch:browser",
"start:browser-frontend": "yarn workspace @actual-app/web start:browser",
"start:storybook": "yarn workspace @actual-app/components start:storybook",
"build": "lage build",
"build:browser-backend": "yarn workspace @actual-app/core build:browser",
"build:server": "yarn build:browser && yarn workspace @actual-app/sync-server build",
"build:browser": "./bin/package-browser",
"build:desktop": "./bin/package-electron",
@@ -52,7 +54,7 @@
"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/desktop-electron -o better-sqlite3,bcrypt --build-from-source -f",
"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",
@@ -87,23 +89,6 @@
"typescript": "^6.0.2",
"vitest": "^4.1.2"
},
"dependenciesMeta": {
"bcrypt": {
"built": true
},
"better-sqlite3": {
"built": true
},
"electron": {
"built": true
},
"esbuild": {
"built": true
},
"sharp": {
"built": true
}
},
"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",

View File

@@ -6,11 +6,6 @@ import { vi } from 'vitest';
import * as api from './index';
declare global {
var IS_TESTING: boolean;
var currentMonth: string | null;
}
// In tests we run from source; loot-core's API fs uses __dirname (for the built dist/).
// Mock the fs so path constants point at loot-core package root where migrations live.
vi.mock(
@@ -516,29 +511,6 @@ describe('API CRUD operations', () => {
);
});
// apis: getNote, updateNote
test('Notes: successfully get and update note', async () => {
const categories = await api.getCategories();
const categoryId = categories[0].id;
// No note exists initially
const initial = await api.getNote(categoryId);
expect(initial).toBeNull();
// Set a note
await api.updateNote(categoryId, 'Test note content');
const afterSet = await api.getNote(categoryId);
expect(afterSet).toEqual({ id: categoryId, note: 'Test note content' });
// Update the note
await api.updateNote(categoryId, 'Updated note content');
const afterUpdate = await api.getNote(categoryId);
expect(afterUpdate).toEqual({
id: categoryId,
note: 'Updated note content',
});
});
// apis: getRules, getPayeeRules, createRule, updateRule, deleteRule
test('Rules: successfully update rules', async () => {
await api.createPayee({ name: 'test-payee' });

View File

@@ -13,7 +13,6 @@ import type { ImportTransactionsOpts } from '@actual-app/core/types/api-handlers
import type { Handlers } from '@actual-app/core/types/handlers';
import type {
ImportTransactionEntity,
NoteEntity,
RuleEntity,
TransactionEntity,
} from '@actual-app/core/types/models';
@@ -204,8 +203,8 @@ export function getAccountBalance(id: APIAccountEntity['id'], cutoff?: Date) {
return send('api/account-balance', { id, cutoff });
}
export function getCategoryGroups(options: { hidden?: boolean } = {}) {
return send('api/category-groups-get', options);
export function getCategoryGroups() {
return send('api/category-groups-get');
}
export function createCategoryGroup(group: Omit<APICategoryGroupEntity, 'id'>) {
@@ -226,8 +225,8 @@ export function deleteCategoryGroup(
return send('api/category-group-delete', { id, transferCategoryId });
}
export function getCategories(options: { hidden?: boolean } = {}) {
return send('api/categories-get', { grouped: false, ...options });
export function getCategories() {
return send('api/categories-get', { grouped: false });
}
export function createCategory(category: Omit<APICategoryEntity, 'id'>) {
@@ -248,14 +247,6 @@ export function deleteCategory(
return send('api/category-delete', { id, transferCategoryId });
}
export function getNote(id: NoteEntity['id']) {
return send('api/note-get', { id });
}
export function updateNote(id: NoteEntity['id'], note: NoteEntity['note']) {
return send('api/note-update', { id, note });
}
export function getCommonPayees() {
return send('api/common-payees-get');
}

View File

@@ -10,9 +10,7 @@
},
"files": [
"@types",
"dist",
"!@types/**/*.test.d.ts",
"!@types/**/*.test.d.ts.map"
"dist"
],
"main": "dist/index.js",
"types": "@types/index.d.ts",
@@ -50,7 +48,7 @@
"@actual-app/crdt": "workspace:*",
"better-sqlite3": "^12.8.0",
"compare-versions": "^6.1.1",
"uuid": "^14.0.0"
"uuid": "^13.0.0"
},
"devDependencies": {
"@typescript/native-preview": "beta",

View File

@@ -35,6 +35,7 @@
"**/node_modules/*",
"dist",
"@types",
"*.test.ts",
"*.config.ts",
"*.config.mts"
]

View File

@@ -85,12 +85,6 @@ export default defineConfig({
},
test: {
globals: true,
// Each test loads a budget file and runs all DB migrations, which can be
// slow on busy CI runners; the default 5s timeout is too tight and causes
// flaky timeouts (and a cascade of unhandled rejections from in-flight work
// continuing after teardown).
testTimeout: 20_000,
hookTimeout: 20_000,
onConsoleLog(log: string, type: 'stdout' | 'stderr'): boolean | void {
// print only console.error
return type === 'stderr';

View File

@@ -43,16 +43,13 @@ 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 |
| `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 |
| Variable | Description |
| ---------------------- | --------------------------------------------- |
| `ACTUAL_SERVER_URL` | URL of the Actual sync server (required) |
| `ACTUAL_PASSWORD` | Server password (required unless using token) |
| `ACTUAL_SESSION_TOKEN` | Session token (alternative to password) |
| `ACTUAL_SYNC_ID` | Budget Sync ID (required for most commands) |
| `ACTUAL_DATA_DIR` | Local directory for cached budget data |
### Config File
@@ -62,10 +59,7 @@ Create an `.actualrc.json` (or `.actualrc`, `.actualrc.yaml`, `actual.config.js`
{
"serverUrl": "http://localhost:5006",
"password": "your-password",
"syncId": "1cfdbb80-6274-49bf-b0c2-737235a4c81f",
"cacheTtl": 60,
"lockTimeout": 10,
"noLock": false
"syncId": "1cfdbb80-6274-49bf-b0c2-737235a4c81f"
}
```
@@ -80,11 +74,6 @@ 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 |
@@ -103,7 +92,6 @@ 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.
@@ -147,32 +135,22 @@ All monetary amounts are **integer cents** when passed as input (flags, JSON):
- **Split transactions:** When summing or counting transactions, filter `"is_parent": false` to avoid double-counting. A split parent holds the total amount, and its children hold the individual parts — including both would count the total twice.
- **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:
- **Avoid rapid sequential requests:** Each CLI invocation opens a new server connection. Running queries in a tight loop (e.g. one per month) may trigger rate limiting or authentication failures. Instead, fetch all data in a single query with a date range filter and process locally:
```bash
actual sync
actual --cache-ttl 3600 query run ...
actual --cache-ttl 3600 accounts list
# Good: single query for the full year
actual query run --table transactions \
--filter '{"$and":[{"date":{"$gte":"2025-01-01"}},{"date":{"$lte":"2025-12-31"}}]}' \
--limit 5000
# Bad: one query per month in a loop (may fail with auth errors)
for month in 01 02 03 ...; do actual query run ...; done
```
- **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

@@ -17,12 +17,10 @@
],
"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"
},
@@ -35,12 +33,10 @@
"@actual-app/api": "workspace:*",
"cli-table3": "^0.6.5",
"commander": "^14.0.3",
"cosmiconfig": "^9.0.1",
"proper-lockfile": "^4.1.2"
"cosmiconfig": "^9.0.1"
},
"devDependencies": {
"@types/node": "^22.19.17",
"@types/proper-lockfile": "^4",
"@typescript/native-preview": "beta",
"rollup-plugin-visualizer": "^7.0.1",
"vite": "^8.0.5",

View File

@@ -1,206 +0,0 @@
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 });
});
});

View File

@@ -1,107 +0,0 @@
import { randomBytes } from 'node:crypto';
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);
// Unique tmp name per writer: concurrent shared-lock commands (encrypted
// budgets, --refresh, stale TTL) can both publish, and a shared tmp path
// lets the second writer's truncate destroy the first writer's bytes
// before either renames into place.
const tmp = `${target}.${process.pid}-${randomBytes(4).toString('hex')}.tmp`;
writeFileSync(tmp, JSON.stringify(state));
renameSync(tmp, target);
} catch {
// Cache persistence is best-effort. A read-only or unreachable dir must
// not crash the CLI; the next invocation simply won't find a cache.
}
}
export type SyncDecision =
| { action: 'download' }
| { action: 'skip'; state: CacheState }
| { action: 'sync'; state: CacheState };
export type DecideSyncArgs = {
state: CacheState | null;
config: { syncId: string; serverUrl: string };
now: number;
ttlMs: number;
mutates: boolean;
refresh: boolean;
encrypted: boolean;
};
export function decideSyncAction({
state,
config,
now,
ttlMs,
mutates,
refresh,
encrypted,
}: DecideSyncArgs): SyncDecision {
if (state === null) return { action: 'download' };
if (state.syncId !== config.syncId) return { action: 'download' };
if (state.serverUrl !== config.serverUrl) return { action: 'download' };
if (mutates || refresh || ttlMs === 0 || encrypted) {
return { action: 'sync', state };
}
const age = now - state.lastSyncedAt;
if (age < 0) return { action: 'sync', state };
if (age < ttlMs) return { action: 'skip', state };
return { action: 'sync', state };
}

View File

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

View File

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

View File

@@ -1,131 +0,0 @@
import * as api from '@actual-app/api';
import { Command } from 'commander';
import { printOutput } from '#output';
import { registerCategoriesCommand } from './categories';
import { registerCategoryGroupsCommand } from './category-groups';
vi.mock('@actual-app/api', () => ({
getCategories: vi.fn().mockResolvedValue([]),
createCategory: vi.fn().mockResolvedValue('new-id'),
updateCategory: vi.fn().mockResolvedValue(undefined),
deleteCategory: vi.fn().mockResolvedValue(undefined),
getCategoryGroups: vi.fn().mockResolvedValue([]),
createCategoryGroup: vi.fn().mockResolvedValue('new-group-id'),
updateCategoryGroup: vi.fn().mockResolvedValue(undefined),
deleteCategoryGroup: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('#connection', () => ({
withConnection: vi.fn((_opts, fn) => fn()),
}));
vi.mock('#output', () => ({
printOutput: vi.fn(),
}));
function createProgram(): Command {
const program = new Command();
program.option('--format <format>');
program.option('--server-url <url>');
program.option('--password <pw>');
program.option('--session-token <token>');
program.option('--sync-id <id>');
program.option('--data-dir <dir>');
program.option('--verbose');
program.exitOverride();
registerCategoriesCommand(program);
registerCategoryGroupsCommand(program);
return program;
}
async function run(args: string[]) {
const program = createProgram();
await program.parseAsync(['node', 'test', ...args]);
}
describe('categories commands', () => {
let stderrSpy: ReturnType<typeof vi.spyOn>;
let stdoutSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
stderrSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
stdoutSpy = vi
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);
});
afterEach(() => {
stderrSpy.mockRestore();
stdoutSpy.mockRestore();
});
describe('categories list', () => {
it('asks the API to exclude hidden categories by default', async () => {
await run(['categories', 'list']);
expect(api.getCategories).toHaveBeenCalledWith({ hidden: false });
});
it('asks the API for all categories when --include-hidden is passed', async () => {
await run(['categories', 'list', '--include-hidden']);
expect(api.getCategories).toHaveBeenCalledWith({});
});
it('prints whatever the API returns', async () => {
const visible = {
id: '1',
name: 'Visible',
group_id: 'g1',
hidden: false,
};
vi.mocked(api.getCategories).mockResolvedValue([visible]);
await run(['categories', 'list']);
expect(printOutput).toHaveBeenCalledWith([visible], undefined);
});
it('passes format option to printOutput', async () => {
vi.mocked(api.getCategories).mockResolvedValue([]);
await run(['--format', 'csv', 'categories', 'list']);
expect(printOutput).toHaveBeenCalledWith([], 'csv');
});
});
describe('category-groups list', () => {
it('asks the API to exclude hidden groups by default', async () => {
await run(['category-groups', 'list']);
expect(api.getCategoryGroups).toHaveBeenCalledWith({ hidden: false });
});
it('asks the API for all groups when --include-hidden is passed', async () => {
await run(['category-groups', 'list', '--include-hidden']);
expect(api.getCategoryGroups).toHaveBeenCalledWith({});
});
it('prints whatever the API returns', async () => {
const group = {
id: 'g1',
name: 'Group',
is_income: false,
hidden: false,
categories: [{ id: 'c1', name: 'Cat', group_id: 'g1', hidden: false }],
};
vi.mocked(api.getCategoryGroups).mockResolvedValue([group]);
await run(['category-groups', 'list']);
expect(printOutput).toHaveBeenCalledWith([group], undefined);
});
});
});

View File

@@ -12,20 +12,13 @@ export function registerCategoriesCommand(program: Command) {
categories
.command('list')
.description('List categories (excludes hidden by default)')
.option('--include-hidden', 'Include hidden categories', false)
.action(async cmdOpts => {
.description('List all categories')
.action(async () => {
const opts = program.opts();
await withConnection(
opts,
async () => {
const result = await api.getCategories(
cmdOpts.includeHidden ? {} : { hidden: false },
);
printOutput(result, opts.format);
},
{ mutates: false },
);
await withConnection(opts, async () => {
const result = await api.getCategories();
printOutput(result, opts.format);
});
});
categories
@@ -36,19 +29,15 @@ 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);
},
{ mutates: true },
);
await withConnection(opts, async () => {
const id = await api.createCategory({
name: cmdOpts.name,
group_id: cmdOpts.groupId,
is_income: cmdOpts.isIncome,
hidden: false,
});
printOutput({ id }, opts.format);
});
});
categories
@@ -66,14 +55,10 @@ 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);
},
{ mutates: true },
);
await withConnection(opts, async () => {
await api.updateCategory(id, fields);
printOutput({ success: true, id }, opts.format);
});
});
categories
@@ -82,13 +67,9 @@ 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);
},
{ mutates: true },
);
await withConnection(opts, async () => {
await api.deleteCategory(id, cmdOpts.transferTo);
printOutput({ success: true, id }, opts.format);
});
});
}

View File

@@ -12,20 +12,13 @@ export function registerCategoryGroupsCommand(program: Command) {
groups
.command('list')
.description('List category groups (excludes hidden by default)')
.option('--include-hidden', 'Include hidden groups and categories', false)
.action(async cmdOpts => {
.description('List all category groups')
.action(async () => {
const opts = program.opts();
await withConnection(
opts,
async () => {
const result = await api.getCategoryGroups(
cmdOpts.includeHidden ? {} : { hidden: false },
);
printOutput(result, opts.format);
},
{ mutates: false },
);
await withConnection(opts, async () => {
const result = await api.getCategoryGroups();
printOutput(result, opts.format);
});
});
groups
@@ -35,18 +28,14 @@ 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);
},
{ mutates: true },
);
await withConnection(opts, async () => {
const id = await api.createCategoryGroup({
name: cmdOpts.name,
is_income: cmdOpts.isIncome,
hidden: false,
});
printOutput({ id }, opts.format);
});
});
groups
@@ -64,14 +53,10 @@ 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);
},
{ mutates: true },
);
await withConnection(opts, async () => {
await api.updateCategoryGroup(id, fields);
printOutput({ success: true, id }, opts.format);
});
});
groups
@@ -80,13 +65,9 @@ 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);
},
{ mutates: true },
);
await withConnection(opts, async () => {
await api.deleteCategoryGroup(id, cmdOpts.transferTo);
printOutput({ success: true, id }, opts.format);
});
});
}

View File

@@ -12,14 +12,10 @@ 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);
},
{ mutates: false },
);
await withConnection(opts, async () => {
const result = await api.getPayees();
printOutput(result, opts.format);
});
});
payees
@@ -27,14 +23,10 @@ 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);
},
{ mutates: false },
);
await withConnection(opts, async () => {
const result = await api.getCommonPayees();
printOutput(result, opts.format);
});
});
payees
@@ -43,14 +35,10 @@ 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);
},
{ mutates: true },
);
await withConnection(opts, async () => {
const id = await api.createPayee({ name: cmdOpts.name });
printOutput({ id }, opts.format);
});
});
payees
@@ -66,14 +54,10 @@ export function registerPayeesCommand(program: Command) {
);
}
const opts = program.opts();
await withConnection(
opts,
async () => {
await api.updatePayee(id, fields);
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
await withConnection(opts, async () => {
await api.updatePayee(id, fields);
printOutput({ success: true, id }, opts.format);
});
});
payees
@@ -81,14 +65,10 @@ 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);
},
{ mutates: true },
);
await withConnection(opts, async () => {
await api.deletePayee(id);
printOutput({ success: true, id }, opts.format);
});
});
payees
@@ -107,13 +87,9 @@ export function registerPayeesCommand(program: Command) {
);
}
const opts = program.opts();
await withConnection(
opts,
async () => {
await api.mergePayees(cmdOpts.target, mergeIds);
printOutput({ success: true }, opts.format);
},
{ mutates: true },
);
await withConnection(opts, async () => {
await api.mergePayees(cmdOpts.target, mergeIds);
printOutput({ success: true }, opts.format);
});
});
}

View File

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

View File

@@ -15,14 +15,10 @@ 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);
},
{ mutates: false },
);
await withConnection(opts, async () => {
const result = await api.getRules();
printOutput(result, opts.format);
});
});
rules
@@ -30,14 +26,10 @@ 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);
},
{ mutates: false },
);
await withConnection(opts, async () => {
const result = await api.getPayeeRules(payeeId);
printOutput(result, opts.format);
});
});
rules
@@ -47,17 +39,13 @@ 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);
},
{ mutates: true },
);
await withConnection(opts, async () => {
const rule = readJsonInput(cmdOpts) as Parameters<
typeof api.createRule
>[0];
const id = await api.createRule(rule);
printOutput({ id }, opts.format);
});
});
rules
@@ -67,17 +55,13 @@ 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);
},
{ mutates: true },
);
await withConnection(opts, async () => {
const rule = readJsonInput(cmdOpts) as Parameters<
typeof api.updateRule
>[0];
await api.updateRule(rule);
printOutput({ success: true }, opts.format);
});
});
rules
@@ -85,13 +69,9 @@ 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);
},
{ mutates: true },
);
await withConnection(opts, async () => {
await api.deleteRule(id);
printOutput({ success: true, id }, opts.format);
});
});
}

View File

@@ -15,14 +15,10 @@ 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);
},
{ mutates: false },
);
await withConnection(opts, async () => {
const result = await api.getSchedules();
printOutput(result, opts.format);
});
});
schedules
@@ -32,17 +28,13 @@ 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);
},
{ mutates: true },
);
await withConnection(opts, async () => {
const schedule = readJsonInput(cmdOpts) as Parameters<
typeof api.createSchedule
>[0];
const id = await api.createSchedule(schedule);
printOutput({ id }, opts.format);
});
});
schedules
@@ -53,17 +45,13 @@ 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);
},
{ mutates: true },
);
await withConnection(opts, async () => {
const fields = readJsonInput(cmdOpts) as Parameters<
typeof api.updateSchedule
>[1];
await api.updateSchedule(id, fields, cmdOpts.resetNextDate);
printOutput({ success: true, id }, opts.format);
});
});
schedules
@@ -71,13 +59,9 @@ 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);
},
{ mutates: true },
);
await withConnection(opts, async () => {
await api.deleteSchedule(id);
printOutput({ success: true, id }, opts.format);
});
});
}

View File

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

View File

@@ -1,124 +0,0 @@
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

@@ -1,118 +0,0 @@
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 rawAgeSeconds = Math.round(
(Date.now() - state.lastSyncedAt) / 1000,
);
const ageSeconds = Math.max(0, rawAgeSeconds);
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: rawAgeSeconds < 0 || rawAgeSeconds > config.cacheTtl,
},
opts.format,
);
return;
}
if (cmdOpts.clear) {
const { config, meta } = await requireSyncIdAndMeta(opts, '--clear');
// Serialize with concurrent writers so we don't rm a half-written
// state.json that's about to be renamed into place.
const release = config.noLock
? null
: await acquireExclusive(meta, {
timeoutMs: config.lockTimeout * 1000,
});
try {
rmSync(join(meta, CACHE_FILE_NAME), { force: true });
} finally {
await release?.();
}
printOutput({ cleared: true, syncId: config.syncId }, opts.format);
return;
}
await withConnection(
opts,
async config => {
const state = config.syncId
? readCacheState(getMetaDir(config.dataDir, config.syncId))
: null;
printOutput(
{
syncedAt: new Date(
state?.lastSyncedAt ?? Date.now(),
).toISOString(),
syncId: config.syncId,
budgetId: state?.budgetId ?? config.syncId,
},
opts.format,
);
},
{ mutates: true },
);
});
}

View File

@@ -12,14 +12,10 @@ 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);
},
{ mutates: false },
);
await withConnection(opts, async () => {
const result = await api.getTags();
printOutput(result, opts.format);
});
});
tags
@@ -30,18 +26,14 @@ 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);
},
{ mutates: true },
);
await withConnection(opts, async () => {
const id = await api.createTag({
tag: cmdOpts.tag,
color: cmdOpts.color,
description: cmdOpts.description,
});
printOutput({ id }, opts.format);
});
});
tags
@@ -63,14 +55,10 @@ export function registerTagsCommand(program: Command) {
);
}
const opts = program.opts();
await withConnection(
opts,
async () => {
await api.updateTag(id, fields);
printOutput({ success: true, id }, opts.format);
},
{ mutates: true },
);
await withConnection(opts, async () => {
await api.updateTag(id, fields);
printOutput({ success: true, id }, opts.format);
});
});
tags
@@ -78,13 +66,9 @@ 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);
},
{ mutates: true },
);
await withConnection(opts, async () => {
await api.deleteTag(id);
printOutput({ success: true, id }, opts.format);
});
});
}

View File

@@ -18,18 +18,14 @@ 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);
},
{ mutates: false },
);
await withConnection(opts, async () => {
const result = await api.getTransactions(
cmdOpts.account,
cmdOpts.start,
cmdOpts.end,
);
printOutput(result, opts.format);
});
});
transactions
@@ -45,24 +41,20 @@ 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);
},
{ mutates: true },
);
await withConnection(opts, async () => {
const transactions = readJsonInput(cmdOpts) as Parameters<
typeof api.addTransactions
>[1];
const result = await api.addTransactions(
cmdOpts.account,
transactions,
{
learnCategories: cmdOpts.learnCategories,
runTransfers: cmdOpts.runTransfers,
},
);
printOutput(result, opts.format);
});
});
transactions
@@ -77,24 +69,20 @@ 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);
},
{ mutates: true },
);
await withConnection(opts, async () => {
const transactions = readJsonInput(cmdOpts) as Parameters<
typeof api.importTransactions
>[1];
const result = await api.importTransactions(
cmdOpts.account,
transactions,
{
defaultCleared: true,
dryRun: cmdOpts.dryRun,
},
);
printOutput(result, opts.format);
});
});
transactions
@@ -104,17 +92,13 @@ 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);
},
{ mutates: true },
);
await withConnection(opts, async () => {
const fields = readJsonInput(cmdOpts) as Parameters<
typeof api.updateTransaction
>[1];
await api.updateTransaction(id, fields);
printOutput({ success: true, id }, opts.format);
});
});
transactions
@@ -122,13 +106,9 @@ 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);
},
{ mutates: true },
);
await withConnection(opts, async () => {
await api.deleteTransaction(id);
printOutput({ success: true, id }, opts.format);
});
});
}

View File

@@ -28,9 +28,6 @@ describe('resolveConfig', () => {
'ACTUAL_SYNC_ID',
'ACTUAL_DATA_DIR',
'ACTUAL_ENCRYPTION_PASSWORD',
'ACTUAL_CACHE_TTL',
'ACTUAL_LOCK_TIMEOUT',
'ACTUAL_NO_LOCK',
];
beforeEach(() => {
@@ -162,125 +159,6 @@ 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 --no-cache is passed (cliOpts.cache === false)', async () => {
const config = await resolveConfig({ cache: false });
expect(config.refresh).toBe(true);
});
it('does not set refresh when cliOpts.cache is true (flag absent)', async () => {
const config = await resolveConfig({ cache: true });
expect(config.refresh).toBe(false);
});
it('defaults noLock to false', async () => {
const config = await resolveConfig({});
expect(config.noLock).toBe(false);
});
it('sets noLock when --no-lock is passed (cliOpts.lock === false)', async () => {
const config = await resolveConfig({ lock: false });
expect(config.noLock).toBe(true);
});
it('leaves noLock false when cliOpts.lock is true (flag absent)', async () => {
const config = await resolveConfig({ lock: true });
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('throws on an invalid ACTUAL_NO_LOCK value', async () => {
process.env.ACTUAL_NO_LOCK = 'yes';
await expect(resolveConfig({})).rejects.toThrow(/ACTUAL_NO_LOCK/);
});
it('reads cacheTtl/lockTimeout/noLock from config file', async () => {
mockConfigFile({
serverUrl: 'http://file',
password: 'pw',
cacheTtl: 120,
lockTimeout: 5,
noLock: true,
});
const config = await resolveConfig({});
expect(config.cacheTtl).toBe(120);
expect(config.lockTimeout).toBe(5);
expect(config.noLock).toBe(true);
});
it('rejects non-number cacheTtl in config file', async () => {
mockConfigFile({
serverUrl: 'http://file',
password: 'pw',
cacheTtl: 'soon',
});
await expect(resolveConfig({})).rejects.toThrow(/cacheTtl/);
});
});
describe('cosmiconfig handling', () => {
it('handles null result (no config file found)', async () => {
mockConfigFile(null);

View File

@@ -3,7 +3,7 @@ import { join } from 'path';
import { cosmiconfig } from 'cosmiconfig';
import { isRecord, parseBoolEnv, parseNonNegativeIntFlag } from './utils';
import { isRecord } from './utils';
export type CliConfig = {
serverUrl: string;
@@ -12,10 +12,6 @@ export type CliConfig = {
syncId?: string;
dataDir: string;
encryptionPassword?: string;
cacheTtl: number;
lockTimeout: number;
refresh: boolean;
noLock: boolean;
};
export type CliGlobalOpts = {
@@ -25,29 +21,10 @@ export type CliGlobalOpts = {
syncId?: string;
dataDir?: string;
encryptionPassword?: string;
cacheTtl?: number;
lockTimeout?: number;
refresh?: boolean;
// Commander stores --no-foo flags under the positive key. Default true,
// false when the flag is passed.
cache?: boolean;
lock?: 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;
@@ -55,15 +32,15 @@ type ConfigFileContent = {
syncId?: string;
dataDir?: string;
encryptionPassword?: string;
cacheTtl?: number;
lockTimeout?: number;
noLock?: boolean;
};
const configFileKeys: readonly string[] = [
...stringKeys,
...numberKeys,
...booleanKeys,
'serverUrl',
'password',
'sessionToken',
'syncId',
'dataDir',
'encryptionPassword',
];
function validateConfigFileContent(value: unknown): ConfigFileContent {
@@ -77,30 +54,9 @@ function validateConfigFileContent(value: unknown): ConfigFileContent {
if (!configFileKeys.includes(key)) {
throw new Error(`Invalid config file: unknown key "${key}"`);
}
const v = value[key];
if (v === undefined) continue;
if (
(stringKeys as readonly string[]).includes(key) &&
typeof v !== 'string'
) {
if (value[key] !== undefined && typeof value[key] !== 'string') {
throw new Error(
`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}`,
`Invalid config file: key "${key}" must be a string, got ${typeof value[key]}`,
);
}
}
@@ -127,22 +83,6 @@ 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> {
@@ -188,37 +128,6 @@ 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 ?? false) || cliOpts.cache === false;
const flagNoLock = cliOpts.lock === false ? true : undefined;
const noLock =
flagNoLock ??
parseBoolEnv(process.env.ACTUAL_NO_LOCK, 'ACTUAL_NO_LOCK') ??
fileConfig.noLock ??
false;
return {
serverUrl,
password,
@@ -226,9 +135,5 @@ export async function resolveConfig(
syncId,
dataDir,
encryptionPassword,
cacheTtl,
lockTimeout,
refresh,
noLock,
};
}

View File

@@ -1,44 +1,24 @@
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,
syncId: 'sync-1',
cacheTtl: 60,
lockTimeout: 10,
refresh: false,
noLock: true,
dataDir: '/tmp/data',
syncId: 'budget-1',
...overrides,
});
}
@@ -51,182 +31,104 @@ 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 () => {
await withConnection({}, async () => 'ok', { mutates: false });
setConfig({ password: 'pw', sessionToken: undefined });
await withConnection({}, async () => 'ok');
expect(api.init).toHaveBeenCalledWith({
serverURL: 'http://test',
password: 'pw',
dataDir,
dataDir: '/tmp/data',
verbose: undefined,
});
});
it('calls api.init with sessionToken when present', async () => {
setConfig({ sessionToken: 'tok', password: undefined });
await withConnection({}, async () => 'ok', { mutates: false });
await withConnection({}, async () => 'ok');
expect(api.init).toHaveBeenCalledWith({
serverURL: 'http://test',
sessionToken: 'tok',
dataDir,
dataDir: '/tmp/data',
verbose: undefined,
});
});
it('first run: calls downloadBudget and writes cache state', async () => {
await withConnection({}, async () => 'ok', { mutates: false });
expect(api.downloadBudget).toHaveBeenCalledWith('sync-1', {
it('calls api.downloadBudget when syncId is set', async () => {
setConfig({ syncId: 'budget-1' });
await withConnection({}, async () => 'ok');
expect(api.downloadBudget).toHaveBeenCalledWith('budget-1', {
password: undefined,
});
expect(api.sync).not.toHaveBeenCalled();
});
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('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('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 () => {
it('throws when loadBudget is true but syncId is not set', async () => {
setConfig({ syncId: undefined });
await expect(
withConnection({}, async () => 'ok', { mutates: false }),
).rejects.toThrow('Sync ID is required');
await expect(withConnection({}, async () => 'ok')).rejects.toThrow(
'Sync ID is required',
);
});
it('returns the callback result', async () => {
const result = await withConnection({}, async () => 42, {
mutates: false,
});
it('skips budget download when loadBudget is false and syncId is not set', async () => {
setConfig({ syncId: undefined });
await withConnection({}, async () => 'ok', { loadBudget: false });
expect(api.downloadBudget).not.toHaveBeenCalled();
});
it('does not call api.downloadBudget when loadBudget is false', async () => {
setConfig({ syncId: 'budget-1' });
await withConnection({}, async () => 'ok', { loadBudget: false });
expect(api.downloadBudget).not.toHaveBeenCalled();
});
it('returns callback result', async () => {
const result = await withConnection({}, async () => 42);
expect(result).toBe(42);
});
it('calls api.shutdown on success', async () => {
await withConnection({}, async () => 'ok', { mutates: false });
it('calls api.shutdown in finally block on success', async () => {
await withConnection({}, async () => 'ok');
expect(api.shutdown).toHaveBeenCalled();
});
it('calls api.shutdown on error', async () => {
it('calls api.shutdown in finally block on error', async () => {
await expect(
withConnection(
{},
async () => {
throw new Error('boom');
},
{ mutates: false },
),
withConnection({}, async () => {
throw new Error('boom');
}),
).rejects.toThrow('boom');
expect(api.shutdown).toHaveBeenCalled();
});
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');
it('does not write to stderr by default', async () => {
await withConnection({}, async () => 'ok');
expect(stderrSpy).not.toHaveBeenCalled();
});
it('writes info to stderr when verbose', async () => {
await withConnection({ verbose: true }, async () => 'ok');
expect(stderrSpy).toHaveBeenCalledWith(
expect.stringContaining('Connecting to'),
);
});
});

View File

@@ -1,49 +1,30 @@
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 { acquireExclusive, acquireShared } from './lock';
import type { Release } from './lock';
type ConnectionOptions = {
mutates: boolean;
skipBudget?: boolean;
};
import type { CliGlobalOpts } from './config';
function info(message: string, verbose?: boolean) {
if (verbose) process.stderr.write(message + '\n');
if (verbose) {
process.stderr.write(message + '\n');
}
}
async function resolveBudgetIdForSyncId(syncId: string): Promise<string> {
const budgets = await api.getBudgets();
const match = budgets.find(
b =>
typeof b.id === 'string' &&
(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;
}
type ConnectionOptions = {
loadBudget?: boolean;
};
export async function withConnection<T>(
globalOpts: CliGlobalOpts,
fn: (config: CliConfig) => Promise<T>,
{ mutates, skipBudget = false }: ConnectionOptions,
fn: () => Promise<T>,
options: ConnectionOptions = {},
): Promise<T> {
const { loadBudget = true } = options;
const config = await resolveConfig(globalOpts);
mkdirSync(config.dataDir, { recursive: true });
info(`Connecting to ${config.serverUrl}...`, globalOpts.verbose);
if (config.sessionToken) {
@@ -67,87 +48,17 @@ export async function withConnection<T>(
}
try {
if (skipBudget) return await fn(config);
if (!config.syncId) {
if (loadBudget && config.syncId) {
info(`Downloading budget ${config.syncId}...`, globalOpts.verbose);
await api.downloadBudget(config.syncId, {
password: config.encryptionPassword,
});
} else if (loadBudget && !config.syncId) {
throw new Error(
'Sync ID is required for this command. Set --sync-id or ACTUAL_SYNC_ID.',
);
}
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();
}
return await fn();
} finally {
await api.shutdown();
}

View File

@@ -9,10 +9,8 @@ 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;
@@ -34,22 +32,6 @@ 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')
.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)',
)
.addOption(
new Option('--format <format>', 'Output format: json, table, csv')
.choices(['json', 'table', 'csv'] as const)
@@ -68,7 +50,6 @@ registerRulesCommand(program);
registerSchedulesCommand(program);
registerQueryCommand(program);
registerServerCommand(program);
registerSyncCommand(program);
function normalizeThrownMessage(err: unknown): string {
if (err instanceof Error) return err.message;

View File

@@ -1,159 +0,0 @@
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();
});
});

View File

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

View File

@@ -18,29 +18,3 @@ 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,
source: string,
): boolean | undefined {
if (raw === undefined) return undefined;
const lower = raw.toLowerCase();
if (raw === '1' || lower === 'true') return true;
if (raw === '0' || lower === 'false') return false;
throw new Error(
`Invalid ${source}: "${raw}". Expected "true", "false", "1", or "0".`,
);
}

View File

@@ -32,8 +32,5 @@ 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

@@ -1,5 +1,4 @@
#!/bin/bash
set -euo pipefail
cd "$(dirname "$(dirname "$0")")"
@@ -8,10 +7,20 @@ if ! [ -x "$(command -v protoc)" ]; then
exit 1
fi
protoc --plugin="protoc-gen-es=../../node_modules/.bin/protoc-gen-es" \
--es_opt=target=ts \
--es_out="src/proto" \
export PATH="$PWD/bin:$PATH"
protoc --plugin="protoc-gen-ts=../../node_modules/.bin/protoc-gen-ts" \
--ts_opt=esModuleInterop=true \
--ts_out="src/proto" \
--js_out=import_style=commonjs,binary:src/proto \
--proto_path=src/proto \
sync.proto
../../node_modules/.bin/oxfmt src/proto/*.ts
../../node_modules/.bin/oxfmt src/proto/*.d.ts
for file in src/proto/*.d.ts; do
{ echo "/* oxlint-disable typescript/no-namespace */"; sed 's/export class/export declare class/g' "$file"; } > "${file%.d.ts}.ts"
rm "$file"
done
echo 'One more step! Find the `var global = ...` declaration in src/proto/sync_pb.js and change it to `var global = globalThis;`'

View File

@@ -1,25 +1,19 @@
{
"name": "@actual-app/crdt",
"version": "3.0.0",
"version": "2.1.0",
"description": "CRDT layer of Actual",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/actualbudget/actual.git",
"directory": "packages/crdt"
},
"files": [
"dist",
"!dist/**/*.test.d.ts",
"!dist/**/*.test.d.ts.map",
"!dist/**/*.spec.d.ts",
"!dist/**/*.spec.d.ts.map"
"dist"
],
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": "./src/index.ts"
".": {
"types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js"
}
},
"publishConfig": {
"exports": {
@@ -27,26 +21,26 @@
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"main": "dist/index.js",
"types": "dist/index.d.ts"
}
},
"scripts": {
"build:node": "vite build",
"proto:generate": "./bin/generate-proto",
"build": "yarn run build:node && tsgo -b",
"build": "yarn run build:node && tsgo -p tsconfig.build.json --emitDeclarationOnly",
"test": "vitest --run",
"typecheck": "tsgo -b"
},
"dependencies": {
"@bufbuild/protobuf": "^2.11.0",
"google-protobuf": "^3.21.4",
"murmurhash": "^2.0.1",
"uuid": "^14.0.0"
"uuid": "^13.0.0"
},
"devDependencies": {
"@bufbuild/protoc-gen-es": "^2.11.0",
"@types/google-protobuf": "3.15.12",
"@typescript/native-preview": "beta",
"protoc-gen-js": "3.21.4-4",
"rollup-plugin-visualizer": "^7.0.1",
"ts-protoc-gen": "0.15.0",
"vite": "^8.0.5",
"vitest": "^4.1.2"
}

View File

@@ -1,3 +1,6 @@
import './proto/sync_pb.js'; // Import for side effects
import type * as SyncPb from './proto/sync_pb';
export {
merkle,
getClock,
@@ -10,17 +13,16 @@ export {
Timestamp,
} from './crdt';
export {
type EncryptedData,
type Message,
type MessageEnvelope,
type SyncRequest,
type SyncResponse,
EncryptedDataSchema,
MessageSchema,
MessageEnvelopeSchema,
SyncRequestSchema,
SyncResponseSchema,
} from './proto/sync_pb';
declare global {
var proto: typeof SyncPb;
}
export { create, fromBinary, toBinary } from '@bufbuild/protobuf';
const { proto } = globalThis;
export const SyncRequest = proto.SyncRequest;
export const SyncResponse = proto.SyncResponse;
export const Message = proto.Message;
export const MessageEnvelope = proto.MessageEnvelope;
export const EncryptedData = proto.EncryptedData;
export const SyncProtoBuf = proto;

View File

@@ -21,7 +21,6 @@ message MessageEnvelope {
}
message SyncRequest {
reserved 4;
repeated MessageEnvelope messages = 1;
string fileId = 2;
string groupId = 3;

File diff suppressed because it is too large Load Diff

View File

@@ -1,161 +1,217 @@
// @generated by protoc-gen-es v2.11.0 with parameter "target=ts"
// @generated from file sync.proto (syntax proto3)
/* eslint-disable */
/* oxlint-disable typescript/no-namespace */
// package:
// file: sync.proto
import type { Message as Message$1 } from '@bufbuild/protobuf';
import type { GenFile, GenMessage } from '@bufbuild/protobuf/codegenv2';
import { fileDesc, messageDesc } from '@bufbuild/protobuf/codegenv2';
import * as jspb from 'google-protobuf';
/**
* Describes the file sync.proto.
*/
export const file_sync: GenFile /*@__PURE__*/ = fileDesc(
'CgpzeW5jLnByb3RvIjoKDUVuY3J5cHRlZERhdGESCgoCaXYYASABKAwSDwoHYXV0aFRhZxgCIAEoDBIMCgRkYXRhGAMgASgMIkYKB01lc3NhZ2USDwoHZGF0YXNldBgBIAEoCRILCgNyb3cYAiABKAkSDgoGY29sdW1uGAMgASgJEg0KBXZhbHVlGAQgASgJIkoKD01lc3NhZ2VFbnZlbG9wZRIRCgl0aW1lc3RhbXAYASABKAkSEwoLaXNFbmNyeXB0ZWQYAiABKAgSDwoHY29udGVudBgDIAEoDCJ2CgtTeW5jUmVxdWVzdBIiCghtZXNzYWdlcxgBIAMoCzIQLk1lc3NhZ2VFbnZlbG9wZRIOCgZmaWxlSWQYAiABKAkSDwoHZ3JvdXBJZBgDIAEoCRINCgVrZXlJZBgFIAEoCRINCgVzaW5jZRgGIAEoCUoECAQQBSJCCgxTeW5jUmVzcG9uc2USIgoIbWVzc2FnZXMYASADKAsyEC5NZXNzYWdlRW52ZWxvcGUSDgoGbWVya2xlGAIgASgJYgZwcm90bzM',
);
export declare class EncryptedData extends jspb.Message {
getIv(): Uint8Array | string;
getIv_asU8(): Uint8Array;
getIv_asB64(): string;
setIv(value: Uint8Array | string): void;
/**
* @generated from message EncryptedData
*/
export type EncryptedData = Message$1<'EncryptedData'> & {
/**
* @generated from field: bytes iv = 1;
*/
iv: Uint8Array;
getAuthtag(): Uint8Array | string;
getAuthtag_asU8(): Uint8Array;
getAuthtag_asB64(): string;
setAuthtag(value: Uint8Array | string): void;
/**
* @generated from field: bytes authTag = 2;
*/
authTag: Uint8Array;
getData(): Uint8Array | string;
getData_asU8(): Uint8Array;
getData_asB64(): string;
setData(value: Uint8Array | string): void;
/**
* @generated from field: bytes data = 3;
*/
data: Uint8Array;
};
serializeBinary(): Uint8Array;
toObject(includeInstance?: boolean): EncryptedData.AsObject;
static toObject(
includeInstance: boolean,
msg: EncryptedData,
): EncryptedData.AsObject;
static extensions: { [key: number]: jspb.ExtensionFieldInfo<jspb.Message> };
static extensionsBinary: {
[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>;
};
static serializeBinaryToWriter(
message: EncryptedData,
writer: jspb.BinaryWriter,
): void;
static deserializeBinary(bytes: Uint8Array): EncryptedData;
static deserializeBinaryFromReader(
message: EncryptedData,
reader: jspb.BinaryReader,
): EncryptedData;
}
/**
* Describes the message EncryptedData.
* Use `create(EncryptedDataSchema)` to create a new message.
*/
export const EncryptedDataSchema: GenMessage<EncryptedData> /*@__PURE__*/ =
messageDesc(file_sync, 0);
export namespace EncryptedData {
export type AsObject = {
iv: Uint8Array | string;
authtag: Uint8Array | string;
data: Uint8Array | string;
};
}
/**
* @generated from message Message
*/
export type Message = Message$1<'Message'> & {
/**
* @generated from field: string dataset = 1;
*/
dataset: string;
export declare class Message extends jspb.Message {
getDataset(): string;
setDataset(value: string): void;
/**
* @generated from field: string row = 2;
*/
row: string;
getRow(): string;
setRow(value: string): void;
/**
* @generated from field: string column = 3;
*/
column: string;
getColumn(): string;
setColumn(value: string): void;
/**
* @generated from field: string value = 4;
*/
value: string;
};
getValue(): string;
setValue(value: string): void;
/**
* Describes the message Message.
* Use `create(MessageSchema)` to create a new message.
*/
export const MessageSchema: GenMessage<Message> /*@__PURE__*/ = messageDesc(
file_sync,
1,
);
serializeBinary(): Uint8Array;
toObject(includeInstance?: boolean): Message.AsObject;
static toObject(includeInstance: boolean, msg: Message): Message.AsObject;
static extensions: { [key: number]: jspb.ExtensionFieldInfo<jspb.Message> };
static extensionsBinary: {
[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>;
};
static serializeBinaryToWriter(
message: Message,
writer: jspb.BinaryWriter,
): void;
static deserializeBinary(bytes: Uint8Array): Message;
static deserializeBinaryFromReader(
message: Message,
reader: jspb.BinaryReader,
): Message;
}
/**
* @generated from message MessageEnvelope
*/
export type MessageEnvelope = Message$1<'MessageEnvelope'> & {
/**
* @generated from field: string timestamp = 1;
*/
timestamp: string;
export namespace Message {
export type AsObject = {
dataset: string;
row: string;
column: string;
value: string;
};
}
/**
* @generated from field: bool isEncrypted = 2;
*/
isEncrypted: boolean;
export declare class MessageEnvelope extends jspb.Message {
getTimestamp(): string;
setTimestamp(value: string): void;
/**
* @generated from field: bytes content = 3;
*/
content: Uint8Array;
};
getIsencrypted(): boolean;
setIsencrypted(value: boolean): void;
/**
* Describes the message MessageEnvelope.
* Use `create(MessageEnvelopeSchema)` to create a new message.
*/
export const MessageEnvelopeSchema: GenMessage<MessageEnvelope> /*@__PURE__*/ =
messageDesc(file_sync, 2);
getContent(): Uint8Array | string;
getContent_asU8(): Uint8Array;
getContent_asB64(): string;
setContent(value: Uint8Array | string): void;
/**
* @generated from message SyncRequest
*/
export type SyncRequest = Message$1<'SyncRequest'> & {
/**
* @generated from field: repeated MessageEnvelope messages = 1;
*/
messages: MessageEnvelope[];
serializeBinary(): Uint8Array;
toObject(includeInstance?: boolean): MessageEnvelope.AsObject;
static toObject(
includeInstance: boolean,
msg: MessageEnvelope,
): MessageEnvelope.AsObject;
static extensions: { [key: number]: jspb.ExtensionFieldInfo<jspb.Message> };
static extensionsBinary: {
[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>;
};
static serializeBinaryToWriter(
message: MessageEnvelope,
writer: jspb.BinaryWriter,
): void;
static deserializeBinary(bytes: Uint8Array): MessageEnvelope;
static deserializeBinaryFromReader(
message: MessageEnvelope,
reader: jspb.BinaryReader,
): MessageEnvelope;
}
/**
* @generated from field: string fileId = 2;
*/
fileId: string;
export namespace MessageEnvelope {
export type AsObject = {
timestamp: string;
isencrypted: boolean;
content: Uint8Array | string;
};
}
/**
* @generated from field: string groupId = 3;
*/
groupId: string;
export declare class SyncRequest extends jspb.Message {
clearMessagesList(): void;
getMessagesList(): Array<MessageEnvelope>;
setMessagesList(value: Array<MessageEnvelope>): void;
addMessages(value?: MessageEnvelope, index?: number): MessageEnvelope;
/**
* @generated from field: string keyId = 5;
*/
keyId: string;
getFileid(): string;
setFileid(value: string): void;
/**
* @generated from field: string since = 6;
*/
since: string;
};
getGroupid(): string;
setGroupid(value: string): void;
/**
* Describes the message SyncRequest.
* Use `create(SyncRequestSchema)` to create a new message.
*/
export const SyncRequestSchema: GenMessage<SyncRequest> /*@__PURE__*/ =
messageDesc(file_sync, 3);
getKeyid(): string;
setKeyid(value: string): void;
/**
* @generated from message SyncResponse
*/
export type SyncResponse = Message$1<'SyncResponse'> & {
/**
* @generated from field: repeated MessageEnvelope messages = 1;
*/
messages: MessageEnvelope[];
getSince(): string;
setSince(value: string): void;
/**
* @generated from field: string merkle = 2;
*/
merkle: string;
};
serializeBinary(): Uint8Array;
toObject(includeInstance?: boolean): SyncRequest.AsObject;
static toObject(
includeInstance: boolean,
msg: SyncRequest,
): SyncRequest.AsObject;
static extensions: { [key: number]: jspb.ExtensionFieldInfo<jspb.Message> };
static extensionsBinary: {
[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>;
};
static serializeBinaryToWriter(
message: SyncRequest,
writer: jspb.BinaryWriter,
): void;
static deserializeBinary(bytes: Uint8Array): SyncRequest;
static deserializeBinaryFromReader(
message: SyncRequest,
reader: jspb.BinaryReader,
): SyncRequest;
}
/**
* Describes the message SyncResponse.
* Use `create(SyncResponseSchema)` to create a new message.
*/
export const SyncResponseSchema: GenMessage<SyncResponse> /*@__PURE__*/ =
messageDesc(file_sync, 4);
export namespace SyncRequest {
export type AsObject = {
messagesList: Array<MessageEnvelope.AsObject>;
fileid: string;
groupid: string;
keyid: string;
since: string;
};
}
export declare class SyncResponse extends jspb.Message {
clearMessagesList(): void;
getMessagesList(): Array<MessageEnvelope>;
setMessagesList(value: Array<MessageEnvelope>): void;
addMessages(value?: MessageEnvelope, index?: number): MessageEnvelope;
getMerkle(): string;
setMerkle(value: string): void;
serializeBinary(): Uint8Array;
toObject(includeInstance?: boolean): SyncResponse.AsObject;
static toObject(
includeInstance: boolean,
msg: SyncResponse,
): SyncResponse.AsObject;
static extensions: { [key: number]: jspb.ExtensionFieldInfo<jspb.Message> };
static extensionsBinary: {
[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>;
};
static serializeBinaryToWriter(
message: SyncResponse,
writer: jspb.BinaryWriter,
): void;
static deserializeBinary(bytes: Uint8Array): SyncResponse;
static deserializeBinaryFromReader(
message: SyncResponse,
reader: jspb.BinaryReader,
): SyncResponse;
}
export namespace SyncResponse {
export type AsObject = {
messagesList: Array<MessageEnvelope.AsObject>;
merkle: string;
};
}

View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": false,
"emitDeclarationOnly": false
},
"exclude": ["**/*.test.ts", "**/*.spec.ts"]
}

View File

@@ -4,8 +4,8 @@
"rootDir": "./src",
"composite": true,
"target": "ES2021",
"module": "ES2022",
"moduleResolution": "bundler",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": false,
"emitDeclarationOnly": true,
"declaration": true,

View File

@@ -6,7 +6,7 @@ import { defineConfig } from 'vite';
export default defineConfig({
ssr: {
noExternal: true,
external: ['@bufbuild/protobuf', 'murmurhash'],
external: ['google-protobuf', 'murmurhash'],
},
build: {
ssr: true,
@@ -16,7 +16,7 @@ export default defineConfig({
sourcemap: true,
lib: {
entry: path.resolve(__dirname, 'src/index.ts'),
formats: ['es'],
formats: ['cjs'],
fileName: () => 'index.js',
},
},

View File

@@ -8,7 +8,6 @@ coverage
test-results
playwright-report
blob-report
.playwright-cli
# production
build

View File

@@ -0,0 +1,17 @@
#!/bin/sh -ex
ROOT=`dirname $0`
cd "$ROOT/.."
echo "Building the browser..."
rm -fr build
export REACT_APP_BACKEND_WORKER_HASH=`ls "$ROOT"/../public/kcab/kcab.worker.*.js | sed 's/.*kcab\.worker\.\(.*\)\.js/\1/'`
yarn build --mode=browser
rm -fr build-stats
mkdir build-stats
mv build/kcab/stats.json build-stats/loot-core-stats.json
mv ./stats.json build-stats/web-stats.json

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 69 KiB

View File

@@ -1,5 +1,5 @@
import { test as base, expect as baseExpect } from '@playwright/test';
import type { Browser, Locator, Page } from '@playwright/test';
import type { Browser, Locator } from '@playwright/test';
/**
* Disable CSS transitions and animations globally in e2e (non-VRT) runs.
@@ -51,7 +51,7 @@ export const test = process.env.VRT
});
export const expect = baseExpect.extend({
async toMatchThemeScreenshots(target: Locator | Page) {
async toMatchThemeScreenshots(locator: Locator) {
// Disable screenshot assertions in regular e2e tests;
// only enable them when doing VRT tests
if (!process.env.VRT) {
@@ -62,33 +62,38 @@ export const expect = baseExpect.extend({
}
const config = {
mask: [target.locator('[data-vrt-mask="true"]')],
mask: [locator.locator('[data-vrt-mask="true"]')],
maxDiffPixels: 5,
};
const page: Page = 'page' in target ? target.page() : target;
const dataThemeLocator = page.locator('[data-theme]');
// Get the data-theme attribute from page.
// If there is a page() function, it means that the locator
// is not a page object but a locator object.
const dataThemeLocator =
typeof locator.page === 'function'
? locator.page().locator('[data-theme]')
: locator.locator('[data-theme]');
// Check lightmode
await page.evaluate(() => window.Actual.setTheme('auto'));
await locator.evaluate(() => window.Actual.setTheme('auto'));
await baseExpect(dataThemeLocator).toHaveAttribute('data-theme', 'auto');
await baseExpect(target).toHaveScreenshot(config);
await baseExpect(locator).toHaveScreenshot(config);
// Switch to darkmode and check
await page.evaluate(() => window.Actual.setTheme('dark'));
await locator.evaluate(() => window.Actual.setTheme('dark'));
await baseExpect(dataThemeLocator).toHaveAttribute('data-theme', 'dark');
await baseExpect(target).toHaveScreenshot(config);
await baseExpect(locator).toHaveScreenshot(config);
// Switch to midnight theme and check
await page.evaluate(() => window.Actual.setTheme('midnight'));
await locator.evaluate(() => window.Actual.setTheme('midnight'));
await baseExpect(dataThemeLocator).toHaveAttribute(
'data-theme',
'midnight',
);
await baseExpect(target).toHaveScreenshot(config);
await baseExpect(locator).toHaveScreenshot(config);
// Switch back to lightmode
await page.evaluate(() => window.Actual.setTheme('auto'));
await locator.evaluate(() => window.Actual.setTheme('auto'));
return {
message: () => 'pass',
pass: true,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

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