mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-18 16:00:34 -05:00
Compare commits
16 Commits
claude/bro
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a43b6f5c47 | ||
|
|
1f821d2849 | ||
|
|
beee16bc8c | ||
|
|
4cdb26f9a7 | ||
|
|
15358b6b54 | ||
|
|
6d9b1a1d72 | ||
|
|
108ccc8aba | ||
|
|
ee8f8bfbba | ||
|
|
c4ee71409e | ||
|
|
dfd6e468a6 | ||
|
|
5b227f5fa1 | ||
|
|
e1606b31ab | ||
|
|
4f7c3c51a5 | ||
|
|
0e1fc07bf3 | ||
|
|
53cdc6fa48 | ||
|
|
1d0281025d |
3
.github/actions/docs-spelling/expect.txt
vendored
3
.github/actions/docs-spelling/expect.txt
vendored
@@ -2,6 +2,7 @@ Abanca
|
||||
ABNAMRO
|
||||
ABNANL
|
||||
Activo
|
||||
actualrc
|
||||
AESUDEF
|
||||
ALZEY
|
||||
Anglais
|
||||
@@ -110,8 +111,8 @@ KBCBE
|
||||
Keycloak
|
||||
Khurozov
|
||||
KORT
|
||||
KRW
|
||||
Kreditbank
|
||||
KRW
|
||||
lage
|
||||
LHV
|
||||
LHVBEE
|
||||
|
||||
8
.github/actions/setup/action.yml
vendored
8
.github/actions/setup/action.yml
vendored
@@ -15,7 +15,7 @@ runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install node
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Install yarn
|
||||
@@ -27,7 +27,7 @@ runs:
|
||||
run: echo "version=$(node -v)" >> "$GITHUB_OUTPUT"
|
||||
shell: bash
|
||||
- name: Cache
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
id: cache
|
||||
with:
|
||||
path: ${{ format('{0}/**/node_modules', inputs.working-directory) }}
|
||||
@@ -36,7 +36,7 @@ runs:
|
||||
run: mkdir -p ${{ format('{0}/.lage', inputs.working-directory) }}
|
||||
shell: bash
|
||||
- name: Cache Lage
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: ${{ format('{0}/.lage', inputs.working-directory) }}
|
||||
key: lage-${{ runner.os }}-${{ github.sha }}
|
||||
@@ -48,7 +48,7 @@ runs:
|
||||
shell: bash
|
||||
if: steps.cache.outputs.cache-hit != 'true'
|
||||
- name: Download translations
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: actualbudget/translations
|
||||
path: ${{ inputs.working-directory }}/packages/desktop-client/locale
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
|
||||
4
.github/workflows/autofix.yml
vendored
4
.github/workflows/autofix.yml
vendored
@@ -15,11 +15,11 @@ jobs:
|
||||
autofix:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
- name: Format code
|
||||
run: yarn lint:fix
|
||||
- uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27
|
||||
- uses: autofix-ci/action@7a166d7532b277f34e16238930461bf77f9d7ed8 # v1.3.3
|
||||
|
||||
47
.github/workflows/build.yml
vendored
47
.github/workflows/build.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
api:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -34,12 +34,12 @@ jobs:
|
||||
- name: Prepare bundle stats artifact
|
||||
run: cp packages/api/app/stats.json api-stats.json
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: actual-api
|
||||
path: packages/api/actual-api.tgz
|
||||
- name: Upload API bundle stats
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: api-build-stats
|
||||
path: api-stats.json
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
crdt:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
- name: Create package tgz
|
||||
run: cd packages/crdt && yarn pack && mv package.tgz actual-crdt.tgz
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: actual-crdt
|
||||
path: packages/crdt/actual-crdt.tgz
|
||||
@@ -65,23 +65,23 @@ jobs:
|
||||
web:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build Web
|
||||
run: yarn build:browser
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: actual-web
|
||||
path: packages/desktop-client/build
|
||||
- name: Upload Build Stats
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: build-stats
|
||||
path: packages/desktop-client/build-stats
|
||||
|
||||
server:
|
||||
cli:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
@@ -89,10 +89,35 @@ jobs:
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
- name: Build Server
|
||||
run: yarn workspace @actual-app/sync-server build
|
||||
- name: Build CLI
|
||||
run: yarn build:cli
|
||||
- name: Create package tgz
|
||||
run: cd packages/cli && yarn pack && mv package.tgz actual-cli.tgz
|
||||
- name: Prepare bundle stats artifact
|
||||
run: cp packages/cli/dist/stats.json cli-stats.json
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: actual-cli
|
||||
path: packages/cli/actual-cli.tgz
|
||||
- name: Upload CLI bundle stats
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: cli-build-stats
|
||||
path: cli-stats.json
|
||||
|
||||
server:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
- name: Build Server
|
||||
run: yarn workspace @actual-app/sync-server build
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: sync-server
|
||||
path: packages/sync-server/build
|
||||
|
||||
20
.github/workflows/check.yml
vendored
20
.github/workflows/check.yml
vendored
@@ -12,10 +12,20 @@ concurrency:
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
|
||||
|
||||
jobs:
|
||||
constraints:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
- name: Check dependency version consistency
|
||||
run: yarn constraints
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -25,7 +35,7 @@ jobs:
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -35,7 +45,7 @@ jobs:
|
||||
validate-cli:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -47,7 +57,7 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -59,7 +69,7 @@ jobs:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
|
||||
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
@@ -22,14 +22,14 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
uses: github/codeql-action/init@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
|
||||
with:
|
||||
languages: javascript
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
uses: github/codeql-action/analyze@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
|
||||
with:
|
||||
category: '/language:javascript'
|
||||
|
||||
2
.github/workflows/count-points.yml
vendored
2
.github/workflows/count-points.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
|
||||
16
.github/workflows/docker-edge.yml
vendored
16
.github/workflows/docker-edge.yml
vendored
@@ -36,17 +36,17 @@ jobs:
|
||||
matrix:
|
||||
os: [ubuntu, alpine]
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
# Push to both Docker Hub and Github Container Registry
|
||||
images: ${{ env.IMAGES }}
|
||||
@@ -54,14 +54,14 @@ jobs:
|
||||
tags: ${{ env.TAGS }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
if: github.event_name != 'pull_request' && !github.event.repository.fork
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
registry: ghcr.io
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
run: yarn build:server
|
||||
|
||||
- name: Build image for testing
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
# This will use the cache from the earlier build step and not rebuild the image
|
||||
# https://docs.docker.com/build/ci/github-actions/test-before-push/
|
||||
- name: Build and push images
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
18
.github/workflows/docker-release.yml
vendored
18
.github/workflows/docker-release.yml
vendored
@@ -28,17 +28,17 @@ jobs:
|
||||
name: Build Docker image
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
# Push to both Docker Hub and Github Container Registry
|
||||
images: ${{ env.IMAGES }}
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
|
||||
- name: Docker meta for Alpine image
|
||||
id: alpine-meta
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: ${{ env.IMAGES }}
|
||||
# Automatically update :latest
|
||||
@@ -58,13 +58,13 @@ jobs:
|
||||
tags: ${{ env.TAGS }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
run: yarn build:server
|
||||
|
||||
- name: Build and push ubuntu image
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
|
||||
- name: Build and push alpine image
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
20
.github/workflows/e2e-test.yml
vendored
20
.github/workflows/e2e-test.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
- name: Run E2E Tests
|
||||
run: yarn e2e --shard=${{ matrix.shard }}/5
|
||||
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: desktop-client-test-results-shard-${{ matrix.shard }}
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
- name: Run Desktop app E2E Tests
|
||||
run: |
|
||||
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" -- yarn e2e:desktop
|
||||
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: desktop-app-test-results
|
||||
@@ -83,14 +83,14 @@ jobs:
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
- name: Run VRT Tests
|
||||
run: yarn vrt --shard=${{ matrix.shard }}/5
|
||||
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: vrt-blob-report-${{ matrix.shard }}
|
||||
@@ -106,11 +106,11 @@ jobs:
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Download all blob reports
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
path: packages/desktop-client/all-blob-reports
|
||||
pattern: vrt-blob-report-*
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
- name: Merge reports
|
||||
id: merge-reports
|
||||
run: yarn workspace @actual-app/web run playwright merge-reports --reporter html ./all-blob-reports
|
||||
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
id: playwright-report-vrt
|
||||
with:
|
||||
name: html-report--attempt-${{ github.run_attempt }}
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
echo "${{ steps.playwright-report-vrt.outputs.artifact-url }}" > vrt-metadata/artifact-url.txt
|
||||
- name: Upload VRT metadata
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: vrt-comment-metadata
|
||||
path: vrt-metadata/
|
||||
|
||||
4
.github/workflows/e2e-vrt-comment.yml
vendored
4
.github/workflows/e2e-vrt-comment.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
if: github.event.workflow_run.event == 'pull_request'
|
||||
steps:
|
||||
- name: Download VRT metadata
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
|
||||
- name: Comment on PR with VRT report link
|
||||
if: steps.metadata.outputs.should_comment == 'true'
|
||||
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
|
||||
uses: marocchino/sticky-pull-request-comment@70d2764d1a7d5d9560b100cbea0077fc8f633987 # v3.0.2
|
||||
with:
|
||||
number: ${{ steps.metadata.outputs.pr_number }}
|
||||
header: vrt-comment
|
||||
|
||||
10
.github/workflows/electron-master.yml
vendored
10
.github/workflows/electron-master.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
- macos-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- if: ${{ startsWith(matrix.os, 'windows') }}
|
||||
run: pip.exe install setuptools
|
||||
- if: ${{ ! startsWith(matrix.os, 'windows') }}
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
if: ${{ ! startsWith(matrix.os, 'macos') }}
|
||||
run: ./bin/package-electron
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: actual-electron-${{ matrix.os }}
|
||||
path: |
|
||||
@@ -85,13 +85,13 @@ jobs:
|
||||
packages/desktop-electron/dist/*.flatpak
|
||||
- name: Upload Windows Store Build
|
||||
if: ${{ startsWith(matrix.os, 'windows') }}
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: actual-electron-${{ matrix.os }}-appx
|
||||
path: |
|
||||
packages/desktop-electron/dist/*.appx
|
||||
- name: Add to new release
|
||||
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
|
||||
with:
|
||||
draft: true
|
||||
body: |
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
Install-Module -Name StoreBroker -AcceptLicense -Force -Scope CurrentUser -Verbose
|
||||
|
||||
- name: Download Microsoft Store artifacts
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: actual-electron-windows-latest-appx
|
||||
|
||||
|
||||
26
.github/workflows/electron-pr.yml
vendored
26
.github/workflows/electron-pr.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
- macos-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- if: ${{ startsWith(matrix.os, 'windows') }}
|
||||
run: pip.exe install setuptools
|
||||
- if: ${{ ! startsWith(matrix.os, 'windows') }}
|
||||
@@ -42,6 +42,8 @@ jobs:
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
python3 -m pip install setuptools
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- if: ${{ startsWith(matrix.os, 'ubuntu') }}
|
||||
name: Setup Flatpak dependencies
|
||||
run: |
|
||||
@@ -56,65 +58,63 @@ jobs:
|
||||
|
||||
METAINFO_FILE="packages/desktop-electron/extra-resources/linux/com.actualbudget.actual.metainfo.xml"
|
||||
TODAY=$(date +%Y-%m-%d)
|
||||
VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-electron/package.json --type nightly)
|
||||
VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/desktop-electron/package.json --type nightly)
|
||||
sed -i "s/%RELEASE_VERSION%/$VERSION/g; s/%RELEASE_DATE%/$TODAY/g" "$METAINFO_FILE"
|
||||
flatpak run --command=flatpak-builder-lint org.flatpak.Builder appstream "$METAINFO_FILE"
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build Electron
|
||||
run: ./bin/package-electron
|
||||
|
||||
- name: Upload Linux x64 AppImage
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: Actual-linux-x86_64.AppImage
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-linux-x86_64.AppImage
|
||||
|
||||
- name: Upload Linux arm64 AppImage
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: Actual-linux-arm64.AppImage
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-linux-arm64.AppImage
|
||||
|
||||
- name: Upload Linux x64 flatpak
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: Actual-linux-x86_64.flatpak
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-linux-x86_64.flatpak
|
||||
|
||||
- name: Upload Windows x32 exe
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: Actual-windows-ia32.exe
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-windows-ia32.exe
|
||||
|
||||
- name: Upload Windows x64 exe
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: Actual-windows-x64.exe
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-windows-x64.exe
|
||||
|
||||
- name: Upload Windows arm64 exe
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: Actual-windows-arm64.exe
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-windows-arm64.exe
|
||||
|
||||
- name: Upload Mac x64 dmg
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: Actual-mac-x64.dmg
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-mac-x64.dmg
|
||||
|
||||
- name: Upload Mac arm64 dmg
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: Actual-mac-arm64.dmg
|
||||
if-no-files-found: ignore
|
||||
@@ -122,7 +122,7 @@ jobs:
|
||||
|
||||
- name: Upload Windows Store Build
|
||||
if: ${{ startsWith(matrix.os, 'windows') }}
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: actual-electron-${{ matrix.os }}-appx
|
||||
path: |
|
||||
|
||||
2
.github/workflows/fork-pr-welcome.yml
vendored
2
.github/workflows/fork-pr-welcome.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
if: github.event.pull_request.head.repo.full_name != github.repository
|
||||
steps:
|
||||
- name: Post welcome comment
|
||||
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
|
||||
uses: marocchino/sticky-pull-request-comment@70d2764d1a7d5d9560b100cbea0077fc8f633987 # v3.0.2
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
number: ${{ github.event.pull_request.number }}
|
||||
|
||||
12
.github/workflows/generate-release-pr.yml
vendored
12
.github/workflows/generate-release-pr.yml
vendored
@@ -17,9 +17,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
- name: Bump package versions
|
||||
id: bump_package_versions
|
||||
shell: bash
|
||||
@@ -35,12 +39,12 @@ jobs:
|
||||
pkg="${packages[$key]}"
|
||||
|
||||
if [[ -n "${{ github.event.inputs.version }}" ]]; then
|
||||
version=$(node ./packages/ci-actions/bin/get-next-package-version.js \
|
||||
version=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts \
|
||||
--package-json "./packages/$pkg/package.json" \
|
||||
--version "${{ github.event.inputs.version }}" \
|
||||
--update)
|
||||
else
|
||||
version=$(node ./packages/ci-actions/bin/get-next-package-version.js \
|
||||
version=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts \
|
||||
--package-json "./packages/$pkg/package.json" \
|
||||
--type auto \
|
||||
--update)
|
||||
@@ -51,7 +55,7 @@ jobs:
|
||||
|
||||
echo "version=$NEW_WEB_VERSION" >> "$GITHUB_OUTPUT"
|
||||
- name: Create PR
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
token: ${{ secrets.ACTIONS_UPDATE_TOKEN }}
|
||||
commit-message: '🔖 (${{ steps.bump_package_versions.outputs.version }})'
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
if: github.repository == 'actualbudget/actual'
|
||||
steps:
|
||||
- name: Check out main repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
path: actual
|
||||
- name: Set up environment
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
push \
|
||||
actualbudget/actual
|
||||
- name: Check out updated translations
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ssh-key: ${{ secrets.STRING_IMPORT_DEPLOY_KEY }}
|
||||
repository: actualbudget/translations
|
||||
|
||||
@@ -24,8 +24,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# This is not a security concern because we have approved & merged the PR
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Handle feature requests
|
||||
|
||||
2
.github/workflows/netlify-release.yml
vendored
2
.github/workflows/netlify-release.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Repository Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
4
.github/workflows/publish-flathub.yml
vendored
4
.github/workflows/publish-flathub.yml
vendored
@@ -92,7 +92,7 @@ jobs:
|
||||
echo "APPIMAGE_ARM64_SHA256=$APPIMAGE_ARM64_SHA256" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Checkout Flathub repo
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: flathub/com.actualbudget.actual
|
||||
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
cat com.actualbudget.actual.yml
|
||||
|
||||
- name: Create PR in Flathub repo
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
|
||||
commit-message: 'Update Actual flatpak to version ${{ steps.resolve_version.outputs.version }}'
|
||||
|
||||
27
.github/workflows/publish-nightly-electron.yml
vendored
27
.github/workflows/publish-nightly-electron.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
if: github.event.repository.fork == false
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- if: ${{ startsWith(matrix.os, 'windows') }}
|
||||
run: pip.exe install setuptools
|
||||
|
||||
@@ -39,6 +39,9 @@ jobs:
|
||||
source .venv/bin/activate
|
||||
python3 -m pip install setuptools
|
||||
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- if: ${{ startsWith(matrix.os, 'ubuntu') }}
|
||||
name: Setup Flatpak dependencies
|
||||
run: |
|
||||
@@ -53,16 +56,14 @@ jobs:
|
||||
|
||||
METAINFO_FILE="packages/desktop-electron/extra-resources/linux/com.actualbudget.actual.metainfo.xml"
|
||||
TODAY=$(date +%Y-%m-%d)
|
||||
VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-electron/package.json --type nightly)
|
||||
VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/desktop-electron/package.json --type nightly)
|
||||
sed -i "s/%RELEASE_VERSION%/$VERSION/g; s/%RELEASE_DATE%/$TODAY/g" "$METAINFO_FILE"
|
||||
flatpak run --command=flatpak-builder-lint org.flatpak.Builder appstream "$METAINFO_FILE"
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Update package versions
|
||||
run: |
|
||||
# Get new nightly version
|
||||
NEW_DESKTOP_APP_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-electron/package.json --type nightly)
|
||||
NEW_DESKTOP_APP_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/desktop-electron/package.json --type nightly)
|
||||
|
||||
# Set package version
|
||||
npm version $NEW_DESKTOP_APP_VERSION --no-git-tag-version --workspace=desktop-electron --no-workspaces-update
|
||||
@@ -82,49 +83,49 @@ jobs:
|
||||
run: ./bin/package-electron
|
||||
|
||||
- name: Upload Linux x64 AppImage
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: Actual-linux-x86_64.AppImage
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-linux-x86_64.AppImage
|
||||
|
||||
- name: Upload Linux arm64 AppImage
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: Actual-linux-arm64.AppImage
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-linux-arm64.AppImage
|
||||
|
||||
- name: Upload Windows x32 exe
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: Actual-windows-ia32.exe
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-windows-ia32.exe
|
||||
|
||||
- name: Upload Windows x64 exe
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: Actual-windows-x64.exe
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-windows-x64.exe
|
||||
|
||||
- name: Upload Windows arm64 exe
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: Actual-windows-arm64.exe
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-windows-arm64.exe
|
||||
|
||||
- name: Upload Mac x64 dmg
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: Actual-mac-x64.dmg
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-mac-x64.dmg
|
||||
|
||||
- name: Upload Mac arm64 dmg
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: Actual-mac-arm64.dmg
|
||||
if-no-files-found: ignore
|
||||
@@ -132,7 +133,7 @@ jobs:
|
||||
|
||||
- name: Upload Windows Store Build
|
||||
if: ${{ startsWith(matrix.os, 'windows') }}
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: actual-electron-${{ matrix.os }}-appx
|
||||
path: |
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
name: Build and pack npm packages
|
||||
if: github.event.repository.fork == false
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
@@ -20,16 +20,18 @@ jobs:
|
||||
- name: Update package versions
|
||||
run: |
|
||||
# Get new nightly versions
|
||||
NEW_CORE_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/loot-core/package.json --type nightly)
|
||||
NEW_WEB_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-client/package.json --type nightly)
|
||||
NEW_SYNC_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/sync-server/package.json --type nightly)
|
||||
NEW_API_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/api/package.json --type nightly)
|
||||
NEW_CORE_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/loot-core/package.json --type nightly)
|
||||
NEW_WEB_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/desktop-client/package.json --type nightly)
|
||||
NEW_SYNC_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/sync-server/package.json --type nightly)
|
||||
NEW_API_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/api/package.json --type nightly)
|
||||
NEW_CLI_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/cli/package.json --type nightly)
|
||||
|
||||
# Set package versions
|
||||
npm version $NEW_CORE_VERSION --no-git-tag-version --workspace=@actual-app/core --no-workspaces-update
|
||||
npm version $NEW_WEB_VERSION --no-git-tag-version --workspace=@actual-app/web --no-workspaces-update
|
||||
npm version $NEW_SYNC_VERSION --no-git-tag-version --workspace=@actual-app/sync-server --no-workspaces-update
|
||||
npm version $NEW_API_VERSION --no-git-tag-version --workspace=@actual-app/api --no-workspaces-update
|
||||
npm version $NEW_CLI_VERSION --no-git-tag-version --workspace=@actual-app/cli --no-workspaces-update
|
||||
|
||||
- name: Yarn install
|
||||
run: |
|
||||
@@ -54,8 +56,15 @@ jobs:
|
||||
run: |
|
||||
yarn workspace @actual-app/api pack --filename @actual-app/api.tgz
|
||||
|
||||
- name: Build CLI
|
||||
run: yarn workspace @actual-app/cli build
|
||||
|
||||
- name: Pack the cli package
|
||||
run: |
|
||||
yarn workspace @actual-app/cli pack --filename @actual-app/cli.tgz
|
||||
|
||||
- name: Upload package artifacts
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: npm-packages
|
||||
path: |
|
||||
@@ -63,6 +72,7 @@ jobs:
|
||||
packages/desktop-client/@actual-app/web.tgz
|
||||
packages/sync-server/@actual-app/sync-server.tgz
|
||||
packages/api/@actual-app/api.tgz
|
||||
packages/cli/@actual-app/cli.tgz
|
||||
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -73,12 +83,12 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Download the artifacts
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: npm-packages
|
||||
|
||||
- name: Setup node and npm registry
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 22
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
@@ -106,3 +116,9 @@ jobs:
|
||||
npm publish api/@actual-app/api.tgz --access public --tag nightly
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish CLI
|
||||
run: |
|
||||
npm publish cli/@actual-app/cli.tgz --access public --tag nightly
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
22
.github/workflows/publish-npm-packages.yml
vendored
22
.github/workflows/publish-npm-packages.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build and pack npm packages
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
@@ -35,8 +35,15 @@ jobs:
|
||||
run: |
|
||||
yarn workspace @actual-app/api pack --filename @actual-app/api.tgz
|
||||
|
||||
- name: Build CLI
|
||||
run: yarn workspace @actual-app/cli build
|
||||
|
||||
- name: Pack the cli package
|
||||
run: |
|
||||
yarn workspace @actual-app/cli pack --filename @actual-app/cli.tgz
|
||||
|
||||
- name: Upload package artifacts
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: npm-packages
|
||||
path: |
|
||||
@@ -44,6 +51,7 @@ jobs:
|
||||
packages/desktop-client/@actual-app/web.tgz
|
||||
packages/sync-server/@actual-app/sync-server.tgz
|
||||
packages/api/@actual-app/api.tgz
|
||||
packages/cli/@actual-app/cli.tgz
|
||||
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -54,12 +62,12 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Download the artifacts
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: npm-packages
|
||||
|
||||
- name: Setup node and npm registry
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 22
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
@@ -87,3 +95,9 @@ jobs:
|
||||
npm publish api/@actual-app/api.tgz --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish CLI
|
||||
run: |
|
||||
npm publish cli/@actual-app/cli.tgz --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
2
.github/workflows/release-notes.yml
vendored
2
.github/workflows/release-notes.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Get changed files
|
||||
|
||||
45
.github/workflows/size-compare.yml
vendored
45
.github/workflows/size-compare.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout base branch
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.base_ref }}
|
||||
- name: Set up environment
|
||||
@@ -57,6 +57,13 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: api
|
||||
ref: ${{github.base_ref}}
|
||||
- name: Wait for ${{github.base_ref}} CLI build to succeed
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
id: master-cli-build
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: cli
|
||||
ref: ${{github.base_ref}}
|
||||
|
||||
- name: Wait for PR build to succeed
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
@@ -72,15 +79,22 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: api
|
||||
ref: ${{github.event.pull_request.head.sha}}
|
||||
- name: Wait for CLI PR build to succeed
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
id: wait-for-cli-build
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: cli
|
||||
ref: ${{github.event.pull_request.head.sha}}
|
||||
|
||||
- name: Report build failure
|
||||
if: steps.wait-for-web-build.outputs.conclusion == 'failure' || steps.wait-for-api-build.outputs.conclusion == 'failure'
|
||||
if: steps.wait-for-web-build.outputs.conclusion == 'failure' || steps.wait-for-api-build.outputs.conclusion == 'failure' || steps.wait-for-cli-build.outputs.conclusion == 'failure'
|
||||
run: |
|
||||
echo "Build failed on PR branch or ${{github.base_ref}}"
|
||||
exit 1
|
||||
|
||||
- name: Download web build artifact from ${{github.base_ref}}
|
||||
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
|
||||
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
|
||||
id: pr-web-build
|
||||
with:
|
||||
branch: ${{github.base_ref}}
|
||||
@@ -89,7 +103,7 @@ jobs:
|
||||
name: build-stats
|
||||
path: base
|
||||
- name: Download API build artifact from ${{github.base_ref}}
|
||||
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
|
||||
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
|
||||
id: pr-api-build
|
||||
with:
|
||||
branch: ${{github.base_ref}}
|
||||
@@ -98,7 +112,7 @@ jobs:
|
||||
name: api-build-stats
|
||||
path: base
|
||||
- name: Download build stats from PR
|
||||
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
|
||||
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
|
||||
with:
|
||||
pr: ${{github.event.pull_request.number}}
|
||||
workflow: build.yml
|
||||
@@ -107,7 +121,7 @@ jobs:
|
||||
path: head
|
||||
allow_forks: true
|
||||
- name: Download API stats from PR
|
||||
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
|
||||
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
|
||||
with:
|
||||
pr: ${{github.event.pull_request.number}}
|
||||
workflow: build.yml
|
||||
@@ -115,6 +129,23 @@ jobs:
|
||||
name: api-build-stats
|
||||
path: head
|
||||
allow_forks: true
|
||||
- name: Download CLI build artifact from ${{github.base_ref}}
|
||||
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
|
||||
with:
|
||||
branch: ${{github.base_ref}}
|
||||
workflow: build.yml
|
||||
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
|
||||
name: cli-build-stats
|
||||
path: base
|
||||
- name: Download CLI stats from PR
|
||||
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
|
||||
with:
|
||||
pr: ${{github.event.pull_request.number}}
|
||||
workflow: build.yml
|
||||
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
|
||||
name: cli-build-stats
|
||||
path: head
|
||||
allow_forks: true
|
||||
- name: Strip content hashes from stats files
|
||||
run: |
|
||||
if [ -f ./head/web-stats.json ]; then
|
||||
@@ -136,9 +167,11 @@ jobs:
|
||||
--base desktop-client=./base/web-stats.json \
|
||||
--base loot-core=./base/loot-core-stats.json \
|
||||
--base api=./base/api-stats.json \
|
||||
--base cli=./base/cli-stats.json \
|
||||
--head desktop-client=./head/web-stats.json \
|
||||
--head loot-core=./head/loot-core-stats.json \
|
||||
--head api=./head/api-stats.json \
|
||||
--head cli=./head/cli-stats.json \
|
||||
--identifier combined \
|
||||
--format pr-body > bundle-stats-comment.md
|
||||
- name: Post combined bundle stats comment
|
||||
|
||||
6
.github/workflows/stale.yml
vendored
6
.github/workflows/stale.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
stale-pr-message: 'This PR is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.'
|
||||
close-pr-message: 'This PR was closed because it has been stalled for 5 days with no activity.'
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
stale-wip:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
stale-pr-message: ':wave: Hi! It looks like this PR has not had any changes for a week now. Would you like someone to review this PR? If so - please remove the "[WIP]" prefix from the PR title. That will let the community know that this PR is open for a review.'
|
||||
days-before-stale: 7
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
stale-needs-info:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
stale-issue-label: 'needs info'
|
||||
days-before-stale: -1
|
||||
|
||||
6
.github/workflows/vrt-update-apply.yml
vendored
6
.github/workflows/vrt-update-apply.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
steps:
|
||||
- name: Download patch artifact
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
path: /tmp/artifacts
|
||||
|
||||
- name: Download metadata artifact
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
|
||||
- name: Checkout fork branch
|
||||
if: steps.metadata.outputs.pr_number != ''
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: ${{ steps.metadata.outputs.head_repo }}
|
||||
ref: ${{ steps.metadata.outputs.head_ref }}
|
||||
|
||||
6
.github/workflows/vrt-update-generate.yml
vendored
6
.github/workflows/vrt-update-generate.yml
vendored
@@ -60,7 +60,7 @@ jobs:
|
||||
core.setOutput('head_ref', pr.head.ref);
|
||||
core.setOutput('head_repo', pr.head.repo.full_name);
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ steps.pr.outputs.head_sha }}
|
||||
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
|
||||
- name: Upload patch artifact
|
||||
if: steps.create-patch.outputs.has_changes == 'true'
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: vrt-patch-${{ github.event.issue.number }}
|
||||
path: vrt-update.patch
|
||||
@@ -129,7 +129,7 @@ jobs:
|
||||
|
||||
- name: Upload PR metadata
|
||||
if: steps.create-patch.outputs.has_changes == 'true'
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: vrt-metadata-${{ github.event.issue.number }}
|
||||
path: pr-metadata/
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -81,3 +81,10 @@ build/
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
||||
# cli config when testing locally
|
||||
.actualrc.json
|
||||
.actualrc
|
||||
.actualrc.yaml
|
||||
.actualrc.yml
|
||||
actual.config.js
|
||||
|
||||
13
.husky/post-checkout
Executable file
13
.husky/post-checkout
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
# Run yarn install when switching branches (if yarn.lock changed)
|
||||
|
||||
# $3 is 1 for branch checkout, 0 for file checkout
|
||||
if [ "$3" != "1" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if yarn.lock changed between the old and new HEAD
|
||||
if git diff --name-only "$1" "$2" | grep -q "^yarn.lock$"; then
|
||||
echo "yarn.lock changed — running yarn install..."
|
||||
yarn install
|
||||
fi
|
||||
@@ -17,6 +17,7 @@ module.exports = {
|
||||
},
|
||||
build: {
|
||||
type: 'npmScript',
|
||||
dependsOn: ['^build'],
|
||||
cache: true,
|
||||
options: {
|
||||
outputGlob: ['lib-dist/**', 'dist/**', 'build/**'],
|
||||
|
||||
20
package.json
20
package.json
@@ -34,12 +34,14 @@
|
||||
"start:browser-backend": "yarn workspace @actual-app/core watch:browser",
|
||||
"start:browser-frontend": "yarn workspace @actual-app/web start:browser",
|
||||
"start:storybook": "yarn workspace @actual-app/components start:storybook",
|
||||
"build": "lage build",
|
||||
"build:browser-backend": "yarn workspace @actual-app/core build:browser",
|
||||
"build:server": "yarn build:browser && yarn workspace @actual-app/sync-server build",
|
||||
"build:browser": "./bin/package-browser",
|
||||
"build:desktop": "./bin/package-electron",
|
||||
"build:plugins-service": "yarn workspace plugins-service build",
|
||||
"build:api": "yarn workspace @actual-app/api build",
|
||||
"build:cli": "yarn build --scope=@actual-app/cli",
|
||||
"build:docs": "yarn workspace docs build",
|
||||
"build:storybook": "yarn workspace @actual-app/components build:storybook",
|
||||
"deploy:docs": "yarn workspace docs deploy",
|
||||
@@ -57,29 +59,31 @@
|
||||
"lint": "oxfmt --check . && oxlint --type-aware --quiet",
|
||||
"lint:fix": "oxfmt . && oxlint --fix --type-aware --quiet",
|
||||
"install:server": "yarn workspaces focus @actual-app/sync-server --production",
|
||||
"constraints": "yarn constraints",
|
||||
"typecheck": "tsgo -p tsconfig.root.json --noEmit && lage typecheck",
|
||||
"jq": "./node_modules/node-jq/bin/jq",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@types/node": "^22.19.10",
|
||||
"@types/node": "^22.19.15",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"@yarnpkg/types": "^4.0.1",
|
||||
"baseline-browser-mapping": "^2.10.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-perfectionist": "^4.15.1",
|
||||
"eslint": "^9.39.3",
|
||||
"eslint-plugin-perfectionist": "^5.6.0",
|
||||
"eslint-plugin-typescript-paths": "^0.0.33",
|
||||
"html-to-image": "^1.11.13",
|
||||
"husky": "^9.1.7",
|
||||
"lage": "^2.14.17",
|
||||
"lint-staged": "^16.2.7",
|
||||
"minimatch": "^10.1.2",
|
||||
"lage": "^2.14.19",
|
||||
"lint-staged": "^16.3.2",
|
||||
"minimatch": "^10.2.4",
|
||||
"node-jq": "^6.3.1",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"oxfmt": "^0.32.0",
|
||||
"oxlint": "^1.47.0",
|
||||
"oxlint": "^1.51.0",
|
||||
"oxlint-tsgolint": "^0.13.0",
|
||||
"p-limit": "^7.3.0",
|
||||
"prompts": "^2.4.2",
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
class Query {
|
||||
/** @type {import('loot-core/shared/query').QueryState} */
|
||||
state;
|
||||
|
||||
constructor(state) {
|
||||
this.state = {
|
||||
filterExpressions: state.filterExpressions || [],
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { init as initLootCore } from '@actual-app/core/server/main';
|
||||
import type { InitConfig, lib } from '@actual-app/core/server/main';
|
||||
|
||||
export * from './methods';
|
||||
export * as utils from './utils';
|
||||
|
||||
let internal: typeof lib | null = null;
|
||||
|
||||
export async function init(config: InitConfig = {}) {
|
||||
internal = await initLootCore(config);
|
||||
return internal;
|
||||
}
|
||||
|
||||
export async function shutdown() {
|
||||
if (internal) {
|
||||
try {
|
||||
await internal.send('sync');
|
||||
} catch {
|
||||
// most likely that no budget is loaded, so the sync failed
|
||||
}
|
||||
|
||||
await internal.send('close-budget');
|
||||
internal = null;
|
||||
}
|
||||
}
|
||||
@@ -896,6 +896,73 @@ describe('API CRUD operations', () => {
|
||||
);
|
||||
expect(transactions[0].notes).toBeNull();
|
||||
});
|
||||
|
||||
test('Transactions: reimportDeleted=false prevents reimporting deleted transactions', async () => {
|
||||
const accountId = await api.createAccount({ name: 'test-account' }, 0);
|
||||
|
||||
// Import a transaction
|
||||
const result1 = await api.importTransactions(accountId, [
|
||||
{
|
||||
date: '2023-11-03',
|
||||
imported_id: 'reimport-test-1',
|
||||
amount: 100,
|
||||
account: accountId,
|
||||
},
|
||||
]);
|
||||
expect(result1.added).toHaveLength(1);
|
||||
|
||||
// Delete the transaction
|
||||
await api.deleteTransaction(result1.added[0]);
|
||||
|
||||
// Reimport the same transaction with reimportDeleted=false
|
||||
const result2 = await api.importTransactions(
|
||||
accountId,
|
||||
[
|
||||
{
|
||||
date: '2023-11-03',
|
||||
imported_id: 'reimport-test-1',
|
||||
amount: 100,
|
||||
account: accountId,
|
||||
},
|
||||
],
|
||||
{ reimportDeleted: false },
|
||||
);
|
||||
|
||||
// Should match the deleted transaction and not create a new one
|
||||
expect(result2.added).toHaveLength(0);
|
||||
expect(result2.updated).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Transactions: reimportDeleted=true reimports deleted transactions', async () => {
|
||||
const accountId = await api.createAccount({ name: 'test-account' }, 0);
|
||||
|
||||
// Import a transaction
|
||||
const result1 = await api.importTransactions(accountId, [
|
||||
{
|
||||
date: '2023-11-03',
|
||||
imported_id: 'reimport-test-2',
|
||||
amount: 200,
|
||||
account: accountId,
|
||||
},
|
||||
]);
|
||||
expect(result1.added).toHaveLength(1);
|
||||
|
||||
// Delete the transaction
|
||||
await api.deleteTransaction(result1.added[0]);
|
||||
|
||||
// Reimport the same transaction relying on reimportDeleted=true default
|
||||
const result2 = await api.importTransactions(accountId, [
|
||||
{
|
||||
date: '2023-11-03',
|
||||
imported_id: 'reimport-test-2',
|
||||
amount: 200,
|
||||
account: accountId,
|
||||
},
|
||||
]);
|
||||
|
||||
// Should create a new transaction since deleted ones are ignored
|
||||
expect(result2.added).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
//apis: createSchedule, getSchedules, updateSchedule, deleteSchedule
|
||||
|
||||
@@ -8,24 +8,23 @@
|
||||
"dist"
|
||||
],
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/browser.js",
|
||||
"types": "@types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"browser": {
|
||||
"types": "./@types/index.d.ts",
|
||||
"default": "./dist/browser.js"
|
||||
},
|
||||
"default": {
|
||||
"development": "./index.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./@types/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "yarn build:node && yarn build:browser",
|
||||
"build:node": "vite build",
|
||||
"build:browser": "vite build --config vite.browser.config.ts",
|
||||
"build": "vite build",
|
||||
"test": "vitest --run",
|
||||
"typecheck": "tsgo -b && tsc-strict"
|
||||
},
|
||||
@@ -39,7 +38,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
|
||||
"rollup-plugin-visualizer": "^6.0.5",
|
||||
"rollup-plugin-visualizer": "^6.0.11",
|
||||
"typescript-strict-plugin": "^2.4.4",
|
||||
"vite": "^8.0.0",
|
||||
"vite-plugin-dts": "^4.5.4",
|
||||
|
||||
@@ -18,12 +18,5 @@
|
||||
},
|
||||
"references": [{ "path": "../crdt" }, { "path": "../loot-core" }],
|
||||
"include": ["."],
|
||||
"exclude": [
|
||||
"**/node_modules/*",
|
||||
"dist",
|
||||
"@types",
|
||||
"*.test.ts",
|
||||
"*.config.ts",
|
||||
"*.browser.config.ts"
|
||||
]
|
||||
"exclude": ["**/node_modules/*", "dist", "@types", "*.test.ts", "*.config.ts"]
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import path from 'path';
|
||||
|
||||
import { defineConfig } from 'vite';
|
||||
import peggyLoader from 'vite-plugin-peggy-loader';
|
||||
|
||||
const distDir = path.resolve(__dirname, 'dist');
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
target: 'esnext',
|
||||
outDir: distDir,
|
||||
emptyOutDir: false,
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
entry: path.resolve(__dirname, 'index.web.ts'),
|
||||
formats: ['es'],
|
||||
fileName: () => 'browser.js',
|
||||
},
|
||||
},
|
||||
plugins: [peggyLoader()],
|
||||
resolve: {
|
||||
// Default extensions — picks up browser implementations (index.ts)
|
||||
// instead of .api.ts (which resolves to Node.js/Electron code)
|
||||
extensions: ['.js', '.ts', '.tsx', '.json'],
|
||||
},
|
||||
});
|
||||
44
packages/ci-actions/bin/get-next-package-version.js → packages/ci-actions/bin/get-next-package-version.ts
Executable file → Normal file
44
packages/ci-actions/bin/get-next-package-version.js → packages/ci-actions/bin/get-next-package-version.ts
Executable file → Normal file
@@ -2,13 +2,13 @@
|
||||
|
||||
// This script is used in GitHub Actions to get the next version based on the current package.json version.
|
||||
// It supports three types of versioning: nightly, hotfix, and monthly.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import { parseArgs } from 'node:util';
|
||||
|
||||
import { getNextVersion } from '../src/versions/get-next-package-version.js';
|
||||
|
||||
const args = process.argv;
|
||||
import {
|
||||
getNextVersion,
|
||||
isValidVersionType,
|
||||
} from '../src/versions/get-next-package-version';
|
||||
|
||||
const options = {
|
||||
'package-json': {
|
||||
@@ -28,40 +28,53 @@ const options = {
|
||||
short: 'u',
|
||||
default: false,
|
||||
},
|
||||
};
|
||||
} as const;
|
||||
|
||||
function fail(message: string): never {
|
||||
console.error(message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { values } = parseArgs({
|
||||
args,
|
||||
options,
|
||||
allowPositionals: true,
|
||||
});
|
||||
|
||||
if (!values['package-json']) {
|
||||
console.error(
|
||||
const packageJsonPath = values['package-json'];
|
||||
if (!packageJsonPath) {
|
||||
fail(
|
||||
'Please specify the path to package.json using --package-json or -p option.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const packageJsonPath = values['package-json'];
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
|
||||
if (!('version' in packageJson) || typeof packageJson.version !== 'string') {
|
||||
fail('The specified package.json does not contain a valid version field.');
|
||||
}
|
||||
|
||||
const currentVersion = packageJson.version;
|
||||
|
||||
const explicitVersion = values.version;
|
||||
let newVersion;
|
||||
|
||||
if (explicitVersion) {
|
||||
newVersion = explicitVersion;
|
||||
} else {
|
||||
const type = values.type;
|
||||
if (!type || !isValidVersionType(type)) {
|
||||
fail('Please specify the release type using --type or -t.');
|
||||
}
|
||||
|
||||
try {
|
||||
newVersion = getNextVersion({
|
||||
currentVersion,
|
||||
type: values.type,
|
||||
type,
|
||||
currentDate: new Date(),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e.message);
|
||||
process.exit(1);
|
||||
} catch (error) {
|
||||
fail(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +89,5 @@ try {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error.message);
|
||||
process.exit(1);
|
||||
fail(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
8
packages/ci-actions/bin/tsx
Executable file
8
packages/ci-actions/bin/tsx
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
cd ../../
|
||||
|
||||
script="$1"
|
||||
shift
|
||||
exec node --import=extensionless/register --experimental-strip-types packages/ci-actions/"$script" "$@"
|
||||
@@ -3,7 +3,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"tsx": "node --import=extensionless/register --experimental-strip-types",
|
||||
"tsx": "bin/tsx",
|
||||
"test": "vitest --run",
|
||||
"typecheck": "tsgo -b"
|
||||
},
|
||||
|
||||
@@ -77,7 +77,7 @@ describe('getNextVersion (lib)', () => {
|
||||
expect(() =>
|
||||
getNextVersion({
|
||||
currentVersion: '25.8.4',
|
||||
type: 'unknown',
|
||||
type: 'unknown' as never,
|
||||
currentDate: new Date('2025-08-10'),
|
||||
}),
|
||||
).toThrow(/Invalid type/);
|
||||
@@ -1,35 +1,69 @@
|
||||
function parseVersion(version) {
|
||||
export const versionTypeArray = [
|
||||
'auto',
|
||||
'hotfix',
|
||||
'monthly',
|
||||
'nightly',
|
||||
] as const;
|
||||
export type VersionType = (typeof versionTypeArray)[number];
|
||||
|
||||
type ParsedVersion = {
|
||||
versionYear: number;
|
||||
versionMonth: number;
|
||||
versionHotfix: number;
|
||||
};
|
||||
|
||||
type GetNextVersionOptions = {
|
||||
currentVersion: string;
|
||||
type: VersionType;
|
||||
currentDate?: Date;
|
||||
};
|
||||
|
||||
function parseVersion(version: string): ParsedVersion {
|
||||
const [y, m, p] = version.split('.');
|
||||
return {
|
||||
versionYear: parseInt(y, 10),
|
||||
versionMonth: parseInt(m, 10),
|
||||
versionHotfix: parseInt(p, 10),
|
||||
versionYear: Number.parseInt(y, 10),
|
||||
versionMonth: Number.parseInt(m, 10),
|
||||
versionHotfix: Number.parseInt(p, 10),
|
||||
};
|
||||
}
|
||||
|
||||
function computeNextMonth(versionYear, versionMonth) {
|
||||
// Create date and add 1 month
|
||||
const versionDate = new Date(2000 + versionYear, versionMonth - 1, 1); // month is 0-indexed
|
||||
function computeNextMonth(versionYear: number, versionMonth: number) {
|
||||
const versionDate = new Date(2000 + versionYear, versionMonth - 1, 1);
|
||||
const nextVersionMonthDate = new Date(
|
||||
versionDate.getFullYear(),
|
||||
versionDate.getMonth() + 1,
|
||||
1,
|
||||
);
|
||||
|
||||
// Format back to YY.M format
|
||||
const fullYear = nextVersionMonthDate.getFullYear();
|
||||
const nextVersionYear = fullYear.toString().slice(fullYear < 2100 ? -2 : -3);
|
||||
const nextVersionMonth = nextVersionMonthDate.getMonth() + 1; // Convert back to 1-indexed
|
||||
const nextVersionMonth = nextVersionMonthDate.getMonth() + 1;
|
||||
|
||||
return { nextVersionYear, nextVersionMonth };
|
||||
}
|
||||
|
||||
// Determine logical type from 'auto' based on the current date and version
|
||||
function resolveType(type, currentDate, versionYear, versionMonth) {
|
||||
if (type !== 'auto') return type;
|
||||
export function isValidVersionType(value: string): value is VersionType {
|
||||
return versionTypeArray.includes(value as VersionType);
|
||||
}
|
||||
|
||||
function resolveType(
|
||||
type: VersionType,
|
||||
currentDate: Date,
|
||||
versionYear: number,
|
||||
versionMonth: number,
|
||||
) {
|
||||
if (type !== 'auto') {
|
||||
return type;
|
||||
}
|
||||
|
||||
const inPatchMonth =
|
||||
currentDate.getFullYear() === 2000 + versionYear &&
|
||||
currentDate.getMonth() + 1 === versionMonth;
|
||||
if (inPatchMonth && currentDate.getDate() <= 25) return 'hotfix';
|
||||
|
||||
if (inPatchMonth && currentDate.getDate() <= 25) {
|
||||
return 'hotfix';
|
||||
}
|
||||
|
||||
return 'monthly';
|
||||
}
|
||||
|
||||
@@ -37,7 +71,7 @@ export function getNextVersion({
|
||||
currentVersion,
|
||||
type,
|
||||
currentDate = new Date(),
|
||||
}) {
|
||||
}: GetNextVersionOptions) {
|
||||
const { versionYear, versionMonth, versionHotfix } =
|
||||
parseVersion(currentVersion);
|
||||
const { nextVersionYear, nextVersionMonth } = computeNextMonth(
|
||||
@@ -51,11 +85,10 @@ export function getNextVersion({
|
||||
versionMonth,
|
||||
);
|
||||
|
||||
// Format date stamp once for nightly
|
||||
const currentDateString = currentDate
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
.replaceAll('-', '');
|
||||
.replace(/-/g, '');
|
||||
|
||||
switch (resolvedType) {
|
||||
case 'nightly':
|
||||
@@ -66,7 +99,7 @@ export function getNextVersion({
|
||||
return `${nextVersionYear}.${nextVersionMonth}.0`;
|
||||
default:
|
||||
throw new Error(
|
||||
'Invalid type specified. Use "auto", "nightly", "hotfix", or "monthly".',
|
||||
`Invalid type ${String(resolvedType satisfies never)} specified. Use "auto", "nightly", "hotfix", or "monthly".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [],
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"module": "es2022",
|
||||
"moduleResolution": "bundler",
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"types": ["node"],
|
||||
|
||||
7
packages/cli/.gitignore
vendored
Normal file
7
packages/cli/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
dist
|
||||
coverage
|
||||
.actualrc.json
|
||||
.actualrc
|
||||
.actualrc.yaml
|
||||
.actualrc.yml
|
||||
actual.config.js
|
||||
155
packages/cli/README.md
Normal file
155
packages/cli/README.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# @actual-app/cli
|
||||
|
||||
> **WARNING:** This CLI is experimental.
|
||||
|
||||
Command-line interface for [Actual Budget](https://actualbudget.org). Query and modify your budget data from the terminal — accounts, transactions, categories, payees, rules, schedules, and more.
|
||||
|
||||
> **Note:** This CLI connects to a running [Actual sync server](https://actualbudget.org/docs/install/). It does not operate on local budget files directly.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install -g @actual-app/cli
|
||||
```
|
||||
|
||||
Requires Node.js >= 22.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Set connection details
|
||||
export ACTUAL_SERVER_URL=http://localhost:5006
|
||||
export ACTUAL_PASSWORD=your-password
|
||||
export ACTUAL_SYNC_ID=your-sync-id # Found in Settings → Advanced → Sync ID
|
||||
|
||||
# List your accounts
|
||||
actual accounts list
|
||||
|
||||
# Check a balance
|
||||
actual accounts balance <account-id>
|
||||
|
||||
# View this month's budget
|
||||
actual budgets month 2026-03
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration is resolved in this order (highest priority first):
|
||||
|
||||
1. **CLI flags** (`--server-url`, `--password`, etc.)
|
||||
2. **Environment variables**
|
||||
3. **Config file** (via [cosmiconfig](https://github.com/cosmiconfig/cosmiconfig))
|
||||
4. **Defaults** (`dataDir` defaults to `~/.actual-cli/data`)
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
| ---------------------- | --------------------------------------------- |
|
||||
| `ACTUAL_SERVER_URL` | URL of the Actual sync server (required) |
|
||||
| `ACTUAL_PASSWORD` | Server password (required unless using token) |
|
||||
| `ACTUAL_SESSION_TOKEN` | Session token (alternative to password) |
|
||||
| `ACTUAL_SYNC_ID` | Budget Sync ID (required for most commands) |
|
||||
| `ACTUAL_DATA_DIR` | Local directory for cached budget data |
|
||||
|
||||
### Config File
|
||||
|
||||
Create an `.actualrc.json` (or `.actualrc`, `.actualrc.yaml`, `actual.config.js`):
|
||||
|
||||
```json
|
||||
{
|
||||
"serverUrl": "http://localhost:5006",
|
||||
"password": "your-password",
|
||||
"syncId": "1cfdbb80-6274-49bf-b0c2-737235a4c81f"
|
||||
}
|
||||
```
|
||||
|
||||
**Security:** Do not store plaintext passwords in config files (e.g. `.actualrc.json`, `.actualrc`, `.actualrc.yaml`, `actual.config.js`). Add these files to `.gitignore` if they contain secrets. Prefer the `ACTUAL_SESSION_TOKEN` environment variable instead of the `password` field. See [Environment Variables](#environment-variables) for using a session token.
|
||||
|
||||
### Global Flags
|
||||
|
||||
| Flag | Description |
|
||||
| ------------------------- | ----------------------------------------------- |
|
||||
| `--server-url <url>` | Server URL |
|
||||
| `--password <pw>` | Server password |
|
||||
| `--session-token <token>` | Session token |
|
||||
| `--sync-id <id>` | Budget Sync ID |
|
||||
| `--data-dir <path>` | Data directory |
|
||||
| `--format <format>` | Output format: `json` (default), `table`, `csv` |
|
||||
| `--verbose` | Show informational messages |
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
| ----------------- | ------------------------------ |
|
||||
| `accounts` | Manage accounts |
|
||||
| `budgets` | Manage budgets and allocations |
|
||||
| `categories` | Manage categories |
|
||||
| `category-groups` | Manage category groups |
|
||||
| `transactions` | Manage transactions |
|
||||
| `payees` | Manage payees |
|
||||
| `tags` | Manage tags |
|
||||
| `rules` | Manage transaction rules |
|
||||
| `schedules` | Manage scheduled transactions |
|
||||
| `query` | Run an ActualQL query |
|
||||
| `server` | Server utilities and lookups |
|
||||
|
||||
Run `actual <command> --help` for subcommands and options.
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
# List all accounts (as a table)
|
||||
actual accounts list --format table
|
||||
|
||||
# Find an entity ID by name
|
||||
actual server get-id --type accounts --name "Checking"
|
||||
|
||||
# Add a transaction (amount in integer cents: -2500 = -$25.00)
|
||||
actual transactions add --account <id> \
|
||||
--data '[{"date":"2026-03-14","amount":-2500,"payee_name":"Coffee Shop"}]'
|
||||
|
||||
# Export transactions to CSV
|
||||
actual transactions list --account <id> \
|
||||
--start 2026-01-01 --end 2026-12-31 --format csv > transactions.csv
|
||||
|
||||
# Set budget amount ($500 = 50000 cents)
|
||||
actual budgets set-amount --month 2026-03 --category <id> --amount 50000
|
||||
|
||||
# Run an ActualQL query
|
||||
actual query run --table transactions \
|
||||
--select "date,amount,payee" --filter '{"amount":{"$lt":0}}' --limit 10
|
||||
```
|
||||
|
||||
### Amount Convention
|
||||
|
||||
All monetary amounts are **integer cents**:
|
||||
|
||||
| CLI Value | Dollar Amount |
|
||||
| --------- | ------------- |
|
||||
| `5000` | $50.00 |
|
||||
| `-12350` | -$123.50 |
|
||||
|
||||
## Running Locally (Development)
|
||||
|
||||
If you're working on the CLI within the monorepo:
|
||||
|
||||
```bash
|
||||
# 1. Build the CLI
|
||||
yarn build:cli
|
||||
|
||||
# 2. Start a local sync server (in a separate terminal)
|
||||
yarn start:server-dev
|
||||
|
||||
# 3. Open http://localhost:5006 in your browser, create a budget,
|
||||
# then find the Sync ID in Settings → Advanced → Sync ID
|
||||
|
||||
# 4. Run the CLI directly from the build output
|
||||
ACTUAL_SERVER_URL=http://localhost:5006 \
|
||||
ACTUAL_PASSWORD=your-password \
|
||||
ACTUAL_SYNC_ID=your-sync-id \
|
||||
node packages/cli/dist/cli.js accounts list
|
||||
|
||||
# Or use a shorthand alias for convenience
|
||||
alias actual-dev="node $(pwd)/packages/cli/dist/cli.js"
|
||||
actual-dev budgets list
|
||||
```
|
||||
35
packages/cli/package.json
Normal file
35
packages/cli/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@actual-app/cli",
|
||||
"version": "26.3.0",
|
||||
"description": "CLI for Actual Budget",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"actual": "./dist/cli.js",
|
||||
"actual-cli": "./dist/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest --run",
|
||||
"typecheck": "tsgo -b"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actual-app/api": "workspace:*",
|
||||
"cli-table3": "^0.6.5",
|
||||
"commander": "^13.0.0",
|
||||
"cosmiconfig": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.19.15",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
|
||||
"rollup-plugin-visualizer": "^6.0.11",
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
259
packages/cli/src/commands/accounts.test.ts
Normal file
259
packages/cli/src/commands/accounts.test.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import * as api from '@actual-app/api';
|
||||
import { Command } from 'commander';
|
||||
|
||||
import { printOutput } from '../output';
|
||||
|
||||
import { registerAccountsCommand } from './accounts';
|
||||
|
||||
vi.mock('@actual-app/api', () => ({
|
||||
getAccounts: vi.fn().mockResolvedValue([]),
|
||||
createAccount: vi.fn().mockResolvedValue('new-id'),
|
||||
updateAccount: vi.fn().mockResolvedValue(undefined),
|
||||
closeAccount: vi.fn().mockResolvedValue(undefined),
|
||||
reopenAccount: vi.fn().mockResolvedValue(undefined),
|
||||
deleteAccount: vi.fn().mockResolvedValue(undefined),
|
||||
getAccountBalance: vi.fn().mockResolvedValue(10000),
|
||||
}));
|
||||
|
||||
vi.mock('../connection', () => ({
|
||||
withConnection: vi.fn((_opts, fn) => fn()),
|
||||
}));
|
||||
|
||||
vi.mock('../output', () => ({
|
||||
printOutput: vi.fn(),
|
||||
}));
|
||||
|
||||
function createProgram(): Command {
|
||||
const program = new Command();
|
||||
program.option('--format <format>');
|
||||
program.option('--server-url <url>');
|
||||
program.option('--password <pw>');
|
||||
program.option('--session-token <token>');
|
||||
program.option('--sync-id <id>');
|
||||
program.option('--data-dir <dir>');
|
||||
program.option('--verbose');
|
||||
program.exitOverride();
|
||||
registerAccountsCommand(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
async function run(args: string[]) {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', ...args]);
|
||||
}
|
||||
|
||||
describe('accounts commands', () => {
|
||||
let stderrSpy: ReturnType<typeof vi.spyOn>;
|
||||
let stdoutSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
stderrSpy = vi
|
||||
.spyOn(process.stderr, 'write')
|
||||
.mockImplementation(() => true);
|
||||
stdoutSpy = vi
|
||||
.spyOn(process.stdout, 'write')
|
||||
.mockImplementation(() => true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stderrSpy.mockRestore();
|
||||
stdoutSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('calls api.getAccounts and prints result', async () => {
|
||||
const accounts = [{ id: '1', name: 'Checking' }];
|
||||
vi.mocked(api.getAccounts).mockResolvedValue(accounts);
|
||||
|
||||
await run(['accounts', 'list']);
|
||||
|
||||
expect(api.getAccounts).toHaveBeenCalled();
|
||||
expect(printOutput).toHaveBeenCalledWith(accounts, undefined);
|
||||
});
|
||||
|
||||
it('passes format option to printOutput', async () => {
|
||||
vi.mocked(api.getAccounts).mockResolvedValue([]);
|
||||
|
||||
await run(['--format', 'csv', 'accounts', 'list']);
|
||||
|
||||
expect(printOutput).toHaveBeenCalledWith([], 'csv');
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('passes name and defaults to api.createAccount', async () => {
|
||||
await run(['accounts', 'create', '--name', 'Savings']);
|
||||
|
||||
expect(api.createAccount).toHaveBeenCalledWith(
|
||||
{ name: 'Savings', offbudget: false },
|
||||
0,
|
||||
);
|
||||
expect(printOutput).toHaveBeenCalledWith({ id: 'new-id' }, undefined);
|
||||
});
|
||||
|
||||
it('passes offbudget and balance options', async () => {
|
||||
await run([
|
||||
'accounts',
|
||||
'create',
|
||||
'--name',
|
||||
'Investments',
|
||||
'--offbudget',
|
||||
'--balance',
|
||||
'50000',
|
||||
]);
|
||||
|
||||
expect(api.createAccount).toHaveBeenCalledWith(
|
||||
{ name: 'Investments', offbudget: true },
|
||||
50000,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('passes fields to api.updateAccount', async () => {
|
||||
await run(['accounts', 'update', 'acct-1', '--name', 'NewName']);
|
||||
|
||||
expect(api.updateAccount).toHaveBeenCalledWith('acct-1', {
|
||||
name: 'NewName',
|
||||
});
|
||||
expect(printOutput).toHaveBeenCalledWith(
|
||||
{ success: true, id: 'acct-1' },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('passes offbudget true', async () => {
|
||||
await run([
|
||||
'accounts',
|
||||
'update',
|
||||
'acct-1',
|
||||
'--name',
|
||||
'X',
|
||||
'--offbudget',
|
||||
'true',
|
||||
]);
|
||||
|
||||
expect(api.updateAccount).toHaveBeenCalledWith('acct-1', {
|
||||
name: 'X',
|
||||
offbudget: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes offbudget false', async () => {
|
||||
await run([
|
||||
'accounts',
|
||||
'update',
|
||||
'acct-1',
|
||||
'--name',
|
||||
'X',
|
||||
'--offbudget',
|
||||
'false',
|
||||
]);
|
||||
|
||||
expect(api.updateAccount).toHaveBeenCalledWith('acct-1', {
|
||||
name: 'X',
|
||||
offbudget: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid offbudget value', async () => {
|
||||
await expect(
|
||||
run(['accounts', 'update', 'acct-1', '--offbudget', 'yes']),
|
||||
).rejects.toThrow(
|
||||
'Invalid --offbudget: "yes". Expected "true" or "false".',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects empty name', async () => {
|
||||
await expect(
|
||||
run(['accounts', 'update', 'acct-1', '--name', ' ']),
|
||||
).rejects.toThrow('Invalid --name: must be a non-empty string.');
|
||||
});
|
||||
|
||||
it('rejects update with no fields', async () => {
|
||||
await expect(run(['accounts', 'update', 'acct-1'])).rejects.toThrow(
|
||||
'No update fields provided. Use --name or --offbudget.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('close', () => {
|
||||
it('passes transfer options to api.closeAccount', async () => {
|
||||
await run([
|
||||
'accounts',
|
||||
'close',
|
||||
'acct-1',
|
||||
'--transfer-account',
|
||||
'acct-2',
|
||||
]);
|
||||
|
||||
expect(api.closeAccount).toHaveBeenCalledWith(
|
||||
'acct-1',
|
||||
'acct-2',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('passes transfer category', async () => {
|
||||
await run([
|
||||
'accounts',
|
||||
'close',
|
||||
'acct-1',
|
||||
'--transfer-category',
|
||||
'cat-1',
|
||||
]);
|
||||
|
||||
expect(api.closeAccount).toHaveBeenCalledWith(
|
||||
'acct-1',
|
||||
undefined,
|
||||
'cat-1',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reopen', () => {
|
||||
it('calls api.reopenAccount', async () => {
|
||||
await run(['accounts', 'reopen', 'acct-1']);
|
||||
|
||||
expect(api.reopenAccount).toHaveBeenCalledWith('acct-1');
|
||||
expect(printOutput).toHaveBeenCalledWith(
|
||||
{ success: true, id: 'acct-1' },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('calls api.deleteAccount', async () => {
|
||||
await run(['accounts', 'delete', 'acct-1']);
|
||||
|
||||
expect(api.deleteAccount).toHaveBeenCalledWith('acct-1');
|
||||
expect(printOutput).toHaveBeenCalledWith(
|
||||
{ success: true, id: 'acct-1' },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('balance', () => {
|
||||
it('calls api.getAccountBalance without cutoff', async () => {
|
||||
await run(['accounts', 'balance', 'acct-1']);
|
||||
|
||||
expect(api.getAccountBalance).toHaveBeenCalledWith('acct-1', undefined);
|
||||
expect(printOutput).toHaveBeenCalledWith(
|
||||
{ id: 'acct-1', balance: 10000 },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('calls api.getAccountBalance with cutoff date', async () => {
|
||||
await run(['accounts', 'balance', 'acct-1', '--cutoff', '2025-01-15']);
|
||||
|
||||
expect(api.getAccountBalance).toHaveBeenCalledWith(
|
||||
'acct-1',
|
||||
new Date('2025-01-15'),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
135
packages/cli/src/commands/accounts.ts
Normal file
135
packages/cli/src/commands/accounts.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import * as api from '@actual-app/api';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { withConnection } from '../connection';
|
||||
import { printOutput } from '../output';
|
||||
import { parseBoolFlag, parseIntFlag } from '../utils';
|
||||
|
||||
export function registerAccountsCommand(program: Command) {
|
||||
const accounts = program.command('accounts').description('Manage accounts');
|
||||
|
||||
accounts
|
||||
.command('list')
|
||||
.description('List all accounts')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getAccounts();
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
accounts
|
||||
.command('create')
|
||||
.description('Create a new account')
|
||||
.requiredOption('--name <name>', 'Account name')
|
||||
.option('--offbudget', 'Create as off-budget account', false)
|
||||
.option('--balance <amount>', 'Initial balance in cents', '0')
|
||||
.action(async cmdOpts => {
|
||||
const balance = parseIntFlag(cmdOpts.balance, '--balance');
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const id = await api.createAccount(
|
||||
{ name: cmdOpts.name, offbudget: cmdOpts.offbudget },
|
||||
balance,
|
||||
);
|
||||
printOutput({ id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
accounts
|
||||
.command('update <id>')
|
||||
.description('Update an account')
|
||||
.option('--name <name>', 'New account name')
|
||||
.option('--offbudget <bool>', 'Set off-budget status')
|
||||
.action(async (id: string, cmdOpts) => {
|
||||
const opts = program.opts();
|
||||
const fields: Record<string, unknown> = {};
|
||||
if (cmdOpts.name !== undefined) {
|
||||
const trimmed = cmdOpts.name.trim();
|
||||
if (trimmed === '') {
|
||||
throw new Error('Invalid --name: must be a non-empty string.');
|
||||
}
|
||||
fields.name = trimmed;
|
||||
}
|
||||
if (cmdOpts.offbudget !== undefined) {
|
||||
fields.offbudget = parseBoolFlag(cmdOpts.offbudget, '--offbudget');
|
||||
}
|
||||
if (Object.keys(fields).length === 0) {
|
||||
throw new Error(
|
||||
'No update fields provided. Use --name or --offbudget.',
|
||||
);
|
||||
}
|
||||
await withConnection(opts, async () => {
|
||||
await api.updateAccount(id, fields);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
accounts
|
||||
.command('close <id>')
|
||||
.description('Close an account')
|
||||
.option(
|
||||
'--transfer-account <id>',
|
||||
'Transfer remaining balance to this account',
|
||||
)
|
||||
.option(
|
||||
'--transfer-category <id>',
|
||||
'Transfer remaining balance to this category',
|
||||
)
|
||||
.action(async (id: string, cmdOpts) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.closeAccount(
|
||||
id,
|
||||
cmdOpts.transferAccount,
|
||||
cmdOpts.transferCategory,
|
||||
);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
accounts
|
||||
.command('reopen <id>')
|
||||
.description('Reopen a closed account')
|
||||
.action(async (id: string) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.reopenAccount(id);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
accounts
|
||||
.command('delete <id>')
|
||||
.description('Delete an account')
|
||||
.action(async (id: string) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.deleteAccount(id);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
accounts
|
||||
.command('balance <id>')
|
||||
.description('Get account balance')
|
||||
.option('--cutoff <date>', 'Cutoff date (YYYY-MM-DD)')
|
||||
.action(async (id: string, cmdOpts) => {
|
||||
let cutoff: Date | undefined;
|
||||
if (cmdOpts.cutoff) {
|
||||
const cutoffDate = new Date(cmdOpts.cutoff);
|
||||
if (Number.isNaN(cutoffDate.getTime())) {
|
||||
throw new Error(
|
||||
'Invalid cutoff date: expected a valid date (e.g. YYYY-MM-DD).',
|
||||
);
|
||||
}
|
||||
cutoff = cutoffDate;
|
||||
}
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const balance = await api.getAccountBalance(id, cutoff);
|
||||
printOutput({ id, balance }, opts.format);
|
||||
});
|
||||
});
|
||||
}
|
||||
135
packages/cli/src/commands/budgets.ts
Normal file
135
packages/cli/src/commands/budgets.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import * as api from '@actual-app/api';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { resolveConfig } from '../config';
|
||||
import { withConnection } from '../connection';
|
||||
import { printOutput } from '../output';
|
||||
import { parseBoolFlag, parseIntFlag } from '../utils';
|
||||
|
||||
export function registerBudgetsCommand(program: Command) {
|
||||
const budgets = program.command('budgets').description('Manage budgets');
|
||||
|
||||
budgets
|
||||
.command('list')
|
||||
.description('List all available budgets')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const result = await api.getBudgets();
|
||||
printOutput(result, opts.format);
|
||||
},
|
||||
{ loadBudget: false },
|
||||
);
|
||||
});
|
||||
|
||||
budgets
|
||||
.command('download <syncId>')
|
||||
.description('Download a budget by sync ID')
|
||||
.option('--encryption-password <password>', 'Encryption password')
|
||||
.action(async (syncId: string, cmdOpts) => {
|
||||
const opts = program.opts();
|
||||
const config = await resolveConfig(opts);
|
||||
const password = config.encryptionPassword ?? cmdOpts.encryptionPassword;
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.downloadBudget(syncId, {
|
||||
password,
|
||||
});
|
||||
printOutput({ success: true, syncId }, opts.format);
|
||||
},
|
||||
{ loadBudget: false },
|
||||
);
|
||||
});
|
||||
|
||||
budgets
|
||||
.command('sync')
|
||||
.description('Sync the current budget')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.sync();
|
||||
printOutput({ success: true }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
budgets
|
||||
.command('months')
|
||||
.description('List available budget months')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getBudgetMonths();
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
budgets
|
||||
.command('month <month>')
|
||||
.description('Get budget data for a specific month (YYYY-MM)')
|
||||
.action(async (month: string) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getBudgetMonth(month);
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
budgets
|
||||
.command('set-amount')
|
||||
.description('Set budget amount for a category in a month')
|
||||
.requiredOption('--month <month>', 'Budget month (YYYY-MM)')
|
||||
.requiredOption('--category <id>', 'Category ID')
|
||||
.requiredOption('--amount <amount>', 'Amount in cents')
|
||||
.action(async cmdOpts => {
|
||||
const amount = parseIntFlag(cmdOpts.amount, '--amount');
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.setBudgetAmount(cmdOpts.month, cmdOpts.category, amount);
|
||||
printOutput({ success: true }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
budgets
|
||||
.command('set-carryover')
|
||||
.description('Enable/disable carryover for a category')
|
||||
.requiredOption('--month <month>', 'Budget month (YYYY-MM)')
|
||||
.requiredOption('--category <id>', 'Category ID')
|
||||
.requiredOption('--flag <bool>', 'Enable (true) or disable (false)')
|
||||
.action(async cmdOpts => {
|
||||
const flag = parseBoolFlag(cmdOpts.flag, '--flag');
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.setBudgetCarryover(cmdOpts.month, cmdOpts.category, flag);
|
||||
printOutput({ success: true }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
budgets
|
||||
.command('hold-next-month')
|
||||
.description('Hold budget amount for next month')
|
||||
.requiredOption('--month <month>', 'Budget month (YYYY-MM)')
|
||||
.requiredOption('--amount <amount>', 'Amount in cents')
|
||||
.action(async cmdOpts => {
|
||||
const parsedAmount = parseIntFlag(cmdOpts.amount, '--amount');
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.holdBudgetForNextMonth(cmdOpts.month, parsedAmount);
|
||||
printOutput({ success: true }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
budgets
|
||||
.command('reset-hold')
|
||||
.description('Reset budget hold for a month')
|
||||
.requiredOption('--month <month>', 'Budget month (YYYY-MM)')
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.resetBudgetHold(cmdOpts.month);
|
||||
printOutput({ success: true }, opts.format);
|
||||
});
|
||||
});
|
||||
}
|
||||
75
packages/cli/src/commands/categories.ts
Normal file
75
packages/cli/src/commands/categories.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import * as api from '@actual-app/api';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { withConnection } from '../connection';
|
||||
import { printOutput } from '../output';
|
||||
import { parseBoolFlag } from '../utils';
|
||||
|
||||
export function registerCategoriesCommand(program: Command) {
|
||||
const categories = program
|
||||
.command('categories')
|
||||
.description('Manage categories');
|
||||
|
||||
categories
|
||||
.command('list')
|
||||
.description('List all categories')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getCategories();
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
categories
|
||||
.command('create')
|
||||
.description('Create a new category')
|
||||
.requiredOption('--name <name>', 'Category name')
|
||||
.requiredOption('--group-id <id>', 'Category group ID')
|
||||
.option('--is-income', 'Mark as income category', false)
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const id = await api.createCategory({
|
||||
name: cmdOpts.name,
|
||||
group_id: cmdOpts.groupId,
|
||||
is_income: cmdOpts.isIncome,
|
||||
hidden: false,
|
||||
});
|
||||
printOutput({ id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
categories
|
||||
.command('update <id>')
|
||||
.description('Update a category')
|
||||
.option('--name <name>', 'New category name')
|
||||
.option('--hidden <bool>', 'Set hidden status')
|
||||
.action(async (id: string, cmdOpts) => {
|
||||
const fields: Record<string, unknown> = {};
|
||||
if (cmdOpts.name !== undefined) fields.name = cmdOpts.name;
|
||||
if (cmdOpts.hidden !== undefined) {
|
||||
fields.hidden = parseBoolFlag(cmdOpts.hidden, '--hidden');
|
||||
}
|
||||
if (Object.keys(fields).length === 0) {
|
||||
throw new Error('No update fields provided. Use --name or --hidden.');
|
||||
}
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.updateCategory(id, fields);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
categories
|
||||
.command('delete <id>')
|
||||
.description('Delete a category')
|
||||
.option('--transfer-to <id>', 'Transfer transactions to this category')
|
||||
.action(async (id: string, cmdOpts) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.deleteCategory(id, cmdOpts.transferTo);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
});
|
||||
}
|
||||
73
packages/cli/src/commands/category-groups.ts
Normal file
73
packages/cli/src/commands/category-groups.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import * as api from '@actual-app/api';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { withConnection } from '../connection';
|
||||
import { printOutput } from '../output';
|
||||
import { parseBoolFlag } from '../utils';
|
||||
|
||||
export function registerCategoryGroupsCommand(program: Command) {
|
||||
const groups = program
|
||||
.command('category-groups')
|
||||
.description('Manage category groups');
|
||||
|
||||
groups
|
||||
.command('list')
|
||||
.description('List all category groups')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getCategoryGroups();
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
groups
|
||||
.command('create')
|
||||
.description('Create a new category group')
|
||||
.requiredOption('--name <name>', 'Group name')
|
||||
.option('--is-income', 'Mark as income group', false)
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const id = await api.createCategoryGroup({
|
||||
name: cmdOpts.name,
|
||||
is_income: cmdOpts.isIncome,
|
||||
hidden: false,
|
||||
});
|
||||
printOutput({ id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
groups
|
||||
.command('update <id>')
|
||||
.description('Update a category group')
|
||||
.option('--name <name>', 'New group name')
|
||||
.option('--hidden <bool>', 'Set hidden status')
|
||||
.action(async (id: string, cmdOpts) => {
|
||||
const fields: Record<string, unknown> = {};
|
||||
if (cmdOpts.name !== undefined) fields.name = cmdOpts.name;
|
||||
if (cmdOpts.hidden !== undefined) {
|
||||
fields.hidden = parseBoolFlag(cmdOpts.hidden, '--hidden');
|
||||
}
|
||||
if (Object.keys(fields).length === 0) {
|
||||
throw new Error('No update fields provided. Use --name or --hidden.');
|
||||
}
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.updateCategoryGroup(id, fields);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
groups
|
||||
.command('delete <id>')
|
||||
.description('Delete a category group')
|
||||
.option('--transfer-to <id>', 'Transfer transactions to this category ID')
|
||||
.action(async (id: string, cmdOpts) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.deleteCategoryGroup(id, cmdOpts.transferTo);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
});
|
||||
}
|
||||
95
packages/cli/src/commands/payees.ts
Normal file
95
packages/cli/src/commands/payees.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import * as api from '@actual-app/api';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { withConnection } from '../connection';
|
||||
import { printOutput } from '../output';
|
||||
|
||||
export function registerPayeesCommand(program: Command) {
|
||||
const payees = program.command('payees').description('Manage payees');
|
||||
|
||||
payees
|
||||
.command('list')
|
||||
.description('List all payees')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getPayees();
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
payees
|
||||
.command('common')
|
||||
.description('List frequently used payees')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getCommonPayees();
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
payees
|
||||
.command('create')
|
||||
.description('Create a new payee')
|
||||
.requiredOption('--name <name>', 'Payee name')
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const id = await api.createPayee({ name: cmdOpts.name });
|
||||
printOutput({ id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
payees
|
||||
.command('update <id>')
|
||||
.description('Update a payee')
|
||||
.option('--name <name>', 'New payee name')
|
||||
.action(async (id: string, cmdOpts) => {
|
||||
const fields: Record<string, unknown> = {};
|
||||
if (cmdOpts.name) fields.name = cmdOpts.name;
|
||||
if (Object.keys(fields).length === 0) {
|
||||
throw new Error(
|
||||
'No fields to update. Use --name to specify a new name.',
|
||||
);
|
||||
}
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.updatePayee(id, fields);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
payees
|
||||
.command('delete <id>')
|
||||
.description('Delete a payee')
|
||||
.action(async (id: string) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.deletePayee(id);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
payees
|
||||
.command('merge')
|
||||
.description('Merge payees into a target payee')
|
||||
.requiredOption('--target <id>', 'Target payee ID')
|
||||
.requiredOption('--ids <ids>', 'Comma-separated payee IDs to merge')
|
||||
.action(async (cmdOpts: { target: string; ids: string }) => {
|
||||
const mergeIds = cmdOpts.ids
|
||||
.split(',')
|
||||
.map(id => id.trim())
|
||||
.filter(id => id.length > 0);
|
||||
if (mergeIds.length === 0) {
|
||||
throw new Error(
|
||||
'No valid payee IDs provided in --ids. Provide comma-separated IDs.',
|
||||
);
|
||||
}
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.mergePayees(cmdOpts.target, mergeIds);
|
||||
printOutput({ success: true }, opts.format);
|
||||
});
|
||||
});
|
||||
}
|
||||
93
packages/cli/src/commands/query.ts
Normal file
93
packages/cli/src/commands/query.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import * as api from '@actual-app/api';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { withConnection } from '../connection';
|
||||
import { readJsonInput } from '../input';
|
||||
import { printOutput } from '../output';
|
||||
import { parseIntFlag } from '../utils';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function buildQueryFromFile(
|
||||
parsed: Record<string, unknown>,
|
||||
fallbackTable: string | undefined,
|
||||
) {
|
||||
const table = typeof parsed.table === 'string' ? parsed.table : fallbackTable;
|
||||
if (!table) {
|
||||
throw new Error(
|
||||
'--table is required when the input file lacks a "table" field',
|
||||
);
|
||||
}
|
||||
let queryObj = api.q(table);
|
||||
if (Array.isArray(parsed.select)) queryObj = queryObj.select(parsed.select);
|
||||
if (isRecord(parsed.filter)) queryObj = queryObj.filter(parsed.filter);
|
||||
if (Array.isArray(parsed.orderBy)) {
|
||||
queryObj = queryObj.orderBy(parsed.orderBy);
|
||||
}
|
||||
if (typeof parsed.limit === 'number') queryObj = queryObj.limit(parsed.limit);
|
||||
return queryObj;
|
||||
}
|
||||
|
||||
function buildQueryFromFlags(cmdOpts: Record<string, string | undefined>) {
|
||||
if (!cmdOpts.table) {
|
||||
throw new Error('--table is required (or use --file)');
|
||||
}
|
||||
let queryObj = api.q(cmdOpts.table);
|
||||
|
||||
if (cmdOpts.select) {
|
||||
queryObj = queryObj.select(cmdOpts.select.split(','));
|
||||
}
|
||||
|
||||
if (cmdOpts.filter) {
|
||||
queryObj = queryObj.filter(JSON.parse(cmdOpts.filter));
|
||||
}
|
||||
|
||||
if (cmdOpts.orderBy) {
|
||||
queryObj = queryObj.orderBy(cmdOpts.orderBy.split(','));
|
||||
}
|
||||
|
||||
if (cmdOpts.limit) {
|
||||
queryObj = queryObj.limit(parseIntFlag(cmdOpts.limit, '--limit'));
|
||||
}
|
||||
|
||||
return queryObj;
|
||||
}
|
||||
|
||||
export function registerQueryCommand(program: Command) {
|
||||
const query = program
|
||||
.command('query')
|
||||
.description('Run AQL (Actual Query Language) queries');
|
||||
|
||||
query
|
||||
.command('run')
|
||||
.description('Execute an AQL query')
|
||||
.option(
|
||||
'--table <table>',
|
||||
'Table to query (transactions, accounts, categories, payees)',
|
||||
)
|
||||
.option('--select <fields>', 'Comma-separated fields to select')
|
||||
.option('--filter <json>', 'Filter expression as JSON')
|
||||
.option('--order-by <fields>', 'Comma-separated fields to order by')
|
||||
.option('--limit <n>', 'Limit number of results')
|
||||
.option(
|
||||
'--file <path>',
|
||||
'Read full query object from JSON file (use - for stdin)',
|
||||
)
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const parsed = cmdOpts.file ? readJsonInput(cmdOpts) : undefined;
|
||||
if (parsed !== undefined && !isRecord(parsed)) {
|
||||
throw new Error('Query file must contain a JSON object');
|
||||
}
|
||||
const queryObj = parsed
|
||||
? buildQueryFromFile(parsed, cmdOpts.table)
|
||||
: buildQueryFromFlags(cmdOpts);
|
||||
|
||||
const result = await api.aqlQuery(queryObj);
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
});
|
||||
}
|
||||
77
packages/cli/src/commands/rules.ts
Normal file
77
packages/cli/src/commands/rules.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import * as api from '@actual-app/api';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { withConnection } from '../connection';
|
||||
import { readJsonInput } from '../input';
|
||||
import { printOutput } from '../output';
|
||||
|
||||
export function registerRulesCommand(program: Command) {
|
||||
const rules = program
|
||||
.command('rules')
|
||||
.description('Manage transaction rules');
|
||||
|
||||
rules
|
||||
.command('list')
|
||||
.description('List all rules')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getRules();
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
rules
|
||||
.command('payee-rules <payeeId>')
|
||||
.description('List rules for a specific payee')
|
||||
.action(async (payeeId: string) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getPayeeRules(payeeId);
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
rules
|
||||
.command('create')
|
||||
.description('Create a new rule')
|
||||
.option('--data <json>', 'Rule definition as JSON')
|
||||
.option('--file <path>', 'Read rule from JSON file (use - for stdin)')
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const rule = readJsonInput(cmdOpts) as Parameters<
|
||||
typeof api.createRule
|
||||
>[0];
|
||||
const id = await api.createRule(rule);
|
||||
printOutput({ id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
rules
|
||||
.command('update')
|
||||
.description('Update a rule')
|
||||
.option('--data <json>', 'Rule data as JSON (must include id)')
|
||||
.option('--file <path>', 'Read rule from JSON file (use - for stdin)')
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const rule = readJsonInput(cmdOpts) as Parameters<
|
||||
typeof api.updateRule
|
||||
>[0];
|
||||
await api.updateRule(rule);
|
||||
printOutput({ success: true }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
rules
|
||||
.command('delete <id>')
|
||||
.description('Delete a rule')
|
||||
.action(async (id: string) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.deleteRule(id);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
});
|
||||
}
|
||||
67
packages/cli/src/commands/schedules.ts
Normal file
67
packages/cli/src/commands/schedules.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import * as api from '@actual-app/api';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { withConnection } from '../connection';
|
||||
import { readJsonInput } from '../input';
|
||||
import { printOutput } from '../output';
|
||||
|
||||
export function registerSchedulesCommand(program: Command) {
|
||||
const schedules = program
|
||||
.command('schedules')
|
||||
.description('Manage scheduled transactions');
|
||||
|
||||
schedules
|
||||
.command('list')
|
||||
.description('List all schedules')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getSchedules();
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
schedules
|
||||
.command('create')
|
||||
.description('Create a new schedule')
|
||||
.option('--data <json>', 'Schedule definition as JSON')
|
||||
.option('--file <path>', 'Read schedule from JSON file (use - for stdin)')
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const schedule = readJsonInput(cmdOpts) as Parameters<
|
||||
typeof api.createSchedule
|
||||
>[0];
|
||||
const id = await api.createSchedule(schedule);
|
||||
printOutput({ id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
schedules
|
||||
.command('update <id>')
|
||||
.description('Update a schedule')
|
||||
.option('--data <json>', 'Fields to update as JSON')
|
||||
.option('--file <path>', 'Read fields from JSON file (use - for stdin)')
|
||||
.option('--reset-next-date', 'Reset next occurrence date', false)
|
||||
.action(async (id: string, cmdOpts) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const fields = readJsonInput(cmdOpts) as Parameters<
|
||||
typeof api.updateSchedule
|
||||
>[1];
|
||||
await api.updateSchedule(id, fields, cmdOpts.resetNextDate);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
schedules
|
||||
.command('delete <id>')
|
||||
.description('Delete a schedule')
|
||||
.action(async (id: string) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.deleteSchedule(id);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
});
|
||||
}
|
||||
60
packages/cli/src/commands/server.ts
Normal file
60
packages/cli/src/commands/server.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as api from '@actual-app/api';
|
||||
import { Option } from 'commander';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { withConnection } from '../connection';
|
||||
import { printOutput } from '../output';
|
||||
|
||||
export function registerServerCommand(program: Command) {
|
||||
const server = program.command('server').description('Server utilities');
|
||||
|
||||
server
|
||||
.command('version')
|
||||
.description('Get server version')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const version = await api.getServerVersion();
|
||||
printOutput({ version }, opts.format);
|
||||
},
|
||||
{ loadBudget: false },
|
||||
);
|
||||
});
|
||||
|
||||
server
|
||||
.command('get-id')
|
||||
.description('Get entity ID by name')
|
||||
.addOption(
|
||||
new Option('--type <type>', 'Entity type')
|
||||
.choices(['accounts', 'categories', 'payees', 'schedules'])
|
||||
.makeOptionMandatory(),
|
||||
)
|
||||
.requiredOption('--name <name>', 'Entity name')
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const id = await api.getIDByName(cmdOpts.type, cmdOpts.name);
|
||||
printOutput(
|
||||
{ id, type: cmdOpts.type, name: cmdOpts.name },
|
||||
opts.format,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
server
|
||||
.command('bank-sync')
|
||||
.description('Run bank synchronization')
|
||||
.option('--account <id>', 'Specific account ID to sync')
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const args = cmdOpts.account
|
||||
? { accountId: cmdOpts.account }
|
||||
: undefined;
|
||||
await api.runBankSync(args);
|
||||
printOutput({ success: true }, opts.format);
|
||||
});
|
||||
});
|
||||
}
|
||||
74
packages/cli/src/commands/tags.ts
Normal file
74
packages/cli/src/commands/tags.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import * as api from '@actual-app/api';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { withConnection } from '../connection';
|
||||
import { printOutput } from '../output';
|
||||
|
||||
export function registerTagsCommand(program: Command) {
|
||||
const tags = program.command('tags').description('Manage tags');
|
||||
|
||||
tags
|
||||
.command('list')
|
||||
.description('List all tags')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getTags();
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
tags
|
||||
.command('create')
|
||||
.description('Create a new tag')
|
||||
.requiredOption('--tag <tag>', 'Tag name')
|
||||
.option('--color <color>', 'Tag color')
|
||||
.option('--description <description>', 'Tag description')
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const id = await api.createTag({
|
||||
tag: cmdOpts.tag,
|
||||
color: cmdOpts.color,
|
||||
description: cmdOpts.description,
|
||||
});
|
||||
printOutput({ id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
tags
|
||||
.command('update <id>')
|
||||
.description('Update a tag')
|
||||
.option('--tag <tag>', 'New tag name')
|
||||
.option('--color <color>', 'New tag color')
|
||||
.option('--description <description>', 'New tag description')
|
||||
.action(async (id: string, cmdOpts) => {
|
||||
const fields: Record<string, unknown> = {};
|
||||
if (cmdOpts.tag !== undefined) fields.tag = cmdOpts.tag;
|
||||
if (cmdOpts.color !== undefined) fields.color = cmdOpts.color;
|
||||
if (cmdOpts.description !== undefined) {
|
||||
fields.description = cmdOpts.description;
|
||||
}
|
||||
if (Object.keys(fields).length === 0) {
|
||||
throw new Error(
|
||||
'At least one of --tag, --color, or --description is required',
|
||||
);
|
||||
}
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.updateTag(id, fields);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
tags
|
||||
.command('delete <id>')
|
||||
.description('Delete a tag')
|
||||
.action(async (id: string) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.deleteTag(id);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
});
|
||||
}
|
||||
114
packages/cli/src/commands/transactions.ts
Normal file
114
packages/cli/src/commands/transactions.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import * as api from '@actual-app/api';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { withConnection } from '../connection';
|
||||
import { readJsonInput } from '../input';
|
||||
import { printOutput } from '../output';
|
||||
|
||||
export function registerTransactionsCommand(program: Command) {
|
||||
const transactions = program
|
||||
.command('transactions')
|
||||
.description('Manage transactions');
|
||||
|
||||
transactions
|
||||
.command('list')
|
||||
.description('List transactions for an account')
|
||||
.requiredOption('--account <id>', 'Account ID')
|
||||
.requiredOption('--start <date>', 'Start date (YYYY-MM-DD)')
|
||||
.requiredOption('--end <date>', 'End date (YYYY-MM-DD)')
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getTransactions(
|
||||
cmdOpts.account,
|
||||
cmdOpts.start,
|
||||
cmdOpts.end,
|
||||
);
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
transactions
|
||||
.command('add')
|
||||
.description('Add transactions to an account')
|
||||
.requiredOption('--account <id>', 'Account ID')
|
||||
.option('--data <json>', 'Transaction data as JSON array')
|
||||
.option(
|
||||
'--file <path>',
|
||||
'Read transaction data from JSON file (use - for stdin)',
|
||||
)
|
||||
.option('--learn-categories', 'Learn category assignments', false)
|
||||
.option('--run-transfers', 'Process transfers', false)
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const transactions = readJsonInput(cmdOpts) as Parameters<
|
||||
typeof api.addTransactions
|
||||
>[1];
|
||||
const result = await api.addTransactions(
|
||||
cmdOpts.account,
|
||||
transactions,
|
||||
{
|
||||
learnCategories: cmdOpts.learnCategories,
|
||||
runTransfers: cmdOpts.runTransfers,
|
||||
},
|
||||
);
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
transactions
|
||||
.command('import')
|
||||
.description('Import transactions to an account')
|
||||
.requiredOption('--account <id>', 'Account ID')
|
||||
.option('--data <json>', 'Transaction data as JSON array')
|
||||
.option(
|
||||
'--file <path>',
|
||||
'Read transaction data from JSON file (use - for stdin)',
|
||||
)
|
||||
.option('--dry-run', 'Preview without importing', false)
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const transactions = readJsonInput(cmdOpts) as Parameters<
|
||||
typeof api.importTransactions
|
||||
>[1];
|
||||
const result = await api.importTransactions(
|
||||
cmdOpts.account,
|
||||
transactions,
|
||||
{
|
||||
defaultCleared: true,
|
||||
dryRun: cmdOpts.dryRun,
|
||||
},
|
||||
);
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
transactions
|
||||
.command('update <id>')
|
||||
.description('Update a transaction')
|
||||
.option('--data <json>', 'Fields to update as JSON')
|
||||
.option('--file <path>', 'Read fields from JSON file (use - for stdin)')
|
||||
.action(async (id: string, cmdOpts) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const fields = readJsonInput(cmdOpts) as Parameters<
|
||||
typeof api.updateTransaction
|
||||
>[1];
|
||||
await api.updateTransaction(id, fields);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
transactions
|
||||
.command('delete <id>')
|
||||
.description('Delete a transaction')
|
||||
.action(async (id: string) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.deleteTransaction(id);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
});
|
||||
}
|
||||
185
packages/cli/src/config.test.ts
Normal file
185
packages/cli/src/config.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import { resolveConfig } from './config';
|
||||
|
||||
const mockSearch = vi.fn().mockResolvedValue(null);
|
||||
|
||||
vi.mock('cosmiconfig', () => ({
|
||||
cosmiconfig: () => ({
|
||||
search: (...args: unknown[]) => mockSearch(...args),
|
||||
}),
|
||||
}));
|
||||
|
||||
function mockConfigFile(config: Record<string, unknown> | null) {
|
||||
if (config) {
|
||||
mockSearch.mockResolvedValue({ config, isEmpty: false });
|
||||
} else {
|
||||
mockSearch.mockResolvedValue(null);
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveConfig', () => {
|
||||
const savedEnv: Record<string, string | undefined> = {};
|
||||
const envKeys = [
|
||||
'ACTUAL_SERVER_URL',
|
||||
'ACTUAL_PASSWORD',
|
||||
'ACTUAL_SESSION_TOKEN',
|
||||
'ACTUAL_SYNC_ID',
|
||||
'ACTUAL_DATA_DIR',
|
||||
'ACTUAL_ENCRYPTION_PASSWORD',
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of envKeys) {
|
||||
savedEnv[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
mockConfigFile(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of envKeys) {
|
||||
if (savedEnv[key] !== undefined) {
|
||||
process.env[key] = savedEnv[key];
|
||||
} else {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe('priority chain', () => {
|
||||
it('CLI opts take highest priority', async () => {
|
||||
process.env.ACTUAL_SERVER_URL = 'http://env';
|
||||
process.env.ACTUAL_PASSWORD = 'envpw';
|
||||
process.env.ACTUAL_ENCRYPTION_PASSWORD = 'env-enc';
|
||||
mockConfigFile({
|
||||
serverUrl: 'http://file',
|
||||
password: 'filepw',
|
||||
encryptionPassword: 'file-enc',
|
||||
});
|
||||
|
||||
const config = await resolveConfig({
|
||||
serverUrl: 'http://cli',
|
||||
password: 'clipw',
|
||||
encryptionPassword: 'cli-enc',
|
||||
});
|
||||
|
||||
expect(config.serverUrl).toBe('http://cli');
|
||||
expect(config.password).toBe('clipw');
|
||||
expect(config.encryptionPassword).toBe('cli-enc');
|
||||
});
|
||||
|
||||
it('env vars override file config', async () => {
|
||||
process.env.ACTUAL_SERVER_URL = 'http://env';
|
||||
process.env.ACTUAL_PASSWORD = 'envpw';
|
||||
process.env.ACTUAL_ENCRYPTION_PASSWORD = 'env-enc';
|
||||
mockConfigFile({
|
||||
serverUrl: 'http://file',
|
||||
password: 'filepw',
|
||||
encryptionPassword: 'file-enc',
|
||||
});
|
||||
|
||||
const config = await resolveConfig({});
|
||||
|
||||
expect(config.serverUrl).toBe('http://env');
|
||||
expect(config.password).toBe('envpw');
|
||||
expect(config.encryptionPassword).toBe('env-enc');
|
||||
});
|
||||
|
||||
it('file config is used when no CLI opts or env vars', async () => {
|
||||
mockConfigFile({
|
||||
serverUrl: 'http://file',
|
||||
password: 'filepw',
|
||||
syncId: 'budget-1',
|
||||
encryptionPassword: 'file-enc',
|
||||
});
|
||||
|
||||
const config = await resolveConfig({});
|
||||
|
||||
expect(config.serverUrl).toBe('http://file');
|
||||
expect(config.password).toBe('filepw');
|
||||
expect(config.syncId).toBe('budget-1');
|
||||
expect(config.encryptionPassword).toBe('file-enc');
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaults', () => {
|
||||
it('dataDir defaults to ~/.actual-cli/data', async () => {
|
||||
const config = await resolveConfig({
|
||||
serverUrl: 'http://test',
|
||||
password: 'pw',
|
||||
});
|
||||
|
||||
expect(config.dataDir).toBe(join(homedir(), '.actual-cli', 'data'));
|
||||
});
|
||||
|
||||
it('CLI opt overrides default dataDir', async () => {
|
||||
const config = await resolveConfig({
|
||||
serverUrl: 'http://test',
|
||||
password: 'pw',
|
||||
dataDir: '/custom/dir',
|
||||
});
|
||||
|
||||
expect(config.dataDir).toBe('/custom/dir');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation', () => {
|
||||
it('throws when serverUrl is missing', async () => {
|
||||
await expect(resolveConfig({ password: 'pw' })).rejects.toThrow(
|
||||
'Server URL is required',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when neither password nor sessionToken provided', async () => {
|
||||
await expect(resolveConfig({ serverUrl: 'http://test' })).rejects.toThrow(
|
||||
'Authentication required',
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts sessionToken without password', async () => {
|
||||
const config = await resolveConfig({
|
||||
serverUrl: 'http://test',
|
||||
sessionToken: 'tok',
|
||||
});
|
||||
|
||||
expect(config.sessionToken).toBe('tok');
|
||||
expect(config.password).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts password without sessionToken', async () => {
|
||||
const config = await resolveConfig({
|
||||
serverUrl: 'http://test',
|
||||
password: 'pw',
|
||||
});
|
||||
|
||||
expect(config.password).toBe('pw');
|
||||
expect(config.sessionToken).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cosmiconfig handling', () => {
|
||||
it('handles null result (no config file found)', async () => {
|
||||
mockConfigFile(null);
|
||||
|
||||
const config = await resolveConfig({
|
||||
serverUrl: 'http://test',
|
||||
password: 'pw',
|
||||
});
|
||||
|
||||
expect(config.serverUrl).toBe('http://test');
|
||||
});
|
||||
|
||||
it('handles isEmpty result', async () => {
|
||||
mockSearch.mockResolvedValue({ config: {}, isEmpty: true });
|
||||
|
||||
const config = await resolveConfig({
|
||||
serverUrl: 'http://test',
|
||||
password: 'pw',
|
||||
});
|
||||
|
||||
expect(config.serverUrl).toBe('http://test');
|
||||
});
|
||||
});
|
||||
});
|
||||
141
packages/cli/src/config.ts
Normal file
141
packages/cli/src/config.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import { cosmiconfig } from 'cosmiconfig';
|
||||
|
||||
export type CliConfig = {
|
||||
serverUrl: string;
|
||||
password?: string;
|
||||
sessionToken?: string;
|
||||
syncId?: string;
|
||||
dataDir: string;
|
||||
encryptionPassword?: string;
|
||||
};
|
||||
|
||||
export type CliGlobalOpts = {
|
||||
serverUrl?: string;
|
||||
password?: string;
|
||||
sessionToken?: string;
|
||||
syncId?: string;
|
||||
dataDir?: string;
|
||||
encryptionPassword?: string;
|
||||
format?: 'json' | 'table' | 'csv';
|
||||
verbose?: boolean;
|
||||
};
|
||||
|
||||
type ConfigFileContent = {
|
||||
serverUrl?: string;
|
||||
password?: string;
|
||||
sessionToken?: string;
|
||||
syncId?: string;
|
||||
dataDir?: string;
|
||||
encryptionPassword?: string;
|
||||
};
|
||||
|
||||
const configFileKeys: readonly string[] = [
|
||||
'serverUrl',
|
||||
'password',
|
||||
'sessionToken',
|
||||
'syncId',
|
||||
'dataDir',
|
||||
'encryptionPassword',
|
||||
];
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function validateConfigFileContent(value: unknown): ConfigFileContent {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error(
|
||||
'Invalid config file: expected an object with keys: ' +
|
||||
configFileKeys.join(', '),
|
||||
);
|
||||
}
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!configFileKeys.includes(key)) {
|
||||
throw new Error(`Invalid config file: unknown key "${key}"`);
|
||||
}
|
||||
if (value[key] !== undefined && typeof value[key] !== 'string') {
|
||||
throw new Error(
|
||||
`Invalid config file: key "${key}" must be a string, got ${typeof value[key]}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return value as ConfigFileContent;
|
||||
}
|
||||
|
||||
async function loadConfigFile(): Promise<ConfigFileContent> {
|
||||
const explorer = cosmiconfig('actual', {
|
||||
searchPlaces: [
|
||||
'package.json',
|
||||
'.actualrc',
|
||||
'.actualrc.json',
|
||||
'.actualrc.yaml',
|
||||
'.actualrc.yml',
|
||||
'actual.config.json',
|
||||
'actual.config.yaml',
|
||||
'actual.config.yml',
|
||||
],
|
||||
});
|
||||
const result = await explorer.search();
|
||||
if (result && !result.isEmpty) {
|
||||
return validateConfigFileContent(result.config);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export async function resolveConfig(
|
||||
cliOpts: CliGlobalOpts,
|
||||
): Promise<CliConfig> {
|
||||
const fileConfig = await loadConfigFile();
|
||||
|
||||
const serverUrl =
|
||||
cliOpts.serverUrl ??
|
||||
process.env.ACTUAL_SERVER_URL ??
|
||||
fileConfig.serverUrl ??
|
||||
'';
|
||||
|
||||
const password =
|
||||
cliOpts.password ?? process.env.ACTUAL_PASSWORD ?? fileConfig.password;
|
||||
|
||||
const sessionToken =
|
||||
cliOpts.sessionToken ??
|
||||
process.env.ACTUAL_SESSION_TOKEN ??
|
||||
fileConfig.sessionToken;
|
||||
|
||||
const syncId =
|
||||
cliOpts.syncId ?? process.env.ACTUAL_SYNC_ID ?? fileConfig.syncId;
|
||||
|
||||
const dataDir =
|
||||
cliOpts.dataDir ??
|
||||
process.env.ACTUAL_DATA_DIR ??
|
||||
fileConfig.dataDir ??
|
||||
join(homedir(), '.actual-cli', 'data');
|
||||
|
||||
const encryptionPassword =
|
||||
cliOpts.encryptionPassword ??
|
||||
process.env.ACTUAL_ENCRYPTION_PASSWORD ??
|
||||
fileConfig.encryptionPassword;
|
||||
|
||||
if (!serverUrl) {
|
||||
throw new Error(
|
||||
'Server URL is required. Set --server-url, ACTUAL_SERVER_URL env var, or serverUrl in config file.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!password && !sessionToken) {
|
||||
throw new Error(
|
||||
'Authentication required. Set --password/--session-token, ACTUAL_PASSWORD/ACTUAL_SESSION_TOKEN env var, or password/sessionToken in config file.',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
serverUrl,
|
||||
password,
|
||||
sessionToken,
|
||||
syncId,
|
||||
dataDir,
|
||||
encryptionPassword,
|
||||
};
|
||||
}
|
||||
134
packages/cli/src/connection.test.ts
Normal file
134
packages/cli/src/connection.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import * as api from '@actual-app/api';
|
||||
|
||||
import { resolveConfig } from './config';
|
||||
import { withConnection } from './connection';
|
||||
|
||||
vi.mock('@actual-app/api', () => ({
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
downloadBudget: vi.fn().mockResolvedValue(undefined),
|
||||
shutdown: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('./config', () => ({
|
||||
resolveConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
function setConfig(overrides: Record<string, unknown> = {}) {
|
||||
vi.mocked(resolveConfig).mockResolvedValue({
|
||||
serverUrl: 'http://test',
|
||||
password: 'pw',
|
||||
dataDir: '/tmp/data',
|
||||
syncId: 'budget-1',
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe('withConnection', () => {
|
||||
let stderrSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
stderrSpy = vi
|
||||
.spyOn(process.stderr, 'write')
|
||||
.mockImplementation(() => true);
|
||||
setConfig();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stderrSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('calls api.init with password when no sessionToken', async () => {
|
||||
setConfig({ password: 'pw', sessionToken: undefined });
|
||||
|
||||
await withConnection({}, async () => 'ok');
|
||||
|
||||
expect(api.init).toHaveBeenCalledWith({
|
||||
serverURL: 'http://test',
|
||||
password: 'pw',
|
||||
dataDir: '/tmp/data',
|
||||
verbose: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls api.init with sessionToken when present', async () => {
|
||||
setConfig({ sessionToken: 'tok', password: undefined });
|
||||
|
||||
await withConnection({}, async () => 'ok');
|
||||
|
||||
expect(api.init).toHaveBeenCalledWith({
|
||||
serverURL: 'http://test',
|
||||
sessionToken: 'tok',
|
||||
dataDir: '/tmp/data',
|
||||
verbose: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls api.downloadBudget when syncId is set', async () => {
|
||||
setConfig({ syncId: 'budget-1' });
|
||||
|
||||
await withConnection({}, async () => 'ok');
|
||||
|
||||
expect(api.downloadBudget).toHaveBeenCalledWith('budget-1', {
|
||||
password: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when loadBudget is true but syncId is not set', async () => {
|
||||
setConfig({ syncId: undefined });
|
||||
|
||||
await expect(withConnection({}, async () => 'ok')).rejects.toThrow(
|
||||
'Sync ID is required',
|
||||
);
|
||||
});
|
||||
|
||||
it('skips budget download when loadBudget is false and syncId is not set', async () => {
|
||||
setConfig({ syncId: undefined });
|
||||
|
||||
await withConnection({}, async () => 'ok', { loadBudget: false });
|
||||
|
||||
expect(api.downloadBudget).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call api.downloadBudget when loadBudget is false', async () => {
|
||||
setConfig({ syncId: 'budget-1' });
|
||||
|
||||
await withConnection({}, async () => 'ok', { loadBudget: false });
|
||||
|
||||
expect(api.downloadBudget).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns callback result', async () => {
|
||||
const result = await withConnection({}, async () => 42);
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
|
||||
it('calls api.shutdown in finally block on success', async () => {
|
||||
await withConnection({}, async () => 'ok');
|
||||
expect(api.shutdown).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls api.shutdown in finally block on error', async () => {
|
||||
await expect(
|
||||
withConnection({}, async () => {
|
||||
throw new Error('boom');
|
||||
}),
|
||||
).rejects.toThrow('boom');
|
||||
|
||||
expect(api.shutdown).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not write to stderr by default', async () => {
|
||||
await withConnection({}, async () => 'ok');
|
||||
|
||||
expect(stderrSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes info to stderr when verbose', async () => {
|
||||
await withConnection({ verbose: true }, async () => 'ok');
|
||||
|
||||
expect(stderrSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Connecting to'),
|
||||
);
|
||||
});
|
||||
});
|
||||
65
packages/cli/src/connection.ts
Normal file
65
packages/cli/src/connection.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { mkdirSync } from 'fs';
|
||||
|
||||
import * as api from '@actual-app/api';
|
||||
|
||||
import { resolveConfig } from './config';
|
||||
import type { CliGlobalOpts } from './config';
|
||||
|
||||
function info(message: string, verbose?: boolean) {
|
||||
if (verbose) {
|
||||
process.stderr.write(message + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
type ConnectionOptions = {
|
||||
loadBudget?: boolean;
|
||||
};
|
||||
|
||||
export async function withConnection<T>(
|
||||
globalOpts: CliGlobalOpts,
|
||||
fn: () => Promise<T>,
|
||||
options: ConnectionOptions = {},
|
||||
): Promise<T> {
|
||||
const { loadBudget = true } = options;
|
||||
const config = await resolveConfig(globalOpts);
|
||||
|
||||
mkdirSync(config.dataDir, { recursive: true });
|
||||
|
||||
info(`Connecting to ${config.serverUrl}...`, globalOpts.verbose);
|
||||
|
||||
if (config.sessionToken) {
|
||||
await api.init({
|
||||
serverURL: config.serverUrl,
|
||||
dataDir: config.dataDir,
|
||||
sessionToken: config.sessionToken,
|
||||
verbose: globalOpts.verbose,
|
||||
});
|
||||
} else if (config.password) {
|
||||
await api.init({
|
||||
serverURL: config.serverUrl,
|
||||
dataDir: config.dataDir,
|
||||
password: config.password,
|
||||
verbose: globalOpts.verbose,
|
||||
});
|
||||
} else {
|
||||
throw new Error(
|
||||
'Authentication required. Provide --password or --session-token, or set ACTUAL_PASSWORD / ACTUAL_SESSION_TOKEN.',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if (loadBudget && config.syncId) {
|
||||
info(`Downloading budget ${config.syncId}...`, globalOpts.verbose);
|
||||
await api.downloadBudget(config.syncId, {
|
||||
password: config.encryptionPassword,
|
||||
});
|
||||
} else if (loadBudget && !config.syncId) {
|
||||
throw new Error(
|
||||
'Sync ID is required for this command. Set --sync-id or ACTUAL_SYNC_ID.',
|
||||
);
|
||||
}
|
||||
return await fn();
|
||||
} finally {
|
||||
await api.shutdown();
|
||||
}
|
||||
}
|
||||
70
packages/cli/src/index.ts
Normal file
70
packages/cli/src/index.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Command, Option } from 'commander';
|
||||
|
||||
import { registerAccountsCommand } from './commands/accounts';
|
||||
import { registerBudgetsCommand } from './commands/budgets';
|
||||
import { registerCategoriesCommand } from './commands/categories';
|
||||
import { registerCategoryGroupsCommand } from './commands/category-groups';
|
||||
import { registerPayeesCommand } from './commands/payees';
|
||||
import { registerQueryCommand } from './commands/query';
|
||||
import { registerRulesCommand } from './commands/rules';
|
||||
import { registerSchedulesCommand } from './commands/schedules';
|
||||
import { registerServerCommand } from './commands/server';
|
||||
import { registerTagsCommand } from './commands/tags';
|
||||
import { registerTransactionsCommand } from './commands/transactions';
|
||||
|
||||
declare const __CLI_VERSION__: string;
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('actual')
|
||||
.description('CLI for Actual Budget')
|
||||
.version(__CLI_VERSION__)
|
||||
.option('--server-url <url>', 'Actual server URL (env: ACTUAL_SERVER_URL)')
|
||||
.option('--password <password>', 'Server password (env: ACTUAL_PASSWORD)')
|
||||
.option(
|
||||
'--session-token <token>',
|
||||
'Session token (env: ACTUAL_SESSION_TOKEN)',
|
||||
)
|
||||
.option('--sync-id <id>', 'Budget sync ID (env: ACTUAL_SYNC_ID)')
|
||||
.option('--data-dir <path>', 'Data directory (env: ACTUAL_DATA_DIR)')
|
||||
.option(
|
||||
'--encryption-password <password>',
|
||||
'E2E encryption password (env: ACTUAL_ENCRYPTION_PASSWORD)',
|
||||
)
|
||||
.addOption(
|
||||
new Option('--format <format>', 'Output format: json, table, csv')
|
||||
.choices(['json', 'table', 'csv'] as const)
|
||||
.default('json'),
|
||||
)
|
||||
.option('--verbose', 'Show informational messages', false);
|
||||
|
||||
registerAccountsCommand(program);
|
||||
registerBudgetsCommand(program);
|
||||
registerCategoriesCommand(program);
|
||||
registerCategoryGroupsCommand(program);
|
||||
registerTransactionsCommand(program);
|
||||
registerPayeesCommand(program);
|
||||
registerTagsCommand(program);
|
||||
registerRulesCommand(program);
|
||||
registerSchedulesCommand(program);
|
||||
registerQueryCommand(program);
|
||||
registerServerCommand(program);
|
||||
|
||||
function normalizeThrownMessage(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
if (typeof err === 'object' && err !== null) {
|
||||
try {
|
||||
return JSON.stringify(err);
|
||||
} catch {
|
||||
return '<non-serializable error>';
|
||||
}
|
||||
}
|
||||
return String(err);
|
||||
}
|
||||
|
||||
program.parseAsync(process.argv).catch((err: unknown) => {
|
||||
const message = normalizeThrownMessage(err);
|
||||
process.stderr.write(`Error: ${message}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
21
packages/cli/src/input.ts
Normal file
21
packages/cli/src/input.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { readFileSync } from 'fs';
|
||||
|
||||
export function readJsonInput(cmdOpts: {
|
||||
data?: string;
|
||||
file?: string;
|
||||
}): unknown {
|
||||
if (cmdOpts.data && cmdOpts.file) {
|
||||
throw new Error('Cannot use both --data and --file');
|
||||
}
|
||||
if (cmdOpts.data) {
|
||||
return JSON.parse(cmdOpts.data);
|
||||
}
|
||||
if (cmdOpts.file) {
|
||||
const content =
|
||||
cmdOpts.file === '-'
|
||||
? readFileSync(0, 'utf-8')
|
||||
: readFileSync(cmdOpts.file, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
}
|
||||
throw new Error('Either --data or --file is required');
|
||||
}
|
||||
152
packages/cli/src/output.test.ts
Normal file
152
packages/cli/src/output.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { formatOutput, printOutput } from './output';
|
||||
|
||||
describe('formatOutput', () => {
|
||||
describe('json (default)', () => {
|
||||
it('pretty-prints with 2-space indent', () => {
|
||||
const data = { a: 1, b: 'two' };
|
||||
expect(formatOutput(data)).toBe(JSON.stringify(data, null, 2));
|
||||
});
|
||||
|
||||
it('is the default format', () => {
|
||||
expect(formatOutput({ x: 1 })).toBe(formatOutput({ x: 1 }, 'json'));
|
||||
});
|
||||
|
||||
it('handles arrays', () => {
|
||||
const data = [1, 2, 3];
|
||||
expect(formatOutput(data, 'json')).toBe('[\n 1,\n 2,\n 3\n]');
|
||||
});
|
||||
|
||||
it('handles null', () => {
|
||||
expect(formatOutput(null, 'json')).toBe('null');
|
||||
});
|
||||
});
|
||||
|
||||
describe('table', () => {
|
||||
it('renders an object as key-value table', () => {
|
||||
const result = formatOutput({ name: 'Alice', age: 30 }, 'table');
|
||||
expect(result).toContain('name');
|
||||
expect(result).toContain('Alice');
|
||||
expect(result).toContain('age');
|
||||
expect(result).toContain('30');
|
||||
});
|
||||
|
||||
it('renders an array of objects as columnar table', () => {
|
||||
const data = [
|
||||
{ id: 1, name: 'a' },
|
||||
{ id: 2, name: 'b' },
|
||||
];
|
||||
const result = formatOutput(data, 'table');
|
||||
expect(result).toContain('id');
|
||||
expect(result).toContain('name');
|
||||
expect(result).toContain('1');
|
||||
expect(result).toContain('a');
|
||||
expect(result).toContain('2');
|
||||
expect(result).toContain('b');
|
||||
});
|
||||
|
||||
it('returns "(no results)" for empty array', () => {
|
||||
expect(formatOutput([], 'table')).toBe('(no results)');
|
||||
});
|
||||
|
||||
it('returns String(data) for scalar values', () => {
|
||||
expect(formatOutput(42, 'table')).toBe('42');
|
||||
expect(formatOutput('hello', 'table')).toBe('hello');
|
||||
expect(formatOutput(true, 'table')).toBe('true');
|
||||
});
|
||||
|
||||
it('handles null/undefined values in objects', () => {
|
||||
const data = [{ a: null, b: undefined }];
|
||||
const result = formatOutput(data, 'table');
|
||||
expect(result).toContain('a');
|
||||
expect(result).toContain('b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('csv', () => {
|
||||
it('renders array of objects as header + data rows', () => {
|
||||
const data = [
|
||||
{ id: 1, name: 'Alice' },
|
||||
{ id: 2, name: 'Bob' },
|
||||
];
|
||||
const result = formatOutput(data, 'csv');
|
||||
const lines = result.split('\n');
|
||||
expect(lines[0]).toBe('id,name');
|
||||
expect(lines[1]).toBe('1,Alice');
|
||||
expect(lines[2]).toBe('2,Bob');
|
||||
});
|
||||
|
||||
it('renders single object as header + single row', () => {
|
||||
const result = formatOutput({ x: 10, y: 20 }, 'csv');
|
||||
const lines = result.split('\n');
|
||||
expect(lines[0]).toBe('x,y');
|
||||
expect(lines[1]).toBe('10,20');
|
||||
});
|
||||
|
||||
it('returns empty string for empty array', () => {
|
||||
expect(formatOutput([], 'csv')).toBe('');
|
||||
});
|
||||
|
||||
it('returns String(data) for scalar values', () => {
|
||||
expect(formatOutput(42, 'csv')).toBe('42');
|
||||
expect(formatOutput('hello', 'csv')).toBe('hello');
|
||||
});
|
||||
|
||||
it('escapes commas by quoting', () => {
|
||||
const data = [{ val: 'a,b' }];
|
||||
expect(formatOutput(data, 'csv')).toBe('val\n"a,b"');
|
||||
});
|
||||
|
||||
it('escapes double quotes by doubling them', () => {
|
||||
const data = [{ val: 'say "hi"' }];
|
||||
expect(formatOutput(data, 'csv')).toBe('val\n"say ""hi"""');
|
||||
});
|
||||
|
||||
it('escapes newlines by quoting', () => {
|
||||
const data = [{ val: 'line1\nline2' }];
|
||||
expect(formatOutput(data, 'csv')).toBe('val\n"line1\nline2"');
|
||||
});
|
||||
|
||||
it('handles null/undefined values', () => {
|
||||
const data = [{ a: null, b: undefined }];
|
||||
const result = formatOutput(data, 'csv');
|
||||
const lines = result.split('\n');
|
||||
expect(lines[0]).toBe('a,b');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('printOutput', () => {
|
||||
let writeSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
writeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('writes formatted output followed by newline', () => {
|
||||
printOutput({ a: 1 }, 'json');
|
||||
expect(writeSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify({ a: 1 }, null, 2) + '\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults to json format', () => {
|
||||
printOutput([1, 2]);
|
||||
expect(writeSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify([1, 2], null, 2) + '\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('supports table format', () => {
|
||||
printOutput([], 'table');
|
||||
expect(writeSpy).toHaveBeenCalledWith('(no results)\n');
|
||||
});
|
||||
|
||||
it('supports csv format', () => {
|
||||
printOutput([], 'csv');
|
||||
expect(writeSpy).toHaveBeenCalledWith('\n');
|
||||
});
|
||||
});
|
||||
82
packages/cli/src/output.ts
Normal file
82
packages/cli/src/output.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import Table from 'cli-table3';
|
||||
|
||||
export type OutputFormat = 'json' | 'table' | 'csv';
|
||||
|
||||
export function formatOutput(
|
||||
data: unknown,
|
||||
format: OutputFormat = 'json',
|
||||
): string {
|
||||
switch (format) {
|
||||
case 'json':
|
||||
return JSON.stringify(data, null, 2);
|
||||
case 'table':
|
||||
return formatTable(data);
|
||||
case 'csv':
|
||||
return formatCsv(data);
|
||||
default:
|
||||
return JSON.stringify(data, null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
function formatTable(data: unknown): string {
|
||||
if (!Array.isArray(data)) {
|
||||
if (data && typeof data === 'object') {
|
||||
const table = new Table();
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
table.push({ [key]: String(value) });
|
||||
}
|
||||
return table.toString();
|
||||
}
|
||||
return String(data);
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return '(no results)';
|
||||
}
|
||||
|
||||
const keys = Object.keys(data[0] as Record<string, unknown>);
|
||||
const table = new Table({ head: keys });
|
||||
|
||||
for (const row of data) {
|
||||
const r = row as Record<string, unknown>;
|
||||
table.push(keys.map(k => String(r[k] ?? '')));
|
||||
}
|
||||
|
||||
return table.toString();
|
||||
}
|
||||
|
||||
function formatCsv(data: unknown): string {
|
||||
if (!Array.isArray(data)) {
|
||||
if (data && typeof data === 'object') {
|
||||
const entries = Object.entries(data);
|
||||
const header = entries.map(([k]) => escapeCsv(k)).join(',');
|
||||
const values = entries.map(([, v]) => escapeCsv(String(v))).join(',');
|
||||
return header + '\n' + values;
|
||||
}
|
||||
return String(data);
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const keys = Object.keys(data[0] as Record<string, unknown>);
|
||||
const header = keys.map(k => escapeCsv(k)).join(',');
|
||||
const rows = data.map(row => {
|
||||
const r = row as Record<string, unknown>;
|
||||
return keys.map(k => escapeCsv(String(r[k] ?? ''))).join(',');
|
||||
});
|
||||
|
||||
return [header, ...rows].join('\n');
|
||||
}
|
||||
|
||||
function escapeCsv(value: string): string {
|
||||
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
|
||||
return '"' + value.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function printOutput(data: unknown, format: OutputFormat = 'json') {
|
||||
process.stdout.write(formatOutput(data, format) + '\n');
|
||||
}
|
||||
65
packages/cli/src/utils.test.ts
Normal file
65
packages/cli/src/utils.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { parseBoolFlag, parseIntFlag } from './utils';
|
||||
|
||||
describe('parseBoolFlag', () => {
|
||||
it('parses "true"', () => {
|
||||
expect(parseBoolFlag('true', '--flag')).toBe(true);
|
||||
});
|
||||
|
||||
it('parses "false"', () => {
|
||||
expect(parseBoolFlag('false', '--flag')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects other strings', () => {
|
||||
expect(() => parseBoolFlag('yes', '--flag')).toThrow(
|
||||
'Invalid --flag: "yes". Expected "true" or "false".',
|
||||
);
|
||||
});
|
||||
|
||||
it('includes the flag name in the error message', () => {
|
||||
expect(() => parseBoolFlag('1', '--offbudget')).toThrow(
|
||||
'Invalid --offbudget',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseIntFlag', () => {
|
||||
it('parses a valid integer string', () => {
|
||||
expect(parseIntFlag('42', '--balance')).toBe(42);
|
||||
});
|
||||
|
||||
it('parses zero', () => {
|
||||
expect(parseIntFlag('0', '--balance')).toBe(0);
|
||||
});
|
||||
|
||||
it('parses negative integers', () => {
|
||||
expect(parseIntFlag('-10', '--balance')).toBe(-10);
|
||||
});
|
||||
|
||||
it('rejects decimal values', () => {
|
||||
expect(() => parseIntFlag('3.5', '--balance')).toThrow(
|
||||
'Invalid --balance: "3.5". Expected an integer.',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects non-numeric strings', () => {
|
||||
expect(() => parseIntFlag('abc', '--balance')).toThrow(
|
||||
'Invalid --balance: "abc". Expected an integer.',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects partially numeric strings', () => {
|
||||
expect(() => parseIntFlag('3abc', '--balance')).toThrow(
|
||||
'Invalid --balance: "3abc". Expected an integer.',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects empty string', () => {
|
||||
expect(() => parseIntFlag('', '--balance')).toThrow(
|
||||
'Invalid --balance: "". Expected an integer.',
|
||||
);
|
||||
});
|
||||
|
||||
it('includes the flag name in the error message', () => {
|
||||
expect(() => parseIntFlag('x', '--amount')).toThrow('Invalid --amount');
|
||||
});
|
||||
});
|
||||
16
packages/cli/src/utils.ts
Normal file
16
packages/cli/src/utils.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export function parseBoolFlag(value: string, flagName: string): boolean {
|
||||
if (value !== 'true' && value !== 'false') {
|
||||
throw new Error(
|
||||
`Invalid ${flagName}: "${value}". Expected "true" or "false".`,
|
||||
);
|
||||
}
|
||||
return value === 'true';
|
||||
}
|
||||
|
||||
export function parseIntFlag(value: string, flagName: string): number {
|
||||
const parsed = value.trim() === '' ? NaN : Number(value);
|
||||
if (!Number.isInteger(parsed)) {
|
||||
throw new Error(`Invalid ${flagName}: "${value}". Expected an integer.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
15
packages/cli/tsconfig.json
Normal file
15
packages/cli/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"lib": ["ES2021"],
|
||||
"types": ["vitest/globals", "node"],
|
||||
"noEmit": false,
|
||||
"strict": true,
|
||||
"outDir": "dist",
|
||||
"tsBuildInfoFile": "dist/.tsbuildinfo"
|
||||
},
|
||||
"references": [{ "path": "../api" }],
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "coverage"]
|
||||
}
|
||||
36
packages/cli/vite.config.ts
Normal file
36
packages/cli/vite.config.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { visualizer } from 'rollup-plugin-visualizer';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
const pkg = JSON.parse(
|
||||
fs.readFileSync(path.resolve(__dirname, 'package.json'), 'utf-8'),
|
||||
);
|
||||
|
||||
export default defineConfig({
|
||||
define: {
|
||||
__CLI_VERSION__: JSON.stringify(pkg.version),
|
||||
},
|
||||
ssr: { noExternal: true, external: ['@actual-app/api'] },
|
||||
build: {
|
||||
ssr: true,
|
||||
target: 'node22',
|
||||
outDir: path.resolve(__dirname, 'dist'),
|
||||
emptyOutDir: true,
|
||||
lib: {
|
||||
entry: path.resolve(__dirname, 'src/index.ts'),
|
||||
formats: ['es'],
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {
|
||||
entryFileNames: 'cli.js',
|
||||
banner: chunk => (chunk.isEntry ? '#!/usr/bin/env node' : ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [visualizer({ template: 'raw-data', filename: 'dist/stats.json' })],
|
||||
test: {
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
@@ -5,7 +5,6 @@ import type { Preview } from '@storybook/react-vite';
|
||||
// Not ideal to import from desktop-client, but we need a source of truth for theme variables
|
||||
// TODO: this needs refactoring
|
||||
import * as darkTheme from '../../desktop-client/src/style/themes/dark';
|
||||
import * as developmentTheme from '../../desktop-client/src/style/themes/development';
|
||||
import * as lightTheme from '../../desktop-client/src/style/themes/light';
|
||||
import * as midnightTheme from '../../desktop-client/src/style/themes/midnight';
|
||||
|
||||
@@ -13,7 +12,6 @@ const THEMES = {
|
||||
light: lightTheme,
|
||||
dark: darkTheme,
|
||||
midnight: midnightTheme,
|
||||
development: developmentTheme,
|
||||
} as const;
|
||||
|
||||
type ThemeName = keyof typeof THEMES;
|
||||
@@ -64,7 +62,6 @@ const preview: Preview = {
|
||||
{ value: 'light', title: 'Light' },
|
||||
{ value: 'dark', title: 'Dark' },
|
||||
{ value: 'midnight', title: 'Midnight' },
|
||||
{ value: 'development', title: 'Development' },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -48,18 +48,18 @@
|
||||
"usehooks-ts": "^3.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^5.0.0",
|
||||
"@storybook/addon-a11y": "^10.2.7",
|
||||
"@storybook/addon-docs": "^10.2.7",
|
||||
"@storybook/react-vite": "^10.2.7",
|
||||
"@chromatic-com/storybook": "^5.0.1",
|
||||
"@storybook/addon-a11y": "^10.2.16",
|
||||
"@storybook/addon-docs": "^10.2.16",
|
||||
"@storybook/react-vite": "^10.2.16",
|
||||
"@svgr/cli": "^8.1.0",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react": "^19.2.14",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"eslint-plugin-storybook": "^10.2.7",
|
||||
"eslint-plugin-storybook": "^10.2.16",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"storybook": "^10.2.7",
|
||||
"storybook": "^10.2.16",
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.1.0"
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ import { css, cx } from '@emotion/css';
|
||||
|
||||
import type { CSSProperties } from './styles';
|
||||
|
||||
type BlockProps = HTMLProps<HTMLDivElement> & {
|
||||
type BlockProps = Omit<HTMLProps<HTMLDivElement>, 'style'> & {
|
||||
innerRef?: Ref<HTMLDivElement>;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import { css } from '@emotion/css';
|
||||
|
||||
import type { CSSProperties } from './styles';
|
||||
|
||||
type ParagraphProps = HTMLProps<HTMLDivElement> & {
|
||||
type ParagraphProps = Omit<HTMLProps<HTMLDivElement>, 'style'> & {
|
||||
style?: CSSProperties;
|
||||
isLast?: boolean;
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import { css, cx } from '@emotion/css';
|
||||
|
||||
import type { CSSProperties } from './styles';
|
||||
|
||||
type TextProps = HTMLProps<HTMLSpanElement> & {
|
||||
type TextProps = Omit<HTMLProps<HTMLSpanElement>, 'style'> & {
|
||||
innerRef?: Ref<HTMLSpanElement>;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { css, cx } from '@emotion/css';
|
||||
|
||||
import type { CSSProperties } from './styles';
|
||||
|
||||
type ViewProps = HTMLProps<HTMLDivElement> & {
|
||||
type ViewProps = Omit<HTMLProps<HTMLDivElement>, 'style'> & {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
nativeStyle?: CSSProperties;
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 122 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 121 KiB |
@@ -22,9 +22,9 @@
|
||||
"@actual-app/components": "workspace:*",
|
||||
"@actual-app/core": "workspace:*",
|
||||
"@babel/core": "^7.29.0",
|
||||
"@codemirror/autocomplete": "^6.20.0",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/language": "^6.12.1",
|
||||
"@codemirror/autocomplete": "^6.20.1",
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/language": "^6.12.2",
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/view": "^6.38.7",
|
||||
"@emotion/css": "^11.13.5",
|
||||
@@ -33,11 +33,11 @@
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@playwright/test": "1.58.2",
|
||||
"@rolldown/plugin-babel": "~0.1.7",
|
||||
"@rolldown/plugin-babel": "~0.1.8",
|
||||
"@rollup/plugin-inject": "^5.0.5",
|
||||
"@swc/core": "^1.15.11",
|
||||
"@swc/helpers": "^0.5.18",
|
||||
"@tanstack/react-query": "^5.90.20",
|
||||
"@swc/core": "^1.15.18",
|
||||
"@swc/helpers": "^0.5.19",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@testing-library/dom": "10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "16.3.2",
|
||||
@@ -45,11 +45,11 @@
|
||||
"@types/lodash": "^4",
|
||||
"@types/pikaday": "^1.7.10",
|
||||
"@types/promise-retry": "^1.1.6",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-modal": "^3.16.3",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
|
||||
"@uiw/react-codemirror": "^4.25.4",
|
||||
"@uiw/react-codemirror": "^4.25.7",
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
"@vitejs/plugin-basic-ssl": "^2.2.0",
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
@@ -58,10 +58,10 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"downshift": "9.0.10",
|
||||
"hyperformula": "^3.1.1",
|
||||
"i18next": "^25.8.4",
|
||||
"i18next-parser": "^9.3.0",
|
||||
"downshift": "9.3.2",
|
||||
"hyperformula": "^3.2.0",
|
||||
"i18next": "^25.8.14",
|
||||
"i18next-parser": "^9.4.0",
|
||||
"i18next-resources-to-backend": "^1.2.1",
|
||||
"jsdom": "^27.4.0",
|
||||
"lodash": "^4.17.23",
|
||||
@@ -80,19 +80,19 @@
|
||||
"react-error-boundary": "^6.0.3",
|
||||
"react-grid-layout": "^2.2.2",
|
||||
"react-hotkeys-hook": "^5.2.4",
|
||||
"react-i18next": "^16.5.4",
|
||||
"react-i18next": "^16.5.6",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-modal": "3.16.3",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "7.13.0",
|
||||
"react-router": "7.13.1",
|
||||
"react-simple-pull-to-refresh": "^1.3.4",
|
||||
"react-spring": "10.0.0",
|
||||
"react-spring": "^10.0.3",
|
||||
"react-swipeable": "^7.0.2",
|
||||
"react-virtualized-auto-sizer": "^2.0.2",
|
||||
"react-virtualized-auto-sizer": "^2.0.3",
|
||||
"recharts": "^3.7.0",
|
||||
"rehype-external-links": "^3.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"rollup-plugin-visualizer": "^6.0.5",
|
||||
"rollup-plugin-visualizer": "^6.0.11",
|
||||
"sass": "^1.97.3",
|
||||
"typescript-strict-plugin": "^2.4.4",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
|
||||
@@ -207,6 +207,7 @@ export function useMoveAccountMutation() {
|
||||
type ImportPreviewTransactionsPayload = {
|
||||
accountId: string;
|
||||
transactions: TransactionEntity[];
|
||||
reimportDeleted?: boolean;
|
||||
};
|
||||
|
||||
export function useImportPreviewTransactionsMutation() {
|
||||
@@ -218,6 +219,7 @@ export function useImportPreviewTransactionsMutation() {
|
||||
mutationFn: async ({
|
||||
accountId,
|
||||
transactions,
|
||||
reimportDeleted,
|
||||
}: ImportPreviewTransactionsPayload) => {
|
||||
const { errors = [], updatedPreview } = await send(
|
||||
'transactions-import',
|
||||
@@ -225,6 +227,7 @@ export function useImportPreviewTransactionsMutation() {
|
||||
accountId,
|
||||
transactions,
|
||||
isPreview: true,
|
||||
opts: reimportDeleted !== undefined ? { reimportDeleted } : undefined,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -259,6 +262,7 @@ type ImportTransactionsPayload = {
|
||||
accountId: string;
|
||||
transactions: TransactionEntity[];
|
||||
reconcile: boolean;
|
||||
reimportDeleted?: boolean;
|
||||
};
|
||||
|
||||
export function useImportTransactionsMutation() {
|
||||
@@ -271,6 +275,7 @@ export function useImportTransactionsMutation() {
|
||||
accountId,
|
||||
transactions,
|
||||
reconcile,
|
||||
reimportDeleted,
|
||||
}: ImportTransactionsPayload) => {
|
||||
if (!reconcile) {
|
||||
await send('api/transactions-add', {
|
||||
@@ -289,6 +294,7 @@ export function useImportTransactionsMutation() {
|
||||
accountId,
|
||||
transactions,
|
||||
isPreview: false,
|
||||
opts: reimportDeleted !== undefined ? { reimportDeleted } : undefined,
|
||||
});
|
||||
|
||||
errors.forEach(error => {
|
||||
|
||||
@@ -6,6 +6,8 @@ import * as Platform from 'loot-core/shared/platform';
|
||||
// oxlint-disable-next-line typescript-paths/absolute-parent-import
|
||||
import packageJson from '../package.json';
|
||||
|
||||
import SharedBrowserServerWorker from './shared-browser-server.ts?sharedworker';
|
||||
|
||||
const backendWorkerUrl = new URL('./browser-server.js', import.meta.url);
|
||||
|
||||
// This file installs global variables that the app expects.
|
||||
@@ -21,9 +23,235 @@ const ACTUAL_VERSION = Platform.isPlaywright
|
||||
: packageJson.version;
|
||||
|
||||
// *** Start the backend ***
|
||||
|
||||
let worker = null;
|
||||
// The regular Worker running the backend, created only on the leader tab
|
||||
let localBackendWorker = null;
|
||||
|
||||
/**
|
||||
* WorkerBridge wraps a SharedWorker port and presents a Worker-like interface
|
||||
* (onmessage, postMessage, addEventListener, start) to the connection layer.
|
||||
*
|
||||
* The SharedWorker coordinator assigns each tab a role per budget:
|
||||
* - LEADER: this tab runs the backend in a dedicated Worker
|
||||
* - FOLLOWER: this tab routes messages through the SharedWorker to the leader
|
||||
*
|
||||
* Multiple budgets can be open simultaneously — each has its own leader.
|
||||
*/
|
||||
class WorkerBridge {
|
||||
constructor(sharedPort) {
|
||||
this._sharedPort = sharedPort;
|
||||
this._onmessage = null;
|
||||
this._listeners = [];
|
||||
this._started = false;
|
||||
|
||||
// Listen for all messages from the SharedWorker port
|
||||
sharedPort.addEventListener('message', e => this._onSharedMessage(e));
|
||||
}
|
||||
|
||||
set onmessage(handler) {
|
||||
this._onmessage = handler;
|
||||
// Setting onmessage on a real MessagePort implicitly starts it.
|
||||
// We need to do this explicitly on the underlying port.
|
||||
if (!this._started) {
|
||||
this._started = true;
|
||||
this._sharedPort.start();
|
||||
}
|
||||
}
|
||||
|
||||
get onmessage() {
|
||||
return this._onmessage;
|
||||
}
|
||||
|
||||
postMessage(msg) {
|
||||
// All messages go through the SharedWorker for coordination.
|
||||
// The SharedWorker forwards to the leader's Worker via __to-worker.
|
||||
this._sharedPort.postMessage(msg);
|
||||
}
|
||||
|
||||
addEventListener(type, handler) {
|
||||
this._listeners.push({ type, handler });
|
||||
}
|
||||
|
||||
start() {
|
||||
if (!this._started) {
|
||||
this._started = true;
|
||||
this._sharedPort.start();
|
||||
}
|
||||
}
|
||||
|
||||
_dispatch(event) {
|
||||
if (this._onmessage) this._onmessage(event);
|
||||
for (const { type, handler } of this._listeners) {
|
||||
if (type === 'message') handler(event);
|
||||
}
|
||||
}
|
||||
|
||||
_onSharedMessage(event) {
|
||||
const msg = event.data;
|
||||
|
||||
// Elected as leader: create the real backend Worker on this tab
|
||||
if (msg && msg.type === '__become-leader') {
|
||||
this._createLocalWorker(msg.initMsg, msg.budgetToRestore, msg.pendingMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward requests from SharedWorker to our local Worker
|
||||
if (msg && msg.type === '__to-worker') {
|
||||
if (localBackendWorker) {
|
||||
localBackendWorker.postMessage(msg.msg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Leadership transfer: this tab is closing the budget but other tabs
|
||||
// still need it. Terminate our Worker (don't actually close-budget on
|
||||
// the backend) and dispatch a synthetic reply so the UI navigates to
|
||||
// show-budgets normally.
|
||||
if (msg && msg.type === '__close-and-transfer') {
|
||||
console.log('[WorkerBridge] Leadership transferred — terminating Worker');
|
||||
if (localBackendWorker) {
|
||||
localBackendWorker.terminate();
|
||||
localBackendWorker = null;
|
||||
}
|
||||
// Only dispatch a synthetic reply if there's an actual close-budget
|
||||
// request to complete. When requestId is null the eviction was
|
||||
// triggered externally (e.g. another tab deleted this budget).
|
||||
if (msg.requestId) {
|
||||
this._dispatch({
|
||||
data: { type: 'reply', id: msg.requestId, data: {} },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Role change notification
|
||||
if (msg && msg.type === '__role-change') {
|
||||
console.log(
|
||||
`[WorkerBridge] Role: ${msg.role}${msg.budgetId ? ` (budget: ${msg.budgetId})` : ''}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Surface SharedWorker console output in this tab's DevTools
|
||||
if (msg && msg.type === '__shared-worker-console') {
|
||||
const method = console[msg.level] || console.log;
|
||||
method(...msg.args);
|
||||
return;
|
||||
}
|
||||
|
||||
// Respond to heartbeat pings
|
||||
if (msg && msg.type === '__heartbeat-ping') {
|
||||
this._sharedPort.postMessage({ type: '__heartbeat-pong' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Everything else goes to the connection layer
|
||||
this._dispatch(event);
|
||||
}
|
||||
|
||||
_createLocalWorker(initMsg, budgetToRestore, pendingMsg) {
|
||||
if (localBackendWorker) {
|
||||
localBackendWorker.terminate();
|
||||
}
|
||||
localBackendWorker = new Worker(backendWorkerUrl);
|
||||
initSQLBackend(localBackendWorker);
|
||||
|
||||
const sharedPort = this._sharedPort;
|
||||
localBackendWorker.onmessage = workerEvent => {
|
||||
const workerMsg = workerEvent.data;
|
||||
// absurd-sql internal messages are handled by initSQLBackend
|
||||
if (
|
||||
workerMsg &&
|
||||
workerMsg.type &&
|
||||
workerMsg.type.startsWith('__absurd:')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// After the backend connects, automatically reload the budget that was
|
||||
// open before the leader left (e.g. page refresh). This lets other tabs
|
||||
// continue working without being sent to the budget list.
|
||||
if (workerMsg.type === 'connect') {
|
||||
if (budgetToRestore) {
|
||||
console.log(
|
||||
`[WorkerBridge] Backend connected, restoring budget "${budgetToRestore}"`,
|
||||
);
|
||||
const id = budgetToRestore;
|
||||
budgetToRestore = null;
|
||||
localBackendWorker.postMessage({
|
||||
id: '__restore-budget',
|
||||
name: 'load-budget',
|
||||
args: { id },
|
||||
catchErrors: true,
|
||||
});
|
||||
// Tell SharedWorker to track the restore request so
|
||||
// currentBudgetId gets updated when the reply arrives.
|
||||
sharedPort.postMessage({
|
||||
type: '__track-restore',
|
||||
requestId: '__restore-budget',
|
||||
budgetId: id,
|
||||
});
|
||||
} else if (pendingMsg) {
|
||||
const toSend = pendingMsg;
|
||||
pendingMsg = null;
|
||||
localBackendWorker.postMessage(toSend);
|
||||
}
|
||||
}
|
||||
sharedPort.postMessage({ type: '__from-worker', msg: workerMsg });
|
||||
};
|
||||
|
||||
localBackendWorker.postMessage(initMsg);
|
||||
}
|
||||
}
|
||||
|
||||
function createBackendWorker() {
|
||||
// Use SharedWorker as a coordinator for multi-tab, multi-budget support.
|
||||
// Each budget gets its own leader tab running a dedicated Worker. All other
|
||||
// tabs on the same budget are followers — their messages are routed through
|
||||
// the SharedWorker to the leader's Worker.
|
||||
// The SharedWorker never touches SharedArrayBuffer, so this works on all
|
||||
// platforms including iOS/Safari.
|
||||
if (typeof SharedWorker !== 'undefined' && !Platform.isPlaywright) {
|
||||
try {
|
||||
const sharedWorker = new SharedBrowserServerWorker({
|
||||
name: 'actual-backend',
|
||||
});
|
||||
|
||||
const sharedPort = sharedWorker.port;
|
||||
worker = new WorkerBridge(sharedPort);
|
||||
console.log('[WorkerBridge] Connected to SharedWorker coordinator');
|
||||
|
||||
// Don't call start() here. The port must remain un-started so that
|
||||
// messages (especially 'connect') are queued until connectWorker()
|
||||
// sets onmessage, which implicitly starts the port via the bridge.
|
||||
|
||||
if (window.SharedArrayBuffer) {
|
||||
localStorage.removeItem('SharedArrayBufferOverride');
|
||||
}
|
||||
|
||||
sharedPort.postMessage({
|
||||
type: 'init',
|
||||
version: ACTUAL_VERSION,
|
||||
isDev: IS_DEV,
|
||||
publicUrl: process.env.PUBLIC_URL,
|
||||
hash: process.env.REACT_APP_BACKEND_WORKER_HASH,
|
||||
isSharedArrayBufferOverrideEnabled: localStorage.getItem(
|
||||
'SharedArrayBufferOverride',
|
||||
),
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
sharedPort.postMessage({ type: 'tab-closing' });
|
||||
});
|
||||
|
||||
return;
|
||||
} catch (e) {
|
||||
console.log('SharedWorker failed, falling back to Worker:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: regular Worker (Playwright, no SharedWorker support, or failure)
|
||||
console.log('[WorkerBridge] No SharedWorker available, using direct Worker');
|
||||
worker = new Worker(backendWorkerUrl);
|
||||
initSQLBackend(worker);
|
||||
|
||||
@@ -37,6 +265,7 @@ function createBackendWorker() {
|
||||
isDev: IS_DEV,
|
||||
publicUrl: process.env.PUBLIC_URL,
|
||||
hash: process.env.REACT_APP_BACKEND_WORKER_HASH,
|
||||
hasSharedArrayBuffer: !!window.SharedArrayBuffer,
|
||||
isSharedArrayBufferOverrideEnabled: localStorage.getItem(
|
||||
'SharedArrayBufferOverride',
|
||||
),
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { ComponentProps, CSSProperties } from 'react';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { SvgCustomNotesPaper } from '@actual-app/components/icons/v2';
|
||||
import { Popover } from '@actual-app/components/popover';
|
||||
import type { CSSProperties } from '@actual-app/components/styles';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { Tooltip } from '@actual-app/components/tooltip';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
@@ -153,22 +153,27 @@ function Notification({
|
||||
const yOffset = index * Y_OFFSET_PER_LEVEL;
|
||||
|
||||
const [isSwiped, setIsSwiped] = useState(false);
|
||||
const [spring, api] = useSpring(() => ({
|
||||
x: 0,
|
||||
y: yOffset,
|
||||
opacity: stackOpacity,
|
||||
scale,
|
||||
}));
|
||||
const [spring, api] = useSpring(
|
||||
() => ({
|
||||
from: {
|
||||
x: 0,
|
||||
y: yOffset,
|
||||
opacity: stackOpacity,
|
||||
scale,
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// Update scale, opacity, and y-position when index changes
|
||||
useEffect(() => {
|
||||
void api.start({ scale, opacity: stackOpacity, y: yOffset });
|
||||
void api.start({ to: { scale, opacity: stackOpacity, y: yOffset } });
|
||||
}, [index, scale, stackOpacity, yOffset, api]);
|
||||
|
||||
const swipeHandlers = useSwipeable({
|
||||
onSwiping: ({ deltaX }) => {
|
||||
if (!isSwiped) {
|
||||
void api.start({ x: deltaX });
|
||||
void api.start({ to: { x: deltaX } });
|
||||
}
|
||||
},
|
||||
onSwiped: ({ velocity, deltaX }) => {
|
||||
@@ -179,14 +184,13 @@ function Notification({
|
||||
if (Math.abs(deltaX) > threshold || velocity > 0.5) {
|
||||
// Animate out & remove item after animation
|
||||
void api.start({
|
||||
x: direction * 1000,
|
||||
opacity: 0,
|
||||
to: { x: direction * 1000, opacity: 0 },
|
||||
onRest: onRemove,
|
||||
});
|
||||
setIsSwiped(true);
|
||||
} else {
|
||||
// Reset position if not swiped far enough
|
||||
void api.start({ x: 0 });
|
||||
void api.start({ to: { x: 0 } });
|
||||
}
|
||||
},
|
||||
trackMouse: true,
|
||||
|
||||
@@ -33,7 +33,6 @@ export function ThemeSelector({ style }: ThemeSelectorProps) {
|
||||
dark: SvgMoonStars,
|
||||
auto: SvgSystem,
|
||||
midnight: SvgMoonStars,
|
||||
development: SvgMoonStars,
|
||||
} as const;
|
||||
|
||||
type ThemeIconKey = keyof typeof themeIcons;
|
||||
|
||||
@@ -24,10 +24,13 @@ export function BudgetSummaries() {
|
||||
const [firstMonth] = months;
|
||||
|
||||
const [widthState, setWidthState] = useState(0);
|
||||
const [styles, spring] = useSpring(() => ({
|
||||
x: 0,
|
||||
config: { mass: 3, tension: 600, friction: 80 },
|
||||
}));
|
||||
const [styles, spring] = useSpring(
|
||||
() => ({
|
||||
from: { x: 0 },
|
||||
config: { mass: 3, tension: 600, friction: 80 },
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const containerRef = useResizeObserver<HTMLDivElement>(
|
||||
useCallback(rect => {
|
||||
@@ -55,7 +58,9 @@ export function BudgetSummaries() {
|
||||
}
|
||||
|
||||
const to = -offsetX;
|
||||
void spring.start({ from: { x: from }, x: to });
|
||||
if (from !== to) {
|
||||
void spring.start({ from: { x: from }, to: { x: to } });
|
||||
}
|
||||
}, [spring, firstMonth, monthWidth, allMonths]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -63,7 +68,7 @@ export function BudgetSummaries() {
|
||||
}, [firstMonth]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
void spring.start({ from: { x: -monthWidth }, to: { x: -monthWidth } });
|
||||
void spring.start({ to: { x: -monthWidth }, immediate: true });
|
||||
}, [spring, monthWidth]);
|
||||
|
||||
const { SummaryComponent } = useBudgetComponents();
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { SvgChartPie } from '@actual-app/components/icons/v1';
|
||||
import type { CSSProperties } from '@actual-app/components/styles';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { css, cx } from '@emotion/css';
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// @ts-strict-ignore
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
import { styles } from '@actual-app/components/styles';
|
||||
import type { CSSProperties } from '@actual-app/components/styles';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { t } from 'i18next';
|
||||
|
||||
|
||||
@@ -17,8 +17,12 @@ type TextLinkProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
type ButtonLinkProps = Omit<ComponentProps<typeof Button>, 'variant'> & {
|
||||
type ButtonLinkProps = Omit<
|
||||
ComponentProps<typeof Button>,
|
||||
'variant' | 'style'
|
||||
> & {
|
||||
buttonVariant?: ComponentProps<typeof Button>['variant'];
|
||||
style?: CSSProperties;
|
||||
to?: string;
|
||||
activeStyle?: CSSProperties;
|
||||
};
|
||||
|
||||
@@ -86,7 +86,7 @@ export const FormField = ({ style, children }: FormFieldProps) => {
|
||||
|
||||
// Custom inputs
|
||||
|
||||
type CheckboxProps = ComponentProps<'input'> & {
|
||||
type CheckboxProps = Omit<ComponentProps<'input'>, 'style'> & {
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
|
||||
@@ -188,6 +188,10 @@ const DATE_FUNCTIONS = new Set([
|
||||
const QUERY_FUNCTIONS = new Set([
|
||||
'QUERY',
|
||||
'QUERY_COUNT',
|
||||
'BUDGET_QUERY',
|
||||
'QUERY_EXTRACT_CATEGORIES',
|
||||
'QUERY_EXTRACT_TIMEFRAME_START',
|
||||
'QUERY_EXTRACT_TIMEFRAME_END',
|
||||
'LOOKUP',
|
||||
'VLOOKUP',
|
||||
'HLOOKUP',
|
||||
@@ -403,6 +407,15 @@ const transactionFields: Completion[] = [
|
||||
'Account balance as of the date of the transaction, excluding the transaction amount. Use for calculations and comparisons.\n\nExample: `=IF(balance < 0, "Negative Balance", "Positive Balance")`',
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'parent_amount',
|
||||
type: 'variable',
|
||||
section: '💰 Transaction Fields',
|
||||
boost: 5,
|
||||
info: t(
|
||||
'The amount of the parent transaction in cents in split transactions.\n\nExample: `=(parent_amount / 100) * .05`',
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// Convert function definitions to completions with grouping
|
||||
@@ -485,6 +498,50 @@ export function excelFormulaAutocomplete(
|
||||
apply: `QUERY_COUNT("${queryName}")`,
|
||||
boost: 14,
|
||||
},
|
||||
{
|
||||
label: `BUDGET_QUERY("budgeted", QUERY_EXTRACT_CATEGORIES("${queryName}"), QUERY_EXTRACT_TIMEFRAME_START("${queryName}"), QUERY_EXTRACT_TIMEFRAME_END("${queryName}"))`,
|
||||
type: 'function',
|
||||
section: '🔍 Query Functions',
|
||||
info: t(
|
||||
'Sum of budgeted amounts with extracted parameters from {{queryName}}.',
|
||||
{ queryName },
|
||||
),
|
||||
apply: `BUDGET_QUERY("budgeted", QUERY_EXTRACT_CATEGORIES("${queryName}"), QUERY_EXTRACT_TIMEFRAME_START("${queryName}"), QUERY_EXTRACT_TIMEFRAME_END("${queryName}"))`,
|
||||
boost: 13,
|
||||
},
|
||||
{
|
||||
label: `BUDGET_QUERY("spent", QUERY_EXTRACT_CATEGORIES("${queryName}"), QUERY_EXTRACT_TIMEFRAME_START("${queryName}"), QUERY_EXTRACT_TIMEFRAME_END("${queryName}"))`,
|
||||
type: 'function',
|
||||
section: '🔍 Query Functions',
|
||||
info: t(
|
||||
'Sum of spending with extracted parameters from {{queryName}}.',
|
||||
{ queryName },
|
||||
),
|
||||
apply: `BUDGET_QUERY("spent", QUERY_EXTRACT_CATEGORIES("${queryName}"), QUERY_EXTRACT_TIMEFRAME_START("${queryName}"), QUERY_EXTRACT_TIMEFRAME_END("${queryName}"))`,
|
||||
boost: 13,
|
||||
},
|
||||
{
|
||||
label: `BUDGET_QUERY("balance_start", QUERY_EXTRACT_CATEGORIES("${queryName}"), QUERY_EXTRACT_TIMEFRAME_START("${queryName}"), QUERY_EXTRACT_TIMEFRAME_END("${queryName}"))`,
|
||||
type: 'function',
|
||||
section: '🔍 Query Functions',
|
||||
info: t(
|
||||
'Opening balance with extracted parameters from {{queryName}}.',
|
||||
{ queryName },
|
||||
),
|
||||
apply: `BUDGET_QUERY("balance_start", QUERY_EXTRACT_CATEGORIES("${queryName}"), QUERY_EXTRACT_TIMEFRAME_START("${queryName}"), QUERY_EXTRACT_TIMEFRAME_END("${queryName}"))`,
|
||||
boost: 13,
|
||||
},
|
||||
{
|
||||
label: `BUDGET_QUERY("balance_end", QUERY_EXTRACT_CATEGORIES("${queryName}"), QUERY_EXTRACT_TIMEFRAME_START("${queryName}"), QUERY_EXTRACT_TIMEFRAME_END("${queryName}"))`,
|
||||
type: 'function',
|
||||
section: '🔍 Query Functions',
|
||||
info: t(
|
||||
'Closing balance with extracted parameters from {{queryName}}.',
|
||||
{ queryName },
|
||||
),
|
||||
apply: `BUDGET_QUERY("balance_end", QUERY_EXTRACT_CATEGORIES("${queryName}"), QUERY_EXTRACT_TIMEFRAME_START("${queryName}"), QUERY_EXTRACT_TIMEFRAME_END("${queryName}"))`,
|
||||
boost: 13,
|
||||
},
|
||||
])
|
||||
: [];
|
||||
|
||||
|
||||
@@ -116,6 +116,54 @@ export const queryModeFunctions: Record<string, FunctionDef> = {
|
||||
{ name: 'decimals', description: 'Decimals' },
|
||||
],
|
||||
},
|
||||
BUDGET_QUERY: {
|
||||
name: 'BUDGET_QUERY',
|
||||
description: t(
|
||||
'Evaluate a budget query using extracted parameters. Supply dimension, categories, and timeframe explicitly.',
|
||||
),
|
||||
parameters: [
|
||||
{
|
||||
name: 'dimension',
|
||||
description:
|
||||
'One of: budgeted, spent, balance_start, balance_end, goal (string)',
|
||||
},
|
||||
{
|
||||
name: 'categories',
|
||||
description:
|
||||
'Categories result from QUERY_EXTRACT_CATEGORIES() (array)',
|
||||
},
|
||||
{
|
||||
name: 'timeframe_start',
|
||||
description:
|
||||
'Start month from QUERY_EXTRACT_TIMEFRAME_START() (string)',
|
||||
},
|
||||
{
|
||||
name: 'timeframe_end',
|
||||
description: 'End month from QUERY_EXTRACT_TIMEFRAME_END() (string)',
|
||||
},
|
||||
],
|
||||
},
|
||||
QUERY_EXTRACT_CATEGORIES: {
|
||||
name: 'QUERY_EXTRACT_CATEGORIES',
|
||||
description: t('Extract category IDs from a named query.'),
|
||||
parameters: [
|
||||
{ name: 'queryName', description: 'Name of the saved query (string)' },
|
||||
],
|
||||
},
|
||||
QUERY_EXTRACT_TIMEFRAME_START: {
|
||||
name: 'QUERY_EXTRACT_TIMEFRAME_START',
|
||||
description: t('Extract the start month from a named query timeframe.'),
|
||||
parameters: [
|
||||
{ name: 'queryName', description: 'Name of the saved query (string)' },
|
||||
],
|
||||
},
|
||||
QUERY_EXTRACT_TIMEFRAME_END: {
|
||||
name: 'QUERY_EXTRACT_TIMEFRAME_END',
|
||||
description: t('Extract the end month from a named query timeframe.'),
|
||||
parameters: [
|
||||
{ name: 'queryName', description: 'Name of the saved query (string)' },
|
||||
],
|
||||
},
|
||||
FLOOR: {
|
||||
name: 'FLOOR',
|
||||
description: t('Rounds down to nearest multiple of significance.'),
|
||||
|
||||
@@ -34,10 +34,13 @@ export function ActionableGridListItem<T extends object>({
|
||||
const hasActions = !!actions;
|
||||
|
||||
// Spring animation for the swipe
|
||||
const [{ x }, api] = useSpring(() => ({
|
||||
x: 0,
|
||||
config: config.stiff,
|
||||
}));
|
||||
const [{ x }, api] = useSpring(
|
||||
() => ({
|
||||
from: { x: 0 },
|
||||
config: config.stiff,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// Handle drag gestures
|
||||
const bind = useDrag(
|
||||
@@ -48,7 +51,7 @@ export function ActionableGridListItem<T extends object>({
|
||||
if (active) {
|
||||
dragStartedRef.current = true;
|
||||
void api.start({
|
||||
x: Math.max(-actionsWidth, Math.min(0, currentX)),
|
||||
to: { x: Math.max(-actionsWidth, Math.min(0, currentX)) },
|
||||
onRest: () => {
|
||||
dragStartedRef.current = false;
|
||||
},
|
||||
@@ -62,7 +65,7 @@ export function ActionableGridListItem<T extends object>({
|
||||
(vx < -0.5 && currentX < -actionsWidth / 5);
|
||||
|
||||
void api.start({
|
||||
x: shouldReveal ? -actionsWidth : 0,
|
||||
to: { x: shouldReveal ? -actionsWidth : 0 },
|
||||
onRest: () => {
|
||||
dragStartedRef.current = false;
|
||||
setIsRevealed(shouldReveal);
|
||||
@@ -141,7 +144,7 @@ export function ActionableGridListItem<T extends object>({
|
||||
? actions({
|
||||
close: () => {
|
||||
void api.start({
|
||||
x: 0,
|
||||
to: { x: 0 },
|
||||
onRest: () => {
|
||||
setIsRevealed(false);
|
||||
},
|
||||
|
||||
@@ -52,7 +52,7 @@ export function MobileNavTabs() {
|
||||
maxWidth: `${100 / COLUMN_COUNT}%`,
|
||||
};
|
||||
|
||||
const [{ y }, api] = useSpring(() => ({ y: OPEN_DEFAULT_Y }));
|
||||
const [{ y }, api] = useSpring(() => ({ from: { y: OPEN_DEFAULT_Y } }), []);
|
||||
|
||||
const openFull = useCallback(
|
||||
({ canceled }: { canceled?: boolean }) => {
|
||||
@@ -60,7 +60,7 @@ export function MobileNavTabs() {
|
||||
// so we change the spring config to create a nice wobbly effect
|
||||
setNavbarState('open');
|
||||
void api.start({
|
||||
y: OPEN_FULL_Y,
|
||||
to: { y: OPEN_FULL_Y },
|
||||
immediate: isTestEnv,
|
||||
config: canceled ? config.wobbly : config.stiff,
|
||||
});
|
||||
@@ -72,7 +72,7 @@ export function MobileNavTabs() {
|
||||
(velocity = 0) => {
|
||||
setNavbarState('default');
|
||||
void api.start({
|
||||
y: OPEN_DEFAULT_Y,
|
||||
to: { y: OPEN_DEFAULT_Y },
|
||||
immediate: isTestEnv,
|
||||
config: { ...config.stiff, velocity },
|
||||
});
|
||||
@@ -84,7 +84,7 @@ export function MobileNavTabs() {
|
||||
(velocity = 0) => {
|
||||
setNavbarState('hidden');
|
||||
void api.start({
|
||||
y: HIDDEN_Y,
|
||||
to: { y: HIDDEN_Y },
|
||||
immediate: isTestEnv,
|
||||
config: { ...config.stiff, velocity },
|
||||
});
|
||||
@@ -199,7 +199,7 @@ export function MobileNavTabs() {
|
||||
} else {
|
||||
// when the user keeps dragging, we just move the sheet according to
|
||||
// the cursor position
|
||||
void api.start({ y: oy, immediate: true });
|
||||
void api.start({ to: { y: oy }, immediate: true });
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
} from 'react';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import type { CSSProperties as EmotionCSSProperties } from '@actual-app/components/styles';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
@@ -167,7 +168,9 @@ type FocusableAmountInputProps = Omit<AmountInputProps, 'onFocus'> & {
|
||||
focused?: boolean;
|
||||
disabled?: boolean;
|
||||
focusedStyle?: CSSProperties;
|
||||
buttonProps?: ComponentPropsWithRef<typeof Button>;
|
||||
buttonProps?: Omit<ComponentPropsWithRef<typeof Button>, 'style'> & {
|
||||
style?: EmotionCSSProperties;
|
||||
};
|
||||
onFocus?: () => void;
|
||||
};
|
||||
|
||||
|
||||
@@ -211,6 +211,7 @@ export function ImportTransactionsModal({
|
||||
const [flipAmount, setFlipAmount] = useState(false);
|
||||
const [multiplierEnabled, setMultiplierEnabled] = useState(false);
|
||||
const [reconcile, setReconcile] = useState(true);
|
||||
const [reimportDeleted, setReimportDeleted] = useState(false);
|
||||
const [importNotes, setImportNotes] = useState(true);
|
||||
|
||||
// This cannot be set after parsing the file, because changing it
|
||||
@@ -718,6 +719,7 @@ export function ImportTransactionsModal({
|
||||
accountId,
|
||||
transactions: finalTransactions,
|
||||
reconcile,
|
||||
reimportDeleted,
|
||||
},
|
||||
{
|
||||
onSuccess: async didChange => {
|
||||
@@ -766,6 +768,7 @@ export function ImportTransactionsModal({
|
||||
{
|
||||
accountId,
|
||||
transactions: previewTransactionsToImport,
|
||||
reimportDeleted,
|
||||
},
|
||||
{
|
||||
onSuccess: previewTrx => {
|
||||
@@ -832,6 +835,7 @@ export function ImportTransactionsModal({
|
||||
startDate,
|
||||
fieldMappings,
|
||||
parseDateFormat,
|
||||
reimportDeleted,
|
||||
]);
|
||||
|
||||
const headers: ComponentProps<typeof TableHeader>['headers'] = [
|
||||
@@ -1071,6 +1075,16 @@ export function ImportTransactionsModal({
|
||||
</CheckboxToggle>
|
||||
)}
|
||||
|
||||
{(isOfxFile(filetype) || isCamtFile(filetype)) && reconcile && (
|
||||
<CheckboxToggle
|
||||
id="form_reimport_deleted"
|
||||
checked={reimportDeleted}
|
||||
onChange={setReimportDeleted}
|
||||
>
|
||||
<Trans>Reimport deleted transactions</Trans>
|
||||
</CheckboxToggle>
|
||||
)}
|
||||
|
||||
{/*Import Options */}
|
||||
{(filetype === 'qif' || filetype === 'csv') && (
|
||||
<View style={{ marginTop: 10 }}>
|
||||
@@ -1187,6 +1201,15 @@ export function ImportTransactionsModal({
|
||||
>
|
||||
<Trans>Merge with existing transactions</Trans>
|
||||
</CheckboxToggle>
|
||||
{reconcile && (
|
||||
<CheckboxToggle
|
||||
id="form_reimport_deleted_csv"
|
||||
checked={reimportDeleted}
|
||||
onChange={setReimportDeleted}
|
||||
>
|
||||
<Trans>Reimport deleted transactions</Trans>
|
||||
</CheckboxToggle>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import type { CSSProperties } from '@actual-app/components/styles';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ const balanceTypeOptions = [
|
||||
const groupByOptions = [
|
||||
{ description: t('Category'), key: 'Category' },
|
||||
{ description: t('Group'), key: 'Group' },
|
||||
{ description: t('Category+Group'), key: 'CategoryGroup' }, // new: two-ring donut support
|
||||
{ description: t('Payee'), key: 'Payee' },
|
||||
{ description: t('Account'), key: 'Account' },
|
||||
{ description: t('Interval'), key: 'Interval' },
|
||||
@@ -220,12 +221,10 @@ const intervalOptions: intervalOptionsProps[] = [
|
||||
format: 'yy-MM-dd',
|
||||
range: 'weekRangeInclusive',
|
||||
},
|
||||
//{ value: 3, description: 'Fortnightly', name: 3},
|
||||
{
|
||||
description: t('Monthly'),
|
||||
key: 'Monthly',
|
||||
name: 'Month',
|
||||
|
||||
format: "MMM ''yy",
|
||||
range: 'rangeInclusive',
|
||||
},
|
||||
@@ -328,17 +327,11 @@ export const categoryLists = (categories: {
|
||||
const categoriesToSort = [...categories.list];
|
||||
const categoryList: UncategorizedEntity[] = [
|
||||
...categoriesToSort.sort((a, b) => {
|
||||
//The point of this sorting is to make the graphs match the "budget" page
|
||||
const catGroupA = categories.grouped.find(f => f.id === a.group);
|
||||
const catGroupB = categories.grouped.find(f => f.id === b.group);
|
||||
//initial check that both a and b have a sort_order and category group
|
||||
return a.sort_order && b.sort_order && catGroupA && catGroupB
|
||||
? /*sorting by "is_income" because sort_order for this group is
|
||||
separate from other groups*/
|
||||
Number(catGroupA.is_income) - Number(catGroupB.is_income) ||
|
||||
//Next, sorting by group sort_order
|
||||
? Number(catGroupA.is_income) - Number(catGroupB.is_income) ||
|
||||
(catGroupA.sort_order ?? 0) - (catGroupB.sort_order ?? 0) ||
|
||||
//Finally, sorting by category within each group
|
||||
a.sort_order - b.sort_order
|
||||
: 0;
|
||||
}),
|
||||
@@ -382,6 +375,20 @@ export const groupBySelections = (
|
||||
});
|
||||
groupByLabel = 'categoryGroup';
|
||||
break;
|
||||
// CategoryGroup uses category-level data from createCustomSpreadsheet.
|
||||
// The group-level data comes from groupedData (createGroupedSpreadsheet).
|
||||
// This case just prevents the default throw so the spreadsheet doesn't error.
|
||||
case 'CategoryGroup':
|
||||
groupByList = categoryGroup.map(group => {
|
||||
return {
|
||||
...group,
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
hidden: group.hidden,
|
||||
};
|
||||
});
|
||||
groupByLabel = 'categoryGroup';
|
||||
break;
|
||||
case 'Payee':
|
||||
groupByList = payees.map(payee => {
|
||||
return { id: payee.id, name: payee.name, hidden: false };
|
||||
|
||||
@@ -63,10 +63,12 @@ type graphOptions = {
|
||||
disableLabel?: boolean;
|
||||
disableSort?: boolean;
|
||||
};
|
||||
|
||||
const totalGraphOptions: graphOptions[] = [
|
||||
{
|
||||
description: 'TableGraph',
|
||||
disabledSplit: [],
|
||||
// CategoryGroup is only valid for DonutGraph
|
||||
disabledSplit: ['CategoryGroup'],
|
||||
defaultSplit: 'Category',
|
||||
disabledType: [],
|
||||
defaultType: 'Payment',
|
||||
@@ -76,7 +78,8 @@ const totalGraphOptions: graphOptions[] = [
|
||||
},
|
||||
{
|
||||
description: 'BarGraph',
|
||||
disabledSplit: [],
|
||||
// CategoryGroup is only valid for DonutGraph
|
||||
disabledSplit: ['CategoryGroup'],
|
||||
defaultSplit: 'Category',
|
||||
disabledType: [],
|
||||
defaultType: 'Payment',
|
||||
@@ -84,7 +87,8 @@ const totalGraphOptions: graphOptions[] = [
|
||||
},
|
||||
{
|
||||
description: 'AreaGraph',
|
||||
disabledSplit: ['Category', 'Group', 'Payee', 'Account'],
|
||||
// CategoryGroup is only valid for DonutGraph
|
||||
disabledSplit: ['Category', 'Group', 'CategoryGroup', 'Payee', 'Account'],
|
||||
defaultSplit: 'Interval',
|
||||
disabledType: [],
|
||||
defaultType: 'Payment',
|
||||
@@ -94,6 +98,7 @@ const totalGraphOptions: graphOptions[] = [
|
||||
},
|
||||
{
|
||||
description: 'DonutGraph',
|
||||
// CategoryGroup is allowed here — it enables the two-ring concentric donut
|
||||
disabledSplit: [],
|
||||
defaultSplit: 'Category',
|
||||
disabledType: ['Net'],
|
||||
@@ -105,7 +110,8 @@ const totalGraphOptions: graphOptions[] = [
|
||||
const timeGraphOptions: graphOptions[] = [
|
||||
{
|
||||
description: 'TableGraph',
|
||||
disabledSplit: ['Interval'],
|
||||
// CategoryGroup disabled in time mode (DonutGraph not available in time mode)
|
||||
disabledSplit: ['Interval', 'CategoryGroup'],
|
||||
defaultSplit: 'Category',
|
||||
disabledType: ['Net Payment', 'Net Deposit'],
|
||||
defaultType: 'Payment',
|
||||
@@ -116,7 +122,8 @@ const timeGraphOptions: graphOptions[] = [
|
||||
},
|
||||
{
|
||||
description: 'StackedBarGraph',
|
||||
disabledSplit: ['Interval'],
|
||||
// CategoryGroup disabled in time mode
|
||||
disabledSplit: ['Interval', 'CategoryGroup'],
|
||||
defaultSplit: 'Category',
|
||||
disabledType: [],
|
||||
defaultType: 'Payment',
|
||||
@@ -125,7 +132,8 @@ const timeGraphOptions: graphOptions[] = [
|
||||
},
|
||||
{
|
||||
description: 'LineGraph',
|
||||
disabledSplit: ['Interval'],
|
||||
// CategoryGroup disabled in time mode
|
||||
disabledSplit: ['Interval', 'CategoryGroup'],
|
||||
defaultSplit: 'Category',
|
||||
disabledType: [],
|
||||
defaultType: 'Payment',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user